fix(codex): preserve app-server exit diagnostics

This commit is contained in:
Peter Steinberger
2026-05-02 17:44:49 +01:00
parent 4e312d9b0e
commit 8d67ee112f
2 changed files with 54 additions and 11 deletions

View File

@@ -69,6 +69,14 @@ describe("CodexAppServerClient", () => {
expect(JSON.stringify(warn.mock.calls)).not.toContain("secret-value");
});
it("redacts prefixed env credential names from app-server previews", () => {
expect(
__testing.redactCodexAppServerLinePreview(
"fatal OPENAI_API_KEY=sk-live ANTHROPIC_API_KEY='anthropic-secret' OTHER=value",
),
).toBe("fatal OPENAI_API_KEY=<redacted> ANTHROPIC_API_KEY='<redacted>' OTHER=value");
});
it("recovers app-server messages split by raw newlines inside JSON strings", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createClientHarness();
@@ -305,9 +313,23 @@ describe("CodexAppServerClient", () => {
// an unhandled exception tearing down the gateway.
await expect(pending).rejects.toThrow("write EPIPE");
// Subsequent requests are rejected immediately (client is closed).
// Subsequent requests keep the original close reason so startup logs stay actionable.
await expect(harness.client.request("another/method")).rejects.toThrow("write EPIPE");
});
it("preserves redacted app-server stderr on exit errors", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const pending = harness.client.request("test/method");
harness.process.stderr.write('fatal token="secret-value" while booting\n');
harness.process.emit("exit", 1, null);
await expect(pending).rejects.toThrow(
'codex app-server exited: code=1 signal=null stderr="fatal token=\\"<redacted>\\" while booting"',
);
await expect(harness.client.request("another/method")).rejects.toThrow(
"codex app-server client is closed",
"codex app-server exited: code=1 signal=null",
);
});

View File

@@ -28,6 +28,7 @@ const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 1_000_000;
const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000;
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 30_000;
const CODEX_APP_SERVER_STDERR_TAIL_MAX = 2_000;
type PendingRequest = {
method: string;
@@ -76,6 +77,8 @@ export class CodexAppServerClient {
private nextId = 1;
private initialized = false;
private closed = false;
private closeError: Error | undefined;
private stderrTail = "";
private pendingParse:
| {
text: string;
@@ -89,20 +92,18 @@ export class CodexAppServerClient {
this.lines = createInterface({ input: child.stdout });
this.lines.on("line", (line) => this.handleLine(line));
child.stderr.on("data", (chunk: Buffer | string) => {
const text = chunk.toString("utf8").trim();
if (text) {
embeddedAgentLog.debug(`codex app-server stderr: ${text}`);
const text = chunk.toString("utf8");
this.stderrTail = appendBoundedTail(this.stderrTail, text, CODEX_APP_SERVER_STDERR_TAIL_MAX);
const trimmed = text.trim();
if (trimmed) {
embeddedAgentLog.debug(`codex app-server stderr: ${trimmed}`);
}
});
child.once("error", (error) =>
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
);
child.once("exit", (code, signal) => {
this.closeWithError(
new Error(
`codex app-server exited: code=${formatExitValue(code)} signal=${formatExitValue(signal)}`,
),
);
this.closeWithError(buildCodexAppServerExitError(code, signal, this.stderrTail));
});
// Guard against unhandled EPIPE / write-after-close errors on the stdin
// stream. When the child process terminates abruptly the pipe can break
@@ -171,7 +172,7 @@ export class CodexAppServerClient {
): Promise<T> {
options ??= {};
if (this.closed) {
return Promise.reject(new Error("codex app-server client is closed"));
return Promise.reject(this.closeError ?? new Error("codex app-server client is closed"));
}
if (options.signal?.aborted) {
return Promise.reject(new Error(`${method} aborted`));
@@ -451,6 +452,7 @@ export class CodexAppServerClient {
return false;
}
this.closed = true;
this.closeError = error;
this.lines.close();
this.rejectPendingRequests(error);
return true;
@@ -596,12 +598,31 @@ function redactCodexAppServerLinePreview(value: string): string {
.replace(
/("(?:api_?key|authorization|token|access_token|refresh_token)"\s*:\s*")([^"]+)(")/gi,
"$1<redacted>$3",
)
.replace(
/\b([a-z0-9_]*(?:api_?key|authorization|access_token|refresh_token|token))(\s*=\s*)(["']?)[^\s"']+(\3)/gi,
"$1$2$3<redacted>$4",
);
return redacted.length > CODEX_APP_SERVER_PARSE_LOG_MAX
? `${redacted.slice(0, CODEX_APP_SERVER_PARSE_LOG_MAX)}...`
: redacted;
}
function appendBoundedTail(current: string, next: string, maxLength: number): string {
const combined = `${current}${next}`;
return combined.length > maxLength ? combined.slice(combined.length - maxLength) : combined;
}
function buildCodexAppServerExitError(code: unknown, signal: unknown, stderrTail: string): Error {
const stderrPreview = redactCodexAppServerLinePreview(stderrTail);
const suffix = stderrPreview ? ` stderr=${JSON.stringify(stderrPreview)}` : "";
return new Error(
`codex app-server exited: code=${formatExitValue(code)} signal=${formatExitValue(
signal,
)}${suffix}`,
);
}
function shouldBufferCodexAppServerParseFailure(value: string, error: unknown): boolean {
if (!value.startsWith("{") && !value.startsWith("[")) {
return false;