fix(setup): preserve client config symlinks

This commit is contained in:
N0zoM1z0
2026-07-14 20:50:07 +00:00
parent 528c05e2fa
commit df87544a5d
7 changed files with 278 additions and 25 deletions
+4
View File
@@ -274,6 +274,10 @@ describe("setup workflow", () => {
});
it.each([
[
"path",
"Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.",
],
[
"backup",
"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",
+70 -1
View File
@@ -1,8 +1,16 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import {
lstat,
mkdtemp,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveClientConfigTransactionPath } from "../src/application/ClientConfigPath.js";
import { configureJsonClient } from "../src/application/Setup.js";
let directory: string | undefined;
@@ -13,6 +21,25 @@ afterEach(async () => {
});
describe("JSON client configuration transaction", () => {
it("rejects a symlink target not owned by the current user", async () => {
if (typeof process.getuid !== "function") return;
const currentUid = process.getuid();
let statCalls = 0;
expect(
await resolveClientConfigTransactionPath("/config", {
lstat: () => {
statCalls += 1;
return Promise.resolve({
uid: statCalls === 1 ? currentUid : currentUid + 1,
isFile: () => statCalls > 1,
isSymbolicLink: () => statCalls === 1,
});
},
realpath: () => Promise.resolve("/target"),
}),
).toBeUndefined();
});
it("preserves existing keys, creates a backup, and reads back the MCP entry", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const configPath = join(directory, "mcp.json");
@@ -34,6 +61,48 @@ describe("JSON client configuration transaction", () => {
});
});
it("updates a symlink target without replacing the JSON config symlink", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const targetPath = join(directory, "managed.json");
const configPath = join(directory, "mcp.json");
const original = '{"theme":"dark"}\n';
await writeFile(targetPath, original);
await symlink(targetPath, configPath);
expect(
await configureJsonClient({ name: "cursor", configPath }),
).toMatchObject({ status: "configured" });
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(JSON.parse(await readFile(targetPath, "utf8"))).toMatchObject({
theme: "dark",
mcpServers: {
rea: {
command: "npx",
args: ["-y", "rea-agents", "mcp"],
},
},
});
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
status: "unchanged",
});
});
it("fails before mutation when a JSON config symlink is dangling", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const configPath = join(directory, "mcp.json");
await symlink(join(directory, "missing.json"), configPath);
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
status: "failed",
reason: "path",
});
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
it("performs no write or second backup when configuration already matches", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const configPath = join(directory, "mcp.json");
+87
View File
@@ -4,7 +4,9 @@ import {
mkdtemp,
mkdir,
readFile,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -92,6 +94,44 @@ describe("agent lifecycle", () => {
).toEqual({ status: "unchanged" });
});
it("updates a symlink target without replacing the TOML config symlink", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-toml-symlink-"));
roots.push(home);
const configPath = join(home, ".codex/config.toml");
const targetPath = join(home, "managed-config.toml");
const original = 'model = "gpt-5"\n';
await mkdir(dirname(configPath), { recursive: true });
await writeFile(targetPath, original);
await symlink(targetPath, configPath);
expect(
await configureTomlClient(
{ name: "codex", configPath, format: "toml" },
undefined,
["rea", "mcp"],
),
).toMatchObject({ status: "configured" });
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(await readFile(targetPath, "utf8")).toContain("[mcp_servers.rea]");
});
it("fails before mutation when a TOML config symlink is dangling", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-toml-symlink-"));
roots.push(home);
const configPath = join(home, ".codex/config.toml");
await mkdir(dirname(configPath), { recursive: true });
await symlink(join(home, "missing-config.toml"), configPath);
expect(
await configureTomlClient({ name: "codex", configPath, format: "toml" }),
).toEqual({ status: "failed", reason: "path" });
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
it("uninstalls only owned entries and refuses purge symlinks", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-"));
roots.push(home);
@@ -131,6 +171,52 @@ describe("agent lifecycle", () => {
);
});
it("removes an owned entry through a symlink without replacing it", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-symlink-"));
roots.push(home);
const configPath = join(home, ".cursor/mcp.json");
const targetPath = join(home, "managed-mcp.json");
const original = JSON.stringify({
mcpServers: {
rea: { command: "rea", args: ["mcp"] },
other: { command: "other" },
},
});
await mkdir(dirname(configPath), { recursive: true });
await writeFile(targetPath, original);
await symlink(targetPath, configPath);
expect((await runUninstall(false, systemUninstallHost(home))).status).toBe(
"complete",
);
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(JSON.parse(await readFile(targetPath, "utf8"))).toEqual({
mcpServers: { other: { command: "other" } },
});
});
it("fails before mutation when an uninstall config symlink is dangling", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-symlink-"));
roots.push(home);
const configPath = join(home, ".cursor/mcp.json");
await mkdir(dirname(configPath), { recursive: true });
await symlink(join(home, "missing-mcp.json"), configPath);
const result = await runUninstall(false, systemUninstallHost(home));
expect(result.status).toBe("failed");
expect(result.items).toContainEqual({
name: "cursor",
status: "failed",
detail:
"Configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun uninstall.",
});
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
it("fails closed on malformed client configuration", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-bad-"));
roots.push(home);
@@ -228,6 +314,7 @@ const testFileSystem: UninstallFileSystem = {
copy: (source, destination) => copyFile(source, destination),
writeText: (path, contents) => writeFile(path, contents),
stat: (path) => lstat(path),
realpath: (path) => realpath(path),
remove: (path) => rm(path, { recursive: true }),
};