mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
844 lines
27 KiB
TypeScript
844 lines
27 KiB
TypeScript
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
|
import {
|
|
readClientConfigurationFiles,
|
|
effectiveClientConfiguration,
|
|
readConfigurationText,
|
|
type ClientConfigurationSource,
|
|
} from "./ClientConfigurationFiles.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
|
import {
|
|
clientRegistrationEntry,
|
|
clientConfigurationValuesEqual,
|
|
clientServerListedDisabled,
|
|
clientServerPath,
|
|
legacyClientServerPath,
|
|
OMP_DISABLED_SERVERS_KEY,
|
|
parseClientConfiguration,
|
|
parseZcodeFallbackConfiguration,
|
|
serializeClientConfiguration,
|
|
withClientServers,
|
|
type ClientConfigurationDocument,
|
|
} from "./ClientConfigurationDocument.js";
|
|
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
|
import { withClientConfigurationTransaction } from "./ClientConfigurationTransaction.js";
|
|
import { isClientConfigurationCleanupFailure } from "./ClientConfigurationCleanup.js";
|
|
import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js";
|
|
import {
|
|
clientRegistrationEnvironment,
|
|
clientRegistrationEnvironmentDetailsWithBudget,
|
|
type ProviderRequestBudgetResolver,
|
|
} from "./ClientRegistrationEnvironment.js";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
|
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
|
import {
|
|
MCP_STARTUP_POLICY,
|
|
recommendedMcpToolTimeoutMs,
|
|
recommendedMcpToolTimeoutSeconds,
|
|
} from "../mcpStartupPolicy.js";
|
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
|
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
|
|
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
|
import {
|
|
readRegularFileText,
|
|
readRegularFileTextSnapshot,
|
|
InvalidUtf8FileError,
|
|
} from "./RegularFileRead.js";
|
|
import {
|
|
preserveCurrentRecoveryBackup,
|
|
selectCurrentRecoveryBackup,
|
|
} from "./SetupRecoveryBackup.js";
|
|
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
|
|
import type {
|
|
ClientConfigurationInspection,
|
|
ClientConfigurationResult,
|
|
SetupProviderEnvironment,
|
|
} from "./SetupTypes.js";
|
|
import {
|
|
registrationWriteBlocker,
|
|
type SetupClient,
|
|
} from "./SupportedClients.js";
|
|
|
|
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
|
|
constructor(
|
|
failure: RegularFileCleanupFailure<string>,
|
|
readonly configurationFailure: Extract<
|
|
ClientConfigurationResult,
|
|
{ readonly status: "failed" }
|
|
>,
|
|
) {
|
|
super(
|
|
failure.path,
|
|
failure.owner,
|
|
failure.outcome,
|
|
new AggregateError(
|
|
[failure.cleanupCause, configurationFailure],
|
|
`Configuration readback cleanup and rollback failed: ${failure.path}`,
|
|
),
|
|
failure.retention,
|
|
failure.completionCancellation,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Configure one supported client's native stdio MCP registration shape. */
|
|
export const configureClientConfiguration = async (
|
|
client: SetupClient,
|
|
environment: SetupProviderEnvironment = {},
|
|
command: readonly string[] = npxRegistrationCommand(),
|
|
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
|
): Promise<ClientConfigurationResult> => {
|
|
if (client.configPathError !== undefined)
|
|
return { status: "failed", reason: "path" };
|
|
const transactionPath = await resolveClientConfigTransactionPath(
|
|
client.configPath,
|
|
);
|
|
if (transactionPath === undefined)
|
|
return { status: "failed", reason: "path" };
|
|
return withClientConfigurationTransaction(transactionPath, () =>
|
|
configureClientDocument(
|
|
{ client, transactionPath },
|
|
environment,
|
|
command,
|
|
resolveProviderRequestBudget,
|
|
),
|
|
);
|
|
};
|
|
|
|
const configureClientDocument = async (
|
|
{
|
|
client,
|
|
transactionPath,
|
|
}: { readonly client: SetupClient; readonly transactionPath: string },
|
|
environment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
|
): 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 existing = files.value.find(
|
|
({ sourcePath }) => sourcePath === client.configPath,
|
|
);
|
|
const editabilityError =
|
|
existing?.dialect === "dsh"
|
|
? dshDocument(existing.document).editabilityError
|
|
: undefined;
|
|
if (editabilityError !== undefined)
|
|
return { status: "failed", reason: "readback" };
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
existing,
|
|
await clientConfigurationDesired(
|
|
client,
|
|
environment,
|
|
command,
|
|
existing,
|
|
files.value,
|
|
resolveProviderRequestBudget,
|
|
),
|
|
);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
if (policy.value !== undefined)
|
|
return { status: "failed", reason: "readback" };
|
|
let snapshot:
|
|
| Awaited<ReturnType<typeof readRegularFileTextSnapshot>>
|
|
| undefined;
|
|
try {
|
|
snapshot = await readRegularFileTextSnapshot(transactionPath, {
|
|
symlinks: "reject",
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
|
|
}
|
|
const original = snapshot?.text;
|
|
let parsed: ClientConfigurationDocument;
|
|
try {
|
|
parsed = parseClientConfiguration(
|
|
original ??
|
|
(format === "toml" || format === "grok" || format === "dsh"
|
|
? ""
|
|
: "{}"),
|
|
format,
|
|
);
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
parsed,
|
|
await clientConfigurationDesired(
|
|
client,
|
|
environment,
|
|
command,
|
|
parsed,
|
|
files.value,
|
|
resolveProviderRequestBudget,
|
|
),
|
|
);
|
|
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 fallback = await readZcodeFallbackServers(client, parsed);
|
|
if (!fallback.ok) {
|
|
throwCleanupFailure(fallback.error);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
const desired = await clientConfigurationDesired(
|
|
client,
|
|
environment,
|
|
command,
|
|
{
|
|
...parsed,
|
|
servers: { ...fallback.value, ...parsed.servers },
|
|
},
|
|
files.value,
|
|
resolveProviderRequestBudget,
|
|
);
|
|
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
|
|
return persistClientConfiguration({
|
|
client,
|
|
transactionPath,
|
|
snapshot,
|
|
format,
|
|
original,
|
|
parsed,
|
|
desired,
|
|
fallbackServers: fallback.value,
|
|
});
|
|
};
|
|
|
|
interface ClientConfigurationUpdate {
|
|
readonly client: SetupClient;
|
|
readonly transactionPath: string;
|
|
readonly snapshot:
|
|
| Awaited<ReturnType<typeof readRegularFileTextSnapshot>>
|
|
| undefined;
|
|
readonly format: NonNullable<SetupClient["format"]>;
|
|
readonly original: string | undefined;
|
|
readonly parsed: ClientConfigurationDocument;
|
|
readonly desired: unknown;
|
|
readonly fallbackServers: Readonly<Record<string, unknown>>;
|
|
}
|
|
|
|
const persistClientConfiguration = async ({
|
|
client,
|
|
transactionPath,
|
|
snapshot,
|
|
format,
|
|
original,
|
|
parsed,
|
|
desired,
|
|
fallbackServers,
|
|
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
|
|
// The plan chose this file; an MSIX package may have switched to its
|
|
// private copy since, for example when it quit.
|
|
if (client.msixPackage !== undefined) {
|
|
const target = await packageReadTargetChanged(
|
|
client.msixPackage,
|
|
client.configPath,
|
|
);
|
|
if (!target.ok)
|
|
return {
|
|
status: "failed",
|
|
reason: "target_unavailable",
|
|
failure: {
|
|
kind: "inspect",
|
|
path: target.error.path,
|
|
detail: target.error.detail,
|
|
},
|
|
};
|
|
if (target.value) return { status: "failed", reason: "target_changed" };
|
|
}
|
|
// 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,
|
|
{
|
|
...fallbackServers,
|
|
...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;
|
|
let serialized: ReturnType<typeof serializeClientConfiguration>;
|
|
try {
|
|
serialized = serializeClientConfiguration(
|
|
document,
|
|
format,
|
|
original,
|
|
[
|
|
...Object.keys(fallbackServers)
|
|
.filter((name) => name !== PRODUCT_IDENTITY.mcpServerKey)
|
|
.map((name) => clientServerPath(parsed, name)),
|
|
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
|
|
...(legacyPath === undefined ? [] : [legacyPath]),
|
|
...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
|
|
],
|
|
// Setup deletes only REA's OpenCode V1 name, which the native V2 entry
|
|
// supersedes whatever its launcher; every repeat goes, as one member does.
|
|
{ removeShadowedMembersAt: legacyPath === undefined ? [] : [legacyPath] },
|
|
);
|
|
} catch (cause: unknown) {
|
|
// An unserializable edit is reported before any file is written.
|
|
void cause;
|
|
return { status: "failed", reason: "write" };
|
|
}
|
|
if (!serialized.ok)
|
|
return {
|
|
status: "failed",
|
|
reason: "shadowed_member",
|
|
path: serialized.error.path,
|
|
};
|
|
let recoveryBackupPath: string | undefined;
|
|
if (original !== undefined) {
|
|
const preserved = await preserveCurrentRecoveryBackup(
|
|
`${client.configPath}.rea.backup`,
|
|
transactionPath,
|
|
original,
|
|
);
|
|
if (!preserved.ok) return { status: "failed", reason: "backup" };
|
|
recoveryBackupPath = preserved.value;
|
|
}
|
|
let publication: ClientConfigurationPublication;
|
|
try {
|
|
await mkdir(dirname(client.configPath), { recursive: true });
|
|
publication = await ClientConfigurationPublication.write(
|
|
transactionPath,
|
|
serialized.value,
|
|
original,
|
|
0o600,
|
|
{ requestedPath: client.configPath, state: snapshot?.state },
|
|
);
|
|
} catch (cause: unknown) {
|
|
if (isClientConfigurationCleanupFailure(cause)) throw cause;
|
|
if (isConfigurationConflict(cause))
|
|
return conflictFailure(recoveryBackupPath);
|
|
// Write failure is reported by the status reason.
|
|
void cause;
|
|
return { status: "failed", reason: "write" };
|
|
}
|
|
return publication.verify(async () => {
|
|
try {
|
|
const text = await readRegularFileText(publication.path);
|
|
await publication.assertCurrent(text);
|
|
const readback = parseClientConfiguration(text, format);
|
|
if (
|
|
!registrationCurrent(readback, desired) ||
|
|
Object.entries(fallbackServers).some(
|
|
([name, entry]) =>
|
|
name !== PRODUCT_IDENTITY.mcpServerKey &&
|
|
!clientConfigurationValuesEqual(readback.servers[name], entry),
|
|
)
|
|
) {
|
|
return rollbackOrReadbackFailure(
|
|
publication,
|
|
original,
|
|
recoveryBackupPath,
|
|
);
|
|
}
|
|
} catch (cause: unknown) {
|
|
if (isConfigurationConflict(cause) || isMissing(cause))
|
|
return conflictFailure(recoveryBackupPath);
|
|
if (cause instanceof RegularFileCleanupFailure) {
|
|
const rollback = await restoreConfig(publication, original);
|
|
if (!rollback.ok)
|
|
throw new ClientConfigurationCleanupRollbackFailure(
|
|
cause,
|
|
rollbackFailure(rollback.error, recoveryBackupPath),
|
|
);
|
|
throw cause;
|
|
}
|
|
// Readback failure restores the transaction before reporting.
|
|
void cause;
|
|
return rollbackOrReadbackFailure(
|
|
publication,
|
|
original,
|
|
recoveryBackupPath,
|
|
);
|
|
}
|
|
return {
|
|
status: "configured",
|
|
...(recoveryBackupPath === undefined
|
|
? {}
|
|
: { backupPath: recoveryBackupPath }),
|
|
};
|
|
});
|
|
};
|
|
|
|
/** Determine whether an existing client configuration matches the desired registration. */
|
|
export const clientConfigurationAligned = async (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
|
): 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 desired = await clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
parsed,
|
|
files.value,
|
|
resolveProviderRequestBudget,
|
|
);
|
|
const policy = await readClientPolicyBlock(client, parsed, desired);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return false;
|
|
}
|
|
return policy.value === undefined && registrationCurrent(parsed, desired);
|
|
} 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[],
|
|
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
|
): Promise<ClientConfigurationInspection> => {
|
|
const inspection = await inspectClientDocument(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
resolveProviderRequestBudget,
|
|
);
|
|
if (inspection.status !== "create" && inspection.status !== "update")
|
|
return inspection;
|
|
// A change the running client would overwrite is not a persisted registration.
|
|
const blocker = registrationWriteBlocker(client, "setup");
|
|
return blocker === undefined
|
|
? inspection
|
|
: { status: "invalid", remediation: blocker };
|
|
};
|
|
|
|
const inspectClientDocument = async (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
|
): 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 existing = files.value.find(
|
|
({ sourcePath }) => sourcePath === client.configPath,
|
|
);
|
|
const editabilityError =
|
|
existing?.dialect === "dsh"
|
|
? dshDocument(existing.document).editabilityError
|
|
: undefined;
|
|
if (editabilityError !== undefined)
|
|
return {
|
|
status: "invalid",
|
|
remediation: editabilityError.message,
|
|
};
|
|
const policy = await readClientPolicyBlock(
|
|
client,
|
|
existing,
|
|
await clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
existing,
|
|
files.value,
|
|
resolveProviderRequestBudget,
|
|
),
|
|
);
|
|
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)) {
|
|
const fallback = await readZcodeFallbackServers(client);
|
|
if (!fallback.ok) {
|
|
throwCleanupFailure(fallback.error);
|
|
return { status: "invalid", remediation: fallback.error.detail };
|
|
}
|
|
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 desired = await clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
parsed,
|
|
files.value,
|
|
resolveProviderRequestBudget,
|
|
);
|
|
const policy = await readClientPolicyBlock(client, parsed, desired);
|
|
if (!policy.ok) {
|
|
throwCleanupFailure(policy.error);
|
|
return {
|
|
status: "invalid",
|
|
remediation: policy.error.detail,
|
|
};
|
|
}
|
|
if (policy.value !== undefined)
|
|
return { status: "invalid", remediation: policy.value };
|
|
if (registrationCurrent(parsed, desired))
|
|
return { status: "already_current" };
|
|
const fallback = await readZcodeFallbackServers(client, parsed);
|
|
if (!fallback.ok) {
|
|
throwCleanupFailure(fallback.error);
|
|
return { status: "invalid", remediation: fallback.error.detail };
|
|
}
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
|
if (cause instanceof ConfigurationError)
|
|
return { status: "invalid", remediation: cause.message };
|
|
// 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.",
|
|
};
|
|
}
|
|
const backupSelection = await selectCurrentRecoveryBackup(
|
|
`${client.configPath}.rea.backup`,
|
|
original,
|
|
);
|
|
if (!backupSelection.ok)
|
|
return {
|
|
status: "invalid",
|
|
remediation: backupSelection.error.detail,
|
|
};
|
|
return {
|
|
status: "update",
|
|
backupPath: backupSelection.value.backupPath,
|
|
};
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
/** Admit the fallback only when the first native ZCode server would suppress it. */
|
|
const readZcodeFallbackServers = async (
|
|
client: SetupClient,
|
|
parsed?: ClientConfigurationDocument,
|
|
): Promise<
|
|
Result<Readonly<Record<string, unknown>>, ClientConfigurationFileError>
|
|
> => {
|
|
if (
|
|
client.format !== "zcode" ||
|
|
(parsed !== undefined && Object.keys(parsed.servers).length > 0)
|
|
)
|
|
return ok({});
|
|
const path = client.zcodeFallbackPath;
|
|
const detail = `The ZCode fallback configuration ${path ?? "path"} is unresolved, malformed or unreadable. Repair it before rerunning setup: the first native server would suppress that fallback.`;
|
|
if (path === undefined)
|
|
return err({ kind: "unreadable", path: client.configPath, detail });
|
|
const transactionPath = await resolveClientConfigTransactionPath(path);
|
|
if (transactionPath === undefined)
|
|
return err({ kind: "unreadable", path, detail });
|
|
const source = await readConfigurationText(transactionPath);
|
|
if (!source.ok)
|
|
return err({ ...source.error, detail: `${detail} ${source.error.detail}` });
|
|
if (source.value === undefined) return ok({});
|
|
try {
|
|
const parsed = parseZcodeFallbackConfiguration(source.value);
|
|
const environment = clientRegistrationEnvironment(parsed);
|
|
if (!environment.ok)
|
|
return err({
|
|
kind: "malformed",
|
|
path,
|
|
detail: `${detail} ${environment.error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}`,
|
|
});
|
|
return ok(parsed.servers);
|
|
} catch (cause: unknown) {
|
|
return err({
|
|
kind: "malformed",
|
|
path,
|
|
detail: `${detail} ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
});
|
|
}
|
|
};
|
|
|
|
type ConfigurationRestoreFailure =
|
|
| { readonly kind: "conflict" }
|
|
| {
|
|
readonly kind: "restore";
|
|
readonly path: string;
|
|
readonly detail: string;
|
|
};
|
|
|
|
const restoreConfig = async (
|
|
publication: ClientConfigurationPublication,
|
|
original: string | undefined,
|
|
): Promise<Result<void, ConfigurationRestoreFailure>> => {
|
|
try {
|
|
await publication.restore(original);
|
|
return ok(undefined);
|
|
} catch (cause: unknown) {
|
|
if (isConfigurationConflict(cause) || isMissing(cause))
|
|
return err({ kind: "conflict" });
|
|
return err({
|
|
kind: "restore",
|
|
path: publication.path,
|
|
detail: errorDetail(cause),
|
|
});
|
|
}
|
|
};
|
|
|
|
const rollbackOrReadbackFailure = async (
|
|
publication: ClientConfigurationPublication,
|
|
original: string | undefined,
|
|
backupPath: string | undefined,
|
|
): Promise<ClientConfigurationResult> => {
|
|
const rollback = await restoreConfig(publication, original);
|
|
return rollback.ok
|
|
? {
|
|
status: "failed",
|
|
reason: "readback",
|
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
}
|
|
: rollbackFailure(rollback.error, backupPath);
|
|
};
|
|
|
|
const rollbackFailure = (
|
|
failure: ConfigurationRestoreFailure,
|
|
backupPath?: string,
|
|
): Extract<ClientConfigurationResult, { readonly status: "failed" }> =>
|
|
failure.kind === "conflict"
|
|
? conflictFailure(backupPath)
|
|
: {
|
|
status: "failed",
|
|
reason: "rollback",
|
|
failure,
|
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
};
|
|
|
|
const isConfigurationConflict = (cause: unknown): boolean =>
|
|
cause instanceof RegularFileChangedError ||
|
|
cause instanceof InvalidUtf8FileError;
|
|
|
|
const conflictFailure = (
|
|
backupPath?: string,
|
|
): Extract<ClientConfigurationResult, { readonly status: "failed" }> => ({
|
|
status: "failed",
|
|
reason: "conflict",
|
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
});
|
|
|
|
const errorDetail = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(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 = async (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
parsed: ClientConfigurationDocument | undefined,
|
|
sources: readonly ClientConfigurationSource[],
|
|
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
|
): Promise<unknown> => {
|
|
const effective = effectiveClientConfiguration(sources, {
|
|
client,
|
|
document: parsed,
|
|
});
|
|
const format = parsed?.dialect ?? client.format ?? "json";
|
|
const launcher =
|
|
command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command;
|
|
if (format === "dsh") {
|
|
const validated = clientRegistrationEnvironment(
|
|
effective,
|
|
providerEnvironment,
|
|
);
|
|
if (!validated.ok) throw validated.error;
|
|
const desired = (
|
|
parsed === undefined
|
|
? new DshCordisDocument("")
|
|
: dshDocument(parsed.document)
|
|
).desired(launcher, providerEnvironment);
|
|
if (!desired.ok) throw desired.error;
|
|
return desired.value;
|
|
}
|
|
const validated = await clientRegistrationEnvironmentDetailsWithBudget(
|
|
effective,
|
|
providerEnvironment,
|
|
"effective",
|
|
client.name === "codex" || client.name === "kimi"
|
|
? resolveProviderRequestBudget
|
|
: undefined,
|
|
);
|
|
if (!validated.ok) throw validated.error;
|
|
// Preserve explicitly configured server settings, never the ambient process
|
|
// environment. The effective layered view validates precedence and supplies
|
|
// timeout facts; the target file's fragment remains the value being written.
|
|
const environment = clientRegistrationEnvironment(
|
|
parsed,
|
|
providerEnvironment,
|
|
"fragment",
|
|
);
|
|
if (!environment.ok) throw environment.error;
|
|
const registrationEnvironment =
|
|
client.name === "kimi" &&
|
|
snapshotEnvironment(environment.value).REA_MCP_INPUT_SCHEMA_PROFILE ===
|
|
undefined
|
|
? {
|
|
...environment.value,
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
|
}
|
|
: environment.value;
|
|
const registration = clientRegistrationEntry(
|
|
format,
|
|
launcher,
|
|
registrationEnvironment,
|
|
);
|
|
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
|
|
if (
|
|
client.name === "kimi" &&
|
|
typeof previous === "object" &&
|
|
previous !== null &&
|
|
"enabledTools" in previous
|
|
)
|
|
registration.enabledTools = previous.enabledTools;
|
|
return {
|
|
...registration,
|
|
...(client.name === "codex" || client.name === "grok_build"
|
|
? {
|
|
startup_timeout_sec:
|
|
client.name === "codex"
|
|
? (positiveTimeout(previous, "startup_timeout_sec") ??
|
|
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)
|
|
: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
|
}
|
|
: {}),
|
|
...(client.name === "codex"
|
|
? {
|
|
tool_timeout_sec:
|
|
positiveTimeout(previous, "tool_timeout_sec") ??
|
|
recommendedMcpToolTimeoutSeconds(validated.value.timeoutInputs),
|
|
}
|
|
: {}),
|
|
...(client.name === "kimi"
|
|
? {
|
|
startupTimeoutMs:
|
|
positiveTimeout(previous, "startupTimeoutMs") ??
|
|
MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
|
toolTimeoutMs:
|
|
positiveTimeout(previous, "toolTimeoutMs") ??
|
|
recommendedMcpToolTimeoutMs(validated.value.timeoutInputs),
|
|
}
|
|
: {}),
|
|
};
|
|
};
|
|
|
|
const positiveTimeout = (
|
|
registration: unknown,
|
|
key: string,
|
|
): number | bigint | undefined => {
|
|
if (typeof registration !== "object" || registration === null) return;
|
|
const value: unknown = Reflect.get(registration, key);
|
|
// TOML keeps integers beyond the safe Number range exact as bigint.
|
|
if (typeof value === "bigint") return value > 0n ? value : undefined;
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
? value
|
|
: undefined;
|
|
};
|