From ec56c1da04c6c25825721a45e596e8cc237a6dd3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:25:53 +0800 Subject: [PATCH] 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. --- docs/client-configuration.md | 12 +- docs/installation.md | 42 +- install.sh | 5 +- src/android/JadxDoctor.ts | 155 +++++++ .../ClientConfigurationDocument.ts | 3 +- src/application/ClientConfigurationFiles.ts | 56 ++- .../ClientRegistrationEnvironment.ts | 13 +- src/application/ClientRegistrationIdentity.ts | 24 +- src/application/ClientRegistrationStatus.ts | 39 +- src/application/Doctor.test.ts | 29 ++ src/application/Doctor.ts | 2 + src/application/DoctorDiagnostics.ts | 13 +- src/application/DoctorProviderIds.ts | 2 +- src/application/DshCordisPatch.ts | 17 +- src/application/LinuxHopper.test.ts | 115 ++++- src/application/LinuxHopper.ts | 227 +++++++--- src/application/Setup.configuration.test.ts | 46 ++ src/application/Setup.fixture.ts | 4 + src/application/Setup.ts | 1 + src/application/SetupClientConfiguration.ts | 158 ++++--- src/application/SetupHost.ts | 32 +- src/application/SetupInstallFailure.test.ts | 48 +++ src/application/SetupInstallFailure.ts | 48 ++- src/application/SetupPlan.ts | 15 +- src/application/SetupRecoveryBackup.ts | 88 ++++ src/application/SetupSkill.ts | 68 ++- src/application/SetupTypes.ts | 2 + src/application/SupportedClients.ts | 1 + src/application/Uninstall.ts | 15 +- src/doctorRuntime.test.ts | 56 +++ src/doctorRuntime.ts | 2 + .../registrationRuntimeEnvironmentCli.test.ts | 235 +++++++++++ .../android/jadxConfiguration.test.ts | 7 + tests/boundary/android/jadxDoctor.test.ts | 396 ++++++++++++++++++ .../clientConfigurationEnvironment.test.ts | 42 +- ...entConfigurationLayeredEnvironment.test.ts | 219 ++++++++++ .../clientRegistrationStatus.test.ts | 26 ++ .../filesystem/dshClientConfiguration.test.ts | 115 ++++- .../boundary/filesystem/installScript.test.ts | 28 +- .../filesystem/packageClientBackup.test.ts | 47 +++ tests/boundary/filesystem/setupSkill.test.ts | 88 +++- .../zcodeClientConfiguration.test.ts | 90 +++- 42 files changed, 2418 insertions(+), 213 deletions(-) create mode 100644 src/android/JadxDoctor.ts create mode 100644 src/application/SetupRecoveryBackup.ts create mode 100644 src/doctorRuntime.test.ts create mode 100644 tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts create mode 100644 tests/boundary/android/jadxDoctor.test.ts create mode 100644 tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts create mode 100644 tests/boundary/filesystem/packageClientBackup.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 409bc7f19..41d239681 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -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 diff --git a/docs/installation.md b/docs/installation.md index 39584b51d..7abefac3d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -64,11 +64,21 @@ preserves a verified `.rea.backup..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 diff --git a/install.sh b/install.sh index c18726611..0da8ef15f 100755 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts new file mode 100644 index 000000000..b357d1e85 --- /dev/null +++ b/src/android/JadxDoctor.ts @@ -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>, +): Promise => { + 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 => (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 => { + const details: Record = {}; + 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 => { + const details: Record = {}; + 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; +}; diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index f7ab8f0e2..896442835 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -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); diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index fc3c2d4ea..5b1665f29 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -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 + Result > => { - 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 => /** 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; + 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; diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 86e95404d..efe6838a4 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -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> = {}, + validation: "fragment" | "effective" = "effective", ): Result>, 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( diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index 6e7744698..bdc94297e 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -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) ); diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 56a3e81ba..878abd0d5 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -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", diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index a39f81e35..63e0eef19 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -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 = { diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 3e8659dca..24adc5500 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -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; + readonly details?: Readonly>; } /** Read-only provider inspection supplied by an outer composition adapter. */ diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 1d8a5e621..069525a33 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -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 => { 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 => diff --git a/src/application/DoctorProviderIds.ts b/src/application/DoctorProviderIds.ts index 4b7f40331..af31f4c68 100644 --- a/src/application/DoctorProviderIds.ts +++ b/src/application/DoctorProviderIds.ts @@ -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; diff --git a/src/application/DshCordisPatch.ts b/src/application/DshCordisPatch.ts index 0e34da23e..fe88e5f16 100644 --- a/src/application/DshCordisPatch.ts +++ b/src/application/DshCordisPatch.ts @@ -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> = {}, + validation: "fragment" | "effective" = "effective", ): Result>, 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 = {}; + const unresolvedSettings = new Set(); 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; } diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index 1c37a03ea..3819ecfed 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -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 => { + ): Promise => { 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 => Promise.resolve(this.launcherStatusValue); cleanup = (path: string): Promise => { 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< diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 765f31d29..b3503ae7e 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -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, ``, 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; @@ -120,15 +139,20 @@ export interface LinuxHopperInstallHost { ): Promise; createTemporaryDirectory(): Promise; writeArchive(path: string, bytes: Uint8Array): Promise; - installPackage(family: LinuxPackageFamily, archive: string): Promise; + installPackage( + family: LinuxPackageFamily, + archive: string, + ): Promise; launcherStatus(path: string): Promise; cleanup(path: string): Promise; } /** 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, + ): Promise { + 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 => { + 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, @@ -304,24 +412,49 @@ const installSystemPackage = async ( family: LinuxPackageFamily, archive: string, environment: Readonly, -): Promise => { - try { - for (const command of linuxPackageManagerCommands( - family, - archive, - process.getuid?.() === 0, - )) - await execFileAsync(command.executable, command.args, { +): Promise => { + 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, diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index a3f46ed6f..8fe8e9ee8 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -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(); diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts index 3c7c21d35..ef7f37d3a 100644 --- a/src/application/Setup.fixture.ts +++ b/src/application/Setup.fixture.ts @@ -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 ? {} diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 7244824db..2b2fb5d45 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -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, diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 90f4bf4ca..0af9702ab 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -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 => { - 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> => { - 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( diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 84d41229e..623202fdd 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -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 => { @@ -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 | undefined, doctorHost: DoctorHost, + retryLinuxHopperCleanup: () => Promise, ): Promise => { - 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("; "); diff --git a/src/application/SetupInstallFailure.test.ts b/src/application/SetupInstallFailure.test.ts index d8ba27bdb..939593c3f 100644 --- a/src/application/SetupInstallFailure.test.ts +++ b/src/application/SetupInstallFailure.test.ts @@ -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); + }); +}); diff --git a/src/application/SetupInstallFailure.ts b/src/application/SetupInstallFailure.ts index 944a60343..25f020e86 100644 --- a/src/application/SetupInstallFailure.ts +++ b/src/application/SetupInstallFailure.ts @@ -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 { diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index f428ecade..345a05c45 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -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 => { 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() diff --git a/src/application/SetupRecoveryBackup.ts b/src/application/SetupRecoveryBackup.ts new file mode 100644 index 000000000..23f324210 --- /dev/null +++ b/src/application/SetupRecoveryBackup.ts @@ -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> => { + 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 => { + try { + await copyFile(source, destination, fsConstants.COPYFILE_EXCL); + return true; + } catch (cause: unknown) { + return cause instanceof Error && "code" in cause && cause.code === "EEXIST"; + } +}; diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index 794202b48..7eeec9456 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -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 => - 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; readonly write: ( path: string, - content: string, + content: string | Buffer, mode: number, ) => Promise; readonly remove: (path: string) => Promise; @@ -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 => { + 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(); 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, }), diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index f0cf61c59..9d7bc44c6 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -13,6 +13,8 @@ export type SetupProviderEnvironment = Readonly>; /** 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; } diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 55a651a52..582c48ba7 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -47,6 +47,7 @@ export interface SetupClient { | "pi" | "kimi" | "hermes" + | "zcode_fallback" | "zcode" | "dsh" | "unsupported"; diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index eac36c73d..d6447a8f6 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -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) => { diff --git a/src/doctorRuntime.test.ts b/src/doctorRuntime.test.ts new file mode 100644 index 000000000..c62bd0f5c --- /dev/null +++ b/src/doctorRuntime.test.ts @@ -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 => { + 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; +}; diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 1605c3691..75abfe2aa 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -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), }); diff --git a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts new file mode 100644 index 000000000..c3ea4ed5a --- /dev/null +++ b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts @@ -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>) => { + 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) => + 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", + }); + }, +); diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index 3ebba3271..7600ce44f 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -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"); +}); diff --git a/tests/boundary/android/jadxDoctor.test.ts b/tests/boundary/android/jadxDoctor.test.ts new file mode 100644 index 000000000..e053f66b5 --- /dev/null +++ b/tests/boundary/android/jadxDoctor.test.ts @@ -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 => { + 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), + }); +}); diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 9e0b340c5..74cbf612d 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -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) => { diff --git a/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts new file mode 100644 index 000000000..41f0b984c --- /dev/null +++ b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts @@ -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" }); +}); diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index d3cb002ea..13aca1826 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -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", () => { diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts index fd65e6b00..0142716b6 100644 --- a/tests/boundary/filesystem/dshClientConfiguration.test.ts +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -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", diff --git a/tests/boundary/filesystem/installScript.test.ts b/tests/boundary/filesystem/installScript.test.ts index d7e7f78a3..861a9bdfc 100644 --- a/tests/boundary/filesystem/installScript.test.ts +++ b/tests/boundary/filesystem/installScript.test.ts @@ -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 `, ), diff --git a/tests/boundary/filesystem/packageClientBackup.test.ts b/tests/boundary/filesystem/packageClientBackup.test.ts new file mode 100644 index 000000000..cd57fda10 --- /dev/null +++ b/tests/boundary/filesystem/packageClientBackup.test.ts @@ -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"); +}); diff --git a/tests/boundary/filesystem/setupSkill.test.ts b/tests/boundary/filesystem/setupSkill.test.ts index af4df0625..83b4d48cd 100644 --- a/tests/boundary/filesystem/setupSkill.test.ts +++ b/tests/boundary/filesystem/setupSkill.test.ts @@ -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(); - 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`, ); diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts index 4b00a2e01..268747356 100644 --- a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -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",