mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
fix: keep codex oauth bridge extension-owned (#68284) (thanks @vincentkoc)
This commit is contained in:
@@ -41,10 +41,12 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct-123",
|
||||
idToken: "id-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
const result = await bridgeCodexAppServerStartOptions({
|
||||
@@ -73,6 +75,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
expect(authFile).toEqual({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
@@ -93,7 +96,9 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
args: ["app-server"],
|
||||
headers: { authorization: "Bearer dev-token" },
|
||||
};
|
||||
saveAuthProfileStore({ version: 1, profiles: {} }, agentDir);
|
||||
saveAuthProfileStore({ version: 1, profiles: {} }, agentDir, {
|
||||
filterExternalAuthProfiles: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
@@ -121,6 +126,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
const codexHome = resolveHashedCodexHome(agentDir, "openai-codex:default");
|
||||
|
||||
@@ -1,7 +1,54 @@
|
||||
import { prepareCodexAuthBridgeFromProfile } from "openclaw/plugin-sdk/codex-auth-bridge-runtime";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ensureAuthProfileStoreForLocalUpdate,
|
||||
type OAuthCredential,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
|
||||
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
|
||||
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, "harness-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 bridgeCodexAppServerStartOptions(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
@@ -9,21 +56,27 @@ export async function bridgeCodexAppServerStartOptions(params: {
|
||||
authProfileId?: string;
|
||||
}): Promise<CodexAppServerStartOptions> {
|
||||
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
|
||||
const bridge = await prepareCodexAuthBridgeFromProfile({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: profileId,
|
||||
bridgeRoot: "harness-auth",
|
||||
});
|
||||
if (!bridge) {
|
||||
const store = ensureAuthProfileStoreForLocalUpdate(params.agentDir);
|
||||
const credential = store.profiles[profileId];
|
||||
if (!isCodexBridgeableOAuthCredential(credential)) {
|
||||
return params.startOptions;
|
||||
}
|
||||
|
||||
const codexHome = resolveCodexBridgeHome(params.agentDir, profileId);
|
||||
await writePrivateSecretFileAtomic({
|
||||
rootDir: params.agentDir,
|
||||
filePath: path.join(codexHome, "auth.json"),
|
||||
content: buildCodexAuthFile(credential),
|
||||
});
|
||||
|
||||
return {
|
||||
...params.startOptions,
|
||||
env: {
|
||||
...params.startOptions.env,
|
||||
CODEX_HOME: bridge.codexHome,
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
clearEnv: Array.from(new Set([...(params.startOptions.clearEnv ?? []), ...bridge.clearEnv])),
|
||||
clearEnv: Array.from(
|
||||
new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -532,7 +532,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: {
|
||||
request: async (method) => {
|
||||
request: async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
return { thread: { id: "thread-existing" }, modelProvider: "openai" };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user