fix: select Codex OAuth profile for bound app-server turns

This commit is contained in:
Kelaw - Keshav's Agent
2026-05-03 23:31:24 +05:30
committed by Peter Steinberger
parent 05d11a4318
commit 71f55214ec
9 changed files with 295 additions and 22 deletions

View File

@@ -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" }));

View File

@@ -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<typeof resolveAuthProfileOrder>[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<typeof ensureAuthProfileStore>;
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<LoginAccountParams | undefined> {
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.`);

View File

@@ -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,

View File

@@ -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)", () => {

View File

@@ -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

View File

@@ -339,6 +339,7 @@ async function bindConversation(
const authProfileId = existingBinding?.authProfileId;
const startParams: Parameters<CodexCommandDeps["startCodexConversationThread"]>[0] = {
pluginConfig,
config: ctx.config,
sessionFile: ctx.sessionFile,
workspaceDir,
threadId: parsed.threadId,

View File

@@ -1422,6 +1422,7 @@ describe("codex command", () => {
});
expect(startCodexConversationThread).toHaveBeenCalledWith({
pluginConfig: undefined,
config: {},
sessionFile,
workspaceDir: "/repo",
threadId: "thread-123",

View File

@@ -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<string, unknown> }> = [];
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<string, unknown>) => {
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(

View File

@@ -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<typeof resolveCodexAppServerAuthProfileIdForAgent>[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,