fix(setup): preserve configuration directory requirements

This commit is contained in:
morluto
2026-10-27 13:14:40 +08:00
parent 235e8bc056
commit 01a725ecfa
4 changed files with 52 additions and 10 deletions
+8 -1
View File
@@ -74,10 +74,17 @@ const canonicalRegularConfigPath = async (
): Promise<string | undefined> => {
if (fileSystem.realpath === undefined) return requestedPath;
let ancestor = absoluteCallerPath(requestedPath);
const directoryRequired =
process.platform !== "win32" && ancestor.endsWith("/");
const missing: string[] = [];
while (true) {
try {
return join(await fileSystem.realpath(ancestor), ...missing);
const canonical = join(await fileSystem.realpath(ancestor), ...missing);
// Reconstructing missing children must retain the caller's directory
// requirement; otherwise a missing `config.json/` becomes a writable file.
return directoryRequired && !canonical.endsWith("/")
? `${canonical}/`
: canonical;
} catch (cause: unknown) {
if (!isAbsentPath(cause) || dirname(ancestor) === ancestor)
return requestedPath;
@@ -84,7 +84,10 @@ export class ClientConfigurationPublication {
selection?: ClientConfigurationSelection,
): Promise<ClientConfigurationPublication> {
const parent = await realpath(dirname(path));
const destination = join(parent, basename(path));
// Retain the transaction owner's directory requirement through final-file
// publication rather than allowing basename/join to select a regular file.
const destination =
join(parent, basename(path)) + (path.endsWith(sep) ? sep : "");
const input = await selectedConfiguration(destination, previous);
if (selection !== undefined) {
await assertRequestedConfigurationPath(path, selection.requestedPath);
@@ -40,7 +40,7 @@ for (const [version, keyStyle] of [
"timestamp: 2026-10-14T00:00:00.123456+08:00\n";
const original =
settings +
"mcp_servers:\n rea:\n command: rea-old\n args: [mcp]\n env:\n" +
"mcp_servers:\n rea:\n command: rea\n args: [mcp]\n env:\n" +
Object.entries(environment)
.map(
([key, value]) =>
@@ -114,7 +114,7 @@ cliTest(
"# Caller configuration",
"mcp_servers:",
" rea:",
" command: rea-old",
" command: rea",
" args: [mcp]",
" env:",
" CALLER_SETTING: keep exactly",
@@ -173,7 +173,7 @@ cliTest(
},
});
expect(parse(configured)).not.toMatchObject({
mcp_servers: { rea: { command: "rea-old" } },
mcp_servers: { rea: { command: "rea" } },
});
expect(configured).toContain("# Curated for this client.");
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
+37 -5
View File
@@ -7,7 +7,7 @@ import {
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { parse as parseToml } from "smol-toml";
@@ -25,6 +25,7 @@ import { supportedClients } from "../../../src/application/SupportedClients.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { failedConfigurationMessage } from "../../../src/application/SetupClients.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js";
const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier;
@@ -34,9 +35,9 @@ it.skipIf(process.platform === "win32")(
const home = await createTestTempDirectory(
"rea-config-equivalent-launcher-",
);
const real = join(home, "rea-current");
const real = CURRENT_ENTRYPOINT_PATH;
if (real === undefined) throw new Error("missing current entrypoint");
const alias = join(home, "rea-alias");
await writeFile(real, "current launcher\n");
await symlink(real, alias);
await mkdir(join(home, ".cursor"));
const configPath = join(home, ".cursor", "mcp.json");
@@ -76,9 +77,9 @@ it.skipIf(process.platform === "win32")(
const home = await createTestTempDirectory(
"rea-config-timeout-launcher-alias-",
);
const real = join(home, "rea-current");
const real = CURRENT_ENTRYPOINT_PATH;
if (real === undefined) throw new Error("missing current entrypoint");
const alias = join(home, "rea-alias");
await writeFile(real, "current launcher\n");
await symlink(real, alias);
await mkdir(join(home, ".codex"));
const configPath = join(home, ".codex", "config.toml");
@@ -157,6 +158,37 @@ it.skipIf(process.platform === "win32").each([false, true])(
},
);
it
.skipIf(process.platform === "win32")
.each(
["/", "/."].flatMap((suffix) =>
[false, true].map((relativePath) => ({ suffix, relativePath })),
),
)(
"rejects a missing configuration with directory suffix $suffix and relative=$relativePath",
async ({ suffix, relativePath }) => {
const root = await createTestTempDirectory("rea-config-directory-suffix-");
const path = join(root, "config.json");
const requested = `${relativePath ? relative(process.cwd(), path) : path}${suffix}`;
expect(await resolveClientConfigTransactionPath(requested)).toBe(
`${path}/`,
);
expect(
await configureClientConfiguration({
name: "cursor",
format: "json",
configPath: requested,
}),
).toMatchObject({ status: "failed" });
const selected = await lstat(path).catch((cause: unknown) => {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return undefined;
throw cause;
});
expect(selected?.isFile() ?? false).toBe(false);
},
);
describe("JSON client configuration transaction: writing configuration through safe paths", () => {
it.skipIf(typeof process.getuid !== "function")(
"rejects a symlink target not owned by the current user",