From 8d67ee112fb0ccebf8f559be0df08b3b5938b708 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 17:44:49 +0100 Subject: [PATCH] fix(codex): preserve app-server exit diagnostics --- .../codex/src/app-server/client.test.ts | 26 ++++++++++++- extensions/codex/src/app-server/client.ts | 39 ++++++++++++++----- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/extensions/codex/src/app-server/client.test.ts b/extensions/codex/src/app-server/client.test.ts index 7d00ce18fe2..d1572550f1b 100644 --- a/extensions/codex/src/app-server/client.test.ts +++ b/extensions/codex/src/app-server/client.test.ts @@ -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= ANTHROPIC_API_KEY='' 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=\\"\\" 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", ); }); diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index be2d27cf8e6..4034dc9ea39 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -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 { 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$3", + ) + .replace( + /\b([a-z0-9_]*(?:api_?key|authorization|access_token|refresh_token|token))(\s*=\s*)(["']?)[^\s"']+(\3)/gi, + "$1$2$3$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;