refactor(auth): route codex runtimes through canonical oauth

This commit is contained in:
Vincent Koc
2026-04-18 06:48:11 -07:00
committed by Peter Steinberger
parent f98e98ab66
commit 859eb06662
36 changed files with 830 additions and 108 deletions

View File

@@ -0,0 +1,100 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureAuthProfileStore: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
}));
let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCodexAppServerStartOptions;
describe("bridgeCodexAppServerStartOptions", () => {
const tempDirs: string[] = [];
beforeAll(async () => {
({ bridgeCodexAppServerStartOptions } = await import("./auth-bridge.js"));
});
afterEach(async () => {
mocks.ensureAuthProfileStore.mockReset();
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("bridges canonical OpenClaw oauth into an isolated CODEX_HOME", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
tempDirs.push(agentDir);
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-123",
},
},
});
const result = await bridgeCodexAppServerStartOptions({
startOptions: {
command: "codex",
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
env: { EXISTING: "1" },
clearEnv: ["FOO"],
},
agentDir,
});
expect(result).toMatchObject({
env: {
EXISTING: "1",
CODEX_HOME: expect.stringContaining(path.join(agentDir, "harness-auth", "codex")),
},
clearEnv: expect.arrayContaining(["FOO", "OPENAI_API_KEY"]),
});
const authFile = JSON.parse(
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
);
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
});
});
it("leaves start options unchanged when canonical oauth is unavailable", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
tempDirs.push(agentDir);
const startOptions = {
command: "codex",
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
};
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
});
await expect(
bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
authProfileId: "openai-codex:missing",
}),
).resolves.toEqual(startOptions);
});
});

View File

@@ -0,0 +1,76 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { ensureAuthProfileStore, type OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import type { CodexAppServerStartOptions } from "./config.js";
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
function isBridgeableCodexOAuthCredential(value: unknown): value is OAuthCredential {
return Boolean(
value &&
typeof value === "object" &&
value !== null &&
"type" in value &&
"provider" in value &&
"access" in value &&
"refresh" in value &&
value.type === "oauth" &&
value.provider === "openai-codex" &&
typeof value.access === "string" &&
value.access.trim().length > 0 &&
typeof value.refresh === "string" &&
value.refresh.trim().length > 0,
);
}
function resolveCodexBridgeHome(agentDir: string, profileId: string): string {
const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16);
return path.join(agentDir, "harness-auth", "codex", digest);
}
function buildCodexAuthFile(credential: OAuthCredential): string {
return `${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId?: string;
}): Promise<CodexAppServerStartOptions> {
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const credential = store.profiles[profileId];
if (!isBridgeableCodexOAuthCredential(credential)) {
return params.startOptions;
}
const codexHome = resolveCodexBridgeHome(params.agentDir, profileId);
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(credential));
return {
...params.startOptions,
env: {
...params.startOptions.env,
CODEX_HOME: codexHome,
},
clearEnv: Array.from(
new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]),
),
};
}

View File

@@ -105,6 +105,38 @@ describe("maybeCompactCodexAppServerSession", () => {
},
});
});
it("reuses the bound auth profile for native compaction", async () => {
const fake = createFakeCodexClient();
let seenAuthProfileId: string | undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileId = authProfileId;
return fake.client;
});
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai-codex:work",
});
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(seenAuthProfileId).toBe("openai-codex:work");
});
});
function createFakeCodexClient(): {

View File

@@ -11,6 +11,7 @@ import { getSharedCodexAppServerClient } from "./shared-client.js";
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
) => Promise<CodexAppServerClient>;
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
@@ -25,8 +26,8 @@ type CodexNativeCompactionWaiter = {
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
getSharedCodexAppServerClient({ startOptions });
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedPiSessionParams,
@@ -38,7 +39,7 @@ export async function maybeCompactCodexAppServerSession(
return { ok: false, compacted: false, reason: "no codex app-server thread binding" };
}
const client = await clientFactory(appServer.start);
const client = await clientFactory(appServer.start, binding.authProfileId);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;
try {
@@ -212,6 +213,7 @@ export const __testing = {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
clientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
},
} as const;

View File

@@ -12,6 +12,8 @@ export type CodexAppServerStartOptions = {
url?: string;
authToken?: string;
headers: Record<string, string>;
env?: Record<string, string>;
clearEnv?: string[];
};
export type CodexAppServerRuntimeOptions = {
@@ -158,6 +160,8 @@ export function codexAppServerStartOptionsKey(options: CodexAppServerStartOption
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
left.localeCompare(right),
),
env: Object.entries(options.env ?? {}).toSorted(([left], [right]) => left.localeCompare(right)),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
});
}

View File

@@ -28,6 +28,7 @@ export type CodexAppServerListModelsOptions = {
includeHidden?: boolean;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
sharedClient?: boolean;
};
@@ -40,10 +41,12 @@ export async function listCodexAppServerModels(
? await getSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
});
try {
const response = await client.request<JsonObject>(

View File

@@ -8,6 +8,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
requestParams?: JsonValue;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
}): Promise<T> {
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
@@ -15,6 +16,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
const client = await getSharedCodexAppServerClient({
startOptions: params.startOptions,
timeoutMs,
authProfileId: params.authProfileId,
});
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
})(),

View File

@@ -313,6 +313,53 @@ describe("runCodexAppServerAttempt", () => {
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
});
it("passes the selected auth profile into app-server startup", async () => {
const seenAuthProfileIds: Array<string | undefined> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileIds.push(authProfileId);
return {
request: async (method: string) => {
if (method === "thread/start") {
return {
thread: { id: "thread-1" },
model: "gpt-5.4-codex",
modelProvider: "openai",
};
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
return {};
},
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.authProfileId = "openai-codex:work";
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:work"]));
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:work"]);
});
it("times out turn start before the active run handle is installed", async () => {
const request = vi.fn(
async (method: string, _params?: unknown, options?: { timeoutMs?: number }) => {

View File

@@ -37,10 +37,11 @@ import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
) => Promise<CodexAppServerClient>;
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
getSharedCodexAppServerClient({ startOptions });
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
export async function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
@@ -101,7 +102,7 @@ export async function runCodexAppServerAttempt(
timeoutMs: params.timeoutMs,
signal: runAbortController.signal,
operation: async () => {
const startupClient = await clientFactory(appServer.start);
const startupClient = await clientFactory(appServer.start, params.authProfileId);
const startupThread = await startOrResumeThread({
client: startupClient,
params,
@@ -487,6 +488,7 @@ export const __testing = {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
clientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
},
} as const;

View File

@@ -6,6 +6,7 @@ export type CodexAppServerThreadBinding = {
threadId: string;
sessionFile: string;
cwd: string;
authProfileId?: string;
model?: string;
modelProvider?: string;
dynamicToolsFingerprint?: string;
@@ -41,6 +42,7 @@ export async function readCodexAppServerBinding(
threadId: parsed.threadId,
sessionFile,
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,
dynamicToolsFingerprint:
@@ -71,6 +73,7 @@ export async function writeCodexAppServerBinding(
sessionFile,
threadId: binding.threadId,
cwd: binding.cwd,
authProfileId: binding.authProfileId,
model: binding.model,
modelProvider: binding.modelProvider,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,

View File

@@ -1,14 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js";
import { listCodexAppServerModels } from "./models.js";
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
}));
vi.mock("./auth-bridge.js", () => ({
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir,
}));
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
describe("shared Codex app-server client", () => {
beforeAll(async () => {
({ listCodexAppServerModels } = await import("./models.js"));
({ resetSharedCodexAppServerClientForTests } = await import("./shared-client.js"));
});
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.resolveOpenClawAgentDir.mockClear();
});
it("closes the shared app-server when the version gate fails", async () => {
@@ -18,6 +39,7 @@ describe("shared Codex app-server client", () => {
// Model discovery uses the shared-client path, which owns child teardown
// when initialize discovers an unsupported app-server.
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
@@ -45,6 +67,7 @@ describe("shared Codex app-server client", () => {
expect(first.process.kill).toHaveBeenCalledTimes(1);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(second.writes[0] ?? "{}") as { id?: number };
second.send({
id: initialize.id,
@@ -57,4 +80,30 @@ describe("shared Codex app-server client", () => {
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("passes the selected auth profile through the bridge helper", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai-codex:work",
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const modelList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: modelList.id, result: { data: [] } });
await expect(listPromise).resolves.toEqual({ models: [] });
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: "openai-codex:work",
}),
);
});
});

View File

@@ -1,3 +1,5 @@
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
import {
codexAppServerStartOptionsKey,
@@ -25,9 +27,14 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
export async function getSharedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
const key = codexAppServerStartOptionsKey(startOptions);
if (state.key && state.key !== key) {
clearSharedCodexAppServerClient();
@@ -62,8 +69,13 @@ export async function getSharedCodexAppServerClient(options?: {
export async function createIsolatedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
const client = CodexAppServerClient.start(startOptions);
const initialize = client.initialize();
try {

View File

@@ -53,6 +53,7 @@ export async function startOrResumeThread(params: {
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
@@ -62,6 +63,7 @@ export async function startOrResumeThread(params: {
...binding,
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
@@ -93,6 +95,7 @@ export async function startOrResumeThread(params: {
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 ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
@@ -103,6 +106,7 @@ export async function startOrResumeThread(params: {
threadId: response.thread.id,
sessionFile: params.params.sessionFile,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,

View File

@@ -3,8 +3,15 @@ import type { CodexAppServerStartOptions } from "./config.js";
import type { CodexAppServerTransport } from "./transport.js";
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
const env = {
...process.env,
...options.env,
};
for (const key of options.clearEnv ?? []) {
delete env[key];
}
return spawn(options.command, options.args, {
env: process.env,
env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
});