Files
openclaw/extensions/codex/src/app-server/client.test.ts
2026-04-23 23:22:31 +01:00

297 lines
11 KiB
TypeScript

import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__testing,
CodexAppServerClient,
CodexAppServerRpcError,
MIN_CODEX_APP_SERVER_VERSION,
isCodexAppServerApprovalRequest,
readCodexVersionFromUserAgent,
} from "./client.js";
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
import { createClientHarness } from "./test-support.js";
describe("CodexAppServerClient", () => {
const clients: CodexAppServerClient[] = [];
function startInitialize() {
const harness = createClientHarness();
clients.push(harness.client);
const initializing = harness.client.initialize();
const outbound = JSON.parse(harness.writes[0] ?? "{}") as {
id?: number;
method?: string;
params?: { clientInfo?: { name?: string; title?: string; version?: string } };
};
return { harness, initializing, outbound };
}
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
for (const client of clients) {
client.close();
}
clients.length = 0;
});
it("routes request responses by id", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("model/list", {});
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number; method?: string };
harness.send({ id: outbound.id, result: { models: [] } });
await expect(request).resolves.toEqual({ models: [] });
expect(outbound.method).toBe("model/list");
});
it("logs a redacted preview for malformed app-server messages", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createClientHarness();
clients.push(harness.client);
harness.process.stdout.write('{"token":"secret-value"} trailing\n');
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(
"failed to parse codex app-server message",
expect.objectContaining({
linePreview: '{"token":"<redacted>"} trailing',
}),
),
);
expect(JSON.stringify(warn.mock.calls)).not.toContain("secret-value");
});
it("preserves JSON-RPC error codes", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("future/method", {});
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({ id: outbound.id, error: { code: -32601, message: "Method not found" } });
await expect(request).rejects.toMatchObject({
name: "CodexAppServerRpcError",
code: -32601,
message: "Method not found",
} satisfies Partial<CodexAppServerRpcError>);
});
it("rejects timed-out requests and ignores late responses", async () => {
vi.useFakeTimers();
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("model/list", {}, { timeoutMs: 1 });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const assertion = expect(request).rejects.toThrow("model/list timed out");
await vi.advanceTimersByTimeAsync(100);
await assertion;
harness.send({ id: outbound.id, result: { data: [] } });
expect(harness.writes).toHaveLength(1);
});
it("rejects aborted requests and ignores late responses", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("model/list", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const assertion = expect(request).rejects.toThrow("model/list aborted");
controller.abort();
await assertion;
harness.send({ id: outbound.id, result: { data: [] } });
expect(harness.writes).toHaveLength(1);
});
it("initializes with the required client version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
});
await expect(initializing).resolves.toBeUndefined();
expect(outbound).toMatchObject({
method: "initialize",
params: {
clientInfo: {
name: "openclaw",
title: "OpenClaw",
version: expect.any(String),
},
},
});
expect(outbound.params?.clientInfo?.version).not.toBe("");
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({ method: "initialized" });
});
it("blocks unsupported app-server versions during initialize", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.117.9 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.117.9`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks app-server initialize responses without a version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({ id: outbound.id, result: {} });
await expect(initializing).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`,
);
expect(harness.writes).toHaveLength(1);
});
it("force-stops app-server transports that ignore the graceful signal", async () => {
vi.useFakeTimers();
const process = Object.assign(new EventEmitter(), {
stdin: {
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
unref: vi.fn(),
},
stdout: Object.assign(new PassThrough(), { unref: vi.fn() }),
stderr: Object.assign(new PassThrough(), { unref: vi.fn() }),
exitCode: null,
signalCode: null,
kill: vi.fn(),
unref: vi.fn(),
});
__testing.closeCodexAppServerTransport(process, { forceKillDelayMs: 25 });
expect(process.kill).toHaveBeenCalledWith("SIGTERM");
await vi.advanceTimersByTimeAsync(25);
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
expect(process.unref).toHaveBeenCalledTimes(1);
});
it("handles stdin write errors without crashing the process", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.
await expect(pending).rejects.toThrow("write EPIPE");
// Subsequent requests are rejected immediately (client is closed).
await expect(harness.client.request("another/method")).rejects.toThrow(
"codex app-server client is closed",
);
});
it("does not write to stdin after the child process exits", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Simulate the child process exiting.
harness.process.emit("exit", 1, null);
// A notification after exit must not attempt a write.
harness.client.notify("late/event", { data: "ignored" });
expect(harness.writes).toHaveLength(0);
});
it("reads the Codex version from the app-server user agent", () => {
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("codex_cli_rs/0.118.1-dev (linux; test)")).toBe(
"0.118.1-dev",
);
expect(readCodexVersionFromUserAgent("Codex Desktop/not-a-version")).toBeUndefined();
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118")).toBeUndefined();
expect(readCodexVersionFromUserAgent("openclaw/0.118.0abc")).toBeUndefined();
expect(readCodexVersionFromUserAgent("missing-version")).toBeUndefined();
});
it("answers server-initiated requests with the registered handler result", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.client.addRequestHandler((request) => {
if (request.method === "item/tool/call") {
return { contentItems: [{ type: "inputText", text: "ok" }], success: true };
}
return undefined;
});
harness.send({ id: "srv-1", method: "item/tool/call", params: { tool: "message" } });
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "srv-1",
result: { contentItems: [{ type: "inputText", text: "ok" }], success: true },
});
});
it("fails closed for unhandled native app-server approvals", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "approval-1",
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-1", command: "pnpm test" },
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "approval-1",
result: { decision: "decline" },
});
});
it("only treats known Codex app-server approval methods as approvals", () => {
expect(isCodexAppServerApprovalRequest("item/commandExecution/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("item/fileChange/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("item/permissions/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("evil/Approval")).toBe(false);
expect(isCodexAppServerApprovalRequest("item/tool/requestApproval")).toBe(false);
});
it("fails closed for unhandled request_user_input prompts", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "input-1",
method: "item/tool/requestUserInput",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "tool-1",
questions: [],
},
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "input-1",
result: { answers: {} },
});
});
});