diff --git a/docs/installation.md b/docs/installation.md index 71b7ffb7c..18d4ba165 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -175,9 +175,26 @@ Setup and doctor follow Qwen Code's tilde and working-directory-relative home overrides. Qwen Code also discovers personal skills in `~/.agents/skills`, where setup installs the bundled workflow. +Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override +is nonempty, including its shared personal skills at +`$GEMINI_CLI_HOME/.agents/skills`. Setup and doctor check MCP allow/exclude +policies from system defaults, user settings, trusted workspace settings, and +system overrides. Workspace trust follows `GEMINI_CLI_TRUST_WORKSPACE`, +`GEMINI_RESTRICTED_MODE`, the effective `security.folderTrust.enabled` setting, +and the selected `trustedFolders.json` (including +`GEMINI_CLI_TRUSTED_FOLDERS_PATH`). Settings in an untrusted workspace do not +participate. A workspace or system `mcpServers.rea` replaces the entire user +entry; setup stops if the winning definition conflicts with its planned entry. +Review those files manually: setup never edits administrator/workspace settings +or grants workspace trust. Trust supplied only by a running IDE is outside this +standalone CLI check. + +Codex treats an unset or empty `CODEX_HOME` as `~/.codex`. Devin honors +`XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems. + For OMP, setup writes a `type: "stdio"` entry to the user-level -`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute -`PI_CODING_AGENT_DIR`, and the profile selected by `OMP_PROFILE` or +`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR` and +`PI_CODING_AGENT_DIR` (relative to the setup working directory), and the profile selected by `OMP_PROFILE` or `PI_PROFILE` (`~/.omp/profiles//agent/mcp.json`). Setup also removes `rea` from that file's `disabledServers` list, which would otherwise hide the registration. Doctor treats an `enabled: false` entry as active when @@ -198,10 +215,15 @@ configuration and skill destinations; doctor and uninstall use those same paths. For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2 both load. If the configuration already uses OpenCode V2's native `mcp.servers` table, setup registers REA there instead and replaces any earlier -`mcp.rea` entry from REA. +`mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and +`OPENCODE_CONFIG`, preferring the later-loaded JSONC file when both JSON and +JSONC exist. The ordinary XDG global configuration still participates when a +custom directory is selected. Doctor checks the merged user registration; uninstall removes +REA-owned entries from every applicable user file so an older entry cannot +become active again. Malformed applicable files block setup before changes. Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from -`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server +`~/.grok/config.toml` when `GROK_HOME` is unset or empty. Setup edits that server table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name in that list. The shared skill installed under `~/.agents/skills` is already @@ -241,9 +263,10 @@ save verified paths for an existing Ghidra installation. The bundled skill is installed where each selected client discovers personal skills: Claude Code uses `~/.claude/skills` (or -`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients, -including Qwen Code, use the shared `~/.agents/skills` directory. A mixed selection -plans the relevant paths. Selecting the skill without a client uses the shared +`$CLAUDE_CONFIG_DIR/skills` when configured). Gemini CLI uses `.agents/skills` +under its selected home; Antigravity 2.0 uses `~/.gemini/config/skills`. +Other integrations, including Qwen Code, use `~/.agents/skills`. +A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared directory. Setup leaves existing skill copies in other locations untouched. `doctor --skill --json` verifies the selected copies against the bundled diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index 1a97cfbab..cd84655b2 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -87,6 +87,10 @@ export async function verifyPackageEnvironment({ PI_PROFILE: undefined, QWEN_HOME: undefined, OPENCODE_CONFIG: undefined, + OPENCODE_CONFIG_DIR: undefined, + GEMINI_CLI_HOME: undefined, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: undefined, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: undefined, XDG_CONFIG_HOME: join(home, ".config"), PATH: `${fakeBin}:${process.env.PATH ?? ""}`, REA_ANALYSIS_PROVIDER: "auto", diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index a0b7c9c1c..c7f13ba66 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -1413,3 +1413,45 @@ export const clientRegistrationEntry = ( }; } }; + +/** Parse one Gemini policy at its source boundary before consolidating scopes. */ +export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) => + z + .object({ + allowed: z.array(z.string()).optional(), + excluded: z.array(z.string()).optional(), + }) + .passthrough() + .parse(parsed.document.mcp ?? {}); + +/** Gemini consolidates excludes by union and allowlists by normalized intersection. */ +export const geminiServerPolicyBlock = ( + policies: readonly ReturnType[], + serverKey: string, +): string | undefined => { + if (policies.some(({ excluded }) => excluded?.includes(serverKey))) + return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`; + const allowlists = policies.flatMap(({ allowed }) => + allowed === undefined ? [] : [allowed], + ); + const allowed = allowlists.reduce( + (previous, current) => { + if (previous === undefined) return current; + const normalized = new Set( + current.map((name) => name.toLowerCase().trim()), + ); + return previous.filter((name) => + normalized.has(name.toLowerCase().trim()), + ); + }, + undefined, + ); + // Gemini's manager applies the consolidated allowlist only when nonempty. + if ( + allowed !== undefined && + allowed.length > 0 && + !allowed.includes(serverKey) + ) + return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`; + return undefined; +}; diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts new file mode 100644 index 000000000..5982f53a9 --- /dev/null +++ b/src/application/ClientConfigurationFiles.ts @@ -0,0 +1,108 @@ +import { err, ok, type Result } from "../domain/result.js"; +import { readFile } from "node:fs/promises"; +import { + effectiveClientServer, + parseClientConfiguration, + type ClientConfigurationDocument, +} from "./ClientConfigurationDocument.js"; +import type { SetupClient } from "./SupportedClients.js"; + +/** A named source failure that setup can explain without losing the bad path. */ +export interface ClientConfigurationFileError { + readonly kind: "unreadable" | "malformed"; + readonly path: string; + readonly detail: string; +} + +/** Preserve named read failures and distinguish a missing source from an empty file. */ +export const readConfigurationText = async ( + path: string, +): Promise> => { + try { + return ok(await readFile(path, "utf8")); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return ok(undefined); + return err({ + kind: "unreadable", + path, + detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } +}; + +/** Read and parse one configuration at its source boundary. */ +export const readConfigurationFile = async ( + path: string, + format: SetupClient["format"], +): Promise< + Result +> => { + const source = await readConfigurationText(path); + if (!source.ok) return source; + const content = source.value; + if (content === undefined) return ok(undefined); + try { + return ok(parseClientConfiguration(content, format)); + } catch (cause: unknown) { + return err({ + kind: "malformed", + path, + detail: `Configuration ${path} is malformed: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } +}; + +/** Read every applicable user file; malformed lower-priority files also stop setup. */ +export const readClientConfigurationFiles = async ( + client: SetupClient, +): Promise< + Result +> => { + const documents: ClientConfigurationDocument[] = []; + for (const path of client.configPaths ?? [client.configPath]) { + const document = await readConfigurationFile(path, client.format); + if (!document.ok) return document; + if (document.value !== undefined) documents.push(document.value); + } + return ok(documents); +}; + +/** OpenCode merges server objects in file order, replacing arrays and scalar values. */ +const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => { + if (!isObject(previous) || !isObject(next)) return next; + return Object.fromEntries( + [...new Set([...Object.keys(previous), ...Object.keys(next)])].map( + (key) => [ + key, + Object.hasOwn(next, key) + ? mergeConfigurationValue(previous[key], next[key]) + : previous[key], + ], + ), + ); +}; +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Project a client's effective user registration without modifying its source files. */ +export const effectiveClientConfiguration = ( + documents: readonly ClientConfigurationDocument[], +): ClientConfigurationDocument | undefined => { + const last = documents.at(-1); + if (last === undefined) return undefined; + if (documents.length === 1) return last; + const servers: Record = {}; + for (const document of documents) { + const names = new Set([ + ...Object.keys(document.legacyServers), + ...Object.keys(document.servers), + ]); + for (const name of names) + servers[name] = mergeConfigurationValue( + servers[name], + effectiveClientServer(document, name), + ); + } + return { ...last, servers, legacyServers: {} }; +}; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 73cab0a1c..1b1bb6d6e 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -1,10 +1,14 @@ +import { readClientPolicyBlock } from "./GeminiClientSettings.js"; +import { + effectiveClientConfiguration, + readClientConfigurationFiles, +} from "./ClientConfigurationFiles.js"; import { clientServerForcedEnabled, clientServerListedDisabled, effectiveClientServer, - parseClientConfiguration, } from "./ClientConfigurationDocument.js"; -import { access, readFile } from "node:fs/promises"; +import { access } from "node:fs/promises"; import { resolve } from "node:path"; import { z } from "zod"; @@ -16,6 +20,7 @@ import { npxRegistrationCommand, } from "./ClientRegistrationIdentity.js"; import { + clientEvidencePaths, manualRegistrationRemediation, supportedClients, } from "./SupportedClients.js"; @@ -90,11 +95,7 @@ export const readClientRegistrationStatuses = async ( }); continue; } - if ( - !(await exists(client.markerPath)) && - !(await exists(client.configPath)) - ) - continue; + if (!(await clientEvidencePresent(client))) continue; const manualRemediation = manualRegistrationRemediation(client.name); if (client.format === "unsupported") { if (manualRemediation !== undefined) @@ -108,8 +109,36 @@ export const readClientRegistrationStatuses = async ( continue; } try { - const content = await readFile(client.configPath, "utf8"); - const parsed = parseClientConfiguration(content, client.format); + const files = await readClientConfigurationFiles(client); + if (!files.ok) { + statuses.push({ + client: client.name, + config_path: client.configPath, + command: [], + state: "invalid", + remediation: files.error.detail, + }); + continue; + } + const parsed = effectiveClientConfiguration(files.value); + if (parsed === undefined) { + statuses.push( + unavailableStatus(client.name, client.configPath, "missing"), + ); + continue; + } + const policy = await readClientPolicyBlock(client, parsed); + if (!policy.ok) { + statuses.push({ + client: client.name, + config_path: client.configPath, + command: [], + state: "invalid", + remediation: policy.error.detail, + }); + continue; + } + const policyBlock = policy.value; const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey); if (raw === undefined) { statuses.push( @@ -134,9 +163,14 @@ export const readClientRegistrationStatuses = async ( options.platform ?? process.platform, clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey), ) && - !clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey) + !clientServerListedDisabled( + parsed, + PRODUCT_IDENTITY.mcpServerKey, + ) && + policyBlock === undefined ? "aligned" : "stale", + policyBlock, ), ); } catch (cause: unknown) { @@ -163,7 +197,7 @@ const registrationAligned = ( ): boolean => { const command = [registration.command, ...registration.args]; if ( - registration.disabled === true || + (client.format !== "grok" && registration.disabled === true) || (registration.enabled === false && !forcedEnabled) ) return false; @@ -256,10 +290,17 @@ const configuredStatus = ( configPath: string, command: RegistrationCommand, state: "aligned" | "stale", + policyRemediation?: string, ): ClientRegistrationStatus => state === "aligned" ? { client, config_path: configPath, command, state, remediation: null } - : { client, config_path: configPath, command, state, remediation }; + : { + client, + config_path: configPath, + command, + state, + remediation: policyRemediation ?? remediation, + }; const unavailableStatus = ( client: string, @@ -273,6 +314,12 @@ const unavailableStatus = ( remediation, }); +const clientEvidencePresent = async (client: SetupClient): Promise => { + for (const path of clientEvidencePaths(client)) + if (await exists(path)) return true; + return false; +}; + const exists = async (path: string | undefined): Promise => { if (path === undefined) return false; try { diff --git a/src/application/GeminiClientSettings.ts b/src/application/GeminiClientSettings.ts new file mode 100644 index 000000000..eecb33b53 --- /dev/null +++ b/src/application/GeminiClientSettings.ts @@ -0,0 +1,202 @@ +import { realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { stripComments } from "jsonc-parser"; +import { z } from "zod"; +import { err, ok, type Result } from "../domain/result.js"; +import { PRODUCT_IDENTITY } from "../identity.js"; +import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { + clientConfigurationValuesEqual, + clientRegistrationEntry, + geminiServerPolicyBlock, + parseGeminiMcpPolicy, + type ClientConfigurationDocument, +} from "./ClientConfigurationDocument.js"; +import { + readConfigurationFile, + readConfigurationText, + type ClientConfigurationFileError, +} from "./ClientConfigurationFiles.js"; +import type { SetupClient } from "./SupportedClients.js"; + +type SettingsSource = { path: string; document: ClientConfigurationDocument }; +const folderTrustSchema = z + .object({ + security: z + .object({ + folderTrust: z + .object({ enabled: z.boolean().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); +const trustRulesSchema = z.record( + z.string(), + z.enum(["TRUST_FOLDER", "TRUST_PARENT", "DO_NOT_TRUST"]), +); +const sourceFailure = ( + path: string, + cause: unknown, +): ClientConfigurationFileError => ({ + kind: "malformed", + path, + detail: `Gemini settings in ${path} are malformed: ${cause instanceof Error ? cause.message : String(cause)}`, +}); + +// Gemini resolves symlinks when possible and compares macOS/Windows paths without case. +const normalizedPath = async ( + path: string, + platform: NodeJS.Platform, +): Promise => { + const physical = await realpath(path).catch(() => path); + const absolute = resolve(physical).replaceAll("\\", "/"); + return platform === "win32" || platform === "darwin" + ? absolute.toLowerCase() + : absolute; +}; + +const workspaceTrusted = async ( + settings: NonNullable, + sources: readonly SettingsSource[], +): Promise> => { + let enabled = true; + for (const { path, document } of sources) { + const parsed = folderTrustSchema.safeParse(document.document); + if (!parsed.success) return err(sourceFailure(path, parsed.error)); + enabled = parsed.data.security?.folderTrust?.enabled ?? enabled; + } + if (settings.trustOverride !== undefined) return ok(settings.trustOverride); + if (!enabled) return ok(true); + const text = await readConfigurationText(settings.trustedFoldersPath); + if (!text.ok) return text; + if (text.value === undefined) return ok(false); + let rules: z.output; + try { + // Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels. + rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value))); + } catch (cause: unknown) { + return err(sourceFailure(settings.trustedFoldersPath, cause)); + } + const workspace = await normalizedPath( + settings.workspaceDirectory, + settings.platform, + ); + let longest = -1; + let trusted = false; + for (const [path, level] of Object.entries(rules)) { + const effective = await normalizedPath( + level === "TRUST_PARENT" ? dirname(path) : path, + settings.platform, + ); + if ( + (workspace === effective || + workspace.startsWith( + effective.endsWith("/") ? effective : `${effective}/`, + )) && + path.length > longest + ) { + longest = path.length; + trusted = level !== "DO_NOT_TRUST"; + } + } + return ok(trusted); +}; + +/** Check effective Gemini scopes while preserving their authority and source identity. */ +export const readClientPolicyBlock = async ( + client: SetupClient, + userDocument?: ClientConfigurationDocument, + desiredRegistration?: unknown, +): Promise> => { + if (client.name !== "gemini_cli" || client.geminiSettings === undefined) + return ok(undefined); + const settings = client.geminiSettings; + const sources: SettingsSource[] = []; + const higher: SettingsSource[] = []; + for (const path of [ + settings.defaultsPath, + client.configPath, + settings.systemPath, + ]) { + const parsed = + path === client.configPath && userDocument !== undefined + ? ok(userDocument) + : await readConfigurationFile(path, "json"); + if (!parsed.ok) return parsed; + if (parsed.value !== undefined) + sources.push({ path, document: parsed.value }); + } + const workspace = await normalizedPath( + settings.workspaceDirectory, + settings.platform, + ); + const home = await normalizedPath( + dirname(dirname(client.configPath)), + settings.platform, + ); + if (workspace !== home) { + const path = resolve( + settings.workspaceDirectory, + ".gemini", + "settings.json", + ); + const document = await readConfigurationFile(path, "json"); + if (!document.ok) return document; + if (document.value !== undefined) { + const trusted = await workspaceTrusted(settings, sources); + if (!trusted.ok) return trusted; + if (trusted.value) { + const source = { path, document: document.value }; + const systemIndex = sources.findIndex( + (item) => item.path === settings.systemPath, + ); + sources.splice( + systemIndex < 0 ? sources.length : systemIndex, + 0, + source, + ); + higher.push(source); + } + } + } + const system = sources.find(({ path }) => path === settings.systemPath); + if (system !== undefined) higher.push(system); + const policies: ReturnType[] = []; + for (const { path, document } of sources) { + try { + policies.push(parseGeminiMcpPolicy(document)); + } catch (cause: unknown) { + return err(sourceFailure(path, cause)); + } + } + const policy = geminiServerPolicyBlock( + policies, + PRODUCT_IDENTITY.mcpServerKey, + ); + if (policy !== undefined) + return ok( + `${policy} Settings sources: ${sources.map(({ path }) => path).join(", ")}.`, + ); + // mcpServers uses shallow merge: the entire same-name definition is replaced. + const override = higher.findLast(({ document }) => + Object.hasOwn(document.servers, PRODUCT_IDENTITY.mcpServerKey), + ); + const desired = + desiredRegistration ?? + userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ?? + clientRegistrationEntry("json", npxRegistrationCommand(), {}); + if ( + override !== undefined && + !clientConfigurationValuesEqual( + override.document.servers[PRODUCT_IDENTITY.mcpServerKey], + desired, + ) + ) + return ok( + `Gemini mcpServers.${PRODUCT_IDENTITY.mcpServerKey} in ${override.path} overrides the user registration. Review that settings file before rerunning setup; REA does not modify system or workspace settings.`, + ); + return ok(undefined); +}; diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index eb5c01571..5cbf95206 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -1,3 +1,5 @@ +import { readClientPolicyBlock } from "./GeminiClientSettings.js"; +import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js"; import { clientRegistrationEntry, clientConfigurationValuesEqual, @@ -46,6 +48,15 @@ const configureClientDocument = async ( command: readonly string[], format: NonNullable, ): Promise => { + const files = await readClientConfigurationFiles(client); + if (!files.ok) return { status: "failed", reason: "readback" }; + const policy = await readClientPolicyBlock( + client, + undefined, + clientConfigurationDesired(client, environment, command, client.format), + ); + if (!policy.ok || policy.value !== undefined) + return { status: "failed", reason: "readback" }; const transactionPath = await resolveClientConfigTransactionPath( client.configPath, ); @@ -63,6 +74,13 @@ const configureClientDocument = async ( original ?? (format === "toml" || format === "grok" ? "" : "{}"), format, ); + const policy = await readClientPolicyBlock( + client, + parsed, + clientConfigurationDesired(client, environment, command, parsed.dialect), + ); + if (!policy.ok || policy.value !== undefined) + return { status: "failed", reason: "readback" }; } catch (cause: unknown) { // Malformed existing configuration fails the readback gate. void cause; @@ -158,9 +176,12 @@ export const clientConfigurationAligned = async ( ): Promise => { if (client.configPathError !== undefined) return false; try { + const files = await readClientConfigurationFiles(client); + if (!files.ok) return false; const original = await readFile(client.configPath, "utf8"); const parsed = parseClientConfiguration(original, client.format); - return registrationCurrent( + const policy = await readClientPolicyBlock( + client, parsed, clientConfigurationDesired( client, @@ -169,6 +190,19 @@ export const clientConfigurationAligned = async ( parsed.dialect, ), ); + return ( + policy.ok && + policy.value === undefined && + registrationCurrent( + parsed, + clientConfigurationDesired( + client, + providerEnvironment, + command, + parsed.dialect, + ), + ) + ); } catch (cause: unknown) { // Unreadable configuration is treated as not aligned so setup repairs it. void cause; @@ -185,6 +219,22 @@ export const inspectClientConfiguration = async ( if (client.configPathError !== undefined) return { status: "invalid", remediation: client.configPathError }; if (client.format === "unsupported") return { status: "already_current" }; + const files = await readClientConfigurationFiles(client); + if (!files.ok) return { status: "invalid", remediation: files.error.detail }; + const policy = await readClientPolicyBlock( + client, + undefined, + clientConfigurationDesired( + client, + providerEnvironment, + command, + client.format, + ), + ); + if (!policy.ok) + return { status: "invalid", remediation: policy.error.detail }; + if (policy.value !== undefined) + return { status: "invalid", remediation: policy.value }; const transactionPath = await resolveClientConfigTransactionPath( client.configPath, ); @@ -207,6 +257,20 @@ export const inspectClientConfiguration = async ( } try { const parsed = parseClientConfiguration(original, client.format); + const policy = await readClientPolicyBlock( + client, + parsed, + clientConfigurationDesired( + client, + providerEnvironment, + command, + parsed.dialect, + ), + ); + if (!policy.ok) + return { status: "invalid", remediation: policy.error.detail }; + if (policy.value !== undefined) + return { status: "invalid", remediation: policy.value }; const desired = clientConfigurationDesired( client, providerEnvironment, diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 87bf63aa0..ed789fec0 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -14,7 +14,11 @@ import { systemLinuxHopperInstallHost, } from "./LinuxHopper.js"; import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js"; -import { supportedClients, type SetupClient } from "./SupportedClients.js"; +import { + clientEvidencePaths, + supportedClients, + type SetupClient, +} from "./SupportedClients.js"; import { skillDestinations, canonicalSkillNeedsInstall, @@ -168,17 +172,19 @@ export const detectClients = async ( const detected: SetupClient[] = []; for (const candidate of supportedClients(home, platform, environment)) { if (candidate.configPathError !== undefined) continue; - const [hasConfig, hasMarker] = await Promise.all([ - exists(candidate.configPath), - candidate.markerPath === undefined ? false : exists(candidate.markerPath), - ]); - if (hasConfig || hasMarker) detected.push(candidate); + if (await clientEvidencePresent(candidate)) detected.push(candidate); } return detected; }; const major = (version: string): number => Number.parseInt(version.split(".")[0] ?? "0", 10); +const clientEvidencePresent = async (client: SetupClient): Promise => { + for (const path of clientEvidencePaths(client)) + if (await exists(path)) return true; + return false; +}; + const exists = async (path: string): Promise => { try { await access(path); diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 90cd52d0f..dfeee34b7 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -1,4 +1,4 @@ -import { isAbsolute, join, resolve, win32 } from "node:path"; +import { dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { lstatSync } from "node:fs"; @@ -7,6 +7,17 @@ export interface SetupClient { readonly name: string; readonly displayName?: string; readonly configPath: string; + /** User configuration files in increasing precedence, including the write target. */ + readonly configPaths?: readonly string[]; + /** Read-only Gemini scopes and trust inputs; never setup write targets. */ + readonly geminiSettings?: { + readonly systemPath: string; + readonly defaultsPath: string; + readonly workspaceDirectory: string; + readonly trustedFoldersPath: string; + readonly trustOverride?: boolean; + readonly platform: NodeJS.Platform; + }; /** Unresolved path evidence only; consumers must not perform I/O on it. */ readonly configPathError?: string; readonly markerPath?: string; @@ -39,6 +50,10 @@ interface ClientDefinition { readonly markerPath: ClientPath; readonly format: NonNullable; readonly skillPath?: ClientPath; + readonly configPaths?: (context: ClientPathContext) => readonly string[]; + readonly geminiSettings?: ( + context: ClientPathContext, + ) => NonNullable; } const vscodeUserDirectory = ({ @@ -76,7 +91,7 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"); const codexDirectory = ({ home, env }: ClientPathContext): string => - env.CODEX_HOME ?? join(home, ".codex"); + env.CODEX_HOME || join(home, ".codex"); /** Match Qwen Code's home override, including tilde and cwd-relative paths. */ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { @@ -98,7 +113,7 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { }; const grokDirectory = ({ home, env }: ClientPathContext): string => - env.GROK_HOME ?? join(home, ".grok"); + env.GROK_HOME || join(home, ".grok"); /** * Hermes Agent home. `HERMES_HOME` wins; otherwise the platform default mirrors @@ -188,17 +203,14 @@ const ompProfile = ({ env }: ClientPathContext): string | undefined => { /** * The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP * joins PI_CONFIG_DIR to the home directory, and a named profile ignores - * PI_CODING_AGENT_DIR. OMP resolves a relative agent directory against each - * process's working directory, so only an absolute override names one file. + * PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory. */ const ompAgentDirectory = (context: ClientPathContext): string => { const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp"); const profile = ompProfile(context); if (profile !== undefined) return join(root, "profiles", profile, "agent"); const override = context.env.PI_CODING_AGENT_DIR; - return override !== undefined && override !== "" && isAbsolute(override) - ? override - : join(root, "agent"); + return override ? resolve(override) : join(root, "agent"); }; /** @@ -242,21 +254,66 @@ const copilotDirectory = ({ home, env }: ClientPathContext): string => const commandCodeDirectory = ({ home }: ClientPathContext): string => join(home, ".commandcode"); -const opencodeDirectory = ({ home, env }: ClientPathContext): string => - join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode"); +/** + * OpenCode appends `OPENCODE_CONFIG_DIR` only when the value is non-empty. + * An empty variable is the same as unset and leaves the XDG directory in place. + */ +const openCodeOverrideDirectory = ( + env: ClientPathContext["env"], +): string | undefined => { + const directory = env.OPENCODE_CONFIG_DIR; + return directory === undefined || directory === "" ? undefined : directory; +}; + +const opencodeDirectory = (context: ClientPathContext): string => + openCodeOverrideDirectory(context.env) ?? + join( + context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"), + "opencode", + ); + +/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */ +const openCodeConfigurationPaths = ( + context: ClientPathContext, +): readonly string[] => { + const directory = join( + context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"), + "opencode", + ); + const overrideDirectory = openCodeOverrideDirectory(context.env); + const paths = [ + join(directory, "config.json"), + join(directory, "opencode.json"), + join(directory, "opencode.jsonc"), + ...(context.env.OPENCODE_CONFIG ? [context.env.OPENCODE_CONFIG] : []), + ...(overrideDirectory !== undefined + ? [ + join(overrideDirectory, "opencode.json"), + join(overrideDirectory, "opencode.jsonc"), + ] + : []), + ]; + const resolved = paths.map((path) => resolve(path)); + return resolved.filter((path, index) => resolved.lastIndexOf(path) === index); +}; const existingOpenCodeConfigPath = (context: ClientPathContext): string => { - if (context.env.OPENCODE_CONFIG !== undefined) - return context.env.OPENCODE_CONFIG; - const directory = opencodeDirectory(context); - const candidates = [ - "opencode.json", - "opencode.jsonc", - ".opencode.json", - ".opencode.jsonc", - ].map((filename) => join(directory, filename)); + // The selected directory is loaded after an explicit file by OpenCode V1. + const overrideDirectory = openCodeOverrideDirectory(context.env); + const candidates = + overrideDirectory !== undefined + ? [ + join(overrideDirectory, "opencode.json"), + join(overrideDirectory, "opencode.jsonc"), + ].map((path) => resolve(path)) + : context.env.OPENCODE_CONFIG + ? [resolve(context.env.OPENCODE_CONFIG)] + : [ + join(opencodeDirectory(context), "opencode.json"), + join(opencodeDirectory(context), "opencode.jsonc"), + ].map((path) => resolve(path)); return ( - candidates.find((path) => { + [...candidates].reverse().find((path) => { try { lstatSync(path); return true; @@ -268,15 +325,52 @@ const existingOpenCodeConfigPath = (context: ClientPathContext): string => { ); } }) ?? - candidates[0] ?? - join(directory, "opencode.json") + resolve( + overrideDirectory !== undefined + ? join(overrideDirectory, "opencode.json") + : context.env.OPENCODE_CONFIG || + join(opencodeDirectory(context), "opencode.json"), + ) ); }; +const geminiSettings = ({ + home, + platform, + env, +}: ClientPathContext): NonNullable => { + const system = + env.GEMINI_CLI_SYSTEM_SETTINGS_PATH || + (platform === "darwin" + ? "/Library/Application Support/GeminiCli/settings.json" + : platform === "win32" + ? "C:\\ProgramData\\gemini-cli\\settings.json" + : "/etc/gemini-cli/settings.json"); + const trustOverride = + env.GEMINI_RESTRICTED_MODE === "true" || + env.GEMINI_CLI_TRUST_WORKSPACE === "false" + ? false + : env.GEMINI_CLI_TRUST_WORKSPACE === "true" + ? true + : undefined; + return { + systemPath: system, + defaultsPath: + env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH || + join(dirname(system), "system-defaults.json"), + workspaceDirectory: process.cwd(), + trustedFoldersPath: + env.GEMINI_CLI_TRUSTED_FOLDERS_PATH || + join(env.GEMINI_CLI_HOME || home, ".gemini", "trustedFolders.json"), + ...(trustOverride === undefined ? {} : { trustOverride }), + platform, + }; +}; + const devinDirectory = ({ home, platform, env }: ClientPathContext): string => platform === "win32" ? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin") - : join(home, ".config", "devin"); + : join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin"); /** Stable product metadata used to derive setup discovery and documentation. */ export const SUPPORTED_CLIENT_DEFINITIONS = [ @@ -316,8 +410,13 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ { name: "gemini_cli", displayName: "Gemini CLI", - configPath: [".gemini", "settings.json"], - markerPath: [".gemini"], + geminiSettings, + configPath: ({ home, env }: ClientPathContext) => + join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"), + markerPath: ({ home, env }: ClientPathContext) => + join(env.GEMINI_CLI_HOME || home, ".gemini"), + skillPath: ({ home, env }: ClientPathContext) => + join(env.GEMINI_CLI_HOME || home, ".agents", "skills"), format: "json", }, { @@ -339,12 +438,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ name: "opencode", displayName: "OpenCode", configPath: existingOpenCodeConfigPath, + configPaths: openCodeConfigurationPaths, markerPath: opencodeDirectory, format: "opencode", }, { name: "antigravity", displayName: "Antigravity", + skillPath: [".gemini", "config", "skills"], configPath: [".gemini", "config", "mcp_config.json"], markerPath: [".gemini", "config"], format: "json", @@ -427,6 +528,23 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }, ] as const satisfies readonly ClientDefinition[]; +/** + * Paths whose presence means doctor and setup should inspect the client. + * Layered sources count: OpenCode still loads its XDG files when a custom + * directory override does not exist. + */ +export const clientEvidencePaths = ( + client: Pick, +): readonly string[] => [ + ...new Set( + [ + client.configPath, + ...(client.configPaths ?? []), + client.markerPath, + ].filter((path): path is string => path !== undefined && path.length > 0), + ), +]; + const resolvePath = (path: ClientPath, context: ClientPathContext): string => typeof path === "function" ? path(context) : join(context.home, ...path); @@ -437,12 +555,24 @@ export const supportedClients = ( env: ClientPathContext["env"] = process.env, ): readonly SetupClient[] => { const context = { home, platform, env }; - return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => { + const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS; + return definitions.map((definition) => { try { + const configPath = resolvePath(definition.configPath, context); return { name: definition.name, displayName: definition.displayName, - configPath: resolvePath(definition.configPath, context), + configPath, + ...(definition.geminiSettings === undefined + ? {} + : { geminiSettings: definition.geminiSettings(context) }), + ...(definition.configPaths === undefined + ? {} + : { + configPaths: [ + ...new Set([...definition.configPaths(context), configPath]), + ], + }), markerPath: resolvePath(definition.markerPath, context), format: definition.format, }; diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index c30accb38..bc8b180dc 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -124,11 +124,12 @@ export const runUninstall = async ( status: "retained", detail: "Hopper is not owned by REA uninstall.", }); + const reported = collapseUninstallItems(items); return { - status: items.some(({ status }) => status === "failed") + status: reported.some(({ status }) => status === "failed") ? "failed" : "complete", - items, + items: reported, }; }; @@ -171,7 +172,16 @@ export const systemUninstallHost = ( selectedHome ?? homeDirectoryFromEnvironment(environment, platform); return { clients: () => - Promise.resolve(supportedClients(home, platform, environment)), + Promise.resolve( + supportedClients(home, platform, environment).flatMap((client) => + client.configPaths === undefined + ? [client] + : client.configPaths.map((configPath) => ({ + ...client, + configPath, + })), + ), + ), inspectClient: async (client) => { const read = await readClientConfiguration(client, fileSystem); return read.kind === "item" && read.item.status === "failed" @@ -452,6 +462,68 @@ const item = ( status: UninstallItem["status"], detail: string, ): UninstallItem => ({ name, status, detail }); + +/** + * Layered clients are removed file by file, but callers look up one status by + * client name. A missing earlier file must not hide a later removal. + */ +const collapseUninstallItems = ( + items: readonly UninstallItem[], +): UninstallItem[] => { + const collapsed: UninstallItem[] = []; + for (const next of items) { + const index = collapsed.findIndex(({ name }) => name === next.name); + const current = collapsed[index]; + if (current === undefined) { + collapsed.push(next); + continue; + } + collapsed[index] = mergeUninstallItems(current, next); + } + return collapsed; +}; + +const mergeUninstallItems = ( + current: UninstallItem, + next: UninstallItem, +): UninstallItem => { + const status = preferredUninstallStatus(current.status, next.status); + const details = [current, next] + .filter( + (entry) => + entry.status === status || + (status === "removed" && entry.status === "retained"), + ) + .map(({ detail }) => detail); + return item(current.name, status, [...new Set(details)].join(" ")); +}; + +const preferredUninstallStatus = ( + current: UninstallItem["status"], + next: UninstallItem["status"], +): UninstallItem["status"] => + uninstallStatusPriority(next) > uninstallStatusPriority(current) + ? next + : current; + +const uninstallStatusPriority = (status: UninstallItem["status"]): number => { + switch (status) { + case "failed": + return 3; + case "removed": + return 2; + case "retained": + return 1; + case "skipped": + return 0; + default: { + const exhaustive: never = status; + throw new TypeError( + `Unhandled uninstall item status: ${String(exhaustive)}`, + ); + } + } +}; const registrationSchema = z .object({ command: z.string(), args: z.array(z.string()) }) .passthrough(); diff --git a/tests/acceptance/setup/piClientSetup.test.ts b/tests/acceptance/setup/piClientSetup.test.ts index 6c586999a..bfb1d1b0a 100644 --- a/tests/acceptance/setup/piClientSetup.test.ts +++ b/tests/acceptance/setup/piClientSetup.test.ts @@ -98,7 +98,11 @@ cliTest( expect(doctor.exitCode).toBe(0); expect(doctor.json).toMatchObject({ healthy: true, - identity: { registrations: [{ client: "pi", state: "aligned" }] }, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "pi", state: "aligned" }), + ]), + }, }); const repeat = await run(["setup", "--client", "pi", "--yes", "--json"]); @@ -159,7 +163,11 @@ cliTest( expect(doctor.exitCode).toBe(0); expect(doctor.json).toMatchObject({ healthy: true, - identity: { registrations: [{ client: "pi", state: "aligned" }] }, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "pi", state: "aligned" }), + ]), + }, }); await run(["uninstall", "--json"]); expect( @@ -215,7 +223,9 @@ cliTest( expect(doctorMalformed.json).toMatchObject({ healthy: false, identity: { - registrations: [{ client: "pi", state: "invalid" }], + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "pi", state: "invalid" }), + ]), }, }); const removed = await runMalformed(["uninstall", "--json"]); diff --git a/tests/boundary/cli/clientProfiles.test.ts b/tests/boundary/cli/clientProfiles.test.ts new file mode 100644 index 000000000..996958986 --- /dev/null +++ b/tests/boundary/cli/clientProfiles.test.ts @@ -0,0 +1,786 @@ +import { access, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect } from "vitest"; +import { parse } from "jsonc-parser"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const cases = [ + { + client: "gemini_cli", + env: { GEMINI_CLI_HOME: "profile" }, + config: "profile/.gemini/settings.json", + skill: "profile/.agents/skills", + }, + { + client: "opencode", + env: { OPENCODE_CONFIG_DIR: "profile" }, + config: "profile/opencode.json", + skill: ".agents/skills", + }, + { + client: "opencode", + env: { OPENCODE_CONFIG_DIR: "" }, + config: ".config/opencode/opencode.json", + skill: ".agents/skills", + }, + { + client: "codex", + env: { CODEX_HOME: "" }, + config: ".codex/config.toml", + skill: ".agents/skills", + }, + { + client: "grok_build", + env: { GROK_HOME: "" }, + config: ".grok/config.toml", + skill: ".agents/skills", + }, + { + client: "omp", + env: { PI_CODING_AGENT_DIR: "profile" }, + config: "profile/mcp.json", + skill: ".agents/skills", + }, + { + client: "devin", + env: { XDG_CONFIG_HOME: "profile" }, + config: "profile/devin/mcp_config.json", + skill: ".agents/skills", + }, + { + client: "antigravity", + env: {}, + config: ".gemini/config/mcp_config.json", + skill: ".gemini/config/skills", + }, +]; +for (const testCase of cases.filter( + ({ client }) => process.platform !== "win32" || client !== "devin", +)) { + cliTest( + `${testCase.client} lifecycle follows the selected roots (${testCase.config})`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-client-profile-"); + const environment: NodeJS.ProcessEnv = { HOME: home, USERPROFILE: home }; + for (const [key, value] of Object.entries(testCase.env)) + environment[key] = value === "profile" ? join(home, value) : value; + // OMP's relative override is relative to the caller, not silently ignored. + if (testCase.client === "omp") + environment.PI_CODING_AGENT_DIR = "profile"; + const config = join(home, testCase.config); + const skillDirectory = join( + home, + testCase.skill, + "reverse-engineer-anything", + ); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment, + timeoutMs: 20_000, + }); + const planned = await run([ + "setup", + "--client", + testCase.client, + "--dry-run", + "--json", + ]); + expect(planned.exitCode).toBe(0); + expect(planned.json).toMatchObject({ + status: "planned", + plannedActions: expect.arrayContaining([ + expect.objectContaining({ target: config }), + expect.objectContaining({ target: skillDirectory }), + ]), + }); + await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" }); + const setup = await run([ + "setup", + "--client", + testCase.client, + "--yes", + "--json", + ]); + expect(setup.exitCode).toBe(0); + expect(setup.json).toMatchObject({ status: "ready" }); + await expect(access(config)).resolves.toBeUndefined(); + expect( + await readFile(join(skillDirectory, "SKILL.md"), "utf8"), + ).toContain("reverse-engineer-anything"); + await expect(access(join(home, "config.toml"))).rejects.toMatchObject({ + code: "ENOENT", + }); + const doctor = await run([ + "doctor", + "--client", + testCase.client, + "--skill", + "--json", + ]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { skill: { state: "aligned" } }, + }); + if (testCase.client === "grok_build") { + const original = await readFile(config, "utf8"); + await writeFile( + config, + original.replace( + "[mcp_servers.rea]", + "[mcp_servers.rea]\ndisabled = true", + ), + ); + expect( + (await run(["doctor", "--client", "grok_build", "--json"])).exitCode, + ).toBe(0); + await writeFile(config, original); + } + const repeat = await run([ + "setup", + "--client", + testCase.client, + "--yes", + "--json", + ]); + expect(repeat.json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode).toBe(0); + expect(uninstall.json).toMatchObject({ status: "complete" }); + await expect( + access(join(skillDirectory, "SKILL.md")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); +} + +for (const policy of [{ excluded: ["rea"] }, { allowed: ["other"] }]) { + cliTest( + `Gemini refuses a registration blocked by ${Object.keys(policy)[0]}`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-policy-"); + const config = join(home, ".gemini/settings.json"); + const original = JSON.stringify({ + mcp: policy, + mcpServers: { + rea: { + command: "npx", + args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + }, + }, + }); + await mkdir(dirname(config), { recursive: true }); + await writeFile(config, original); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { HOME: home, USERPROFILE: home }, + timeoutMs: 20_000, + }); + const setup = await run([ + "setup", + "--client", + "gemini_cli", + "--yes", + "--json", + ]); + expect(setup.exitCode).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("mcp." + Object.keys(policy)[0]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(access(config + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + const doctor = await run(["doctor", "--client", "gemini_cli", "--json"]); + expect(doctor.exitCode).toBe(1); + expect(doctor.json).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "stale", + remediation: expect.stringContaining("mcp."), + }), + ]), + }, + }); + }, + ); +} + +cliTest( + "OpenCode diagnoses merged JSON/JSONC and removes owned entries from both files", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-layers-"); + const directory = join(home, ".config/opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "opencode.json"); + const higher = join(directory, "opencode.jsonc"); + const originalLower = JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ], + enabled: true, + }, + other: { type: "local", command: ["other"] }, + }, + }); + const originalHigher = + '// keep this comment\n{"mcp":{"rea":{"enabled":false}},"theme":"system"}\n'; + await writeFile(lower, originalLower); + await writeFile(higher, originalHigher); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { HOME: home, USERPROFILE: home }, + timeoutMs: 20_000, + }); + const before = await run(["doctor", "--client", "opencode", "--json"]); + expect(before.exitCode).toBe(1); + expect(before.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "opencode", state: "stale" }), + ]), + }, + }); + const setup = await run([ + "setup", + "--client", + "opencode", + "--yes", + "--json", + ]); + expect(setup.exitCode).toBe(0); + expect(setup.json).toMatchObject({ status: "ready" }); + expect(await readFile(lower, "utf8")).toBe(originalLower); + expect(await readFile(higher + ".rea.backup", "utf8")).toBe(originalHigher); + expect(await readFile(higher, "utf8")).toContain("// keep this comment"); + expect( + (await run(["doctor", "--client", "opencode", "--json"])).exitCode, + ).toBe(0); + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode).toBe(0); + const report = uninstall.json as { + items?: readonly { name: string; status: string }[]; + }; + const opencodeItems = report.items?.filter( + ({ name }) => name === "opencode", + ); + expect(opencodeItems).toEqual([ + expect.objectContaining({ status: "removed" }), + ]); + expect(parse(await readFile(lower, "utf8"))).toEqual({ + mcp: { other: { type: "local", command: ["other"] } }, + }); + expect(parse(await readFile(higher, "utf8"))).toEqual({ + mcp: {}, + theme: "system", + }); + }, +); + +cliTest( + "malformed lower-priority OpenCode config blocks setup before any writes", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-invalid-layer-"); + const directory = join(home, ".config/opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "opencode.json"); + const higher = join(directory, "opencode.jsonc"); + await writeFile(lower, "malformed"); + await writeFile(higher, "{}"); + const result = await cli.run({ + arguments: ["setup", "--client", "opencode", "--yes", "--json"], + cwd: home, + environment: { HOME: home, USERPROFILE: home }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + }); + expect(await readFile(lower, "utf8")).toBe("malformed"); + expect(await readFile(higher, "utf8")).toBe("{}"); + await expect(access(higher + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini system policy blocks a new registration without modifying managed files", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-managed-"); + const policy = join(home, "managed.json"); + const original = JSON.stringify({ + mcp: { excluded: ["rea"] }, + unrelated: true, + }); + await writeFile(policy, original); + const environment = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: policy, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + }; + const setup = await cli.run({ + arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"], + cwd: home, + environment, + }); + expect(setup.exitCode).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("mcp.excluded"), + }); + expect(await readFile(policy, "utf8")).toBe(original); + await expect( + access(join(home, ".gemini/settings.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(policy + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini intersects system/default/user allowlists and accepts an empty effective allowlist", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-allowlists-"); + const system = join(home, "system.json"); + const defaults = join(home, "defaults.json"); + const user = join(home, ".gemini/settings.json"); + await mkdir(dirname(user), { recursive: true }); + await writeFile( + system, + JSON.stringify({ mcp: { allowed: ["rea", "other"] } }), + ); + await writeFile(defaults, JSON.stringify({ mcp: { allowed: ["other"] } })); + await writeFile( + user, + JSON.stringify({ mcp: { allowed: ["rea", "other"] } }), + ); + const environment = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults, + }; + const run = () => + cli.run({ + arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"], + cwd: home, + environment, + }); + const blocked = await run(); + expect(blocked.exitCode).toBe(1); + expect(blocked.json).toMatchObject({ + status: "needs_human", + appliedActions: [], + }); + await writeFile(defaults, JSON.stringify({ mcp: { allowed: ["rea"] } })); + const allowed = await run(); + expect(allowed.exitCode).toBe(0); + expect(allowed.json).toMatchObject({ status: "ready" }); + expect(JSON.parse(await readFile(defaults, "utf8"))).toEqual({ + mcp: { allowed: ["rea"] }, + }); + // The consumer treats an empty consolidated allowlist as unrestricted. + await writeFile(defaults, JSON.stringify({ mcp: { allowed: [] } })); + const empty = await run(); + expect(empty.exitCode).toBe(0); + expect(empty.json).toMatchObject({ status: "ready", appliedActions: [] }); + }, +); + +cliTest( + "OpenCode doctor sees a global registration when the custom directory is absent", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-absent-custom-"); + const global = join(home, ".config/opencode/opencode.json"); + const custom = join(home, "missing-custom"); + await mkdir(dirname(global), { recursive: true }); + await writeFile( + global, + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ], + enabled: true, + }, + }, + }), + ); + const run = (args: readonly string[]) => + cli.run({ + arguments: args, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + OPENCODE_CONFIG_DIR: custom, + }, + }); + const doctor = await run(["doctor", "--client", "opencode", "--json"]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "opencode", state: "aligned" }), + ]), + }, + }); + const planned = await run([ + "setup", + "--client", + "opencode", + "--dry-run", + "--json", + ]); + expect(planned.json).toMatchObject({ + clientStates: expect.arrayContaining([ + expect.objectContaining({ + client: expect.objectContaining({ name: "opencode" }), + detected: true, + }), + ]), + }); + await expect(access(custom)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +cliTest( + "OpenCode retains the XDG global layer when a custom directory is selected", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-global-custom-"); + const global = join(home, ".config/opencode/opencode.json"); + const custom = join(home, "custom/opencode.jsonc"); + await mkdir(dirname(global), { recursive: true }); + await mkdir(dirname(custom), { recursive: true }); + const owned = { + type: "local", + command: [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ], + enabled: true, + }; + const original = JSON.stringify({ + mcp: { rea: owned, other: { type: "local", command: ["other"] } }, + }); + await writeFile(global, original); + await writeFile(custom, "{}"); + const run = (args: readonly string[]) => + cli.run({ + arguments: args, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + OPENCODE_CONFIG_DIR: dirname(custom), + }, + }); + const doctor = await run(["doctor", "--client", "opencode", "--json"]); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "opencode", state: "aligned" }), + ]), + }, + }); + await writeFile(global, "malformed"); + expect( + (await run(["setup", "--client", "opencode", "--yes", "--json"])).json, + ).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(global), + }); + expect(await readFile(custom, "utf8")).toBe("{}"); + await writeFile(global, original); + expect( + (await run(["setup", "--client", "opencode", "--yes", "--json"])) + .exitCode, + ).toBe(0); + expect((await run(["uninstall", "--json"])).exitCode).toBe(0); + expect(parse(await readFile(global, "utf8"))).toEqual({ + mcp: { other: { type: "local", command: ["other"] } }, + }); + expect(parse(await readFile(custom, "utf8"))).toEqual({ mcp: {} }); + }, +); + +cliTest( + "Gemini blocks a conflicting system definition and accepts an identical managed entry", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-managed-server-"); + const user = join(home, ".gemini/settings.json"); + const system = join(home, "system.json"); + const defaults = join(home, "defaults.json"); + const environment = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults, + }; + const run = (args: readonly string[]) => + cli.run({ arguments: args, cwd: home, environment }); + const command = ["setup", "--client", "gemini_cli", "--yes", "--json"]; + // A user registration overrides a default definition, including its entire env object. + await writeFile( + defaults, + JSON.stringify({ + mcpServers: { + rea: { command: "managed-default", env: { EXTRA: "default" } }, + }, + }), + ); + expect((await run(command)).exitCode).toBe(0); + const original = await readFile(user, "utf8"); + const managed = JSON.stringify({ + mcpServers: { rea: { command: "managed-override", args: [] } }, + unrelated: true, + }); + await writeFile(system, managed); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(system), + }); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).json, + ).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "stale", + remediation: expect.stringContaining("mcpServers.rea"), + }), + ]), + }, + }); + expect(await readFile(user, "utf8")).toBe(original); + expect(await readFile(system, "utf8")).toBe(managed); + const aligned = JSON.stringify({ + mcpServers: JSON.parse(original).mcpServers, + }); + await writeFile(system, aligned); + expect((await run(command)).json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).exitCode, + ).toBe(0); + expect(await readFile(system, "utf8")).toBe(aligned); + await expect(access(system + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini workspace policy follows persisted trust and explicit trust overrides", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-workspace-policy-"); + const project = join(home, "project"); + const workspace = join(project, ".gemini/settings.json"); + const trust = join(home, "trust.json"); + await mkdir(dirname(workspace), { recursive: true }); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"), + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + GEMINI_CLI_TRUSTED_FOLDERS_PATH: trust, + }; + const run = (args: readonly string[]) => + cli.run({ arguments: args, cwd: project, environment }); + const command = ["setup", "--client", "gemini_cli", "--yes", "--json"]; + const original = JSON.stringify({ + mcp: { excluded: ["rea"] }, + unrelated: true, + }); + await writeFile(workspace, original); + await writeFile( + trust, + JSON.stringify({ [home]: "TRUST_FOLDER", [project]: "DO_NOT_TRUST" }), + ); + expect((await run(command)).exitCode).toBe(0); + await writeFile( + trust, + JSON.stringify({ [join(project, "child")]: "TRUST_PARENT" }), + ); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + appliedActions: [], + remediation: expect.stringContaining(workspace), + }); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).json, + ).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "stale", + remediation: expect.stringContaining("mcp.excluded"), + }), + ]), + }, + }); + environment.GEMINI_CLI_TRUST_WORKSPACE = "false"; + expect((await run(command)).json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + environment.GEMINI_CLI_TRUST_WORKSPACE = "true"; + await writeFile(workspace, JSON.stringify({ mcp: { allowed: ["other"] } })); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + remediation: expect.stringContaining("mcp.allowed"), + }); + // Restricted mode takes precedence over an explicit trust opt-in. + environment.GEMINI_RESTRICTED_MODE = "true"; + expect((await run(command)).json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + expect(JSON.parse(await readFile(workspace, "utf8"))).toEqual({ + mcp: { allowed: ["other"] }, + }); + await expect(access(workspace + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini workspace definitions use shallow precedence and remain read-only", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-workspace-server-"); + const project = join(home, "project"); + const workspace = join(project, ".gemini/settings.json"); + const system = join(home, "system.json"); + await mkdir(dirname(workspace), { recursive: true }); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + GEMINI_CLI_TRUST_WORKSPACE: "true", + }; + const run = (args: readonly string[]) => + cli.run({ arguments: args, cwd: project, environment }); + const command = ["setup", "--client", "gemini_cli", "--yes", "--json"]; + const original = JSON.stringify({ + mcpServers: { rea: { command: "project-server", args: [] } }, + }); + await writeFile(workspace, original); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(workspace), + }); + await expect( + access(join(home, ".gemini/settings.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + // Obtain this setup invocation's actual command while workspace settings are untrusted. + environment.GEMINI_CLI_TRUST_WORKSPACE = "false"; + expect((await run(command)).exitCode).toBe(0); + const user = JSON.parse( + await readFile(join(home, ".gemini/settings.json"), "utf8"), + ); + environment.GEMINI_CLI_TRUST_WORKSPACE = "true"; + // System's entire entry wins over the workspace entry. + await writeFile(system, JSON.stringify({ mcpServers: user.mcpServers })); + expect((await run(command)).exitCode).toBe(0); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).exitCode, + ).toBe(0); + expect(await readFile(workspace, "utf8")).toBe(original); + await expect(access(workspace + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini validates trust configuration and honors a disabled folder trust feature", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-trust-invalid-"); + const project = join(home, "project"); + const workspace = join(project, ".gemini/settings.json"); + const user = join(home, ".gemini/settings.json"); + const trust = join(home, ".gemini/trustedFolders.json"); + await mkdir(dirname(workspace), { recursive: true }); + await mkdir(dirname(user), { recursive: true }); + await writeFile(workspace, JSON.stringify({ mcp: { excluded: ["rea"] } })); + await writeFile(trust, JSON.stringify({ [project]: "INVALID" })); + const run = () => + cli.run({ + arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"], + cwd: project, + environment: { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"), + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + }, + }); + expect((await run()).json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(trust), + }); + await expect(access(user)).rejects.toMatchObject({ code: "ENOENT" }); + await rm(trust); + await writeFile( + user, + JSON.stringify({ security: { folderTrust: { enabled: false } } }), + ); + expect((await run()).json).toMatchObject({ + status: "needs_human", + remediation: expect.stringContaining("mcp.excluded"), + }); + }, +); diff --git a/tests/boundary/filesystem/ompClientConfiguration.test.ts b/tests/boundary/filesystem/ompClientConfiguration.test.ts index 0f5aeb2cc..7a961e08d 100644 --- a/tests/boundary/filesystem/ompClientConfiguration.test.ts +++ b/tests/boundary/filesystem/ompClientConfiguration.test.ts @@ -38,9 +38,9 @@ describe("OMP configuration paths", () => { expect(ompPath({ PI_CODING_AGENT_DIR: "/custom/agent" })?.configPath).toBe( "/custom/agent/mcp.json", ); - // A relative override names a different file in every working directory. + // Match OMP by resolving the override against the caller working directory. expect(ompPath({ PI_CODING_AGENT_DIR: "agent" })?.configPath).toBe( - "/home/a/.omp/agent/mcp.json", + resolve("agent", "mcp.json"), ); }); diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index bdc6d0102..91ccb92cf 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -17,11 +17,18 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "CODEX_HOME", "COPILOT_HOME", "GROK_HOME", + "GEMINI_CLI_HOME", + "GEMINI_CLI_TRUST_WORKSPACE", + "GEMINI_RESTRICTED_MODE", + "GEMINI_CLI_TRUSTED_FOLDERS_PATH", + "GEMINI_CLI_SYSTEM_SETTINGS_PATH", + "GEMINI_CLI_SYSTEM_DEFAULTS_PATH", "HERMES_DATA_DIR_SUFFIX", "HERMES_HOME", "LOCALAPPDATA", "OMP_PROFILE", "OPENCODE_CONFIG", + "OPENCODE_CONFIG_DIR", "PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "PI_PROFILE",