mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 05:35:19 +00:00
592 lines
22 KiB
TypeScript
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");
|
|
}
|