@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.
356 lines • 16.1 kB
JavaScript
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { getConfig } from "../config.js";
import { codexHomeDir, buildResumeEnv } from "./agent-config.js";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
/** Translate COGENT_CODEX_SANDBOX into codex exec CLI flags. */
function sandboxArgs(mode) {
switch (mode) {
case "bypass":
return ["--dangerously-bypass-approvals-and-sandbox"];
case "read-only":
case "workspace-write":
case "danger-full-access":
// `--sandbox <mode>` is a parent `codex exec` flag and is REJECTED by the
// `exec resume` subcommand ("unexpected argument '--sandbox'"). The
// non-deprecated way to set the sandbox on resume is a `-c` config override.
return ["-c", `sandbox_mode=${mode}`];
case "full-auto":
default:
// `--full-auto` works on `exec resume` but is DEPRECATED (warns on every
// invocation: "use --sandbox workspace-write instead"). That suggested flag
// doesn't exist on `exec resume` either — the correct equivalent is the
// workspace-write sandbox via `-c sandbox_mode=`.
return ["-c", "sandbox_mode=workspace-write"];
}
}
/**
* Process-wide effective-sandbox override. Startup (codex-preflight) sets this to
* `"bypass"` when the host's OS sandbox can't initialize AND
* `COGENT_CODEX_SANDBOX_FALLBACK` is on, so every subsequent `execCodex` runs
* without the (broken) sandbox. Null = no override (use the configured mode).
* `getConfig()` is frozen, so this explicit setter is how we apply the fallback
* without mutating config.
*/
let sandboxOverride = null;
export function setSandboxOverride(mode) {
sandboxOverride = mode;
}
/** Reset the override (test isolation). */
export function resetSandboxOverride() {
sandboxOverride = null;
}
/** The sandbox mode execCodex will actually use (override wins over config). */
export function effectiveSandbox(config) {
return sandboxOverride ?? config.COGENT_CODEX_SANDBOX;
}
/**
* Signature of an OS-sandbox initialization failure in Codex stderr: the sandbox
* (bubblewrap) could not create an unprivileged user/network namespace on this
* host. Covers the uid-map failure (`bwrap: setting up uid map: Permission
* denied`), the netns loopback variant (`RTM_NEWADDR: Operation not permitted`,
* memory `codex-bwrap-netns-sandbox-2026-05-31`), and bare `unshare`/userns EPERM.
* Used to tag `CliExecResult.sandboxBlocked` so the caller surfaces an actionable
* remedy instead of a generic exec failure.
*/
export const SANDBOX_FAILURE_RE = /bwrap:|setting up uid ?map|\/proc\/self\/(uid|gid)_map|newuidmap|user namespaces?|RTM_NEWADDR|unshare[^\n]*(Operation not permitted|Permission denied)|clone3?[^\n]*(EPERM|not permitted)/i;
/**
* Signature of the codex >=0.145 SINGLE-WRITER thread-store conflict: the target
* thread is held by a live interactive TUI, so `codex exec resume` cannot open
* its writer and fast-fails. Used to tag `CliExecResult.codexLiveSession` so the
* caller surfaces an honest "queued — will answer next turn" message (and points
* at the app-server rail) instead of a generic capture failure.
*/
export const ACTIVE_WRITER_RE = /already has an active writer|thread-store conflict|-32600/i;
/**
* Resume a Codex session non-interactively and return its final agent message.
* Parallel to execClaude() in cc-cli.ts. Uses `-o <file>` to capture the last
* message cleanly (no stdout parsing); falls back to stdout if the file is
* unreadable. Runs sandboxed via --full-auto by default so it never blocks on
* an approval prompt the relay cannot answer.
*/
export function execCodex(sessionId, message, cwd, timeoutMs) {
return new Promise((resolve) => {
const config = getConfig();
const effectiveTimeout = timeoutMs ?? config.COGENT_TIMEOUT_MS;
const truncated = config.COGENT_CHAR_LIMIT > 0 && message.length > config.COGENT_CHAR_LIMIT
? message.slice(0, config.COGENT_CHAR_LIMIT) + "\n...[truncated]"
: message;
const lastMsgFile = path.join(os.tmpdir(), `cogent-codex-${randomUUID()}.txt`);
// NOTE: `--color` is a parent `codex exec` flag and is REJECTED by the
// `exec resume` subcommand (exit 2: "unexpected argument '--color'").
// Do NOT add it here — it breaks every auto-relay invocation. The agent's
// final message is captured via `-o <file>`, so terminal color is moot.
const args = [
"exec", "resume", sessionId,
...sandboxArgs(effectiveSandbox(config)),
"--skip-git-repo-check",
"-o", lastMsgFile,
truncated,
];
// Strip CODEX* env so the resumed process doesn't think it's nested inside a
// parent Codex session — but PRESERVE CODEX_HOME so a relocated Codex home is
// honored when the child resumes the session (mirrors the execClaude strip).
const cleanEnv = buildResumeEnv(process.env, "CODEX", ["CODEX_HOME"]);
// Disable check-on-stop (feature C) inside this wake subprocess — this resume
// IS the answer to the incoming message (mirrors the execClaude opt-out).
cleanEnv.COGENT_CHECK_ON_STOP = "0";
const child = spawn(config.COGENT_CODEX_PATH, args, {
cwd,
env: cleanEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let resolved = false;
const safeResolve = (result) => {
if (!resolved) {
resolved = true;
resolve(result);
}
};
const cleanupFile = () => fs.rm(lastMsgFile, { force: true }).catch(() => { });
const timer = setTimeout(async () => {
killed = true;
child.kill("SIGTERM");
await cleanupFile();
safeResolve({
stdout: "",
stderr: `CLI_TIMEOUT: CLI subprocess timed out after ${effectiveTimeout}ms`,
exitCode: null,
});
}, effectiveTimeout);
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
child.on("error", async (err) => {
clearTimeout(timer);
await cleanupFile();
if (err.code === "ENOENT") {
safeResolve({
stdout: "",
stderr: `CLI_NOT_FOUND: '${config.COGENT_CODEX_PATH}' not found. Install Codex CLI or set COGENT_CODEX_PATH.`,
exitCode: 127,
});
return;
}
safeResolve({ stdout: "", stderr: `CLI_EXEC_FAILED: spawn error: ${err.message}`, exitCode: 1 });
});
child.on("close", async (code) => {
clearTimeout(timer);
if (killed) {
// Already resolved by the timeout handler; nothing to do.
return;
}
if (code !== 0) {
await cleanupFile();
// Detect an OS-sandbox init failure (unprivileged userns blocked) so the
// caller can surface an actionable remedy instead of a generic failure.
const sandboxBlocked = SANDBOX_FAILURE_RE.test(stderr);
// codex >=0.145 single-writer conflict: the agent is live in a TUI, so
// this isn't a real failure — the message is effectively queued.
const codexLiveSession = ACTIVE_WRITER_RE.test(stderr);
safeResolve({
stdout: "",
stderr: `CLI_EXEC_FAILED: codex exited with code ${code}. stderr: ${stderr}`,
exitCode: code,
...(sandboxBlocked ? { sandboxBlocked: true } : {}),
...(codexLiveSession ? { codexLiveSession: true } : {}),
});
return;
}
// Slice 3 (Story 8.4): the `-o` last-message file is a structured capture
// (Codex writes its final agent message there) — the event_stream tier
// (stable). If the file is absent/unreadable OR EMPTY (turn ended on tool
// calls with no final agent text), we actually serve raw stdout, so the
// contract must key on the SERVED content, not merely on readFile
// succeeding — otherwise an empty file would disguise a stdout fallback as
// a trusted structured capture (AC3).
let fileMessage = "";
try {
fileMessage = await fs.readFile(lastMsgFile, "utf8");
}
catch {
fileMessage = ""; // absent/unreadable → fall through to stdout
}
await cleanupFile();
const usedFile = fileMessage.trim().length > 0;
safeResolve({
stdout: (usedFile ? fileMessage : stdout).trim(),
stderr: stderr || "",
exitCode: 0,
captureContract: usedFile ? "event_stream" : "stdout_compat",
stable: usedFile,
});
});
});
}
/** Recursively collect all *.jsonl rollout files under a sessions root. */
async function listRolloutFiles(root) {
const out = [];
async function walk(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
}
catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory())
await walk(full);
else if (e.isFile() && e.name.endsWith(".jsonl"))
out.push(full);
}
}
await walk(root);
return out;
}
/** Read the session_meta {id, cwd} from the first line of a rollout file. */
async function readSessionMeta(file) {
// The session_meta first line embeds Codex's full system prompt in
// `base_instructions.text` — ~15 KB in codex 0.133.0 and liable to grow. A
// fixed-size header read truncates the JSON mid-string (the 3.4.2 detection
// regression), so we stream exactly the first line regardless of its length
// and stop as soon as we have it.
let stream = null;
try {
stream = createReadStream(file, { encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
let firstLine = "";
for await (const line of rl) {
firstLine = line;
break; // only the first line carries session_meta
}
rl.close();
const obj = JSON.parse(firstLine);
// codex >=0.145 splits one session into multiple rollout SEGMENTS: `payload.id`
// is the per-segment id (changes every segment), while `payload.session_id` is
// the STABLE session id that `codex exec resume <SESSION_ID>` actually expects
// (its --help: "Conversation/session id (UUID)"). Prefer session_id; fall back to
// id for older codex (<=0.144, where session_id is absent and id IS the session).
// Using the segment id was the wake-break: the pin went stale on every segment
// roll and resume targeted a non-session id.
const sid = obj.payload?.session_id ?? obj.payload?.id;
if (obj?.type === "session_meta" && sid && obj.payload?.cwd) {
return { id: sid, cwd: obj.payload.cwd };
}
return null;
}
catch {
return null;
}
finally {
stream?.destroy();
}
}
export function codexSessionsRoot() {
return path.join(codexHomeDir(), "sessions");
}
/**
* Canonical forms of a cwd for tolerant matching against a rollout's recorded
* session_meta.cwd. Codex records its own cwd string; the bridge registers with
* process.cwd(). These can differ by representation for the SAME directory: a
* trailing slash, `.`/`..` segments, or a symlink — notably macOS `/var` ->
* `/private/var` and `/tmp` -> `/private/tmp`. We resolve AND realpath the live cwd
* (realpath needs the dir to exist; falls back to resolve) and accept a meta.cwd if
* its resolved form equals EITHER — so a symlink-representation mismatch no longer
* yields "no candidate sessions found for cwd".
*/
async function cwdCanonicals(cwd) {
const resolved = path.resolve(cwd);
const forms = new Set([resolved]);
try {
forms.add(await fs.realpath(resolved));
}
catch {
// dir no longer exists → resolved form only
}
return forms;
}
function metaCwdMatches(metaCwd, forms) {
return forms.has(path.resolve(metaCwd));
}
/**
* Find the most recently active Codex session for a given cwd by scanning
* ~/.codex/sessions/**.jsonl, matching session_meta.payload.cwd, newest mtime
* wins. Parallel to detectCurrentSession() in cc-cli.ts. Null if none match.
*/
export async function detectCurrentCodexSession(cwd) {
try {
const forms = await cwdCanonicals(cwd);
const files = await listRolloutFiles(codexSessionsRoot());
const withStats = await Promise.all(files.map(async (f) => {
const stat = await fs.stat(f);
return { file: f, mtimeMs: stat.mtimeMs };
}));
withStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
for (const { file } of withStats) {
const meta = await readSessionMeta(file);
if (meta && metaCwdMatches(meta.cwd, forms))
return meta.id;
}
return null;
}
catch {
return null;
}
}
/**
* List all Codex session candidates for a cwd as {sessionId, mtimeMs}, filtered
* to rollouts whose session_meta.cwd === cwd (stronger than the CC path — it
* verifies from file content, not a lossy escaped-path map). Returns [] on
* error. Parallel to listCandidates() in cc-cli.ts.
*/
export async function listCodexCandidates(cwd) {
try {
const forms = await cwdCanonicals(cwd);
const files = await listRolloutFiles(codexSessionsRoot());
const withStats = await Promise.all(files.map(async (f) => ({ file: f, mtimeMs: (await fs.stat(f)).mtimeMs })));
// Dedupe by session id: codex >=0.145 writes MANY segment rollouts per session
// (all sharing session_id). Without dedupe they look like N distinct candidates
// within the ambiguity margin, so the resolver REFUSES ("ambiguous"). Collapse to
// one candidate per session id, keeping its newest segment mtime.
const bySession = new Map();
for (const { file, mtimeMs } of withStats) {
const meta = await readSessionMeta(file);
if (meta && metaCwdMatches(meta.cwd, forms)) {
const prev = bySession.get(meta.id);
if (prev === undefined || mtimeMs > prev)
bySession.set(meta.id, mtimeMs);
}
}
return [...bySession].map(([sessionId, mtimeMs]) => ({ sessionId, mtimeMs }));
}
catch {
return [];
}
}
/**
* True if a Codex rollout with this session id exists on disk.
*
* Unlike cc-cli.ts's validateSession(sessionId, cwd), this takes no cwd: Codex
* partitions rollouts by date, not by cwd, so the id must be located by scan.
* Reads file headers until the first match (early-exit), acceptable for the
* occasional auto-relay session-refresh path.
*/
export async function validateCodexSession(sessionId) {
try {
const files = await listRolloutFiles(codexSessionsRoot());
for (const file of files) {
const meta = await readSessionMeta(file);
if (meta && meta.id === sessionId)
return true;
}
return false;
}
catch {
return false;
}
}
//# sourceMappingURL=codex-cli.js.map