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.

356 lines • 16.1 kB
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