fix: resolve Codex native auth by profile provider

This commit is contained in:
Kelaw - Keshav's Agent
2026-05-04 03:23:21 +05:30
committed by Peter Steinberger
parent 12d90a26f7
commit 8ea04f994a
6 changed files with 329 additions and 80 deletions

View File

@@ -7,10 +7,26 @@ import {
readCodexAppServerBinding,
resolveCodexAppServerBindingPath,
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
} from "./session-binding.js";
let tempDir: string;
const nativeAuthLookup: Pick<CodexAppServerAuthProfileLookup, "authProfileStore"> = {
authProfileStore: {
version: 1,
profiles: {
work: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
};
describe("codex app-server session binding", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-binding-"));
@@ -46,21 +62,25 @@ describe("codex app-server session binding", () => {
it("does not persist public OpenAI as the provider for Codex-native auth bindings", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-123",
cwd: tempDir,
authProfileId: "openai-codex:work",
model: "gpt-5.4-mini",
modelProvider: "openai",
});
await writeCodexAppServerBinding(
sessionFile,
{
threadId: "thread-123",
cwd: tempDir,
authProfileId: "work",
model: "gpt-5.4-mini",
modelProvider: "openai",
},
nativeAuthLookup,
);
const raw = await fs.readFile(resolveCodexAppServerBindingPath(sessionFile), "utf8");
const binding = await readCodexAppServerBinding(sessionFile);
const binding = await readCodexAppServerBinding(sessionFile, nativeAuthLookup);
expect(raw).not.toContain('"modelProvider": "openai"');
expect(binding).toMatchObject({
threadId: "thread-123",
authProfileId: "openai-codex:work",
authProfileId: "work",
model: "gpt-5.4-mini",
});
expect(binding?.modelProvider).toBeUndefined();
@@ -75,7 +95,7 @@ describe("codex app-server session binding", () => {
threadId: "thread-123",
sessionFile,
cwd: tempDir,
authProfileId: "openai-codex:work",
authProfileId: "work",
model: "gpt-5.4-mini",
modelProvider: "openai",
createdAt: "2026-05-03T00:00:00.000Z",
@@ -83,12 +103,53 @@ describe("codex app-server session binding", () => {
})}\n`,
);
const binding = await readCodexAppServerBinding(sessionFile);
const binding = await readCodexAppServerBinding(sessionFile, nativeAuthLookup);
expect(binding?.authProfileId).toBe("openai-codex:work");
expect(binding?.authProfileId).toBe("work");
expect(binding?.modelProvider).toBeUndefined();
});
it("does not infer native Codex auth from the profile id prefix", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(
sessionFile,
{
threadId: "thread-123",
cwd: tempDir,
authProfileId: "openai-codex:work",
model: "gpt-5.4-mini",
modelProvider: "openai",
},
{
authProfileStore: {
version: 1,
profiles: {
"openai-codex:work": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
},
},
},
);
const binding = await readCodexAppServerBinding(sessionFile, {
authProfileStore: {
version: 1,
profiles: {
"openai-codex:work": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
},
},
});
expect(binding?.modelProvider).toBe("openai");
});
it("clears missing bindings without throwing", async () => {
const sessionFile = path.join(tempDir, "missing.json");
await clearCodexAppServerBinding(sessionFile);

View File

@@ -1,11 +1,27 @@
import fs from "node:fs/promises";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
ensureAuthProfileStore,
resolveOpenClawAgentDir,
resolveProviderIdForAuth,
type AuthProfileStore,
} from "openclaw/plugin-sdk/agent-runtime";
import type { CodexAppServerApprovalPolicy, CodexAppServerSandboxMode } from "./config.js";
import type { CodexServiceTier } from "./protocol.js";
const CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER = "openai-codex";
const PUBLIC_OPENAI_MODEL_PROVIDER = "openai";
type ProviderAuthAliasLookupParams = Parameters<typeof resolveProviderIdForAuth>[1];
type ProviderAuthAliasConfig = NonNullable<ProviderAuthAliasLookupParams>["config"];
export type CodexAppServerAuthProfileLookup = {
authProfileId?: string;
authProfileStore?: AuthProfileStore;
agentDir?: string;
config?: ProviderAuthAliasConfig;
};
export type CodexAppServerThreadBinding = {
schemaVersion: 1;
threadId: string;
@@ -28,6 +44,7 @@ export function resolveCodexAppServerBindingPath(sessionFile: string): string {
export async function readCodexAppServerBinding(
sessionFile: string,
lookup: Omit<CodexAppServerAuthProfileLookup, "authProfileId"> = {},
): Promise<CodexAppServerThreadBinding | undefined> {
const path = resolveCodexAppServerBindingPath(sessionFile);
let raw: string;
@@ -45,15 +62,18 @@ export async function readCodexAppServerBinding(
if (parsed.schemaVersion !== 1 || typeof parsed.threadId !== "string") {
return undefined;
}
const authProfileId =
typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined;
return {
schemaVersion: 1,
threadId: parsed.threadId,
sessionFile,
cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
authProfileId,
model: typeof parsed.model === "string" ? parsed.model : undefined,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
...lookup,
authProfileId,
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
}),
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
@@ -80,6 +100,7 @@ export async function writeCodexAppServerBinding(
> & {
createdAt?: string;
},
lookup: Omit<CodexAppServerAuthProfileLookup, "authProfileId"> = {},
): Promise<void> {
const now = new Date().toISOString();
const payload: CodexAppServerThreadBinding = {
@@ -90,6 +111,7 @@ export async function writeCodexAppServerBinding(
authProfileId: binding.authProfileId,
model: binding.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
...lookup,
authProfileId: binding.authProfileId,
modelProvider: binding.modelProvider,
}),
@@ -120,25 +142,44 @@ function isNotFound(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
}
export function isCodexAppServerNativeAuthProfileId(authProfileId: string | undefined): boolean {
const normalized = authProfileId?.trim().toLowerCase();
return Boolean(
normalized &&
(normalized === CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER ||
normalized.startsWith(`${CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER}:`)),
);
export function isCodexAppServerNativeAuthProfile(
lookup: CodexAppServerAuthProfileLookup,
): boolean {
const authProfileId = lookup.authProfileId?.trim();
if (!authProfileId) {
return false;
}
try {
const credential = resolveCodexAppServerAuthProfileCredential({
...lookup,
authProfileId,
});
return isCodexAppServerNativeAuthProvider({
provider: credential?.provider,
config: lookup.config,
});
} catch (error) {
embeddedAgentLog.debug("failed to resolve codex app-server auth profile provider", {
authProfileId,
error,
});
return false;
}
}
export function normalizeCodexAppServerBindingModelProvider(params: {
authProfileId?: string;
modelProvider?: string;
authProfileStore?: AuthProfileStore;
agentDir?: string;
config?: ProviderAuthAliasConfig;
}): string | undefined {
const modelProvider = params.modelProvider?.trim();
if (!modelProvider) {
return undefined;
}
if (
isCodexAppServerNativeAuthProfileId(params.authProfileId) &&
isCodexAppServerNativeAuthProfile(params) &&
modelProvider.toLowerCase() === PUBLIC_OPENAI_MODEL_PROVIDER
) {
return undefined;
@@ -146,6 +187,35 @@ export function normalizeCodexAppServerBindingModelProvider(params: {
return modelProvider;
}
function resolveCodexAppServerAuthProfileCredential(
lookup: CodexAppServerAuthProfileLookup,
): AuthProfileStore["profiles"][string] | undefined {
const authProfileId = lookup.authProfileId?.trim();
if (!authProfileId) {
return undefined;
}
const store = lookup.authProfileStore ?? loadCodexAppServerAuthProfileStore(lookup.agentDir);
return store.profiles[authProfileId];
}
function loadCodexAppServerAuthProfileStore(agentDir: string | undefined): AuthProfileStore {
return ensureAuthProfileStore(agentDir?.trim() || resolveOpenClawAgentDir(), {
allowKeychainPrompt: false,
});
}
function isCodexAppServerNativeAuthProvider(params: {
provider?: string;
config?: ProviderAuthAliasConfig;
}): boolean {
const provider = params.provider?.trim();
return Boolean(
provider &&
resolveProviderIdForAuth(provider, { config: params.config }) ===
CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER,
);
}
function readApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
return value === "never" ||
value === "on-request" ||

View File

@@ -9,11 +9,33 @@ import {
function createAttemptParams(params: {
provider: string;
authProfileId?: string;
authProfileProvider?: string;
authProfileProviders?: Record<string, string>;
}): EmbeddedRunAttemptParams {
const authProfileProviders =
params.authProfileProviders ??
(params.authProfileId
? { [params.authProfileId]: params.authProfileProvider ?? "openai-codex" }
: {});
return {
provider: params.provider,
modelId: "gpt-5.4",
authProfileId: params.authProfileId,
authProfileStore: {
version: 1,
profiles: Object.fromEntries(
Object.entries(authProfileProviders).map(([profileId, provider]) => [
profileId,
{
type: "oauth" as const,
provider,
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
]),
),
},
} as EmbeddedRunAttemptParams;
}
@@ -30,7 +52,7 @@ describe("Codex app-server model provider selection", () => {
"omits public %s modelProvider when forwarding native Codex auth on thread/start",
(provider) => {
const request = buildThreadStartParams(
createAttemptParams({ provider, authProfileId: "openai-codex:work" }),
createAttemptParams({ provider, authProfileId: "work" }),
{
cwd: "/repo",
dynamicTools: [],
@@ -44,16 +66,40 @@ describe("Codex app-server model provider selection", () => {
);
it("uses the bound native Codex auth profile when deciding thread/resume modelProvider", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
authProfileId: "openai-codex:bound",
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
});
const request = buildThreadResumeParams(
createAttemptParams({
provider: "openai",
authProfileProviders: { bound: "openai-codex" },
}),
{
threadId: "thread-1",
authProfileId: "bound",
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
},
);
expect(request).not.toHaveProperty("modelProvider");
});
it("does not infer native Codex auth from the profile id prefix", () => {
const request = buildThreadStartParams(
createAttemptParams({
provider: "openai",
authProfileId: "openai-codex:work",
authProfileProvider: "openai",
}),
{
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
},
);
expect(request).toMatchObject({ modelProvider: "openai" });
});
it("keeps public OpenAI modelProvider when no native Codex auth profile is selected", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
cwd: "/repo",

View File

@@ -25,9 +25,10 @@ import {
} from "./protocol.js";
import {
clearCodexAppServerBinding,
isCodexAppServerNativeAuthProfileId,
isCodexAppServerNativeAuthProfile,
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
@@ -41,7 +42,11 @@ export async function startOrResumeThread(params: {
config?: JsonObject;
}): Promise<CodexAppServerThreadBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const binding = await readCodexAppServerBinding(params.params.sessionFile);
const binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
@@ -75,16 +80,27 @@ export async function startOrResumeThread(params: {
const fallbackModelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId: boundAuthProfileId,
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
createdAt: binding.createdAt,
});
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
createdAt: binding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
);
return {
...binding,
threadId: response.thread.id,
@@ -121,17 +137,28 @@ export async function startOrResumeThread(params: {
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId: params.params.authProfileId,
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
const createdAt = new Date().toISOString();
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
createdAt,
});
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
);
return {
schemaVersion: 1,
threadId: response.thread.id,
@@ -159,6 +186,9 @@ export function buildThreadStartParams(
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.provider,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
agentDir: params.agentDir,
config: params.config,
});
return {
model: params.modelId,
@@ -190,6 +220,9 @@ export function buildThreadResumeParams(
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.provider,
authProfileId: options.authProfileId ?? params.authProfileId,
authProfileStore: params.authProfileStore,
agentDir: params.agentDir,
config: params.config,
});
return {
threadId: options.threadId,
@@ -345,6 +378,9 @@ function buildUserInput(
function resolveCodexAppServerModelProvider(params: {
provider: string;
authProfileId?: string;
authProfileStore?: CodexAppServerAuthProfileLookup["authProfileStore"];
agentDir?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): string | undefined {
const normalized = params.provider.trim();
const normalizedLower = normalized.toLowerCase();
@@ -354,7 +390,7 @@ function resolveCodexAppServerModelProvider(params: {
return undefined;
}
if (
isCodexAppServerNativeAuthProfileId(params.authProfileId) &&
isCodexAppServerNativeAuthProfile(params) &&
(normalizedLower === "openai" || normalizedLower === "openai-codex")
) {
// When OpenClaw is forwarding ChatGPT/Codex OAuth, forcing the public

View File

@@ -106,13 +106,25 @@ describe("codex conversation binding", () => {
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
work: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
});
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-old",
cwd: tempDir,
authProfileId: "openai-codex:work",
authProfileId: "work",
modelProvider: "openai",
}),
);
@@ -136,7 +148,7 @@ describe("codex conversation binding", () => {
});
expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "openai-codex:work" }),
expect.objectContaining({ authProfileId: "work" }),
);
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
@@ -145,7 +157,7 @@ describe("codex conversation binding", () => {
});
expect(requests[0]?.params).not.toHaveProperty("modelProvider");
await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain(
'"authProfileId": "openai-codex:work"',
'"authProfileId": "work"',
);
await expect(
fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),

View File

@@ -19,10 +19,11 @@ import {
} from "./app-server/protocol.js";
import {
clearCodexAppServerBinding,
isCodexAppServerNativeAuthProfileId,
isCodexAppServerNativeAuthProfile,
normalizeCodexAppServerBindingModelProvider,
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
} from "./app-server/session-binding.js";
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
import {
@@ -82,7 +83,9 @@ export async function startCodexConversationThread(
): Promise<CodexConversationBindingData> {
const workspaceDir =
params.workspaceDir?.trim() || resolveCodexDefaultWorkspaceDir(params.pluginConfig);
const existingBinding = await readCodexAppServerBinding(params.sessionFile);
const existingBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: params.authProfileId ?? existingBinding?.authProfileId,
config: params.config,
@@ -96,6 +99,7 @@ export async function startCodexConversationThread(
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
config: params.config,
});
} else {
await createThread({
@@ -105,6 +109,7 @@ export async function startCodexConversationThread(
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
config: params.config,
});
}
return createCodexConversationBindingData({
@@ -171,11 +176,13 @@ async function attachExistingThread(params: {
model?: string;
modelProvider?: string;
authProfileId?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
config: params.config,
});
const client = await getSharedCodexAppServerClient({
startOptions: runtime.start,
@@ -197,19 +204,26 @@ async function attachExistingThread(params: {
{ timeoutMs: runtime.requestTimeoutMs },
);
const thread = response.thread;
await writeCodexAppServerBinding(params.sessionFile, {
threadId: thread.id,
cwd: thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: thread.id,
cwd: thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
});
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
config: params.config,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
},
{
config: params.config,
},
);
}
async function createThread(params: {
@@ -219,11 +233,13 @@ async function createThread(params: {
model?: string;
modelProvider?: string;
authProfileId?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
config: params.config,
});
const client = await getSharedCodexAppServerClient({
startOptions: runtime.start,
@@ -247,19 +263,26 @@ async function createThread(params: {
},
{ timeoutMs: runtime.requestTimeoutMs },
);
await writeCodexAppServerBinding(params.sessionFile, {
threadId: response.thread.id,
cwd: response.thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: response.thread.id,
cwd: response.thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
});
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
config: params.config,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
},
{
config: params.config,
},
);
}
async function runBoundTurn(params: {
@@ -387,13 +410,14 @@ function enqueueBoundTurn<T>(key: string, run: () => Promise<T>): Promise<T> {
function resolveThreadRequestModelProvider(params: {
authProfileId?: string;
modelProvider?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): string | undefined {
const modelProvider = params.modelProvider?.trim();
if (!modelProvider || modelProvider.toLowerCase() === "codex") {
return undefined;
}
if (
isCodexAppServerNativeAuthProfileId(params.authProfileId) &&
isCodexAppServerNativeAuthProfile(params) &&
(modelProvider.toLowerCase() === "openai" || modelProvider.toLowerCase() === "openai-codex")
) {
return undefined;