Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion packages/effect-codex-app-server/src/_internal/stdio.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
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";

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", () =>
Expand Down Expand Up @@ -45,4 +49,98 @@ 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)),
},
{ snapshot: 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");
}),
);

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");
}),
);
});
148 changes: 140 additions & 8 deletions packages/effect-codex-app-server/src/_internal/stdio.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +10,111 @@ 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;
readonly truncated: boolean;
}

export interface StderrTailSnapshot {
readonly stderrTail: string;
readonly stderrTruncated: boolean;
}

export interface StderrTailDiagnostics {
readonly snapshot: Effect.Effect<StderrTailSnapshot>;
readonly awaitDrain?: Effect.Effect<unknown, never>;
}

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,
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: state.truncated || state.bytes.byteLength > 0 || chunk.byteLength > byteLimit,
};
}

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<Uint8Array, unknown>,
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 = redactSensitiveStderr(decoder.decode(current.bytes)).trim();
return {
stderrTail,
stderrTruncated: current.truncated,
};
}),
),
};
});

export const makeChildStdio = (handle: ChildProcessSpawner.ChildProcessHandle) =>
Stdio.make({
Expand Down Expand Up @@ -51,13 +157,39 @@ type ChildProcessTerminationHandle = Pick<

export const makeTerminationError = (
handle: ChildProcessTerminationHandle,
stderrDiagnostics?: StderrTailDiagnostics,
): Effect.Effect<CodexError.CodexAppServerError> =>
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 exitStatus = yield* Effect.match(handle.exitCode, {
onFailure: (cause) =>
({
_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,
}
: {}),
Comment thread
StiensWout marked this conversation as resolved.
});
});
Loading
Loading