Files
openclaw/extensions/acpx/src/service.test.ts
2026-05-08 05:28:12 +01:00

571 lines
18 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
}));
const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
prepareAcpxCodexAuthConfigMock: vi.fn(
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
),
}));
const { cleanupOpenClawOwnedAcpxProcessTreeMock } = vi.hoisted(() => ({
cleanupOpenClawOwnedAcpxProcessTreeMock: vi.fn(
async (): Promise<{
inspectedPids: number[];
terminatedPids: number[];
skippedReason?: string;
}> => ({
inspectedPids: [],
terminatedPids: [],
}),
),
}));
const { reapStaleOpenClawOwnedAcpxOrphansMock } = vi.hoisted(() => ({
reapStaleOpenClawOwnedAcpxOrphansMock: vi.fn(
async (): Promise<{
inspectedPids: number[];
terminatedPids: number[];
skippedReason?: string;
}> => ({
inspectedPids: [],
terminatedPids: [],
}),
),
}));
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } =
vi.hoisted(() => ({
acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) {
return {
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
ensureSession: vi.fn(async () => ({
backend: "acpx",
runtimeSessionName: "agent:codex:acp:test",
sessionKey: "agent:codex:acp:test",
})),
getCapabilities: vi.fn(async () => ({ controls: [] })),
getStatus: vi.fn(async () => ({ summary: "ready" })),
isHealthy: vi.fn(() => true),
prepareFreshSession: vi.fn(async () => {}),
probeAvailability: vi.fn(async () => {}),
runTurn: vi.fn(async function* () {}),
setConfigOption: vi.fn(async () => {}),
setMode: vi.fn(async () => {}),
__options: options,
};
}),
createAgentRegistryMock: vi.fn(() => ({})),
createFileSessionStoreMock: vi.fn(() => ({})),
}));
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown; healthy?: () => boolean }) => {
runtimeRegistry.set(entry.id, entry);
},
unregisterAcpRuntimeBackend: (id: string) => {
runtimeRegistry.delete(id);
},
}));
vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: acpxRuntimeConstructorMock,
createAgentRegistry: createAgentRegistryMock,
createFileSessionStore: createFileSessionStoreMock,
}));
vi.mock("./codex-auth-bridge.js", () => ({
prepareAcpxCodexAuthConfig: prepareAcpxCodexAuthConfigMock,
}));
vi.mock("./process-reaper.js", () => ({
cleanupOpenClawOwnedAcpxProcessTree: cleanupOpenClawOwnedAcpxProcessTreeMock,
reapStaleOpenClawOwnedAcpxOrphans: reapStaleOpenClawOwnedAcpxOrphansMock,
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";
const tempDirs: string[] = [];
const previousEnv = {
OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE: process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE,
OPENCLAW_SKIP_ACPX_RUNTIME: process.env.OPENCLAW_SKIP_ACPX_RUNTIME,
OPENCLAW_SKIP_ACPX_RUNTIME_PROBE: process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE,
};
function restoreEnv(name: keyof typeof previousEnv): void {
const value = previousEnv[name];
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-service-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
runtimeRegistry.clear();
prepareAcpxCodexAuthConfigMock.mockClear();
cleanupOpenClawOwnedAcpxProcessTreeMock.mockClear();
reapStaleOpenClawOwnedAcpxOrphansMock.mockClear();
acpxRuntimeConstructorMock.mockClear();
createAgentRegistryMock.mockClear();
createFileSessionStoreMock.mockClear();
restoreEnv("OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE");
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME");
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME_PROBE");
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
function createServiceContext(workspaceDir: string) {
return {
workspaceDir,
stateDir: path.join(workspaceDir, ".openclaw-plugin-state"),
config: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
}
function createMockRuntime(overrides: Record<string, unknown> = {}) {
return {
ensureSession: vi.fn(),
runTurn: vi.fn(),
cancel: vi.fn(),
close: vi.fn(),
probeAvailability: vi.fn(async () => {}),
isHealthy: vi.fn(() => true),
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
...overrides,
};
}
describe("createAcpxRuntimeService", () => {
it("registers and unregisters the embedded backend", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
await service.stop?.(ctx);
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
});
it("creates the embedded runtime state directory without probing at startup by default", async () => {
const workspaceDir = await makeTempDir();
const stateDir = path.join(workspaceDir, "custom-state");
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {
await fs.access(stateDir);
});
const runtime = createMockRuntime({
doctor: async () => ({ ok: true, message: "ok" }),
isHealthy: () => true,
probeAvailability,
});
const service = createAcpxRuntimeService({
pluginConfig: { stateDir },
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
await fs.access(stateDir);
expect(probeAvailability).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")?.healthy).toBeUndefined();
await service.stop?.(ctx);
});
it("reaps stale ACPX process leases from the generated wrapper root at startup", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
await fs.mkdir(path.join(ctx.stateDir, "acpx"), { recursive: true });
await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
await fs.writeFile(
path.join(ctx.stateDir, "acpx", "process-leases.json"),
JSON.stringify({
version: 1,
leases: [
{
leaseId: "lease-1",
gatewayInstanceId: "gw-test",
sessionKey: "agent:codex:acp:test",
wrapperRoot: path.join(ctx.stateDir, "acpx"),
wrapperPath: path.join(ctx.stateDir, "acpx", "codex-acp-wrapper.mjs"),
rootPid: 101,
commandHash: "hash",
startedAt: 1,
state: "open",
},
],
}),
);
cleanupOpenClawOwnedAcpxProcessTreeMock.mockResolvedValueOnce({
inspectedPids: [101, 102],
terminatedPids: [101, 102],
});
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime as never,
processCleanupDeps,
});
await service.start(ctx);
expect(cleanupOpenClawOwnedAcpxProcessTreeMock).toHaveBeenCalledWith({
rootPid: 101,
expectedLeaseId: "lease-1",
expectedGatewayInstanceId: "gw-test",
wrapperRoot: path.join(ctx.stateDir, "acpx"),
deps: processCleanupDeps,
});
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
await service.stop?.(ctx);
});
it("runs wrapper-root orphan cleanup before dropping pending ACPX leases", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
const wrapperRoot = path.join(ctx.stateDir, "acpx");
await fs.mkdir(wrapperRoot, { recursive: true });
await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
await fs.writeFile(
path.join(wrapperRoot, "process-leases.json"),
JSON.stringify({
version: 1,
leases: [
{
leaseId: "lease-pending",
gatewayInstanceId: "gw-test",
sessionKey: "agent:codex:acp:test",
wrapperRoot,
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
rootPid: 0,
commandHash: "hash",
startedAt: 1,
state: "open",
},
],
}),
);
reapStaleOpenClawOwnedAcpxOrphansMock.mockResolvedValueOnce({
inspectedPids: [201, 202],
terminatedPids: [201, 202],
});
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime as never,
processCleanupDeps,
});
await service.start(ctx);
expect(cleanupOpenClawOwnedAcpxProcessTreeMock).not.toHaveBeenCalled();
expect(reapStaleOpenClawOwnedAcpxOrphansMock).toHaveBeenCalledWith({
wrapperRoot,
deps: processCleanupDeps,
});
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
const leaseFile = JSON.parse(
await fs.readFile(path.join(wrapperRoot, "process-leases.json"), "utf8"),
);
expect(leaseFile.leases[0].state).toBe("closed");
await service.stop?.(ctx);
});
it("keeps startup quiet when no process leases are open", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
expect(cleanupOpenClawOwnedAcpxProcessTreeMock).not.toHaveBeenCalled();
expect(ctx.logger.warn).not.toHaveBeenCalled();
await service.stop?.(ctx);
});
it("registers the default backend without importing ACPX runtime until first use", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const service = createAcpxRuntimeService();
await service.start(ctx);
const backend = getAcpRuntimeBackend("acpx");
if (!backend) {
throw new Error("expected ACPX runtime backend");
}
expect(backend.runtime).toMatchObject({
ensureSession: expect.any(Function),
});
expect(acpxRuntimeConstructorMock).not.toHaveBeenCalled();
await backend.runtime.ensureSession({
agent: "codex",
mode: "oneshot",
sessionKey: "agent:codex:acp:test",
});
expect(acpxRuntimeConstructorMock).toHaveBeenCalledOnce();
await service.stop?.(ctx);
});
it("can run the embedded runtime probe at startup when explicitly enabled", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {});
const runtime = createMockRuntime({
probeAvailability,
isHealthy: () => true,
});
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
expect(probeAvailability).toHaveBeenCalledOnce();
expect(getAcpRuntimeBackend("acpx")?.healthy?.()).toBe(true);
await service.stop?.(ctx);
});
it("passes the default runtime timeout to the embedded runtime factory", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const runtimeFactory = vi.fn(() => runtime as never);
const service = createAcpxRuntimeService({
runtimeFactory,
});
await service.start(ctx);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
pluginConfig: expect.objectContaining({
timeoutSeconds: 120,
}),
}),
);
await service.stop?.(ctx);
});
it("forwards a configured probeAgent to the runtime factory so the probe does not hardcode the default", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = {
ensureSession: vi.fn(),
runTurn: vi.fn(),
cancel: vi.fn(),
close: vi.fn(),
probeAvailability: vi.fn(async () => {}),
isHealthy: vi.fn(() => true),
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
};
const runtimeFactory = vi.fn(() => runtime as never);
const service = createAcpxRuntimeService({
pluginConfig: { probeAgent: "opencode" },
runtimeFactory,
});
await service.start(ctx);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
pluginConfig: expect.objectContaining({
probeAgent: "opencode",
}),
}),
);
await service.stop?.(ctx);
});
it("uses the first allowed ACP agent as the default probe agent", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
ctx.config = {
acp: {
allowedAgents: [" OpenCode ", "codex"],
},
};
const runtime = createMockRuntime();
const runtimeFactory = vi.fn(() => runtime as never);
const service = createAcpxRuntimeService({
runtimeFactory,
});
await service.start(ctx);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
pluginConfig: expect.objectContaining({
probeAgent: "opencode",
}),
}),
);
await service.stop?.(ctx);
});
it("keeps explicit probeAgent ahead of acp.allowedAgents", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
ctx.config = {
acp: {
allowedAgents: ["opencode"],
},
};
const runtime = createMockRuntime();
const runtimeFactory = vi.fn(() => runtime as never);
const service = createAcpxRuntimeService({
pluginConfig: { probeAgent: "codex" },
runtimeFactory,
});
await service.start(ctx);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
pluginConfig: expect.objectContaining({
probeAgent: "codex",
}),
}),
);
await service.stop?.(ctx);
});
it("warns when legacy compatibility config is explicitly ignored", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime();
const service = createAcpxRuntimeService({
pluginConfig: {
queueOwnerTtlSeconds: 30,
strictWindowsCmdWrapper: false,
},
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
"embedded acpx runtime ignores legacy compatibility config: queueOwnerTtlSeconds, strictWindowsCmdWrapper=false",
),
);
await service.stop?.(ctx);
});
it("can skip the embedded runtime probe via env", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {});
const runtime = createMockRuntime({
doctor: async () => ({ ok: false, message: "nope" }),
isHealthy: () => false,
probeAvailability,
});
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
expect(probeAvailability).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toMatchObject({
runtime: expect.any(Object),
});
await service.stop?.(ctx);
});
it("formats non-string doctor details without losing object payloads", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = createMockRuntime({
doctor: async () => ({
ok: false,
message: "probe failed",
details: [{ code: "ACP_CLOSED", agent: "codex" }, new Error("stdin closed")],
}),
isHealthy: () => false,
});
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime as never,
});
await service.start(ctx);
await vi.waitFor(() => {
expect(ctx.logger.warn).toHaveBeenCalledWith(
'embedded acpx runtime backend probe failed: probe failed ({"code":"ACP_CLOSED","agent":"codex"}; stdin closed)',
);
});
await service.stop?.(ctx);
});
it("can skip the embedded runtime backend via env", async () => {
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtimeFactory = vi.fn(() => {
throw new Error("runtime factory should not run when ACPX is skipped");
});
const service = createAcpxRuntimeService({
runtimeFactory: runtimeFactory as never,
});
await service.start(ctx);
expect(runtimeFactory).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
);
});
});