From 01a725ecfa901e6eeeb6cecc6ef7316a82ac257c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:14:40 +0800 Subject: [PATCH] fix(setup): preserve configuration directory requirements --- src/application/ClientConfigPath.ts | 9 +++- .../ClientConfigurationPublication.ts | 5 ++- .../setup/hermesClientSetup.test.ts | 6 +-- tests/boundary/filesystem/setupConfig.test.ts | 42 ++++++++++++++++--- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/application/ClientConfigPath.ts b/src/application/ClientConfigPath.ts index 52c1091bb8..b2490ae1bf 100644 --- a/src/application/ClientConfigPath.ts +++ b/src/application/ClientConfigPath.ts @@ -74,10 +74,17 @@ const canonicalRegularConfigPath = async ( ): Promise => { 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; diff --git a/src/application/ClientConfigurationPublication.ts b/src/application/ClientConfigurationPublication.ts index 960945bc19..abdb47f4a5 100644 --- a/src/application/ClientConfigurationPublication.ts +++ b/src/application/ClientConfigurationPublication.ts @@ -84,7 +84,10 @@ export class ClientConfigurationPublication { selection?: ClientConfigurationSelection, ): Promise { 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); diff --git a/tests/acceptance/setup/hermesClientSetup.test.ts b/tests/acceptance/setup/hermesClientSetup.test.ts index 075c7de9ff..af7dfb746a 100644 --- a/tests/acceptance/setup/hermesClientSetup.test.ts +++ b/tests/acceptance/setup/hermesClientSetup.test.ts @@ -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); diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index 94117bc95f..373e0ada19 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -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",