mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 02:15:44 +00:00
* feat(setup): register DeepSeek Harness through setup, doctor and uninstall DeepSeek Harness registers MCP clients through a Cordis YAML patch sequence in the home patch rather than a server table, so setup could not configure it and users had to append the documented entry by hand. Add a dsh client: the home patch $DSH_HOME/cordis.patch.yml (~/.dsh/cordis.patch.yml when unset) is parsed as a patch list, the REA entry owns the stable rea-mcp id, and setup appends or replaces exactly that entry while preserving unrelated patches and comments in place. Doctor reports the registration through the same entry, and uninstall removes it, dropping insert operations it emptied. Skill destinations follow ~/.agents/skills ($DSH_AGENTS_HOME/skills when configured), the home the rest of the client machinery already uses. Fixes #1839 * fix(setup): carry environment and identity validation into DSH registrations Address review: the DSH entry discarded discovered provider overrides; config.env is now emitted and retained like the other dialects. An entry carrying the rea-mcp id under a wrong or missing package name, or duplicated entries, reads back as absent so setup rewrites exactly one canonical entry instead of reporting unchanged. Doctor now requires serverName rea and stdio transport before calling a DSH registration aligned, and the Harness skill identifier in the docs matches the installed reverse-engineer-anything skill. * fix(setup): apply Cordis patch order and collapse DSH registrations Address review: a later direct { id, config } operation replaces the row's config under the native patch DSL, so the registration view now applies patches in order instead of only reading insert operations; doctor reports the effective command rather than the inserted one. Setup rewrites exactly one canonical entry: duplicate id-matching rows collapse to the first, direct patches to the entry are dropped from the document and from the edited YAML, and uninstall removes them with the entry. The client-location test fixture now clears DSH_HOME and DSH_AGENTS_HOME so a developer environment cannot point the suite at real Harness homes. * fix(setup): repair direct-only registrations and preserve Cordis entry state Serialize now edits the first insertion carrying rea-mcp in place, so a grouped entry keeps its scope, neighbors, and comments instead of moving to a fresh root-level insertion, and later carrying insertions and direct patches collapse away. Entry-level disabled state projects into the registration the doctor and setup compare, so a disabled row reads as stale and the repair re-enables it. A direct-only registration no longer suppresses the canonical insert append, and an update retains the registration environment. Duplicate entries collapse across operations, not only within one. * fix(setup): preserve Harness row state through identity and refresh repairs A wrong-package row still reads back as a server record carrying its environment and caller fields, projected with a package marker the alignment path treats as stale, instead of vanishing and dropping config.env from the repair. A direct Cordis patch without a preceding insertion configures nothing, so a direct-only registration reads as absent even when its config matches and the repair appends the insert. Caller-owned config fields beyond the REA-owned keys, such as the cwd the native verifier sets, survive a refresh instead of being silently dropped. * fix(setup): preserve Cordis tags and collapse aligned Harness rows A refresh rebuilt Harness config from parsed values, so a caller !!js expression was written back as a literal. Owned keys are now overlaid on the winning config node and other nodes stay in place. Two already-aligned rea-mcp insertions read as stale, so setup collapses them to one row, and the bundled skill lists dsh with the other clients. * fix(setup): drop the unused DSH configuration type Knip reports DshConfigurationFormat as an unused export. The format union does not depend on that alias. * fix(setup): keep Harness env tags and comment-only patches A refresh replaced the whole env map, so a caller !!js value was written back as a literal. Environment entries now stay in place unless the value changed. A comment-only cordis.patch.yml parses as an empty patch list and keeps those comments when the registration is appended. Docs describe plain rea uninstall, which scans supported clients and has no --client flag. * fix(setup): own Harness rows by entry id and expression state Uninstall removes a rea-mcp row even when the launcher is stale or the patch never established a server record. A non-boolean disabled value, including a Cordis expression, reads as stale. Expression text on a known environment setting is not validated as a literal path, so a refresh can keep the tag. * fix(setup): collapse aliased Harness insertions An anchored insert followed by an alias parses as two rea-mcp rows, but the YAML edit kept the alias, so readback stayed duplicated and setup restored the file. Aliases that resolve to the owned entry are now dropped, including an alias of the entry inside one insert. Aliases of other patches stay. * fix(setup): resolve aliased Harness lists and config maps An insert value that aliases another entry list still loaded a second rea-mcp row after the operation-level alias collapse. That alias is now dropped once the anchored list is rewritten. A config: *cfg map is resolved and cloned before the refresh, so a caller !!js field on the aliased map is not rewritten as a plain literal. * fix(setup): follow Harness aliases for ids, lists, and env tags An insert alias whose anchor is not another insert is copied so unrelated rows stay while the owned row is rewritten. An aliased entry id is resolved before ownership checks, and an environment alias of an unchanged !!js scalar is left in place. * fix(setup): keep aliased Harness env maps through uninstall An env alias is cloned before a refresh so tagged values are not rebuilt as literals, and expression-key scans follow aliases before path checks. Insert aliases that carry REA are recorded before uninstall empties their anchor, so the later alias is dropped instead of dangling. * fix(setup): follow root Harness aliases and mark dsh as main-only Winning-config walks resolve a root alias before patch order, so a replayed direct config's !!js env is not validated as a literal path. Root aliases that own rea-mcp are recorded before uninstall empties their anchor. An insert whose only owned member is an external alias is cloned so Cordis tags survive. Docs state dsh is not in npm 6.4.0. * fix(setup): keep rows behind inert Harness aliases A root alias whose anchor is not a loaded operation is copied and only the owned row is edited, so unrelated rows stay active. Entry id aliases become a scalar before their anchor patch is dropped. Root-alias ownership follows insert-list aliases, so a replay is removed instead of dangling after uninstall. * refactor(setup): centralize Cordis source ownership * fix(javascript): share Electron mutation proof with wrapped calls * test(mcp): align graph and catalog checks with current contracts * fix(setup): separate Cordis ownership from editability * docs: clarify ownership and interpretation boundaries * fix(setup): preserve Cordis applicability and caller metadata --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
|
import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js";
|
|
import {
|
|
clientRegistrationEntry,
|
|
clientConfigurationValuesEqual,
|
|
clientServerListedDisabled,
|
|
clientServerPath,
|
|
legacyClientServerPath,
|
|
OMP_DISABLED_SERVERS_KEY,
|
|
parseClientConfiguration,
|
|
serializeClientConfiguration,
|
|
withClientServers,
|
|
type ClientConfigurationDocument,
|
|
} from "./ClientConfigurationDocument.js";
|
|
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import writeFileAtomic from "write-file-atomic";
|
|
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
|
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
|
import {
|
|
readRegularFileText,
|
|
RegularFileCleanupFailure,
|
|
} from "./RegularFileRead.js";
|
|
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
|
|
import type {
|
|
ClientConfigurationInspection,
|
|
ClientConfigurationResult,
|
|
SetupProviderEnvironment,
|
|
} from "./SetupTypes.js";
|
|
import type { SetupClient } from "./SupportedClients.js";
|
|
|
|
/** Configure one supported client's native stdio MCP registration shape. */
|
|
export const configureClientConfiguration = (
|
|
client: SetupClient,
|
|
environment: SetupProviderEnvironment = {},
|
|
command: readonly string[] = npxRegistrationCommand(),
|
|
): Promise<ClientConfigurationResult> => {
|
|
if (client.configPathError !== undefined)
|
|
return Promise.resolve({ status: "failed", reason: "path" });
|
|
return configureClientDocument(client, environment, command);
|
|
};
|
|
|
|
const configureClientDocument = async (
|
|
client: SetupClient,
|
|
environment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
): Promise<ClientConfigurationResult> => {
|
|
const format = client.format;
|
|
if (format === undefined || format === "unsupported")
|
|
return { status: "failed", reason: "readback" };
|
|
const files = await readClientConfigurationFiles(client);
|
|
if (!files.ok) {
|
|
throwCleanupFailure(files.error);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
undefined,
|
|
clientConfigurationDesired(
|
|
client,
|
|
environment,
|
|
command,
|
|
files.value.at(-1),
|
|
),
|
|
);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
if (policy.value !== undefined)
|
|
return { status: "failed", reason: "readback" };
|
|
const transactionPath = await resolveClientConfigTransactionPath(
|
|
client.configPath,
|
|
);
|
|
if (transactionPath === undefined)
|
|
return { status: "failed", reason: "path" };
|
|
let original: string | undefined;
|
|
try {
|
|
original = await readRegularFileText(transactionPath);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
|
|
}
|
|
let parsed: ClientConfigurationDocument;
|
|
try {
|
|
parsed = parseClientConfiguration(
|
|
original ??
|
|
(format === "toml" || format === "grok" || format === "dsh"
|
|
? ""
|
|
: "{}"),
|
|
format,
|
|
);
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
parsed,
|
|
clientConfigurationDesired(client, environment, command, parsed),
|
|
);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
if (policy.value !== undefined)
|
|
return { status: "failed", reason: "readback" };
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
// Malformed existing configuration fails the readback gate.
|
|
void cause;
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
const desired = clientConfigurationDesired(
|
|
client,
|
|
environment,
|
|
command,
|
|
parsed,
|
|
);
|
|
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
|
|
return persistClientConfiguration({
|
|
client,
|
|
transactionPath,
|
|
format,
|
|
original,
|
|
parsed,
|
|
desired,
|
|
});
|
|
};
|
|
|
|
interface ClientConfigurationUpdate {
|
|
readonly client: SetupClient;
|
|
readonly transactionPath: string;
|
|
readonly format: NonNullable<SetupClient["format"]>;
|
|
readonly original: string | undefined;
|
|
readonly parsed: ClientConfigurationDocument;
|
|
readonly desired: unknown;
|
|
}
|
|
|
|
const persistClientConfiguration = async ({
|
|
client,
|
|
transactionPath,
|
|
format,
|
|
original,
|
|
parsed,
|
|
desired,
|
|
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
|
|
const backupPath =
|
|
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
|
if (
|
|
backupPath !== undefined &&
|
|
!(await preserveConfigBackup(transactionPath, backupPath))
|
|
)
|
|
return { status: "failed", reason: "backup" };
|
|
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
|
|
// otherwise conflict with it.
|
|
const legacyPath = legacyClientServerPath(
|
|
parsed,
|
|
PRODUCT_IDENTITY.mcpServerKey,
|
|
);
|
|
const registered = withClientServers(
|
|
parsed,
|
|
{ ...parsed.servers, [PRODUCT_IDENTITY.mcpServerKey]: desired },
|
|
PRODUCT_IDENTITY.mcpServerKey,
|
|
);
|
|
// OMP's denylist would hide the registration; keep every other listed name.
|
|
const ompDisabled =
|
|
parsed.dialect === "omp"
|
|
? parsed.document[OMP_DISABLED_SERVERS_KEY]
|
|
: undefined;
|
|
const enableOmp =
|
|
Array.isArray(ompDisabled) &&
|
|
ompDisabled.includes(PRODUCT_IDENTITY.mcpServerKey);
|
|
const document =
|
|
enableOmp && Array.isArray(ompDisabled)
|
|
? {
|
|
...registered,
|
|
[OMP_DISABLED_SERVERS_KEY]: ompDisabled.filter(
|
|
(name: unknown) => name !== PRODUCT_IDENTITY.mcpServerKey,
|
|
),
|
|
}
|
|
: registered;
|
|
try {
|
|
await mkdir(dirname(client.configPath), { recursive: true });
|
|
await writeFileAtomic(
|
|
transactionPath,
|
|
serializeClientConfiguration(document, format, original, [
|
|
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
|
|
...(legacyPath === undefined ? [] : [legacyPath]),
|
|
...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
|
|
]),
|
|
{
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
},
|
|
);
|
|
} catch (cause: unknown) {
|
|
// Write failure is reported by the status reason.
|
|
void cause;
|
|
return { status: "failed", reason: "write" };
|
|
}
|
|
try {
|
|
const readback = parseClientConfiguration(
|
|
await readRegularFileText(transactionPath),
|
|
format,
|
|
);
|
|
if (!registrationCurrent(readback, desired)) {
|
|
await restoreConfig(transactionPath, original);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
// Readback failure restores the transaction before reporting.
|
|
void cause;
|
|
await restoreConfig(transactionPath, original);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
return {
|
|
status: "configured",
|
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
};
|
|
};
|
|
|
|
/** Determine whether an existing client configuration matches the desired registration. */
|
|
export const clientConfigurationAligned = async (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
): Promise<boolean> => {
|
|
if (client.configPathError !== undefined) return false;
|
|
try {
|
|
const files = await readClientConfigurationFiles(client);
|
|
if (!files.ok) {
|
|
throwCleanupFailure(files.error);
|
|
return false;
|
|
}
|
|
const original = await readRegularFileText(client.configPath);
|
|
const parsed = parseClientConfiguration(original, client.format);
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
parsed,
|
|
clientConfigurationDesired(client, providerEnvironment, command, parsed),
|
|
);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return false;
|
|
}
|
|
return (
|
|
policy.value === undefined &&
|
|
registrationCurrent(
|
|
parsed,
|
|
clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
parsed,
|
|
),
|
|
)
|
|
);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
// Unreadable configuration is treated as not aligned so setup repairs it.
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Preflight one client configuration before setup presents any mutations. */
|
|
export const inspectClientConfiguration = async (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
): Promise<ClientConfigurationInspection> => {
|
|
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) {
|
|
throwCleanupFailure(files.error);
|
|
return {
|
|
status: "invalid",
|
|
remediation: files.error.detail,
|
|
};
|
|
}
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
undefined,
|
|
clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
files.value.at(-1),
|
|
),
|
|
);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return {
|
|
status: "invalid",
|
|
remediation: policy.error.detail,
|
|
};
|
|
}
|
|
if (policy.value !== undefined)
|
|
return { status: "invalid", remediation: policy.value };
|
|
const transactionPath = await resolveClientConfigTransactionPath(
|
|
client.configPath,
|
|
);
|
|
if (transactionPath === undefined)
|
|
return {
|
|
status: "invalid",
|
|
remediation:
|
|
"The configuration path is unsafe or unresolved. Check ownership and symbolic links before rerunning setup.",
|
|
};
|
|
let original: string;
|
|
try {
|
|
original = await readRegularFileText(transactionPath);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
if (isMissing(cause)) return { status: "create" };
|
|
return {
|
|
status: "invalid",
|
|
remediation: `The configuration file could not be read. Check its permissions before rerunning setup. ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
};
|
|
}
|
|
try {
|
|
const parsed = parseClientConfiguration(original, client.format);
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
parsed,
|
|
clientConfigurationDesired(client, providerEnvironment, command, parsed),
|
|
);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return {
|
|
status: "invalid",
|
|
remediation: policy.error.detail,
|
|
};
|
|
}
|
|
if (policy.value !== undefined)
|
|
return { status: "invalid", remediation: policy.value };
|
|
const desired = clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
parsed,
|
|
);
|
|
if (registrationCurrent(parsed, desired))
|
|
return { status: "already_current" };
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
// Malformed configuration is reported with the invalid remediation.
|
|
void cause;
|
|
return {
|
|
status: "invalid",
|
|
remediation:
|
|
"The existing configuration is malformed. Repair it before setup applies any other changes.",
|
|
};
|
|
}
|
|
return {
|
|
status: "update",
|
|
backupPath: `${client.configPath}.rea.backup`,
|
|
};
|
|
};
|
|
|
|
const isMissing = (cause: unknown): boolean =>
|
|
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
|
|
|
const throwCleanupFailure = (error: ClientConfigurationFileError): void => {
|
|
if (error.cleanupFailure !== undefined) throw error.cleanupFailure;
|
|
};
|
|
|
|
const preserveConfigBackup = async (
|
|
source: string,
|
|
destination: string,
|
|
): Promise<boolean> => {
|
|
try {
|
|
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
|
}
|
|
};
|
|
|
|
const restoreConfig = async (
|
|
path: string,
|
|
original: string | undefined,
|
|
): Promise<void> => {
|
|
try {
|
|
if (original === undefined) await rm(path, { force: true });
|
|
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
|
|
} catch (cause: unknown) {
|
|
// The backup remains available for the remediation reported by setup.
|
|
void cause;
|
|
}
|
|
};
|
|
|
|
/** Whether REA's entry matches, no legacy entry remains, and no client disable list suppresses it. */
|
|
const registrationCurrent = (
|
|
parsed: ClientConfigurationDocument,
|
|
desired: unknown,
|
|
): boolean =>
|
|
(parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) &&
|
|
clientConfigurationValuesEqual(
|
|
parsed.servers[PRODUCT_IDENTITY.mcpServerKey],
|
|
desired,
|
|
) &&
|
|
!Object.hasOwn(parsed.legacyServers, PRODUCT_IDENTITY.mcpServerKey) &&
|
|
!clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey);
|
|
|
|
const clientConfigurationDesired = (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
parsed?: ClientConfigurationDocument,
|
|
) => {
|
|
const format = parsed?.dialect ?? client.format ?? "json";
|
|
const launcher =
|
|
command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command;
|
|
if (format === "dsh") {
|
|
const desired = (
|
|
parsed === undefined
|
|
? new DshCordisDocument("")
|
|
: dshDocument(parsed.document)
|
|
).desired(launcher, providerEnvironment);
|
|
if (!desired.ok) throw desired.error;
|
|
return desired.value;
|
|
}
|
|
// Preserve explicitly configured server settings, never the ambient process
|
|
// environment. Newly discovered provider paths win only for their own keys.
|
|
const environment = clientRegistrationEnvironment(
|
|
parsed,
|
|
providerEnvironment,
|
|
);
|
|
if (!environment.ok) throw environment.error;
|
|
const registration = clientRegistrationEntry(
|
|
format,
|
|
launcher,
|
|
environment.value,
|
|
);
|
|
return {
|
|
...registration,
|
|
...(client.name === "codex" || client.name === "grok_build"
|
|
? {
|
|
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
|
}
|
|
: {}),
|
|
};
|
|
};
|