fix(setup): preserve registration identity and recovery ownership

Keep layered environment choices, current byte-exact recovery snapshots, and package-manager failure details. Report pinned package drift without claiming runner resolution. Retain failed Linux installer cleanup for host-owned retry while preserving the install outcome.

Reviewed fixes include #2632, #2769, #2783, #2959, #2979, #2983, #2995, #3009, and #2559.

Validation: 98 focused setup/registration tests passed; 45 Linux installer tests passed. Real Linux package installation remains unverified.
This commit is contained in:
morluto 2026-10-14 08:25:53 +08:00
parent 599a8ee11d
commit ec56c1da04
42 changed files with 2418 additions and 213 deletions

View File

@ -129,6 +129,12 @@ and Claude Code reports that autocompact is thrashing. To avoid this:
The sizes above come from REA's own `tools/list`. A live Claude Code session
against DeepSeek's API is unverified.
For a client whose stdio reader rejects the complete catalog frame, add
`"REA_MCP_OUTPUT_SCHEMA_PROFILE": "off"` to REA's `env` object and restart the
client. This omits only advertised output schemas; REA still validates complete
structured results against its canonical contracts. See [MCP output schema
profiles](mcp-contracts.md#output-schemas).
## Kimi Code
Setup registers `rea` in `~/.kimi-code/mcp.json`, or
@ -279,7 +285,11 @@ the first native server into an empty table, it copies the fallback's
`mcpServers` entries into the native table so they keep loading, and leaves
the fallback file untouched. Existing REA environment overrides are preserved
when refreshing its command. A malformed or unreadable fallback file fails setup instead of
being silently suppressed; repair or remove it and rerun. ZCode disables a
being silently suppressed; repair or remove it and rerun. Uninstall removes an
REA-owned `rea` entry from both the native table and the fallback, backing up
each changed file, so an emptied native table cannot load a leftover fallback
registration; other fallback servers, settings and a foreign `rea` entry are
kept. ZCode disables a
server with `enable: false` on its entry; doctor reports such a registration
as stale, and rerunning setup removes the flag to repair it. Native configuration
uses strict JSON; setup refuses comments, trailing commas and a BOM rather than

View File

@ -64,11 +64,21 @@ preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
Both snapshots remain available; a failed rollback reports the current recovery
copy's path and the restore failure.
Setup also retains valid environment overrides in REA's existing server entry,
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
such as `REA_MCP_INPUT_SCHEMA_PROFILE`, `REA_MCP_OUTPUT_SCHEMA_PROFILE`, and
`REA_MCP_MAX_RESPONSE_BYTES`.
Detected provider settings replace only matching environment keys.
Setup and doctor report a malformed REA environment map or invalid runtime
setting before setup writes files. Repair the named setting in the client
configuration; setup does not silently discard the rest of the map.
This includes malformed loader/exclusion JSON arrays and a Ghidra compiler
specification without its required language identifier. Valid overrides keep
their original strings, including empty Hopper arguments. DeepSeek Harness
`!!js` environment expressions remain unevaluated and preserved; registration
alignment does not verify the runtime values those expressions will produce.
For layered OpenCode files, each file's values are checked before merging, while
setting dependencies are checked against the effective merged environment.
Setup preserves each layer's overrides instead of copying inherited settings
into the file it updates.
After setup, REA reports which agents, analysis tools, and workflow files passed
its final checks. Restart any agent named in the completion message, then begin
@ -334,6 +344,20 @@ document for the same target. REA does not currently identify, focus, or reuse
that existing GUI document, and the supported launcher exposes no attach or
reuse action for it.
REA currently declines saved Hopper database (`.hop`) targets on macOS because
the native `open database` launch route does not run REA's bridge script. To
analyze that program with REA, select its original executable as the target and
open it through Hopper; this starts the supported bridge route. This does not
recover database-only annotations or analysis changes. Saved-database analysis
on Linux continues to use its supported demo-launch route.
Hopper lease directories must be readable and owned by the current user.
REA validates and secures an opened directory before reserving a lease. If
directory access fails, the error identifies the path and recovery steps;
REA leaves its permissions unchanged. Check ownership and parent-directory
access, then restore owner-only read, write, and search permissions (`0700`)
on a directory you own before retrying.
On supported Linux distributions, approved setup verifies Hopper's official
`.deb`, `.rpm`, or Arch package before invoking the native package manager.
REA runs the supported demo build on a private Xvfb display and selects Hopper's
@ -346,6 +370,13 @@ host mount and the rest of `/tmp` remain unchanged; this fallback never invokes
`sudo`. `rea doctor --provider hopper --json` reports the selected
strategy and both host and effective mount facts.
When the package-manager step fails, setup reports the failing command and its
captured output, so a package conflict is distinguishable from an authorization
failure. On CachyOS, an unversioned compatibility package such as
`zlib-ng-compat` may conflict with Hopper's versioned `zlib` dependency. Resolve
the package conflict yourself or install Hopper's official package manually,
then rerun `rea setup`.
The supported Linux demo forwards additional launches into its existing
application, even across private displays. REA therefore reserves one Linux
Hopper application per user across cooperating REA processes. A competing
@ -370,8 +401,9 @@ sessions before removing that recovery directory and retrying.
On macOS, REA uses
`/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` by default.
On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable
`~/.local/share/rea/hopper/bin/Hopper`; if neither exists, the former remains
the diagnostic fallback. `HOPPER_LAUNCHER_PATH` overrides those choices:
`~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper`
and `/usr/bin/hopper`; if none exists, the former remains the diagnostic
fallback. `HOPPER_LAUNCHER_PATH` overrides those choices:
```bash
export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
@ -511,7 +543,7 @@ reported as incompatible instead of being admitted from its directory name, and
a header that cannot be read or recognized is reported as unknown. REA does not
build or install native tools, or change Gatekeeper quarantine settings.
The adapter exposes 25 read-only operations: thirteen inventory/name/search
The adapter exposes 26 read-only operations: fourteen inventory/name/search
operations and twelve function-analysis operations. These cover metadata,
decompilation, assembly, resolved calls, typed references, xrefs, function
dossiers, instructions, recovered data types, measured load mappings, loaded
@ -799,7 +831,7 @@ On a controlled Windows x64 runner, use
`npm run verify:ghidra:windows`. The verifier generates a deterministic native
PE fixture from source bytes and requires the Windows native authority before
opening the provider. The controls are implemented on main; see the
[release boundary](#released-package-and-main). The lane verifies all 25 admitted
[release boundary](#released-package-and-main). The lane verifies all 26 admitted
read-only operations, digest identity, and runtime cleanup. The separate
`verify:ghidra:windows:package` lane checks the packed CLI/MCP boundary with an
ordinary user. It requires the built Windows native bundle and an existing

View File

@ -16,6 +16,7 @@ prerelease_identifier="(${numeric_identifier}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]
build_identifier='[0-9A-Za-z-]+'
version_pattern="${numeric_identifier}\\.${numeric_identifier}\\.${numeric_identifier}(-${prerelease_identifier}(\\.${prerelease_identifier})*)?(\\+${build_identifier}(\\.${build_identifier})*)?"
version="${REA_VERSION:-}"
version_requested=false
dry_run=false
start_setup=true
verbose=false
@ -81,6 +82,7 @@ main() {
--version)
(($# >= 2)) || fail "--version requires a value."
version="$2"
version_requested=true
shift 2
;;
--dry-run)
@ -121,7 +123,7 @@ main() {
fail_node_version "$node_version"
fi
if [ -n "$version" ]; then
if [ "$version_requested" = true ] || [ -n "$version" ]; then
matches "$version" "^${version_pattern}$" || fail "version must be an exact semantic version."
else
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
@ -164,6 +166,7 @@ main() {
npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
[ -x "$install_bin" ] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
[ -n "$installed_version" ] || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
[ "$installed_version" = "$version" ] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
printf 'REA %s is installed.\n' "$version"

155
src/android/JadxDoctor.ts Normal file
View File

@ -0,0 +1,155 @@
import type {
DoctorProviderCheck,
DoctorProviderInspection,
} from "../application/Doctor.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
inspectJadxAvailability,
JADX_FAILURE_KINDS,
projectJadxFailureOutcome,
type JadxFailureKind,
} from "./JadxConfiguration.js";
/** Project the validated JADX configuration into the doctor contract. */
export const inspectJadxProvider = async (
environment: Readonly<Record<string, string | undefined>>,
): Promise<DoctorProviderInspection> => {
const configured = environment.REA_JADX_MCP_JAR !== undefined;
const availability = await inspectJadxAvailability(environment);
// diagnostics is a loosely-typed JsonValue map; guard the published path
// even though the success path always sets jar_path today.
const jarPath =
availability.status === "available" &&
typeof availability.diagnostics["jar_path"] === "string"
? availability.diagnostics["jar_path"]
: undefined;
const available =
availability.status === "available" && jarPath !== undefined;
// Preserve the caller-selected JAR path (it may be a stable symlink) and
// the validated JAVA_HOME selection; the resolved jarPath stays in
// diagnostics as inspection evidence.
const configuredJar = environment.REA_JADX_MCP_JAR;
return {
id: "jadx",
configured,
available,
providerVersion: null,
registrationEnvironment:
jarPath === undefined || configuredJar === undefined
? {}
: {
REA_JADX_MCP_JAR: configuredJar,
...optionalEntry("JAVA_HOME", environment.JAVA_HOME),
...optionalEntry(
"REA_JADX_HEAP_MIB",
environment.REA_JADX_HEAP_MIB,
),
...optionalEntry(
"REA_JADX_ACTIVE_PROCESSOR_COUNT",
environment.REA_JADX_ACTIVE_PROCESSOR_COUNT,
),
},
checks: [configurationCheck(configured, availability, jarPath)],
};
};
/**
* Publish a caller-selected variable only when explicitly set; never persist
* ambient inheritance. Values are validated before this point.
*/
const optionalEntry = (
name: string,
value: string | undefined,
): Record<string, string> => (value === undefined ? {} : { [name]: value });
const configurationCheck = (
configured: boolean,
availability: ProviderAvailability,
jarPath: string | undefined,
): DoctorProviderCheck => {
const code = availability.status === "unavailable" ? availability.code : null;
if (availability.status === "available" && jarPath !== undefined) {
return {
name: "configuration",
ok: true,
code: null,
detail: `JADX MCP bridge JAR validated at ${jarPath}; its path is recorded for agent registrations.`,
remediation: null,
classification: "config_drift",
};
}
// The failure kind is tagged at the configuration boundary; project its
// remediation and classification here instead of reinterpreting the
// phase/setting evidence. Every failed check keeps a remediation.
const kind =
availability.status === "unavailable"
? asFailureKind(availability.diagnostics["failure_kind"])
: null;
const outcome = projectJadxFailureOutcome(kind, configured);
return {
name: "configuration",
ok: false,
code,
detail:
availability.status === "available"
? "Validated JADX configuration reported no JAR path."
: availability.reason,
remediation: outcome.remediation,
classification: outcome.classification,
...(kind === "java_probe" && availability.status === "unavailable"
? { details: probeDetails(availability.diagnostics) }
: kind !== null &&
kind.startsWith("windows_native_") &&
availability.status === "unavailable"
? { details: windowsNativeFailureDetails(availability.diagnostics) }
: {}),
};
};
const windowsNativeFailureDetails = (
diagnostics: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> => {
const details: Record<string, JsonValue> = {};
for (const key of ["failure_stage", "remediation"] as const) {
const value = diagnostics[key];
if (value !== undefined) details[key] = value;
}
return details;
};
/** Validate the loose diagnostics tag against the boundary's failure kinds. */
const asFailureKind = (value: JsonValue | undefined): JadxFailureKind | null =>
typeof value === "string" &&
(JADX_FAILURE_KINDS as readonly string[]).includes(value)
? (value as JadxFailureKind)
: null;
/**
* Project bounded Java probe metadata for the doctor report. The full
* stdout/stderr can reach the 1 MiB probe budget, so only the bounded
* outcome metadata is carried; the message already references the retained
* output.
*/
const probeDetails = (
diagnostics: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> => {
const details: Record<string, JsonValue> = {};
for (const key of [
"java_executable",
"failure",
"output_truncated",
"exit_code",
"process_error_code",
"signal",
"killed",
"output_limit_bytes",
"deadline_ms",
"jdk_compiler",
"java_major",
] as const) {
const value = diagnostics[key];
if (value !== undefined) details[key] = value;
}
return details;
};

View File

@ -167,7 +167,7 @@ export const parseZcodeFallbackConfiguration = (
? {}
: requireObject(document.mcpServers, "ZCode fallback mcpServers"),
serversPath: ["mcpServers"],
dialect: "json",
dialect: "zcode_fallback",
legacyServers: {},
};
};
@ -183,6 +183,7 @@ export const parseClientConfiguration = (
if (format === undefined || format === "unsupported")
throw new TypeError("client does not have a supported MCP config format");
// DeepSeek Harness stores a Cordis patch list, not a server table.
if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text);
if (format === "dsh") return parseDshCordisDocument(text);
const document = parseDocument(text, format);
const serversKey = clientConfigurationServersKey(format);

View File

@ -11,6 +11,21 @@ import {
import type { SetupClient } from "./SupportedClients.js";
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { ConfigurationError } from "../domain/configurationErrors.js";
/** A parsed layer retains its path so rereading the write target replaces only that layer. */
export interface ClientConfigurationSource extends ClientConfigurationDocument {
readonly sourcePath: string;
}
const invalidEnvironment = (
path: string,
error: ConfigurationError,
): ClientConfigurationFileError => ({
kind: "malformed",
path,
detail: `Configuration ${path} has an invalid REA server environment: ${error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}. Repair it before rerunning setup.`,
});
/** A named source failure that setup can explain without losing the bad path. */
export interface ClientConfigurationFileError {
@ -74,9 +89,9 @@ export const readClientConfigurationFiles = async (
client: SetupClient,
resources?: ArtifactResourceScope,
): Promise<
Result<readonly ClientConfigurationDocument[], ClientConfigurationFileError>
Result<readonly ClientConfigurationSource[], ClientConfigurationFileError>
> => {
const documents: ClientConfigurationDocument[] = [];
const documents: ClientConfigurationSource[] = [];
for (const path of client.configPaths ?? [client.configPath]) {
const document = await readConfigurationFile(
path,
@ -85,16 +100,21 @@ export const readClientConfigurationFiles = async (
);
if (!document.ok) return document;
if (document.value !== undefined) {
const environment = clientRegistrationEnvironment(document.value);
const environment = clientRegistrationEnvironment(
document.value,
{},
"fragment",
);
if (!environment.ok)
return err({
kind: "malformed",
path,
detail: `Configuration ${path} has an invalid REA server environment: ${environment.error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}. Repair it before rerunning setup.`,
});
documents.push(document.value);
return err(invalidEnvironment(path, environment.error));
documents.push({ ...document.value, sourcePath: path });
}
}
const environment = clientRegistrationEnvironment(
effectiveClientConfiguration(documents),
);
if (!environment.ok)
return err(invalidEnvironment(client.configPath, environment.error));
return ok(documents);
};
@ -117,8 +137,24 @@ const isObject = (value: unknown): value is Record<string, unknown> =>
/** Project a client's effective user registration without modifying its source files. */
export const effectiveClientConfiguration = (
documents: readonly ClientConfigurationDocument[],
sources: readonly ClientConfigurationSource[],
target?: {
readonly client: Pick<SetupClient, "configPath" | "configPaths">;
readonly document: ClientConfigurationDocument | undefined;
},
): ClientConfigurationDocument | undefined => {
const documents =
target === undefined
? sources
: (target.client.configPaths ?? [target.client.configPath]).flatMap(
(path) => {
const document =
path === target.client.configPath
? target.document
: sources.find(({ sourcePath }) => sourcePath === path);
return document === undefined ? [] : [document];
},
);
const last = documents.at(-1);
if (last === undefined) return undefined;
if (documents.length === 1) return last;

View File

@ -1,6 +1,9 @@
import { z } from "zod";
import { parseEnvironment } from "../config/environment.js";
import {
parseEnvironment,
parseEnvironmentValues,
} from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@ -15,9 +18,10 @@ import { dshDocument } from "./DshCordisPatch.js";
export const clientRegistrationEnvironment = (
parsed: ClientConfigurationDocument | undefined,
overrides: Readonly<Record<string, string>> = {},
validation: "fragment" | "effective" = "effective",
): Result<Readonly<Record<string, string>>, ConfigurationError> => {
if (parsed?.dialect === "dsh")
return dshDocument(parsed.document).environment(overrides);
return dshDocument(parsed.document).environment(overrides, validation);
const key =
parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2"
? "environment"
@ -46,7 +50,10 @@ export const clientRegistrationEnvironment = (
// Validate known settings with the runtime's parser, but retain the original
// string overrides, including unknown client-specific keys and no defaults.
const environment = { ...existing.data, ...overrides };
const validated = parseEnvironment(environment);
const validated =
validation === "fragment"
? parseEnvironmentValues(environment)
: parseEnvironment(environment);
if (!validated.ok) return validated;
return ok(
Object.fromEntries(

View File

@ -76,18 +76,24 @@ const unwrapCmdCommand = (command: readonly string[]): readonly string[] => {
return [];
};
const packageRunnerReference = (
/** Preserve the package argument from a supported package-runner command. */
export const clientRegistrationPackageReference = (
command: readonly string[],
): string | undefined => {
const arguments_ = unwrapCmdCommand(command);
if (
command.length === 4 &&
command[0] === "npx" &&
command[1] === "-y" &&
command[3] === "mcp"
arguments_.length === 4 &&
arguments_[0] === "npx" &&
arguments_[1] === "-y" &&
arguments_[3] === "mcp"
)
return command[2];
if (command.length === 3 && command[0] === "bunx" && command[2] === "mcp")
return command[1];
return arguments_[2];
if (
arguments_.length === 3 &&
arguments_[0] === "bunx" &&
arguments_[2] === "mcp"
)
return arguments_[1];
return undefined;
};
@ -129,7 +135,7 @@ export const isOwnedClientRegistrationCommand = (
return true;
}
const packageReference = packageRunnerReference(unwrapCmdCommand(command));
const packageReference = clientRegistrationPackageReference(command);
return (
packageReference !== undefined && isOwnedPackageReference(packageReference)
);

View File

@ -13,10 +13,12 @@ import { access } from "node:fs/promises";
import { resolve } from "node:path";
import { z } from "zod";
import { valid } from "semver";
import { PRODUCT_IDENTITY } from "../identity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import {
clientRegistrationPackageReference,
isOwnedClientRegistrationCommand,
packageRunnerRegistrationCommand,
} from "./ClientRegistrationIdentity.js";
@ -33,6 +35,8 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
interface ClientRegistrationStatusBase {
readonly client: string;
readonly config_path: string;
/** Exact package reference requested by npx/bunx, when registration uses one. */
readonly package_reference?: string;
}
type RegistrationCommand = readonly [string, ...string[]];
@ -201,12 +205,18 @@ export const readClientRegistrationInspections = async (
registration.command,
...registration.args,
];
const packageReference = clientRegistrationPackageReference(command);
const activation = registrationActivation(registration, client, parsed);
const packageVersion = exactRegisteredPackageVersion(packageReference);
const packageVersionDrift =
packageVersion !== undefined &&
packageVersion !== PRODUCT_IDENTITY.packageVersion;
record(
configuredStatus(
client,
command,
activation === "enabled" &&
!packageVersionDrift &&
registrationAligned(
registration,
client,
@ -217,7 +227,11 @@ export const readClientRegistrationInspections = async (
policyBlock === undefined
? "aligned"
: "stale",
policyBlock,
policyBlock ??
(packageVersionDrift
? packageVersionRemediation(packageReference ?? "", client)
: undefined),
packageReference,
),
activation,
);
@ -378,12 +392,16 @@ const configuredStatus = (
command: RegistrationCommand,
state: "aligned" | "stale",
policyRemediation?: string,
packageReference?: string,
): ClientRegistrationStatus =>
state === "aligned"
? {
client: client.name,
config_path: client.configPath,
command,
...(packageReference === undefined
? {}
: { package_reference: packageReference }),
state,
remediation: null,
}
@ -391,10 +409,29 @@ const configuredStatus = (
client: client.name,
config_path: client.configPath,
command,
...(packageReference === undefined
? {}
: { package_reference: packageReference }),
state,
remediation: policyRemediation ?? remediation(client),
};
const exactRegisteredPackageVersion = (
packageReference: string | undefined,
): string | undefined => {
if (packageReference === undefined) return undefined;
const prefix = `${PRODUCT_IDENTITY.packageName}@`;
if (!packageReference.startsWith(prefix)) return undefined;
const version = packageReference.slice(prefix.length);
return valid(version) === version ? version : undefined;
};
const packageVersionRemediation = (
packageReference: string,
client: SetupClient,
): string =>
`This registration requests ${packageReference}, while the current REA CLI package is ${PRODUCT_IDENTITY.packageVersion}. REA does not resolve package-runner requests during doctor; update the registration with rea setup --client ${client.name}, then restart the client.`;
const unavailableStatus = (
client: SetupClient,
state: "missing" | "invalid",

View File

@ -588,6 +588,9 @@ describe("doctor Linux and Hopper discovery", () => {
result.checks.find(({ name }) => name === "hopper-version")?.remediation,
).toContain("Unset or update HOPPER_LAUNCHER_PATH");
});
});
describe("doctor installed Hopper discovery", () => {
it("detects a Homebrew cask installed outside /Applications", async () => {
const path =
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
@ -612,7 +615,9 @@ describe("doctor Linux and Hopper discovery", () => {
);
expect(result.hopperPath).toBe(path);
});
});
describe("doctor target admission on Linux", () => {
it("uses shared app target validation when a target is supplied", async () => {
const result = await runDoctor(
"/Applications/Notes.app",
@ -652,6 +657,30 @@ describe("doctor Linux and Hopper discovery", () => {
});
});
describe("doctor standard Linux Hopper paths", () => {
it.each(["/opt/hopper-v4/bin/hopper", "/usr/bin/hopper"])(
"detects Hopper at the standard path %s",
async (path) => {
const result = await runDoctor(
undefined,
host({
platform: "linux",
macosVersion: () => Promise.resolve(undefined),
linuxDistribution: () =>
Promise.resolve({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
}),
executable: (candidate) => Promise.resolve(candidate === path),
}),
);
expect(result.hopperPath).toBe(path);
},
);
});
describe("doctor target admission diagnostics", () => {
it("preserves route-specific target diagnostics from the host", async () => {
const targetCheck = {

View File

@ -48,6 +48,7 @@ export interface DoctorCheck {
readonly classification:
| "healthy"
| "missing_dependency"
| "runtime_failure"
| "unsupported_host"
| "unsupported_target"
| "missing_analysis_engine"
@ -65,6 +66,7 @@ export interface DoctorProviderCheck {
readonly detail: string;
readonly remediation: string | null;
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
readonly details?: Readonly<Record<string, JsonValue>>;
}
/** Read-only provider inspection supplied by an outer composition adapter. */

View File

@ -2,7 +2,11 @@ import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js";
import {
linuxHopperLauncherCandidates,
systemLinuxHopperLauncherCandidates,
supportsReaHost,
} from "./LinuxHopper.js";
import type {
DoctorCheck,
DoctorHost,
@ -11,7 +15,6 @@ import type {
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
export interface DoctorDiagnostics {
@ -148,8 +151,9 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
host.configuredHopperPath === undefined
? [
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(host.homeDirectory),
...(host.platform === "linux"
? linuxHopperLauncherCandidates(host.homeDirectory)
: [...systemLinuxHopperLauncherCandidates]),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];
@ -280,6 +284,7 @@ const providerDoctorChecks = (
...(candidate.remediation === null
? {}
: { remediation: candidate.remediation }),
...(candidate.details === undefined ? {} : { details: candidate.details }),
}));
const unsupportedHopperRemediation = (configured: boolean): string =>

View File

@ -1,2 +1,2 @@
/** Deep-analysis providers whose readiness can be selected explicitly by doctor. */
export const DOCTOR_PROVIDER_IDS = ["hopper", "ghidra", "ida"] as const;
export const DOCTOR_PROVIDER_IDS = ["hopper", "ghidra", "ida", "jadx"] as const;

View File

@ -1,5 +1,8 @@
import { isMap, isSeq, type Node, type YAMLMap } from "yaml";
import { parseEnvironment } from "../config/environment.js";
import {
parseEnvironment,
parseEnvironmentValues,
} from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
@ -181,6 +184,7 @@ export class DshCordisDocument {
/** Validate literals without treating expression source as an environment value. */
environment(
overrides: Readonly<Record<string, string>> = {},
validation: "fragment" | "effective" = "effective",
): Result<Readonly<Record<string, string>>, ConfigurationError> {
const config = this.#field("config");
if (config instanceof CordisExpression)
@ -215,8 +219,12 @@ export class DshCordisDocument {
new ConfigurationError("Cordis server environment must be a mapping"),
);
const literals: Record<string, string> = {};
const unresolvedSettings = new Set<string>();
for (const [key, value] of Object.entries(raw ?? {})) {
if (value instanceof CordisExpression) continue;
if (value instanceof CordisExpression) {
if (overrides[key] === undefined) unresolvedSettings.add(key);
continue;
}
if (typeof value !== "string")
return err(
new ConfigurationError("Invalid Cordis server environment", {
@ -232,7 +240,10 @@ export class DshCordisDocument {
literals[key] = value;
}
const environment = { ...literals, ...overrides };
const validated = parseEnvironment(environment);
const validated =
validation === "fragment"
? parseEnvironmentValues(environment)
: parseEnvironment(environment, unresolvedSettings);
return validated.ok ? ok(environment) : validated;
}

View File

@ -2,19 +2,36 @@ import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
formatCommandForDisplay,
installLinuxHopper,
linuxHopperLauncherDigestSupported,
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
LinuxHopperTemporaryCleanupOwner,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
type LinuxHopperLauncherStatus,
type LinuxPackageFamily,
type LinuxPackageInstallFailure,
type LinuxPackageInstallOutcome,
} from "./LinuxHopper.js";
describe("Linux package-manager command display", () => {
it("preserves argument boundaries for paths with spaces and quotes", () => {
expect(
formatCommandForDisplay("pkexec", [
"pacman",
"-U",
"/tmp/rea test/hopper.arch",
"apostrophe'path",
]),
).toBe('pkexec pacman -U "/tmp/rea test/hopper.arch" "apostrophe\'path"');
});
});
class RecordingLinuxHost implements LinuxHopperInstallHost {
distributionValue: LinuxDistribution | undefined = {
id: "ubuntu",
@ -25,6 +42,14 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
archive = new Uint8Array([1, 2, 3]);
archiveOk = true;
installSucceeds = true;
cleanupFails = false;
packageFailure: LinuxPackageInstallFailure = {
command: "pacman -U --noconfirm /tmp/rea test/hopper.arch",
exitCode: 1,
stdout: "",
stderr: "error: failed to prepare transaction",
outputTruncated: false,
};
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
downloads: string[] = [];
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
@ -42,15 +67,20 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
installPackage = (
family: LinuxPackageFamily,
archive: string,
): Promise<boolean> => {
): Promise<LinuxPackageInstallOutcome> => {
this.installed.push({ family, archive });
return Promise.resolve(this.installSucceeds);
const outcome: LinuxPackageInstallOutcome = this.installSucceeds
? { status: "installed" }
: { status: "failed", failure: this.packageFailure };
return Promise.resolve(outcome);
};
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
Promise.resolve(this.launcherStatusValue);
cleanup = (path: string): Promise<void> => {
this.cleaned.push(path);
return Promise.resolve();
return this.cleanupFails
? Promise.reject(new Error("temporary path is busy"))
: Promise.resolve();
};
}
@ -183,7 +213,9 @@ describe("Linux Hopper installation", () => {
);
},
);
});
describe("Linux Hopper package installation", () => {
it.each(["deb", "rpm", "arch"] as const)(
"downloads, verifies, installs, reads back, and cleans a %s package",
async (family) => {
@ -204,7 +236,9 @@ describe("Linux Hopper installation", () => {
expect(host.cleaned).toEqual(["/tmp/rea test"]);
},
);
});
describe("Linux Hopper installation failure classification", () => {
it.each([
["unsupported host", "unsupported_host"],
["package request failure", "download"],
@ -232,11 +266,65 @@ describe("Linux Hopper installation", () => {
expect(await installFixture(host)).toEqual({
status: "failed",
reason,
...(scenario === "package manager failure"
? { packageFailure: host.packageFailure }
: {}),
});
if (host.installed.length > 0)
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
});
describe("Linux Hopper cleanup and failure details", () => {
it("preserves the failing package-manager command and its output", async () => {
const host = new RecordingLinuxHost();
host.installSucceeds = false;
host.packageFailure = {
command: "pkexec pacman -U --noconfirm /tmp/rea test/hopper.arch",
exitCode: 1,
stdout: "loading packages...",
stderr:
"\u001b[31merror: unresolvable package conflicts detected\u001b[0m",
outputTruncated: false,
};
expect(await installFixture(host)).toEqual({
status: "failed",
reason: "authorization_or_package_manager",
packageFailure: host.packageFailure,
});
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
it("preserves a successful install result when temporary cleanup fails", async () => {
const host = new RecordingLinuxHost();
host.cleanupFails = true;
expect(await installFixture(host)).toEqual({
status: "installed",
launcherPath: "/opt/hopper/bin/Hopper",
cleanupFailure: {
path: "/tmp/rea test",
reason: "temporary path is busy",
},
});
});
it("preserves an install failure when temporary cleanup also fails", async () => {
const host = new RecordingLinuxHost();
host.installSucceeds = false;
host.cleanupFails = true;
expect(await installFixture(host)).toMatchObject({
status: "failed",
reason: "authorization_or_package_manager",
packageFailure: host.packageFailure,
cleanupFailure: {
path: "/tmp/rea test",
reason: "temporary path is busy",
},
});
});
});
describe("Linux Hopper cancellation", () => {
it("cancels before network or mutation", async () => {
const host = new RecordingLinuxHost();
const controller = new AbortController();
@ -252,6 +340,27 @@ describe("Linux Hopper installation", () => {
});
});
describe("Linux Hopper temporary cleanup ownership", () => {
it("retains a failed cleanup for a later successful retry", async () => {
const owner = new LinuxHopperTemporaryCleanupOwner();
owner.retain({ path: "/tmp/rea-hopper", reason: "busy" });
let attempts = 0;
expect(
await owner.retry(async () => {
attempts += 1;
throw new Error("still busy");
}),
).toContain("/tmp/rea-hopper");
expect(
await owner.retry(async () => {
attempts += 1;
}),
).toBeUndefined();
expect(attempts).toBe(2);
});
});
function releasesFor(
bytes: Uint8Array,
): Record<

View File

@ -1,18 +1,18 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { createReadStream } from "node:fs";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
} from "./HopperDownload.js";
const execFileAsync = promisify(execFile);
/** Package-manager output retained per stream without stopping the command. */
const PACKAGE_MANAGER_OUTPUT_BYTES = 64 * 1024;
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
const SUPPORTED_HOPPER_SHA256 = new Set([
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
@ -67,7 +67,7 @@ export const linuxHopperInstallDisclosure = (
family,
`<verified-hopper.${family}>`,
isRoot,
).map(({ executable, args }) => `${executable} ${args.join(" ")}`),
).map(({ executable, args }) => formatCommandForDisplay(executable, args)),
};
};
@ -111,6 +111,25 @@ export interface LinuxHopperDownload {
readonly bytes: Uint8Array;
}
/** One failed package-manager command, with the output that explains it. */
export interface LinuxPackageInstallFailure {
/** The executed executable and arguments, joined for display. */
readonly command: string;
readonly exitCode: string | number | null;
readonly stdout: string;
readonly stderr: string;
/** Whether the command produced more output than was retained. */
readonly outputTruncated: boolean;
}
/** Whether the package-manager step installed the verified package. */
export type LinuxPackageInstallOutcome =
| { readonly status: "installed" }
| {
readonly status: "failed";
readonly failure: LinuxPackageInstallFailure;
};
/** External effects required to install the official Linux package. */
export interface LinuxHopperInstallHost {
distribution(): Promise<LinuxDistribution | undefined>;
@ -120,15 +139,20 @@ export interface LinuxHopperInstallHost {
): Promise<LinuxHopperDownload>;
createTemporaryDirectory(): Promise<string>;
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
installPackage(
family: LinuxPackageFamily,
archive: string,
): Promise<LinuxPackageInstallOutcome>;
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
cleanup(path: string): Promise<void>;
}
/** Structured outcome for every expected Linux installation failure. */
export type LinuxHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
| {
| ({ readonly status: "installed"; readonly launcherPath: string } & {
readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure;
})
| ({
readonly status: "failed";
readonly reason:
| "unsupported_host"
@ -140,7 +164,49 @@ export type LinuxHopperInstallResult =
| "runtime_dependencies"
| "unsupported_hopper_build"
| "cancelled";
};
/**
* The failing package-manager command and its output. Present only when
* the package-manager step itself failed, so setup can report what the
* package manager said instead of assuming an authorization problem.
*/
readonly packageFailure?: LinuxPackageInstallFailure;
} & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure });
/** Failed removal of an owned temporary install directory. */
export interface LinuxHopperTemporaryCleanupFailure {
readonly path: string;
readonly reason: string;
}
/** Own failed temporary cleanup until a later retry succeeds. */
export class LinuxHopperTemporaryCleanupOwner {
private readonly pending = new Map<
string,
LinuxHopperTemporaryCleanupFailure
>();
retain(failure: LinuxHopperTemporaryCleanupFailure): void {
this.pending.set(failure.path, failure);
}
async retry(
cleanup: (path: string) => Promise<void>,
): Promise<string | undefined> {
const failures: string[] = [];
for (const path of this.pending.keys()) {
try {
await cleanup(path);
this.pending.delete(path);
} catch (cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
failures.push(
`Linux Hopper temporary cleanup failed for ${path}: ${reason}`,
);
}
}
return failures.length === 0 ? undefined : failures.join("; ");
}
}
export type LinuxHopperLauncherStatus =
| "ready"
@ -215,33 +281,57 @@ export const installLinuxHopper = async (
if (distribution?.hopperSetupSupported !== true)
return { status: "failed", reason: "unsupported_host" };
let temporary: string | undefined;
let result: LinuxHopperInstallResult;
try {
const release = (options.releases ?? SUPPORTED_RELEASES)[
distribution.packageFamily
];
const archiveDownload = await host.download(release.filename, options);
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
if (!packageIntegrityMatches(archiveDownload.bytes, release))
return { status: "failed", reason: "integrity" };
temporary = await host.createTemporaryDirectory();
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
await host.writeArchive(archive, archiveDownload.bytes);
if (!(await host.installPackage(distribution.packageFamily, archive)))
return { status: "failed", reason: "authorization_or_package_manager" };
const launcherPath = "/opt/hopper/bin/Hopper";
const launcherStatus = await host.launcherStatus(launcherPath);
if (launcherStatus !== "ready")
return {
status: "failed",
reason:
launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
};
return { status: "installed", launcherPath };
result = await (async (): Promise<LinuxHopperInstallResult> => {
const release = (options.releases ?? SUPPORTED_RELEASES)[
distribution.packageFamily
];
const archiveDownload = await host.download(release.filename, options);
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
if (!packageIntegrityMatches(archiveDownload.bytes, release))
return { status: "failed", reason: "integrity" };
temporary = await host.createTemporaryDirectory();
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
await host.writeArchive(archive, archiveDownload.bytes);
const install = await host.installPackage(
distribution.packageFamily,
archive,
);
if (install.status === "failed")
return {
status: "failed",
reason: "authorization_or_package_manager",
packageFailure: install.failure,
};
const launcherPath = "/opt/hopper/bin/Hopper";
const launcherStatus = await host.launcherStatus(launcherPath);
if (launcherStatus !== "ready")
return {
status: "failed",
reason:
launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
};
return { status: "installed", launcherPath };
})();
} catch (cause: unknown) {
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
return { status: "failed", reason: "download" };
} finally {
if (temporary !== undefined) await host.cleanup(temporary);
result = {
status: "failed",
reason: isAbortCause(cause) ? "cancelled" : "download",
};
}
if (temporary === undefined) return result;
try {
await host.cleanup(temporary);
return result;
} catch (cause: unknown) {
return {
...result,
cleanupFailure: {
path: temporary,
reason: cause instanceof Error ? cause.message : String(cause),
},
};
}
};
@ -249,6 +339,24 @@ export const installLinuxHopper = async (
export const linuxHopperLauncherPath = (home: string): string =>
join(home, ".local/share/rea/hopper/bin/Hopper");
/** REA's managed Linux install path, preferred when no explicit launcher is set. */
export const REA_MANAGED_LINUX_HOPPER_LAUNCHER = "/opt/hopper/bin/Hopper";
/** Other system-wide Hopper paths used by package installations. */
export const systemLinuxHopperLauncherCandidates: readonly string[] = [
"/opt/hopper-v4/bin/hopper",
"/usr/bin/hopper",
];
/** Probe REA-managed and user-local installs before arbitrary system builds. */
export const linuxHopperLauncherCandidates = (
home: string,
): readonly string[] => [
REA_MANAGED_LINUX_HOPPER_LAUNCHER,
linuxHopperLauncherPath(home),
...systemLinuxHopperLauncherCandidates,
];
/** Compose package-manager and linker checks from the selected caller environment. */
export const systemLinuxHopperInstallHost = (
environment: Readonly<NodeJS.ProcessEnv>,
@ -304,24 +412,49 @@ const installSystemPackage = async (
family: LinuxPackageFamily,
archive: string,
environment: Readonly<NodeJS.ProcessEnv>,
): Promise<boolean> => {
try {
for (const command of linuxPackageManagerCommands(
family,
archive,
process.getuid?.() === 0,
))
await execFileAsync(command.executable, command.args, {
): Promise<LinuxPackageInstallOutcome> => {
for (const command of linuxPackageManagerCommands(
family,
archive,
process.getuid?.() === 0,
)) {
const output = await execFileBoundedOutput(
command.executable,
command.args,
{
env: environment,
});
return true;
} catch (cause: unknown) {
// Package-manager failure is reported by the false return.
void cause;
return false;
limit: PACKAGE_MANAGER_OUTPUT_BYTES,
},
);
if (output.exitCode !== 0) {
return {
status: "failed",
failure: {
command: formatCommandForDisplay(command.executable, command.args),
exitCode: output.exitCode,
// The streams stay separate; their original interleaved order
// is not recoverable after capture.
stdout: output.stdout,
stderr: output.stderr,
outputTruncated: output.outputTruncated,
},
};
}
}
return { status: "installed" };
};
/** Preserve argument boundaries in a command shown to the user. */
export const formatCommandForDisplay = (
executable: string,
args: readonly string[],
): string =>
[executable, ...args]
.map((part) =>
/^[A-Za-z0-9_./:@%+=,-]+$/u.test(part) ? part : JSON.stringify(part),
)
.join(" ");
/** Select the native package manager and authorization boundary without shell evaluation. */
export const linuxPackageManagerCommands = (
family: LinuxPackageFamily,

View File

@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
import { FakeSetupHost, options } from "./Setup.fixture.js";
import { runSetup } from "./Setup.js";
import { setupRegistrationCommand } from "./SetupHost.js";
import { discoverSetupState } from "./SetupPlan.js";
import type { MsixPackageView } from "./MsixPackageStorage.js";
import type { SetupClient } from "./SupportedClients.js";
@ -191,6 +192,51 @@ describe("setup workflow", () => {
});
});
describe("configured Linux Hopper selection", () => {
it("keeps an explicitly configured launcher even when demo checks fail", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
};
host.hopper = "/opt/hopper/bin/Hopper";
host.hopperLauncherConfigured = true;
host.linuxDemoRuntimeMissing = true;
const doctor = await host.doctor();
const discovery = await discoverSetupState({
host,
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
forceHopperInstall: false,
proposeHopper: true,
hopperLauncherConfigured: true,
initialDoctor: doctor,
});
expect(discovery.installHopper).toBe(false);
});
it("still offers repair for an unconfigured REA managed install", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
};
host.hopper = "/opt/hopper/bin/Hopper";
host.unsupportedHopperVersion = true;
const discovery = await discoverSetupState({
host,
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
forceHopperInstall: false,
proposeHopper: true,
initialDoctor: await host.doctor(),
});
expect(discovery.installHopper).toBe(true);
});
});
describe("setup workflow action selection", () => {
it("executes only actions selected by the interactive adapter", async () => {
const host = new FakeSetupHost();

View File

@ -52,6 +52,7 @@ export class FakeSetupHost implements SetupHost {
scopedDoctorHealthy: boolean | undefined;
linuxDemoRuntimeMissing = false;
unsupportedHopperVersion = false;
hopperLauncherConfigured = false;
constructor(platform: NodeJS.Platform = "darwin") {
this.platform = platform;
@ -64,6 +65,9 @@ export class FakeSetupHost implements SetupHost {
Promise.resolve(this.distribution);
initialSetupState = async (scope?: DoctorScope) => ({
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
...(this.hopperLauncherConfigured
? { hopperLauncherConfigured: true }
: {}),
providerEnvironment: {
...(this.hopper === undefined
? {}

View File

@ -107,6 +107,7 @@ const runSetupWorkflow = async (
host,
providerEnvironment,
forceHopperInstall: options.installHopper,
hopperLauncherConfigured: initialState.hopperLauncherConfigured === true,
proposeHopper:
options.proposeHopper ?? (confirm !== undefined && !options.structured),
initialDoctor: initialState.doctor,

View File

@ -1,7 +1,9 @@
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import {
readClientConfigurationFiles,
effectiveClientConfiguration,
readConfigurationText,
type ClientConfigurationSource,
} from "./ClientConfigurationFiles.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@ -18,9 +20,7 @@ import {
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
import { constants as fsConstants } from "node:fs";
import { createHash } from "node:crypto";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import writeFileAtomic from "write-file-atomic";
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
@ -31,10 +31,13 @@ import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
import {
readRegularFile,
readRegularFileText,
RegularFileCleanupFailure,
} from "./RegularFileRead.js";
import {
preserveRecoveryBackup,
selectCurrentRecoveryBackup,
} from "./SetupRecoveryBackup.js";
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
import type {
ClientConfigurationInspection,
@ -90,7 +93,9 @@ const configureClientDocument = async (
throwCleanupFailure(files.error);
return { status: "failed", reason: "readback" };
}
const existing = files.value.at(-1);
const existing = files.value.find(
({ sourcePath }) => sourcePath === client.configPath,
);
const editabilityError =
existing?.dialect === "dsh"
? dshDocument(existing.document).editabilityError
@ -104,7 +109,8 @@ const configureClientDocument = async (
client,
environment,
command,
files.value.at(-1),
existing,
files.value,
),
);
if (!policy.ok) {
@ -137,7 +143,13 @@ const configureClientDocument = async (
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(client, environment, command, parsed),
clientConfigurationDesired(
client,
environment,
command,
parsed,
files.value,
),
);
if (!policy.ok) {
throwCleanupFailure(policy.error);
@ -156,10 +168,16 @@ const configureClientDocument = async (
throwCleanupFailure(fallback.error);
return { status: "failed", reason: "readback" };
}
const desired = clientConfigurationDesired(client, environment, command, {
...parsed,
servers: { ...fallback.value, ...parsed.servers },
});
const desired = clientConfigurationDesired(
client,
environment,
command,
{
...parsed,
servers: { ...fallback.value, ...parsed.servers },
},
files.value,
);
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
return persistClientConfiguration({
client,
@ -205,7 +223,10 @@ const persistClientConfiguration = async ({
const selection = await selectCurrentRecoveryBackup(backupPath, original);
if (!selection.ok) return { status: "failed", reason: "backup" };
if (
!(await preserveConfigBackup(transactionPath, selection.value.backupPath))
!(await preserveRecoveryBackup(
transactionPath,
selection.value.backupPath,
))
)
return { status: "failed", reason: "backup" };
const verified = await selectCurrentRecoveryBackup(backupPath, original);
@ -338,7 +359,13 @@ export const clientConfigurationAligned = async (
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(client, providerEnvironment, command, parsed),
clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed,
files.value,
),
);
if (!policy.ok) {
throwCleanupFailure(policy.error);
@ -353,6 +380,7 @@ export const clientConfigurationAligned = async (
providerEnvironment,
command,
parsed,
files.value,
),
)
);
@ -400,7 +428,9 @@ const inspectClientDocument = async (
remediation: files.error.detail,
};
}
const existing = files.value.at(-1);
const existing = files.value.find(
({ sourcePath }) => sourcePath === client.configPath,
);
const editabilityError =
existing?.dialect === "dsh"
? dshDocument(existing.document).editabilityError
@ -413,7 +443,13 @@ const inspectClientDocument = async (
const policy = await readClientPolicyBlock(
client,
undefined,
clientConfigurationDesired(client, providerEnvironment, command, existing),
clientConfigurationDesired(
client,
providerEnvironment,
command,
existing,
files.value,
),
);
if (!policy.ok) {
throwCleanupFailure(policy.error);
@ -456,7 +492,13 @@ const inspectClientDocument = async (
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(client, providerEnvironment, command, parsed),
clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed,
files.value,
),
);
if (!policy.ok) {
throwCleanupFailure(policy.error);
@ -472,6 +514,7 @@ const inspectClientDocument = async (
providerEnvironment,
command,
parsed,
files.value,
);
if (registrationCurrent(parsed, desired))
return { status: "already_current" };
@ -554,76 +597,6 @@ const readZcodeFallbackServers = async (
}
};
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";
}
};
interface RecoveryBackupSelection {
readonly backupPath: string;
readonly present: boolean;
}
interface RecoveryBackupSelectionFailure {
readonly kind: "unreadable" | "occupied";
readonly path: string;
readonly detail: string;
}
const selectCurrentRecoveryBackup = async (
firstBackupPath: string,
original: string,
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
const expected = Buffer.from(original, "utf8");
let firstBackup: Buffer | undefined;
try {
firstBackup = await readRegularFile(firstBackupPath, {
symlinks: "reject",
});
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: firstBackupPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (firstBackup === undefined)
return ok({ backupPath: firstBackupPath, present: false });
if (firstBackup.equals(expected))
return ok({ backupPath: firstBackupPath, present: true });
const digest = createHash("sha256").update(original, "utf8").digest("hex");
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
let recovery: Buffer | undefined;
try {
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: recoveryPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (recovery !== undefined && !recovery.equals(expected))
return err({
kind: "occupied",
path: recoveryPath,
detail: `The recovery path already contains different data: ${recoveryPath}`,
});
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
};
const restoreConfig = async (
path: string,
original: string | undefined,
@ -678,8 +651,18 @@ const clientConfigurationDesired = (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
parsed?: ClientConfigurationDocument,
parsed: ClientConfigurationDocument | undefined,
sources: readonly ClientConfigurationSource[],
) => {
const effective = effectiveClientConfiguration(sources, {
client,
document: parsed,
});
const validated = clientRegistrationEnvironment(
effective,
providerEnvironment,
);
if (!validated.ok) throw validated.error;
const format = parsed?.dialect ?? client.format ?? "json";
const launcher =
command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command;
@ -697,6 +680,7 @@ const clientConfigurationDesired = (
const environment = clientRegistrationEnvironment(
parsed,
providerEnvironment,
"fragment",
);
if (!environment.ok) throw environment.error;
const registration = clientRegistrationEntry(

View File

@ -17,6 +17,7 @@ import {
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
import {
installLinuxHopper,
LinuxHopperTemporaryCleanupOwner,
readLinuxDistribution,
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
@ -142,6 +143,8 @@ export const systemSetupHost = (
? systemMacHopperInstallHost(homeDirectory, environment)
: undefined;
const resources = new ArtifactResourceScope();
const linuxHopperHost = systemLinuxHopperInstallHost(environment);
const pendingLinuxHopperCleanup = new LinuxHopperTemporaryCleanupOwner();
return {
platform,
homeDirectory,
@ -151,7 +154,10 @@ export const systemSetupHost = (
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution: readLinuxDistribution,
close: () => closeSetupResources(resources, macHopperHost, doctorHost),
close: () =>
closeSetupResources(resources, macHopperHost, doctorHost, () =>
pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup),
),
initialSetupState: async (
scope?: DoctorScope,
): Promise<SetupInitialState> => {
@ -160,6 +166,11 @@ export const systemSetupHost = (
...(diagnosis.hopperPath === undefined
? {}
: { hopperPath: diagnosis.hopperPath }),
...(typeof environment.HOPPER_LAUNCHER_PATH === "string" &&
environment.HOPPER_LAUNCHER_PATH.trim() !== "" &&
diagnosis.hopperPath === environment.HOPPER_LAUNCHER_PATH
? { hopperLauncherConfigured: true }
: {}),
providerEnvironment: {
...providerRegistrationEnvironment(
diagnosis.providerInspections ?? [],
@ -173,12 +184,12 @@ export const systemSetupHost = (
},
installHopper: async (replaceExisting) => {
if (platform === "linux") {
const result = await installLinuxHopper(
systemLinuxHopperInstallHost(environment),
);
const result = await installLinuxHopper(linuxHopperHost);
if (result.cleanupFailure !== undefined)
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
return result.status === "installed"
? result
: setupInstallFailure(result.reason);
? { status: "installed", launcherPath: result.launcherPath }
: setupInstallFailure(result.reason, result.packageFailure);
}
if (macHopperHost === undefined)
return setupInstallFailure("unsupported_host");
@ -264,12 +275,14 @@ const closeSetupResources = async (
resources: ArtifactResourceScope,
macHopperHost: ReturnType<typeof systemMacHopperInstallHost> | undefined,
doctorHost: DoctorHost,
retryLinuxHopperCleanup: () => Promise<string | undefined>,
): Promise<string | undefined> => {
const [resourceCleanup, hopperCleanup, doctorCleanup] =
const [resourceCleanup, hopperCleanup, doctorCleanup, linuxCleanup] =
await Promise.allSettled([
resources.close(),
Promise.resolve().then(() => macHopperHost?.close()),
Promise.resolve().then(() => doctorHost.close?.()),
retryLinuxHopperCleanup(),
]);
const failures: string[] = [];
if (hopperCleanup.status === "rejected")
@ -284,6 +297,11 @@ const closeSetupResources = async (
);
else if (doctorCleanup.value !== undefined)
failures.push(doctorCleanup.value);
if (linuxCleanup.status === "rejected")
failures.push(
`Linux Hopper cleanup retry failed: ${errorMessage(linuxCleanup.reason)}`,
);
else if (linuxCleanup.value !== undefined) failures.push(linuxCleanup.value);
if (resourceCleanup.status === "rejected")
failures.push(errorMessage(resourceCleanup.reason));
return failures.length === 0 ? undefined : failures.join("; ");

View File

@ -21,3 +21,51 @@ describe("macOS Hopper setup failure projection", () => {
});
});
});
describe("Linux package-manager setup failure projection", () => {
it("quotes the failing command and a sanitized bounded diagnostic", () => {
const result = setupInstallFailure("authorization_or_package_manager", {
command: 'pkexec pacman -U "/tmp/hopper package.pkg.tar.xz"',
exitCode: 1,
stdout: "",
stderr: "\u001b[31merror:\u001b[0m package conflict\nrefused",
outputTruncated: false,
});
expect(result).toMatchObject({
status: "failed",
code: "authorization_or_package_manager_failed",
});
if (result.status !== "failed") throw new Error("expected failure");
expect(result.remediation).toContain(
'pkexec pacman -U "/tmp/hopper package.pkg.tar.xz"; exit 1',
);
expect(result.remediation).toContain("error: package conflict refused");
expect(result.remediation).not.toContain("\u001b");
expect(result.remediation).not.toContain("\n");
});
it("preserves the established remediation if no package output exists", () => {
expect(setupInstallFailure("authorization_or_package_manager")).toEqual({
status: "failed",
code: "authorization_or_package_manager_failed",
remediation:
"Allow the system package-manager operation or install the planned Hopper package and Linux runtime dependencies, then rerun rea setup.",
});
});
it("keeps truncated package output within its excerpt budget", () => {
const result = setupInstallFailure("authorization_or_package_manager", {
command: "pacman -U hopper.pkg.tar.xz",
exitCode: 1,
stdout: "",
stderr: `${"warning ".repeat(100)}final package conflict`,
outputTruncated: true,
});
if (result.status !== "failed") throw new Error("expected failure");
const excerpt = result.remediation
.split("its output: ")[1]
?.split(" Resolve")[0];
expect(excerpt).toContain("output truncated");
expect(excerpt?.length).toBeLessThanOrEqual(400);
});
});

View File

@ -1,4 +1,5 @@
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
import type { LinuxPackageInstallFailure } from "./LinuxHopper.js";
export type SetupFailureCode =
| "unsupported_host"
@ -45,9 +46,53 @@ export type HopperInstallFailureReason =
| "publisher_verification_failed"
| "copy";
const normalizePackageOutput = (output: string): string =>
output
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
.replace(/\s+/gu, " ")
.trim();
const packageOutputExcerpt = (
failure: LinuxPackageInstallFailure,
limit = 400,
): string => {
const output = [failure.stderr, failure.stdout]
.map(normalizePackageOutput)
.filter((text) => text !== "")
.join(" ");
const suffix = failure.outputTruncated ? " (output truncated)" : "";
const bodyLimit = limit - suffix.length;
if (output.length <= bodyLimit) return `${output}${suffix}`;
const half = Math.floor((bodyLimit - 3) / 2);
const head = output
.slice(0, half)
.replace(/\s+\S*$/u, "")
.trimEnd();
const tail = output
.slice(-half)
.replace(/^\S*\s+/u, "")
.trimStart();
return `${head} … ${tail}${suffix}`;
};
const packageManagerRemediation = (
failure: LinuxPackageInstallFailure | undefined,
): string => {
if (failure === undefined)
return "Allow the system package-manager operation or install the planned Hopper package and Linux runtime dependencies, then rerun rea setup.";
const status =
failure.exitCode === null ? "no exit status" : `exit ${failure.exitCode}`;
const excerpt = packageOutputExcerpt(failure);
const observed =
excerpt === "" ? "no output was captured." : `its output: ${excerpt}`;
return `The system package-manager command failed (${failure.command}; ${status}); ${observed} Resolve the reported problem, grant the authorization the command needs, or install the planned Hopper package and Linux runtime dependencies manually, then rerun rea setup.`;
};
/** Map provider installer failures to stable setup codes and safe recovery. */
export const setupInstallFailure = (
reason: HopperInstallFailureReason,
packageFailure?: LinuxPackageInstallFailure,
): SetupHopperInstallResult => {
switch (reason) {
case "unsupported_host":
@ -67,8 +112,7 @@ export const setupInstallFailure = (
return {
status: "failed",
code: "authorization_or_package_manager_failed",
remediation:
"Allow the system package-manager operation or install the planned Hopper package and Linux runtime dependencies, then rerun rea setup.",
remediation: packageManagerRemediation(packageFailure),
};
case "launcher_missing":
return {

View File

@ -39,16 +39,19 @@ export const discoverSetupState = async (input: {
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly forceHopperInstall: boolean;
readonly hopperLauncherConfigured?: boolean;
readonly proposeHopper: boolean;
readonly initialDoctor: DoctorReport;
}): Promise<SetupDiscovery> => {
const { initialDoctor } = input;
const linuxHopperRepairNeeded = initialDoctor.checks.some(
({ name, ok, detail }) =>
!ok &&
(name === "hopper-demo-runtime" ||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
);
const linuxHopperRepairNeeded =
input.hopperLauncherConfigured !== true &&
initialDoctor.checks.some(
({ name, ok, detail }) =>
!ok &&
(name === "hopper-demo-runtime" ||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
);
const linuxDistribution =
input.host.platform === "linux"
? await input.host.linuxDistribution()

View File

@ -0,0 +1,88 @@
import { createHash } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { copyFile } from "node:fs/promises";
import { err, ok, type Result } from "../domain/result.js";
import {
readRegularFile,
RegularFileCleanupFailure,
} from "./RegularFileRead.js";
export interface RecoveryBackupSelection {
readonly backupPath: string;
readonly present: boolean;
}
export interface RecoveryBackupSelectionFailure {
readonly kind: "unreadable" | "occupied";
readonly path: string;
readonly detail: string;
}
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
const errorDetail = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/** Select a persistent snapshot only when its bytes match this transaction's original. */
export const selectCurrentRecoveryBackup = async (
firstBackupPath: string,
original: string | Buffer,
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
const expected = Buffer.isBuffer(original)
? original
: Buffer.from(original, "utf8");
let firstBackup: Buffer | undefined;
try {
firstBackup = await readRegularFile(firstBackupPath, {
symlinks: "reject",
});
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: firstBackupPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (firstBackup === undefined)
return ok({ backupPath: firstBackupPath, present: false });
if (firstBackup.equals(expected))
return ok({ backupPath: firstBackupPath, present: true });
const digest = createHash("sha256").update(expected).digest("hex");
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
let recovery: Buffer | undefined;
try {
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: recoveryPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (recovery !== undefined && !recovery.equals(expected))
return err({
kind: "occupied",
path: recoveryPath,
detail: `The recovery path already contains different data: ${recoveryPath}`,
});
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
};
/** Preserve a snapshot without replacing an earlier recovery file. */
export const preserveRecoveryBackup = 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";
}
};

View File

@ -17,6 +17,7 @@ import {
withRegularFile,
} from "./RegularFileRead.js";
import { clientSkillDirectories } from "./SupportedClients.js";
import { selectCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
export const MANAGED_SKILL_FILES = [
"SKILL.md",
@ -36,10 +37,12 @@ export const readSkillFile = (
path: string,
resources?: ArtifactResourceScope,
): Promise<string> =>
readSkillFileState(path, resources).then(({ content }) => content);
readSkillFileState(path, resources).then(({ bytes }) =>
bytes.toString("utf8"),
);
interface SkillFileState {
readonly content: string;
readonly bytes: Buffer;
readonly mode: number;
}
@ -58,7 +61,7 @@ const readSkillFileState = async (
);
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return { content: bytes.toString("utf8"), mode: stats.mode & 0o7777 };
return { bytes, mode: stats.mode & 0o7777 };
},
{
symlinks: "reject",
@ -86,7 +89,7 @@ export interface SkillInstallFileOperations {
readonly backup: (source: string, destination: string) => Promise<void>;
readonly write: (
path: string,
content: string,
content: string | Buffer,
mode: number,
) => Promise<void>;
readonly remove: (path: string) => Promise<void>;
@ -277,7 +280,8 @@ const canonicalSkillNeedsInstallByReading = async (
const destination = join(root, relativePath);
const content = await canonicalSkillContent(relativePath);
const original = await readOptionalSkillState(destination, resources);
if (original?.content !== content) return true;
if (original?.bytes.equals(Buffer.from(content, "utf8")) !== true)
return true;
}
}
return false;
@ -327,7 +331,7 @@ const restoreSkillFiles = async (
for (const { destination, original } of [...attempted].reverse()) {
try {
if (original === undefined) await operations.remove(destination);
else await operations.write(destination, original.content, original.mode);
else await operations.write(destination, original.bytes, original.mode);
} catch (cause: unknown) {
failures.push({ path: destination, reason: causeReason(cause) });
}
@ -335,6 +339,32 @@ const restoreSkillFiles = async (
return failures;
};
const preserveCurrentSkillBackup = async (
destination: string,
original: Buffer,
operations: SkillInstallFileOperations,
): Promise<string> => {
const firstBackupPath = `${destination}.rea.backup`;
const selection = await selectCurrentRecoveryBackup(
firstBackupPath,
original,
);
if (!selection.ok) throw new Error(selection.error.detail);
if (selection.value.present) return selection.value.backupPath;
await operations.backup(destination, selection.value.backupPath);
const verified = await selectCurrentRecoveryBackup(firstBackupPath, original);
if (
!verified.ok ||
verified.value.backupPath !== selection.value.backupPath ||
!verified.value.present
)
throw new Error(
`The current file was not preserved at ${selection.value.backupPath}`,
);
return verified.value.backupPath;
};
/** Transactionally install the canonical skill bundle and optional references. */
export const installCanonicalSkill = async (
home: string,
@ -347,6 +377,7 @@ export const installCanonicalSkill = async (
let attempted: CanonicalSkillFile[] = [];
let stage: "prepare" | "backup" | "write" | "readback" = "prepare";
let failingPath = "skill bundle";
const recoveryPaths = new Map<string, string>();
try {
const canonical = await canonicalSkillFiles(
home,
@ -356,7 +387,8 @@ export const installCanonicalSkill = async (
resources,
);
const changed = canonical.filter(
({ content, original }) => original?.content !== content,
({ content, original }) =>
original?.bytes.equals(Buffer.from(content, "utf8")) !== true,
);
if (changed.length === 0) return ok("unchanged");
@ -364,8 +396,16 @@ export const installCanonicalSkill = async (
for (const { destination, original } of changed) {
failingPath = destination;
await operations.mkdir(dirname(destination));
if (original !== undefined)
await operations.backup(destination, `${destination}.rea.backup`);
if (original !== undefined) {
recoveryPaths.set(
destination,
await preserveCurrentSkillBackup(
destination,
original.bytes,
operations,
),
);
}
}
stage = "write";
for (const file of changed) {
@ -382,7 +422,11 @@ export const installCanonicalSkill = async (
stage = "readback";
for (const { destination, content } of changed) {
failingPath = destination;
if ((await readSkillFile(destination, resources)) !== content)
if (
!(await readSkillFileState(destination, resources)).bytes.equals(
Buffer.from(content, "utf8"),
)
)
throw new Error(`skill readback mismatch: ${destination}`);
}
return ok("installed");
@ -396,7 +440,9 @@ export const installCanonicalSkill = async (
reason: causeReason(cause),
rollbackFailures,
recoveryBackupPaths: attempted.flatMap(({ destination, original }) =>
original === undefined ? [] : [`${destination}.rea.backup`],
original === undefined || recoveryPaths.get(destination) === undefined
? []
: [recoveryPaths.get(destination)!],
),
cause,
}),

View File

@ -13,6 +13,8 @@ export type SetupProviderEnvironment = Readonly<Record<string, string>>;
/** Read-only host facts shared by the initial setup plan. */
export interface SetupInitialState {
readonly hopperPath?: string;
/** Whether the caller explicitly selected a Hopper launcher in its environment. */
readonly hopperLauncherConfigured?: boolean;
readonly providerEnvironment: SetupProviderEnvironment;
readonly doctor: DoctorReport;
}

View File

@ -47,6 +47,7 @@ export interface SetupClient {
| "pi"
| "kimi"
| "hermes"
| "zcode_fallback"
| "zcode"
| "dsh"
| "unsupported";

View File

@ -231,12 +231,21 @@ export const systemUninstallHost = (
const clients = supportedClients(home, platform, environment);
redirection = appDataRedirection(clients);
return Promise.resolve(
clients.flatMap((client) =>
uninstallConfigPaths(client).map((configPath) => ({
clients.flatMap((client) => [
...uninstallConfigPaths(client).map((configPath) => ({
...client,
configPath,
})),
),
...(client.zcodeFallbackPath === undefined
? []
: [
{
...client,
configPath: client.zcodeFallbackPath,
format: "zcode_fallback" as const,
},
]),
]),
);
},
inspectClient: async (client) => {

56
src/doctorRuntime.test.ts Normal file
View File

@ -0,0 +1,56 @@
import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import { createSystemDoctorHost } from "./doctorRuntime.js";
import { DOCTOR_PROVIDER_IDS } from "./application/DoctorProviderIds.js";
import { providerRegistrationEnvironment } from "./application/SetupRegistrationEnvironment.js";
import { createTestTempDirectory } from "../tests/fixtures/temporaryDirectory.js";
import { writeJadxJarInventory } from "../tests/fixtures/android/jadxJar.js";
const it = test.skipIf(process.platform === "win32");
it("exposes jadx through the doctor provider selector", () => {
expect(DOCTOR_PROVIDER_IDS).toContain("jadx");
});
it("carries a validated REA_JADX_MCP_JAR into the provider registration environment", async () => {
const root = await createTestTempDirectory("rea-jadx-registration-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
const host = createSystemDoctorHost({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
const inspections = await host.providerInspections?.();
expect(inspections?.map(({ id }) => id)).toContain("jadx");
const environment = providerRegistrationEnvironment(inspections ?? []);
expect(environment).toMatchObject({
REA_JADX_MCP_JAR: await realpath(jar),
});
});
it("omits REA_JADX_MCP_JAR from the provider registration environment when it is not configured", async () => {
const host = createSystemDoctorHost({});
const inspections = await host.providerInspections?.();
const environment = providerRegistrationEnvironment(inspections ?? []);
expect(environment).not.toHaveProperty("REA_JADX_MCP_JAR");
});
const createFakeJdk = async (root: string): Promise<string> => {
const javaHome = join(root, "fake-jdk");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
await writeFile(
java,
"#!/bin/sh\nprintf 'java.base@21\\njdk.compiler@21\\n'\n",
);
await chmod(java, 0o700);
return javaHome;
};

View File

@ -4,6 +4,7 @@ import {
type DoctorHost,
} from "./application/Doctor.js";
import { fileURLToPath } from "node:url";
import { inspectJadxProvider } from "./android/JadxDoctor.js";
import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js";
import { inspectIdaRegistration } from "./ida/IdaDoctor.js";
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
@ -17,6 +18,7 @@ export const createSystemDoctorHost = (
providerInspections: async () => [
await inspectSystemGhidraProvider(environment),
inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG),
await inspectJadxProvider(environment),
],
linuxDemoRuntimeCheck: () => inspectLinuxPrivateDisplay(environment),
});

View File

@ -0,0 +1,235 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { expect } from "vitest";
import { stringify } from "yaml";
import {
effectiveClientServer,
parseClientConfiguration,
clientRegistrationEntry,
} from "../../../src/application/ClientConfigurationDocument.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const scenarios = [
{
client: "cursor",
format: "json",
path: ".cursor/mcp.json",
setting: "HOPPER_LOADER_ARGS_JSON",
invalid: { HOPPER_LOADER_ARGS_JSON: "{" },
repaired: { HOPPER_LOADER_ARGS_JSON: '["", "--loader", "Mach-O"]' },
},
{
client: "dsh",
format: "dsh",
path: ".dsh/cordis.patch.yml",
setting: "REA_GHIDRA_COMPILER_SPEC_ID",
invalid: { REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" },
repaired: {
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
},
},
] as const;
for (const scenario of scenarios)
cliTest(
`setup and doctor reject ${scenario.client} overrides rejected by MCP startup`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-registration-runtime-");
const config = join(home, scenario.path);
await mkdir(dirname(config), { recursive: true });
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
DSH_HOME: join(home, ".dsh"),
};
const registration = (env: Readonly<Record<string, string>>) => {
const server = {
command: "stale-launcher",
args: ["mcp"],
env: { CALLER_SETTING: "keep exactly", ...env },
};
return scenario.client === "dsh"
? stringify([
{
insert: [
{
id: "rea-mcp",
name: "@deepseek-ai/dsh-mcp-client",
config: {
serverName: "rea",
transport: "stdio",
...server,
},
},
],
},
])
: JSON.stringify({ mcpServers: { rea: server } });
};
const original = registration(scenario.invalid);
await writeFile(config, original);
const startup = await cli.run({
arguments: ["mcp"],
cwd: home,
environment: { ...environment, ...scenario.invalid },
});
expect(startup.exitCode).toBe(1);
expect(startup.stderr).toContain(scenario.setting);
expect(startup.stdout).toBe("");
const run = (args: readonly string[]) =>
cli.run({ arguments: [...args, "--json"], cwd: home, environment });
const setup = await run([
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
]);
expect(setup.exitCode).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(scenario.setting),
});
const doctor = await run(["doctor", "--client", scenario.client]);
expect(doctor.exitCode).toBe(1);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: scenario.client,
state: "invalid",
remediation: expect.stringContaining(scenario.setting),
}),
]),
},
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
// Model the caller repairing the named setting before rerunning setup.
const repaired = registration(scenario.repaired);
await writeFile(config, repaired);
const configured = await run([
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
]);
expect(configured.exitCode, configured.stdout).toBe(0);
const text = await readFile(config, "utf8");
const parsed = parseClientConfiguration(text, scenario.format);
expect(effectiveClientServer(parsed, "rea")).toMatchObject({
env: { CALLER_SETTING: "keep exactly", ...scenario.repaired },
});
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(repaired);
const repeated = await run([
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
]);
expect(repeated.json).toMatchObject({
status: "ready",
appliedActions: [],
plannedActions: [],
});
expect(await readFile(config, "utf8")).toBe(text);
},
);
cliTest(
"OpenCode CLI validates dependent settings after merging JSON and JSONC",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-layered-runtime-cli-");
const directory = join(home, ".config/opencode");
await mkdir(directory, { recursive: true });
const lower = join(directory, "opencode.json");
const upper = join(directory, "opencode.jsonc");
const content = (env: Record<string, string>) =>
JSON.stringify({
mcp: {
rea: clientRegistrationEntry(
"opencode",
[resolve("scripts/rea.mjs"), "mcp"],
env,
),
},
});
const lowerContent = content({
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
});
const upperContent = content({ REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" });
await writeFile(lower, lowerContent);
await writeFile(upper, upperContent);
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
};
const run = (args: readonly string[]) =>
cli.run({ arguments: [...args, "--json"], cwd: home, environment });
const setup = await run([
"setup",
"--client",
"opencode",
"--skill=false",
"--yes",
]);
expect(setup.exitCode, setup.stdout).toBe(0);
expect(setup.json).toMatchObject({
status: "ready",
plannedActions: [],
appliedActions: [],
});
const doctor = await run(["doctor", "--client", "opencode"]);
expect(doctor.exitCode, doctor.stdout).toBe(0);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "opencode", state: "aligned" }),
]),
},
});
expect(await readFile(lower, "utf8")).toBe(lowerContent);
expect(await readFile(upper, "utf8")).toBe(upperContent);
await writeFile(lower, content({ CALLER_SETTING: "retained" }));
const missing = await run([
"setup",
"--client",
"opencode",
"--skill=false",
"--yes",
]);
expect(missing.exitCode).toBe(1);
expect(missing.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("REA_GHIDRA_COMPILER_SPEC_ID"),
});
const invalidDoctor = await run(["doctor", "--client", "opencode"]);
expect(invalidDoctor.exitCode).toBe(1);
expect(invalidDoctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "opencode", state: "invalid" }),
]),
},
});
expect(await readFile(upper, "utf8")).toBe(upperContent);
for (const path of [lower, upper])
await expect(access(`${path}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
},
);

View File

@ -380,3 +380,10 @@ const createFakeJdk = async (
await chmod(java, 0o700);
return javaHome;
};
it("tags unavailable diagnostics with the boundary failure kind", async () => {
const availability = await inspectJadxAvailability({});
expect(availability.status).toBe("unavailable");
if (availability.status !== "unavailable") throw new Error("unreachable");
expect(availability.diagnostics["failure_kind"]).toBe("jar");
});

View File

@ -0,0 +1,396 @@
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import { inspectJadxProvider } from "../../../src/android/JadxDoctor.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
const it = test.skipIf(process.platform === "win32");
it("publishes REA_JADX_MCP_JAR as registration environment when the configured JAR validates", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(inspection).toMatchObject({
id: "jadx",
configured: true,
available: true,
providerVersion: null,
registrationEnvironment: { REA_JADX_MCP_JAR: await realpath(jar) },
checks: [
{
name: "configuration",
ok: true,
code: null,
classification: "config_drift",
},
],
});
});
it("publishes JAVA_HOME alongside the JAR when the configured JAR validates", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-javahome-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(inspection.registrationEnvironment).toEqual({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
});
it("preserves the caller-selected JAR path when it is a symlink", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-symlink-");
const jar = join(root, "jadx-headless-mcp.jar");
const link = join(root, "current.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
await symlink(jar, link);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: link,
JAVA_HOME: javaHome,
});
expect(inspection.available).toBe(true);
expect(inspection.registrationEnvironment["REA_JADX_MCP_JAR"]).toBe(link);
});
it("guides Java selection when the Java runtime is missing", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-remediation-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: join(root, "missing-jdk"),
});
expect(inspection.checks[0]?.code).toBe("runtime_missing");
expect(inspection.checks[0]?.remediation).toContain("JAVA_HOME");
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
});
it("guides Java selection when JAVA_HOME is not an absolute path", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-options-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: "relative/path",
});
expect(inspection.checks[0]?.code).toBe("open_options_invalid");
expect(inspection.checks[0]?.remediation).toContain("JAVA_HOME");
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
});
it("publishes no registration environment when REA_JADX_MCP_JAR is unset", async () => {
const inspection = await inspectJadxProvider({});
expect(inspection).toMatchObject({
id: "jadx",
configured: false,
available: false,
providerVersion: null,
registrationEnvironment: {},
checks: [
{
name: "configuration",
ok: false,
code: "not_configured",
classification: "missing_analysis_engine",
},
],
});
});
it("publishes no registration environment when the configured JAR fails validation", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-invalid-");
const jar = join(root, "engine.jar");
const javaHome = await createFakeJdk(root);
await writeFile(jar, "not a Java archive");
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(inspection).toMatchObject({
id: "jadx",
configured: true,
available: false,
registrationEnvironment: {},
checks: [
{
name: "configuration",
ok: false,
code: "version_unresolved",
classification: "config_drift",
},
],
});
expect(inspection.checks[0]?.detail).toContain("REA_JADX_MCP_JAR");
});
it("classifies a missing Java runtime as a missing dependency", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-runtime-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: join(root, "missing-jdk"),
});
expect(inspection).toMatchObject({
configured: true,
available: false,
registrationEnvironment: {},
checks: [
{
name: "configuration",
ok: false,
code: "runtime_missing",
classification: "missing_dependency",
},
],
});
});
it("guides JVM option correction when REA_JADX_HEAP_MIB is invalid", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-heap-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
REA_JADX_HEAP_MIB: "not-a-number",
});
expect(inspection.checks[0]?.code).toBe("open_options_invalid");
expect(inspection.checks[0]?.remediation).toContain("REA_JADX_HEAP_MIB");
expect(inspection.checks[0]?.remediation).not.toContain("JAVA_HOME");
});
it("publishes validated JVM resource overrides in the registration environment", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-overrides-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
REA_JADX_HEAP_MIB: "4096",
REA_JADX_ACTIVE_PROCESSOR_COUNT: "8",
});
expect(inspection.available).toBe(true);
expect(inspection.registrationEnvironment).toEqual({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
REA_JADX_HEAP_MIB: "4096",
REA_JADX_ACTIVE_PROCESSOR_COUNT: "8",
});
});
it("guides JDK selection when the Java runtime is older than 17", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-oldjdk-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root, "11");
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(inspection.checks[0]?.code).toBe("unsupported_version");
expect(inspection.checks[0]?.remediation).toContain("JDK");
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
});
it("guides JAR replacement when the JAR misses bridge classes", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-classes-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeOrderedZip(jar, ["com/atxx/jhmcp/JadxSession.class"]);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(inspection.checks[0]?.code).toBe("unsupported_version");
expect(inspection.checks[0]?.remediation).toContain("REA_JADX_MCP_JAR");
});
it("remediates an unsupported host with platform guidance", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "sunos",
configurable: true,
});
try {
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: "/opt/jadx-headless-mcp.jar",
});
expect(inspection.checks[0]?.code).toBe("unsupported_host");
expect(inspection.checks[0]?.remediation).toContain("Linux");
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
}
});
it("guides Windows native package correction when process ownership is unavailable", async () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
try {
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: "/opt/jadx-headless-mcp.jar",
});
expect(inspection.checks[0]?.code).toBe("runtime_missing");
expect(inspection.checks[0]?.remediation).toContain(
"Windows x64 REA package",
);
expect(inspection.checks[0]?.remediation).not.toContain("JAVA_HOME");
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
Object.defineProperty(process, "arch", {
value: originalArch,
configurable: true,
});
}
});
it("guides Java probe correction when the module listing is unparseable", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-unparseable-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeJadxJarInventory(jar);
// Fake JDK whose module listing lacks a parseable java.base version.
const javaHome = join(root, "fake-jdk");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
await writeFile(java, "#!/bin/sh\nprintf 'jdk.compiler@21\\n'\n");
await chmod(java, 0o700);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(inspection.checks[0]?.code).toBe("version_unresolved");
expect(inspection.checks[0]?.remediation).toContain("java --list-modules");
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
});
const createFakeJdk = async (root: string, major = "21"): Promise<string> => {
const javaHome = join(root, "fake-jdk");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
await writeFile(
java,
`#!/bin/sh\nprintf 'java.base@${major}\\njdk.compiler@${major}\\n'\n`,
);
await chmod(java, 0o700);
return javaHome;
};
it("classifies missing Windows native artifacts as missing dependencies", async () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
try {
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: "/opt/jadx-headless-mcp.jar",
});
expect(inspection.checks[0]?.code).toBe("runtime_missing");
expect(inspection.checks[0]?.classification).toBe("missing_dependency");
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
Object.defineProperty(process, "arch", {
value: originalArch,
configurable: true,
});
}
});
it("classifies malformed JAVA_HOME as configuration drift", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-javahome-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeJadxJarInventory(jar);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: "relative/path",
});
expect(inspection.checks[0]?.code).toBe("open_options_invalid");
expect(inspection.checks[0]?.classification).toBe("config_drift");
});
it("preserves Java probe diagnostics in doctor output", async () => {
const root = await createTestTempDirectory("rea-jadx-doctor-probe-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeJadxJarInventory(jar);
const javaHome = join(root, "fake-jdk-unparseable");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
// Emits jdk.compiler but no parseable java.base version.
await writeFile(java, `#!/bin/sh\nprintf 'jdk.compiler@21\\n'\n`);
await chmod(java, 0o700);
const inspection = await inspectJadxProvider({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(inspection.checks[0]?.code).toBe("version_unresolved");
expect(inspection.checks[0]?.details).toMatchObject({
java_executable: expect.any(String),
});
});

View File

@ -32,6 +32,11 @@ it.each([
REA_MCP_MAX_RESPONSE_BYTES: "33554432",
REA_BROWSER_EXECUTABLE: join(root, "custom-browser"),
GHIDRA_INSTALL_DIR: join(root, "old-ghidra"),
HOPPER_LOADER_ARGS_JSON: '["", "--loader", "Mach-O"]',
REA_REFERENCE_SECRET_PATTERNS_JSON: '["*.private", "local/**"]',
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
CALLER_SETTING: "preserve exactly",
};
const original = serializeClientConfiguration(
{
@ -53,9 +58,10 @@ it.each([
).toMatchObject({ status: "configured" });
const configured = await readFile(configPath, "utf8");
const parsed = parseClientConfiguration(configured, format);
expect(effectiveClientServer(parsed, "rea")).toMatchObject({
[environmentKey]: { ...custom, ...providers },
});
expect(effectiveClientServer(parsed, "rea")).toHaveProperty(
environmentKey,
{ ...custom, ...providers },
);
expect(effectiveClientServer(parsed, "other")).toEqual(
effectiveClientServer(
parseClientConfiguration(original, format),
@ -142,6 +148,36 @@ it.each([
{ REA_MCP_MAX_RESPONSE_BYTES: "1" },
"REA_MCP_MAX_RESPONSE_BYTES",
],
[
"malformed loader JSON",
{ HOPPER_LOADER_ARGS_JSON: "{" },
"HOPPER_LOADER_ARGS_JSON",
],
[
"non-array loader JSON",
{ HOPPER_LOADER_ARGS_JSON: "{}" },
"HOPPER_LOADER_ARGS_JSON",
],
[
"NUL loader argument",
{ HOPPER_LOADER_ARGS_JSON: JSON.stringify(["a\0b"]) },
"HOPPER_LOADER_ARGS_JSON",
],
[
"malformed exclusion JSON",
{ REA_REFERENCE_SECRET_PATTERNS_JSON: "{" },
"REA_REFERENCE_SECRET_PATTERNS_JSON",
],
[
"empty exclusion pattern",
{ REA_REFERENCE_SECRET_PATTERNS_JSON: '[""]' },
"REA_REFERENCE_SECRET_PATTERNS_JSON",
],
[
"unpaired compiler specification",
{ REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" },
"REA_GHIDRA_COMPILER_SPEC_ID",
],
] as const)(
"refuses %s before changing the configuration",
async (_name, env, setting) => {

View File

@ -0,0 +1,219 @@
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
clientRegistrationEntry,
parseClientConfiguration,
effectiveClientServer,
} from "../../../src/application/ClientConfigurationDocument.js";
import {
configureClientConfiguration,
inspectClientConfiguration,
clientConfigurationAligned,
} from "../../../src/application/SetupClientConfiguration.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const command = npxRegistrationCommand(process.platform);
const language = "x86:LE:32:default";
const compiler = "borlandcpp";
const layered = async (lower: unknown, upper: unknown) => {
const home = await createTestTempDirectory("rea-layered-environment-");
const directory = join(home, ".config/opencode");
await mkdir(directory, { recursive: true });
const paths = [
join(directory, "opencode.json"),
join(directory, "opencode.jsonc"),
];
const sources = [lower, upper].map((environment) =>
JSON.stringify({
mcp: {
rea: {
...clientRegistrationEntry("opencode", command, {}),
environment,
},
},
}),
);
for (const [index, path] of paths.entries())
await writeFile(path, sources[index]!);
const client = {
name: "opencode",
format: "opencode",
configPath: paths[1]!,
configPaths: paths,
} as const;
const status = async () =>
(
await readClientRegistrationStatuses(home, "rea", {
environment: { XDG_CONFIG_HOME: join(home, ".config") },
})
).find(({ client }) => client === "opencode");
const unchanged = async () => {
for (const [index, path] of paths.entries()) {
expect(await readFile(path, "utf8")).toBe(sources[index]);
await expect(access(`${path}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
}
};
return { client, paths, sources, status, unchanged };
};
it("validates language/compiler settings in the effective OpenCode environment", async () => {
const fixture = await layered(
{ REA_GHIDRA_LANGUAGE_ID: language },
{ REA_GHIDRA_COMPILER_SPEC_ID: compiler },
);
expect(await inspectClientConfiguration(fixture.client, {}, command)).toEqual(
{ status: "already_current" },
);
expect(await clientConfigurationAligned(fixture.client, {}, command)).toBe(
true,
);
expect(await fixture.status()).toMatchObject({ state: "aligned" });
expect(
await configureClientConfiguration(fixture.client, {}, command),
).toEqual({ status: "unchanged" });
await fixture.unchanged();
});
it("rejects a companion absent from the complete layered environment", async () => {
const fixture = await layered(
{ CALLER_SETTING: "keep" },
{ REA_GHIDRA_COMPILER_SPEC_ID: compiler },
);
expect(
await inspectClientConfiguration(fixture.client, {}, command),
).toMatchObject({
status: "invalid",
remediation: expect.stringContaining("REA_GHIDRA_COMPILER_SPEC_ID"),
});
expect(await clientConfigurationAligned(fixture.client, {}, command)).toBe(
false,
);
expect(await fixture.status()).toMatchObject({
state: "invalid",
remediation: expect.stringContaining("REA_GHIDRA_COMPILER_SPEC_ID"),
});
expect(
await configureClientConfiguration(fixture.client, {}, command),
).toMatchObject({ status: "failed" });
await fixture.unchanged();
});
it("keeps higher-priority values and does not copy inherited settings into the write layer", async () => {
const fixture = await layered(
{ REA_GHIDRA_LANGUAGE_ID: language, HOPPER_LOADER_ARGS_JSON: '["lower"]' },
{ REA_GHIDRA_COMPILER_SPEC_ID: compiler, HOPPER_LOADER_ARGS_JSON: '[""]' },
);
expect(
await configureClientConfiguration(
fixture.client,
{ REA_LOG_LEVEL: "debug" },
command,
),
).toMatchObject({ status: "configured" });
expect(await readFile(fixture.paths[0]!, "utf8")).toBe(fixture.sources[0]);
const configured = parseClientConfiguration(
await readFile(fixture.client.configPath, "utf8"),
"opencode",
);
expect(effectiveClientServer(configured, "rea")).toHaveProperty(
"environment",
{
REA_GHIDRA_COMPILER_SPEC_ID: compiler,
HOPPER_LOADER_ARGS_JSON: '[""]',
REA_LOG_LEVEL: "debug",
},
);
expect(
await readFile(`${fixture.client.configPath}.rea.backup`, "utf8"),
).toBe(fixture.sources[1]);
expect(await fixture.status()).toMatchObject({ state: "aligned" });
expect(await clientConfigurationAligned(fixture.client, {}, command)).toBe(
true,
);
expect(
await configureClientConfiguration(fixture.client, {}, command),
).toEqual({ status: "unchanged" });
});
it.each([
["invalid JSON", { HOPPER_LOADER_ARGS_JSON: "{" }],
["invalid value type", { HOPPER_LOADER_ARGS_JSON: 42 }],
["invalid environment map", []],
])(
"does not hide a lower-priority %s behind a valid override",
async (_name, lower) => {
const fixture = await layered(lower, { HOPPER_LOADER_ARGS_JSON: "[]" });
expect(
await inspectClientConfiguration(fixture.client, {}, command),
).toMatchObject({
status: "invalid",
remediation: expect.stringContaining(fixture.paths[0]!),
});
expect(await fixture.status()).toMatchObject({ state: "invalid" });
expect(
await configureClientConfiguration(fixture.client, {}, command),
).toMatchObject({ status: "failed" });
await fixture.unchanged();
},
);
it("uses inherited settings when creating a missing write layer", async () => {
const fixture = await layered({ REA_GHIDRA_LANGUAGE_ID: language }, {});
await rm(fixture.client.configPath);
const overrides = { REA_GHIDRA_COMPILER_SPEC_ID: compiler };
expect(
await inspectClientConfiguration(fixture.client, overrides, command),
).toEqual({ status: "create" });
expect(
await configureClientConfiguration(fixture.client, overrides, command),
).toMatchObject({ status: "configured" });
const parsed = parseClientConfiguration(
await readFile(fixture.client.configPath, "utf8"),
"opencode",
);
expect(effectiveClientServer(parsed, "rea")).toHaveProperty(
"environment",
overrides,
);
expect(await readFile(fixture.paths[0]!, "utf8")).toBe(fixture.sources[0]);
expect(await fixture.status()).toMatchObject({ state: "aligned" });
await expect(
access(`${fixture.client.configPath}.rea.backup`),
).rejects.toMatchObject({ code: "ENOENT" });
});
it("replaces the selected write layer rather than the last loaded layer", async () => {
const fixture = await layered(
{ REA_GHIDRA_LANGUAGE_ID: language },
{ REA_GHIDRA_COMPILER_SPEC_ID: compiler },
);
const client = { ...fixture.client, configPath: fixture.paths[0]! };
const overrides = { REA_LOG_LEVEL: "debug" };
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
});
expect(
await configureClientConfiguration(client, overrides, command),
).toMatchObject({ status: "configured" });
const parsed = parseClientConfiguration(
await readFile(client.configPath, "utf8"),
"opencode",
);
expect(effectiveClientServer(parsed, "rea")).toHaveProperty("environment", {
REA_GHIDRA_LANGUAGE_ID: language,
...overrides,
});
expect(await readFile(fixture.paths[1]!, "utf8")).toBe(fixture.sources[1]);
expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
fixture.sources[0],
);
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
expect(await fixture.status()).toMatchObject({ state: "aligned" });
});

View File

@ -131,6 +131,32 @@ describe("client registration status", () => {
}),
]);
});
it("reports the exact requested package version when it differs from this CLI", async () => {
const home = await createTestTempDirectory(
"rea-registration-version-drift-",
);
await mkdir(join(home, ".codex"));
const command = ["npx", "-y", "rea-agents@4.1.0", "mcp"];
await writeFile(
join(home, ".codex/config.toml"),
`[mcp_servers.rea]\ncommand = "${command[0]}"\nargs = ["${command[1]}", "${command[2]}", "${command[3]}"]\n`,
);
const [status] = await readClientRegistrationStatuses(home);
expect(status).toMatchObject({
client: "codex",
command,
package_reference: "rea-agents@4.1.0",
state: "stale",
remediation: expect.stringContaining(
`current REA CLI package is ${PRODUCT_IDENTITY.packageVersion}`,
),
});
expect(status?.remediation).toContain("does not resolve package-runner");
expect(status?.remediation).toContain("rea setup --client codex");
});
});
describe("commandcode registration status", () => {

View File

@ -1,4 +1,4 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -1704,6 +1704,119 @@ const dshStatus = async (home: string) =>
})
).find(({ client }) => client === "dsh");
describe("DeepSeek Harness runtime environment validation", () => {
it.each([
["HOPPER_LOADER_ARGS_JSON", "{"],
["REA_REFERENCE_SECRET_PATTERNS_JSON", '[""]'],
["REA_GHIDRA_COMPILER_SPEC_ID", "borlandcpp"],
])("refuses invalid literal %s before writing", async (setting, value) => {
const { home, client } = await harnessClient();
const entry = canonicalEntry();
const original = stringifyYaml([
{
insert: [
{ ...entry, config: { ...entry.config, env: { [setting]: value } } },
],
},
]);
await writeFile(client.configPath, original);
expect(await inspectClientConfiguration(client, {}, command)).toMatchObject(
{ status: "invalid", remediation: expect.stringContaining(setting) },
);
expect(await dshStatus(home)).toMatchObject({
state: "invalid",
remediation: expect.stringContaining(setting),
});
expect(
await configureClientConfiguration(client, {}, command),
).toMatchObject({ status: "failed" });
expect(await readFile(client.configPath, "utf8")).toBe(original);
await expect(
access(`${client.configPath}.rea.backup`),
).rejects.toMatchObject({ code: "ENOENT" });
});
it("keeps an expression-backed companion unknown until a literal override replaces it", async () => {
const { home, client } = await harnessClient();
const entry = canonicalEntry();
const document = parseDocument(
stringifyYaml([
{
insert: [
{
...entry,
config: {
...entry.config,
command: "stale",
env: {
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
CALLER_SETTING: "keep",
},
},
},
],
},
]),
);
for (const setting of [
"REA_GHIDRA_LANGUAGE_ID",
"HOPPER_LOADER_ARGS_JSON",
]) {
const expression = document.createNode(`process.env.${setting}`);
expression.tag = "tag:yaml.org,2002:js";
document.setIn([0, "insert", 0, "config", "env", setting], expression);
}
const original = document.toString();
await writeFile(client.configPath, original);
expect(await inspectClientConfiguration(client, {}, command)).toMatchObject(
{ status: "update" },
);
expect(
await configureClientConfiguration(client, {}, command),
).toMatchObject({ status: "configured" });
const configured = await readFile(client.configPath, "utf8");
expect(configured).toContain("!!js process.env.REA_GHIDRA_LANGUAGE_ID");
expect(configured).toContain("!!js process.env.HOPPER_LOADER_ARGS_JSON");
expect(configured).toContain("CALLER_SETTING: keep");
expect(await dshStatus(home)).toMatchObject({ state: "aligned" });
expect(
await configureClientConfiguration(client, {}, command),
).toMatchObject({ status: "unchanged" });
await expect(
inspectClientConfiguration(
client,
{ HOPPER_LOADER_ARGS_JSON: "{" },
command,
),
).rejects.toMatchObject({
settings: [{ setting: "HOPPER_LOADER_ARGS_JSON" }],
});
await expect(
inspectClientConfiguration(
client,
{ REA_GHIDRA_LANGUAGE_ID: "" },
command,
),
).rejects.toMatchObject({
settings: [{ setting: "REA_GHIDRA_LANGUAGE_ID" }],
});
expect(
await inspectClientConfiguration(
client,
{
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
HOPPER_LOADER_ARGS_JSON: '[""]',
},
command,
),
).toMatchObject({ status: "update" });
expect(await readFile(client.configPath, "utf8")).toBe(configured);
expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
original,
);
});
});
describe("DeepSeek Harness source ownership", () => {
it.each(["setup", "uninstall"])(
"preserves unrelated rows in a later externally anchored operation during %s",

View File

@ -186,6 +186,18 @@ describe("curl installer scenarios", { timeout: 20_000 }, () => {
});
});
describe("installer reported version", { timeout: 20_000 }, () => {
it("rejects an empty version from a successful installed command", async () => {
const fixture = await createFixture();
await expect(
runInstaller(fixture, ["--version", "0.3.0"], { FAKE_REA_VERSION: "" }),
).rejects.toMatchObject({
stderr:
"REA installation failed: the installed REA version could not be read. Reinstall the requested version, then retry.\n",
});
});
});
describe("installer TTY fallback", { timeout: 20_000 }, () => {
it("skips automatic setup when stderr is redirected", async () => {
const fixture = await createFixture();
@ -392,6 +404,20 @@ describe("native macOS installer", { timeout: 20_000 }, () => {
});
describe("installer semantic version parsing", { timeout: 20_000 }, () => {
it("rejects an explicitly empty version instead of selecting latest", async () => {
const fixture = await createFixture();
await expect(
runInstaller(fixture, ["--version", "", "--dry-run"]),
).rejects.toMatchObject({
stdout: "",
stderr:
"REA installation failed: version must be an exact semantic version.\n",
});
await expect(readFile(fixture.npmLog, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it.each([
"1.2.3-01",
"1.2.3-$(printf unexpected)",
@ -559,7 +585,7 @@ if [ -n "\${FAKE_CURL_BODY:-}" ]; then printf '%s' "$FAKE_CURL_BODY"; else print
`#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_REA_LOG"
[ "\${FAKE_REA_VERSION_FAIL:-}" = "1" ] && exit 1
[ "$1" = "--version" ] && printf '%s\n' "\${FAKE_REA_VERSION:-0.3.0}"
[ "$1" = "--version" ] && printf '%s\n' "\${FAKE_REA_VERSION-0.3.0}"
exit 0
`,
),

View File

@ -0,0 +1,47 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { assertClientConfig } from "../../../scripts/verify-package-setup.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("rejects a backup replaced with the post-setup client configuration", async () => {
const directory = await createTestTempDirectory("rea-client-backup-");
const path = join(directory, "client.json");
const original = Buffer.from('{"existing":true,"model":"caller"}\n');
const configured = JSON.stringify({
existing: true,
model: "caller",
mcpServers: { rea: { command: process.execPath, args: ["mcp"] } },
});
await writeFile(path, configured);
await writeFile(`${path}.rea.backup`, original);
await assertClientConfig(process.execPath, path, original);
await writeFile(`${path}.rea.backup`, configured);
await expect(
assertClientConfig(process.execPath, path, original),
).rejects.toThrow("packaged client backup failed");
expect(await readFile(path, "utf8")).toBe(configured);
});
it("compares backup bytes even when invalid UTF-8 decodes to the same text", async () => {
const directory = await createTestTempDirectory("rea-client-backup-bytes-");
const path = join(directory, "client.json");
const original = Buffer.from('{"existing":true,"note":"\uFFFD"}\n');
const configured = JSON.stringify({
existing: true,
mcpServers: { rea: { command: process.execPath, args: ["mcp"] } },
});
const changed = Buffer.concat([
Buffer.from('{"existing":true,"note":"'),
Buffer.from([0xff]),
Buffer.from('"}\n'),
]);
expect(changed.toString("utf8")).toBe(original.toString("utf8"));
await writeFile(path, configured);
await writeFile(`${path}.rea.backup`, changed);
await expect(
assertClientConfig(process.execPath, path, original),
).rejects.toThrow("packaged client backup failed");
});

View File

@ -39,6 +39,90 @@ const expectPermissionMode = async (
expect((await stat(path)).mode & 0o777).toBe(expected);
};
describe("skill recovery backup selection", () => {
it("advertises a current recovery snapshot when the first backup is historical", async () => {
const home = await createTestTempDirectory("rea-skill-historical-backup-");
const [destination] = skillDestinations(home, []);
if (destination === undefined) throw new Error("Missing shared skill root");
const manifest = join(destination.path, "SKILL.md");
const current = "currently installed instructions V2\n";
const oldSnapshot = "historical instructions V0\n";
await mkdir(dirname(manifest), { recursive: true });
await writeFile(manifest, current);
await writeFile(`${manifest}.rea.backup`, oldSnapshot);
let manifestWrites = 0;
const operations: SkillInstallFileOperations = {
mkdir: async (path) =>
mkdir(path, { recursive: true }).then(() => undefined),
backup: async (source, target) =>
copyFile(source, target, fsConstants.COPYFILE_EXCL),
write: async (path, content, mode) => {
if (path === manifest && ++manifestWrites === 2)
throw new Error("injected rollback failure");
await writeFileAtomic(path, content, { encoding: "utf8", mode });
if (path === manifest && manifestWrites === 1)
throw new Error("injected failure after publication");
},
remove: async (path) => rm(path, { force: true }),
};
const result = await installCanonicalSkill(
home,
[],
{},
process.platform,
operations,
);
if (result.ok) throw new Error("Expected the injected write failure");
expect(result.error.rollbackFailures).toEqual([
{ path: manifest, reason: "injected rollback failure" },
]);
const recovery = result.error.recoveryBackupPaths.find((path) =>
path.startsWith(`${manifest}.rea.backup.`),
);
expect(recovery).toBeDefined();
expect(await readFile(recovery!, "utf8")).toBe(current);
expect(await readFile(`${manifest}.rea.backup`, "utf8")).toBe(oldSnapshot);
});
});
describe("skill recovery byte preservation", () => {
it("backs up and restores the exact bytes of invalid UTF-8 before replacing a skill", async () => {
const home = await createTestTempDirectory("rea-skill-damaged-bytes-");
const [destination] = skillDestinations(home, []);
if (destination === undefined) throw new Error("Missing shared skill root");
const manifest = join(destination.path, "SKILL.md");
const original = Buffer.from([0x63, 0x61, 0x66, 0xe9, 0xff, 0x0a]);
const firstBackup = `${manifest}.rea.backup`;
const recovery = `${firstBackup}.${createHash("sha256").update(original).digest("hex")}.recovery`;
await mkdir(dirname(manifest), { recursive: true });
await writeFile(manifest, original);
await writeFile(firstBackup, "historical instructions V0\n");
let manifestWrites = 0;
const result = await installCanonicalSkill(home, [], {}, process.platform, {
mkdir: async (path) =>
mkdir(path, { recursive: true }).then(() => undefined),
backup: async (source, target) =>
copyFile(source, target, fsConstants.COPYFILE_EXCL),
write: async (path, content, mode) => {
if (path === manifest && ++manifestWrites === 2)
throw new Error("injected rollback failure");
await writeFileAtomic(path, content, { encoding: "utf8", mode });
if (path === manifest && manifestWrites === 1)
throw new Error("injected failure after publication");
},
remove: async (path) => rm(path, { force: true }),
});
if (result.ok) throw new Error("Expected the injected write failure");
expect(result.error.recoveryBackupPaths).toContain(recovery);
expect(await readFile(recovery)).toEqual(original);
expect(await readFile(firstBackup, "utf8")).toBe(
"historical instructions V0\n",
);
});
});
describe("canonical skill transaction", () => {
it("does not roll back files when backup preparation fails", async () => {
const home = await createTestTempDirectory("rea-skill-backup-failure-");
@ -105,7 +189,7 @@ describe("canonical skill transaction", () => {
await writeFile(connection, "old connection\n", { mode: 0o644 });
await writeFile(native, "old native\n", { mode: 0o600 });
const writes = new Map<string, number>();
const nativeWrites: string[] = [];
const nativeWrites: (string | Buffer)[] = [];
const operations: SkillInstallFileOperations = {
mkdir: async (path) => {
await mkdir(path, { recursive: true });
@ -154,7 +238,7 @@ describe("canonical skill transaction", () => {
expect(await readFile(native, "utf8")).not.toBe("old native\n");
expect(await readFile(`${native}.rea.backup`, "utf8")).toBe("old native\n");
expect(nativeWrites).toHaveLength(2);
expect(nativeWrites[1]).toBe("old native\n");
expect(nativeWrites[1]).toEqual(Buffer.from("old native\n", "utf8"));
expect(result.error.message).toContain(
`Existing-file backups remain at ${manifest}.rea.backup`,
);

View File

@ -13,7 +13,10 @@ import {
} from "../../../src/application/SetupClientConfiguration.js";
import { skillDestinations } from "../../../src/application/SetupSkill.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import {
runUninstall,
systemUninstallHost,
} from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
@ -257,6 +260,91 @@ describe("ZCode .agents fallback preservation", () => {
});
});
describe("ZCode fallback uninstall", () => {
const owned = { command: command[0], args: command.slice(1) };
const writeFallback = async (home: string, servers: object) => {
await mkdir(join(home, ".agents"), { recursive: true });
const text = `${JSON.stringify({ ui: { keep: true }, mcpServers: servers }, null, 2)}\n`;
await writeFile(fallbackPath(home), text);
return text;
};
it.each(["comment", "trailing-comma", "bom"])(
"fails closed on strict fallback syntax: %s",
async (mode) => {
const { home } = await zcodeClient();
const valid = await writeFallback(home, {
rea: owned,
other: { command: "other-server" },
});
const original =
mode === "comment"
? `// unsupported comment\n${valid}`
: mode === "bom"
? `\uFEFF${valid}`
: valid.replace(/\}\s*$/u, ",}");
await writeFile(fallbackPath(home), original);
const result = await runUninstall(false, systemUninstallHost(home));
expect(result.items).toContainEqual(
expect.objectContaining({ name: "zcode", status: "failed" }),
);
expect(await readFile(fallbackPath(home), "utf8")).toBe(original);
await expect(
readFile(`${fallbackPath(home)}.rea.backup`),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("removes the REA-owned fallback entry with the native registration", async () => {
const { home, client } = await zcodeClient();
await writeFile(
client.configPath,
JSON.stringify({ mcp: { servers: { rea: owned } } }),
);
const original = await writeFallback(home, {
rea: owned,
other: { command: "other-server" },
});
const result = await runUninstall(false, systemUninstallHost(home));
expect(result.status).toBe("complete");
expect(result.items.find(({ name }) => name === "zcode")?.status).toBe(
"removed",
);
const native = JSON.parse(await readFile(client.configPath, "utf8"));
expect(native.mcp.servers).toEqual({});
const fallback = JSON.parse(await readFile(fallbackPath(home), "utf8"));
expect(fallback).toEqual({
ui: { keep: true },
mcpServers: { other: { command: "other-server" } },
});
expect(await readFile(`${fallbackPath(home)}.rea.backup`, "utf8")).toBe(
original,
);
});
it("removes a fallback-only REA entry and keeps a foreign rea entry", async () => {
for (const [servers, expected] of [
[{ rea: owned }, {}],
[
{ rea: { command: "someone-else" } },
{ rea: { command: "someone-else" } },
],
] as const) {
const { home } = await zcodeClient();
await writeFallback(home, servers);
expect(
(await runUninstall(false, systemUninstallHost(home))).status,
).toBe("complete");
expect(
JSON.parse(await readFile(fallbackPath(home), "utf8")).mcpServers,
).toEqual(expected);
}
});
});
describe("ZCode fallback admission", () => {
it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])(
"refuses malformed fallback REA environment %j before native writes",