From 1532a7e72accf1290246bb5cbbf887b784341af2 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:27:37 +0000 Subject: [PATCH 1/2] Surface Codex app-server exit diagnostics Co-authored-by: Codex --- .../src/_internal/stdio.test.ts | 29 ++++- .../src/_internal/stdio.ts | 115 ++++++++++++++++-- .../src/client.test.ts | 63 ++++++---- .../effect-codex-app-server/src/client.ts | 12 +- .../effect-codex-app-server/src/errors.ts | 18 ++- .../fixtures/codex-app-server-mock-peer.ts | 7 ++ 6 files changed, 206 insertions(+), 38 deletions(-) diff --git a/packages/effect-codex-app-server/src/_internal/stdio.test.ts b/packages/effect-codex-app-server/src/_internal/stdio.test.ts index 6dbf4c857eb..fedad02bf51 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.test.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.test.ts @@ -1,10 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as PlatformError from "effect/PlatformError"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexError from "../errors.ts"; -import { makeTerminationError } from "./stdio.ts"; +import { makeStderrTailCapture, makeTerminationError } from "./stdio.ts"; + +const encoder = new TextEncoder(); describe("Codex App Server child process termination", () => { it.effect("retains the process identifier with the exit code", () => @@ -45,4 +48,28 @@ describe("Codex App Server child process termination", () => { assert.notInclude(error.message, rootCause.message); }), ); + + it.effect("adds the trimmed truncated stderr tail to process-exited errors", () => + Effect.gen(function* () { + const capture = yield* makeStderrTailCapture( + Stream.fromIterable([encoder.encode("prefix-Access is denied\n")]), + 17, + ); + yield* capture.drain; + const snapshot = yield* capture.snapshot; + const error = yield* makeTerminationError( + { + pid: ChildProcessSpawner.ProcessId(53), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }, + Effect.succeed(snapshot), + ); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.equal(error.stderrTail, "Access is denied"); + assert.equal(error.stderrTruncated, true); + assert.include(error.message, "recent stderr (last 4096 bytes, truncated)"); + assert.include(error.message, "Access is denied"); + }), + ); }); diff --git a/packages/effect-codex-app-server/src/_internal/stdio.ts b/packages/effect-codex-app-server/src/_internal/stdio.ts index 312022824cb..6ac6dfd101f 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; @@ -10,6 +11,90 @@ import * as CodexError from "../errors.ts"; const encoder = new TextEncoder(); +interface StderrTailState { + readonly bytes: Uint8Array; + readonly truncated: boolean; +} + +export interface StderrTailSnapshot { + readonly stderrTail: string; + readonly stderrTruncated: boolean; +} + +const emptyStderrTailState: StderrTailState = { + bytes: new Uint8Array(), + truncated: false, +}; + +const appendTailBytes = ( + state: StderrTailState, + chunk: Uint8Array, + byteLimit: number, +): StderrTailState => { + if (chunk.byteLength === 0) { + return state; + } + if (byteLimit <= 0) { + return { + bytes: new Uint8Array(), + truncated: true, + }; + } + if (chunk.byteLength >= byteLimit) { + return { + bytes: chunk.slice(chunk.byteLength - byteLimit), + truncated: true, + }; + } + + const combinedLength = state.bytes.byteLength + chunk.byteLength; + if (combinedLength <= byteLimit) { + const next = new Uint8Array(combinedLength); + next.set(state.bytes); + next.set(chunk, state.bytes.byteLength); + return { + bytes: next, + truncated: state.truncated, + }; + } + + const droppedBytes = combinedLength - byteLimit; + const keptPrevious = state.bytes.subarray(Math.min(droppedBytes, state.bytes.byteLength)); + const next = new Uint8Array(byteLimit); + next.set(keptPrevious); + next.set(chunk, keptPrevious.byteLength); + return { + bytes: next, + truncated: true, + }; +}; + +export const makeStderrTailCapture = Effect.fn("makeStderrTailCapture")(function* ( + stderr: Stream.Stream, + byteLimit = CodexError.CodexAppServerProcessStderrTailByteLimit, +) { + const decoder = new TextDecoder(); + const state = yield* Ref.make(emptyStderrTailState); + + return { + drain: stderr.pipe( + Stream.runForEach((chunk) => + Ref.update(state, (current) => appendTailBytes(current, chunk, byteLimit)), + ), + Effect.ignore, + ), + snapshot: Ref.get(state).pipe( + Effect.map((current): StderrTailSnapshot => { + const stderrTail = decoder.decode(current.bytes).trim(); + return { + stderrTail, + stderrTruncated: current.truncated, + }; + }), + ), + }; +}); + export const makeChildStdio = (handle: ChildProcessSpawner.ChildProcessHandle) => Stdio.make({ args: Effect.succeed([]), @@ -51,13 +136,27 @@ type ChildProcessTerminationHandle = Pick< export const makeTerminationError = ( handle: ChildProcessTerminationHandle, + stderrSnapshot?: Effect.Effect, ): Effect.Effect => - Effect.match(handle.exitCode, { - onFailure: (cause) => - new CodexError.CodexAppServerTransportError({ - operation: "read-process-exit-status", - pid: handle.pid, - cause, - }), - onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code, pid: handle.pid }), + Effect.gen(function* () { + const snapshot = stderrSnapshot ? yield* stderrSnapshot : undefined; + return yield* Effect.match(handle.exitCode, { + onFailure: (cause) => + new CodexError.CodexAppServerTransportError({ + operation: "read-process-exit-status", + pid: handle.pid, + cause, + }), + onSuccess: (code) => + new CodexError.CodexAppServerProcessExitedError({ + code, + pid: handle.pid, + ...(snapshot?.stderrTail + ? { + stderrTail: snapshot.stderrTail, + stderrTruncated: snapshot.stderrTruncated, + } + : {}), + }), + }); }); diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 3830c5fc5f6..9a3c68fac79 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -10,11 +10,23 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as CodexClient from "./client.ts"; +import * as CodexError from "./errors.ts"; const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/codex-app-server-mock-peer.ts"), ); const mockPeerArgs = (path: string) => [path]; +const initializeParams = { + clientInfo: { + name: "effect-codex-app-server-test", + title: "Effect Codex App Server Test", + version: "0.0.0", + }, + capabilities: { + experimentalApi: true, + optOutNotificationMethods: null, + }, +} as const; it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { const makeHandle = (env?: Record) => @@ -57,17 +69,7 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { Ref.update(messageDeltas, (current) => [...current, payload]), ); - const initialized = yield* client.request("initialize", { - clientInfo: { - name: "effect-codex-app-server-test", - title: "Effect Codex App Server Test", - version: "0.0.0", - }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: null, - }, - }); + const initialized = yield* client.request("initialize", initializeParams); assert.equal(initialized.userAgent, "mock-codex-app-server"); yield* client.notify("initialized", undefined); @@ -134,17 +136,7 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { const initialized = yield* Effect.gen(function* () { const client = yield* CodexClient.CodexAppServerClient; - return yield* client.request("initialize", { - clientInfo: { - name: "effect-codex-app-server-test", - title: "Effect Codex App Server Test", - version: "0.0.0", - }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: null, - }, - }); + return yield* client.request("initialize", initializeParams); }).pipe( Effect.timeout("5 seconds"), Effect.provide(context), @@ -154,4 +146,31 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { assert.equal(initialized.userAgent, "mock-codex-app-server"); }), ); + it.effect("includes child stderr tail when initialize exits before responding", () => + Effect.gen(function* () { + const handle = yield* makeHandle({ + CODEX_APP_SERVER_TEST_EXIT_WITH_STDERR: " \nAccess is denied\n ", + }); + const scope = yield* Scope.make(); + const clientLayer = CodexClient.layerChildProcess(handle); + const context = yield* Layer.buildWithScope(clientLayer, scope); + + const error = yield* Effect.gen(function* () { + const client = yield* CodexClient.CodexAppServerClient; + return yield* client.request("initialize", initializeParams).pipe(Effect.flip); + }).pipe( + Effect.timeout("5 seconds"), + Effect.provide(context), + Effect.ensuring(Scope.close(scope, Exit.void)), + ); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.equal(error.code, 1); + assert.equal(error.stderrTail, "Access is denied"); + assert.equal(error.stderrTruncated, false); + assert.include(error.message, "Codex App Server process exited with code 1"); + assert.include(error.message, "recent stderr (last 4096 bytes)"); + assert.include(error.message, "Access is denied"); + }), + ); }); diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index c0cb5b1dc23..cd3ff9c9054 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -4,7 +4,6 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stdio from "effect/Stdio"; -import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as CodexRpc from "./_generated/meta.gen.ts"; @@ -16,7 +15,7 @@ import { encodeOptionalPayload, runHandler, } from "./_internal/shared.ts"; -import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; +import { makeChildStdio, makeStderrTailCapture, makeTerminationError } from "./_internal/stdio.ts"; export interface CodexAppServerClientOptions { readonly logIncoming?: boolean; @@ -264,6 +263,11 @@ export const layerChildProcess = ( const makeChildProcessClient = Effect.fn( "effect-codex-app-server/CodexAppServerClient.makeChildProcessClient", )(function* (handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions) { - yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); - return yield* make(makeChildStdio(handle), options, makeTerminationError(handle)); + const stderrCapture = yield* makeStderrTailCapture(handle.stderr); + yield* stderrCapture.drain.pipe(Effect.forkScoped); + return yield* make( + makeChildStdio(handle), + options, + makeTerminationError(handle, stderrCapture.snapshot), + ); }); diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index f0e0945d352..c4d0b4ac320 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -142,18 +142,30 @@ export class CodexAppServerSpawnError extends Schema.TaggedErrorClass()( "CodexAppServerProcessExitedError", { code: Schema.optional(Schema.Number), pid: Schema.optionalKey(Schema.Int), + stderrTail: Schema.optionalKey(Schema.String), + stderrTruncated: Schema.optionalKey(Schema.Boolean), cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - return this.code === undefined - ? "Codex App Server process exited" - : `Codex App Server process exited with code ${this.code}`; + const base = + this.code === undefined + ? "Codex App Server process exited" + : `Codex App Server process exited with code ${this.code}`; + if (!this.stderrTail) { + return base; + } + const label = this.stderrTruncated + ? `recent stderr (last ${CodexAppServerProcessStderrTailByteLimit} bytes, truncated)` + : `recent stderr (last ${CodexAppServerProcessStderrTailByteLimit} bytes)`; + return `${base}\n\n${label}:\n${this.stderrTail}`; } } diff --git a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts index 3f2a213d38c..dcd02526f84 100644 --- a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts +++ b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts @@ -36,6 +36,13 @@ const handleMethod = (message: Record) => { switch (method) { case "initialize": { + const exitStderr = process.env.CODEX_APP_SERVER_TEST_EXIT_WITH_STDERR; + if (exitStderr !== undefined) { + process.stderr.write(exitStderr, () => { + process.exit(1); + }); + return; + } // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone mock peer process has no Effect runtime. const platform = NodeOS.platform(); const stderrBytes = Number(process.env.CODEX_APP_SERVER_TEST_STDERR_BYTES ?? 0); From 711ef97704eb2a6a6da1c5494c73077c8919da71 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:46:04 +0000 Subject: [PATCH 2/2] Address Codex app-server exit diagnostic review Co-authored-by: Codex --- .../src/_internal/stdio.test.ts | 73 +++++++++++++++++- .../src/_internal/stdio.ts | 75 +++++++++++++------ .../src/client.test.ts | 29 +++++++ .../effect-codex-app-server/src/client.ts | 8 +- .../fixtures/codex-app-server-mock-peer.ts | 16 ++++ 5 files changed, 177 insertions(+), 24 deletions(-) diff --git a/packages/effect-codex-app-server/src/_internal/stdio.test.ts b/packages/effect-codex-app-server/src/_internal/stdio.test.ts index fedad02bf51..34db1b8f4e2 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.test.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -62,7 +63,7 @@ describe("Codex App Server child process termination", () => { pid: ChildProcessSpawner.ProcessId(53), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), }, - Effect.succeed(snapshot), + { snapshot: Effect.succeed(snapshot) }, ); assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); @@ -72,4 +73,74 @@ describe("Codex App Server child process termination", () => { assert.include(error.message, "Access is denied"); }), ); + + it.effect("does not mark an exact-limit first stderr chunk as truncated", () => + Effect.gen(function* () { + const capture = yield* makeStderrTailCapture( + Stream.fromIterable([encoder.encode("abcde")]), + 5, + ); + + yield* capture.drain; + + const snapshot = yield* capture.snapshot; + assert.equal(snapshot.stderrTail, "abcde"); + assert.equal(snapshot.stderrTruncated, false); + }), + ); + + it.effect("redacts secret-shaped stderr before building process-exited messages", () => + Effect.gen(function* () { + const apiKey = "sk-proj-privateDiagnosticSecret1234567890"; + const bearerToken = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJwcml2YXRlIn0.signaturePart123"; + const capture = yield* makeStderrTailCapture( + Stream.fromIterable([ + encoder.encode( + `OPENAI_API_KEY=${apiKey}\nAuthorization: Bearer ${bearerToken}\nAccess is denied\n`, + ), + ]), + ); + + yield* capture.drain; + const snapshot = yield* capture.snapshot; + const error = yield* makeTerminationError( + { + pid: ChildProcessSpawner.ProcessId(54), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }, + { snapshot: Effect.succeed(snapshot) }, + ); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.notInclude(error.stderrTail ?? "", apiKey); + assert.notInclude(error.stderrTail ?? "", bearerToken); + assert.notInclude(error.message, apiKey); + assert.notInclude(error.message, bearerToken); + assert.include(error.message, "[REDACTED]"); + assert.include(error.message, "Access is denied"); + }), + ); + + it.effect("snapshots stderr after exit status and a bounded drain opportunity", () => + Effect.gen(function* () { + const stderrTail = yield* Ref.make("before-exit"); + const error = yield* makeTerminationError( + { + pid: ChildProcessSpawner.ProcessId(55), + exitCode: Ref.set(stderrTail, "after-exit").pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }, + { + awaitDrain: Ref.update(stderrTail, (current) => `${current}-after-drain`), + snapshot: Ref.get(stderrTail).pipe( + Effect.map((stderrTail) => ({ stderrTail, stderrTruncated: false })), + ), + }, + ); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.equal(error.stderrTail, "after-exit-after-drain"); + }), + ); }); diff --git a/packages/effect-codex-app-server/src/_internal/stdio.ts b/packages/effect-codex-app-server/src/_internal/stdio.ts index 6ac6dfd101f..6558272bafb 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.ts @@ -10,6 +10,14 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexError from "../errors.ts"; const encoder = new TextEncoder(); +const RedactedDiagnosticValue = "[REDACTED]"; +const StderrDrainGracePeriod = "50 millis"; +const sensitiveKeyValuePattern = + /((?:^|[^A-Za-z0-9_-])(?:[A-Za-z0-9_.-]*(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|bearer[_-]?token|token|secret|password|passwd|private[_-]?key|credential)[A-Za-z0-9_.-]*)["']?\s*[:=]\s*["']?)([^\s"',;}\]]+)/giu; +const bearerCredentialPattern = /\b(Bearer|Basic)\s+([A-Za-z0-9._~+/=-]{8,})/giu; +const openAiSecretPattern = /\bsk-(?:proj-)?[A-Za-z0-9_-]{8,}\b/gu; +const jwtPattern = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu; +const urlCredentialPattern = /\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)([^@\s/]+)@/gu; interface StderrTailState { readonly bytes: Uint8Array; @@ -21,11 +29,24 @@ export interface StderrTailSnapshot { readonly stderrTruncated: boolean; } +export interface StderrTailDiagnostics { + readonly snapshot: Effect.Effect; + readonly awaitDrain?: Effect.Effect; +} + const emptyStderrTailState: StderrTailState = { bytes: new Uint8Array(), truncated: false, }; +const redactSensitiveStderr = (stderr: string): string => + stderr + .replace(urlCredentialPattern, `$1${RedactedDiagnosticValue}@`) + .replace(bearerCredentialPattern, `$1 ${RedactedDiagnosticValue}`) + .replace(sensitiveKeyValuePattern, `$1${RedactedDiagnosticValue}`) + .replace(openAiSecretPattern, RedactedDiagnosticValue) + .replace(jwtPattern, RedactedDiagnosticValue); + const appendTailBytes = ( state: StderrTailState, chunk: Uint8Array, @@ -43,7 +64,7 @@ const appendTailBytes = ( if (chunk.byteLength >= byteLimit) { return { bytes: chunk.slice(chunk.byteLength - byteLimit), - truncated: true, + truncated: state.truncated || state.bytes.byteLength > 0 || chunk.byteLength > byteLimit, }; } @@ -85,7 +106,7 @@ export const makeStderrTailCapture = Effect.fn("makeStderrTailCapture")(function ), snapshot: Ref.get(state).pipe( Effect.map((current): StderrTailSnapshot => { - const stderrTail = decoder.decode(current.bytes).trim(); + const stderrTail = redactSensitiveStderr(decoder.decode(current.bytes)).trim(); return { stderrTail, stderrTruncated: current.truncated, @@ -136,27 +157,39 @@ type ChildProcessTerminationHandle = Pick< export const makeTerminationError = ( handle: ChildProcessTerminationHandle, - stderrSnapshot?: Effect.Effect, + stderrDiagnostics?: StderrTailDiagnostics, ): Effect.Effect => Effect.gen(function* () { - const snapshot = stderrSnapshot ? yield* stderrSnapshot : undefined; - return yield* Effect.match(handle.exitCode, { + const exitStatus = yield* Effect.match(handle.exitCode, { onFailure: (cause) => - new CodexError.CodexAppServerTransportError({ - operation: "read-process-exit-status", - pid: handle.pid, - cause, - }), - onSuccess: (code) => - new CodexError.CodexAppServerProcessExitedError({ - code, - pid: handle.pid, - ...(snapshot?.stderrTail - ? { - stderrTail: snapshot.stderrTail, - stderrTruncated: snapshot.stderrTruncated, - } - : {}), - }), + ({ + _tag: "failure" as const, + error: new CodexError.CodexAppServerTransportError({ + operation: "read-process-exit-status", + pid: handle.pid, + cause, + }), + }) as const, + onSuccess: (code) => ({ _tag: "success" as const, code }) as const, + }); + if (exitStatus._tag === "failure") { + return exitStatus.error; + } + if (stderrDiagnostics?.awaitDrain) { + yield* stderrDiagnostics.awaitDrain.pipe( + Effect.timeoutOption(StderrDrainGracePeriod), + Effect.ignore, + ); + } + const snapshot = stderrDiagnostics ? yield* stderrDiagnostics.snapshot : undefined; + return new CodexError.CodexAppServerProcessExitedError({ + code: exitStatus.code, + pid: handle.pid, + ...(snapshot?.stderrTail + ? { + stderrTail: snapshot.stderrTail, + stderrTruncated: snapshot.stderrTruncated, + } + : {}), }); }); diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 9a3c68fac79..d5019789e87 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -173,4 +173,33 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { assert.include(error.message, "Access is denied"); }), ); + it.effect("includes redacted delayed multi-chunk stderr when initialize exits", () => + Effect.gen(function* () { + const secret = "sk-proj-clientDiagnosticSecret1234567890"; + const handle = yield* makeHandle({ + CODEX_APP_SERVER_TEST_EXIT_WITH_STDERR_CHUNKS: [ + `OPENAI_API_KEY=${secret}\n`, + "Delayed access failure\n", + ].join("|"), + }); + const scope = yield* Scope.make(); + const clientLayer = CodexClient.layerChildProcess(handle); + const context = yield* Layer.buildWithScope(clientLayer, scope); + + const error = yield* Effect.gen(function* () { + const client = yield* CodexClient.CodexAppServerClient; + return yield* client.request("initialize", initializeParams).pipe(Effect.flip); + }).pipe( + Effect.timeout("5 seconds"), + Effect.provide(context), + Effect.ensuring(Scope.close(scope, Exit.void)), + ); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.notInclude(error.stderrTail ?? "", secret); + assert.notInclude(error.message, secret); + assert.include(error.message, "[REDACTED]"); + assert.include(error.message, "Delayed access failure"); + }), + ); }); diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index cd3ff9c9054..40dc7c177da 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -264,10 +265,13 @@ const makeChildProcessClient = Effect.fn( "effect-codex-app-server/CodexAppServerClient.makeChildProcessClient", )(function* (handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions) { const stderrCapture = yield* makeStderrTailCapture(handle.stderr); - yield* stderrCapture.drain.pipe(Effect.forkScoped); + const stderrDrainFiber = yield* stderrCapture.drain.pipe(Effect.forkScoped); return yield* make( makeChildStdio(handle), options, - makeTerminationError(handle, stderrCapture.snapshot), + makeTerminationError(handle, { + snapshot: stderrCapture.snapshot, + awaitDrain: Fiber.join(stderrDrainFiber), + }), ); }); diff --git a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts index dcd02526f84..d23d5f46d6a 100644 --- a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts +++ b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts @@ -36,6 +36,22 @@ const handleMethod = (message: Record) => { switch (method) { case "initialize": { + const exitStderrChunks = process.env.CODEX_APP_SERVER_TEST_EXIT_WITH_STDERR_CHUNKS; + if (exitStderrChunks !== undefined) { + const chunks = exitStderrChunks.split("|"); + const writeChunk = (index: number) => { + const chunk = chunks[index]; + if (chunk === undefined) { + process.exit(1); + return; + } + process.stderr.write(chunk, () => { + process.nextTick(() => writeChunk(index + 1)); + }); + }; + writeChunk(0); + return; + } const exitStderr = process.env.CODEX_APP_SERVER_TEST_EXIT_WITH_STDERR; if (exitStderr !== undefined) { process.stderr.write(exitStderr, () => {