fix: preserve Codex binding OAuth transport

This commit is contained in:
Kelaw - Keshav's Agent
2026-05-03 19:23:22 +05:30
parent 8c95664e55
commit f45dc3168a
6 changed files with 283 additions and 15 deletions

View File

@@ -44,6 +44,51 @@ describe("codex app-server session binding", () => {
await expect(fs.stat(resolveCodexAppServerBindingPath(sessionFile))).resolves.toBeTruthy();
});
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",
});
const raw = await fs.readFile(resolveCodexAppServerBindingPath(sessionFile), "utf8");
const binding = await readCodexAppServerBinding(sessionFile);
expect(raw).not.toContain('"modelProvider": "openai"');
expect(binding).toMatchObject({
threadId: "thread-123",
authProfileId: "openai-codex:work",
model: "gpt-5.4-mini",
});
expect(binding?.modelProvider).toBeUndefined();
});
it("normalizes older Codex-native bindings that stored public OpenAI provider", async () => {
const sessionFile = path.join(tempDir, "session.json");
await fs.writeFile(
resolveCodexAppServerBindingPath(sessionFile),
`${JSON.stringify({
schemaVersion: 1,
threadId: "thread-123",
sessionFile,
cwd: tempDir,
authProfileId: "openai-codex:work",
model: "gpt-5.4-mini",
modelProvider: "openai",
createdAt: "2026-05-03T00:00:00.000Z",
updatedAt: "2026-05-03T00:00:00.000Z",
})}\n`,
);
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.authProfileId).toBe("openai-codex:work");
expect(binding?.modelProvider).toBeUndefined();
});
it("clears missing bindings without throwing", async () => {
const sessionFile = path.join(tempDir, "missing.json");
await clearCodexAppServerBinding(sessionFile);

View File

@@ -3,6 +3,9 @@ import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-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";
export type CodexAppServerThreadBinding = {
schemaVersion: 1;
threadId: string;
@@ -49,7 +52,10 @@ export async function readCodexAppServerBinding(
cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
model: typeof parsed.model === "string" ? parsed.model : undefined,
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
}),
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
sandbox: readSandboxMode(parsed.sandbox),
serviceTier: readServiceTier(parsed.serviceTier),
@@ -83,7 +89,10 @@ export async function writeCodexAppServerBinding(
cwd: binding.cwd,
authProfileId: binding.authProfileId,
model: binding.model,
modelProvider: binding.modelProvider,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: binding.authProfileId,
modelProvider: binding.modelProvider,
}),
approvalPolicy: binding.approvalPolicy,
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
@@ -111,6 +120,32 @@ 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 normalizeCodexAppServerBindingModelProvider(params: {
authProfileId?: string;
modelProvider?: string;
}): string | undefined {
const modelProvider = params.modelProvider?.trim();
if (!modelProvider) {
return undefined;
}
if (
isCodexAppServerNativeAuthProfileId(params.authProfileId) &&
modelProvider.toLowerCase() === PUBLIC_OPENAI_MODEL_PROVIDER
) {
return undefined;
}
return modelProvider;
}
function readApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
return value === "never" ||
value === "on-request" ||

View File

@@ -335,14 +335,20 @@ async function bindConversation(
};
}
const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig);
const data = await deps.startCodexConversationThread({
const existingBinding = await deps.readCodexAppServerBinding(ctx.sessionFile);
const authProfileId = existingBinding?.authProfileId;
const startParams: Parameters<CodexCommandDeps["startCodexConversationThread"]>[0] = {
pluginConfig,
sessionFile: ctx.sessionFile,
workspaceDir,
threadId: parsed.threadId,
model: parsed.model,
modelProvider: parsed.provider,
});
};
if (authProfileId) {
startParams.authProfileId = authProfileId;
}
const data = await deps.startCodexConversationThread(startParams);
const binding = await deps.readCodexAppServerBinding(ctx.sessionFile);
const threadId = binding?.threadId ?? parsed.threadId ?? "new thread";
const summary = `Codex app-server thread ${threadId} in ${workspaceDir}`;

View File

@@ -1374,7 +1374,13 @@ describe("codex command", () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }),
JSON.stringify({
schemaVersion: 1,
threadId: "thread-123",
cwd: "/repo",
authProfileId: "openai-codex:work",
modelProvider: "openai",
}),
);
const startCodexConversationThread = vi.fn(async () => ({
kind: "codex-app-server-session" as const,
@@ -1421,6 +1427,7 @@ describe("codex command", () => {
threadId: "thread-123",
model: "gpt-5.4",
modelProvider: "openai",
authProfileId: "openai-codex:work",
});
expect(requestConversationBinding).toHaveBeenCalledWith({
summary: "Codex app-server thread thread-123 in /repo",

View File

@@ -1,10 +1,18 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sharedClientMocks = vi.hoisted(() => ({
getSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./app-server/shared-client.js", () => sharedClientMocks);
import {
handleCodexConversationBindingResolved,
handleCodexConversationInboundClaim,
startCodexConversationThread,
} from "./conversation-binding.js";
let tempDir: string;
@@ -15,9 +23,58 @@ describe("codex conversation binding", () => {
});
afterEach(async () => {
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
await fs.rm(tempDir, { recursive: true, force: true });
});
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(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-old",
cwd: tempDir,
authProfileId: "openai-codex:work",
modelProvider: "openai",
}),
);
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
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",
modelProvider: "openai",
};
}),
});
await startCodexConversationThread({
sessionFile,
workspaceDir: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
});
expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "openai-codex:work" }),
);
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:work"',
);
await expect(
fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
).resolves.not.toContain('"modelProvider": "openai"');
});
it("clears the Codex app-server sidecar when a pending bind is denied", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sidecar = `${sessionFile}.codex-app-server.json`;
@@ -73,4 +130,76 @@ describe("codex conversation binding", () => {
expect(result).toEqual({ handled: true });
});
it("returns a clean failure reply when app-server turn start rejects", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai-codex:work",
}),
);
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string) => {
if (method === "turn/start") {
throw new Error(
"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header",
);
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
});
try {
const result = await handleCodexConversationInboundClaim(
{
content: "hi",
bodyForAgent: "hi",
channel: "telegram",
isGroup: false,
commandAuthorized: true,
},
{
channelId: "telegram",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "telegram",
accountId: "default",
conversationId: "5185575566",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile,
workspaceDir: tempDir,
},
},
},
{ timeoutMs: 50 },
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(result).toEqual({
handled: true,
reply: {
text: "Codex app-server turn failed: unexpected status 401 Unauthorized: Missing bearer or basic authentication in header",
},
});
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
});

View File

@@ -18,6 +18,8 @@ import {
} from "./app-server/protocol.js";
import {
clearCodexAppServerBinding,
isCodexAppServerNativeAuthProfileId,
normalizeCodexAppServerBindingModelProvider,
readCodexAppServerBinding,
writeCodexAppServerBinding,
} from "./app-server/session-binding.js";
@@ -52,6 +54,7 @@ type CodexConversationStartParams = {
threadId?: string;
model?: string;
modelProvider?: string;
authProfileId?: string;
};
type BoundTurnResult = {
@@ -77,6 +80,8 @@ export async function startCodexConversationThread(
): Promise<CodexConversationBindingData> {
const workspaceDir =
params.workspaceDir?.trim() || resolveCodexDefaultWorkspaceDir(params.pluginConfig);
const existingBinding = await readCodexAppServerBinding(params.sessionFile);
const authProfileId = params.authProfileId ?? existingBinding?.authProfileId;
if (params.threadId?.trim()) {
await attachExistingThread({
pluginConfig: params.pluginConfig,
@@ -85,6 +90,7 @@ export async function startCodexConversationThread(
workspaceDir,
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
});
} else {
await createThread({
@@ -93,6 +99,7 @@ export async function startCodexConversationThread(
workspaceDir,
model: params.model,
modelProvider: params.modelProvider,
authProfileId,
});
}
return createCodexConversationBindingData({
@@ -158,18 +165,24 @@ async function attachExistingThread(params: {
workspaceDir: string;
model?: string;
modelProvider?: string;
authProfileId?: string;
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
});
const client = await getSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: params.authProfileId,
});
const response: CodexThreadResumeResponse = await client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(params.model ? { model: params.model } : {}),
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
...(modelProvider ? { modelProvider } : {}),
approvalPolicy: runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: runtime.sandbox,
@@ -182,8 +195,12 @@ async function attachExistingThread(params: {
await writeCodexAppServerBinding(params.sessionFile, {
threadId: thread.id,
cwd: thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: response.modelProvider ?? params.modelProvider,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
@@ -196,18 +213,24 @@ async function createThread(params: {
workspaceDir: string;
model?: string;
modelProvider?: string;
authProfileId?: string;
}): Promise<void> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const modelProvider = resolveThreadRequestModelProvider({
authProfileId: params.authProfileId,
modelProvider: params.modelProvider,
});
const client = await getSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: params.authProfileId,
});
const response: CodexThreadStartResponse = await client.request(
"thread/start",
{
cwd: params.workspaceDir,
...(params.model ? { model: params.model } : {}),
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
...(modelProvider ? { modelProvider } : {}),
approvalPolicy: runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: runtime.sandbox,
@@ -222,8 +245,12 @@ async function createThread(params: {
await writeCodexAppServerBinding(params.sessionFile, {
threadId: response.thread.id,
cwd: response.thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: response.modelProvider ?? params.modelProvider,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
}),
approvalPolicy: runtime.approvalPolicy,
sandbox: runtime.sandbox,
serviceTier: runtime.serviceTier,
@@ -342,10 +369,29 @@ function enqueueBoundTurn<T>(key: string, run: () => Promise<T>): Promise<T> {
() => undefined,
);
state.queues.set(key, queued);
void next.finally(() => {
if (state.queues.get(key) === queued) {
state.queues.delete(key);
}
});
void next
.finally(() => {
if (state.queues.get(key) === queued) {
state.queues.delete(key);
}
})
.catch(() => undefined);
return next;
}
function resolveThreadRequestModelProvider(params: {
authProfileId?: string;
modelProvider?: string;
}): string | undefined {
const modelProvider = params.modelProvider?.trim();
if (!modelProvider || modelProvider.toLowerCase() === "codex") {
return undefined;
}
if (
isCodexAppServerNativeAuthProfileId(params.authProfileId) &&
(modelProvider.toLowerCase() === "openai" || modelProvider.toLowerCase() === "openai-codex")
) {
return undefined;
}
return modelProvider.toLowerCase() === "openai-codex" ? "openai" : modelProvider;
}