import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { WebSocketServer, type RawData } from "ws"; import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./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 clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient; let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests; async function sendInitializeResult( harness: ReturnType, userAgent: string, ): Promise { 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 } }); } async function sendEmptyModelList(harness: ReturnType): Promise { 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: [] } }); } describe("shared Codex app-server client", () => { beforeAll(async () => { ({ listCodexAppServerModels } = await import("./models.js")); ({ clearSharedCodexAppServerClient, 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 () => { const harness = createClientHarness(); const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.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 sendInitializeResult(harness, "openclaw/0.117.9 (macOS; test)"); await expect(listPromise).rejects.toThrow( `Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`, ); expect(harness.process.kill).toHaveBeenCalledTimes(1); startSpy.mockRestore(); }); it("closes and clears a shared app-server when initialize times out", async () => { const first = createClientHarness(); const second = createClientHarness(); const startSpy = vi .spyOn(CodexAppServerClient, "start") .mockReturnValueOnce(first.client) .mockReturnValueOnce(second.client); await expect(listCodexAppServerModels({ timeoutMs: 5 })).rejects.toThrow( "codex app-server initialize timed out", ); expect(first.process.kill).toHaveBeenCalledTimes(1); const secondList = listCodexAppServerModels({ timeoutMs: 1000 }); await sendInitializeResult(second, "openclaw/0.118.0 (macOS; test)"); await sendEmptyModelList(second); 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 sendInitializeResult(harness, "openclaw/0.118.0 (macOS; test)"); await sendEmptyModelList(harness); await expect(listPromise).resolves.toEqual({ models: [] }); expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith( expect.objectContaining({ authProfileId: "openai-codex:work", }), ); }); it("restarts the shared client when the bridged auth token changes", async () => { const first = createClientHarness(); const second = createClientHarness(); const startSpy = vi .spyOn(CodexAppServerClient, "start") .mockReturnValueOnce(first.client) .mockReturnValueOnce(second.client); const firstList = listCodexAppServerModels({ timeoutMs: 1000, startOptions: { transport: "websocket", command: "codex", args: [], url: "ws://127.0.0.1:39175", authToken: "tok-first", headers: {}, }, }); await sendInitializeResult(first, "openclaw/0.118.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); const secondList = listCodexAppServerModels({ timeoutMs: 1000, startOptions: { transport: "websocket", command: "codex", args: [], url: "ws://127.0.0.1:39175", authToken: "tok-second", headers: {}, }, }); await sendInitializeResult(second, "openclaw/0.118.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); expect(startSpy).toHaveBeenCalledTimes(2); expect(first.process.kill).toHaveBeenCalledWith("SIGTERM"); }); it("does not let a superseded shared-client failure tear down the newer client", async () => { const first = createClientHarness(); const second = createClientHarness(); vi.spyOn(CodexAppServerClient, "start") .mockReturnValueOnce(first.client) .mockReturnValueOnce(second.client); const firstList = listCodexAppServerModels({ timeoutMs: 1000, startOptions: { transport: "websocket", command: "codex", args: [], url: "ws://127.0.0.1:39175", authToken: "tok-first", headers: {}, }, }); const firstFailure = firstList.catch((error: unknown) => error); await vi.waitFor(() => expect(first.writes.length).toBeGreaterThanOrEqual(1)); const secondList = listCodexAppServerModels({ timeoutMs: 1000, startOptions: { transport: "websocket", command: "codex", args: [], url: "ws://127.0.0.1:39175", authToken: "tok-second", headers: {}, }, }); await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1)); await expect(firstFailure).resolves.toBeInstanceOf(Error); await sendInitializeResult(second, "openclaw/0.118.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); expect(second.process.kill).not.toHaveBeenCalled(); }); it("uses a fresh websocket Authorization header after shared-client token rotation", async () => { const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); const authHeaders: Array = []; server.on("connection", (socket, request) => { authHeaders.push(request.headers.authorization); socket.on("message", (data) => { const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string }; if (message.method === "initialize") { socket.send( JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.118.0" } }), ); return; } if (message.method === "model/list") { socket.send(JSON.stringify({ id: message.id, result: { data: [] } })); } }); }); try { await new Promise((resolve) => server.once("listening", resolve)); const address = server.address(); if (!address || typeof address === "string") { throw new Error("expected websocket test server port"); } const url = `ws://127.0.0.1:${address.port}`; await expect( listCodexAppServerModels({ timeoutMs: 1000, startOptions: { transport: "websocket", command: "codex", args: [], url, authToken: "tok-first", headers: {}, }, }), ).resolves.toEqual({ models: [] }); await expect( listCodexAppServerModels({ timeoutMs: 1000, startOptions: { transport: "websocket", command: "codex", args: [], url, authToken: "tok-second", headers: {}, }, }), ).resolves.toEqual({ models: [] }); expect(authHeaders).toEqual(["Bearer tok-first", "Bearer tok-second"]); } finally { clearSharedCodexAppServerClient(); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); } }); }); function rawDataToText(data: RawData): string { if (Array.isArray(data)) { return Buffer.concat(data).toString("utf8"); } if (data instanceof ArrayBuffer) { return Buffer.from(new Uint8Array(data)).toString("utf8"); } return Buffer.from(data).toString("utf8"); }