diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 5681c8c6ee..5b457426c0 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -1674,10 +1674,11 @@ export const clientRegistrationEntry = ( ...(Object.keys(environment).length === 0 ? {} : { env: environment }), }; case "opencode_v2": - // V2 connects servers unless `disabled` is true. + // Override lower-priority disabled flags when refreshing a V2 registration. return { type: "local", command: [...command], + disabled: false, ...(Object.keys(environment).length === 0 ? {} : { environment: { ...environment } }), diff --git a/tests/acceptance/setup/opencodeV2ClientSetup.test.ts b/tests/acceptance/setup/opencodeV2ClientSetup.test.ts new file mode 100644 index 0000000000..2f25644dd4 --- /dev/null +++ b/tests/acceptance/setup/opencodeV2ClientSetup.test.ts @@ -0,0 +1,154 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parse } from "jsonc-parser"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +const setupArguments = [ + "setup", + "--client", + "opencode", + "--skill=false", + "--yes", + "--json", +]; + +const layeredOpenCode = async ( + cli: TestCli, + disabled: boolean, + jsonc: boolean, +) => { + const home = await createTestTempDirectory("rea-opencode-v2-activation-"); + const configurationHome = join(home, "config"); + const directory = join(configurationHome, "opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "config.json"); + const upper = join(directory, jsonc ? "opencode.jsonc" : "opencode.json"); + const other = { type: "local", command: ["other-server"], disabled: true }; + const originalLower = JSON.stringify({ + mcp: { + servers: { + rea: { + type: "local", + command: ["rea", "mcp"], + disabled, + environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" }, + }, + other, + }, + }, + }); + const originalUpper = `${jsonc ? "// Keep the user configuration comment.\n" : ""}${JSON.stringify( + { + mcp: { servers: {} }, + theme: "system", + }, + )}\n`; + await writeFile(lower, originalLower); + await writeFile(upper, originalUpper); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: configurationHome, + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + HOPPER_LAUNCHER_PATH: join(home, "absent-hopper"), + }, + }); + return { lower, upper, originalLower, originalUpper, other, run }; +}; + +for (const disabled of [true, false]) { + for (const jsonc of [false, true]) { + cliTest( + `OpenCode V2 setup activates ${jsonc ? "JSONC" : "JSON"} over a lower disabled=${disabled} registration`, + async ({ cli }) => { + const fixture = await layeredOpenCode(cli, disabled, jsonc); + const setup = await fixture.run(setupArguments); + expect(setup.exitCode, setup.stdout).toBe(0); + expect(setup.json).toMatchObject({ status: "ready" }); + const configured = await readFile(fixture.upper, "utf8"); + expect(parse(configured)).toMatchObject({ + mcp: { + servers: { + rea: { + type: "local", + }, + }, + }, + theme: "system", + }); + if (disabled) + expect(parse(configured).mcp.servers.rea.disabled).toBe(false); + if (jsonc) + expect(configured).toContain( + "// Keep the user configuration comment.", + ); + expect(await readFile(fixture.lower, "utf8")).toBe( + fixture.originalLower, + ); + expect(await readFile(`${fixture.upper}.rea.backup`, "utf8")).toBe( + fixture.originalUpper, + ); + + const repeated = await fixture.run(setupArguments); + expect(repeated.exitCode, repeated.stdout).toBe(0); + expect(repeated.json).toMatchObject({ + status: "ready", + plannedActions: [], + appliedActions: [], + }); + const doctor = await fixture.run([ + "doctor", + "--client", + "opencode", + "--json", + ]); + expect(doctor.exitCode, doctor.stdout).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "opencode", state: "aligned" }), + ]), + }, + }); + expect(await readFile(fixture.upper, "utf8")).toBe(configured); + expect(await readFile(fixture.lower, "utf8")).toBe( + fixture.originalLower, + ); + expect( + parse(await readFile(fixture.lower, "utf8")).mcp.servers.other, + ).toEqual(fixture.other); + }, + ); + } +} + +cliTest( + "OpenCode V2 activation does not bypass a malformed lower configuration", + async ({ cli }) => { + const fixture = await layeredOpenCode(cli, true, true); + const malformed = "{ not json"; + await writeFile(fixture.lower, malformed); + const setup = await fixture.run(setupArguments); + expect(setup.exitCode, setup.stdout).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(fixture.lower), + }); + expect(await readFile(fixture.lower, "utf8")).toBe(malformed); + expect(await readFile(fixture.upper, "utf8")).toBe(fixture.originalUpper); + await expect(access(`${fixture.upper}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index ca688295c5..fd86387ecd 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -458,7 +458,7 @@ describe("OpenCode V2 native server table", () => { timeout: { catalog: 30000 }, servers: { other: { type: "local", command: ["node", "other.js"] }, - rea: { type: "local", command: [...command] }, + rea: { type: "local", command: [...command], disabled: false }, }, }); expect(await readClientRegistrationStatuses(home)).toEqual( @@ -501,7 +501,9 @@ describe("OpenCode V2 native server table", () => { expect( parseOpenCode(await readFile(client.configPath, "utf8")).mcp, ).toEqual({ - servers: { rea: { type: "local", command: [...command] } }, + servers: { + rea: { type: "local", command: [...command], disabled: false }, + }, }); expect((await systemUninstallHost(home).removeClient(client)).status).toBe( "removed", diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index 8fac7e9548..5c3341ba95 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -130,6 +130,28 @@ describe("OMP integration maintenance", () => { }); describe("maintenance activation and layered configuration", () => { + it("leaves an inherited disabled OpenCode V2 registration outside maintenance", async () => { + const upper = await writeClient( + "opencode", + JSON.stringify({ mcp: { servers: {} }, theme: "user-selected" }), + ); + const lower = join(home, ".config", "opencode", "config.json"); + const originalLower = JSON.stringify({ + mcp: { + servers: { + rea: { type: "local", command: [entryPoint, "mcp"], disabled: true }, + }, + }, + }); + await writeFile(lower, originalLower); + const originalUpper = await readFile(upper, "utf8"); + await expect( + existingMaintenanceScope(home, entryPoint, process.env), + ).resolves.toMatchObject({ clients: [] }); + expect(await readFile(lower, "utf8")).toBe(originalLower); + expect(await readFile(upper, "utf8")).toBe(originalUpper); + }); + it("leaves a natively disabled ZCode registration outside maintenance", async () => { const configPath = await writeClient( "zcode",