Files
openclaw/extensions/codex/src/app-server/shared-client.test.ts
2026-05-17 01:46:39 +01:00

592 lines
22 KiB
TypeScript

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 { codexAppServerStartOptionsKey } from "./config.js";
import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
applyCodexAppServerAuthProfile: vi.fn(
async (_params?: { agentDir?: string; authProfileId?: string; config?: unknown }) => undefined,
),
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
(params?: { authProfileId?: string }) => params?.authProfileId,
),
resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions),
embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() },
resolveDefaultAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
}));
vi.mock("./auth-bridge.js", () => ({
applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
}));
vi.mock("./managed-binary.js", () => ({
resolveManagedCodexAppServerStartOptions: mocks.resolveManagedCodexAppServerStartOptions,
}));
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({
embeddedAgentLog: mocks.embeddedAgentLog,
OPENCLAW_VERSION: "test",
}));
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
resolveDefaultAgentDir: mocks.resolveDefaultAgentDir,
}));
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
let clearSharedCodexAppServerClientIfCurrentAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrentAndWait;
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
async function sendInitializeResult(
harness: ReturnType<typeof createClientHarness>,
userAgent: string,
): Promise<void> {
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<typeof createClientHarness>): Promise<void> {
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: [] } });
}
function firstMockArg(mock: unknown, label: string): unknown {
const call = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls?.at(0);
if (!call) {
throw new Error(`Expected ${label} first call`);
}
return call[0];
}
function bridgeStartOptionsCall() {
return firstMockArg(mocks.bridgeCodexAppServerStartOptions, "bridge start options") as {
agentDir?: string;
authProfileId?: string;
config?: unknown;
startOptions: { command?: string; commandSource?: string };
};
}
function applyAuthProfileCall() {
return firstMockArg(mocks.applyCodexAppServerAuthProfile, "apply auth profile") as {
agentDir?: string;
authProfileId?: string;
config?: unknown;
};
}
function resolveAuthProfileCall() {
return firstMockArg(mocks.resolveCodexAppServerAuthProfileIdForAgent, "resolve auth profile") as {
agentDir?: string;
authProfileId?: string;
config?: unknown;
};
}
function managedStartOptionsCall() {
return firstMockArg(mocks.resolveManagedCodexAppServerStartOptions, "managed start options") as {
command?: string;
commandSource?: string;
};
}
function clientStartCall(startSpy: unknown) {
return firstMockArg(startSpy, "CodexAppServerClient.start") as {
command?: string;
commandSource?: string;
};
}
describe("shared Codex app-server client", () => {
beforeAll(async () => {
({ listCodexAppServerModels } = await import("./models.js"));
({
clearSharedCodexAppServerClient,
clearSharedCodexAppServerClientIfCurrent,
clearSharedCodexAppServerClientIfCurrentAndWait,
createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} = await import("./shared-client.js"));
});
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.applyCodexAppServerAuthProfile.mockClear();
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
(params?: { authProfileId?: string }) => params?.authProfileId,
);
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(
async (startOptions) => startOptions,
);
mocks.embeddedAgentLog.debug.mockClear();
mocks.embeddedAgentLog.warn.mockClear();
mocks.resolveDefaultAgentDir.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.stdin.destroyed).toBe(true);
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.stdin.destroyed).toBe(true);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("does not wait for isolated initialize after a timeout closes the client", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
await expect(createIsolatedCodexAppServerClient({ timeoutMs: 5 })).rejects.toThrow(
"codex app-server initialize timed out",
);
expect(harness.process.stdin.destroyed).toBe(true);
});
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.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
const bridgeCall = bridgeStartOptionsCall();
expect(bridgeCall?.authProfileId).toBe("openai-codex:work");
const applyCall = applyAuthProfileCall();
expect(applyCall?.authProfileId).toBe("openai-codex:work");
});
it("skips target auth resolution when native source auth is requested", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const config = { auth: { order: { "openai-codex": ["openai-codex:target"] } } };
const clientPromise = getSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-target-agent",
config,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
const bridgeCall = bridgeStartOptionsCall();
expect(bridgeCall.agentDir).toBe("/tmp/openclaw-target-agent");
expect(bridgeCall.authProfileId).toBeNull();
expect(bridgeCall.config).toBe(config);
const applyCall = applyAuthProfileCall();
expect(applyCall.agentDir).toBe("/tmp/openclaw-target-agent");
expect(applyCall.authProfileId).toBeNull();
expect(applyCall.config).toBe(config);
});
it("resolves the configured implicit auth profile before sharing a client", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } };
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockReturnValue("openai-codex:work");
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
config,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
const resolveCall = resolveAuthProfileCall();
expect(resolveCall).toStrictEqual({
authProfileId: undefined,
agentDir: "/tmp/openclaw-agent",
config,
});
const bridgeCall = bridgeStartOptionsCall();
expect(bridgeCall?.authProfileId).toBe("openai-codex:work");
expect(bridgeCall?.config).toBe(config);
const applyCall = applyAuthProfileCall();
expect(applyCall?.authProfileId).toBe("openai-codex:work");
expect(applyCall?.config).toBe(config);
});
it("uses the selected agent dir for shared app-server auth bridging", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai-codex:work",
agentDir: "/tmp/openclaw-agent-nova",
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
const bridgeCall = bridgeStartOptionsCall();
expect(bridgeCall?.agentDir).toBe("/tmp/openclaw-agent-nova");
expect(bridgeCall?.authProfileId).toBe("openai-codex:work");
const applyCall = applyAuthProfileCall();
expect(applyCall?.agentDir).toBe("/tmp/openclaw-agent-nova");
expect(applyCall?.authProfileId).toBe("openai-codex:work");
});
it("migrates legacy singleton global state into the keyed registry", async () => {
const legacy = createClientHarness();
const next = createClientHarness();
const startOptions = {
transport: "websocket" as const,
command: "codex",
args: [],
url: "ws://127.0.0.1:39175",
authToken: "tok-legacy",
headers: {},
};
const key = codexAppServerStartOptionsKey(startOptions, {
agentDir: "/tmp/openclaw-agent",
});
const globalState = globalThis as typeof globalThis & {
[key: symbol]: unknown;
};
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
key,
client: legacy.client,
promise: Promise.resolve(legacy.client),
};
await expect(getSharedCodexAppServerClient({ startOptions })).resolves.toBe(legacy.client);
legacy.client.close();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(next.client);
const list = listCodexAppServerModels({ timeoutMs: 1000, startOptions });
await sendInitializeResult(next, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(next);
await expect(list).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(1);
});
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
const first = createClientHarness();
const second = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-one",
});
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const secondList = listCodexAppServerModels({
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-two",
});
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
expect(first.process.stdin.destroyed).toBe(false);
expect(second.process.stdin.destroyed).toBe(false);
});
it("resolves the managed binary before bridging and spawning the shared client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => ({
...startOptions,
command: "/cache/openclaw/codex",
commandSource: "resolved-managed",
}));
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
const managedCall = managedStartOptionsCall();
expect(managedCall?.command).toBe("codex");
expect(managedCall?.commandSource).toBe("managed");
const bridgeCall = bridgeStartOptionsCall();
expect(bridgeCall?.startOptions.command).toBe("/cache/openclaw/codex");
expect(bridgeCall?.startOptions.commandSource).toBe("resolved-managed");
const startCall = clientStartCall(startSpy);
expect(startCall?.command).toBe("/cache/openclaw/codex");
expect(startCall?.commandSource).toBe("resolved-managed");
});
it("starts an independent 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.125.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.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
expect(first.process.stdin.destroyed).toBe(false);
});
it("does not let one shared-client failure tear down another keyed 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 sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
first.client.close();
await expect(firstFailure).resolves.toBeInstanceOf(Error);
expect(second.process.kill).not.toHaveBeenCalled();
});
it("only clears the shared client that is still current", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
expect(first.process.stdin.destroyed).toBe(true);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
expect(second.process.kill).not.toHaveBeenCalled();
expect(clearSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
expect(second.process.stdin.destroyed).toBe(true);
});
it("waits only for the shared client that is still current", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
const secondCloseAndWait = vi.spyOn(second.client, "closeAndWait");
const firstList = listCodexAppServerModels({
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-one",
});
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const secondList = listCodexAppServerModels({
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-two",
});
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
await expect(
clearSharedCodexAppServerClientIfCurrentAndWait(first.client, {
exitTimeoutMs: 25,
forceKillDelayMs: 5,
}),
).resolves.toBe(true);
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
expect(secondCloseAndWait).not.toHaveBeenCalled();
expect(first.process.stdin.destroyed).toBe(true);
expect(second.process.stdin.destroyed).toBe(false);
});
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<string | undefined> = [];
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.125.0" } }),
);
return;
}
if (message.method === "model/list") {
socket.send(JSON.stringify({ id: message.id, result: { data: [] } }));
}
});
});
try {
await new Promise<void>((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<void>((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");
}