diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 96ee010aee9..5a6175a6b92 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -387,6 +387,40 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("applies the default OpenAI Codex OAuth profile when no profile id is explicit", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:default", + credential: { + type: "oauth", + provider: "openai-codex", + access: "default-access-token", + refresh: "default-refresh-token", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "account-default", + email: "codex-default@example.test", + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "default-access-token", + chatgptAccountId: "account-default", + chatgptPlanType: null, + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("refreshes an expired OpenAI Codex OAuth profile before app-server login", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index af98889b916..41363a256f9 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -3,8 +3,10 @@ import path from "node:path"; import { ensureAuthProfileStore, loadAuthProfileStoreForSecretsRuntime, + resolveAuthProfileOrder, resolveProviderIdForAuth, resolveApiKeyForProfile, + resolveOpenClawAgentDir, resolvePersistedAuthProfileOwnerAgentDir, saveAuthProfileStore, type AuthProfileCredential, @@ -28,6 +30,8 @@ const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY"; const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]; const CODEX_APP_SERVER_ISOLATION_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR]; +type AuthProfileOrderConfig = Parameters[0]["cfg"]; + export async function bridgeCodexAppServerStartOptions(params: { startOptions: CodexAppServerStartOptions; agentDir: string; @@ -41,15 +45,49 @@ export async function bridgeCodexAppServerStartOptions(params: { params.agentDir, ); const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); + const authProfileId = resolveCodexAppServerAuthProfileId({ + authProfileId: params.authProfileId, + store, + }); const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({ store, - authProfileId: params.authProfileId, + authProfileId, }); return shouldClearInheritedOpenAiApiKey ? withClearedEnvironmentVariables(isolatedStartOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS) : isolatedStartOptions; } +export function resolveCodexAppServerAuthProfileId(params: { + authProfileId?: string; + store: ReturnType; + config?: AuthProfileOrderConfig; +}): string | undefined { + const requested = params.authProfileId?.trim(); + if (requested) { + return requested; + } + return resolveAuthProfileOrder({ + cfg: params.config, + store: params.store, + provider: CODEX_APP_SERVER_AUTH_PROVIDER, + })[0]?.trim(); +} + +export function resolveCodexAppServerAuthProfileIdForAgent(params: { + authProfileId?: string; + agentDir?: string; + config?: AuthProfileOrderConfig; +}): string | undefined { + const agentDir = params.agentDir?.trim() || resolveOpenClawAgentDir(); + const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + return resolveCodexAppServerAuthProfileId({ + authProfileId: params.authProfileId, + store, + config: params.config, + }); +} + export function resolveCodexAppServerHomeDir(agentDir: string): string { return path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME); } @@ -153,11 +191,14 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: { authProfileId?: string; forceOAuthRefresh?: boolean; }): Promise { - const profileId = params.authProfileId?.trim(); + const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); + const profileId = resolveCodexAppServerAuthProfileId({ + authProfileId: params.authProfileId, + store, + }); if (!profileId) { return undefined; } - const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); const credential = store.profiles[profileId]; if (!credential) { throw new Error(`Codex app-server auth profile "${profileId}" was not found.`); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 6e737f437fc..50783c861a8 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -40,7 +40,11 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; -import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js"; +import { + refreshCodexAppServerAuthTokens, + resolveCodexAppServerAuthProfileId, + resolveCodexAppServerAuthProfileIdForAgent, +} from "./auth-bridge.js"; import { createCodexAppServerClientFactoryTestHooks, defaultCodexAppServerClientFactory, @@ -377,16 +381,31 @@ export async function runCodexAppServerAttempt( agentId: params.agentId, }); const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); - const runtimeParams = { ...params, sessionKey: sandboxSessionKey }; + const startupBinding = await readCodexAppServerBinding(params.sessionFile); + const startupAuthProfileCandidate = + params.runtimePlan?.auth.forwardedAuthProfileId ?? + params.authProfileId ?? + startupBinding?.authProfileId; + const startupAuthProfileId = params.authProfileStore + ? resolveCodexAppServerAuthProfileId({ + authProfileId: startupAuthProfileCandidate, + store: params.authProfileStore, + config: params.config, + }) + : resolveCodexAppServerAuthProfileIdForAgent({ + authProfileId: startupAuthProfileCandidate, + agentDir, + config: params.config, + }); + const runtimeParams = { + ...params, + sessionKey: sandboxSessionKey, + ...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}), + }; const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine) ? params.contextEngine : undefined; let yieldDetected = false; - const startupBinding = await readCodexAppServerBinding(params.sessionFile); - const startupAuthProfileId = - params.runtimePlan?.auth.forwardedAuthProfileId ?? - params.authProfileId ?? - startupBinding?.authProfileId; const tools = await buildDynamicTools({ params, resolvedWorkspace, @@ -553,7 +572,7 @@ export async function runCodexAppServerAttempt( }); const startupThread = await startOrResumeThread({ client: startupClient, - params, + params: runtimeParams, cwd: effectiveWorkspace, dynamicTools: toolBridge.specs, appServer, diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 9507c6fb1a5..f8524cf8601 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -1,5 +1,70 @@ +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import { describe, expect, it } from "vitest"; -import { resolveReasoningEffort } from "./thread-lifecycle.js"; +import { + buildThreadResumeParams, + buildThreadStartParams, + resolveReasoningEffort, +} from "./thread-lifecycle.js"; + +function createAttemptParams(params: { + provider: string; + authProfileId?: string; +}): EmbeddedRunAttemptParams { + return { + provider: params.provider, + modelId: "gpt-5.4", + authProfileId: params.authProfileId, + } as EmbeddedRunAttemptParams; +} + +function createAppServerOptions() { + return { + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: "workspace-write", + } as const; +} + +describe("Codex app-server model provider selection", () => { + it.each(["openai", "openai-codex"])( + "omits public %s modelProvider when forwarding native Codex auth on thread/start", + (provider) => { + const request = buildThreadStartParams( + createAttemptParams({ provider, authProfileId: "openai-codex:work" }), + { + cwd: "/repo", + dynamicTools: [], + appServer: createAppServerOptions() as never, + developerInstructions: "test instructions", + }, + ); + + expect(request).not.toHaveProperty("modelProvider"); + }, + ); + + 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", + }); + + expect(request).not.toHaveProperty("modelProvider"); + }); + + it("keeps public OpenAI modelProvider when no native Codex auth profile is selected", () => { + const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), { + cwd: "/repo", + dynamicTools: [], + appServer: createAppServerOptions() as never, + developerInstructions: "test instructions", + }); + + expect(request).toMatchObject({ modelProvider: "openai" }); + }); +}); describe("resolveReasoningEffort (#71946)", () => { describe("modern Codex models (none/low/medium/high/xhigh enum)", () => { diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index bb3741f0588..61f151d9415 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -25,6 +25,7 @@ import { } from "./protocol.js"; import { clearCodexAppServerBinding, + isCodexAppServerNativeAuthProfileId, readCodexAppServerBinding, writeCodexAppServerBinding, type CodexAppServerThreadBinding, @@ -57,19 +58,24 @@ export async function startOrResumeThread(params: { await clearCodexAppServerBinding(params.params.sessionFile); } else { try { + const authProfileId = params.params.authProfileId ?? binding.authProfileId; const response = assertCodexThreadResumeResponse( await params.client.request( "thread/resume", buildThreadResumeParams(params.params, { threadId: binding.threadId, + authProfileId, appServer: params.appServer, developerInstructions: params.developerInstructions, config: params.config, }), ), ); - const boundAuthProfileId = params.params.authProfileId ?? binding.authProfileId; - const fallbackModelProvider = resolveCodexAppServerModelProvider(params.params.provider); + const boundAuthProfileId = authProfileId; + const fallbackModelProvider = resolveCodexAppServerModelProvider({ + provider: params.params.provider, + authProfileId: boundAuthProfileId, + }); await writeCodexAppServerBinding(params.params.sessionFile, { threadId: response.thread.id, cwd: params.cwd, @@ -112,7 +118,10 @@ export async function startOrResumeThread(params: { }), ), ); - const modelProvider = resolveCodexAppServerModelProvider(params.params.provider); + const modelProvider = resolveCodexAppServerModelProvider({ + provider: params.params.provider, + authProfileId: params.params.authProfileId, + }); const createdAt = new Date().toISOString(); await writeCodexAppServerBinding(params.params.sessionFile, { threadId: response.thread.id, @@ -147,7 +156,10 @@ export function buildThreadStartParams( config?: JsonObject; }, ): CodexThreadStartParams { - const modelProvider = resolveCodexAppServerModelProvider(params.provider); + const modelProvider = resolveCodexAppServerModelProvider({ + provider: params.provider, + authProfileId: params.authProfileId, + }); return { model: params.modelId, ...(modelProvider ? { modelProvider } : {}), @@ -169,12 +181,16 @@ export function buildThreadResumeParams( params: EmbeddedRunAttemptParams, options: { threadId: string; + authProfileId?: string; appServer: CodexAppServerRuntimeOptions; developerInstructions?: string; config?: JsonObject; }, ): CodexThreadResumeParams { - const modelProvider = resolveCodexAppServerModelProvider(params.provider); + const modelProvider = resolveCodexAppServerModelProvider({ + provider: params.provider, + authProfileId: options.authProfileId ?? params.authProfileId, + }); return { threadId: options.threadId, model: params.modelId, @@ -326,14 +342,28 @@ function buildUserInput( ]; } -function resolveCodexAppServerModelProvider(provider: string): string | undefined { - const normalized = provider.trim(); - if (!normalized || normalized === "codex") { +function resolveCodexAppServerModelProvider(params: { + provider: string; + authProfileId?: string; +}): string | undefined { + const normalized = params.provider.trim(); + const normalizedLower = normalized.toLowerCase(); + if (!normalized || normalizedLower === "codex") { // `codex` is OpenClaw's virtual provider; let Codex app-server keep its // native provider/auth selection instead of forcing the legacy OpenAI path. return undefined; } - return normalized === "openai-codex" ? "openai" : normalized; + if ( + isCodexAppServerNativeAuthProfileId(params.authProfileId) && + (normalizedLower === "openai" || normalizedLower === "openai-codex") + ) { + // When OpenClaw is forwarding ChatGPT/Codex OAuth, forcing the public + // OpenAI model provider makes app-server call api.openai.com without the + // ChatGPT bearer and fails with "Missing bearer or basic authentication". + // Omit the provider so app-server keeps its native account-backed route. + return undefined; + } + return normalizedLower === "openai-codex" ? "openai" : normalized; } // Modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) use the diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 576c1fb74d7..c33ae755176 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -339,6 +339,7 @@ async function bindConversation( const authProfileId = existingBinding?.authProfileId; const startParams: Parameters[0] = { pluginConfig, + config: ctx.config, sessionFile: ctx.sessionFile, workspaceDir, threadId: parsed.threadId, diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index b3a9350b21e..9b453a5052e 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -1422,6 +1422,7 @@ describe("codex command", () => { }); expect(startCodexConversationThread).toHaveBeenCalledWith({ pluginConfig: undefined, + config: {}, sessionFile, workspaceDir: "/repo", threadId: "thread-123", diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index 5ef42eeb0cb..9ecaeb7d09e 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -7,7 +7,19 @@ const sharedClientMocks = vi.hoisted(() => ({ getSharedCodexAppServerClient: vi.fn(), })); +const agentRuntimeMocks = vi.hoisted(() => ({ + ensureAuthProfileStore: vi.fn(), + loadAuthProfileStoreForSecretsRuntime: vi.fn(), + resolveApiKeyForProfile: vi.fn(), + resolveAuthProfileOrder: vi.fn(), + resolveOpenClawAgentDir: vi.fn(() => "/agent"), + resolvePersistedAuthProfileOwnerAgentDir: vi.fn(), + resolveProviderIdForAuth: vi.fn((provider: string) => provider), + saveAuthProfileStore: vi.fn(), +})); + vi.mock("./app-server/shared-client.js", () => sharedClientMocks); +vi.mock("openclaw/plugin-sdk/agent-runtime", () => agentRuntimeMocks); import { handleCodexConversationBindingResolved, @@ -24,9 +36,74 @@ describe("codex conversation binding", () => { afterEach(async () => { sharedClientMocks.getSharedCodexAppServerClient.mockReset(); + agentRuntimeMocks.ensureAuthProfileStore.mockReset(); + agentRuntimeMocks.loadAuthProfileStoreForSecretsRuntime.mockReset(); + agentRuntimeMocks.resolveApiKeyForProfile.mockReset(); + agentRuntimeMocks.resolveAuthProfileOrder.mockReset(); + agentRuntimeMocks.resolveOpenClawAgentDir.mockClear(); + agentRuntimeMocks.resolvePersistedAuthProfileOwnerAgentDir.mockReset(); + agentRuntimeMocks.resolveProviderIdForAuth.mockClear(); + agentRuntimeMocks.saveAuthProfileStore.mockReset(); await fs.rm(tempDir, { recursive: true, force: true }); }); + beforeEach(() => { + agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue([]); + agentRuntimeMocks.resolveOpenClawAgentDir.mockReturnValue("/agent"); + agentRuntimeMocks.resolveProviderIdForAuth.mockImplementation((provider: string) => provider); + }); + + it("uses the default Codex auth profile and omits the public OpenAI provider for new binds", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const config = { auth: { order: { "openai-codex": ["openai-codex:default"] } } }; + const requests: Array<{ method: string; params: Record }> = []; + agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + }, + }, + }); + agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue(["openai-codex:default"]); + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async (method: string, requestParams: Record) => { + requests.push({ method, params: requestParams }); + return { + thread: { id: "thread-new", cwd: tempDir }, + model: "gpt-5.4-mini", + }; + }), + }); + + await startCodexConversationThread({ + config: config as never, + sessionFile, + workspaceDir: tempDir, + model: "gpt-5.4-mini", + modelProvider: "openai", + }); + + expect(agentRuntimeMocks.resolveAuthProfileOrder).toHaveBeenCalledWith( + expect.objectContaining({ cfg: config, provider: "openai-codex" }), + ); + expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: "openai-codex:default" }), + ); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + method: "thread/start", + params: expect.objectContaining({ model: "gpt-5.4-mini" }), + }); + expect(requests[0]?.params).not.toHaveProperty("modelProvider"); + await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain( + '"authProfileId": "openai-codex:default"', + ); + }); + it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index 613f595817e..1a2a92eb848 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -5,6 +5,7 @@ import type { PluginHookInboundClaimEvent, } from "openclaw/plugin-sdk/plugin-entry"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; import { codexSandboxPolicyForTurn, @@ -49,6 +50,7 @@ type CodexConversationRunOptions = { type CodexConversationStartParams = { pluginConfig?: unknown; + config?: Parameters[0]["config"]; sessionFile: string; workspaceDir?: string; threadId?: string; @@ -81,7 +83,10 @@ export async function startCodexConversationThread( const workspaceDir = params.workspaceDir?.trim() || resolveCodexDefaultWorkspaceDir(params.pluginConfig); const existingBinding = await readCodexAppServerBinding(params.sessionFile); - const authProfileId = params.authProfileId ?? existingBinding?.authProfileId; + const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({ + authProfileId: params.authProfileId ?? existingBinding?.authProfileId, + config: params.config, + }); if (params.threadId?.trim()) { await attachExistingThread({ pluginConfig: params.pluginConfig,