diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 29095089df5..62b8955f6c5 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -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); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 2d710ff4a57..8dec6cd7252 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -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; diff --git a/extensions/openai/openai-codex-cli-bridge.test.ts b/extensions/openai/openai-codex-cli-bridge.test.ts index ebff520c93d..f23197b0449 100644 --- a/extensions/openai/openai-codex-cli-bridge.test.ts +++ b/extensions/openai/openai-codex-cli-bridge.test.ts @@ -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")); diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index 1b24f878fc3..0c506f0f435 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -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 = {}, +): 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 { 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 { + 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;