Skip to content

Commit 8219dc5

Browse files
xuiocodex
andcommitted
Bound diagnostics and job retention
Add timeouts for Codex version probes and bounds for unterminated child output lines. Make debug bundle log tails opt-in and bounded, add key-aware redaction for production logs, and cap async job retention. Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 8da399b commit 8219dc5

16 files changed

Lines changed: 460 additions & 60 deletions

dist/index.js

Lines changed: 147 additions & 32 deletions
Large diffs are not rendered by default.

src/app-server.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { OutputArtifactWriter } from "./artifacts.js";
2525
import { recordDiagnosticEvent } from "./diagnostics.js";
2626

2727
type JsonObject = Record<string, unknown>;
28+
29+
const maxPendingJsonLineChars = 1_000_000;
2830
type AppServerRequestMethod =
2931
| "initialize"
3032
| "thread/start"
@@ -213,6 +215,7 @@ export class CodexAppServerSession {
213215
private readonly closedPromise: Promise<void>;
214216
private resolveClosed: (() => void) | undefined;
215217
private lineBuffer = "";
218+
private lineBufferOverflowReported = false;
216219
private requestCounter = 0;
217220
private activeTurn?: ActiveTurnState;
218221
private acceptingStartNotifications = false;
@@ -719,11 +722,29 @@ export class CodexAppServerSession {
719722
chunk: summarizeRawTrafficForLog(chunk),
720723
});
721724
this.lineBuffer += chunk;
725+
if (this.lineBuffer.length > maxPendingJsonLineChars) {
726+
const dropped = this.lineBuffer.length - maxPendingJsonLineChars;
727+
this.lineBuffer = this.lineBuffer.slice(-maxPendingJsonLineChars);
728+
if (!this.lineBufferOverflowReported) {
729+
this.lineBufferOverflowReported = true;
730+
const error = `Codex app-server stdout JSON line exceeded ${maxPendingJsonLineChars} chars; dropped leading data from an unterminated line.`;
731+
logger.warn("codex.app_server.stdout_line_oversized", {
732+
...this.logContext,
733+
appServerId: this.id,
734+
threadId: this.threadId || undefined,
735+
activeTurnId: this.activeTurnId,
736+
droppedChars: dropped,
737+
maxPendingJsonLineChars,
738+
});
739+
this.recordBufferedLineError(error);
740+
}
741+
}
722742
let newlineIndex = this.lineBuffer.indexOf("\n");
723743
while (newlineIndex >= 0) {
724744
const line = this.lineBuffer.slice(0, newlineIndex);
725745
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
726746
this.handleLine(line);
747+
this.lineBufferOverflowReported = false;
727748
newlineIndex = this.lineBuffer.indexOf("\n");
728749
}
729750
}
@@ -982,6 +1003,19 @@ export class CodexAppServerSession {
9821003
this.activeTurn?.publishSnapshot(true);
9831004
}
9841005

1006+
private recordBufferedLineError(error: string): void {
1007+
this.lastError = error;
1008+
if (this.activeTurn) {
1009+
this.activeTurn.summary.errors.push(error);
1010+
this.activeTurn.publishSnapshot(true);
1011+
} else if (this.acceptingStartNotifications) {
1012+
this.queuePendingStartNotification({
1013+
method: "internal/unparseableLine",
1014+
params: { error, line: "" },
1015+
});
1016+
}
1017+
}
1018+
9851019
private async probeThreadRead(timeoutMs: number): Promise<void> {
9861020
try {
9871021
await this.request("thread/read", {

src/binary.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { accessSync, constants } from "node:fs";
1+
import { accessSync, constants, statSync } from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
44

@@ -31,6 +31,7 @@ export function cleanOption(value: string | undefined): string | undefined {
3131

3232
export function isExecutable(candidate: string): boolean {
3333
try {
34+
if (!statSync(candidate).isFile()) return false;
3435
accessSync(candidate, constants.X_OK);
3536
return true;
3637
} catch {

src/diagnostics.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
1+
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";
@@ -62,11 +62,18 @@ export function diagnosticStats(): Record<string, unknown> {
6262
}
6363

6464
async function tailFile(file: string, maxBytes = 200_000): Promise<string | undefined> {
65+
let handle: Awaited<ReturnType<typeof open>> | undefined;
6566
try {
66-
const text = await readFile(file, "utf8");
67-
return text.length <= maxBytes ? text : text.slice(text.length - maxBytes);
67+
handle = await open(file, "r");
68+
const stat = await handle.stat();
69+
const length = Math.min(stat.size, maxBytes);
70+
const buffer = Buffer.alloc(length);
71+
await handle.read(buffer, 0, length, stat.size - length);
72+
return buffer.toString("utf8");
6873
} catch {
6974
return undefined;
75+
} finally {
76+
await handle?.close().catch(() => {});
7077
}
7178
}
7279

@@ -76,6 +83,7 @@ export async function createDebugBundle(input: {
7683
status?: unknown;
7784
notes?: string[];
7885
env?: NodeJS.ProcessEnv;
86+
includeLogTail?: boolean;
7987
} = {}): Promise<{ bundleDir: string; diagnosticsPath: string }> {
8088
const env = input.env ?? process.env;
8189
const base = path.resolve(env.CODEX_SUBAGENTS_DEBUG_BUNDLE_DIR?.trim() || os.tmpdir());
@@ -101,7 +109,7 @@ export async function createDebugBundle(input: {
101109
session: input.session,
102110
job: input.job,
103111
notes: input.notes,
104-
logTail: logFile ? await tailFile(logFile) : undefined,
112+
logTail: input.includeLogTail && logFile ? await tailFile(logFile) : undefined,
105113
});
106114
const diagnosticsPath = path.join(bundleDir, "diagnostics.json");
107115
await writeFile(diagnosticsPath, JSON.stringify(payload, null, 2), "utf8");

src/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
probeCodexVersion,
1212
reasoningEfforts,
1313
reasoningSummaries,
14+
resolveWorkingDirectory,
1415
sandboxModes,
1516
serviceTiers,
1617
} from "./runner.js";
@@ -2020,6 +2021,10 @@ server.registerTool(
20202021
session_id: sessionIdSchema.optional(),
20212022
job_id: jobIdSchema.optional(),
20222023
include_all_sessions: z.boolean().default(false),
2024+
include_log_tail: z
2025+
.boolean()
2026+
.default(false)
2027+
.describe("Include a bounded tail of CODEX_SUBAGENTS_LOG_FILE in the bundle. This may contain raw MCP traffic."),
20232028
},
20242029
},
20252030
async (args, extra) =>
@@ -2048,8 +2053,11 @@ server.registerTool(
20482053
},
20492054
notes: [
20502055
"The bundle intentionally records environment key names, not environment values.",
2051-
"If CODEX_SUBAGENTS_LOG_FILE is configured, diagnostics.json includes a bounded tail of that log file.",
2056+
args.include_log_tail
2057+
? "A bounded CODEX_SUBAGENTS_LOG_FILE tail was included because include_log_tail was true."
2058+
: "The configured log file tail was not included; rerun with include_log_tail=true when raw MCP traffic is needed.",
20522059
],
2060+
includeLogTail: args.include_log_tail,
20532061
});
20542062
await progress.flush();
20552063
return jsonResult({
@@ -2158,10 +2166,10 @@ server.registerTool(
21582166
}
21592167

21602168
try {
2161-
const projectDir = args.project_dir ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
2169+
const projectDir = await resolveWorkingDirectory(args.project_dir);
21622170
checks.push({
21632171
name: "project_dir",
2164-
ok: Boolean(projectDir),
2172+
ok: true,
21652173
detail: { projectDir: cleanOption(projectDir) },
21662174
});
21672175
} catch (error) {

src/jobs.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ export async function runQueuedAgents(
349349
export class CodexJobManager {
350350
private readonly jobs = new Map<string, JobRecord>();
351351
private readonly ttlMs = readPositiveInt(process.env.CODEX_SUBAGENTS_JOB_TTL_SECONDS, 3600, 86_400) * 1000;
352+
private readonly maxJobs: number;
353+
354+
constructor(maxJobs = readPositiveInt(process.env.CODEX_SUBAGENTS_MAX_JOBS, 200, 10_000)) {
355+
this.maxJobs = maxJobs;
356+
}
352357

353358
startAgent(options: AgentRunOptions): JobSnapshot {
354359
return this.start("agent", async (job) => {
@@ -446,11 +451,12 @@ export class CodexJobManager {
446451
return snapshot(job);
447452
}
448453

449-
stats(): QueueStats & { jobs: number; waiters: number } {
454+
stats(): QueueStats & { jobs: number; maxJobs: number; waiters: number } {
450455
this.prune();
451456
return {
452457
...agentRunQueue.stats(),
453458
jobs: this.jobs.size,
459+
maxJobs: this.maxJobs,
454460
waiters: [...this.jobs.values()].reduce((count, job) => count + job.waiters.size, 0),
455461
};
456462
}
@@ -472,6 +478,13 @@ export class CodexJobManager {
472478

473479
private start(kind: JobKind, run: (job: JobRecord) => Promise<unknown>): JobSnapshot {
474480
this.prune();
481+
this.pruneOverflow();
482+
if (this.jobs.size >= this.maxJobs) {
483+
logger.warn("job.start_rejected_backpressure", { kind, jobs: this.jobs.size, maxJobs: this.maxJobs });
484+
throw new BackpressureError(
485+
`Codex async job table is full (${this.jobs.size}/${this.maxJobs}). Wait for or cancel existing jobs before starting another asynchronous run.`,
486+
);
487+
}
475488
const now = new Date().toISOString();
476489
const job: JobRecord = {
477490
id: `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
@@ -542,6 +555,17 @@ export class CodexJobManager {
542555
if (Date.parse(job.completedAt) < cutoff) this.jobs.delete(id);
543556
}
544557
}
558+
559+
private pruneOverflow(): void {
560+
if (this.jobs.size < this.maxJobs) return;
561+
const completed = [...this.jobs.entries()]
562+
.filter(([, job]) => Boolean(job.completedAt))
563+
.sort(([, left], [, right]) => Date.parse(left.completedAt ?? "") - Date.parse(right.completedAt ?? ""));
564+
for (const [id] of completed) {
565+
if (this.jobs.size < this.maxJobs) return;
566+
this.jobs.delete(id);
567+
}
568+
}
545569
}
546570

547571
export const jobManager = new CodexJobManager();

src/logging.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash, randomUUID } from "node:crypto";
2-
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2+
import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
33
import path from "node:path";
44
import { redactJsonValue, redactSensitiveText } from "./redaction.js";
55

@@ -20,6 +20,7 @@ const sensitiveKeyRe = /(api[_-]?key|token|secret|password|private[_-]?key|cooki
2020
let logWriter: (line: string) => void = (line) => {
2121
writeDefaultLog(line);
2222
};
23+
let lastLogFileError: string | undefined;
2324

2425
export function configuredLogProfile(env: NodeJS.ProcessEnv = process.env): LogProfile {
2526
const raw = env.CODEX_SUBAGENTS_LOG_PROFILE?.trim().toLowerCase();
@@ -184,6 +185,7 @@ export function setLogWriterForTest(writer: (line: string) => void): void {
184185
}
185186

186187
export function resetLogWriterForTest(): void {
188+
lastLogFileError = undefined;
187189
logWriter = (line) => {
188190
writeDefaultLog(line);
189191
};
@@ -198,6 +200,7 @@ export function loggingDiagnostics(env: NodeJS.ProcessEnv = process.env): Record
198200
maxStringChars: maxStringChars(env),
199201
logFile: logFile || undefined,
200202
logFileMaxBytes: logFile ? logFileMaxBytes(env) : undefined,
203+
logFileLastError: lastLogFileError,
201204
};
202205
}
203206

@@ -209,11 +212,17 @@ function writeDefaultLog(line: string): void {
209212
mkdirSync(path.dirname(logFile), { recursive: true });
210213
try {
211214
if (statSync(logFile).size > logFileMaxBytes()) renameSync(logFile, `${logFile}.1`);
212-
} catch {
215+
} catch (error) {
213216
// Missing files or rotation races are harmless.
217+
if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
218+
lastLogFileError = error instanceof Error ? error.message : String(error);
219+
}
214220
}
215-
appendFileSync(logFile, `${line}\n`, "utf8");
216-
} catch {
221+
appendFileSync(logFile, `${line}\n`, { encoding: "utf8", mode: 0o600 });
222+
chmodSync(logFile, 0o600);
223+
lastLogFileError = undefined;
224+
} catch (error) {
225+
lastLogFileError = error instanceof Error ? error.message : String(error);
217226
// Logging must never break MCP traffic or Codex execution.
218227
}
219228
}

src/redaction.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,20 @@ export function redactSensitiveText(text: string): string {
4848
return redacted;
4949
}
5050

51-
export function redactJsonValue<T>(value: T): T {
51+
export function isSensitiveKey(key: string): boolean {
52+
return SENSITIVE_ENV_KEY.test(key);
53+
}
54+
55+
export function redactJsonValue<T>(value: T, key = ""): T {
56+
if (key && isSensitiveKey(key)) return "[REDACTED]" as T;
5257
if (typeof value === "string") return redactSensitiveText(value) as T;
53-
if (Array.isArray(value)) return value.map((item) => redactJsonValue(item)) as T;
58+
if (Array.isArray(value)) return value.map((item) => redactJsonValue(item, key)) as T;
5459
if (!value || typeof value !== "object") return value;
5560

5661
return Object.fromEntries(
5762
Object.entries(value as Record<string, unknown>).map(([key, child]) => [
5863
key,
59-
redactJsonValue(child),
64+
redactJsonValue(child, key),
6065
]),
6166
) as T;
6267
}

src/response.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AgentRunPartial, AgentRunResult, CodexEventSummary } from "./runner.js";
2+
import { redactSensitiveText } from "./redaction.js";
23

34
interface TruncatedString {
45
text: string;
@@ -104,7 +105,9 @@ export function compactAgentResultForMcp(
104105
stdoutTail: stdoutTail.text,
105106
eventSummary: compactSummary(result.eventSummary, limits),
106107
structuredOutput: compactUnknown(result.structuredOutput, limits.structuredStringChars),
107-
commandPreview: result.commandPreview.slice(0, 40).map((arg) => truncateString(arg, 1_000).text),
108+
commandPreview: result.commandPreview
109+
.slice(0, 40)
110+
.map((arg) => truncateString(redactSensitiveText(arg), 1_000).text),
108111
mcpResponse: {
109112
compacted,
110113
finalMessageOmittedChars: finalMessage.omittedChars,

0 commit comments

Comments
 (0)