Fix Codex auth handoff for the app-server harness (#69990)

* Codex: fix auth bridge token shape

* Codex: preserve selected auth tokens

* Codex: prefer selected profile id token

* Codex: honor inherited Codex home

---------

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
pashpashpash
2026-04-22 00:22:29 -07:00
committed by GitHub
parent a4cafde0da
commit 1dd3fb1611
4 changed files with 312 additions and 4 deletions

View File

@@ -89,6 +89,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
refresh_token: "refresh-token",
account_id: "acct-123",
},
last_refresh: expect.any(String),
});
if (process.platform !== "win32") {
const authStat = await fs.stat(path.join(result.env?.CODEX_HOME ?? "", "auth.json"));
@@ -96,6 +97,171 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("hydrates Codex-only auth fields from a matching Codex CLI auth file", async () => {
const sourceCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-source-home-"));
tempDirs.push(sourceCodexHome);
await fs.writeFile(
path.join(sourceCodexHome, "auth.json"),
`${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
id_token: "source-id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
last_refresh: "2026-04-22T00:00:00.000Z",
},
null,
2,
)}\n`,
);
const agentDir = await createAgentDirWithDefaultProfile({
accountId: "acct-123",
});
const result = await bridgeCodexAppServerStartOptions({
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
env: { CODEX_HOME: sourceCodexHome },
},
agentDir,
});
expect(result.env?.CODEX_HOME).not.toBe(sourceCodexHome);
const authFile = JSON.parse(
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
);
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
id_token: "source-id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
last_refresh: "2026-04-22T00:00:00.000Z",
});
});
it("keeps the selected profile tokens when hydrating from a same-account Codex CLI auth file", async () => {
const sourceCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-source-home-"));
tempDirs.push(sourceCodexHome);
await fs.writeFile(
path.join(sourceCodexHome, "auth.json"),
`${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
id_token: "source-id-token",
access_token: "stale-source-access-token",
refresh_token: "stale-source-refresh-token",
account_id: "acct-123",
},
last_refresh: "2026-04-22T00:00:00.000Z",
},
null,
2,
)}\n`,
);
const agentDir = await createAgentDirWithDefaultProfile({
access: "selected-profile-access-token",
refresh: "selected-profile-refresh-token",
accountId: "acct-123",
idToken: "selected-profile-id-token",
});
const result = await bridgeCodexAppServerStartOptions({
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
env: { CODEX_HOME: sourceCodexHome },
},
agentDir,
});
expect(result.env?.CODEX_HOME).not.toBe(sourceCodexHome);
const authFile = JSON.parse(
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
);
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
id_token: "selected-profile-id-token",
access_token: "selected-profile-access-token",
refresh_token: "selected-profile-refresh-token",
account_id: "acct-123",
},
last_refresh: "2026-04-22T00:00:00.000Z",
});
});
it("hydrates from inherited CODEX_HOME when start options do not override it", async () => {
const sourceCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-source-home-"));
tempDirs.push(sourceCodexHome);
await fs.writeFile(
path.join(sourceCodexHome, "auth.json"),
`${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
id_token: "source-id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
last_refresh: "2026-04-22T00:00:00.000Z",
},
null,
2,
)}\n`,
);
const previousCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = sourceCodexHome;
try {
const agentDir = await createAgentDirWithDefaultProfile({
accountId: "acct-123",
});
const result = await bridgeCodexAppServerStartOptions({
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
agentDir,
});
expect(result.env?.CODEX_HOME).not.toBe(sourceCodexHome);
const authFile = JSON.parse(
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
);
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
id_token: "source-id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
last_refresh: "2026-04-22T00:00:00.000Z",
});
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodexHome;
}
}
});
it("leaves start options unchanged when canonical oauth is unavailable", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
tempDirs.push(agentDir);

View File

@@ -13,6 +13,7 @@ export async function bridgeCodexAppServerStartOptions(params: {
agentDir: params.agentDir,
bridgeDir: "harness-auth",
profileId,
sourceCodexHome: params.startOptions.env?.CODEX_HOME,
});
if (!bridge) {
return params.startOptions;

View File

@@ -71,6 +71,7 @@ describe("prepareOpenAICodexCliExecution", () => {
refresh_token: "refresh-token",
account_id: "acct-123",
},
last_refresh: expect.any(String),
});
if (process.platform !== "win32") {
const authStat = await fs.stat(path.join(result?.env?.CODEX_HOME ?? "", "auth.json"));

View File

@@ -3,6 +3,7 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ensureAuthProfileStoreForLocalUpdate } from "../agents/auth-profiles/store.js";
@@ -198,6 +199,25 @@ export function isCodexBridgeableOAuthCredential(value: unknown): value is OAuth
);
}
type CodexAuthBridgeRecord = {
auth_mode?: unknown;
tokens?: {
id_token?: unknown;
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
};
last_refresh?: unknown;
OPENAI_API_KEY?: unknown;
};
type CodexAuthBridgeMaterial = {
accountId?: string;
idToken?: string;
lastRefresh?: string | number;
openaiApiKey?: string;
};
export function resolveCodexAuthBridgeHome(params: {
agentDir: string;
bridgeDir: string;
@@ -207,16 +227,27 @@ export function resolveCodexAuthBridgeHome(params: {
return path.join(params.agentDir, params.bridgeDir, "codex", digest);
}
export function buildCodexAuthBridgeFile(credential: OAuthCredential): string {
export function buildCodexAuthBridgeFile(
credential: OAuthCredential,
material: Partial<CodexAuthBridgeMaterial> = {},
): string {
const lastRefresh =
normalizeCodexAuthLastRefresh(material.lastRefresh) ?? new Date().toISOString();
const openaiApiKey = readCodexAuthString(material.openaiApiKey);
const idToken = readCodexAuthString(credential.idToken) ?? readCodexAuthString(material.idToken);
const accountId =
readCodexAuthString(credential.accountId) ?? readCodexAuthString(material.accountId);
return `${JSON.stringify(
{
auth_mode: "chatgpt",
...(openaiApiKey ? { OPENAI_API_KEY: openaiApiKey } : {}),
tokens: {
...(credential.idToken ? { id_token: credential.idToken } : {}),
...(idToken ? { id_token: idToken } : {}),
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
...(accountId ? { account_id: accountId } : {}),
},
last_refresh: lastRefresh,
},
null,
2,
@@ -227,6 +258,8 @@ export async function prepareCodexAuthBridge(params: {
agentDir: string;
bridgeDir: string;
profileId: string;
sourceCodexHome?: string;
env?: NodeJS.ProcessEnv;
}): Promise<PreparedCodexAuthBridge | undefined> {
const store = ensureAuthProfileStoreForLocalUpdate(params.agentDir);
const credential = store.profiles[params.profileId];
@@ -235,10 +268,15 @@ export async function prepareCodexAuthBridge(params: {
}
const codexHome = resolveCodexAuthBridgeHome(params);
const material = resolveCodexAuthBridgeMaterial({
credential,
sourceCodexHome: params.sourceCodexHome,
env: { ...process.env, ...params.env },
});
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthBridgeFile(credential),
content: buildCodexAuthBridgeFile(credential, material),
});
return {
@@ -247,6 +285,108 @@ export async function prepareCodexAuthBridge(params: {
};
}
function resolveCodexAuthBridgeMaterial(params: {
credential: OAuthCredential;
sourceCodexHome?: string;
env?: NodeJS.ProcessEnv;
}): Partial<CodexAuthBridgeMaterial> {
const source = readCodexAuthBridgeSourceFile({
codexHome: params.sourceCodexHome,
env: params.env,
});
if (!source || source.auth_mode !== "chatgpt") {
return {};
}
const tokens = source.tokens;
if (!tokens || typeof tokens !== "object") {
return {};
}
const access = readCodexAuthString(tokens.access_token);
const refresh = readCodexAuthString(tokens.refresh_token);
if (!access || !refresh) {
return {};
}
const accountId = readCodexAuthString(tokens.account_id);
if (!codexAuthSourceMatchesCredential(params.credential, { access, refresh, accountId })) {
return {};
}
const idToken = readCodexAuthString(tokens.id_token);
const lastRefresh = normalizeCodexAuthLastRefresh(source.last_refresh);
const openaiApiKey = readCodexAuthString(source.OPENAI_API_KEY);
return {
...(accountId ? { accountId } : {}),
...(idToken ? { idToken } : {}),
...(lastRefresh ? { lastRefresh } : {}),
...(openaiApiKey ? { openaiApiKey } : {}),
};
}
function codexAuthSourceMatchesCredential(
credential: OAuthCredential,
source: { access: string; refresh: string; accountId?: string },
): boolean {
if (credential.access === source.access && credential.refresh === source.refresh) {
return true;
}
const credentialAccountId = credential.accountId?.trim();
const sourceAccountId = source.accountId?.trim();
return Boolean(credentialAccountId && sourceAccountId && credentialAccountId === sourceAccountId);
}
function readCodexAuthBridgeSourceFile(params: {
codexHome?: string;
env?: NodeJS.ProcessEnv;
}): CodexAuthBridgeRecord | undefined {
const codexHome = resolveSourceCodexHome(params);
if (!codexHome) {
return undefined;
}
try {
const parsed = JSON.parse(fs.readFileSync(path.join(codexHome, "auth.json"), "utf8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as CodexAuthBridgeRecord)
: undefined;
} catch {
return undefined;
}
}
function resolveSourceCodexHome(params: {
codexHome?: string;
env?: NodeJS.ProcessEnv;
}): string | undefined {
const configured = params.codexHome?.trim() || params.env?.CODEX_HOME?.trim();
if (configured) {
return resolveTildePath(configured);
}
const home = os.homedir();
return home ? path.join(home, ".codex") : undefined;
}
function resolveTildePath(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return path.resolve(value);
}
function readCodexAuthString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function normalizeCodexAuthLastRefresh(value: unknown): string | number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return readCodexAuthString(value);
}
type ResolveApiKeyForProvider = typeof import("../agents/model-auth.js").resolveApiKeyForProvider;
type GetRuntimeAuthForModel =
typeof import("../plugins/runtime/runtime-model-auth.runtime.js").getRuntimeAuthForModel;