diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index 5b1665f29a..4781e1490d 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -172,3 +172,47 @@ export const effectiveClientConfiguration = ( } return { ...last, servers, legacyServers: {} }; }; + +/** Read the configuration ZCode will load, preserving its same-scope fallback rule. */ +export const readEffectiveClientRegistrationConfiguration = async ( + client: SetupClient, + resources?: ArtifactResourceScope, +): Promise< + Result< + { + readonly document: ClientConfigurationDocument | undefined; + readonly configPath: string; + }, + ClientConfigurationFileError + > +> => { + const nativeSources = await readClientConfigurationFiles(client, resources); + if (!nativeSources.ok) return nativeSources; + const nativeDocument = effectiveClientConfiguration(nativeSources.value); + const fallbackPath = + client.format === "zcode" ? client.zcodeFallbackPath : undefined; + if ( + fallbackPath === undefined || + (nativeDocument !== undefined && + Object.keys(nativeDocument.servers).length > 0) + ) + return ok({ document: nativeDocument, configPath: client.configPath }); + + const fallbackClient: SetupClient = { + ...client, + configPath: fallbackPath, + configPaths: [fallbackPath], + format: "zcode_fallback", + }; + const fallbackSources = await readClientConfigurationFiles( + fallbackClient, + resources, + ); + if (!fallbackSources.ok) return fallbackSources; + const fallbackDocument = effectiveClientConfiguration(fallbackSources.value); + return ok( + fallbackDocument === undefined + ? { document: nativeDocument, configPath: client.configPath } + : { document: fallbackDocument, configPath: fallbackPath }, + ); +}; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 878abd0d5b..0afbf59686 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -1,8 +1,5 @@ import { readClientPolicyBlock } from "./GeminiClientSettings.js"; -import { - effectiveClientConfiguration, - readClientConfigurationFiles, -} from "./ClientConfigurationFiles.js"; +import { readEffectiveClientRegistrationConfiguration } from "./ClientConfigurationFiles.js"; import { clientServerForcedEnabled, clientServerListedDisabled, @@ -144,24 +141,26 @@ export const readClientRegistrationInspections = async ( }); continue; } + let selectedConfigPath = client.configPath; try { - const files = await readClientConfigurationFiles( + const configuration = await readEffectiveClientRegistrationConfiguration( client, options.resources, ); - if (!files.ok) { + if (!configuration.ok) { record({ client: client.name, - config_path: client.configPath, + config_path: configuration.error.path, command: [], state: "invalid", - remediation: files.error.detail, + remediation: configuration.error.detail, }); continue; } - const parsed = effectiveClientConfiguration(files.value); + const { document: parsed, configPath } = configuration.value; + selectedConfigPath = configPath; if (parsed === undefined) { - record(unavailableStatus(client, "missing")); + record(unavailableStatus(client, "missing", configPath)); continue; } const policy = await readClientPolicyBlock( @@ -173,7 +172,7 @@ export const readClientRegistrationInspections = async ( if (!policy.ok) { record({ client: client.name, - config_path: client.configPath, + config_path: configPath, command: [], state: "invalid", remediation: policy.error.detail, @@ -190,17 +189,26 @@ export const readClientRegistrationInspections = async ( if (editabilityError !== undefined) { record({ client: client.name, - config_path: client.configPath, + config_path: configPath, command: [], state: "invalid", remediation: editabilityError.message, }); continue; } - record(unavailableStatus(client, "missing")); + record(unavailableStatus(client, "missing", configPath)); continue; } - const registration = parseRegistration(raw, client); + const effectiveClient: SetupClient = + parsed.dialect === "zcode_fallback" + ? { + ...client, + configPath, + configPaths: [configPath], + format: "zcode_fallback", + } + : client; + const registration = parseRegistration(raw, effectiveClient); const command: RegistrationCommand = [ registration.command, ...registration.args, @@ -213,13 +221,13 @@ export const readClientRegistrationInspections = async ( packageVersion !== PRODUCT_IDENTITY.packageVersion; record( configuredStatus( - client, + effectiveClient, command, activation === "enabled" && !packageVersionDrift && registrationAligned( registration, - client, + effectiveClient, currentCommandPath, options.platform ?? process.platform, ) && @@ -237,7 +245,11 @@ export const readClientRegistrationInspections = async ( ); } catch (cause: unknown) { record( - unavailableStatus(client, isMissing(cause) ? "missing" : "invalid"), + unavailableStatus( + client, + isMissing(cause) ? "missing" : "invalid", + selectedConfigPath, + ), ); } } @@ -254,7 +266,8 @@ const registrationActivation = ( (client.format !== "grok" && registration.disabled === true) || (registration.enabled === false && !clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey)) || - (client.format === "zcode" && registration.enable === false) || + ((parsed.dialect === "zcode" || parsed.dialect === "zcode_fallback") && + registration.enable === false) || clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey) ? "disabled" : "enabled"; @@ -435,9 +448,10 @@ const packageVersionRemediation = ( const unavailableStatus = ( client: SetupClient, state: "missing" | "invalid", + configPath: string = client.configPath, ): ClientRegistrationStatus => ({ client: client.name, - config_path: client.configPath, + config_path: configPath, command: [], state, remediation: remediation(client), diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts index 2687473564..9e1b6026c3 100644 --- a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -457,6 +457,115 @@ describe("ZCode disabled registrations", () => { ).toBe("configured"); expect(await statusFor()).toMatchObject({ state: "aligned", command }); }); + + it("reports the effective same-scope fallback registration and source path", async () => { + const { home, client } = await zcodeClient(); + const fallback = client.zcodeFallbackPath; + if (fallback === undefined) throw new Error("missing ZCode fallback path"); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallback, + JSON.stringify({ + mcpServers: { + rea: { command: command[0], args: command.slice(1) }, + }, + }), + ); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ + config_path: fallback, + command, + state: "aligned", + }); + }); + + it("reports a selected fallback parse failure as invalid at its source path", async () => { + const { home, client } = await zcodeClient(); + const fallback = client.zcodeFallbackPath; + if (fallback === undefined) throw new Error("missing ZCode fallback path"); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile(fallback, "{ malformed"); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ config_path: fallback, state: "invalid" }); + }); + + it("reports disabled fallback registrations as stale at the fallback path", async () => { + const { home, client } = await zcodeClient(); + const fallback = client.zcodeFallbackPath; + if (fallback === undefined) throw new Error("missing ZCode fallback path"); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallback, + JSON.stringify({ + mcpServers: { + rea: { command: command[0], args: command.slice(1), enable: false }, + }, + }), + ); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ + config_path: fallback, + command, + state: "stale", + }); + }); + + it("prefers a nonempty native server table over the fallback", async () => { + const { home, client } = await zcodeClient(); + const fallback = client.zcodeFallbackPath; + if (fallback === undefined) throw new Error("missing ZCode fallback path"); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallback, + JSON.stringify({ + mcpServers: { + rea: { command: "fallback-command", args: ["mcp"] }, + }, + }), + ); + await writeFile( + client.configPath, + JSON.stringify({ + mcp: { + servers: { rea: { command: command[0], args: command.slice(1) } }, + }, + }), + ); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ + config_path: client.configPath, + command, + state: "aligned", + }); + }); }); describe("ZCode strict native JSON", () => { diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index 141cf23ec6..a57864da14 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -146,6 +146,22 @@ describe("maintenance activation and layered configuration", () => { expect(await readFile(configPath, "utf8")).toBe(original); }); + it("selects a ZCode registration loaded from the same-scope fallback", async () => { + await writeClient("zcode", JSON.stringify({ mcp: { servers: {} } })); + const fallbackPath = join(home, ".agents", "mcp.json"); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallbackPath, + JSON.stringify({ + mcpServers: { rea: { command: entryPoint, args: ["mcp"] } }, + }), + ); + + await expect( + existingMaintenanceScope(home, entryPoint, process.env), + ).resolves.toMatchObject({ clients: ["zcode"] }); + }); + it("selects an enabled registration inherited through layered OpenCode files", async () => { const directory = join(home, ".config", "opencode"); await mkdir(directory, { recursive: true });