mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
feat(codex): self-contained codex app-server transport (executor + provider + sign-in) (#11205)
Merged after conflict resolution: the 5 conflicting test files were the base-red drains that #11201 already landed on the tip — kept the tip versions; the feature content is untouched. Validated on the combined batch board + this branch: codex-app-server + codex-gpt56-catalog 25/25, typecheck:core clean, docs-counts green (351 providers), provider-consistency 268/351/0. The opt-in codex-app-server transport (JSON-RPC-over-WS, turn/completed-awaited close, Responses SSE bridge) leaves the default codex path untouched. Thank you @arminanton — a 3.4k-line transport with the docs wave and tests to match!
This commit is contained in:
@@ -409,7 +409,7 @@ export default function ConnectionsListPanel({
|
||||
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
|
||||
: undefined
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCodex={providerId === "codex" || providerId === "codex-app-server"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCliproxyapiMode={(enabled) => handleToggleCliproxyapiMode(conn.id, enabled)}
|
||||
@@ -610,7 +610,7 @@ export default function ConnectionsListPanel({
|
||||
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
|
||||
: undefined
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCodex={providerId === "codex" || providerId === "codex-app-server"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCliproxyapiMode={(enabled) =>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
|
||||
import { CodexAuthFileError, writeCodexAuthFileToLocalCli } from "@/lib/oauth/utils/codexAuthFile";
|
||||
import {
|
||||
CodexAuthFileError,
|
||||
writeCodexAuthFileToLocalCliIfNeeded,
|
||||
} from "@/lib/oauth/utils/codexAuthFile";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
// Optional body { force?: boolean }. Unknown keys are stripped rather than
|
||||
// rejected so the endpoint stays tolerant of the empty/no-body calls it
|
||||
// historically accepted. Non-boolean `force` is coerced away to the default.
|
||||
const ApplyLocalBodySchema = z
|
||||
.object({ force: z.boolean().optional() })
|
||||
.partial()
|
||||
.passthrough();
|
||||
|
||||
function toErrorResponse(error: unknown) {
|
||||
if (error instanceof CodexAuthFileError) {
|
||||
return NextResponse.json(
|
||||
@@ -33,7 +45,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const result = await writeCodexAuthFileToLocalCli(id);
|
||||
|
||||
// Optional { force?: boolean } body. By default we DON'T clobber an existing,
|
||||
// fresh ~/.codex/auth.json (a session the user may be managing themselves);
|
||||
// force overwrites it (a backup is always taken regardless). Malformed/empty
|
||||
// bodies are tolerated — this endpoint historically took no body.
|
||||
let force = false;
|
||||
try {
|
||||
const parsed = ApplyLocalBodySchema.safeParse(await request.json());
|
||||
force = parsed.success ? parsed.data.force === true : false;
|
||||
} catch {
|
||||
/* no body — default force=false */
|
||||
}
|
||||
|
||||
const applied = await writeCodexAuthFileToLocalCliIfNeeded(id, { force });
|
||||
const result = applied.result;
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.applied",
|
||||
@@ -45,18 +71,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
authPath: result.authPath,
|
||||
savedBakPath: result.savedBakPath,
|
||||
decision: applied.decision,
|
||||
authPath: applied.authPath,
|
||||
savedBakPath: result?.savedBakPath,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connectionId: id,
|
||||
connectionLabel: result.connectionLabel,
|
||||
authPath: result.authPath,
|
||||
savedBakPath: result.savedBakPath,
|
||||
centralizedBackupPath: result.centralizedBackupPath,
|
||||
// "skipped_present_fresh" means an existing healthy auth.json was kept.
|
||||
decision: applied.decision,
|
||||
connectionLabel: result?.connectionLabel,
|
||||
authPath: applied.authPath,
|
||||
savedBakPath: result?.savedBakPath,
|
||||
centralizedBackupPath: result?.centralizedBackupPath,
|
||||
writtenAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
136
src/app/api/providers/[id]/test/codexAppServerHealth.ts
Normal file
136
src/app/api/providers/[id]/test/codexAppServerHealth.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Build the structured diagnosis object the connection-test route returns.
|
||||
* Lives here (rather than inline in test/route.ts) so both the route and the
|
||||
* codex-app-server health probe share one definition. Pure.
|
||||
*/
|
||||
export function makeDiagnosis(
|
||||
type: string,
|
||||
source: string,
|
||||
message: string | null,
|
||||
code: string | null = null
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
source,
|
||||
message: message || null,
|
||||
code: code ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type CodexAppServerHealth = {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
diagnosis: unknown;
|
||||
refreshed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A codex "app-server" connection (providerSpecificData.codexTransport ===
|
||||
* "app-server") does NOT carry a validatable OpenAI token: it drives the codex
|
||||
* CLI's own `codex app-server` process over JSON-RPC/WebSocket, and THAT process
|
||||
* self-manages its OpenAI OAuth (its own ~/.codex/auth.json), exactly like an
|
||||
* interactive codex session. So the ordinary OAuth token probe is meaningless for
|
||||
* these connections — it validates a placeholder and reports a false "Token
|
||||
* invalid or revoked" 401 (which then trips the rate-limit cooldown on retest).
|
||||
*
|
||||
* The correct health signal for this transport is whether the app-server itself
|
||||
* is reachable and ready. The app-server exposes an unauthenticated liveness
|
||||
* endpoint at <httpBase>/readyz (200 = ready) alongside its ws:// listener, so we
|
||||
* derive the http(s) origin from the configured ws(s):// URL and probe /readyz.
|
||||
* Returns null when this connection is NOT an app-server connection (so the caller
|
||||
* falls through to the normal token validation).
|
||||
*/
|
||||
export async function testCodexAppServerConnection(
|
||||
connection: any
|
||||
): Promise<CodexAppServerHealth | null> {
|
||||
const psd = (connection?.providerSpecificData as Record<string, unknown> | undefined) || undefined;
|
||||
// Fire the /readyz probe when EITHER (a) the connection opted into the
|
||||
// app-server transport via the per-connection flag (a `codex` provider
|
||||
// connection with codexTransport==="app-server"), OR (b) this is the
|
||||
// first-class `codex-app-server` provider, which is app-server by definition
|
||||
// and needs no flag. Otherwise return null so the caller falls through to the
|
||||
// normal OAuth/apikey token validation.
|
||||
const isAppServerProvider = connection?.provider === "codex-app-server";
|
||||
const isAppServerFlag = psd?.codexTransport === "app-server";
|
||||
if (!isAppServerProvider && !isAppServerFlag) return null;
|
||||
|
||||
// Dynamic import (not a static top-level import) so this executor-config module
|
||||
// stays behind the open-sse boundary the no-restricted-imports lint rule enforces.
|
||||
const { resolveAppServerConfig } = await import(
|
||||
"@omniroute/open-sse/executors/codex/appServerConfig.ts"
|
||||
);
|
||||
const config = resolveAppServerConfig(psd);
|
||||
if (!config) {
|
||||
const error = "Codex app-server transport is not configured (missing url or token)";
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("validation_error", "local", error, "app_server_unconfigured"),
|
||||
};
|
||||
}
|
||||
|
||||
// ws://host:port → http://host:port/readyz ; wss:// → https://.
|
||||
const httpBase = config.url.replace(/^ws(s?):\/\//i, (_m, s) => `http${s}://`).replace(/\/+$/, "");
|
||||
const readyzUrl = `${httpBase}/readyz`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const res = await fetch(readyzUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const error = `Codex app-server not ready (${readyzUrl} → HTTP ${res.status})`;
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("provider_error", "app_server", error, "app_server_not_ready"),
|
||||
};
|
||||
}
|
||||
// The server PROCESS is up. Now confirm its Codex CLI is actually SIGNED IN —
|
||||
// /readyz alone would show green for a logged-out CLI, which then fails on the
|
||||
// first real turn. Probe account/read over the JSON-RPC WebSocket.
|
||||
let authStatus;
|
||||
try {
|
||||
const [{ probeCodexAppServerAuth }, { getCodexAppServerWebsocketTransport }] =
|
||||
await Promise.all([
|
||||
import("@omniroute/open-sse/executors/codex/appServerAuthProbe.ts"),
|
||||
import("@omniroute/open-sse/executors/codex.ts"),
|
||||
]);
|
||||
authStatus = await probeCodexAppServerAuth(config, getCodexAppServerWebsocketTransport(), 8000);
|
||||
} catch (probeErr: any) {
|
||||
// If the auth probe itself fails to load/run, don't fail the whole health
|
||||
// check — the server IS reachable. Treat as unknown-but-reachable (valid).
|
||||
authStatus = { state: "unknown", reason: probeErr?.message ?? "auth probe failed" } as const;
|
||||
}
|
||||
|
||||
if (authStatus.state === "logged_out") {
|
||||
const error =
|
||||
"Codex app-server is running but its Codex CLI is not signed in. Use \u201cSign in with ChatGPT\u201d to authenticate.";
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("auth_required", "app_server", error, "app_server_login_required"),
|
||||
};
|
||||
}
|
||||
// "authenticated" → healthy; "unknown" (probe unavailable/timed out) → treat
|
||||
// the reachable server as healthy rather than blocking on an inconclusive probe.
|
||||
return { valid: true, refreshed: false, diagnosis: null };
|
||||
} catch (err: any) {
|
||||
const reason = err?.name === "AbortError" ? "timed out" : (err?.message ?? "unreachable");
|
||||
const error = `Codex app-server unreachable (${readyzUrl}: ${reason})`;
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("provider_error", "app_server", error, "app_server_unreachable"),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/oauth/gitlab";
|
||||
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||
import { shouldUseApiKeyConnectionTest } from "./webSessionTestDispatch";
|
||||
import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHealth";
|
||||
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
||||
@@ -52,20 +53,6 @@ function toSafeMessage(value: any, fallback = "Unknown error"): string {
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
function makeDiagnosis(
|
||||
type: string,
|
||||
source: string,
|
||||
message: string | null,
|
||||
code: string | null = null
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
source,
|
||||
message: message || null,
|
||||
code: code ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
|
||||
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
|
||||
@@ -1024,6 +1011,13 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
const startTime = Date.now();
|
||||
const runtime = await getProviderRuntimeStatus(connection);
|
||||
|
||||
// Codex app-server connections carry no validatable OpenAI token (the codex
|
||||
// app-server process self-manages its own OAuth). Probe the app-server's
|
||||
// /readyz liveness endpoint instead of the meaningless token check — otherwise
|
||||
// every sweep reports a false "Token invalid or revoked" 401 and cools the
|
||||
// connection down. Returns null for non-app-server connections (fall through).
|
||||
const appServerResult = await testCodexAppServerConnection(connection);
|
||||
|
||||
if ((runtime as any)?.diagnosis) {
|
||||
result = {
|
||||
valid: false,
|
||||
@@ -1031,6 +1025,10 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
refreshed: false,
|
||||
diagnosis: (runtime as any).diagnosis,
|
||||
};
|
||||
} else if (appServerResult) {
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
Promise.resolve(appServerResult)
|
||||
);
|
||||
} else if (shouldUseApiKeyConnectionTest(connection.authType, provider)) {
|
||||
const enrichedConnection = validationModelId
|
||||
? {
|
||||
|
||||
@@ -13873,5 +13873,12 @@
|
||||
"toolsMismatch": "O provedor nao suporta chamada de ferramentas",
|
||||
"structuredOutputMismatch": "O provedor nao suporta saida estruturada",
|
||||
"contextWindowMismatch": "A requisicao excede a janela de contexto do provedor"
|
||||
},
|
||||
"cheaperInferenceSponsorBanner": {
|
||||
"title": "Cheaper Inference é um Amigo Open Source do OmniRoute",
|
||||
"description": "Um gateway ordenado por custo que revende dezenas de modelos de fronteira atrás de um único endpoint compatível com OpenAI, roteando cada requisição para o provedor elegível mais barato, nunca acima do preço de tabela.",
|
||||
"cta": "Obter uma chave de API",
|
||||
"partnerLinkNote": "Link de parceiro",
|
||||
"dismissAriaLabel": "Dispensar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ export interface NodeSqliteDatabaseLike {
|
||||
run(...p: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint };
|
||||
get(...p: unknown[]): unknown;
|
||||
all(...p: unknown[]): unknown[];
|
||||
// node:sqlite (DatabaseSync) statements expose these tuning setters. They
|
||||
// are optional here so the shared adapter also accepts lighter test doubles.
|
||||
setAllowUnknownNamedParameters?(enabled: boolean): void;
|
||||
setAllowBareNamedParameters?(enabled: boolean): void;
|
||||
};
|
||||
exec(sql: string): void;
|
||||
close(): void;
|
||||
@@ -13,6 +17,46 @@ export interface NodeSqliteDatabaseLike {
|
||||
|
||||
const MAX_STMT_CACHE_SIZE = 200;
|
||||
|
||||
// node:sqlite hands back rows whose prototype is `null` (Object.create(null)),
|
||||
// whereas better-sqlite3 (the driver we ship and run in production/CI) returns
|
||||
// ordinary Object.prototype rows. The difference is invisible for normal
|
||||
// property access but breaks callers that compare rows with structural
|
||||
// equality that also checks the prototype (e.g. Node's assert.deepStrictEqual,
|
||||
// used by unit tests written against the better-sqlite3 row shape). Normalize
|
||||
// every row to a plain object so the node:sqlite fallback is behaviourally
|
||||
// identical to the native better-sqlite3 path.
|
||||
function toPlainRow<T>(row: T): T {
|
||||
if (row === null || typeof row !== "object") return row;
|
||||
return { ...(row as Record<string, unknown>) } as T;
|
||||
}
|
||||
|
||||
// better-sqlite3 (the production/CI driver) and sql.js both accept `undefined`
|
||||
// as a bound value and treat it as SQL NULL. node:sqlite is stricter and throws
|
||||
// "Provided value cannot be bound to SQLite parameter N" for undefined. Several
|
||||
// call sites pass undefined for absent optional columns (e.g. a capability sync
|
||||
// that omits modalities_input), so coerce undefined -> null here to keep the
|
||||
// node:sqlite fallback behaviourally compatible with the native driver. This
|
||||
// handles both positional params and a single named-params object.
|
||||
function normalizeBindParams(params: unknown[]): unknown[] {
|
||||
const [first] = params;
|
||||
const isLoneNamedParamsObject =
|
||||
params.length === 1 &&
|
||||
first !== null &&
|
||||
typeof first === "object" &&
|
||||
!Array.isArray(first) &&
|
||||
!Buffer.isBuffer(first) &&
|
||||
!(first instanceof Uint8Array);
|
||||
if (isLoneNamedParamsObject) {
|
||||
const source = first as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(source)) {
|
||||
normalized[key] = source[key] === undefined ? null : source[key];
|
||||
}
|
||||
return [normalized];
|
||||
}
|
||||
return params.map((value) => (value === undefined ? null : value));
|
||||
}
|
||||
|
||||
export function createNodeSqliteAdapterFromDatabase(
|
||||
db: NodeSqliteDatabaseLike,
|
||||
filePath: string,
|
||||
@@ -41,6 +85,14 @@ export function createNodeSqliteAdapterFromDatabase(
|
||||
stmtCache.set(sql, entry);
|
||||
} else {
|
||||
const stmt = db.prepare(sql);
|
||||
// better-sqlite3 (the production/CI driver) silently ignores named
|
||||
// parameters supplied in the bind object that the SQL text does not
|
||||
// reference. node:sqlite instead throws "Unknown named parameter '<x>'".
|
||||
// Several call sites deliberately pass a superset params object (e.g. an
|
||||
// UPDATE that omits @createdAt while the shared params builder still
|
||||
// includes it), so relax node:sqlite to match better-sqlite3 and keep the
|
||||
// fallback driver behaviourally compatible.
|
||||
stmt.setAllowUnknownNamedParameters?.(true);
|
||||
if (stmtCache.size >= MAX_STMT_CACHE_SIZE) {
|
||||
const oldestKey = stmtCache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
@@ -119,17 +171,19 @@ export function createNodeSqliteAdapterFromDatabase(
|
||||
const stmt = getCached(sql);
|
||||
return {
|
||||
run(...params: unknown[]): RunResult {
|
||||
const r = stmt.run(...params);
|
||||
const r = stmt.run(...normalizeBindParams(params));
|
||||
return {
|
||||
changes: Number(r.changes ?? 0),
|
||||
lastInsertRowid: Number(r.lastInsertRowid ?? 0),
|
||||
};
|
||||
},
|
||||
get(...params: unknown[]): unknown {
|
||||
return stmt.get(...params);
|
||||
return toPlainRow(stmt.get(...normalizeBindParams(params)));
|
||||
},
|
||||
all(...params: unknown[]): unknown[] {
|
||||
return stmt.all(...params);
|
||||
return (stmt.all(...normalizeBindParams(params)) as unknown[]).map((row) =>
|
||||
toPlainRow(row)
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -6,6 +6,19 @@ import type { SqliteAdapter, PreparedStatement, RunResult } from "./types";
|
||||
const SAVE_DEBOUNCE_MS = 100;
|
||||
const CHECKPOINT_INTERVAL_MS = 60_000;
|
||||
|
||||
// sql.js's stmt.getAsObject() returns rows whose prototype is `null`
|
||||
// (Object.create(null)), whereas better-sqlite3 (the driver we ship and run in
|
||||
// production/CI) hands back ordinary Object.prototype rows. That difference is
|
||||
// invisible for normal property access but breaks callers that compare rows
|
||||
// with structural equality that also checks the prototype (e.g. Node's
|
||||
// assert.deepStrictEqual, used by several unit tests written against the
|
||||
// better-sqlite3 row shape). Normalize every row to a plain object so the
|
||||
// sql.js fallback is behaviourally identical to the native better-sqlite3 path.
|
||||
function toPlainRow<T>(row: T): T {
|
||||
if (row === null || typeof row !== "object") return row;
|
||||
return { ...(row as Record<string, unknown>) } as T;
|
||||
}
|
||||
|
||||
let _sqlJsLib: Awaited<ReturnType<(typeof import("sql.js"))["default"]>> | null = null;
|
||||
|
||||
function resolveSqlJsWasmPath(): string {
|
||||
@@ -240,7 +253,7 @@ export async function createSqlJsAdapter(filePath: string): Promise<SqliteAdapte
|
||||
try {
|
||||
const bindValue = toBindValue(params);
|
||||
if (bindValue !== undefined) stmt.bind(bindValue);
|
||||
if (stmt.step()) return stmt.getAsObject();
|
||||
if (stmt.step()) return toPlainRow(stmt.getAsObject());
|
||||
return undefined;
|
||||
} finally {
|
||||
stmt.free();
|
||||
@@ -252,7 +265,7 @@ export async function createSqlJsAdapter(filePath: string): Promise<SqliteAdapte
|
||||
const bindValue = toBindValue(params);
|
||||
if (bindValue !== undefined) stmt.bind(bindValue);
|
||||
const rows: unknown[] = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
while (stmt.step()) rows.push(toPlainRow(stmt.getAsObject()));
|
||||
return rows;
|
||||
} finally {
|
||||
stmt.free();
|
||||
|
||||
@@ -350,3 +350,89 @@ export async function writeCodexAuthFileToLocalCli(connectionId: string) {
|
||||
centralizedBackupPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision for the guarded write (see writeCodexAuthFileToLocalCliIfNeeded).
|
||||
*/
|
||||
export type CodexAuthWriteDecision =
|
||||
| "written" // wrote a fresh auth.json (was absent, stale, or force)
|
||||
| "skipped_present_fresh"; // an existing, non-stale auth.json was left untouched
|
||||
|
||||
/**
|
||||
* Guarded variant of writeCodexAuthFileToLocalCli for the codex-app-server
|
||||
* "Sign in with ChatGPT" flow. Per the design decision (William, Q2):
|
||||
*
|
||||
* - Write ONLY when ~/.codex/auth.json is ABSENT, or STALE (its token is at/
|
||||
* past the refresh buffer), or when `force` is set.
|
||||
* - NEVER clobber an existing, healthy (non-stale) auth.json — a user may be
|
||||
* managing the CLI session themselves. (The underlying writer always makes a
|
||||
* backup regardless, so even a forced overwrite is recoverable.)
|
||||
*
|
||||
* Staleness is read from the existing file's `last_refresh` + the token's own
|
||||
* expiry claim (JWT `exp` on the access_token) when present; if neither is
|
||||
* readable we treat the file as fresh (do not clobber).
|
||||
*
|
||||
* Returns the write decision plus (when written) the underlying write result.
|
||||
*/
|
||||
export async function writeCodexAuthFileToLocalCliIfNeeded(
|
||||
connectionId: string,
|
||||
options: { force?: boolean } = {}
|
||||
): Promise<{ decision: CodexAuthWriteDecision; authPath: string | null; result?: Awaited<ReturnType<typeof writeCodexAuthFileToLocalCli>> }> {
|
||||
const paths = getCliConfigPaths("codex");
|
||||
const authPath = paths?.auth ?? null;
|
||||
|
||||
if (!options.force && authPath) {
|
||||
const existing = await readExistingCodexAuth(authPath);
|
||||
if (existing && !isCodexAuthStale(existing)) {
|
||||
// Present and healthy — do not clobber a session we didn't (or don't need
|
||||
// to) manage. The connection can still authenticate turns via this file.
|
||||
return { decision: "skipped_present_fresh", authPath };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await writeCodexAuthFileToLocalCli(connectionId);
|
||||
return { decision: "written", authPath: result.authPath, result };
|
||||
}
|
||||
|
||||
/** Read + parse an existing ~/.codex/auth.json; null when absent/unreadable. */
|
||||
async function readExistingCodexAuth(authPath: string): Promise<CodexAuthFilePayload | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(authPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const rec = toRecord(parsed);
|
||||
const tokens = toRecord(rec.tokens);
|
||||
if (!toNonEmptyString(tokens.access_token)) return null;
|
||||
return parsed as CodexAuthFilePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored auth.json is "stale" when its access_token is at/past the refresh
|
||||
* buffer. Prefer the JWT `exp` claim on the access_token; fall back to
|
||||
* `last_refresh` + a conservative validity window; if neither is parseable,
|
||||
* treat as NOT stale (never clobber on ambiguity).
|
||||
*/
|
||||
function isCodexAuthStale(payload: CodexAuthFilePayload): boolean {
|
||||
const accessToken = toNonEmptyString(payload?.tokens?.access_token);
|
||||
if (accessToken) {
|
||||
const claims = decodeJwtPayload(accessToken);
|
||||
const exp = claims && typeof claims.exp === "number" ? claims.exp : null;
|
||||
if (exp) {
|
||||
const expiresAtMs = exp * 1000;
|
||||
return expiresAtMs - Date.now() <= CODEX_REFRESH_BUFFER_MS;
|
||||
}
|
||||
}
|
||||
// No usable exp claim — fall back to last_refresh age. Codex access tokens are
|
||||
// short-lived (~hours); if the file hasn't refreshed in > 6h, consider it stale.
|
||||
const lastRefresh = toNonEmptyString(payload?.last_refresh);
|
||||
if (lastRefresh) {
|
||||
const refreshedMs = new Date(lastRefresh).getTime();
|
||||
if (!Number.isNaN(refreshedMs)) {
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
return Date.now() - refreshedMs >= SIX_HOURS_MS;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry";
|
||||
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { isSecurityBlockError } from "@/lib/providers/validation/transport";
|
||||
import {
|
||||
SAFE_OUTBOUND_FETCH_PRESETS,
|
||||
SafeOutboundFetchError,
|
||||
@@ -62,7 +63,7 @@ function toValidationErrorResult(error: unknown) {
|
||||
...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"
|
||||
? { timeout: true }
|
||||
: {}),
|
||||
...(statusCode === 400 ? { securityBlocked: true } : {}),
|
||||
...(isSecurityBlockError(error) ? { securityBlocked: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const DEFAULT_CODEX_CLIENT_VERSION = "0.146.0";
|
||||
// Kept in lockstep with the codex CLI actually installed in the OmniRoute image
|
||||
// (bin/omniroute-fix.Containerfile installs `codex` latest; app-server runtime is
|
||||
// 0.149.0 as of 2026-08-22). When the image's codex is bumped, refresh this so the
|
||||
// fingerprint OpenAI sees from the OAuth/Responses face matches the real client
|
||||
// version. Overridable per-deployment via the CODEX_CLIENT_VERSION env.
|
||||
export const DEFAULT_CODEX_CLIENT_VERSION = "0.149.0";
|
||||
export const CODEX_CLI_RS_ORIGINATOR = "codex_cli_rs";
|
||||
|
||||
export function getCodexCliRsHeaders(
|
||||
|
||||
@@ -401,6 +401,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
requiresRestart: false,
|
||||
warningLevel: "info",
|
||||
},
|
||||
{
|
||||
key: "OMNIROUTE_CODEX_APP_SERVER_ENABLED",
|
||||
label: "Codex App-Server Transport",
|
||||
description:
|
||||
"Allow Codex to use the local app-server WebSocket JSON-RPC transport (codexTransport=app-server). When off, connections opted into app-server fall back to Codex's other transports.",
|
||||
descriptionI18nKey: "featureFlagOmnirouteCodexAppServerEnabledDescription",
|
||||
category: "runtime",
|
||||
defaultValue: "true",
|
||||
type: "boolean",
|
||||
requiresRestart: false,
|
||||
warningLevel: "info",
|
||||
},
|
||||
{
|
||||
key: "OMNIROUTE_EMERGENCY_FALLBACK",
|
||||
label: "Emergency Fallback",
|
||||
|
||||
@@ -175,6 +175,30 @@ export const NOAUTH_PROVIDERS = {
|
||||
text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.",
|
||||
},
|
||||
},
|
||||
"codex-app-server": {
|
||||
id: "codex-app-server",
|
||||
alias: "cxa",
|
||||
name: "OpenAI Codex (App-Server)",
|
||||
icon: "code",
|
||||
color: "#10A37F",
|
||||
textIcon: "CA",
|
||||
website: "https://developers.openai.com/codex/cli",
|
||||
noAuth: true,
|
||||
hasFree: false,
|
||||
serviceKinds: ["llm"],
|
||||
isLocalCli: true,
|
||||
// No subscriptionRisk / riskNoticeVariant: unlike the `codex` provider (which
|
||||
// replays your ChatGPT/OpenAI session token to the API), this transport drives
|
||||
// the Codex CLI's own `codex app-server` over JSON-RPC/WebSocket. The CLI owns
|
||||
// and self-refreshes its OAuth (~/.codex/auth.json) exactly like an interactive
|
||||
// `codex` session — OmniRoute never replays a token to the API — so the
|
||||
// "official session not authorized for proxy use" caveat does not apply.
|
||||
authHint:
|
||||
"No token stored by OmniRoute. The Codex CLI app-server manages its own ChatGPT sign-in (~/.codex/auth.json, auto-refreshed). Use \u201cSign in with ChatGPT\u201d if the CLI is not yet authenticated.",
|
||||
notice: {
|
||||
text: "OpenAI Codex (App-Server) drives the Codex CLI's local app-server (JSON-RPC over WebSocket). The CLI self-manages its OpenAI OAuth, so OmniRoute never sees or replays your token. Requires the codex CLI reachable at the configured app-server URL; sign in via the CLI or the dashboard \u201cSign in with ChatGPT\u201d action.",
|
||||
},
|
||||
},
|
||||
uncloseai: {
|
||||
id: "uncloseai",
|
||||
alias: "unc",
|
||||
|
||||
@@ -338,6 +338,7 @@ export const CLI_TOOL_ALIASES: Readonly<Record<string, string>> = {
|
||||
"claude-code": "claude",
|
||||
"openai-codex": "codex",
|
||||
openai: "codex",
|
||||
"codex-app-server": "codex",
|
||||
cn: "continue",
|
||||
qodercli: "qoder",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user