From d2da71385955b86b2c7a669e917987bfb7916afc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:45:01 +0800 Subject: [PATCH] fix(setup): recognize inherited owned launcher --- src/application/SetupClientConfiguration.ts | 12 +++- .../setup/registrationOwnershipSetup.test.ts | 68 +++++++++++++++++++ .../setupPolicySnapshot.test.ts | 49 +++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 9d02237ed9..5102d5b955 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -705,7 +705,17 @@ const registrationOwnershipBlocker = ( value: effective.servers[name], paths: client.configPaths ?? [client.configPath], }); - if (Object.hasOwn(parsed.servers, name)) + const fragment = parsed.servers[name]; + // A layered policy fragment inherits its launcher. Classify the effective + // launcher above, but also admit any explicit command this write replaces. + const inheritsLauncher = + effective !== undefined && + effective !== parsed && + typeof fragment === "object" && + fragment !== null && + !Array.isArray(fragment) && + !Object.hasOwn(fragment, "command"); + if (Object.hasOwn(parsed.servers, name) && !inheritsLauncher) entries.push({ value: parsed.servers[name], paths: [client.configPath] }); if (legacyClientServerPath(parsed, name) !== undefined) { entries.push({ diff --git a/tests/acceptance/setup/registrationOwnershipSetup.test.ts b/tests/acceptance/setup/registrationOwnershipSetup.test.ts index f3cbb82fb0..ffa3b34461 100644 --- a/tests/acceptance/setup/registrationOwnershipSetup.test.ts +++ b/tests/acceptance/setup/registrationOwnershipSetup.test.ts @@ -262,3 +262,71 @@ cliTest( }); }, ); + +cliTest( + "OpenCode setup refreshes an owned inherited launcher through a policy fragment", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-owned-layer-"); + const directory = join(home, ".config", "opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "config.json"); + const upper = join(directory, "opencode.json"); + const originalLower = JSON.stringify({ + mcp: { rea: { type: "local", command: ["rea", "mcp"] } }, + }); + const originalUpper = JSON.stringify({ + mcp: { rea: { environment: { CUSTOM: "keep" } } }, + }); + await writeFile(lower, originalLower); + await writeFile(upper, originalUpper); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "opencode", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(lower, "utf8")).toBe(originalLower); + expect(await readFile(`${upper}.rea.backup`, "utf8")).toBe(originalUpper); + expect(JSON.parse(await readFile(upper, "utf8"))).toMatchObject({ + mcp: { rea: { type: "local", environment: { CUSTOM: "keep" } } }, + }); + const foreignFragment = JSON.stringify({ + mcp: { rea: { command: ["other", "mcp"] } }, + }); + await writeFile(upper, foreignFragment); + const blocked = await cli.run({ + arguments: [ + "setup", + "--client", + "opencode", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + }); + expect(blocked.exitCode, blocked.stdout).toBe(1); + expect(blocked.json).toMatchObject({ + status: "needs_human", + remediation: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(upper, "utf8")).toBe(foreignFragment); + expect(await readFile(`${upper}.rea.backup`, "utf8")).toBe(originalUpper); + }, +); diff --git a/tests/process-global/setupPolicySnapshot.test.ts b/tests/process-global/setupPolicySnapshot.test.ts index 89e82754f8..9ac1e5338c 100644 --- a/tests/process-global/setupPolicySnapshot.test.ts +++ b/tests/process-global/setupPolicySnapshot.test.ts @@ -136,3 +136,52 @@ it("blocks an inherited foreign row before planning creation of a missing write code: "ENOENT", }); }); + +it.each(["other", "rea"])( + "rechecks an inherited %s launcher after preflight for a policy fragment", + async (command) => { + const home = await createTestTempDirectory("rea-setup-layer-recheck-"); + const lower = join(home, "config.json"); + const configPath = join(home, "opencode.json"); + const client: SetupClient = { + name: "opencode", + format: "opencode", + configPath, + configPaths: [lower, configPath], + }; + await writeFile( + lower, + '{"mcp":{"rea":{"type":"local","command":["rea","mcp"]}}}', + ); + const original = '{"mcp":{"rea":{"environment":{"CUSTOM":"keep"}}}}'; + await writeFile(configPath, original); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "update" }); + // The write transaction must use a fresh source discovery after preflight. + await writeFile( + lower, + JSON.stringify({ + mcp: { rea: { type: "local", command: [command, "mcp"] } }, + }), + ); + const result = await configureClientConfiguration(client, {}, [ + "rea", + "mcp", + ]); + if (command === "other") { + expect(result).toMatchObject({ + status: "failed", + reason: "registration_ownership", + detail: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(configPath, "utf8")).toBe(original); + await expect(readFile(`${configPath}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + } else { + expect(result).toMatchObject({ status: "configured" }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + } + }, +);