fix: keep codex oauth bridge extension-owned (#68284) (thanks @vincentkoc)

This commit is contained in:
Peter Steinberger
2026-04-18 20:56:38 +01:00
parent f1cc8f0cfc
commit 2fc429dfbf
27 changed files with 363 additions and 198 deletions

View File

@@ -42,6 +42,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -61,6 +62,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
idToken: "id-token",
email: "codex@example.com",
},
});
@@ -172,6 +174,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -201,6 +204,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
idToken: "id-token",
email: "codex@example.com",
},
});
@@ -217,6 +221,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -254,6 +259,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -281,6 +287,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
idToken: "id-token",
email: "codex@example.com",
},
});

View File

@@ -22,6 +22,7 @@ export const OPENAI_CODEX_DEFAULT_PROFILE_ID = `${PROVIDER_ID}:default`;
type CodexCliAuthFile = {
auth_mode?: unknown;
tokens?: {
id_token?: unknown;
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
@@ -76,7 +77,8 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean
a.displayName === b.displayName &&
a.enterpriseUrl === b.enterpriseUrl &&
a.projectId === b.projectId &&
a.accountId === b.accountId
a.accountId === b.accountId &&
a.idToken === b.idToken
);
}
@@ -131,6 +133,7 @@ export function readOpenAICodexCliOAuthProfile(params: {
}
const accountId = trimNonEmptyString(authFile.tokens?.account_id);
const idToken = trimNonEmptyString(authFile.tokens?.id_token);
const identity = resolveCodexAuthIdentity({ accessToken: access });
const credential: OAuthCredential = {
type: "oauth",
@@ -139,6 +142,7 @@ export function readOpenAICodexCliOAuthProfile(params: {
refresh,
expires: resolveCodexAccessTokenExpiry(access) ?? 0,
...(accountId ? { accountId } : {}),
...(idToken ? { idToken } : {}),
...(identity.email ? { email: identity.email } : {}),
...(identity.profileName ? { displayName: identity.profileName } : {}),
};

View File

@@ -36,10 +36,12 @@ describe("prepareOpenAICodexCliExecution", () => {
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-123",
idToken: "id-token",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const result = await prepareOpenAICodexCliExecution({
@@ -64,6 +66,7 @@ describe("prepareOpenAICodexCliExecution", () => {
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
@@ -90,6 +93,7 @@ describe("prepareOpenAICodexCliExecution", () => {
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
await expect(
@@ -124,6 +128,7 @@ describe("prepareOpenAICodexCliExecution", () => {
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
await expect(

View File

@@ -1,8 +1,56 @@
import crypto from "node:crypto";
import path from "node:path";
import type {
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
} from "openclaw/plugin-sdk/cli-backend";
import { prepareCodexAuthBridgeFromProfile } from "openclaw/plugin-sdk/codex-auth-bridge-runtime";
import {
ensureAuthProfileStoreForLocalUpdate,
type OAuthCredential,
} from "openclaw/plugin-sdk/provider-auth";
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
function isCodexBridgeableOAuthCredential(value: unknown): value is OAuthCredential {
return Boolean(
value &&
typeof value === "object" &&
value !== null &&
"type" in value &&
"provider" in value &&
"access" in value &&
"refresh" in value &&
value.type === "oauth" &&
value.provider === OPENAI_CODEX_PROVIDER_ID &&
typeof value.access === "string" &&
value.access.trim().length > 0 &&
typeof value.refresh === "string" &&
value.refresh.trim().length > 0,
);
}
function resolveCodexBridgeHome(agentDir: string, profileId: string): string {
const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16);
return path.join(agentDir, "cli-auth", "codex", digest);
}
function buildCodexAuthFile(credential: OAuthCredential): string {
return `${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
...(credential.idToken ? { id_token: credential.idToken } : {}),
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
export async function prepareOpenAICodexCliExecution(
ctx: CliBackendPrepareExecutionContext,
@@ -11,19 +59,23 @@ export async function prepareOpenAICodexCliExecution(
return null;
}
const bridge = await prepareCodexAuthBridgeFromProfile({
agentDir: ctx.agentDir,
authProfileId: ctx.authProfileId,
bridgeRoot: "cli-auth",
});
if (!bridge) {
const store = ensureAuthProfileStoreForLocalUpdate(ctx.agentDir);
const credential = store.profiles[ctx.authProfileId];
if (!isCodexBridgeableOAuthCredential(credential)) {
return null;
}
const codexHome = resolveCodexBridgeHome(ctx.agentDir, ctx.authProfileId);
await writePrivateSecretFileAtomic({
rootDir: ctx.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(credential),
});
return {
env: {
CODEX_HOME: bridge.codexHome,
CODEX_HOME: codexHome,
},
clearEnv: bridge.clearEnv,
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
};
}

View File

@@ -118,6 +118,41 @@ describe("openai codex provider", () => {
});
});
it("exposes Codex CLI auth as a runtime-only external profile", () => {
const provider = buildOpenAICodexProviderPlugin();
const credential = {
type: "oauth" as const,
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-123",
};
readOpenAICodexCliOAuthProfileMock.mockReturnValueOnce({
profileId: "openai-codex:default",
credential,
});
expect(
provider.resolveExternalAuthProfiles?.({
env: { CODEX_HOME: "/sandboxed/codex-home" } as NodeJS.ProcessEnv,
store: { version: 1, profiles: {} },
}),
).toEqual([
{
profileId: "openai-codex:default",
credential,
persistence: "runtime-only",
},
]);
expect(readOpenAICodexCliOAuthProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({ CODEX_HOME: "/sandboxed/codex-home" }),
store: { version: 1, profiles: {} },
}),
);
});
it("uses the provider auth context env when importing Codex CLI auth", async () => {
const provider = buildOpenAICodexProviderPlugin();
const importMethod = provider.auth?.find((method) => method.id === "import-codex-cli");
@@ -156,7 +191,7 @@ describe("openai codex provider", () => {
).resolves.toMatchObject({
profiles: [
{
profileId: "default:codex@example.com",
profileId: "openai-codex:default",
credential: expect.objectContaining({
provider: "openai-codex",
access: "access-token",

View File

@@ -5,9 +5,10 @@ import type {
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
ensureAuthProfileStore,
ensureAuthProfileStoreForLocalUpdate,
listProfilesForProvider,
type OAuthCredential,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login";
@@ -285,9 +286,7 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) {
async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) {
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env ?? process.env,
store: ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
}),
store: ensureAuthProfileStoreForLocalUpdate(ctx.agentDir),
});
if (!profile) {
throw new Error(
@@ -295,20 +294,38 @@ async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) {
);
}
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
return {
profiles: [{ profileId: profile.profileId, credential: profile.credential }],
configPatch: {
agents: {
defaults: {
models: {
[OPENAI_CODEX_DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: OPENAI_CODEX_DEFAULT_MODEL,
access: profile.credential.access,
refresh: profile.credential.refresh,
expires: profile.credential.expires,
email: profile.credential.email,
displayName: profile.credential.displayName,
profilePrefix: "default",
credentialExtra: profile.credential.accountId
? { accountId: profile.credential.accountId }
: {},
notes: ["Imported existing Codex CLI login into OpenClaw canonical auth."],
} satisfies ProviderAuthResult;
}
function ensureOpenAICodexCatalogAuthStore(ctx: { agentDir?: string; env?: NodeJS.ProcessEnv }) {
const store = ensureAuthProfileStoreForLocalUpdate(ctx.agentDir);
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env ?? process.env,
store,
});
if (!profile) {
return store;
}
return {
...store,
profiles: {
...store.profiles,
[profile.profileId]: profile.credential,
},
};
}
function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) {
@@ -350,9 +367,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
catalog: {
order: "profile",
run: async (ctx) => {
const authStore = ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
});
const authStore = ensureOpenAICodexCatalogAuthStore(ctx);
if (listProfilesForProvider(authStore, PROVIDER_ID).length === 0) {
return null;
}
@@ -395,6 +410,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
fetchUsageSnapshot: async (ctx) =>
await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn),
refreshOAuth: async (cred) => await refreshOpenAICodexOAuthCredential(cred),
resolveExternalAuthProfiles: (ctx) => {
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env,
store: ctx.store,
});
return profile ? [{ ...profile, persistence: "runtime-only" }] : [];
},
augmentModelCatalog: (ctx) => {
const gpt54Template = findCatalogTemplate({
entries: ctx.entries,