[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,350 @@
"use strict";
const path = require("node:path");
const { spawn } = require("node:child_process");
const { createHash } = require("node:crypto");
const { StringDecoder } = require("node:string_decoder");
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const INITIALIZE_TIMEOUT_MS = 10_000;
const MAX_STDERR_CHARS = 32_000;
const MAX_JSONL_LINE_BYTES = 16 * 1024 * 1024;
const CLOSE_KILL_GRACE_MS = 750;
function createBoundedLineReader(stream, onLine, onError, maxLineBytes) {
const decoder = new StringDecoder("utf8");
let buffer = "";
let bufferedBytes = 0;
let closed = false;
const fail = () => {
buffer = "";
bufferedBytes = 0;
onError(new Error(`Codex App Server message exceeded ${maxLineBytes} bytes`));
};
const onData = (chunk) => {
if (closed) return;
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
bufferedBytes += bytes.length;
buffer += decoder.write(bytes);
let index;
let consumedLine = false;
while ((index = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, index).trim();
buffer = buffer.slice(index + 1);
consumedLine = true;
if (line) onLine(line);
if (closed) return;
}
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
if (bufferedBytes > maxLineBytes) fail();
};
const onEnd = () => {
if (closed) return;
buffer += decoder.end();
const line = buffer.trim();
buffer = "";
bufferedBytes = 0;
if (line) onLine(line);
};
stream?.on?.("data", onData);
stream?.once?.("end", onEnd);
return {
close() {
if (closed) return;
closed = true;
buffer = "";
bufferedBytes = 0;
stream?.removeListener?.("data", onData);
stream?.removeListener?.("end", onEnd);
},
};
}
function buildCodexAppServerLaunch(binPath, args = ["app-server", "--stdio"], {
nodePath = process.execPath,
} = {}) {
const executable = String(binPath || "").trim();
if (!executable) {
throw new Error("Codex binary not found. Configure Codex in Settings -> AI.");
}
const extension = path.extname(executable).toLowerCase();
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
return {
command: nodePath,
args: [executable, ...args],
env: { ELECTRON_RUN_AS_NODE: "1" },
};
}
if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
throw new Error(
`Codex App Server cannot launch the shell shim ${executable}. ` +
"Configure the native Codex executable or reinstall the Codex CLI.",
);
}
return { command: executable, args };
}
function buildCodexAppServerKey(binPath, env) {
const fingerprint = createHash("sha256")
.update(JSON.stringify(
Object.entries(env || {})
.map(([key, value]) => [key, String(value)])
.sort(([left], [right]) => left.localeCompare(right)),
))
.digest("hex");
return `${String(binPath || "")}\u0000${fingerprint}`;
}
class CodexAppServerConnection {
constructor({
binPath,
env,
appVersion = "0.0.0",
spawnImpl = spawn,
onNotification,
onServerRequest,
onFatal,
closeKillGraceMs = CLOSE_KILL_GRACE_MS,
maxJsonlLineBytes = MAX_JSONL_LINE_BYTES,
}) {
this.binPath = binPath;
this.env = env || {};
this.appVersion = appVersion;
this.spawnImpl = spawnImpl;
this.onNotification = onNotification;
this.onServerRequest = onServerRequest;
this.onFatal = onFatal;
this.closeKillGraceMs = closeKillGraceMs;
this.maxJsonlLineBytes = maxJsonlLineBytes;
this.process = null;
this.closingProcesses = new Map();
this.readline = null;
this.nextRequestId = 1;
this.pending = new Map();
this.startPromise = null;
this.initialized = false;
this.closing = false;
this.stderr = "";
}
async start() {
if (this.initialized && this.process && !this.process.killed) return this;
if (this.startPromise) return this.startPromise;
this.startPromise = this.#startInternal().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
async #startInternal() {
this.closing = false;
this.stderr = "";
const launch = buildCodexAppServerLaunch(this.binPath);
const child = this.spawnImpl(launch.command, launch.args, {
cwd: process.cwd(),
env: { ...this.env, ...(launch.env || {}) },
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
shell: false,
});
this.process = child;
child.stderr?.setEncoding?.("utf8");
child.stderr?.on?.("data", (chunk) => {
this.stderr = `${this.stderr}${String(chunk || "")}`.slice(-MAX_STDERR_CHARS);
});
this.readline = createBoundedLineReader(
child.stdout,
(line) => this.#handleLine(line),
(error) => this.#handleFatal(error),
this.maxJsonlLineBytes,
);
child.once("error", (error) => {
if (this.closingProcesses.has(child) || this.process !== child) return;
this.#handleFatal(error);
});
child.once("exit", (code, signal) => {
const wasClosing = this.closingProcesses.has(child);
if (wasClosing) this.#releaseClosingProcess(child);
if (wasClosing || this.process !== child) return;
const detail = this.stderr.trim();
const suffix = detail ? `\n${detail}` : "";
this.#handleFatal(new Error(
`Codex App Server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "none"}).${suffix}`,
));
});
try {
await this.request("initialize", {
clientInfo: {
name: "netcatty",
title: "Netcatty",
version: this.appVersion,
},
capabilities: {
experimentalApi: true,
requestAttestation: false,
mcpServerOpenaiFormElicitation: false,
},
}, INITIALIZE_TIMEOUT_MS, { skipStart: true });
this.notify("initialized", {});
this.initialized = true;
return this;
} catch (error) {
this.close();
const detail = this.stderr.trim();
if (detail && !String(error?.message || error).includes(detail)) {
throw new Error(`${error?.message || error}\n${detail}`);
}
throw error;
}
}
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
if (!options.skipStart) await this.start();
const id = this.nextRequestId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Codex App Server request timed out: ${method}`));
}, timeoutMs);
this.pending.set(id, { method, resolve, reject, timer });
try {
this.#write({ id, method, params });
} catch (error) {
clearTimeout(timer);
this.pending.delete(id);
reject(error);
}
});
}
notify(method, params = {}) {
this.#write({ method, params });
}
respond(id, result) {
this.#write({ id, result });
}
respondError(id, code, message, data) {
const error = { code, message };
if (data !== undefined) error.data = data;
this.#write({ id, error });
}
#write(message) {
const stdin = this.process?.stdin;
if (!stdin || stdin.destroyed || !stdin.writable) {
throw new Error("Codex App Server stdin is unavailable");
}
stdin.write(`${JSON.stringify(message)}\n`);
}
#handleLine(rawLine) {
const line = String(rawLine || "").trim();
if (!line) return;
let message;
try {
message = JSON.parse(line);
} catch {
this.#handleFatal(new Error(`Codex App Server emitted invalid JSON: ${line.slice(0, 500)}`));
return;
}
if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) {
const entry = this.pending.get(message.id);
if (!entry) return;
this.pending.delete(message.id);
clearTimeout(entry.timer);
if (message.error) {
const error = new Error(message.error.message || `Codex App Server ${entry.method} failed`);
error.code = message.error.code;
error.data = message.error.data;
entry.reject(error);
} else {
entry.resolve(message.result);
}
return;
}
if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) {
Promise.resolve(this.onServerRequest?.(message, this)).catch((error) => {
try {
this.respondError(message.id, -32603, error?.message || String(error));
} catch {}
});
return;
}
if (message.method) {
try {
this.onNotification?.(message, this);
} catch (error) {
this.#handleFatal(error);
}
}
}
#handleFatal(error) {
if (this.closing) return;
this.initialized = false;
const fatal = error instanceof Error ? error : new Error(String(error));
for (const [, entry] of this.pending) {
clearTimeout(entry.timer);
entry.reject(fatal);
}
this.pending.clear();
try { this.onFatal?.(fatal, this); } catch {}
this.close();
}
#releaseClosingProcess(child) {
if (!this.closingProcesses.has(child)) return;
clearTimeout(this.closingProcesses.get(child));
this.closingProcesses.delete(child);
}
getClosingProcessCountForTests() {
return this.closingProcesses.size;
}
close() {
this.closing = true;
this.initialized = false;
try { this.readline?.close?.(); } catch {}
this.readline = null;
for (const [, entry] of this.pending) {
clearTimeout(entry.timer);
entry.reject(new Error("Codex App Server connection closed"));
}
this.pending.clear();
const child = this.process;
this.process = null;
if (!child) return;
try { child.stdin?.end?.(); } catch {}
this.closingProcesses.set(child, null);
const killTimer = setTimeout(() => {
if (!this.closingProcesses.has(child)) return;
try { child.kill?.("SIGKILL"); } catch {}
this.#releaseClosingProcess(child);
}, this.closeKillGraceMs);
killTimer.unref?.();
this.closingProcesses.set(child, killTimer);
try { child.kill?.("SIGTERM"); } catch {}
}
}
module.exports = {
CodexAppServerConnection,
buildCodexAppServerKey,
buildCodexAppServerLaunch,
DEFAULT_REQUEST_TIMEOUT_MS,
INITIALIZE_TIMEOUT_MS,
CLOSE_KILL_GRACE_MS,
MAX_JSONL_LINE_BYTES,
};

View File

@@ -0,0 +1,200 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, once } = require("node:events");
const { PassThrough } = require("node:stream");
const {
CodexAppServerConnection,
buildCodexAppServerKey,
buildCodexAppServerLaunch,
} = require("./connection.cjs");
function createFakeChild() {
const child = new EventEmitter();
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killed = false;
child.kill = () => { child.killed = true; };
return child;
}
async function readJsonLine(stream) {
const [chunk] = await once(stream, "data");
return JSON.parse(String(chunk).trim());
}
test("buildCodexAppServerLaunch runs JS entries through Node without a shell", () => {
assert.deepEqual(
buildCodexAppServerLaunch("/opt/codex/bin/codex.js", ["app-server", "--help"], { nodePath: "/usr/bin/node" }),
{
command: "/usr/bin/node",
args: ["/opt/codex/bin/codex.js", "app-server", "--help"],
env: { ELECTRON_RUN_AS_NODE: "1" },
},
);
assert.deepEqual(
buildCodexAppServerLaunch("/usr/local/bin/codex"),
{ command: "/usr/local/bin/codex", args: ["app-server", "--stdio"] },
);
assert.throws(() => buildCodexAppServerLaunch("C:\\npm\\codex.cmd"), /shell shim/);
});
test("App Server connection initializes once and correlates JSONL requests", async () => {
const child = createFakeChild();
const notifications = [];
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: { HOME: "/tmp/home" },
appVersion: "1.2.3",
spawnImpl: () => child,
onNotification: (message) => notifications.push(message),
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
assert.equal(initialize.method, "initialize");
assert.equal(initialize.params.clientInfo.name, "netcatty");
assert.equal(initialize.params.capabilities.experimentalApi, true);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: { userAgent: "codex" } })}\n`);
await startPromise;
const initialized = await readJsonLine(child.stdin);
assert.equal(initialized.method, "initialized");
const requestPromise = connection.request("model/list", { limit: 100 });
const request = await readJsonLine(child.stdin);
assert.equal(request.method, "model/list");
child.stdout.write(`${JSON.stringify({ id: request.id, result: { data: [], nextCursor: null } })}\n`);
assert.deepEqual(await requestPromise, { data: [], nextCursor: null });
child.stdout.write(`${JSON.stringify({ method: "warning", params: { message: "heads up" } })}\n`);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(notifications[0].method, "warning");
connection.close();
});
test("App Server connection preserves a Chinese response split across UTF-8 chunks", async () => {
const child = createFakeChild();
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
const response = Buffer.from(`${JSON.stringify({
id: initialize.id,
result: { message: "中文" },
})}\n`, "utf8");
const split = response.indexOf(Buffer.from("中", "utf8")) + 1;
child.stdout.write(response.subarray(0, split));
child.stdout.write(response.subarray(split));
await startPromise;
await readJsonLine(child.stdin);
connection.close();
});
test("App Server connection rejects an unterminated oversized JSONL message", async () => {
const child = createFakeChild();
let fatal;
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
maxJsonlLineBytes: 8,
spawnImpl: () => child,
onFatal: (error) => { fatal = error; },
});
const startPromise = connection.start();
await readJsonLine(child.stdin);
child.stdout.write("123456789");
await assert.rejects(startPromise, /message exceeded 8 bytes/);
assert.match(fatal.message, /message exceeded 8 bytes/);
assert.equal(child.killed, true);
});
test("App Server connection rejects pending RPCs when the process exits", async () => {
const child = createFakeChild();
let fatal;
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
spawnImpl: () => child,
onFatal: (error) => { fatal = error; },
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin); // initialized notification
const request = connection.request("thread/start", {});
await readJsonLine(child.stdin);
child.emit("exit", 1, null);
await assert.rejects(request, /exited unexpectedly/);
assert.match(fatal.message, /code 1/);
});
test("App Server close force-kills a child that ignores SIGTERM", async () => {
const child = createFakeChild();
const signals = [];
child.kill = (signal) => {
signals.push(signal);
return true;
};
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
closeKillGraceMs: 5,
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin);
connection.close();
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
});
test("App Server close does not retain or re-kill a child that exits synchronously on SIGTERM", async () => {
const child = createFakeChild();
const signals = [];
child.kill = (signal) => {
signals.push(signal);
if (signal === "SIGTERM") child.emit("exit", 0, "SIGTERM");
return true;
};
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
closeKillGraceMs: 5,
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin);
connection.close();
assert.equal(connection.getClosingProcessCountForTests(), 0);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(signals, ["SIGTERM"]);
});
test("App Server process keys include executable and environment identity", () => {
assert.notEqual(
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
buildCodexAppServerKey("/b/codex", { HOME: "/a" }),
);
assert.notEqual(
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
);
});

View File

@@ -0,0 +1,44 @@
"use strict";
const { execFile } = require("node:child_process");
const { buildCodexAppServerLaunch } = require("./connection.cjs");
function execFileText(command, args, options = {}) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
error.stdout = stdout;
error.stderr = stderr;
reject(error);
return;
}
resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
});
});
}
async function probeCodexAppServer({ binPath, env, execFileImpl = execFileText }) {
try {
const launch = buildCodexAppServerLaunch(binPath, ["app-server", "--help"]);
const result = await execFileImpl(launch.command, launch.args, {
env: { ...(env || {}), ...(launch.env || {}) },
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
maxBuffer: 1024 * 1024,
});
const output = `${result.stdout || ""}\n${result.stderr || ""}`;
const available = /Run the app server|--listen|--stdio/i.test(output);
return available
? { available: true }
: { available: false, error: "This Codex CLI does not advertise App Server support." };
} catch (error) {
const detail = String(error?.stderr || error?.message || error || "").trim();
return {
available: false,
error: detail || "Failed to probe Codex App Server support.",
};
}
}
module.exports = { execFileText, probeCodexAppServer };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,988 @@
"use strict";
const {
CodexAppServerConnection,
buildCodexAppServerKey,
} = require("./connection.cjs");
const {
parseCodexModelSelection,
toCodexMcpConfig,
} = require("../sdk/codexDriver.cjs");
const INTERACTION_TIMEOUT_MS = 5 * 60 * 1000;
const INTERRUPT_REQUEST_TIMEOUT_MS = 5_000;
const INTERRUPT_GRACE_MS = 2_000;
const MAX_STREAMED_PREFIX_CHARS = 256 * 1024;
const MAX_TOOL_OUTPUT_CHARS = 1024 * 1024;
function appendStreamState(map, itemId, delta, maxPrefixChars = MAX_STREAMED_PREFIX_CHARS) {
const text = String(delta || "");
const previous = map.get(itemId) || { prefix: "", length: 0, truncated: false };
const remaining = Math.max(0, maxPrefixChars - previous.prefix.length);
const next = {
prefix: remaining > 0 ? previous.prefix + text.slice(0, remaining) : previous.prefix,
length: previous.length + text.length,
truncated: previous.truncated || text.length > remaining,
};
map.set(itemId, next);
return next;
}
function appendToolOutputState(map, itemId, delta) {
const text = String(delta || "");
const previous = map.get(itemId) || { text: "", totalLength: 0, truncated: false };
const remaining = Math.max(0, MAX_TOOL_OUTPUT_CHARS - previous.text.length);
const next = {
text: remaining > 0 ? previous.text + text.slice(0, remaining) : previous.text,
totalLength: previous.totalLength + text.length,
truncated: previous.truncated || text.length > remaining,
};
map.set(itemId, next);
return next;
}
function formatBoundedToolOutput(value, totalLength = String(value || "").length) {
const text = String(value || "");
if (text.length <= MAX_TOOL_OUTPUT_CHARS && totalLength <= MAX_TOOL_OUTPUT_CHARS) return text;
const kept = text.slice(0, MAX_TOOL_OUTPUT_CHARS);
return `${kept}\n[output truncated: ${Math.max(totalLength, text.length)} characters total]`;
}
function resolveCodexPermissionConfig(permissionMode) {
if (permissionMode === "observer") {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
};
}
if (permissionMode === "auto") {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "danger-full-access",
sandboxPolicy: { type: "dangerFullAccess" },
};
}
return {
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
};
}
function buildThreadConfig(injectedMcpServers) {
return {
// Netcatty already applies its Observer/Confirm/Auto policy inside the MCP
// bridge. Tell Codex not to add a second MCP approval prompt: App Server
// otherwise routes the stable MCP elicitation request back to this client,
// and rejecting/omitting that duplicate prompt surfaces as
// "user rejected MCP tool call" before Netcatty's own gate can run.
mcp_servers: toCodexMcpConfig(injectedMcpServers, {
defaultToolsApprovalMode: "approve",
}),
model_reasoning_summary: "concise",
};
}
function normalizeFileChanges(changes) {
if (!Array.isArray(changes)) return [];
return changes
.filter((change) => change && typeof change.path === "string")
.map((change) => ({
path: change.path,
kind: change.kind?.type === "add"
? "add"
: change.kind?.type === "delete"
? "delete"
: "update",
}));
}
function normalizeGrantedPermissions(requested) {
const granted = {};
if (requested?.network != null) granted.network = requested.network;
if (requested?.fileSystem != null) granted.fileSystem = requested.fileSystem;
return granted;
}
function stringifyMcpContent(result) {
if (!result) return "";
const content = Array.isArray(result.content) ? result.content : [];
let text = "";
let totalLength = 0;
for (const item of content) {
const rawPart = item && typeof item === "object" && typeof item.text === "string"
? item.text
: typeof item === "string" ? item : JSON.stringify(item);
const part = typeof rawPart === "string" ? rawPart : "";
totalLength += part.length;
if (text.length < MAX_TOOL_OUTPUT_CHARS) {
text += part.slice(0, MAX_TOOL_OUTPUT_CHARS - text.length);
}
}
if (text || totalLength > 0) return formatBoundedToolOutput(text, totalLength);
if (result.structuredContent == null) return "";
return formatBoundedToolOutput(JSON.stringify(result.structuredContent));
}
function buildTurnInput(prompt, attachments) {
const input = [{ type: "text", text: String(prompt || ""), text_elements: [] }];
for (const attachment of attachments || []) {
if (!attachment?.filePath) continue;
if (!String(attachment.mediaType || "").toLowerCase().startsWith("image/")) continue;
input.push({ type: "localImage", path: attachment.filePath });
}
return input;
}
function getActiveTurnNotSteerableKind(error) {
const turnKind = error?.data?.activeTurnNotSteerable?.turnKind
?? error?.data?.codexErrorInfo?.activeTurnNotSteerable?.turnKind;
return turnKind === "review" || turnKind === "compact" ? turnKind : null;
}
function mapAppServerModels(rawModels) {
return (Array.isArray(rawModels) ? rawModels : [])
.filter((model) => model && model.id && !model.hidden)
.map((model) => ({
id: model.id,
name: model.displayName || model.id,
description: model.description || undefined,
thinkingLevels: Array.isArray(model.supportedReasoningEfforts)
? model.supportedReasoningEfforts
.map((option) => option?.reasoningEffort)
.filter(Boolean)
: [],
defaultThinkingLevel: model.defaultReasoningEffort || undefined,
isDefault: model.isDefault === true,
}));
}
function resolveAppServerModelSelection(model) {
if (!model) return null;
const defaultThinkingLevel = model.defaultThinkingLevel;
if (
defaultThinkingLevel
&& Array.isArray(model.thinkingLevels)
&& model.thinkingLevels.includes(defaultThinkingLevel)
) {
return `${model.id}/${defaultThinkingLevel}`;
}
return model.id;
}
class CodexAppServerRuntime {
constructor({
appVersion = "0.0.0",
connectionFactory,
sendInteractionRequest,
sendInteractionCleared,
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
interruptGraceMs = INTERRUPT_GRACE_MS,
} = {}) {
this.appVersion = appVersion;
this.connectionFactory = connectionFactory;
this.sendInteractionRequest = sendInteractionRequest;
this.sendInteractionCleared = sendInteractionCleared;
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
this.interruptGraceMs = interruptGraceMs;
this.connections = new Map();
this.preferredConnectionKey = null;
this.activeByRequest = new Map();
this.activeByThread = new Map();
this.activeByTurn = new Map();
this.pendingInteractions = new Map();
this.interactionCounter = 0;
this.eventCounter = 0;
}
#scopedKey(connectionKey, id) {
return `${connectionKey}\u0000${String(id || "")}`;
}
#getConnection(binPath, env) {
const connectionKey = buildCodexAppServerKey(binPath, env);
this.preferredConnectionKey = connectionKey;
const existing = this.connections.get(connectionKey);
if (existing) {
this.#closeIdleConnections(connectionKey);
return { connection: existing, connectionKey };
}
this.#closeIdleConnections(connectionKey);
const factory = this.connectionFactory || ((options) => new CodexAppServerConnection(options));
const connection = factory({
binPath,
env,
appVersion: this.appVersion,
onNotification: (message) => this.#handleNotification(connectionKey, message),
onServerRequest: (message, source) => this.#handleServerRequest(connectionKey, source, message),
onFatal: (error) => this.#handleConnectionFatal(connectionKey, error),
});
this.connections.set(connectionKey, connection);
return { connection, connectionKey };
}
#closeIdleConnections(keepKey = this.preferredConnectionKey) {
const activeConnectionKeys = new Set(
Array.from(this.activeByRequest.values(), (context) => context.connectionKey),
);
for (const [connectionKey, connection] of this.connections) {
if (connectionKey === keepKey || activeConnectionKeys.has(connectionKey)) continue;
this.connections.delete(connectionKey);
try { connection.close(); } catch {}
}
}
#refreshPreferredConnectionKey() {
if (this.preferredConnectionKey && this.connections.has(this.preferredConnectionKey)) return;
const connectionKeys = Array.from(this.connections.keys());
this.preferredConnectionKey = connectionKeys.at(-1) || null;
}
async runTurn({
requestId,
chatSessionId,
prompt,
attachments,
cwd,
model,
permissionMode,
env,
binPath,
injectedMcpServers,
resumeThreadId,
emitter,
signal,
sender,
}) {
const throwIfAborted = () => {
if (!signal?.aborted) return;
const error = new Error("Codex App Server turn was interrupted before it started");
error.name = "AbortError";
throw error;
};
throwIfAborted();
const { connection, connectionKey } = this.#getConnection(binPath, env);
await connection.start();
throwIfAborted();
const permission = resolveCodexPermissionConfig(permissionMode);
const selection = parseCodexModelSelection(model);
const threadParams = {
model: selection.model || null,
cwd: cwd || process.cwd(),
approvalPolicy: permission.approvalPolicy,
approvalsReviewer: permission.approvalsReviewer,
sandbox: permission.sandbox,
config: buildThreadConfig(injectedMcpServers),
};
const threadResult = resumeThreadId
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
: await connection.request("thread/start", threadParams);
throwIfAborted();
const threadId = threadResult?.thread?.id || resumeThreadId;
if (!threadId) throw new Error("Codex App Server did not return a thread id");
emitter.sessionId(threadId);
const context = {
requestId,
chatSessionId,
connection,
connectionKey,
threadId,
turnId: null,
emitter,
signal,
sender,
lastError: null,
settled: false,
cancelRequested: false,
interruptPromise: null,
steerPromise: null,
reasoningOpen: false,
streamedTextByItem: new Map(),
streamedReasoningByItem: new Map(),
commandOutputByItem: new Map(),
emittedToolCalls: new Set(),
emittedToolResults: new Set(),
forceCancelTimer: null,
abortListener: null,
};
this.activeByRequest.set(requestId, context);
this.activeByThread.set(this.#scopedKey(connectionKey, threadId), context);
const completion = new Promise((resolve, reject) => {
context.resolve = resolve;
context.reject = reject;
});
if (signal) {
context.abortListener = () => { void this.cancelTurn(requestId); };
signal.addEventListener("abort", context.abortListener, { once: true });
if (signal.aborted) context.abortListener();
}
try {
const turnResult = await connection.request("turn/start", {
threadId,
input: buildTurnInput(prompt, attachments),
cwd: cwd || process.cwd(),
approvalPolicy: permission.approvalPolicy,
approvalsReviewer: permission.approvalsReviewer,
sandboxPolicy: permission.sandboxPolicy,
model: selection.model || null,
effort: selection.effort || null,
summary: "concise",
});
const turnId = turnResult?.turn?.id;
if (turnId) this.#assignTurnId(context, turnId);
await completion;
return { threadId, turnId: context.turnId };
} finally {
this.#removeContext(context);
}
}
async listModels({ binPath, env }) {
const { connection } = this.#getConnection(binPath, env);
await connection.start();
const all = [];
let cursor = null;
do {
const response = await connection.request("model/list", {
cursor,
limit: 100,
}, 10_000);
all.push(...(response?.data || []));
cursor = response?.nextCursor || null;
} while (cursor);
const models = mapAppServerModels(all);
const defaultModel = models.find((model) => model.isDefault);
return {
currentModelId: resolveAppServerModelSelection(defaultModel),
models,
};
}
async steerTurn(requestId, {
chatSessionId,
prompt,
attachments,
clientUserMessageId,
} = {}) {
const context = this.activeByRequest.get(requestId);
if (!context || context.settled || context.chatSessionId !== chatSessionId) {
return { status: "inactive" };
}
if (context.cancelRequested || context.signal?.aborted) {
return { status: "cancelled" };
}
if (!context.turnId) {
return { status: "busy", message: "Codex turn is still starting" };
}
if (context.steerPromise) {
return { status: "busy", message: "A Codex instruction is already being sent" };
}
const steerPromise = (async () => {
try {
const response = await context.connection.request("turn/steer", {
threadId: context.threadId,
expectedTurnId: context.turnId,
input: buildTurnInput(prompt, attachments),
clientUserMessageId: clientUserMessageId || null,
});
if (context.cancelRequested || context.signal?.aborted || context.settled) {
return { status: "cancelled" };
}
if (response?.turnId && response.turnId !== context.turnId) {
return {
status: "failed",
message: "Codex App Server returned a different turn id while steering",
};
}
return { status: "accepted" };
} catch (error) {
const turnKind = getActiveTurnNotSteerableKind(error);
if (turnKind) {
return {
status: "not-steerable",
turnKind,
message: error?.message || "The active Codex turn cannot be steered",
};
}
if (context.cancelRequested || context.signal?.aborted || context.settled) {
return { status: "cancelled" };
}
return {
status: "failed",
message: error?.message || String(error),
};
}
})();
context.steerPromise = steerPromise;
try {
return await steerPromise;
} finally {
if (context.steerPromise === steerPromise) context.steerPromise = null;
}
}
#assignTurnId(context, turnId) {
if (!turnId || context.turnId === turnId) return;
if (context.turnId) {
this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
}
context.turnId = turnId;
this.activeByTurn.set(this.#scopedKey(context.connectionKey, turnId), context);
if (context.cancelRequested) void this.#interruptAndSchedule(context);
}
#interruptContext(context) {
if (!context.turnId) return Promise.resolve(false);
if (context.interruptPromise) return context.interruptPromise;
let timeout;
const request = Promise.resolve().then(() => context.connection.request("turn/interrupt", {
threadId: context.threadId,
turnId: context.turnId,
}, this.interruptRequestTimeoutMs)).then(() => true).catch(() => false);
const deadline = new Promise((resolve) => {
timeout = setTimeout(() => resolve(false), this.interruptRequestTimeoutMs);
timeout.unref?.();
});
context.interruptPromise = Promise.race([request, deadline])
.finally(() => clearTimeout(timeout));
return context.interruptPromise;
}
#scheduleForcedCancellation(context, delayMs) {
if (context.settled) return;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = setTimeout(() => {
this.#forceCancelContext(context, "Codex App Server did not complete the interrupted turn");
}, Math.max(0, delayMs));
context.forceCancelTimer.unref?.();
}
async #interruptAndSchedule(context) {
if (context.settled) return;
if (!context.turnId) {
this.#scheduleForcedCancellation(
context,
this.interruptRequestTimeoutMs + this.interruptGraceMs,
);
return;
}
const interrupted = await this.#interruptContext(context);
if (context.settled) return;
if (!interrupted) {
this.#forceCancelContext(context, "Codex App Server could not interrupt the turn");
return;
}
this.#scheduleForcedCancellation(context, this.interruptGraceMs);
}
#forceCancelContext(context, reason) {
if (context.settled) return;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#closeReasoning(context);
this.#clearInteractionsForContext(context, "cancel");
context.emitter.emitDone();
context.resolve();
const connection = this.connections.get(context.connectionKey);
if (connection === context.connection) {
this.connections.delete(context.connectionKey);
try { connection.close(); } catch {}
this.#refreshPreferredConnectionKey();
}
const error = new Error(reason);
for (const candidate of this.activeByRequest.values()) {
if (candidate === context || candidate.connectionKey !== context.connectionKey || candidate.settled) continue;
candidate.settled = true;
clearTimeout(candidate.forceCancelTimer);
candidate.forceCancelTimer = null;
this.#clearInteractionsForContext(candidate, "cancel");
candidate.reject(error);
}
}
#findContext(connectionKey, params) {
if (params?.turnId) {
const byTurn = this.activeByTurn.get(this.#scopedKey(connectionKey, params.turnId));
if (byTurn) return byTurn;
}
if (params?.threadId) {
return this.activeByThread.get(this.#scopedKey(connectionKey, params.threadId)) || null;
}
return null;
}
#handleNotification(connectionKey, message) {
const params = message.params || {};
const context = this.#findContext(connectionKey, params);
if (!context) {
if (message.method === "warning") {
const contexts = Array.from(this.activeByRequest.values())
.filter((candidate) => candidate.connectionKey === connectionKey);
for (const candidate of contexts) {
candidate.emitter.warning(
`codex-warning:connection:${++this.eventCounter}`,
params.message || "Codex warning",
);
}
}
return;
}
const emitter = context.emitter;
switch (message.method) {
case "turn/started":
this.#assignTurnId(context, params.turn?.id);
return;
case "item/agentMessage/delta": {
appendStreamState(context.streamedTextByItem, params.itemId, params.delta);
emitter.text(params.delta || "");
return;
}
case "item/reasoning/summaryTextDelta": {
appendStreamState(context.streamedReasoningByItem, params.itemId, params.delta);
emitter.reasoning(params.delta || "");
context.reasoningOpen = true;
return;
}
case "item/commandExecution/outputDelta": {
appendToolOutputState(context.commandOutputByItem, params.itemId, params.delta);
return;
}
case "item/started":
this.#handleItem(context, params.item, false);
return;
case "item/completed":
this.#handleItem(context, params.item, true);
return;
case "turn/plan/updated":
emitter.planUpdate(
`codex-plan:${params.turnId}`,
(params.plan || []).map((item) => ({
text: item.step || "",
completed: item.status === "completed",
})),
(params.plan || []).every((item) => item.status === "completed") ? "completed" : "running",
);
return;
case "thread/tokenUsage/updated": {
const usage = params.tokenUsage?.last;
if (usage) {
emitter.usage({
inputTokens: Number(usage.inputTokens) || 0,
cachedInputTokens: Number(usage.cachedInputTokens) || 0,
outputTokens: Number(usage.outputTokens) || 0,
reasoningTokens: Number(usage.reasoningOutputTokens) || 0,
totalTokens: Number(usage.totalTokens) || 0,
});
}
return;
}
case "warning":
emitter.warning(
`codex-warning:${params.turnId || context.turnId}:${++this.eventCounter}`,
params.message || "Codex warning",
);
return;
case "error":
context.lastError = params.error?.message || "Codex App Server error";
emitter.warning(
`codex-error:${params.turnId || context.turnId}:${++this.eventCounter}`,
params.willRetry ? `${context.lastError} (retrying)` : context.lastError,
);
return;
case "turn/completed":
this.#completeTurn(context, params.turn);
return;
default:
return;
}
}
#closeReasoning(context) {
if (!context.reasoningOpen) return;
context.emitter.reasoningEnd();
context.reasoningOpen = false;
}
#emitToolCallOnce(context, item, name, args) {
if (!item?.id || context.emittedToolCalls.has(item.id)) return;
context.emittedToolCalls.add(item.id);
this.#closeReasoning(context);
context.emitter.toolCall(name, args || {}, item.id);
}
#emitToolResultOnce(context, item, output, name) {
if (!item?.id || context.emittedToolResults.has(item.id)) return;
context.emittedToolResults.add(item.id);
context.emitter.toolResult(item.id, output || "", name);
}
#handleItem(context, item, completed) {
if (!item || typeof item !== "object") return;
const emitter = context.emitter;
switch (item.type) {
case "agentMessage": {
if (!completed) return;
this.#closeReasoning(context);
const streamed = context.streamedTextByItem.get(item.id);
context.streamedTextByItem.delete(item.id);
if (item.text && streamed && item.text.startsWith(streamed.prefix)) {
if (item.text.length > streamed.length) emitter.text(item.text.slice(streamed.length));
} else if (item.text && !streamed) emitter.text(item.text);
return;
}
case "reasoning": {
if (!completed) return;
const finalText = Array.isArray(item.summary) ? item.summary.join("\n") : "";
const streamed = context.streamedReasoningByItem.get(item.id);
context.streamedReasoningByItem.delete(item.id);
if (finalText && streamed && finalText.startsWith(streamed.prefix)) {
if (finalText.length > streamed.length) emitter.reasoning(finalText.slice(streamed.length));
} else if (finalText && !streamed) emitter.reasoning(finalText);
context.reasoningOpen = true;
this.#closeReasoning(context);
return;
}
case "commandExecution": {
const toolName = "codex.command";
this.#emitToolCallOnce(context, item, toolName, { command: item.command, cwd: item.cwd });
if (completed) {
const streamedOutput = context.commandOutputByItem.get(item.id);
context.commandOutputByItem.delete(item.id);
const output = item.aggregatedOutput == null
? formatBoundedToolOutput(
streamedOutput?.text || "",
streamedOutput?.totalLength || 0,
)
: formatBoundedToolOutput(item.aggregatedOutput);
const suffix = item.exitCode == null ? "" : `\n[exit code: ${item.exitCode}]`;
this.#emitToolResultOnce(context, item, `${output}${suffix}`, toolName);
}
return;
}
case "mcpToolCall": {
const toolName = `${item.server || "mcp"}.${item.tool || "tool"}`;
this.#emitToolCallOnce(context, item, toolName, item.arguments || {});
if (completed) {
const output = item.error?.message || stringifyMcpContent(item.result);
this.#emitToolResultOnce(context, item, output, toolName);
}
return;
}
case "fileChange":
if (completed) {
emitter.fileChange(
item.id,
normalizeFileChanges(item.changes),
item.status === "completed" ? "completed" : "failed",
);
}
return;
case "webSearch":
emitter.webSearch(item.id, item.query || "", completed ? "completed" : "running");
return;
default:
return;
}
}
#completeTurn(context, turn) {
if (context.settled) return;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#closeReasoning(context);
this.#clearInteractionsForContext(context, "cancel");
if (turn?.status === "failed") {
context.reject(new Error(turn.error?.message || context.lastError || "Codex turn failed"));
return;
}
context.emitter.emitDone();
context.resolve();
}
async #handleServerRequest(connectionKey, connection, message) {
const params = message.params || {};
const context = this.#findContext(connectionKey, params);
const supported = new Map([
["item/commandExecution/requestApproval", "command"],
["item/fileChange/requestApproval", "file-change"],
["item/permissions/requestApproval", "permissions"],
["item/tool/requestUserInput", "user-input"],
]);
const kind = supported.get(message.method);
if (!kind) {
connection.respondError(message.id, -32601, `Unsupported Codex App Server request: ${message.method}`);
context?.emitter.warning(
`codex-unsupported-request:${++this.eventCounter}`,
`Unsupported Codex request: ${message.method}`,
);
return;
}
if (!context) {
connection.respond(message.id, this.#safeInteractionResponse(kind, params, "reject"));
return;
}
const interactionId = `codex_interaction_${++this.interactionCounter}_${Date.now()}`;
const timeoutMs = kind === "user-input" && Number(params.autoResolutionMs) > 0
? Number(params.autoResolutionMs)
: INTERACTION_TIMEOUT_MS;
// Hard ceiling from creation — review can cancel the idle timer but must
// re-arm the absolute remainder (Catty/MCP pattern; never unbounded).
const absoluteExpiresAt = Date.now() + timeoutMs;
const armTimer = (ms) => {
const pending = this.pendingInteractions.get(interactionId);
if (!pending) return;
if (pending.timer) {
clearTimeout(pending.timer);
pending.timer = null;
}
if (ms <= 0) {
this.#resolveInteraction(
interactionId,
kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
return;
}
pending.timer = setTimeout(() => {
this.#resolveInteraction(
interactionId,
kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
}, ms);
};
this.pendingInteractions.set(interactionId, {
interactionId,
connection,
rpcId: message.id,
kind,
params,
context,
timer: null,
absoluteExpiresAt,
idleCancelled: false,
});
armTimer(timeoutMs);
const payload = {
interactionId,
source: "codex-app-server",
kind,
requestId: context.requestId,
chatSessionId: context.chatSessionId,
itemId: params.itemId,
toolName: kind === "command"
? "codex.command"
: kind === "file-change"
? "codex.file_change"
: kind === "permissions"
? "codex.permissions"
: undefined,
args: kind === "command"
? {
command: params.command,
cwd: params.cwd,
reason: params.reason,
commandActions: params.commandActions,
}
: kind === "file-change"
? { reason: params.reason, grantRoot: params.grantRoot, itemId: params.itemId }
: kind === "permissions"
? { cwd: params.cwd, reason: params.reason, permissions: params.permissions }
: undefined,
availableDecisions: kind === "command" && Array.isArray(params.availableDecisions)
? params.availableDecisions
: undefined,
questions: kind === "user-input" ? params.questions || [] : undefined,
autoResolutionMs: kind === "user-input" ? params.autoResolutionMs : undefined,
};
let delivered = false;
try {
delivered = typeof this.sendInteractionRequest === "function"
&& this.sendInteractionRequest(payload, context) !== false;
} catch {
delivered = false;
}
if (!delivered) {
this.#resolveInteraction(interactionId, kind === "user-input" ? { answers: {} } : { decision: "reject" });
}
}
#safeInteractionResponse(kind, params, decision) {
if (kind === "user-input") return { answers: {} };
if (kind === "permissions") {
const granted = decision === "once" || decision === "session"
? normalizeGrantedPermissions(params.permissions)
: {};
return { permissions: granted, scope: decision === "session" ? "session" : "turn" };
}
const mapped = decision === "once"
? "accept"
: decision === "session"
? "acceptForSession"
: decision === "cancel"
? "cancel"
: "decline";
return { decision: mapped };
}
#resolveInteraction(interactionId, response) {
const pending = this.pendingInteractions.get(interactionId);
if (!pending) return false;
this.pendingInteractions.delete(interactionId);
clearTimeout(pending.timer);
try {
const result = pending.kind === "user-input"
? { answers: response?.answers || {} }
: this.#safeInteractionResponse(pending.kind, pending.params, response?.decision || "reject");
try { pending.connection.respond(pending.rpcId, result); } catch {}
} finally {
this.sendInteractionCleared?.({
interactionIds: [interactionId],
chatSessionId: pending.context.chatSessionId,
}, pending.context);
}
return true;
}
respondInteraction(interactionId, response, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
return this.#resolveInteraction(interactionId, response);
}
/**
* Drop the idle auto-reject timer after the user starts reviewing an approval card.
* Re-arms the absolute creation deadline so a late approve cannot outlive the
* original timeout window (matches Catty/MCP approval cancel semantics).
*/
cancelInteractionTimeout(interactionId, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (!pending || pending.idleCancelled) return false;
if (sender && pending.context?.sender && pending.context.sender !== sender) return false;
pending.idleCancelled = true;
if (pending.timer) {
clearTimeout(pending.timer);
pending.timer = null;
}
const remainingMs = Math.max(0, (pending.absoluteExpiresAt ?? 0) - Date.now());
if (remainingMs <= 0) {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
return true;
}
pending.timer = setTimeout(() => {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
}, remainingMs);
return true;
}
#clearInteractionsForContext(context, decision) {
for (const [interactionId, pending] of Array.from(this.pendingInteractions)) {
if (pending.context === context) {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision },
);
}
}
}
async cancelTurn(requestId) {
const context = this.activeByRequest.get(requestId);
if (!context) return false;
context.cancelRequested = true;
this.#clearInteractionsForContext(context, "cancel");
await this.#interruptAndSchedule(context);
return true;
}
async cleanupChatSession(chatSessionId) {
const contexts = Array.from(this.activeByRequest.values())
.filter((context) => context.chatSessionId === chatSessionId);
await Promise.all(contexts.map((context) => this.cancelTurn(context.requestId)));
}
#handleConnectionFatal(connectionKey, error) {
const connection = this.connections.get(connectionKey);
if (connection) {
this.connections.delete(connectionKey);
this.#refreshPreferredConnectionKey();
}
const contexts = Array.from(this.activeByRequest.values())
.filter((context) => context.connectionKey === connectionKey);
for (const context of contexts) {
if (context.settled) continue;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#clearInteractionsForContext(context, "cancel");
context.reject(error);
}
}
#removeContext(context) {
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
if (context.abortListener && context.signal) {
context.signal.removeEventListener("abort", context.abortListener);
context.abortListener = null;
}
this.activeByRequest.delete(context.requestId);
this.activeByThread.delete(this.#scopedKey(context.connectionKey, context.threadId));
if (context.turnId) this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
this.#closeIdleConnections();
}
close() {
for (const interactionId of Array.from(this.pendingInteractions.keys())) {
this.#resolveInteraction(interactionId, { decision: "cancel", answers: {} });
}
for (const [, connection] of this.connections) connection.close();
this.connections.clear();
this.preferredConnectionKey = null;
for (const context of this.activeByRequest.values()) {
if (!context.settled) {
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
context.reject(new Error("Codex App Server shut down"));
}
}
this.activeByRequest.clear();
this.activeByThread.clear();
this.activeByTurn.clear();
}
}
module.exports = {
CodexAppServerRuntime,
INTERACTION_TIMEOUT_MS,
buildThreadConfig,
buildTurnInput,
getActiveTurnNotSteerableKind,
mapAppServerModels,
normalizeFileChanges,
normalizeGrantedPermissions,
resolveAppServerModelSelection,
resolveCodexPermissionConfig,
stringifyMcpContent,
};

View File

@@ -0,0 +1,850 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
CodexAppServerRuntime,
buildTurnInput,
mapAppServerModels,
normalizeFileChanges,
resolveCodexPermissionConfig,
} = require("./runtime.cjs");
class FakeConnection {
constructor(options) {
this.options = options;
this.requests = [];
this.responses = [];
this.threadId = "thread-1";
this.turnId = "turn-1";
this.closed = false;
}
async start() { return this; }
async request(method, params) {
this.requests.push({ method, params });
if (method === "thread/start" || method === "thread/resume") {
return { thread: { id: this.threadId } };
}
if (method === "turn/start") {
if (this.turnStartGate) await this.turnStartGate;
return { turn: { id: this.turnId } };
}
if (method === "turn/steer") {
if (this.turnSteerGate) await this.turnSteerGate;
if (this.turnSteerError) throw this.turnSteerError;
return { turnId: this.turnId };
}
if (method === "turn/interrupt") {
if (this.turnInterruptGate) await this.turnInterruptGate;
if (this.turnInterruptError) throw this.turnInterruptError;
return {};
}
if (method === "model/list") {
return {
data: [
{
id: "gpt-first",
displayName: "GPT First",
description: "First model in the catalog",
hidden: false,
supportedReasoningEfforts: [{ reasoningEffort: "low" }],
defaultReasoningEffort: "low",
isDefault: false,
},
{
id: "gpt-test",
displayName: "GPT Test",
description: "Server default model",
hidden: false,
supportedReasoningEfforts: [{ reasoningEffort: "low" }, { reasoningEffort: "high" }],
defaultReasoningEffort: "high",
isDefault: true,
},
],
nextCursor: null,
};
}
return {};
}
respond(id, result) { this.responses.push({ id, result }); }
respondError(id, code, message) { this.responses.push({ id, error: { code, message } }); }
notify(message) { this.options.onNotification(message); }
serverRequest(message) { return this.options.onServerRequest(message, this); }
close() { this.closed = true; }
}
function createEmitter() {
const events = [];
return {
events,
emitDone: () => events.push(["done"]),
sessionId: (id) => events.push(["session", id]),
text: (text) => events.push(["text", text]),
reasoning: (text) => events.push(["reasoning", text]),
reasoningEnd: () => events.push(["reasoning-end"]),
toolCall: (name, args, id) => events.push(["tool-call", name, args, id]),
toolResult: (id, output, name) => events.push(["tool-result", id, output, name]),
fileChange: (id, changes, status) => events.push(["file-change", id, changes, status]),
webSearch: (id, query, status) => events.push(["web-search", id, query, status]),
planUpdate: (id, items, status) => events.push(["plan", id, items, status]),
warning: (id, message) => events.push(["warning", id, message]),
usage: (usage) => events.push(["usage", usage]),
};
}
async function waitFor(predicate) {
for (let index = 0; index < 50; index += 1) {
if (predicate()) return;
await new Promise((resolve) => setImmediate(resolve));
}
throw new Error("condition not reached");
}
test("permission modes map to fail-closed Codex policies", () => {
assert.deepEqual(resolveCodexPermissionConfig("observer"), {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
});
assert.equal(resolveCodexPermissionConfig("confirm").approvalPolicy, "on-request");
assert.equal(resolveCodexPermissionConfig("confirm").sandbox, "read-only");
assert.equal(resolveCodexPermissionConfig("auto").sandbox, "danger-full-access");
});
test("turn input uses text plus local images only", () => {
assert.deepEqual(buildTurnInput("hello", [
{ filePath: "/tmp/a.png", mediaType: "image/png" },
{ filePath: "/tmp/a.txt", mediaType: "text/plain" },
]), [
{ type: "text", text: "hello", text_elements: [] },
{ type: "localImage", path: "/tmp/a.png" },
]);
});
test("runtime maps lifecycle, activities, usage, and retry warnings", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const emitter = createEmitter();
const run = runtime.runTurn({
requestId: "request-1",
chatSessionId: "chat-1",
prompt: "hello",
cwd: "/repo",
model: "gpt-test/high",
permissionMode: "confirm",
env: { HOME: "/home" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
connection.notify({ method: "item/agentMessage/delta", params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "Hi" } });
connection.notify({ method: "turn/plan/updated", params: { threadId: "thread-1", turnId: "turn-1", plan: [{ step: "Inspect", status: "completed" }] } });
connection.notify({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "webSearch", id: "search-1", query: "Netcatty" } } });
connection.notify({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "file-1", status: "completed", changes: [{ path: "a.ts", kind: { type: "add" } }] } } });
connection.notify({ method: "thread/tokenUsage/updated", params: { threadId: "thread-1", turnId: "turn-1", tokenUsage: { last: { inputTokens: 10, cachedInputTokens: 2, outputTokens: 3, reasoningOutputTokens: 1, totalTokens: 13 } } } });
connection.notify({ method: "error", params: { threadId: "thread-1", turnId: "turn-1", willRetry: true, error: { message: "network" } } });
connection.notify({ method: "warning", params: { message: "global warning" } });
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
assert.ok(emitter.events.some((event) => event[0] === "text" && event[1] === "Hi"));
assert.ok(emitter.events.some((event) => event[0] === "plan"));
assert.ok(emitter.events.some((event) => event[0] === "web-search" && event[3] === "running"));
assert.ok(emitter.events.some((event) => event[0] === "file-change" && event[3] === "completed"));
assert.ok(emitter.events.some((event) => event[0] === "usage" && event[1].cachedInputTokens === 2));
assert.ok(emitter.events.some((event) => event[0] === "warning" && /retrying/.test(event[2])));
assert.ok(emitter.events.some((event) => event[0] === "warning" && event[2] === "global warning"));
assert.ok(emitter.events.some((event) => event[0] === "done"));
});
test("runtime bounds command output and avoids replaying streamed message prefixes", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const emitter = createEmitter();
const run = runtime.runTurn({
requestId: "request-bounded-output",
chatSessionId: "chat-bounded-output",
prompt: "run",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
connection.notify({ method: "item/agentMessage/delta", params: {
threadId: "thread-1", turnId: "turn-1", itemId: "msg-bounded", delta: "Hello",
} });
connection.notify({ method: "item/completed", params: {
threadId: "thread-1", turnId: "turn-1",
item: { type: "agentMessage", id: "msg-bounded", text: "Hello world" },
} });
connection.notify({ method: "item/commandExecution/outputDelta", params: {
threadId: "thread-1", turnId: "turn-1", itemId: "cmd-bounded",
delta: "x".repeat(1024 * 1024 + 1024),
} });
connection.notify({ method: "item/completed", params: {
threadId: "thread-1", turnId: "turn-1",
item: { type: "commandExecution", id: "cmd-bounded", command: "large", exitCode: 0 },
} });
connection.notify({ method: "turn/completed", params: {
threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null },
} });
await run;
assert.deepEqual(
emitter.events.filter((event) => event[0] === "text"),
[["text", "Hello"], ["text", " world"]],
);
const toolResult = emitter.events.find((event) => event[0] === "tool-result");
assert.ok(toolResult);
assert.ok(toolResult[2].length < 1024 * 1024 + 200);
assert.match(toolResult[2], /output truncated: 1049600 characters total/);
assert.match(toolResult[2], /\[exit code: 0\]$/);
});
test("runtime delegates injected MCP approvals to Netcatty's policy gate", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-mcp-policy",
chatSessionId: "chat-mcp-policy",
prompt: "inspect the terminal",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [{
name: "netcatty-remote-hosts",
command: "/abs/electron",
args: ["/abs/server.cjs"],
env: [{ name: "NETCATTY_MCP_PERMISSION_MODE", value: "confirm" }],
}],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const threadStart = connection.requests.find((request) => request.method === "thread/start");
assert.deepEqual(threadStart.params.config.mcp_servers["netcatty-remote-hosts"], {
command: "/abs/electron",
args: ["/abs/server.cjs"],
env: { NETCATTY_MCP_PERMISSION_MODE: "confirm" },
default_tools_approval_mode: "approve",
});
connection.notify({
method: "turn/completed",
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
});
await run;
});
test("runtime routes native approvals and request_user_input responses", async () => {
let connection;
let interaction;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
sendInteractionRequest: (payload) => { interaction = payload; return true; },
});
const run = runtime.runTurn({
requestId: "request-2",
chatSessionId: "chat-2",
prompt: "change it",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 70,
method: "item/commandExecution/requestApproval",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-1",
command: "npm test",
cwd: "/repo",
availableDecisions: ["accept", "acceptForSession", "decline", "cancel"],
},
});
assert.equal(interaction.kind, "command");
assert.deepEqual(interaction.availableDecisions, ["accept", "acceptForSession", "decline", "cancel"]);
runtime.respondInteraction(interaction.interactionId, { decision: "session" });
assert.deepEqual(connection.responses.at(-1), { id: 70, result: { decision: "acceptForSession" } });
await connection.serverRequest({
id: 72,
method: "item/permissions/requestApproval",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "permissions-1",
permissions: { network: { enabled: true }, fileSystem: null },
cwd: "/repo",
},
});
runtime.respondInteraction(interaction.interactionId, { decision: "once" });
assert.deepEqual(connection.responses.at(-1), {
id: 72,
result: { permissions: { network: { enabled: true } }, scope: "turn" },
});
await connection.serverRequest({
id: 71,
method: "item/tool/requestUserInput",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "question-1", questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }] },
});
runtime.respondInteraction(interaction.interactionId, { answers: { choice: { answers: ["safe"] } } });
assert.deepEqual(connection.responses.at(-1), { id: 71, result: { answers: { choice: { answers: ["safe"] } } } });
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
let connection;
let interaction;
const realNow = Date.now;
let now = 5_000_000;
Date.now = () => now;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
sendInteractionRequest: (payload) => { interaction = payload; return true; },
});
try {
const run = runtime.runTurn({
requestId: "request-timeout-cancel",
chatSessionId: "chat-timeout-cancel",
prompt: "ask",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 90,
method: "item/tool/requestUserInput",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "question-timeout",
questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }],
autoResolutionMs: 100,
},
});
assert.ok(interaction?.interactionId);
// Jump close to the absolute ceiling, then cancel idle. Remaining absolute ~30ms.
now += 70;
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), false);
await new Promise((resolve) => setTimeout(resolve, 15));
assert.equal(
connection.responses.some((response) => response.id === 90),
false,
"must stay pending before absolute expiry",
);
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(
connection.responses.some((response) => response.id === 90),
true,
"absolute deadline must auto-reject after remaining time elapses",
);
assert.deepEqual(connection.responses.at(-1), {
id: 90,
result: { answers: {} },
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
} finally {
Date.now = realNow;
}
});
test("cancelInteractionTimeout still allows explicit approve before absolute expiry", async () => {
let connection;
let interaction;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
sendInteractionRequest: (payload) => { interaction = payload; return true; },
});
const run = runtime.runTurn({
requestId: "request-timeout-approve",
chatSessionId: "chat-timeout-approve",
prompt: "ask",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 91,
method: "item/commandExecution/requestApproval",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-approve",
command: "echo ok",
cwd: "/tmp",
reason: "demo",
},
});
assert.ok(interaction?.interactionId);
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
assert.equal(runtime.respondInteraction(interaction.interactionId, { decision: "once" }), true);
assert.deepEqual(connection.responses.at(-1), {
id: 91,
result: { decision: "accept" },
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("runtime steers the active turn with text, local images, and a stable user message id", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-steer",
chatSessionId: "chat-steer",
prompt: "initial",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const result = await runtime.steerTurn("request-steer", {
chatSessionId: "chat-steer",
prompt: "use this image",
attachments: [
{ filePath: "/tmp/image.png", mediaType: "image/png" },
{ filePath: "/tmp/notes.txt", mediaType: "text/plain" },
],
clientUserMessageId: "user-steer-1",
});
assert.deepEqual(result, { status: "accepted" });
assert.deepEqual(connection.requests.find((request) => request.method === "turn/steer"), {
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [
{ type: "text", text: "use this image", text_elements: [] },
{ type: "localImage", path: "/tmp/image.png" },
],
clientUserMessageId: "user-steer-1",
},
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("runtime serializes steering and classifies non-steerable turns", async () => {
let connection;
let releaseSteer;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-steer-busy",
chatSessionId: "chat-steer-busy",
prompt: "initial",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const first = runtime.steerTurn("request-steer-busy", {
chatSessionId: "chat-steer-busy",
prompt: "first",
clientUserMessageId: "user-first",
});
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
assert.equal((await runtime.steerTurn("request-steer-busy", {
chatSessionId: "chat-steer-busy",
prompt: "second",
clientUserMessageId: "user-second",
})).status, "busy");
releaseSteer();
assert.equal((await first).status, "accepted");
const error = new Error("active turn cannot be steered");
error.data = { activeTurnNotSteerable: { turnKind: "review" } };
connection.turnSteerError = error;
const rejected = await runtime.steerTurn("request-steer-busy", {
chatSessionId: "chat-steer-busy",
prompt: "review change",
clientUserMessageId: "user-review",
});
assert.deepEqual(rejected, {
status: "not-steerable",
turnKind: "review",
message: "active turn cannot be steered",
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("stop during steering cancels the UI result without creating a replacement turn", async () => {
let connection;
let releaseSteer;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-steer-stop",
chatSessionId: "chat-steer-stop",
prompt: "initial",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const steer = runtime.steerTurn("request-steer-stop", {
chatSessionId: "chat-steer-stop",
prompt: "too late",
clientUserMessageId: "user-steer-stop",
});
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
assert.equal(await runtime.cancelTurn("request-steer-stop"), true);
releaseSteer();
assert.equal((await steer).status, "cancelled");
assert.equal(connection.requests.filter((request) => request.method === "turn/start").length, 1);
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } } });
await run;
});
test("stop requested while turn/start is pending interrupts the assigned turn", async () => {
let connection;
let releaseTurnStart;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnStartGate = new Promise((resolve) => { releaseTurnStart = resolve; });
return connection;
},
});
const emitter = createEmitter();
const run = runtime.runTurn({
requestId: "request-stop",
chatSessionId: "chat-stop",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-stop"), true);
assert.equal(connection.requests.some((request) => request.method === "turn/interrupt"), false);
releaseTurnStart();
await waitFor(() => connection.requests.some((request) => request.method === "turn/interrupt"));
connection.notify({
method: "turn/completed",
params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } },
});
await run;
assert.equal(connection.requests.filter((request) => request.method === "turn/interrupt").length, 1);
assert.ok(emitter.events.some((event) => event[0] === "done"));
});
test("failed interrupt force-settles the turn and releases its connection", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
interruptGraceMs: 5,
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnInterruptError = new Error("interrupt unavailable");
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-interrupt-failure",
chatSessionId: "chat-interrupt-failure",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-interrupt-failure"), true);
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
]);
assert.equal(connection.closed, true);
assert.equal(runtime.activeByRequest.size, 0);
assert.equal(runtime.activeByThread.size, 0);
assert.equal(runtime.activeByTurn.size, 0);
});
test("hung interrupt request times out and force-settles the turn", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
interruptRequestTimeoutMs: 5,
interruptGraceMs: 5,
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnInterruptGate = new Promise(() => {});
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-interrupt-hung",
chatSessionId: "chat-interrupt-hung",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-interrupt-hung"), true);
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
]);
assert.equal(connection.closed, true);
assert.equal(runtime.activeByRequest.size, 0);
});
test("acknowledged interrupt force-settles when completion never arrives", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
interruptGraceMs: 5,
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-interrupt-no-completion",
chatSessionId: "chat-interrupt-no-completion",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-interrupt-no-completion"), true);
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
]);
assert.equal(connection.closed, true);
assert.equal(runtime.activeByRequest.size, 0);
});
test("idle superseded app-server connections close when their active turn finishes", async () => {
const connections = [];
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
const connection = new FakeConnection(options);
connection.threadId = `thread-${connections.length + 1}`;
connection.turnId = `turn-${connections.length + 1}`;
connections.push(connection);
return connection;
},
});
const firstRun = runtime.runTurn({
requestId: "request-config-a",
chatSessionId: "chat-config-a",
prompt: "first",
permissionMode: "confirm",
env: { PROFILE: "a" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
const secondRun = runtime.runTurn({
requestId: "request-config-b",
chatSessionId: "chat-config-b",
prompt: "second",
permissionMode: "confirm",
env: { PROFILE: "b" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
connections[0].notify({
method: "turn/completed",
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
});
await firstRun;
assert.equal(connections[0].closed, true);
assert.equal(connections[1].closed, false);
connections[1].notify({
method: "turn/completed",
params: { threadId: "thread-2", turn: { id: "turn-2", status: "completed", error: null } },
});
await secondRun;
assert.equal(connections[1].closed, false);
runtime.close();
});
test("force-cancelling the preferred connection keeps another active connection reusable", async () => {
const connections = [];
const runtime = new CodexAppServerRuntime({
interruptGraceMs: 5,
connectionFactory: (options) => {
const connection = new FakeConnection(options);
connection.threadId = `thread-reuse-${connections.length + 1}`;
connection.turnId = `turn-reuse-${connections.length + 1}`;
connections.push(connection);
return connection;
},
});
const firstRun = runtime.runTurn({
requestId: "request-reuse-a",
chatSessionId: "chat-reuse-a",
prompt: "first",
permissionMode: "confirm",
env: { PROFILE: "reuse-a" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
const secondRun = runtime.runTurn({
requestId: "request-reuse-b",
chatSessionId: "chat-reuse-b",
prompt: "second",
permissionMode: "confirm",
env: { PROFILE: "reuse-b" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-reuse-b"), true);
await secondRun;
connections[0].notify({
method: "turn/completed",
params: {
threadId: "thread-reuse-1",
turn: { id: "turn-reuse-1", status: "completed", error: null },
},
});
await firstRun;
assert.equal(connections[0].closed, false);
runtime.close();
});
test("unsupported requests fail immediately and warn without hanging", async () => {
let connection;
const emitter = createEmitter();
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-unsupported",
chatSessionId: "chat-unsupported",
prompt: "hello",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 99,
method: "item/unknown/requestApproval",
params: { threadId: "thread-1", turnId: "turn-1" },
});
assert.deepEqual(connection.responses.at(-1), {
id: 99,
error: { code: -32601, message: "Unsupported Codex App Server request: item/unknown/requestApproval" },
});
assert.ok(emitter.events.some((event) => event[0] === "warning"));
await connection.serverRequest({
id: 100,
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-no-renderer", command: "rm -rf /" },
});
assert.deepEqual(connection.responses.at(-1), { id: 100, result: { decision: "decline" } });
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("model and file-change normalization preserve UI contract", async () => {
assert.deepEqual(normalizeFileChanges([
{ path: "a", kind: { type: "add" } },
{ path: "b", kind: { type: "delete" } },
{ path: "c", kind: { type: "update", move_path: null } },
]), [
{ path: "a", kind: "add" },
{ path: "b", kind: "delete" },
{ path: "c", kind: "update" },
]);
assert.equal(mapAppServerModels([{ id: "hidden", hidden: true }]).length, 0);
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const catalog = await runtime.listModels({ binPath: "/bin/codex", env: {} });
assert.equal(connection.requests[0].method, "model/list");
assert.equal(catalog.currentModelId, "gpt-test/high");
assert.equal(catalog.models[0].id, "gpt-first");
assert.deepEqual(catalog.models[1].thinkingLevels, ["low", "high"]);
assert.equal(catalog.models[1].defaultThinkingLevel, "high");
});