Skip to content

Commit 84d8f90

Browse files
xuiocodex
andcommitted
Harden Codex session recovery
Preserve app-server sessions across MCP runtime shutdown, keep nested subagent config out of argv, tighten diagnostic/log file handling, advertise async job durability limits, and add plugin manifest CI coverage. Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 69ce2f3 commit 84d8f90

21 files changed

Lines changed: 451 additions & 68 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ coverage/
66
.vitest/
77
tmp/
88
.in_use/
9+
codex-subagents-debug-*/
10+
codex-subagents-artifacts/
11+
.codex-subagents/
12+
sessions.json

dist/index.js

Lines changed: 91 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21330,13 +21330,16 @@ var privateKeyPattern = new RegExp(
2133021330
"g"
2133121331
);
2133221332
var SECRET_PATTERNS = [
21333+
/\bAuthorization\s*:\s*Bearer\s+([A-Za-z0-9._~+/=-]{12,})\b/gi,
21334+
/\bBearer\s+([A-Za-z0-9._~+/=-]{12,})\b/g,
2133321335
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
2133421336
/\bsk-proj-[A-Za-z0-9_-]{16,}\b/g,
2133521337
/\bghp_[A-Za-z0-9_]{20,}\b/g,
2133621338
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
2133721339
/\bglpat-[A-Za-z0-9_-]{16,}\b/g,
2133821340
/\bxox[baprs]-[A-Za-z0-9-]{16,}\b/g,
2133921341
/\b[A-Za-z_][A-Za-z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|PRIVATE_KEY)=([^\s"'`]+)\b/gi,
21342+
/\b(?:api[_-]?key|token|secret|password|private[_-]?key|authorization)\s*[:=]\s*([^\s"'`]+)\b/gi,
2134021343
privateKeyPattern
2134121344
];
2134221345
var SENSITIVE_ENV_KEY = /(API[_-]?KEY|SECRET|PASSWORD|PRIVATE[_-]?KEY|COOKIE|CREDENTIAL|AUTH|BEARER|(^|[_-])TOKEN$|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|OAUTH[_-]?TOKEN|SESSION[_-]?(KEY|TOKEN|SECRET|COOKIE))/i;
@@ -21529,7 +21532,12 @@ function writeDefaultLog(line) {
2152921532
try {
2153021533
mkdirSync(path2.dirname(logFile), { recursive: true });
2153121534
try {
21532-
if (statSync2(logFile).size > logFileMaxBytes()) renameSync(logFile, `${logFile}.1`);
21535+
if (statSync2(logFile).size > logFileMaxBytes()) {
21536+
chmodSync(logFile, 384);
21537+
const rotated = `${logFile}.1`;
21538+
renameSync(logFile, rotated);
21539+
chmodSync(rotated, 384);
21540+
}
2153321541
} catch (error2) {
2153421542
if (error2?.code !== "ENOENT") {
2153521543
lastLogFileError = error2 instanceof Error ? error2.message : String(error2);
@@ -21727,11 +21735,6 @@ function codexSubagentConfigOverrides(definitions = []) {
2172721735
definition.reasoningEffort
2172821736
);
2172921737
appendConfigOverride(overrides, `${prefix}.sandbox_mode`, definition.sandbox);
21730-
appendConfigOverride(overrides, `${prefix}.mcp_servers`, definition.mcpServers);
21731-
appendConfigOverride(overrides, `${prefix}.skills.config`, definition.skillsConfig);
21732-
for (const [key, value] of Object.entries(definition.extraConfig ?? {})) {
21733-
appendConfigOverride(overrides, `${prefix}.${tomlPathSegment(key)}`, value);
21734-
}
2173521738
}
2173621739
return overrides;
2173721740
}
@@ -22770,6 +22773,8 @@ function recordDiagnosticEvent(event, env = process.env) {
2277022773
id: makeId(),
2277122774
ts: (/* @__PURE__ */ new Date()).toISOString(),
2277222775
...event,
22776+
message: redactSensitiveText(event.message),
22777+
recovery: event.recovery === void 0 ? void 0 : redactJsonValue(event.recovery),
2277322778
detail: event.detail === void 0 ? void 0 : redactJsonValue(event.detail)
2277422779
};
2277522780
events.push(entry);
@@ -23584,11 +23589,29 @@ function compactJobSnapshotForMcp(job) {
2358423589
partial: isPartial(job.partial) ? compactPartialForMcp(job.partial) : compactUnknown(job.partial, 2e3)
2358523590
};
2358623591
}
23592+
function compactSessionTurn(value) {
23593+
if (!value || typeof value !== "object") return value;
23594+
const turn = value;
23595+
if (typeof turn.prompt !== "string") return value;
23596+
const prompt = truncateString(turn.prompt, 2e3);
23597+
return {
23598+
...turn,
23599+
prompt: prompt.text,
23600+
promptOmittedChars: prompt.omittedChars || void 0
23601+
};
23602+
}
23603+
function compactSessionTurns(value) {
23604+
if (!Array.isArray(value)) return value;
23605+
return value.slice(0, 20).map(compactSessionTurn);
23606+
}
2358723607
function compactSessionSnapshotForMcp(session) {
2358823608
return {
2358923609
...session,
2359023610
lastResult: compactRunValue(session.lastResult),
23591-
partial: isPartial(session.partial) ? compactPartialForMcp(session.partial) : compactUnknown(session.partial, 2e3)
23611+
partial: isPartial(session.partial) ? compactPartialForMcp(session.partial) : compactUnknown(session.partial, 2e3),
23612+
activeTurn: compactSessionTurn(session.activeTurn),
23613+
queuedTurns: compactSessionTurns(session.queuedTurns),
23614+
recentTurns: compactSessionTurns(session.recentTurns)
2359223615
};
2359323616
}
2359423617
function compactRunValue(value) {
@@ -23916,12 +23939,18 @@ var CodexAppServerSession = class _CodexAppServerSession {
2391623939
};
2391723940
}
2391823941
async readThread(includeTurns = false) {
23919-
const response = await this.request("thread/read", {
23920-
threadId: this.threadId,
23921-
includeTurns
23922-
}, 1e4);
23923-
this.capabilities.threadRead = true;
23924-
return response;
23942+
try {
23943+
const response = await this.request("thread/read", {
23944+
threadId: this.threadId,
23945+
includeTurns
23946+
}, 1e4);
23947+
this.capabilities.threadRead = true;
23948+
return response;
23949+
} catch (error2) {
23950+
this.capabilities.threadRead = false;
23951+
this.lastError = error2 instanceof Error ? error2.message : String(error2);
23952+
throw error2;
23953+
}
2392523954
}
2392623955
async startTurn(options, abortSignal, onSnapshot, turnLogContext = {}) {
2392723956
if (this.activeTurn) throw new Error(`Codex app-server already has an active turn: ${this.activeTurn.turnId}`);
@@ -24517,7 +24546,7 @@ var CodexAppServerSession = class _CodexAppServerSession {
2451724546
};
2451824547

2451924548
// src/session-state.ts
24520-
import { mkdirSync as mkdirSync3, readFileSync, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
24549+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
2452124550
import os6 from "node:os";
2452224551
import path7 from "node:path";
2452324552
function defaultSessionStateFile(env = process.env) {
@@ -24553,8 +24582,9 @@ var SessionStateStore = class {
2455324582
sessions: merged
2455424583
};
2455524584
writeFileSync2(temp, `${JSON.stringify(payload, null, 2)}
24556-
`, "utf8");
24585+
`, { encoding: "utf8", mode: 384 });
2455724586
renameSync2(temp, this.file);
24587+
chmodSync2(this.file, 384);
2455824588
}
2455924589
};
2456024590
function isDurableSessionState(value) {
@@ -24765,6 +24795,7 @@ var CodexSessionManager = class {
2476524795
recentTurns: [],
2476624796
draining: false,
2476724797
cancelRequested: false,
24798+
runtimeShutdownRecoverable: false,
2476824799
waiters: /* @__PURE__ */ new Set(),
2476924800
persisted: Boolean(this.stateStore),
2477024801
recovered: false,
@@ -24867,8 +24898,9 @@ var CodexSessionManager = class {
2486724898
turn.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2486824899
this.notifyTurn(turn);
2486924900
this.notifySession(session);
24870-
if (options.wait && session.activeTurn) {
24871-
const completed = await this.waitForTurn(session, session.activeTurn, options.waitSignal);
24901+
const activeTurn = session.activeTurn;
24902+
if (options.wait && activeTurn) {
24903+
const completed = await this.waitForTurn(session, activeTurn, options.waitSignal);
2487224904
if (!completed) {
2487324905
return {
2487424906
session: snapshot2(session),
@@ -24881,6 +24913,7 @@ var CodexSessionManager = class {
2488124913
return {
2488224914
session: snapshot2(session),
2488324915
turn: turnSnapshot(turn),
24916+
result: activeTurn?.result,
2488424917
delivery: "delivered_to_active_turn"
2488524918
};
2488624919
}
@@ -25024,7 +25057,21 @@ var CodexSessionManager = class {
2502425057
ephemeral: false
2502525058
});
2502625059
} else {
25027-
await session.appServer.readThread(false);
25060+
try {
25061+
await session.appServer.readThread(false);
25062+
} catch (error2) {
25063+
logger.warn("session.recover_thread_read_unavailable", {
25064+
sessionId: session.id,
25065+
error: errorForLog(error2)
25066+
});
25067+
recordDiagnosticEvent({
25068+
severity: "warn",
25069+
source: "session.recover",
25070+
message: error2 instanceof Error ? error2.message : String(error2),
25071+
sessionId: session.id,
25072+
detail: { protocol: session.protocol, codexThreadId: session.codexThreadId }
25073+
});
25074+
}
2502825075
}
2502925076
session.status = session.status === "failed" ? "active" : session.status;
2503025077
session.recovered = true;
@@ -25051,7 +25098,9 @@ var CodexSessionManager = class {
2505125098
const snapshots = [];
2505225099
const now = (/* @__PURE__ */ new Date()).toISOString();
2505325100
for (const session of this.sessions.values()) {
25054-
session.cancelRequested = true;
25101+
const recoverable = Boolean(session.codexThreadId) && !session.cancelRequested && session.status !== "cancelled";
25102+
session.runtimeShutdownRecoverable = recoverable;
25103+
session.cancelRequested = !recoverable;
2505525104
for (const turn of session.queuedTurns) {
2505625105
turn.status = "cancelled";
2505725106
turn.updatedAt = now;
@@ -25065,7 +25114,7 @@ var CodexSessionManager = class {
2506525114
session.activeTurn.error = `Session was cancelled during ${reason}.`;
2506625115
this.notifyTurn(session.activeTurn);
2506725116
}
25068-
session.status = "cancelled";
25117+
session.status = recoverable ? "active" : "cancelled";
2506925118
session.updatedAt = now;
2507025119
if (session.controller) session.controller.abort();
2507125120
if (session.appServer) closePromises.push(session.appServer.close("cancelled").catch(() => {
@@ -25074,6 +25123,12 @@ var CodexSessionManager = class {
2507425123
snapshots.push(snapshot2(session));
2507525124
}
2507625125
await Promise.allSettled(closePromises);
25126+
for (const session of this.sessions.values()) {
25127+
if (!session.runtimeShutdownRecoverable || !session.codexThreadId) continue;
25128+
session.cancelRequested = false;
25129+
session.status = "active";
25130+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25131+
}
2507725132
this.persist();
2507825133
return snapshots;
2507925134
}
@@ -25188,7 +25243,7 @@ var CodexSessionManager = class {
2518825243
this.completeTurn(session, turn, result);
2518925244
return result;
2519025245
} catch (error2) {
25191-
session.status = controller.signal.aborted ? "cancelled" : "failed";
25246+
session.status = controller.signal.aborted && session.runtimeShutdownRecoverable && session.codexThreadId ? "active" : controller.signal.aborted ? "cancelled" : "failed";
2519225247
session.error = error2 instanceof Error ? error2.message : String(error2);
2519325248
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2519425249
turn.status = controller.signal.aborted ? "cancelled" : "failed";
@@ -25315,7 +25370,7 @@ var CodexSessionManager = class {
2531525370
turn.resultStatus = result.status;
2531625371
turn.status = result.ok ? "completed" : result.status === "cancelled" ? "cancelled" : "failed";
2531725372
turn.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25318-
session.status = result.ok ? "active" : result.status === "cancelled" && session.queuedTurns.length > 0 && !session.cancelRequested ? "running" : result.status === "cancelled" ? "cancelled" : "failed";
25373+
session.status = result.ok ? "active" : result.status === "cancelled" && session.runtimeShutdownRecoverable && session.codexThreadId ? "active" : result.status === "cancelled" && session.queuedTurns.length > 0 && !session.cancelRequested ? "running" : result.status === "cancelled" ? "cancelled" : "failed";
2531925374
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2532025375
logger.rawInfo("session.turn.finish", {
2532125376
session: summarizeRawTrafficForLog(snapshot2(session)),
@@ -25442,6 +25497,7 @@ var CodexSessionManager = class {
2544225497
recentTurns: [],
2544325498
draining: false,
2544425499
cancelRequested: state.status === "cancelled",
25500+
runtimeShutdownRecoverable: false,
2544525501
waiters: /* @__PURE__ */ new Set(),
2544625502
persisted: true,
2544725503
recovered: true,
@@ -25694,8 +25750,8 @@ function jsonResult(value, isError = false) {
2569425750
function errorResult(error2, context = "tool_call") {
2569525751
return jsonResult(
2569625752
{
25697-
error: error2 instanceof Error ? error2.message : String(error2),
25698-
recovery: recoveryForError(error2, context)
25753+
error: redactSensitiveText(error2 instanceof Error ? error2.message : String(error2)),
25754+
recovery: redactJsonValue(recoveryForError(error2, context))
2569925755
},
2570025756
true
2570125757
);
@@ -25717,6 +25773,13 @@ function withRequestAbort(options, extra) {
2571725773
function requestCancelledError() {
2571825774
return new Error("MCP request was cancelled by the client.");
2571925775
}
25776+
function ephemeralJobDurability() {
25777+
return {
25778+
durable: false,
25779+
survivesRestart: false,
25780+
recommendation: "Use start_codex_session_async when Claude needs recoverable long-running Codex work across MCP restarts."
25781+
};
25782+
}
2572025783
function throwIfRequestAborted(extra) {
2572125784
if (extra?.signal?.aborted) throw requestCancelledError();
2572225785
}
@@ -26090,7 +26153,7 @@ server.registerTool(
2609026153
"run_agent",
2609126154
{
2609226155
title: "Run one Codex agent",
26093-
description: "Launch one OpenAI Codex agent via codex exec. Use automatically when the user asks Claude to use Codex, ask Codex, get a Codex second opinion, run a Codex subagent, use Codex Spark, or delegate one read-only analysis task. Defaults to the Codex desktop app binary when installed, read-only sandbox, Codex's normal service tier, and non-interactive approvals. For explicit non-sandbox/full-access requests, set dangerously_bypass_approvals_and_sandbox true.",
26156+
description: "Compatibility/manual tool for launching one OpenAI Codex agent via codex exec. Prefer ask_codex for normal Claude delegation. Defaults to the Codex desktop app binary when installed, read-only sandbox, Codex's normal service tier, and non-interactive approvals. For explicit non-sandbox/full-access requests, set dangerously_bypass_approvals_and_sandbox true.",
2609426157
inputSchema: {
2609526158
prompt: external_exports.string().min(1).describe(
2609626159
"Concrete instructions for the Codex agent. Include scope, read-only expectation, desired output shape, and file/line reference requirements when reviewing code."
@@ -26144,7 +26207,7 @@ server.registerTool(
2614426207
const job = jobManager.startAgent(toRunOptions(args));
2614526208
await progress.send(`Started Codex job ${job.id}`);
2614626209
await progress.flush();
26147-
return jsonResult({ job });
26210+
return jsonResult({ job, durability: ephemeralJobDurability() });
2614826211
} catch (error2) {
2614926212
await progress.flush();
2615026213
logger.error("start_agent_run.failed", { error: errorForLog(error2) });
@@ -26286,7 +26349,7 @@ server.registerTool(
2628626349
"run_agents",
2628726350
{
2628826351
title: "Run parallel Codex agents",
26289-
description: "Launch multiple independent OpenAI Codex agents concurrently and return one structured result per agent. Use automatically when the user asks for parallel Codex agents, multiple Codex subagents, broad review by independent agents, or several concurrent Codex workstreams. Split work by clear ownership, pass project_dir, keep defaults read-only, and use max_parallel to bound concurrency. For explicit non-sandbox/full-access requests, set dangerously_bypass_approvals_and_sandbox true.",
26352+
description: "Compatibility/manual tool for launching multiple independent OpenAI Codex agents concurrently. Prefer ask_codex_parallel for normal Claude delegation. Split work by clear ownership, pass project_dir, keep defaults read-only, and use max_parallel to bound concurrency. For explicit non-sandbox/full-access requests, set dangerously_bypass_approvals_and_sandbox true.",
2629026353
inputSchema: {
2629126354
agents: external_exports.array(parallelAgentSchema).min(1).max(12).describe(
2629226355
"Independent Codex agent tasks. Use names like api, tests, security, docs, performance, or ui when helpful."
@@ -26415,7 +26478,7 @@ server.registerTool(
2641526478
const job = jobManager.startAgents(toParallelRunOptions(args));
2641626479
await progress.send(`Started Codex job ${job.id}`);
2641726480
await progress.flush();
26418-
return jsonResult({ job });
26481+
return jsonResult({ job, durability: ephemeralJobDurability() });
2641926482
} catch (error2) {
2642026483
await progress.flush();
2642126484
logger.error("start_agents_run.failed", { error: errorForLog(error2) });

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"test:progress": "node test/progress-mcp.mjs",
3232
"test:advanced": "node test/advanced-mcp.mjs",
3333
"test:dev-link": "node test/dev-link.mjs",
34+
"test:plugin-manifest": "node test/plugin-manifest.mjs",
3435
"test:codex-runtime": "node test/codex-runtime-probe.mjs",
3536
"test:app-server-contract": "node test/app-server-protocol-contract.mjs",
3637
"test:real-matrix": "node test/real-compatibility-matrix.mjs",
@@ -42,7 +43,7 @@
4243
"test:claude-real-codex": "node test/claude-real-codex.mjs",
4344
"test:claude-real-session": "node test/claude-real-session.mjs",
4445
"test:real-soak": "node test/real-soak.mjs",
45-
"test:ci": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced && npm run test:dev-link",
46+
"test:ci": "npm run build && npm run test:plugin-manifest && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced && npm run test:dev-link",
4647
"test:comprehensive": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced && npm run test:codex-runtime && npm run test:app-server-contract && npm run test:real-matrix && npm run validate:plugin && npm run test:claude-desktop",
4748
"test:claude-desktop": "node test/claude-desktop-cli.mjs"
4849
},

src/app-server.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -391,12 +391,18 @@ export class CodexAppServerSession {
391391
}
392392

393393
async readThread(includeTurns = false): Promise<unknown> {
394-
const response = await this.request("thread/read", {
395-
threadId: this.threadId,
396-
includeTurns,
397-
}, 10_000);
398-
this.capabilities.threadRead = true;
399-
return response;
394+
try {
395+
const response = await this.request("thread/read", {
396+
threadId: this.threadId,
397+
includeTurns,
398+
}, 10_000);
399+
this.capabilities.threadRead = true;
400+
return response;
401+
} catch (error) {
402+
this.capabilities.threadRead = false;
403+
this.lastError = error instanceof Error ? error.message : String(error);
404+
throw error;
405+
}
400406
}
401407

402408
async startTurn(

src/diagnostics.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, open, writeFile } from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { loggingDiagnostics } from "./logging.js";
5-
import { redactJsonValue } from "./redaction.js";
5+
import { redactJsonValue, redactSensitiveText } from "./redaction.js";
66

77
export type DiagnosticSeverity = "info" | "warn" | "error";
88

@@ -41,6 +41,8 @@ export function recordDiagnosticEvent(
4141
id: makeId(),
4242
ts: new Date().toISOString(),
4343
...event,
44+
message: redactSensitiveText(event.message),
45+
recovery: event.recovery === undefined ? undefined : redactJsonValue(event.recovery),
4446
detail: event.detail === undefined ? undefined : redactJsonValue(event.detail),
4547
};
4648
events.push(entry);

0 commit comments

Comments
 (0)