fix(codex): bootstrap app-server auth fallback

This commit is contained in:
Peter Steinberger
2026-04-27 23:58:15 +01:00
parent a1c88f3ebe
commit 5f15bea6ce
9 changed files with 254 additions and 49 deletions

View File

@@ -109,31 +109,26 @@ function createStartOptions(
};
}
async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(
path.join(codexHome, "auth.json"),
JSON.stringify({
tokens: {
access_token: "cli-access-token",
refresh_token: "cli-refresh-token",
account_id: "cli-account-123",
},
}),
);
}
describe("bridgeCodexAppServerStartOptions", () => {
it("clears an inherited OpenAI API key when local Codex CLI OAuth is available", async () => {
it("clears inherited API-key env vars when the default Codex profile is subscription auth", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const codexHome = path.join(agentDir, "codex-home");
const startOptions = createStartOptions({
env: { CODEX_HOME: "/tmp/source-codex-home", EXISTING: "1" },
env: { EXISTING: "1" },
clearEnv: ["FOO"],
});
vi.stubEnv("CODEX_HOME", codexHome);
try {
await writeCodexCliAuthFile(codexHome);
upsertAuthProfile({
agentDir,
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-123",
},
});
await expect(
bridgeCodexAppServerStartOptions({
@@ -142,7 +137,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
}),
).resolves.toEqual({
...startOptions,
clearEnv: ["FOO", "OPENAI_API_KEY"],
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
});
expect(startOptions.clearEnv).toEqual(["FOO"]);
await expect(fs.access(path.join(agentDir, "harness-auth"))).rejects.toMatchObject({
@@ -178,7 +173,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
}),
).resolves.toEqual({
...startOptions,
clearEnv: ["FOO", "OPENAI_API_KEY"],
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
@@ -207,7 +202,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
}),
).resolves.toEqual({
...startOptions,
clearEnv: ["FOO", "OPENAI_API_KEY"],
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
@@ -380,6 +375,105 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("falls back to CODEX_API_KEY when no auth profile and no Codex account is available", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async (method: string) => {
if (method === "account/read") {
return { account: null, requiresOpenaiAuth: true };
}
return { type: "apiKey" };
});
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key");
try {
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
});
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
expect(request).toHaveBeenNthCalledWith(2, "account/login/start", {
type: "apiKey",
apiKey: "codex-env-api-key",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("falls back to OPENAI_API_KEY when CODEX_API_KEY is not set", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async (method: string) => {
if (method === "account/read") {
return { account: null, requiresOpenaiAuth: true };
}
return { type: "apiKey" };
});
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key");
try {
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
});
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
expect(request).toHaveBeenNthCalledWith(2, "account/login/start", {
type: "apiKey",
apiKey: "openai-env-api-key",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps an existing app-server ChatGPT account over env API-key fallback", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async (method: string) => {
if (method === "account/read") {
return {
account: { type: "chatgpt", email: "codex@example.test", planType: "plus" },
requiresOpenaiAuth: true,
};
}
return { type: "apiKey" };
});
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
try {
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
});
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("account/read", { refreshToken: false });
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("skips env API-key fallback when app-server does not require OpenAI auth", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async (method: string) => {
if (method === "account/read") {
return { account: null, requiresOpenaiAuth: false };
}
return { type: "apiKey" };
});
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
try {
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
});
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("account/read", { refreshToken: false });
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies an OpenAI Codex token profile backed by a secret ref", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

View File

@@ -10,11 +10,14 @@ import {
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type { ChatgptAuthTokensRefreshResponse } from "./protocol-generated/typescript/v2/ChatgptAuthTokensRefreshResponse.js";
import type { GetAccountResponse } from "./protocol-generated/typescript/v2/GetAccountResponse.js";
import type { LoginAccountParams } from "./protocol-generated/typescript/v2/LoginAccountParams.js";
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex";
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
@@ -30,7 +33,7 @@ export async function bridgeCodexAppServerStartOptions(params: {
authProfileId: params.authProfileId,
});
return shouldClearInheritedOpenAiApiKey
? withClearedEnvironmentVariable(params.startOptions, OPENAI_API_KEY_ENV_VAR)
? withClearedEnvironmentVariables(params.startOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
: params.startOptions;
}
@@ -44,6 +47,13 @@ export async function applyCodexAppServerAuthProfile(params: {
authProfileId: params.authProfileId,
});
if (!loginParams) {
const fallbackLoginParams = await resolveCodexAppServerEnvApiKeyLoginParams({
client: params.client,
env: process.env,
});
if (fallbackLoginParams) {
await params.client.request("account/login/start", fallbackLoginParams);
}
return;
}
await params.client.request("account/login/start", loginParams);
@@ -105,6 +115,23 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
return loginParams;
}
async function resolveCodexAppServerEnvApiKeyLoginParams(params: {
client: CodexAppServerClient;
env: NodeJS.ProcessEnv;
}): Promise<LoginAccountParams | undefined> {
const apiKey = readFirstNonEmptyEnv(params.env, CODEX_APP_SERVER_API_KEY_ENV_VARS);
if (!apiKey) {
return undefined;
}
const response = await params.client.request<GetAccountResponse>("account/read", {
refreshToken: false,
});
if (response.account || !response.requiresOpenaiAuth) {
return undefined;
}
return { type: "apiKey", apiKey };
}
async function resolveLoginParamsForCredential(
profileId: string,
credential: AuthProfileCredential,
@@ -189,20 +216,31 @@ function isCodexSubscriptionCredential(credential: AuthProfileCredential | undef
return credential.type === "oauth" || credential.type === "token";
}
function withClearedEnvironmentVariable(
function withClearedEnvironmentVariables(
startOptions: CodexAppServerStartOptions,
envVar: string,
envVars: readonly string[],
): CodexAppServerStartOptions {
const clearEnv = startOptions.clearEnv ?? [];
if (clearEnv.includes(envVar)) {
const missingEnvVars = envVars.filter((envVar) => !clearEnv.includes(envVar));
if (missingEnvVars.length === 0) {
return startOptions;
}
return {
...startOptions,
clearEnv: [...clearEnv, envVar],
clearEnv: [...clearEnv, ...missingEnvVars],
};
}
function readFirstNonEmptyEnv(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function buildChatgptAuthTokensParams(
profileId: string,
credential: AuthProfileCredential,

View File

@@ -18,7 +18,6 @@ describe("Codex app-server config", () => {
transport: "websocket",
url: "ws://127.0.0.1:39175",
headers: { "X-Test": "yes" },
clearEnv: ["OPENAI_API_KEY"],
approvalPolicy: "on-request",
sandbox: "danger-full-access",
approvalsReviewer: "guardian_subagent",
@@ -41,12 +40,26 @@ describe("Codex app-server config", () => {
transport: "websocket",
url: "ws://127.0.0.1:39175",
headers: { "X-Test": "yes" },
clearEnv: ["OPENAI_API_KEY"],
}),
}),
);
});
it("ignores app-server environment clearing for websocket transports", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {
appServer: {
transport: "websocket",
url: "ws://127.0.0.1:39175",
clearEnv: ["OPENAI_API_KEY"],
},
},
env: {},
});
expect(runtime.start).not.toHaveProperty("clearEnv");
});
it("normalizes app-server environment variables to clear", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {

View File

@@ -214,7 +214,7 @@ export function resolveCodexAppServerRuntimeOptions(
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
...(clearEnv.length > 0 ? { clearEnv } : {}),
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
},
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
approvalPolicy:

View File

@@ -3,7 +3,10 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CodexAppServerStartOptions } from "./config.js";
import { resolveCodexAppServerSpawnInvocation } from "./transport-stdio.js";
import {
resolveCodexAppServerSpawnEnv,
resolveCodexAppServerSpawnInvocation,
} from "./transport-stdio.js";
const tempDirs: string[] = [];
@@ -86,3 +89,26 @@ describe("resolveCodexAppServerSpawnInvocation", () => {
});
});
});
describe("resolveCodexAppServerSpawnEnv", () => {
it("applies configured env overrides before clearing denied env vars", () => {
expect(
resolveCodexAppServerSpawnEnv(
{
env: {
OPENAI_API_KEY: "configured-openai-key",
KEEP: "override",
},
clearEnv: ["OPENAI_API_KEY", "CODEX_API_KEY", "MISSING"],
},
{
OPENAI_API_KEY: "parent-openai-key",
CODEX_API_KEY: "parent-codex-key",
KEEP: "parent",
},
),
).toEqual({
KEEP: "override",
});
});
});

View File

@@ -41,14 +41,22 @@ export function resolveCodexAppServerSpawnInvocation(
};
}
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
export function resolveCodexAppServerSpawnEnv(
options: Pick<CodexAppServerStartOptions, "env" | "clearEnv">,
baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const env = {
...process.env,
...baseEnv,
...options.env,
};
for (const key of options.clearEnv ?? []) {
delete env[key];
}
return env;
}
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
const env = resolveCodexAppServerSpawnEnv(options);
const invocation = resolveCodexAppServerSpawnInvocation(options, {
platform: process.platform,
env,