Files
rea/tests/boundary/filesystem/piClientConfiguration.test.ts
Richard A Steenbergenandmorluto 9e22da845b feat(setup): add native Pi client support (#1376)
* feat(setup): add native Pi client support

* fix(setup): isolate invalid Pi agent directory overrides

Keep malformed Pi path evidence on its client entry so unrelated selected clients can complete setup and doctor. Preserve explicit Pi rejection and block filesystem operations rather than falling back to another directory.

* fix(setup): parse Pi configuration as strict JSON

Pi's installed MCP loader reads mcp.json with a bare JSON.parse and no
BOM tolerance, so comments, trailing commas, a BOM, or an empty existing
file yield loader errors and no servers. Parsing the Pi dialect with
objectSchema.parse(JSON.parse(text)) before the shared empty/JSONC
shortcut rejects those files at every mutation boundary instead of
reporting configured registrations Pi cannot load. A missing file still
initializes a fresh strict-JSON document, other dialects keep their
JSONC/BOM/empty tolerance, and malformed files are never rewritten.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 16:10:39 +08:00

334 lines
12 KiB
TypeScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { parseClientConfiguration } from "../../../src/application/ClientConfigurationDocument.js";
import {
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import { detectClients } from "../../../src/application/SetupHost.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
clearClientLocationEnvironment,
NPX_REGISTRATION_COMMAND,
} from "../../fixtures/clientEnvironment.js";
beforeEach(clearClientLocationEnvironment);
afterEach(() => vi.unstubAllEnvs());
const piPath = (
home: string,
platform: NodeJS.Platform,
env: Parameters<typeof supportedClients>[2],
) => supportedClients(home, platform, env).find(({ name }) => name === "pi");
// Catch fallback to OMP paths, loss of relative overrides, and normalization
// that differs from Pi's public getAgentDir()/normalizePath boundary.
describe("Pi user configuration paths", () => {
it.each([
[undefined, "/home/a/.pi/agent"],
["", "/home/a/.pi/agent"],
["/custom/agent", "/custom/agent"],
["~", "/home/a"],
["~/custom/agent", "/home/a/custom/agent"],
["agent", "agent"],
["./agent", "./agent"],
[" agent ", " agent "],
["~other/agent", "~other/agent"],
["file:///custom/agent%20dir", "/custom/agent dir"],
["/mnt/c/agent", "/mnt/c/agent"],
])(
"uses Pi's agent directory for %s, independently of OMP",
(override, expected) => {
const client = piPath("/home/a", "linux", {
PI_CODING_AGENT_DIR: override,
OMP_PROFILE: "work",
PI_PROFILE: "legacy",
PI_CONFIG_DIR: ".omp-other",
});
expect(client?.markerPath).toBe(expected);
expect(client?.configPath).toBe(join(expected, "mcp.json"));
},
);
it.each([
[undefined, "C:\\Users\\a\\.pi\\agent"],
["", "C:\\Users\\a\\.pi\\agent"],
["~", "C:\\Users\\a"],
["~/agent", "C:\\Users\\a\\agent"],
["~\\agent", "C:\\Users\\a\\agent"],
["C:\\custom\\agent", "C:\\custom\\agent"],
["/c/custom/agent", "C:\\custom\\agent"],
["/mnt/c/custom/agent", "C:\\custom\\agent"],
["/cygdrive/c/custom/agent", "C:\\custom\\agent"],
["/c", "C:\\"],
["//server/share/agent", "//server/share/agent"],
["\\\\server\\share\\agent", "\\\\server\\share\\agent"],
["agent", "agent"],
["file:///C:/custom/agent%20dir", "C:\\custom\\agent dir"],
["file://server/share/agent", "\\\\server\\share\\agent"],
])("normalizes Windows Pi override %s", (override, expected) => {
const client = piPath("C:\\Users\\a", "win32", {
PI_CODING_AGENT_DIR: override,
});
expect(client?.markerPath).toBe(expected);
expect(client?.configPath).toBe(
`${expected.replace(/[\\/]+$/u, "").replaceAll("/", "\\")}\\mcp.json`,
);
});
it("reports malformed file URLs instead of silently choosing the default", async () => {
const home = await createTestTempDirectory("rea-pi-invalid-path-");
const client = piPath(home, "linux", {
PI_CODING_AGENT_DIR: "file:///agent%2fdir",
});
expect(client).toMatchObject({
configPath: "file:///agent%2fdir",
configPathError: expect.stringContaining("PI_CODING_AGENT_DIR"),
});
if (client === undefined) throw new Error("missing Pi client");
expect(await configureClientConfiguration(client)).toEqual({
status: "failed",
reason: "path",
});
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
status: "failed",
detail: expect.stringContaining("PI_CODING_AGENT_DIR"),
});
expect(
await detectClients(home, "linux", {
PI_CODING_AGENT_DIR: "file:///agent%2fdir",
}),
).toEqual([]);
});
it("detects the normalized override marker without needing mcp.json", async () => {
const home = await createTestTempDirectory("rea-pi-detection-");
await mkdir(join(home, "custom-agent"));
const detected = await detectClients(home, process.platform, {
PI_CODING_AGENT_DIR: "~/custom-agent",
});
expect(detected.find(({ name }) => name === "pi")?.configPath).toBe(
join(home, "custom-agent", "mcp.json"),
);
});
});
const piClient = async () => {
const home = await createTestTempDirectory("rea-pi-config-");
const client = piPath(home, process.platform, {});
if (client?.markerPath === undefined) throw new Error("missing Pi client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
describe("Pi registration semantics", () => {
it("preserves unrelated content and OMP-only lists during setup, repeat, and uninstall", async () => {
const { home, client } = await piClient();
// Pi loads mcp.json with strict JSON, so the retained unrelated settings
// are valid strict JSON rather than the commented JSONC fixture.
const original = `{"autoEnableCodemode": false,
"mcpServers": { "other": { "command": "other-server" } },
"enabledServers": ["other"],
"disabledServers": ["rea", "other"]
}\n`;
await writeFile(client.configPath, original);
expect(
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toMatchObject({ status: "configured" });
expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
original,
);
const configured = await readFile(client.configPath, "utf8");
expect(JSON.parse(configured)).toEqual({
autoEnableCodemode: false,
mcpServers: {
other: { command: "other-server" },
rea: {
type: "stdio",
command: "npx",
args: NPX_REGISTRATION_COMMAND.slice(1),
},
},
enabledServers: ["other"],
disabledServers: ["rea", "other"],
});
expect(
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toEqual({ status: "unchanged" });
expect(await readFile(client.configPath, "utf8")).toBe(configured);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "pi"),
).toMatchObject({ state: "aligned" });
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
"removed",
);
expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual(
JSON.parse(original),
);
expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
original,
);
});
it.each([
[undefined, true, "aligned"],
["stdio", true, "aligned"],
["http", true, "invalid"],
["streamable-http", true, "invalid"],
["sse", true, "invalid"],
[undefined, false, "stale"],
["stdio", false, "stale"],
])(
"doctor handles type %s and enabled %s without OMP allowlist overrides",
async (type, enabled, state) => {
const { home, client } = await piClient();
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
command: "npx",
args: NPX_REGISTRATION_COMMAND.slice(1),
type,
enabled,
},
},
enabledServers: ["rea"],
}),
);
const statuses = await readClientRegistrationStatuses(home, undefined, {
environment: {},
});
expect(statuses.find(({ client: name }) => name === "pi")).toMatchObject({
state,
});
},
);
});
describe("Pi strict JSON parsing", () => {
it.each([
[
"a comment",
`{ // Pi loads strict JSON only\n"mcpServers": { "other": { "command": "other" } }\n}\n`,
],
[
"a trailing comma",
`{"mcpServers": { "other": { "command": "other" }, },}\n`,
],
[
"a BOM before valid JSON",
`\uFEFF{"mcpServers": { "other": { "command": "other" } }}\n`,
],
["empty text", ""],
["whitespace only", "\n \t\r\n"],
["a BOM only", "\uFEFF"],
])(
"rejects an existing Pi configuration with %s before any mutation",
async (_description, original) => {
const { home, client } = await piClient();
await writeFile(client.configPath, original);
expect(() => parseClientConfiguration(original, "pi")).toThrow();
expect(
await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toMatchObject({ status: "invalid" });
expect(
await configureClientConfiguration(
client,
{},
NPX_REGISTRATION_COMMAND,
),
).toEqual({ status: "failed", reason: "readback" });
expect(await readFile(client.configPath, "utf8")).toBe(original);
await expect(
readFile(`${client.configPath}.rea.backup`),
).rejects.toMatchObject({ code: "ENOENT" });
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "pi"),
).toMatchObject({ state: "invalid" });
expect(
await systemUninstallHost(home).removeClient(client),
).toMatchObject({
name: "pi",
status: "failed",
detail: expect.stringContaining("not valid JSON"),
});
expect(await readFile(client.configPath, "utf8")).toBe(original);
await expect(
readFile(`${client.configPath}.rea.backup`),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("initializes a missing Pi configuration as valid strict JSON without a backup", async () => {
const { home, client } = await piClient();
await expect(readFile(client.configPath)).rejects.toMatchObject({
code: "ENOENT",
});
expect(
await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toEqual({ status: "create" });
expect(
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toMatchObject({ status: "configured" });
const created = await readFile(client.configPath, "utf8");
expect(() => JSON.parse(created)).not.toThrow();
expect(JSON.parse(created)).toEqual({
mcpServers: {
rea: {
type: "stdio",
command: "npx",
args: NPX_REGISTRATION_COMMAND.slice(1),
},
},
});
await expect(
readFile(`${client.configPath}.rea.backup`),
).rejects.toMatchObject({ code: "ENOENT" });
expect(
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toEqual({ status: "unchanged" });
expect(await readFile(client.configPath, "utf8")).toBe(created);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "pi"),
).toMatchObject({ state: "aligned" });
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
name: "pi",
status: "removed",
});
expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({
mcpServers: {},
});
});
it("keeps JSONC tolerance for other dialects while Pi rejects the same text", () => {
const jsonc = `\uFEFF{\n // A comment Pi rejects but OpenCode keeps.\n "mcp": { "other": { "type": "local" }, },\n}\n`;
expect(parseClientConfiguration(jsonc, "opencode").servers).toEqual({
other: { type: "local" },
});
expect(parseClientConfiguration("", "opencode").document).toEqual({});
expect(parseClientConfiguration("\uFEFF", "opencode").document).toEqual({});
expect(() => parseClientConfiguration(jsonc, "pi")).toThrow();
expect(() => parseClientConfiguration("", "pi")).toThrow();
expect(() => parseClientConfiguration("\uFEFF", "pi")).toThrow();
});
});