mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-05 14:32:13 +03:00
Compare commits
5 Commits
fix/v3851-
...
security/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5747f772e5 | ||
|
|
73dad04391 | ||
|
|
32896f1372 | ||
|
|
1b7940d295 | ||
|
|
9f23e8ff60 |
@@ -38,6 +38,7 @@ import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
|
||||
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||
import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
import { projectCodexPublicError } from "../utils/codexPublicError.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts";
|
||||
import * as prl from "../utils/providerRequestLogging.ts";
|
||||
@@ -493,7 +494,6 @@ function toCodexResponseFailedEvent(parsed: Record<string, unknown>): Record<str
|
||||
typeof upstreamError.message === "string" && upstreamError.message.trim()
|
||||
? upstreamError.message
|
||||
: "Codex upstream error";
|
||||
const error: Record<string, unknown> = { code, message };
|
||||
const explicitStatus =
|
||||
toStatusCode(parsed.status_code) ??
|
||||
toStatusCode(parsed.status) ??
|
||||
@@ -503,8 +503,10 @@ function toCodexResponseFailedEvent(parsed: Record<string, unknown>): Record<str
|
||||
toStatusCode(upstreamError.status);
|
||||
const statusCode =
|
||||
explicitStatus ?? (looksLikeQuotaOrRateLimit(code, type, message) ? 429 : null);
|
||||
const error: Record<string, unknown> = {
|
||||
...projectCodexPublicError({ status: statusCode, code, type }),
|
||||
};
|
||||
|
||||
if (type) error.type = type;
|
||||
if (statusCode !== null) error.status_code = statusCode;
|
||||
|
||||
return {
|
||||
@@ -951,7 +953,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
}
|
||||
};
|
||||
|
||||
const failController = (code: string, message: string) => {
|
||||
const failController = (code: string, _message: string) => {
|
||||
if (closed) return;
|
||||
const controller = streamController;
|
||||
const payload = JSON.stringify({
|
||||
@@ -959,7 +961,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
response: {
|
||||
id: null,
|
||||
status: "failed",
|
||||
error: { code, message },
|
||||
error: projectCodexPublicError({ status: 502, code, type: "provider_error" }),
|
||||
},
|
||||
});
|
||||
try {
|
||||
|
||||
110
open-sse/utils/codexPublicError.ts
Normal file
110
open-sse/utils/codexPublicError.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { sanitizeErrorMessage } from "./error.ts";
|
||||
|
||||
export const CODEX_PUBLIC_ERROR_MESSAGE = sanitizeErrorMessage("Codex provider request failed");
|
||||
|
||||
export interface CodexPublicError {
|
||||
message: string;
|
||||
type: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface CodexPublicErrorInput {
|
||||
status?: number | null;
|
||||
type?: unknown;
|
||||
code?: unknown;
|
||||
}
|
||||
|
||||
interface CodexPublicErrorRule {
|
||||
type: string;
|
||||
allowsStatus: (status: number) => boolean;
|
||||
}
|
||||
|
||||
const exactStatuses =
|
||||
(...statuses: number[]) =>
|
||||
(status: number): boolean =>
|
||||
statuses.includes(status);
|
||||
|
||||
const CODEX_PUBLIC_ERROR_RULES = new Map<string, CodexPublicErrorRule>([
|
||||
["browser_stream_inconsistent", { type: "server_error", allowsStatus: exactStatuses(502) }],
|
||||
["chatgpt_session_expired", { type: "authentication_error", allowsStatus: exactStatuses(401) }],
|
||||
["chatgpt_submission_ambiguous", { type: "server_error", allowsStatus: exactStatuses(502) }],
|
||||
["chatgpt_submitted_turn_failed", { type: "server_error", allowsStatus: exactStatuses(502) }],
|
||||
["chatgpt_subscription_unavailable", { type: "server_error", allowsStatus: exactStatuses(503) }],
|
||||
["client_cancelled", { type: "invalid_request_error", allowsStatus: exactStatuses(499) }],
|
||||
["client_closed_request", { type: "invalid_request_error", allowsStatus: exactStatuses(499) }],
|
||||
["codex_app_server_turn_failed", { type: "provider_error", allowsStatus: exactStatuses(502) }],
|
||||
[
|
||||
"compaction_control_unavailable",
|
||||
{ type: "invalid_request_error", allowsStatus: exactStatuses(409) },
|
||||
],
|
||||
[
|
||||
"compaction_handoff_failed",
|
||||
{ type: "invalid_request_error", allowsStatus: exactStatuses(409) },
|
||||
],
|
||||
[
|
||||
"compaction_source_unavailable",
|
||||
{ type: "invalid_request_error", allowsStatus: exactStatuses(409) },
|
||||
],
|
||||
["connector_not_found", { type: "connector_error", allowsStatus: exactStatuses(424) }],
|
||||
[
|
||||
"context_length_exceeded",
|
||||
{ type: "invalid_request_error", allowsStatus: exactStatuses(400, 413) },
|
||||
],
|
||||
["insufficient_quota", { type: "insufficient_quota", allowsStatus: exactStatuses(429) }],
|
||||
["invalid_api_key", { type: "authentication_error", allowsStatus: exactStatuses(401) }],
|
||||
["invalid_output_schema", { type: "invalid_request_error", allowsStatus: exactStatuses(400) }],
|
||||
["invalid_request_error", { type: "invalid_request_error", allowsStatus: exactStatuses(400) }],
|
||||
["multipart_protocol_violation", { type: "server_error", allowsStatus: exactStatuses(502) }],
|
||||
["origin_rejected", { type: "invalid_request_error", allowsStatus: exactStatuses(403) }],
|
||||
["permission_denied", { type: "permission_error", allowsStatus: exactStatuses(403) }],
|
||||
["prompt_attachment_integrity", { type: "server_error", allowsStatus: exactStatuses(502) }],
|
||||
["rate_limit_exceeded", { type: "rate_limit_error", allowsStatus: exactStatuses(429) }],
|
||||
["server_is_overloaded", { type: "server_error", allowsStatus: exactStatuses(503) }],
|
||||
[
|
||||
"structured_output_validation_failed",
|
||||
{ type: "server_error", allowsStatus: exactStatuses(502) },
|
||||
],
|
||||
["subscription_required", { type: "permission_error", allowsStatus: exactStatuses(403) }],
|
||||
[
|
||||
"upstream_server_error",
|
||||
{
|
||||
type: "server_error",
|
||||
allowsStatus: (status) => status >= 500 && status <= 599 && status !== 503,
|
||||
},
|
||||
],
|
||||
[
|
||||
"upstream_websocket_connect_failed",
|
||||
{ type: "provider_error", allowsStatus: exactStatuses(502) },
|
||||
],
|
||||
["upstream_websocket_error", { type: "provider_error", allowsStatus: exactStatuses(502) }],
|
||||
["usage_limit_reached", { type: "rate_limit_error", allowsStatus: exactStatuses(429) }],
|
||||
]);
|
||||
|
||||
function defaultPublicClassification(status: number): Pick<CodexPublicError, "type" | "code"> {
|
||||
if (status === 429) return { type: "rate_limit_error", code: "rate_limit_exceeded" };
|
||||
if (status === 401) return { type: "authentication_error", code: "invalid_api_key" };
|
||||
if (status === 403) return { type: "permission_error", code: "permission_denied" };
|
||||
if (status === 499) return { type: "invalid_request_error", code: "client_closed_request" };
|
||||
if (status === 503) return { type: "server_error", code: "server_is_overloaded" };
|
||||
if (status >= 500) return { type: "server_error", code: "upstream_server_error" };
|
||||
return { type: "invalid_request_error", code: "invalid_request_error" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project an internally classified Codex failure onto its public Responses contract.
|
||||
*
|
||||
* Upstream message, code, and type fields are untrusted. The public message is fixed,
|
||||
* while code/type retain only closed, protocol-level identifiers already produced by
|
||||
* OmniRoute. Everything else falls back to the HTTP status classification.
|
||||
*/
|
||||
export function projectCodexPublicError(input: CodexPublicErrorInput): CodexPublicError {
|
||||
const status =
|
||||
typeof input.status === "number" && Number.isInteger(input.status) ? input.status : 502;
|
||||
const fallback = defaultPublicClassification(status);
|
||||
const rule =
|
||||
typeof input.code === "string" ? CODEX_PUBLIC_ERROR_RULES.get(input.code) : undefined;
|
||||
if (!rule || !rule.allowsStatus(status)) {
|
||||
return { message: CODEX_PUBLIC_ERROR_MESSAGE, ...fallback };
|
||||
}
|
||||
return { message: CODEX_PUBLIC_ERROR_MESSAGE, type: rule.type, code: input.code as string };
|
||||
}
|
||||
21
open-sse/vendor/codex-chatgpt-web/bridge.ts
vendored
21
open-sse/vendor/codex-chatgpt-web/bridge.ts
vendored
@@ -5,6 +5,7 @@ import type {
|
||||
CodexProviderContinuationState,
|
||||
CodexUsage,
|
||||
} from "./types";
|
||||
import { projectCodexPublicError } from "../../utils/codexPublicError";
|
||||
import { adapterFailureFromMessage, classifyError, type CodexErrorPayload } from "./lib/errors";
|
||||
import { encodeCompactionSummary } from "./responses/compaction";
|
||||
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
|
||||
@@ -46,7 +47,8 @@ function responsesUsage(usage: CodexUsage | undefined): Record<string, unknown>
|
||||
}
|
||||
|
||||
function responseError(status: number, type: string, message: string): CodexErrorPayload {
|
||||
return classifyError(status, type, message);
|
||||
const classified = classifyError(status, type, message);
|
||||
return projectCodexPublicError({ status, type: classified.type, code: classified.code });
|
||||
}
|
||||
|
||||
function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>): {
|
||||
@@ -54,14 +56,25 @@ function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>
|
||||
error: CodexErrorPayload;
|
||||
} {
|
||||
if (event.status === undefined && event.errorType === undefined && event.code === undefined) {
|
||||
return adapterFailureFromMessage(event.message);
|
||||
const fallback = adapterFailureFromMessage(event.message);
|
||||
return {
|
||||
httpStatus: fallback.httpStatus,
|
||||
error: projectCodexPublicError({
|
||||
status: fallback.httpStatus,
|
||||
type: fallback.error.type,
|
||||
code: fallback.error.code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const fallback = adapterFailureFromMessage(event.message);
|
||||
const httpStatus = event.status ?? fallback.httpStatus;
|
||||
const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message);
|
||||
if (event.errorType !== undefined) error.type = event.errorType;
|
||||
if (event.code !== undefined) error.code = event.code;
|
||||
return { httpStatus, error };
|
||||
return {
|
||||
httpStatus,
|
||||
error: projectCodexPublicError({ status: httpStatus, type: error.type, code: error.code }),
|
||||
};
|
||||
}
|
||||
|
||||
export { adapterFailureFromMessage } from "./lib/errors";
|
||||
@@ -1314,7 +1327,7 @@ export function buildResponseJSON(
|
||||
}
|
||||
|
||||
export function formatErrorResponse(status: number, type: string, message: string): Response {
|
||||
return new Response(JSON.stringify({ error: classifyError(status, type, message) }), {
|
||||
return new Response(JSON.stringify({ error: responseError(status, type, message) }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
376
tests/fixtures/codex-response-failed-boundary.fixture.ts
vendored
Normal file
376
tests/fixtures/codex-response-failed-boundary.fixture.ts
vendored
Normal file
@@ -0,0 +1,376 @@
|
||||
// This suite intentionally owns process-wide DATA_DIR, plugin, and DB state. It must run only
|
||||
// inside the subprocess launched by tests/unit/codex-response-failed-boundary.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CodexWreqWebSocket } from "../../open-sse/executors/codex/appServerClient.ts";
|
||||
import type { AdapterEvent } from "../../open-sse/vendor/codex-chatgpt-web/types.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-boundary-data-"));
|
||||
const TEST_PLUGINS_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-codex-boundary-plugins-")
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
process.env.APP_LOG_TO_FILE = "false";
|
||||
|
||||
const { CodexExecutor, __setCodexWebSocketTransportForTesting, encodeResponseSseEvent } =
|
||||
await import("../../open-sse/executors/codex.ts");
|
||||
const { CodexAppServerExecutor } = await import("../../open-sse/executors/codex-app-server.ts");
|
||||
const { bridgeToResponsesSSE, buildResponseJSON } =
|
||||
await import("../../open-sse/vendor/codex-chatgpt-web/bridge.ts");
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
const PUBLIC_MESSAGE = "Codex provider request failed";
|
||||
const HOSTILE_MESSAGE =
|
||||
"token=codex-secret-value at /srv/omniroute/private/config.json\nforged-log: admin=true";
|
||||
|
||||
type FailedPayload = {
|
||||
type: "response.failed";
|
||||
response: {
|
||||
error: {
|
||||
code: string | null;
|
||||
message: string;
|
||||
status_code?: number;
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function responseFailedPayload(sse: string): FailedPayload {
|
||||
for (const line of sse.split("\n")) {
|
||||
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
||||
const parsed = JSON.parse(line.slice("data: ".length)) as Record<string, unknown>;
|
||||
if (parsed.type === "response.failed") return parsed as FailedPayload;
|
||||
}
|
||||
assert.fail(`response.failed frame missing from: ${sse}`);
|
||||
}
|
||||
|
||||
function assertPublicFailure(
|
||||
payload: FailedPayload,
|
||||
expected: { code: string; type: string; statusCode?: number }
|
||||
): void {
|
||||
assert.equal(payload.response.error.message, PUBLIC_MESSAGE);
|
||||
assert.equal(payload.response.error.code, expected.code);
|
||||
assert.equal(payload.response.error.type, expected.type);
|
||||
if (expected.statusCode !== undefined) {
|
||||
assert.equal(payload.response.error.status_code, expected.statusCode);
|
||||
}
|
||||
assert.ok(!JSON.stringify(payload).includes(HOSTILE_MESSAGE));
|
||||
assert.ok(!JSON.stringify(payload).includes("codex-secret-value"));
|
||||
assert.ok(!JSON.stringify(payload).includes("/srv/omniroute/private"));
|
||||
}
|
||||
|
||||
async function executeCodexWebSocketFailure(
|
||||
websocket: Parameters<typeof __setCodexWebSocketTransportForTesting>[0]
|
||||
): Promise<string> {
|
||||
__setCodexWebSocketTransportForTesting(websocket);
|
||||
try {
|
||||
const result = await new CodexExecutor().execute({
|
||||
model: "gpt-5.5",
|
||||
body: { model: "gpt-5.5", input: "hello" },
|
||||
stream: true,
|
||||
credentials: {
|
||||
accessToken: "test-token",
|
||||
providerSpecificData: { codexTransport: "websocket" },
|
||||
},
|
||||
});
|
||||
return await result.response.text();
|
||||
} finally {
|
||||
__setCodexWebSocketTransportForTesting(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAppServerFailure(stream: boolean): Promise<Response> {
|
||||
const socket: CodexWreqWebSocket = {
|
||||
send(data: string) {
|
||||
const frame = JSON.parse(data) as Record<string, unknown>;
|
||||
if (frame.id == null || typeof frame.method !== "string") return;
|
||||
queueMicrotask(() => {
|
||||
if (frame.method === "thread/start") {
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: frame.id,
|
||||
result: { thread: { id: "thread-public-boundary" } },
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (frame.method === "turn/start") {
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: frame.id,
|
||||
result: { turn: { id: "turn-public-boundary", status: "inProgress" } },
|
||||
}),
|
||||
});
|
||||
setTimeout(() => {
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "error",
|
||||
params: { error: { message: HOSTILE_MESSAGE } },
|
||||
}),
|
||||
});
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: {} }),
|
||||
});
|
||||
});
|
||||
},
|
||||
close() {},
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
onclose: null,
|
||||
};
|
||||
const executor = new CodexAppServerExecutor({ websocketFn: async () => socket });
|
||||
const result = await executor.execute({
|
||||
model: "gpt-5.5",
|
||||
body: { input: "hello" },
|
||||
stream,
|
||||
credentials: {
|
||||
providerSpecificData: {
|
||||
codexTransport: "app-server",
|
||||
codexAppServerUrl: "ws://codex-app-server.test:1456",
|
||||
codexAppServerToken: "test-app-server-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
__setCodexWebSocketTransportForTesting(undefined);
|
||||
resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_PLUGINS_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Codex same-format error event emits only a fixed public failure contract", () => {
|
||||
const result = encodeResponseSseEvent(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
status_code: 502,
|
||||
error: {
|
||||
code: "secret_backend_code_9182",
|
||||
type: "secret_backend_type_7731",
|
||||
message: HOSTILE_MESSAGE,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(result.terminal, true);
|
||||
assertPublicFailure(responseFailedPayload(result.sse), {
|
||||
code: "upstream_server_error",
|
||||
type: "server_error",
|
||||
statusCode: 502,
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex same-format quota classification survives while its raw message does not", () => {
|
||||
const result = encodeResponseSseEvent(
|
||||
JSON.stringify({
|
||||
type: "response.failed",
|
||||
response: {
|
||||
status: "failed",
|
||||
error: { code: "usage_limit_reached", message: HOSTILE_MESSAGE },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assertPublicFailure(responseFailedPayload(result.sse), {
|
||||
code: "usage_limit_reached",
|
||||
type: "rate_limit_error",
|
||||
statusCode: 429,
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex same-format failures reject contradictory allowlisted status, code and type", () => {
|
||||
const wrongStatus = responseFailedPayload(
|
||||
encodeResponseSseEvent(
|
||||
JSON.stringify({
|
||||
type: "response.failed",
|
||||
status_code: 502,
|
||||
response: {
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "invalid_api_key",
|
||||
type: "rate_limit_error",
|
||||
message: HOSTILE_MESSAGE,
|
||||
},
|
||||
},
|
||||
})
|
||||
).sse
|
||||
);
|
||||
assertPublicFailure(wrongStatus, {
|
||||
code: "upstream_server_error",
|
||||
type: "server_error",
|
||||
statusCode: 502,
|
||||
});
|
||||
|
||||
const wrongType = responseFailedPayload(
|
||||
encodeResponseSseEvent(
|
||||
JSON.stringify({
|
||||
type: "response.failed",
|
||||
status_code: 401,
|
||||
response: {
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "invalid_api_key",
|
||||
type: "rate_limit_error",
|
||||
message: HOSTILE_MESSAGE,
|
||||
},
|
||||
},
|
||||
})
|
||||
).sse
|
||||
);
|
||||
assertPublicFailure(wrongType, {
|
||||
code: "invalid_api_key",
|
||||
type: "authentication_error",
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex WebSocket in-flight error event cannot expose transport details", async () => {
|
||||
const socket = {
|
||||
send() {
|
||||
queueMicrotask(() => socket.onerror?.({ message: HOSTILE_MESSAGE }));
|
||||
},
|
||||
close() {},
|
||||
onmessage: null as ((event: { data: unknown }) => void) | null,
|
||||
onerror: null as ((event: { message?: string }) => void) | null,
|
||||
onclose: null as (() => void) | null,
|
||||
};
|
||||
const sse = await executeCodexWebSocketFailure(async () => socket);
|
||||
|
||||
assertPublicFailure(responseFailedPayload(sse), {
|
||||
code: "upstream_websocket_error",
|
||||
type: "provider_error",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex WebSocket connection failure cannot expose exception details", async () => {
|
||||
const sse = await executeCodexWebSocketFailure(async () => {
|
||||
throw new Error(HOSTILE_MESSAGE);
|
||||
});
|
||||
|
||||
assertPublicFailure(responseFailedPayload(sse), {
|
||||
code: "upstream_websocket_connect_failed",
|
||||
type: "provider_error",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex App Server streaming failure is projected before the HTTP 200 SSE boundary", async () => {
|
||||
const response = await executeAppServerFailure(true);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
assertPublicFailure(responseFailedPayload(await response.text()), {
|
||||
code: "codex_app_server_turn_failed",
|
||||
type: "provider_error",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex App Server non-streaming failure is projected before the HTTP 200 JSON boundary", async () => {
|
||||
const response = await executeAppServerFailure(false);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as FailedPayload["response"] & { status: string };
|
||||
|
||||
assert.equal(body.status, "failed");
|
||||
assertPublicFailure(
|
||||
{ type: "response.failed", response: body },
|
||||
{
|
||||
code: "codex_app_server_turn_failed",
|
||||
type: "provider_error",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("ChatGPT Web Playwright adapter failures keep safe routing metadata without raw text", async () => {
|
||||
async function* browserEvents(): AsyncGenerator<AdapterEvent> {
|
||||
yield {
|
||||
type: "error",
|
||||
message: HOSTILE_MESSAGE,
|
||||
status: 502,
|
||||
errorType: "server_error",
|
||||
code: "chatgpt_submission_ambiguous",
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const sse = await new Response(bridgeToResponsesSSE(browserEvents(), "gpt-5.5")).text();
|
||||
assertPublicFailure(responseFailedPayload(sse), {
|
||||
code: "chatgpt_submission_ambiguous",
|
||||
type: "server_error",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex bridge projects message-only adapter failures before SSE serialization", async () => {
|
||||
async function* messageOnlyEvents(): AsyncGenerator<AdapterEvent> {
|
||||
yield { type: "error", message: HOSTILE_MESSAGE };
|
||||
}
|
||||
|
||||
const sse = await new Response(bridgeToResponsesSSE(messageOnlyEvents(), "gpt-5.5")).text();
|
||||
assertPublicFailure(responseFailedPayload(sse), {
|
||||
code: "upstream_server_error",
|
||||
type: "server_error",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex batch bridge projects message-only adapter failures before JSON serialization", () => {
|
||||
const body = buildResponseJSON(
|
||||
[{ type: "error", message: HOSTILE_MESSAGE }],
|
||||
"gpt-5.5"
|
||||
) as FailedPayload["response"] & { status: string };
|
||||
|
||||
assert.equal(body.status, "failed");
|
||||
assertPublicFailure(
|
||||
{ type: "response.failed", response: body },
|
||||
{
|
||||
code: "upstream_server_error",
|
||||
type: "server_error",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex bridge exceptions cannot serialize raw exception messages", async () => {
|
||||
async function* throwingEvents(): AsyncGenerator<AdapterEvent> {
|
||||
throw new Error(HOSTILE_MESSAGE);
|
||||
}
|
||||
|
||||
const sse = await new Response(bridgeToResponsesSSE(throwingEvents(), "gpt-5.5")).text();
|
||||
assertPublicFailure(responseFailedPayload(sse), {
|
||||
code: "upstream_server_error",
|
||||
type: "server_error",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex batch bridge applies the same public failure projector", () => {
|
||||
const body = buildResponseJSON(
|
||||
[
|
||||
{
|
||||
type: "error",
|
||||
message: HOSTILE_MESSAGE,
|
||||
status: 502,
|
||||
errorType: "server_error",
|
||||
code: "chatgpt_submitted_turn_failed",
|
||||
retryable: false,
|
||||
},
|
||||
],
|
||||
"gpt-5.5"
|
||||
) as FailedPayload["response"] & { status: string };
|
||||
|
||||
assert.equal(body.status, "failed");
|
||||
assertPublicFailure(
|
||||
{ type: "response.failed", response: body },
|
||||
{
|
||||
code: "chatgpt_submitted_turn_failed",
|
||||
type: "server_error",
|
||||
}
|
||||
);
|
||||
});
|
||||
73
tests/unit/codex-response-failed-boundary.test.ts
Normal file
73
tests/unit/codex-response-failed-boundary.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
||||
const FIXTURE = fileURLToPath(
|
||||
new URL("../fixtures/codex-response-failed-boundary.fixture.ts", import.meta.url)
|
||||
);
|
||||
|
||||
const CHILD_RUNTIME_ENV_KEYS = [
|
||||
"PATH",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"SystemRoot",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"TZ",
|
||||
] as const;
|
||||
|
||||
function buildFixtureEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
NODE_ENV: "test",
|
||||
APP_LOG_TO_FILE: "false",
|
||||
API_KEY_SECRET: "codex-boundary-fixture-api-key-secret-20260902",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
};
|
||||
|
||||
for (const key of CHILD_RUNTIME_ENV_KEYS) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined) env[key] = value;
|
||||
}
|
||||
|
||||
// A nested test runner must receive its own context instead of inheriting the parent's.
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
return env;
|
||||
}
|
||||
|
||||
test("Codex public failure boundaries pass in an isolated process", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"--import",
|
||||
"./open-sse/utils/setupPolyfill.ts",
|
||||
"--test",
|
||||
"--test-force-exit",
|
||||
FIXTURE,
|
||||
],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
env: buildFixtureEnv(),
|
||||
timeout: 60_000,
|
||||
}
|
||||
);
|
||||
|
||||
assert.ifError(result.error);
|
||||
assert.equal(
|
||||
result.signal,
|
||||
null,
|
||||
`isolated Codex boundary fixture terminated by ${result.signal}\n${result.stdout}\n${result.stderr}`
|
||||
);
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`isolated Codex boundary fixture failed\n${result.stdout}\n${result.stderr}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user