mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
fix(setup): honor active client configuration and skill roots (#1504)
* refactor(setup): resolve skill destinations per client * fix(setup): honor active client configuration and skill roots * fix(setup): validate effective client scopes and Gemini trust * fix(setup): treat empty OpenCode overrides as unset An empty OPENCODE_CONFIG_DIR now stays on the XDG directory, and doctor and setup inspect global OpenCode files when a custom directory is absent. Uninstall reports one status per client so a missing earlier layer does not hide a removed registration. --------- Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
+30
-7
@@ -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/<name>/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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof parseGeminiMcpPolicy>[],
|
||||
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<string[] | undefined>(
|
||||
(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;
|
||||
};
|
||||
|
||||
@@ -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<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||
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<ClientConfigurationDocument | undefined, ClientConfigurationFileError>
|
||||
> => {
|
||||
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<readonly ClientConfigurationDocument[], ClientConfigurationFileError>
|
||||
> => {
|
||||
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<string, unknown> =>
|
||||
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<string, unknown> = {};
|
||||
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: {} };
|
||||
};
|
||||
@@ -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<boolean> => {
|
||||
for (const path of clientEvidencePaths(client))
|
||||
if (await exists(path)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const exists = async (path: string | undefined): Promise<boolean> => {
|
||||
if (path === undefined) return false;
|
||||
try {
|
||||
|
||||
@@ -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<string> => {
|
||||
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<SetupClient["geminiSettings"]>,
|
||||
sources: readonly SettingsSource[],
|
||||
): Promise<Result<boolean, ClientConfigurationFileError>> => {
|
||||
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<typeof trustRulesSchema>;
|
||||
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<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||
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<typeof parseGeminiMcpPolicy>[] = [];
|
||||
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);
|
||||
};
|
||||
@@ -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<SetupClient["format"]>,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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<boolean> => {
|
||||
for (const path of clientEvidencePaths(client))
|
||||
if (await exists(path)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const exists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
|
||||
@@ -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<SetupClient["format"]>;
|
||||
readonly skillPath?: ClientPath;
|
||||
readonly configPaths?: (context: ClientPathContext) => readonly string[];
|
||||
readonly geminiSettings?: (
|
||||
context: ClientPathContext,
|
||||
) => NonNullable<SetupClient["geminiSettings"]>;
|
||||
}
|
||||
|
||||
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<SetupClient["geminiSettings"]> => {
|
||||
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<SetupClient, "configPath" | "configPaths" | "markerPath">,
|
||||
): 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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Vendored
+7
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user