mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix: preserve Codex binding OAuth transport
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user