mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user