Skip to content

Commit 5ef8122

Browse files
committed
fix(codex): bridge cli api-key auth into app-server
1 parent 0f605ee commit 5ef8122

10 files changed

Lines changed: 270 additions & 31 deletions

File tree

extensions/codex/src/app-server/auth-bridge.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
refreshCodexAppServerAuthTokens,
1414
resolveCodexAppServerAuthAccountCacheKey,
1515
resolveCodexAppServerAuthProfileId,
16+
resolveCodexAppServerFallbackApiKeyCacheKey,
1617
resolveCodexAppServerHomeDir,
1718
resolveCodexAppServerNativeHomeDir,
1819
} from "./auth-bridge.js";
@@ -172,6 +173,17 @@ async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
172173
);
173174
}
174175

176+
async function writeCodexCliApiKeyAuthFile(codexHome: string): Promise<void> {
177+
await fs.mkdir(codexHome, { recursive: true });
178+
await fs.writeFile(
179+
path.join(codexHome, "auth.json"),
180+
`${JSON.stringify({
181+
auth_mode: "apikey",
182+
OPENAI_API_KEY: "cli-auth-json-api-key",
183+
})}\n`,
184+
);
185+
}
186+
175187
describe("bridgeCodexAppServerStartOptions", () => {
176188
it("sets agent-owned CODEX_HOME without overriding HOME for local app-server launches", async () => {
177189
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
@@ -1042,6 +1054,92 @@ describe("bridgeCodexAppServerStartOptions", () => {
10421054
}
10431055
});
10441056

1057+
it("uses Codex CLI api-key auth.json when no auth profile or env key exists", async () => {
1058+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
1059+
const agentDir = path.join(root, "agent");
1060+
const codexHome = path.join(root, "codex-cli");
1061+
const request = vi.fn(async (method: string) => {
1062+
if (method === "account/read") {
1063+
return { account: null, requiresOpenaiAuth: true };
1064+
}
1065+
return { type: "apiKey" };
1066+
});
1067+
vi.stubEnv("CODEX_HOME", codexHome);
1068+
vi.stubEnv("CODEX_API_KEY", "");
1069+
vi.stubEnv("OPENAI_API_KEY", "");
1070+
try {
1071+
await writeCodexCliApiKeyAuthFile(codexHome);
1072+
1073+
await applyCodexAppServerAuthProfile({
1074+
client: { request } as never,
1075+
agentDir,
1076+
startOptions: createStartOptions({
1077+
env: { CODEX_HOME: path.join(root, "isolated-codex-home") },
1078+
}),
1079+
});
1080+
1081+
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
1082+
expect(request).toHaveBeenNthCalledWith(2, "account/login/start", {
1083+
type: "apiKey",
1084+
apiKey: "cli-auth-json-api-key",
1085+
});
1086+
} finally {
1087+
await fs.rm(root, { recursive: true, force: true });
1088+
}
1089+
});
1090+
1091+
it("includes Codex CLI api-key auth.json in fallback app-server cache keys", async () => {
1092+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
1093+
const codexHome = path.join(root, "codex-cli");
1094+
try {
1095+
await writeCodexCliApiKeyAuthFile(codexHome);
1096+
1097+
const first = resolveCodexAppServerFallbackApiKeyCacheKey({
1098+
startOptions: createStartOptions(),
1099+
baseEnv: { CODEX_HOME: codexHome },
1100+
});
1101+
await fs.writeFile(
1102+
path.join(codexHome, "auth.json"),
1103+
`${JSON.stringify({
1104+
auth_mode: "apikey",
1105+
OPENAI_API_KEY: "second-cli-auth-json-api-key",
1106+
})}\n`,
1107+
);
1108+
const second = resolveCodexAppServerFallbackApiKeyCacheKey({
1109+
startOptions: createStartOptions(),
1110+
baseEnv: { CODEX_HOME: codexHome },
1111+
});
1112+
1113+
expect(first).toMatch(/^CODEX_AUTH_JSON:sha256:[a-f0-9]{64}$/);
1114+
expect(second).toMatch(/^CODEX_AUTH_JSON:sha256:[a-f0-9]{64}$/);
1115+
expect(second).not.toBe(first);
1116+
expect(first).not.toContain("cli-auth-json-api-key");
1117+
expect(second).not.toContain("second-cli-auth-json-api-key");
1118+
} finally {
1119+
await fs.rm(root, { recursive: true, force: true });
1120+
}
1121+
});
1122+
1123+
it("does not include Codex CLI api-key auth.json in websocket fallback cache keys", async () => {
1124+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
1125+
const codexHome = path.join(root, "codex-cli");
1126+
try {
1127+
await writeCodexCliApiKeyAuthFile(codexHome);
1128+
1129+
expect(
1130+
resolveCodexAppServerFallbackApiKeyCacheKey({
1131+
startOptions: createStartOptions({
1132+
transport: "websocket",
1133+
url: "ws://127.0.0.1:1455",
1134+
}),
1135+
baseEnv: { CODEX_HOME: codexHome },
1136+
}),
1137+
).toBeUndefined();
1138+
} finally {
1139+
await fs.rm(root, { recursive: true, force: true });
1140+
}
1141+
});
1142+
10451143
it("honors clearEnv before env API-key fallback", async () => {
10461144
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
10471145
const request = vi.fn(async (method: string) => {

extensions/codex/src/app-server/auth-bridge.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createHash } from "node:crypto";
2+
import fsSync from "node:fs";
23
import fs from "node:fs/promises";
4+
import os from "node:os";
35
import path from "node:path";
46
import {
57
ensureAuthProfileStore,
@@ -35,6 +37,8 @@ const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
3537
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
3638
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
3739
const CODEX_APP_SERVER_HOME_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
40+
const CODEX_AUTH_JSON_FILENAME = "auth.json";
41+
const CODEX_HOME_DIRNAME = ".codex";
3842

3943
type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[0]["cfg"];
4044

@@ -228,6 +232,20 @@ export function resolveCodexAppServerEnvApiKeyCacheKey(params: {
228232
return `${apiKey.key}:sha256:${hash.digest("hex")}`;
229233
}
230234

235+
export function resolveCodexAppServerFallbackApiKeyCacheKey(params: {
236+
startOptions: Pick<CodexAppServerStartOptions, "transport" | "env" | "clearEnv">;
237+
baseEnv?: NodeJS.ProcessEnv;
238+
platform?: NodeJS.Platform;
239+
}): string | undefined {
240+
if (params.startOptions.transport !== "stdio") {
241+
return undefined;
242+
}
243+
return (
244+
resolveCodexAppServerEnvApiKeyCacheKey(params) ??
245+
resolveCodexCliAuthFileApiKeyCacheKey(params.baseEnv ?? process.env)
246+
);
247+
}
248+
231249
function fingerprintApiKeyAuthProfileCacheKey(apiKey: string): string {
232250
const hash = createHash("sha256");
233251
hash.update("openclaw:codex:app-server-auth-profile-api-key:v1");
@@ -244,6 +262,14 @@ function fingerprintTokenAuthProfileCacheKey(accessToken: string): string {
244262
return `token:sha256:${hash.digest("hex")}`;
245263
}
246264

265+
function fingerprintCodexCliAuthFileApiKeyCacheKey(apiKey: string): string {
266+
const hash = createHash("sha256");
267+
hash.update("openclaw:codex:app-server-cli-auth-json-api-key:v1");
268+
hash.update("\0");
269+
hash.update(apiKey);
270+
return `CODEX_AUTH_JSON:sha256:${hash.digest("hex")}`;
271+
}
272+
247273
export function resolveCodexAppServerHomeDir(agentDir: string): string {
248274
return path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME);
249275
}
@@ -312,9 +338,10 @@ export async function applyCodexAppServerAuthProfile(params: {
312338
return;
313339
}
314340
const env = resolveCodexAppServerSpawnEnv(params.startOptions, process.env);
315-
const fallbackLoginParams = await resolveCodexAppServerEnvApiKeyLoginParams({
341+
const fallbackLoginParams = await resolveCodexAppServerFallbackApiKeyLoginParams({
316342
client: params.client,
317343
env,
344+
codexCliAuthEnv: process.env,
318345
});
319346
if (fallbackLoginParams) {
320347
await params.client.request("account/login/start", fallbackLoginParams);
@@ -392,11 +419,14 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
392419
return loginParams;
393420
}
394421

395-
async function resolveCodexAppServerEnvApiKeyLoginParams(params: {
422+
async function resolveCodexAppServerFallbackApiKeyLoginParams(params: {
396423
client: CodexAppServerClient;
397424
env: NodeJS.ProcessEnv;
425+
codexCliAuthEnv: NodeJS.ProcessEnv;
398426
}): Promise<CodexLoginAccountParams | undefined> {
399-
const apiKey = readFirstNonEmptyEnv(params.env, CODEX_APP_SERVER_API_KEY_ENV_VARS);
427+
const apiKey =
428+
readFirstNonEmptyEnv(params.env, CODEX_APP_SERVER_API_KEY_ENV_VARS) ??
429+
(await readCodexCliAuthFileApiKey(params.codexCliAuthEnv));
400430
if (!apiKey) {
401431
return undefined;
402432
}
@@ -409,6 +439,56 @@ async function resolveCodexAppServerEnvApiKeyLoginParams(params: {
409439
return { type: "apiKey", apiKey };
410440
}
411441

442+
function resolveCodexCliAuthFilePath(env: NodeJS.ProcessEnv): string {
443+
const configuredCodexHome = env[CODEX_HOME_ENV_VAR]?.trim();
444+
if (configuredCodexHome) {
445+
return path.join(resolveHomeRelativePath(configuredCodexHome, env), CODEX_AUTH_JSON_FILENAME);
446+
}
447+
const home = env[HOME_ENV_VAR]?.trim() || env.USERPROFILE?.trim() || os.homedir();
448+
return path.join(home, CODEX_HOME_DIRNAME, CODEX_AUTH_JSON_FILENAME);
449+
}
450+
451+
function resolveHomeRelativePath(value: string, env: NodeJS.ProcessEnv): string {
452+
if (value === "~" || value.startsWith("~/") || value.startsWith("~\\")) {
453+
const home = env[HOME_ENV_VAR]?.trim() || env.USERPROFILE?.trim() || os.homedir();
454+
return path.join(home, value.slice(value === "~" ? 1 : 2));
455+
}
456+
return value;
457+
}
458+
459+
function parseCodexCliAuthFileApiKey(raw: string): string | undefined {
460+
let parsed: unknown;
461+
try {
462+
parsed = JSON.parse(raw);
463+
} catch {
464+
return undefined;
465+
}
466+
if (!parsed || typeof parsed !== "object") {
467+
return undefined;
468+
}
469+
const apiKey = (parsed as Record<string, unknown>).OPENAI_API_KEY;
470+
return typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : undefined;
471+
}
472+
473+
async function readCodexCliAuthFileApiKey(env: NodeJS.ProcessEnv): Promise<string | undefined> {
474+
try {
475+
return parseCodexCliAuthFileApiKey(await fs.readFile(resolveCodexCliAuthFilePath(env), "utf8"));
476+
} catch {
477+
return undefined;
478+
}
479+
}
480+
481+
function resolveCodexCliAuthFileApiKeyCacheKey(env: NodeJS.ProcessEnv): string | undefined {
482+
try {
483+
const apiKey = parseCodexCliAuthFileApiKey(
484+
fsSync.readFileSync(resolveCodexCliAuthFilePath(env), "utf8"),
485+
);
486+
return apiKey ? fingerprintCodexCliAuthFileApiKeyCacheKey(apiKey) : undefined;
487+
} catch {
488+
return undefined;
489+
}
490+
}
491+
412492
async function resolveLoginParamsForCredential(
413493
profileId: string,
414494
credential: AuthProfileCredential,

extensions/codex/src/app-server/config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,11 @@ export function resolveCodexComputerUseConfig(
535535

536536
export function codexAppServerStartOptionsKey(
537537
options: CodexAppServerStartOptions,
538-
params: { authProfileId?: string; agentDir?: string } = {},
538+
params: {
539+
authProfileId?: string;
540+
agentDir?: string;
541+
fallbackApiKeyCacheKey?: string;
542+
} = {},
539543
): string {
540544
return JSON.stringify({
541545
transport: options.transport,
@@ -553,6 +557,7 @@ export function codexAppServerStartOptionsKey(
553557
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
554558
authProfileId: params.authProfileId ?? null,
555559
agentDir: params.agentDir ?? null,
560+
fallbackApiKeyCacheKey: params.fallbackApiKeyCacheKey ?? null,
556561
});
557562
}
558563

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
6666
import {
6767
refreshCodexAppServerAuthTokens,
6868
resolveCodexAppServerAuthAccountCacheKey,
69-
resolveCodexAppServerEnvApiKeyCacheKey,
69+
resolveCodexAppServerFallbackApiKeyCacheKey,
7070
resolveCodexAppServerHomeDir,
7171
resolveCodexAppServerAuthProfileId,
7272
resolveCodexAppServerAuthProfileIdForAgent,
@@ -1067,7 +1067,7 @@ export async function runCodexAppServerAttempt(
10671067
});
10681068
const startupEnvApiKeyCacheKey = startupAuthProfileId
10691069
? undefined
1070-
: resolveCodexAppServerEnvApiKeyCacheKey({
1070+
: resolveCodexAppServerFallbackApiKeyCacheKey({
10711071
startOptions: appServer.start,
10721072
});
10731073
const nodeExecBlocksNativeExecution = isCodexNativeExecutionBlockedByNodeExecHost(params, {
@@ -2722,9 +2722,8 @@ export async function runCodexAppServerAttempt(
27222722
const codexDiagnosticToolDefinitions = codexModelContentCapture.toolDefinitions
27232723
? buildCodexDiagnosticToolDefinitions(tools)
27242724
: undefined;
2725-
const codexModelContentPrivateData = (
2726-
modelContent: DiagnosticModelCallContent | undefined,
2727-
) => (modelContent && Object.keys(modelContent).length > 0 ? { modelContent } : undefined);
2725+
const codexModelContentPrivateData = (modelContent: DiagnosticModelCallContent | undefined) =>
2726+
modelContent && Object.keys(modelContent).length > 0 ? { modelContent } : undefined;
27282727
const buildCodexModelCallDiagnosticContent = (): DiagnosticModelCallContent | undefined => {
27292728
const modelContent = {
27302729
...(codexModelContentCapture.inputMessages
@@ -2768,9 +2767,7 @@ export async function runCodexAppServerAttempt(
27682767
...buildCodexModelCallDiagnosticContent(),
27692768
...(codexModelContentCapture.outputMessages
27702769
? {
2771-
outputMessages: result.lastAssistant
2772-
? [result.lastAssistant]
2773-
: result.assistantTexts,
2770+
outputMessages: result.lastAssistant ? [result.lastAssistant] : result.assistantTexts,
27742771
}
27752772
: {}),
27762773
}),

extensions/codex/src/app-server/shared-client.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
1212
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
1313
(params?: { authProfileId?: string }) => params?.authProfileId,
1414
),
15+
resolveCodexAppServerFallbackApiKeyCacheKey: vi.fn(() => undefined as string | undefined),
1516
resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions),
1617
embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() },
1718
resolveDefaultAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
@@ -21,6 +22,7 @@ vi.mock("./auth-bridge.js", () => ({
2122
applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile,
2223
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
2324
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
25+
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey,
2426
}));
2527

2628
vi.mock("./managed-binary.js", () => ({
@@ -129,6 +131,8 @@ describe("shared Codex app-server client", () => {
129131
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
130132
(params?: { authProfileId?: string }) => params?.authProfileId,
131133
);
134+
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear();
135+
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined);
132136
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
133137
mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(
134138
async (startOptions) => startOptions,
@@ -408,6 +412,32 @@ describe("shared Codex app-server client", () => {
408412
expect(first.process.stdin.destroyed).toBe(false);
409413
});
410414

415+
it("starts an independent shared client when fallback api-key auth changes", async () => {
416+
const first = createClientHarness();
417+
const second = createClientHarness();
418+
const startSpy = vi
419+
.spyOn(CodexAppServerClient, "start")
420+
.mockReturnValueOnce(first.client)
421+
.mockReturnValueOnce(second.client);
422+
mocks.resolveCodexAppServerFallbackApiKeyCacheKey
423+
.mockReturnValueOnce("api-key:first")
424+
.mockReturnValueOnce("api-key:second");
425+
426+
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
427+
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
428+
await sendEmptyModelList(first);
429+
await expect(firstList).resolves.toEqual({ models: [] });
430+
431+
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
432+
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
433+
await sendEmptyModelList(second);
434+
await expect(secondList).resolves.toEqual({ models: [] });
435+
436+
expect(startSpy).toHaveBeenCalledTimes(2);
437+
expect(first.process.stdin.destroyed).toBe(false);
438+
expect(second.process.stdin.destroyed).toBe(false);
439+
});
440+
411441
it("does not let one shared-client failure tear down another keyed client", async () => {
412442
const first = createClientHarness();
413443
const second = createClientHarness();

extensions/codex/src/app-server/shared-client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
applyCodexAppServerAuthProfile,
44
bridgeCodexAppServerStartOptions,
55
resolveCodexAppServerAuthProfileIdForAgent,
6+
resolveCodexAppServerFallbackApiKeyCacheKey,
67
} from "./auth-bridge.js";
78
import { CodexAppServerClient } from "./client.js";
89
import {
@@ -97,9 +98,13 @@ export async function getSharedCodexAppServerClient(options?: {
9798
authProfileId: usesNativeAuth ? null : authProfileId,
9899
config: options?.config,
99100
});
101+
const fallbackApiKeyCacheKey = authProfileId
102+
? undefined
103+
: resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions });
100104
const key = codexAppServerStartOptionsKey(startOptions, {
101105
authProfileId,
102106
agentDir: usesNativeAuth ? undefined : agentDir,
107+
fallbackApiKeyCacheKey,
103108
});
104109
const state = getSharedCodexAppServerClientState();
105110
const entry = getOrCreateSharedClientEntry(state, key);

0 commit comments

Comments
 (0)