fix(setup): preserve registration identity and recovery ownership
Keep layered environment choices, current byte-exact recovery snapshots, and package-manager failure details. Report pinned package drift without claiming runner resolution. Retain failed Linux installer cleanup for host-owned retry while preserving the install outcome. Reviewed fixes include #2632, #2769, #2783, #2959, #2979, #2983, #2995, #3009, and #2559. Validation: 98 focused setup/registration tests passed; 45 Linux installer tests passed. Real Linux package installation remains unverified.
This commit is contained in:
parent
599a8ee11d
commit
ec56c1da04
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -64,11 +64,21 @@ preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
|
|||
Both snapshots remain available; a failed rollback reports the current recovery
|
||||
copy's path and the restore failure.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE`, `REA_MCP_OUTPUT_SCHEMA_PROFILE`, and
|
||||
`REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
Detected provider settings replace only matching environment keys.
|
||||
Setup and doctor report a malformed REA environment map or invalid runtime
|
||||
setting before setup writes files. Repair the named setting in the client
|
||||
configuration; setup does not silently discard the rest of the map.
|
||||
This includes malformed loader/exclusion JSON arrays and a Ghidra compiler
|
||||
specification without its required language identifier. Valid overrides keep
|
||||
their original strings, including empty Hopper arguments. DeepSeek Harness
|
||||
`!!js` environment expressions remain unevaluated and preserved; registration
|
||||
alignment does not verify the runtime values those expressions will produce.
|
||||
For layered OpenCode files, each file's values are checked before merging, while
|
||||
setting dependencies are checked against the effective merged environment.
|
||||
Setup preserves each layer's overrides instead of copying inherited settings
|
||||
into the file it updates.
|
||||
|
||||
After setup, REA reports which agents, analysis tools, and workflow files passed
|
||||
its final checks. Restart any agent named in the completion message, then begin
|
||||
|
|
@ -334,6 +344,20 @@ document for the same target. REA does not currently identify, focus, or reuse
|
|||
that existing GUI document, and the supported launcher exposes no attach or
|
||||
reuse action for it.
|
||||
|
||||
REA currently declines saved Hopper database (`.hop`) targets on macOS because
|
||||
the native `open database` launch route does not run REA's bridge script. To
|
||||
analyze that program with REA, select its original executable as the target and
|
||||
open it through Hopper; this starts the supported bridge route. This does not
|
||||
recover database-only annotations or analysis changes. Saved-database analysis
|
||||
on Linux continues to use its supported demo-launch route.
|
||||
|
||||
Hopper lease directories must be readable and owned by the current user.
|
||||
REA validates and secures an opened directory before reserving a lease. If
|
||||
directory access fails, the error identifies the path and recovery steps;
|
||||
REA leaves its permissions unchanged. Check ownership and parent-directory
|
||||
access, then restore owner-only read, write, and search permissions (`0700`)
|
||||
on a directory you own before retrying.
|
||||
|
||||
On supported Linux distributions, approved setup verifies Hopper's official
|
||||
`.deb`, `.rpm`, or Arch package before invoking the native package manager.
|
||||
REA runs the supported demo build on a private Xvfb display and selects Hopper's
|
||||
|
|
@ -346,6 +370,13 @@ host mount and the rest of `/tmp` remain unchanged; this fallback never invokes
|
|||
`sudo`. `rea doctor --provider hopper --json` reports the selected
|
||||
strategy and both host and effective mount facts.
|
||||
|
||||
When the package-manager step fails, setup reports the failing command and its
|
||||
captured output, so a package conflict is distinguishable from an authorization
|
||||
failure. On CachyOS, an unversioned compatibility package such as
|
||||
`zlib-ng-compat` may conflict with Hopper's versioned `zlib` dependency. Resolve
|
||||
the package conflict yourself or install Hopper's official package manually,
|
||||
then rerun `rea setup`.
|
||||
|
||||
The supported Linux demo forwards additional launches into its existing
|
||||
application, even across private displays. REA therefore reserves one Linux
|
||||
Hopper application per user across cooperating REA processes. A competing
|
||||
|
|
@ -370,8 +401,9 @@ sessions before removing that recovery directory and retrying.
|
|||
On macOS, REA uses
|
||||
`/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` by default.
|
||||
On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable
|
||||
`~/.local/share/rea/hopper/bin/Hopper`; if neither exists, the former remains
|
||||
the diagnostic fallback. `HOPPER_LAUNCHER_PATH` overrides those choices:
|
||||
`~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper`
|
||||
and `/usr/bin/hopper`; if none exists, the former remains the diagnostic
|
||||
fallback. `HOPPER_LAUNCHER_PATH` overrides those choices:
|
||||
|
||||
```bash
|
||||
export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
|
||||
|
|
@ -511,7 +543,7 @@ reported as incompatible instead of being admitted from its directory name, and
|
|||
a header that cannot be read or recognized is reported as unknown. REA does not
|
||||
build or install native tools, or change Gatekeeper quarantine settings.
|
||||
|
||||
The adapter exposes 25 read-only operations: thirteen inventory/name/search
|
||||
The adapter exposes 26 read-only operations: fourteen inventory/name/search
|
||||
operations and twelve function-analysis operations. These cover metadata,
|
||||
decompilation, assembly, resolved calls, typed references, xrefs, function
|
||||
dossiers, instructions, recovered data types, measured load mappings, loaded
|
||||
|
|
@ -799,7 +831,7 @@ On a controlled Windows x64 runner, use
|
|||
`npm run verify:ghidra:windows`. The verifier generates a deterministic native
|
||||
PE fixture from source bytes and requires the Windows native authority before
|
||||
opening the provider. The controls are implemented on main; see the
|
||||
[release boundary](#released-package-and-main). The lane verifies all 25 admitted
|
||||
[release boundary](#released-package-and-main). The lane verifies all 26 admitted
|
||||
read-only operations, digest identity, and runtime cleanup. The separate
|
||||
`verify:ghidra:windows:package` lane checks the packed CLI/MCP boundary with an
|
||||
ordinary user. It requires the built Windows native bundle and an existing
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
import type {
|
||||
DoctorProviderCheck,
|
||||
DoctorProviderInspection,
|
||||
} from "../application/Doctor.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
JADX_FAILURE_KINDS,
|
||||
projectJadxFailureOutcome,
|
||||
type JadxFailureKind,
|
||||
} from "./JadxConfiguration.js";
|
||||
|
||||
/** Project the validated JADX configuration into the doctor contract. */
|
||||
export const inspectJadxProvider = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Promise<DoctorProviderInspection> => {
|
||||
const configured = environment.REA_JADX_MCP_JAR !== undefined;
|
||||
const availability = await inspectJadxAvailability(environment);
|
||||
// diagnostics is a loosely-typed JsonValue map; guard the published path
|
||||
// even though the success path always sets jar_path today.
|
||||
const jarPath =
|
||||
availability.status === "available" &&
|
||||
typeof availability.diagnostics["jar_path"] === "string"
|
||||
? availability.diagnostics["jar_path"]
|
||||
: undefined;
|
||||
const available =
|
||||
availability.status === "available" && jarPath !== undefined;
|
||||
// Preserve the caller-selected JAR path (it may be a stable symlink) and
|
||||
// the validated JAVA_HOME selection; the resolved jarPath stays in
|
||||
// diagnostics as inspection evidence.
|
||||
const configuredJar = environment.REA_JADX_MCP_JAR;
|
||||
return {
|
||||
id: "jadx",
|
||||
configured,
|
||||
available,
|
||||
providerVersion: null,
|
||||
registrationEnvironment:
|
||||
jarPath === undefined || configuredJar === undefined
|
||||
? {}
|
||||
: {
|
||||
REA_JADX_MCP_JAR: configuredJar,
|
||||
...optionalEntry("JAVA_HOME", environment.JAVA_HOME),
|
||||
...optionalEntry(
|
||||
"REA_JADX_HEAP_MIB",
|
||||
environment.REA_JADX_HEAP_MIB,
|
||||
),
|
||||
...optionalEntry(
|
||||
"REA_JADX_ACTIVE_PROCESSOR_COUNT",
|
||||
environment.REA_JADX_ACTIVE_PROCESSOR_COUNT,
|
||||
),
|
||||
},
|
||||
checks: [configurationCheck(configured, availability, jarPath)],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Publish a caller-selected variable only when explicitly set; never persist
|
||||
* ambient inheritance. Values are validated before this point.
|
||||
*/
|
||||
const optionalEntry = (
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
): Record<string, string> => (value === undefined ? {} : { [name]: value });
|
||||
|
||||
const configurationCheck = (
|
||||
configured: boolean,
|
||||
availability: ProviderAvailability,
|
||||
jarPath: string | undefined,
|
||||
): DoctorProviderCheck => {
|
||||
const code = availability.status === "unavailable" ? availability.code : null;
|
||||
if (availability.status === "available" && jarPath !== undefined) {
|
||||
return {
|
||||
name: "configuration",
|
||||
ok: true,
|
||||
code: null,
|
||||
detail: `JADX MCP bridge JAR validated at ${jarPath}; its path is recorded for agent registrations.`,
|
||||
remediation: null,
|
||||
classification: "config_drift",
|
||||
};
|
||||
}
|
||||
// The failure kind is tagged at the configuration boundary; project its
|
||||
// remediation and classification here instead of reinterpreting the
|
||||
// phase/setting evidence. Every failed check keeps a remediation.
|
||||
const kind =
|
||||
availability.status === "unavailable"
|
||||
? asFailureKind(availability.diagnostics["failure_kind"])
|
||||
: null;
|
||||
const outcome = projectJadxFailureOutcome(kind, configured);
|
||||
return {
|
||||
name: "configuration",
|
||||
ok: false,
|
||||
code,
|
||||
detail:
|
||||
availability.status === "available"
|
||||
? "Validated JADX configuration reported no JAR path."
|
||||
: availability.reason,
|
||||
remediation: outcome.remediation,
|
||||
classification: outcome.classification,
|
||||
...(kind === "java_probe" && availability.status === "unavailable"
|
||||
? { details: probeDetails(availability.diagnostics) }
|
||||
: kind !== null &&
|
||||
kind.startsWith("windows_native_") &&
|
||||
availability.status === "unavailable"
|
||||
? { details: windowsNativeFailureDetails(availability.diagnostics) }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const windowsNativeFailureDetails = (
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> => {
|
||||
const details: Record<string, JsonValue> = {};
|
||||
for (const key of ["failure_stage", "remediation"] as const) {
|
||||
const value = diagnostics[key];
|
||||
if (value !== undefined) details[key] = value;
|
||||
}
|
||||
return details;
|
||||
};
|
||||
|
||||
/** Validate the loose diagnostics tag against the boundary's failure kinds. */
|
||||
const asFailureKind = (value: JsonValue | undefined): JadxFailureKind | null =>
|
||||
typeof value === "string" &&
|
||||
(JADX_FAILURE_KINDS as readonly string[]).includes(value)
|
||||
? (value as JadxFailureKind)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Project bounded Java probe metadata for the doctor report. The full
|
||||
* stdout/stderr can reach the 1 MiB probe budget, so only the bounded
|
||||
* outcome metadata is carried; the message already references the retained
|
||||
* output.
|
||||
*/
|
||||
const probeDetails = (
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> => {
|
||||
const details: Record<string, JsonValue> = {};
|
||||
for (const key of [
|
||||
"java_executable",
|
||||
"failure",
|
||||
"output_truncated",
|
||||
"exit_code",
|
||||
"process_error_code",
|
||||
"signal",
|
||||
"killed",
|
||||
"output_limit_bytes",
|
||||
"deadline_ms",
|
||||
"jdk_compiler",
|
||||
"java_major",
|
||||
] as const) {
|
||||
const value = diagnostics[key];
|
||||
if (value !== undefined) details[key] = value;
|
||||
}
|
||||
return details;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,21 @@ import {
|
|||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
|
||||
/** A parsed layer retains its path so rereading the write target replaces only that layer. */
|
||||
export interface ClientConfigurationSource extends ClientConfigurationDocument {
|
||||
readonly sourcePath: string;
|
||||
}
|
||||
|
||||
const invalidEnvironment = (
|
||||
path: string,
|
||||
error: ConfigurationError,
|
||||
): ClientConfigurationFileError => ({
|
||||
kind: "malformed",
|
||||
path,
|
||||
detail: `Configuration ${path} has an invalid REA server environment: ${error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}. Repair it before rerunning setup.`,
|
||||
});
|
||||
|
||||
/** A named source failure that setup can explain without losing the bad path. */
|
||||
export interface ClientConfigurationFileError {
|
||||
|
|
@ -74,9 +89,9 @@ export const readClientConfigurationFiles = async (
|
|||
client: SetupClient,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<
|
||||
Result<readonly ClientConfigurationDocument[], ClientConfigurationFileError>
|
||||
Result<readonly ClientConfigurationSource[], ClientConfigurationFileError>
|
||||
> => {
|
||||
const documents: ClientConfigurationDocument[] = [];
|
||||
const documents: ClientConfigurationSource[] = [];
|
||||
for (const path of client.configPaths ?? [client.configPath]) {
|
||||
const document = await readConfigurationFile(
|
||||
path,
|
||||
|
|
@ -85,16 +100,21 @@ export const readClientConfigurationFiles = async (
|
|||
);
|
||||
if (!document.ok) return document;
|
||||
if (document.value !== undefined) {
|
||||
const environment = clientRegistrationEnvironment(document.value);
|
||||
const environment = clientRegistrationEnvironment(
|
||||
document.value,
|
||||
{},
|
||||
"fragment",
|
||||
);
|
||||
if (!environment.ok)
|
||||
return err({
|
||||
kind: "malformed",
|
||||
path,
|
||||
detail: `Configuration ${path} has an invalid REA server environment: ${environment.error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}. Repair it before rerunning setup.`,
|
||||
});
|
||||
documents.push(document.value);
|
||||
return err(invalidEnvironment(path, environment.error));
|
||||
documents.push({ ...document.value, sourcePath: path });
|
||||
}
|
||||
}
|
||||
const environment = clientRegistrationEnvironment(
|
||||
effectiveClientConfiguration(documents),
|
||||
);
|
||||
if (!environment.ok)
|
||||
return err(invalidEnvironment(client.configPath, environment.error));
|
||||
return ok(documents);
|
||||
};
|
||||
|
||||
|
|
@ -117,8 +137,24 @@ const isObject = (value: unknown): value is Record<string, unknown> =>
|
|||
|
||||
/** Project a client's effective user registration without modifying its source files. */
|
||||
export const effectiveClientConfiguration = (
|
||||
documents: readonly ClientConfigurationDocument[],
|
||||
sources: readonly ClientConfigurationSource[],
|
||||
target?: {
|
||||
readonly client: Pick<SetupClient, "configPath" | "configPaths">;
|
||||
readonly document: ClientConfigurationDocument | undefined;
|
||||
},
|
||||
): ClientConfigurationDocument | undefined => {
|
||||
const documents =
|
||||
target === undefined
|
||||
? sources
|
||||
: (target.client.configPaths ?? [target.client.configPath]).flatMap(
|
||||
(path) => {
|
||||
const document =
|
||||
path === target.client.configPath
|
||||
? target.document
|
||||
: sources.find(({ sourcePath }) => sourcePath === path);
|
||||
return document === undefined ? [] : [document];
|
||||
},
|
||||
);
|
||||
const last = documents.at(-1);
|
||||
if (last === undefined) return undefined;
|
||||
if (documents.length === 1) return last;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { parseEnvironment } from "../config/environment.js";
|
||||
import {
|
||||
parseEnvironment,
|
||||
parseEnvironmentValues,
|
||||
} from "../config/environment.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
|
|
@ -15,9 +18,10 @@ import { dshDocument } from "./DshCordisPatch.js";
|
|||
export const clientRegistrationEnvironment = (
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
validation: "fragment" | "effective" = "effective",
|
||||
): Result<Readonly<Record<string, string>>, ConfigurationError> => {
|
||||
if (parsed?.dialect === "dsh")
|
||||
return dshDocument(parsed.document).environment(overrides);
|
||||
return dshDocument(parsed.document).environment(overrides, validation);
|
||||
const key =
|
||||
parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2"
|
||||
? "environment"
|
||||
|
|
@ -46,7 +50,10 @@ export const clientRegistrationEnvironment = (
|
|||
// Validate known settings with the runtime's parser, but retain the original
|
||||
// string overrides, including unknown client-specific keys and no defaults.
|
||||
const environment = { ...existing.data, ...overrides };
|
||||
const validated = parseEnvironment(environment);
|
||||
const validated =
|
||||
validation === "fragment"
|
||||
? parseEnvironmentValues(environment)
|
||||
: parseEnvironment(environment);
|
||||
if (!validated.ok) return validated;
|
||||
return ok(
|
||||
Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export interface DoctorCheck {
|
|||
readonly classification:
|
||||
| "healthy"
|
||||
| "missing_dependency"
|
||||
| "runtime_failure"
|
||||
| "unsupported_host"
|
||||
| "unsupported_target"
|
||||
| "missing_analysis_engine"
|
||||
|
|
@ -65,6 +66,7 @@ export interface DoctorProviderCheck {
|
|||
readonly detail: string;
|
||||
readonly remediation: string | null;
|
||||
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
|
||||
readonly details?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Read-only provider inspection supplied by an outer composition adapter. */
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import {
|
|||
SUPPORTED_NODE_VERSION_PROSE,
|
||||
supportsNodeVersion,
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js";
|
||||
import {
|
||||
linuxHopperLauncherCandidates,
|
||||
systemLinuxHopperLauncherCandidates,
|
||||
supportsReaHost,
|
||||
} from "./LinuxHopper.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
DoctorHost,
|
||||
|
|
@ -11,7 +15,6 @@ import type {
|
|||
|
||||
const DEFAULT_HOPPER =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
|
||||
|
||||
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
|
||||
export interface DoctorDiagnostics {
|
||||
|
|
@ -148,8 +151,9 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
|
|||
host.configuredHopperPath === undefined
|
||||
? [
|
||||
DEFAULT_HOPPER,
|
||||
SYSTEM_LINUX_HOPPER,
|
||||
linuxHopperLauncherPath(host.homeDirectory),
|
||||
...(host.platform === "linux"
|
||||
? linuxHopperLauncherCandidates(host.homeDirectory)
|
||||
: [...systemLinuxHopperLauncherCandidates]),
|
||||
...(await host.manualHopperPaths()),
|
||||
]
|
||||
: [host.configuredHopperPath];
|
||||
|
|
@ -280,6 +284,7 @@ const providerDoctorChecks = (
|
|||
...(candidate.remediation === null
|
||||
? {}
|
||||
: { remediation: candidate.remediation }),
|
||||
...(candidate.details === undefined ? {} : { details: candidate.details }),
|
||||
}));
|
||||
|
||||
const unsupportedHopperRemediation = (configured: boolean): string =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { isMap, isSeq, type Node, type YAMLMap } from "yaml";
|
||||
import { parseEnvironment } from "../config/environment.js";
|
||||
import {
|
||||
parseEnvironment,
|
||||
parseEnvironmentValues,
|
||||
} from "../config/environment.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
|
||||
|
|
@ -181,6 +184,7 @@ export class DshCordisDocument {
|
|||
/** Validate literals without treating expression source as an environment value. */
|
||||
environment(
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
validation: "fragment" | "effective" = "effective",
|
||||
): Result<Readonly<Record<string, string>>, ConfigurationError> {
|
||||
const config = this.#field("config");
|
||||
if (config instanceof CordisExpression)
|
||||
|
|
@ -215,8 +219,12 @@ export class DshCordisDocument {
|
|||
new ConfigurationError("Cordis server environment must be a mapping"),
|
||||
);
|
||||
const literals: Record<string, string> = {};
|
||||
const unresolvedSettings = new Set<string>();
|
||||
for (const [key, value] of Object.entries(raw ?? {})) {
|
||||
if (value instanceof CordisExpression) continue;
|
||||
if (value instanceof CordisExpression) {
|
||||
if (overrides[key] === undefined) unresolvedSettings.add(key);
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string")
|
||||
return err(
|
||||
new ConfigurationError("Invalid Cordis server environment", {
|
||||
|
|
@ -232,7 +240,10 @@ export class DshCordisDocument {
|
|||
literals[key] = value;
|
||||
}
|
||||
const environment = { ...literals, ...overrides };
|
||||
const validated = parseEnvironment(environment);
|
||||
const validated =
|
||||
validation === "fragment"
|
||||
? parseEnvironmentValues(environment)
|
||||
: parseEnvironment(environment, unresolvedSettings);
|
||||
return validated.ok ? ok(environment) : validated;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,19 +2,36 @@ import { createHash } from "node:crypto";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatCommandForDisplay,
|
||||
installLinuxHopper,
|
||||
linuxHopperLauncherDigestSupported,
|
||||
linuxPackageManagerCommands,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
supportsReaHost,
|
||||
LinuxHopperTemporaryCleanupOwner,
|
||||
type LinuxDistribution,
|
||||
type LinuxHopperDownload,
|
||||
type LinuxHopperInstallHost,
|
||||
type LinuxHopperLauncherStatus,
|
||||
type LinuxPackageFamily,
|
||||
type LinuxPackageInstallFailure,
|
||||
type LinuxPackageInstallOutcome,
|
||||
} from "./LinuxHopper.js";
|
||||
|
||||
describe("Linux package-manager command display", () => {
|
||||
it("preserves argument boundaries for paths with spaces and quotes", () => {
|
||||
expect(
|
||||
formatCommandForDisplay("pkexec", [
|
||||
"pacman",
|
||||
"-U",
|
||||
"/tmp/rea test/hopper.arch",
|
||||
"apostrophe'path",
|
||||
]),
|
||||
).toBe('pkexec pacman -U "/tmp/rea test/hopper.arch" "apostrophe\'path"');
|
||||
});
|
||||
});
|
||||
|
||||
class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
distributionValue: LinuxDistribution | undefined = {
|
||||
id: "ubuntu",
|
||||
|
|
@ -25,6 +42,14 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
|||
archive = new Uint8Array([1, 2, 3]);
|
||||
archiveOk = true;
|
||||
installSucceeds = true;
|
||||
cleanupFails = false;
|
||||
packageFailure: LinuxPackageInstallFailure = {
|
||||
command: "pacman -U --noconfirm /tmp/rea test/hopper.arch",
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "error: failed to prepare transaction",
|
||||
outputTruncated: false,
|
||||
};
|
||||
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
|
||||
downloads: string[] = [];
|
||||
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
|
||||
|
|
@ -42,15 +67,20 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
|||
installPackage = (
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
): Promise<boolean> => {
|
||||
): Promise<LinuxPackageInstallOutcome> => {
|
||||
this.installed.push({ family, archive });
|
||||
return Promise.resolve(this.installSucceeds);
|
||||
const outcome: LinuxPackageInstallOutcome = this.installSucceeds
|
||||
? { status: "installed" }
|
||||
: { status: "failed", failure: this.packageFailure };
|
||||
return Promise.resolve(outcome);
|
||||
};
|
||||
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
|
||||
Promise.resolve(this.launcherStatusValue);
|
||||
cleanup = (path: string): Promise<void> => {
|
||||
this.cleaned.push(path);
|
||||
return Promise.resolve();
|
||||
return this.cleanupFails
|
||||
? Promise.reject(new Error("temporary path is busy"))
|
||||
: Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +213,9 @@ describe("Linux Hopper installation", () => {
|
|||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Linux Hopper package installation", () => {
|
||||
it.each(["deb", "rpm", "arch"] as const)(
|
||||
"downloads, verifies, installs, reads back, and cleans a %s package",
|
||||
async (family) => {
|
||||
|
|
@ -204,7 +236,9 @@ describe("Linux Hopper installation", () => {
|
|||
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Linux Hopper installation failure classification", () => {
|
||||
it.each([
|
||||
["unsupported host", "unsupported_host"],
|
||||
["package request failure", "download"],
|
||||
|
|
@ -232,11 +266,65 @@ describe("Linux Hopper installation", () => {
|
|||
expect(await installFixture(host)).toEqual({
|
||||
status: "failed",
|
||||
reason,
|
||||
...(scenario === "package manager failure"
|
||||
? { packageFailure: host.packageFailure }
|
||||
: {}),
|
||||
});
|
||||
if (host.installed.length > 0)
|
||||
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Linux Hopper cleanup and failure details", () => {
|
||||
it("preserves the failing package-manager command and its output", async () => {
|
||||
const host = new RecordingLinuxHost();
|
||||
host.installSucceeds = false;
|
||||
host.packageFailure = {
|
||||
command: "pkexec pacman -U --noconfirm /tmp/rea test/hopper.arch",
|
||||
exitCode: 1,
|
||||
stdout: "loading packages...",
|
||||
stderr:
|
||||
"\u001b[31merror: unresolvable package conflicts detected\u001b[0m",
|
||||
outputTruncated: false,
|
||||
};
|
||||
expect(await installFixture(host)).toEqual({
|
||||
status: "failed",
|
||||
reason: "authorization_or_package_manager",
|
||||
packageFailure: host.packageFailure,
|
||||
});
|
||||
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
||||
});
|
||||
|
||||
it("preserves a successful install result when temporary cleanup fails", async () => {
|
||||
const host = new RecordingLinuxHost();
|
||||
host.cleanupFails = true;
|
||||
expect(await installFixture(host)).toEqual({
|
||||
status: "installed",
|
||||
launcherPath: "/opt/hopper/bin/Hopper",
|
||||
cleanupFailure: {
|
||||
path: "/tmp/rea test",
|
||||
reason: "temporary path is busy",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an install failure when temporary cleanup also fails", async () => {
|
||||
const host = new RecordingLinuxHost();
|
||||
host.installSucceeds = false;
|
||||
host.cleanupFails = true;
|
||||
expect(await installFixture(host)).toMatchObject({
|
||||
status: "failed",
|
||||
reason: "authorization_or_package_manager",
|
||||
packageFailure: host.packageFailure,
|
||||
cleanupFailure: {
|
||||
path: "/tmp/rea test",
|
||||
reason: "temporary path is busy",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Linux Hopper cancellation", () => {
|
||||
it("cancels before network or mutation", async () => {
|
||||
const host = new RecordingLinuxHost();
|
||||
const controller = new AbortController();
|
||||
|
|
@ -252,6 +340,27 @@ describe("Linux Hopper installation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Linux Hopper temporary cleanup ownership", () => {
|
||||
it("retains a failed cleanup for a later successful retry", async () => {
|
||||
const owner = new LinuxHopperTemporaryCleanupOwner();
|
||||
owner.retain({ path: "/tmp/rea-hopper", reason: "busy" });
|
||||
let attempts = 0;
|
||||
|
||||
expect(
|
||||
await owner.retry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("still busy");
|
||||
}),
|
||||
).toContain("/tmp/rea-hopper");
|
||||
expect(
|
||||
await owner.retry(async () => {
|
||||
attempts += 1;
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
function releasesFor(
|
||||
bytes: Uint8Array,
|
||||
): Record<
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import {
|
||||
downloadHopperPackage,
|
||||
HOPPER_DOWNLOAD_BYTES,
|
||||
} from "./HopperDownload.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
/** Package-manager output retained per stream without stopping the command. */
|
||||
const PACKAGE_MANAGER_OUTPUT_BYTES = 64 * 1024;
|
||||
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
||||
const SUPPORTED_HOPPER_SHA256 = new Set([
|
||||
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
|
||||
|
|
@ -67,7 +67,7 @@ export const linuxHopperInstallDisclosure = (
|
|||
family,
|
||||
`<verified-hopper.${family}>`,
|
||||
isRoot,
|
||||
).map(({ executable, args }) => `${executable} ${args.join(" ")}`),
|
||||
).map(({ executable, args }) => formatCommandForDisplay(executable, args)),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -111,6 +111,25 @@ export interface LinuxHopperDownload {
|
|||
readonly bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/** One failed package-manager command, with the output that explains it. */
|
||||
export interface LinuxPackageInstallFailure {
|
||||
/** The executed executable and arguments, joined for display. */
|
||||
readonly command: string;
|
||||
readonly exitCode: string | number | null;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
/** Whether the command produced more output than was retained. */
|
||||
readonly outputTruncated: boolean;
|
||||
}
|
||||
|
||||
/** Whether the package-manager step installed the verified package. */
|
||||
export type LinuxPackageInstallOutcome =
|
||||
| { readonly status: "installed" }
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly failure: LinuxPackageInstallFailure;
|
||||
};
|
||||
|
||||
/** External effects required to install the official Linux package. */
|
||||
export interface LinuxHopperInstallHost {
|
||||
distribution(): Promise<LinuxDistribution | undefined>;
|
||||
|
|
@ -120,15 +139,20 @@ export interface LinuxHopperInstallHost {
|
|||
): Promise<LinuxHopperDownload>;
|
||||
createTemporaryDirectory(): Promise<string>;
|
||||
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
|
||||
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
|
||||
installPackage(
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
): Promise<LinuxPackageInstallOutcome>;
|
||||
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Structured outcome for every expected Linux installation failure. */
|
||||
export type LinuxHopperInstallResult =
|
||||
| { readonly status: "installed"; readonly launcherPath: string }
|
||||
| {
|
||||
| ({ readonly status: "installed"; readonly launcherPath: string } & {
|
||||
readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure;
|
||||
})
|
||||
| ({
|
||||
readonly status: "failed";
|
||||
readonly reason:
|
||||
| "unsupported_host"
|
||||
|
|
@ -140,7 +164,49 @@ export type LinuxHopperInstallResult =
|
|||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build"
|
||||
| "cancelled";
|
||||
};
|
||||
/**
|
||||
* The failing package-manager command and its output. Present only when
|
||||
* the package-manager step itself failed, so setup can report what the
|
||||
* package manager said instead of assuming an authorization problem.
|
||||
*/
|
||||
readonly packageFailure?: LinuxPackageInstallFailure;
|
||||
} & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure });
|
||||
|
||||
/** Failed removal of an owned temporary install directory. */
|
||||
export interface LinuxHopperTemporaryCleanupFailure {
|
||||
readonly path: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Own failed temporary cleanup until a later retry succeeds. */
|
||||
export class LinuxHopperTemporaryCleanupOwner {
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
LinuxHopperTemporaryCleanupFailure
|
||||
>();
|
||||
|
||||
retain(failure: LinuxHopperTemporaryCleanupFailure): void {
|
||||
this.pending.set(failure.path, failure);
|
||||
}
|
||||
|
||||
async retry(
|
||||
cleanup: (path: string) => Promise<void>,
|
||||
): Promise<string | undefined> {
|
||||
const failures: string[] = [];
|
||||
for (const path of this.pending.keys()) {
|
||||
try {
|
||||
await cleanup(path);
|
||||
this.pending.delete(path);
|
||||
} catch (cause: unknown) {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
failures.push(
|
||||
`Linux Hopper temporary cleanup failed for ${path}: ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures.length === 0 ? undefined : failures.join("; ");
|
||||
}
|
||||
}
|
||||
|
||||
export type LinuxHopperLauncherStatus =
|
||||
| "ready"
|
||||
|
|
@ -215,33 +281,57 @@ export const installLinuxHopper = async (
|
|||
if (distribution?.hopperSetupSupported !== true)
|
||||
return { status: "failed", reason: "unsupported_host" };
|
||||
let temporary: string | undefined;
|
||||
let result: LinuxHopperInstallResult;
|
||||
try {
|
||||
const release = (options.releases ?? SUPPORTED_RELEASES)[
|
||||
distribution.packageFamily
|
||||
];
|
||||
const archiveDownload = await host.download(release.filename, options);
|
||||
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
|
||||
if (!packageIntegrityMatches(archiveDownload.bytes, release))
|
||||
return { status: "failed", reason: "integrity" };
|
||||
temporary = await host.createTemporaryDirectory();
|
||||
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
|
||||
await host.writeArchive(archive, archiveDownload.bytes);
|
||||
if (!(await host.installPackage(distribution.packageFamily, archive)))
|
||||
return { status: "failed", reason: "authorization_or_package_manager" };
|
||||
const launcherPath = "/opt/hopper/bin/Hopper";
|
||||
const launcherStatus = await host.launcherStatus(launcherPath);
|
||||
if (launcherStatus !== "ready")
|
||||
return {
|
||||
status: "failed",
|
||||
reason:
|
||||
launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
|
||||
};
|
||||
return { status: "installed", launcherPath };
|
||||
result = await (async (): Promise<LinuxHopperInstallResult> => {
|
||||
const release = (options.releases ?? SUPPORTED_RELEASES)[
|
||||
distribution.packageFamily
|
||||
];
|
||||
const archiveDownload = await host.download(release.filename, options);
|
||||
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
|
||||
if (!packageIntegrityMatches(archiveDownload.bytes, release))
|
||||
return { status: "failed", reason: "integrity" };
|
||||
temporary = await host.createTemporaryDirectory();
|
||||
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
|
||||
await host.writeArchive(archive, archiveDownload.bytes);
|
||||
const install = await host.installPackage(
|
||||
distribution.packageFamily,
|
||||
archive,
|
||||
);
|
||||
if (install.status === "failed")
|
||||
return {
|
||||
status: "failed",
|
||||
reason: "authorization_or_package_manager",
|
||||
packageFailure: install.failure,
|
||||
};
|
||||
const launcherPath = "/opt/hopper/bin/Hopper";
|
||||
const launcherStatus = await host.launcherStatus(launcherPath);
|
||||
if (launcherStatus !== "ready")
|
||||
return {
|
||||
status: "failed",
|
||||
reason:
|
||||
launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
|
||||
};
|
||||
return { status: "installed", launcherPath };
|
||||
})();
|
||||
} catch (cause: unknown) {
|
||||
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
|
||||
return { status: "failed", reason: "download" };
|
||||
} finally {
|
||||
if (temporary !== undefined) await host.cleanup(temporary);
|
||||
result = {
|
||||
status: "failed",
|
||||
reason: isAbortCause(cause) ? "cancelled" : "download",
|
||||
};
|
||||
}
|
||||
if (temporary === undefined) return result;
|
||||
try {
|
||||
await host.cleanup(temporary);
|
||||
return result;
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
...result,
|
||||
cleanupFailure: {
|
||||
path: temporary,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -249,6 +339,24 @@ export const installLinuxHopper = async (
|
|||
export const linuxHopperLauncherPath = (home: string): string =>
|
||||
join(home, ".local/share/rea/hopper/bin/Hopper");
|
||||
|
||||
/** REA's managed Linux install path, preferred when no explicit launcher is set. */
|
||||
export const REA_MANAGED_LINUX_HOPPER_LAUNCHER = "/opt/hopper/bin/Hopper";
|
||||
|
||||
/** Other system-wide Hopper paths used by package installations. */
|
||||
export const systemLinuxHopperLauncherCandidates: readonly string[] = [
|
||||
"/opt/hopper-v4/bin/hopper",
|
||||
"/usr/bin/hopper",
|
||||
];
|
||||
|
||||
/** Probe REA-managed and user-local installs before arbitrary system builds. */
|
||||
export const linuxHopperLauncherCandidates = (
|
||||
home: string,
|
||||
): readonly string[] => [
|
||||
REA_MANAGED_LINUX_HOPPER_LAUNCHER,
|
||||
linuxHopperLauncherPath(home),
|
||||
...systemLinuxHopperLauncherCandidates,
|
||||
];
|
||||
|
||||
/** Compose package-manager and linker checks from the selected caller environment. */
|
||||
export const systemLinuxHopperInstallHost = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
|
|
@ -304,24 +412,49 @@ const installSystemPackage = async (
|
|||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
for (const command of linuxPackageManagerCommands(
|
||||
family,
|
||||
archive,
|
||||
process.getuid?.() === 0,
|
||||
))
|
||||
await execFileAsync(command.executable, command.args, {
|
||||
): Promise<LinuxPackageInstallOutcome> => {
|
||||
for (const command of linuxPackageManagerCommands(
|
||||
family,
|
||||
archive,
|
||||
process.getuid?.() === 0,
|
||||
)) {
|
||||
const output = await execFileBoundedOutput(
|
||||
command.executable,
|
||||
command.args,
|
||||
{
|
||||
env: environment,
|
||||
});
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// Package-manager failure is reported by the false return.
|
||||
void cause;
|
||||
return false;
|
||||
limit: PACKAGE_MANAGER_OUTPUT_BYTES,
|
||||
},
|
||||
);
|
||||
if (output.exitCode !== 0) {
|
||||
return {
|
||||
status: "failed",
|
||||
failure: {
|
||||
command: formatCommandForDisplay(command.executable, command.args),
|
||||
exitCode: output.exitCode,
|
||||
// The streams stay separate; their original interleaved order
|
||||
// is not recoverable after capture.
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
outputTruncated: output.outputTruncated,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return { status: "installed" };
|
||||
};
|
||||
|
||||
/** Preserve argument boundaries in a command shown to the user. */
|
||||
export const formatCommandForDisplay = (
|
||||
executable: string,
|
||||
args: readonly string[],
|
||||
): string =>
|
||||
[executable, ...args]
|
||||
.map((part) =>
|
||||
/^[A-Za-z0-9_./:@%+=,-]+$/u.test(part) ? part : JSON.stringify(part),
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
/** Select the native package manager and authorization boundary without shell evaluation. */
|
||||
export const linuxPackageManagerCommands = (
|
||||
family: LinuxPackageFamily,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
||||
import {
|
||||
readClientConfigurationFiles,
|
||||
effectiveClientConfiguration,
|
||||
readConfigurationText,
|
||||
type ClientConfigurationSource,
|
||||
} from "./ClientConfigurationFiles.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
|
|
@ -18,9 +20,7 @@ import {
|
|||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
|
||||
|
|
@ -31,10 +31,13 @@ import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
|||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
|
||||
import {
|
||||
readRegularFile,
|
||||
readRegularFileText,
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
import {
|
||||
preserveRecoveryBackup,
|
||||
selectCurrentRecoveryBackup,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
|
||||
import type {
|
||||
ClientConfigurationInspection,
|
||||
|
|
@ -90,7 +93,9 @@ const configureClientDocument = async (
|
|||
throwCleanupFailure(files.error);
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const existing = files.value.at(-1);
|
||||
const existing = files.value.find(
|
||||
({ sourcePath }) => sourcePath === client.configPath,
|
||||
);
|
||||
const editabilityError =
|
||||
existing?.dialect === "dsh"
|
||||
? dshDocument(existing.document).editabilityError
|
||||
|
|
@ -104,7 +109,8 @@ const configureClientDocument = async (
|
|||
client,
|
||||
environment,
|
||||
command,
|
||||
files.value.at(-1),
|
||||
existing,
|
||||
files.value,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
|
|
@ -137,7 +143,13 @@ const configureClientDocument = async (
|
|||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
parsed,
|
||||
clientConfigurationDesired(client, environment, command, parsed),
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
environment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
|
|
@ -156,10 +168,16 @@ const configureClientDocument = async (
|
|||
throwCleanupFailure(fallback.error);
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const desired = clientConfigurationDesired(client, environment, command, {
|
||||
...parsed,
|
||||
servers: { ...fallback.value, ...parsed.servers },
|
||||
});
|
||||
const desired = clientConfigurationDesired(
|
||||
client,
|
||||
environment,
|
||||
command,
|
||||
{
|
||||
...parsed,
|
||||
servers: { ...fallback.value, ...parsed.servers },
|
||||
},
|
||||
files.value,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
|
||||
return persistClientConfiguration({
|
||||
client,
|
||||
|
|
@ -205,7 +223,10 @@ const persistClientConfiguration = async ({
|
|||
const selection = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (!selection.ok) return { status: "failed", reason: "backup" };
|
||||
if (
|
||||
!(await preserveConfigBackup(transactionPath, selection.value.backupPath))
|
||||
!(await preserveRecoveryBackup(
|
||||
transactionPath,
|
||||
selection.value.backupPath,
|
||||
))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
const verified = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
|
|
@ -338,7 +359,13 @@ export const clientConfigurationAligned = async (
|
|||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
parsed,
|
||||
clientConfigurationDesired(client, providerEnvironment, command, parsed),
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
|
|
@ -353,6 +380,7 @@ export const clientConfigurationAligned = async (
|
|||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
@ -400,7 +428,9 @@ const inspectClientDocument = async (
|
|||
remediation: files.error.detail,
|
||||
};
|
||||
}
|
||||
const existing = files.value.at(-1);
|
||||
const existing = files.value.find(
|
||||
({ sourcePath }) => sourcePath === client.configPath,
|
||||
);
|
||||
const editabilityError =
|
||||
existing?.dialect === "dsh"
|
||||
? dshDocument(existing.document).editabilityError
|
||||
|
|
@ -413,7 +443,13 @@ const inspectClientDocument = async (
|
|||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
undefined,
|
||||
clientConfigurationDesired(client, providerEnvironment, command, existing),
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
existing,
|
||||
files.value,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
|
|
@ -456,7 +492,13 @@ const inspectClientDocument = async (
|
|||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
parsed,
|
||||
clientConfigurationDesired(client, providerEnvironment, command, parsed),
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
|
|
@ -472,6 +514,7 @@ const inspectClientDocument = async (
|
|||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired))
|
||||
return { status: "already_current" };
|
||||
|
|
@ -554,76 +597,6 @@ const readZcodeFallbackServers = async (
|
|||
}
|
||||
};
|
||||
|
||||
const preserveConfigBackup = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
}
|
||||
};
|
||||
|
||||
interface RecoveryBackupSelection {
|
||||
readonly backupPath: string;
|
||||
readonly present: boolean;
|
||||
}
|
||||
|
||||
interface RecoveryBackupSelectionFailure {
|
||||
readonly kind: "unreadable" | "occupied";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
const selectCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
original: string,
|
||||
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
|
||||
const expected = Buffer.from(original, "utf8");
|
||||
let firstBackup: Buffer | undefined;
|
||||
try {
|
||||
firstBackup = await readRegularFile(firstBackupPath, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: firstBackupPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (firstBackup === undefined)
|
||||
return ok({ backupPath: firstBackupPath, present: false });
|
||||
if (firstBackup.equals(expected))
|
||||
return ok({ backupPath: firstBackupPath, present: true });
|
||||
|
||||
const digest = createHash("sha256").update(original, "utf8").digest("hex");
|
||||
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
|
||||
let recovery: Buffer | undefined;
|
||||
try {
|
||||
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (recovery !== undefined && !recovery.equals(expected))
|
||||
return err({
|
||||
kind: "occupied",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery path already contains different data: ${recoveryPath}`,
|
||||
});
|
||||
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
|
||||
};
|
||||
|
||||
const restoreConfig = async (
|
||||
path: string,
|
||||
original: string | undefined,
|
||||
|
|
@ -678,8 +651,18 @@ const clientConfigurationDesired = (
|
|||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
parsed?: ClientConfigurationDocument,
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
sources: readonly ClientConfigurationSource[],
|
||||
) => {
|
||||
const effective = effectiveClientConfiguration(sources, {
|
||||
client,
|
||||
document: parsed,
|
||||
});
|
||||
const validated = clientRegistrationEnvironment(
|
||||
effective,
|
||||
providerEnvironment,
|
||||
);
|
||||
if (!validated.ok) throw validated.error;
|
||||
const format = parsed?.dialect ?? client.format ?? "json";
|
||||
const launcher =
|
||||
command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command;
|
||||
|
|
@ -697,6 +680,7 @@ const clientConfigurationDesired = (
|
|||
const environment = clientRegistrationEnvironment(
|
||||
parsed,
|
||||
providerEnvironment,
|
||||
"fragment",
|
||||
);
|
||||
if (!environment.ok) throw environment.error;
|
||||
const registration = clientRegistrationEntry(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
||||
import {
|
||||
installLinuxHopper,
|
||||
LinuxHopperTemporaryCleanupOwner,
|
||||
readLinuxDistribution,
|
||||
systemLinuxHopperInstallHost,
|
||||
} from "./LinuxHopper.js";
|
||||
|
|
@ -142,6 +143,8 @@ export const systemSetupHost = (
|
|||
? systemMacHopperInstallHost(homeDirectory, environment)
|
||||
: undefined;
|
||||
const resources = new ArtifactResourceScope();
|
||||
const linuxHopperHost = systemLinuxHopperInstallHost(environment);
|
||||
const pendingLinuxHopperCleanup = new LinuxHopperTemporaryCleanupOwner();
|
||||
return {
|
||||
platform,
|
||||
homeDirectory,
|
||||
|
|
@ -151,7 +154,10 @@ export const systemSetupHost = (
|
|||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
close: () => closeSetupResources(resources, macHopperHost, doctorHost),
|
||||
close: () =>
|
||||
closeSetupResources(resources, macHopperHost, doctorHost, () =>
|
||||
pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup),
|
||||
),
|
||||
initialSetupState: async (
|
||||
scope?: DoctorScope,
|
||||
): Promise<SetupInitialState> => {
|
||||
|
|
@ -160,6 +166,11 @@ export const systemSetupHost = (
|
|||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { hopperPath: diagnosis.hopperPath }),
|
||||
...(typeof environment.HOPPER_LAUNCHER_PATH === "string" &&
|
||||
environment.HOPPER_LAUNCHER_PATH.trim() !== "" &&
|
||||
diagnosis.hopperPath === environment.HOPPER_LAUNCHER_PATH
|
||||
? { hopperLauncherConfigured: true }
|
||||
: {}),
|
||||
providerEnvironment: {
|
||||
...providerRegistrationEnvironment(
|
||||
diagnosis.providerInspections ?? [],
|
||||
|
|
@ -173,12 +184,12 @@ export const systemSetupHost = (
|
|||
},
|
||||
installHopper: async (replaceExisting) => {
|
||||
if (platform === "linux") {
|
||||
const result = await installLinuxHopper(
|
||||
systemLinuxHopperInstallHost(environment),
|
||||
);
|
||||
const result = await installLinuxHopper(linuxHopperHost);
|
||||
if (result.cleanupFailure !== undefined)
|
||||
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
|
||||
return result.status === "installed"
|
||||
? result
|
||||
: setupInstallFailure(result.reason);
|
||||
? { status: "installed", launcherPath: result.launcherPath }
|
||||
: setupInstallFailure(result.reason, result.packageFailure);
|
||||
}
|
||||
if (macHopperHost === undefined)
|
||||
return setupInstallFailure("unsupported_host");
|
||||
|
|
@ -264,12 +275,14 @@ const closeSetupResources = async (
|
|||
resources: ArtifactResourceScope,
|
||||
macHopperHost: ReturnType<typeof systemMacHopperInstallHost> | undefined,
|
||||
doctorHost: DoctorHost,
|
||||
retryLinuxHopperCleanup: () => Promise<string | undefined>,
|
||||
): Promise<string | undefined> => {
|
||||
const [resourceCleanup, hopperCleanup, doctorCleanup] =
|
||||
const [resourceCleanup, hopperCleanup, doctorCleanup, linuxCleanup] =
|
||||
await Promise.allSettled([
|
||||
resources.close(),
|
||||
Promise.resolve().then(() => macHopperHost?.close()),
|
||||
Promise.resolve().then(() => doctorHost.close?.()),
|
||||
retryLinuxHopperCleanup(),
|
||||
]);
|
||||
const failures: string[] = [];
|
||||
if (hopperCleanup.status === "rejected")
|
||||
|
|
@ -284,6 +297,11 @@ const closeSetupResources = async (
|
|||
);
|
||||
else if (doctorCleanup.value !== undefined)
|
||||
failures.push(doctorCleanup.value);
|
||||
if (linuxCleanup.status === "rejected")
|
||||
failures.push(
|
||||
`Linux Hopper cleanup retry failed: ${errorMessage(linuxCleanup.reason)}`,
|
||||
);
|
||||
else if (linuxCleanup.value !== undefined) failures.push(linuxCleanup.value);
|
||||
if (resourceCleanup.status === "rejected")
|
||||
failures.push(errorMessage(resourceCleanup.reason));
|
||||
return failures.length === 0 ? undefined : failures.join("; ");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -39,16 +39,19 @@ export const discoverSetupState = async (input: {
|
|||
readonly host: SetupHost;
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly forceHopperInstall: boolean;
|
||||
readonly hopperLauncherConfigured?: boolean;
|
||||
readonly proposeHopper: boolean;
|
||||
readonly initialDoctor: DoctorReport;
|
||||
}): Promise<SetupDiscovery> => {
|
||||
const { initialDoctor } = input;
|
||||
const linuxHopperRepairNeeded = initialDoctor.checks.some(
|
||||
({ name, ok, detail }) =>
|
||||
!ok &&
|
||||
(name === "hopper-demo-runtime" ||
|
||||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
|
||||
);
|
||||
const linuxHopperRepairNeeded =
|
||||
input.hopperLauncherConfigured !== true &&
|
||||
initialDoctor.checks.some(
|
||||
({ name, ok, detail }) =>
|
||||
!ok &&
|
||||
(name === "hopper-demo-runtime" ||
|
||||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
|
||||
);
|
||||
const linuxDistribution =
|
||||
input.host.platform === "linux"
|
||||
? await input.host.linuxDistribution()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
readRegularFile,
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
|
||||
export interface RecoveryBackupSelection {
|
||||
readonly backupPath: string;
|
||||
readonly present: boolean;
|
||||
}
|
||||
|
||||
export interface RecoveryBackupSelectionFailure {
|
||||
readonly kind: "unreadable" | "occupied";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
|
||||
const errorDetail = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** Select a persistent snapshot only when its bytes match this transaction's original. */
|
||||
export const selectCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
original: string | Buffer,
|
||||
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
|
||||
const expected = Buffer.isBuffer(original)
|
||||
? original
|
||||
: Buffer.from(original, "utf8");
|
||||
let firstBackup: Buffer | undefined;
|
||||
try {
|
||||
firstBackup = await readRegularFile(firstBackupPath, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: firstBackupPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (firstBackup === undefined)
|
||||
return ok({ backupPath: firstBackupPath, present: false });
|
||||
if (firstBackup.equals(expected))
|
||||
return ok({ backupPath: firstBackupPath, present: true });
|
||||
|
||||
const digest = createHash("sha256").update(expected).digest("hex");
|
||||
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
|
||||
let recovery: Buffer | undefined;
|
||||
try {
|
||||
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (recovery !== undefined && !recovery.equals(expected))
|
||||
return err({
|
||||
kind: "occupied",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery path already contains different data: ${recoveryPath}`,
|
||||
});
|
||||
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
|
||||
};
|
||||
|
||||
/** Preserve a snapshot without replacing an earlier recovery file. */
|
||||
export const preserveRecoveryBackup = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
}
|
||||
};
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
withRegularFile,
|
||||
} from "./RegularFileRead.js";
|
||||
import { clientSkillDirectories } from "./SupportedClients.js";
|
||||
import { selectCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
|
||||
|
||||
export const MANAGED_SKILL_FILES = [
|
||||
"SKILL.md",
|
||||
|
|
@ -36,10 +37,12 @@ export const readSkillFile = (
|
|||
path: string,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<string> =>
|
||||
readSkillFileState(path, resources).then(({ content }) => content);
|
||||
readSkillFileState(path, resources).then(({ bytes }) =>
|
||||
bytes.toString("utf8"),
|
||||
);
|
||||
|
||||
interface SkillFileState {
|
||||
readonly content: string;
|
||||
readonly bytes: Buffer;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +61,7 @@ const readSkillFileState = async (
|
|||
);
|
||||
if (bytes === undefined || bytes.length !== stats.size)
|
||||
throw new RegularFileChangedError(path);
|
||||
return { content: bytes.toString("utf8"), mode: stats.mode & 0o7777 };
|
||||
return { bytes, mode: stats.mode & 0o7777 };
|
||||
},
|
||||
{
|
||||
symlinks: "reject",
|
||||
|
|
@ -86,7 +89,7 @@ export interface SkillInstallFileOperations {
|
|||
readonly backup: (source: string, destination: string) => Promise<void>;
|
||||
readonly write: (
|
||||
path: string,
|
||||
content: string,
|
||||
content: string | Buffer,
|
||||
mode: number,
|
||||
) => Promise<void>;
|
||||
readonly remove: (path: string) => Promise<void>;
|
||||
|
|
@ -277,7 +280,8 @@ const canonicalSkillNeedsInstallByReading = async (
|
|||
const destination = join(root, relativePath);
|
||||
const content = await canonicalSkillContent(relativePath);
|
||||
const original = await readOptionalSkillState(destination, resources);
|
||||
if (original?.content !== content) return true;
|
||||
if (original?.bytes.equals(Buffer.from(content, "utf8")) !== true)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
|
@ -327,7 +331,7 @@ const restoreSkillFiles = async (
|
|||
for (const { destination, original } of [...attempted].reverse()) {
|
||||
try {
|
||||
if (original === undefined) await operations.remove(destination);
|
||||
else await operations.write(destination, original.content, original.mode);
|
||||
else await operations.write(destination, original.bytes, original.mode);
|
||||
} catch (cause: unknown) {
|
||||
failures.push({ path: destination, reason: causeReason(cause) });
|
||||
}
|
||||
|
|
@ -335,6 +339,32 @@ const restoreSkillFiles = async (
|
|||
return failures;
|
||||
};
|
||||
|
||||
const preserveCurrentSkillBackup = async (
|
||||
destination: string,
|
||||
original: Buffer,
|
||||
operations: SkillInstallFileOperations,
|
||||
): Promise<string> => {
|
||||
const firstBackupPath = `${destination}.rea.backup`;
|
||||
const selection = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
);
|
||||
if (!selection.ok) throw new Error(selection.error.detail);
|
||||
if (selection.value.present) return selection.value.backupPath;
|
||||
|
||||
await operations.backup(destination, selection.value.backupPath);
|
||||
const verified = await selectCurrentRecoveryBackup(firstBackupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
throw new Error(
|
||||
`The current file was not preserved at ${selection.value.backupPath}`,
|
||||
);
|
||||
return verified.value.backupPath;
|
||||
};
|
||||
|
||||
/** Transactionally install the canonical skill bundle and optional references. */
|
||||
export const installCanonicalSkill = async (
|
||||
home: string,
|
||||
|
|
@ -347,6 +377,7 @@ export const installCanonicalSkill = async (
|
|||
let attempted: CanonicalSkillFile[] = [];
|
||||
let stage: "prepare" | "backup" | "write" | "readback" = "prepare";
|
||||
let failingPath = "skill bundle";
|
||||
const recoveryPaths = new Map<string, string>();
|
||||
try {
|
||||
const canonical = await canonicalSkillFiles(
|
||||
home,
|
||||
|
|
@ -356,7 +387,8 @@ export const installCanonicalSkill = async (
|
|||
resources,
|
||||
);
|
||||
const changed = canonical.filter(
|
||||
({ content, original }) => original?.content !== content,
|
||||
({ content, original }) =>
|
||||
original?.bytes.equals(Buffer.from(content, "utf8")) !== true,
|
||||
);
|
||||
if (changed.length === 0) return ok("unchanged");
|
||||
|
||||
|
|
@ -364,8 +396,16 @@ export const installCanonicalSkill = async (
|
|||
for (const { destination, original } of changed) {
|
||||
failingPath = destination;
|
||||
await operations.mkdir(dirname(destination));
|
||||
if (original !== undefined)
|
||||
await operations.backup(destination, `${destination}.rea.backup`);
|
||||
if (original !== undefined) {
|
||||
recoveryPaths.set(
|
||||
destination,
|
||||
await preserveCurrentSkillBackup(
|
||||
destination,
|
||||
original.bytes,
|
||||
operations,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
stage = "write";
|
||||
for (const file of changed) {
|
||||
|
|
@ -382,7 +422,11 @@ export const installCanonicalSkill = async (
|
|||
stage = "readback";
|
||||
for (const { destination, content } of changed) {
|
||||
failingPath = destination;
|
||||
if ((await readSkillFile(destination, resources)) !== content)
|
||||
if (
|
||||
!(await readSkillFileState(destination, resources)).bytes.equals(
|
||||
Buffer.from(content, "utf8"),
|
||||
)
|
||||
)
|
||||
throw new Error(`skill readback mismatch: ${destination}`);
|
||||
}
|
||||
return ok("installed");
|
||||
|
|
@ -396,7 +440,9 @@ export const installCanonicalSkill = async (
|
|||
reason: causeReason(cause),
|
||||
rollbackFailures,
|
||||
recoveryBackupPaths: attempted.flatMap(({ destination, original }) =>
|
||||
original === undefined ? [] : [`${destination}.rea.backup`],
|
||||
original === undefined || recoveryPaths.get(destination) === undefined
|
||||
? []
|
||||
: [recoveryPaths.get(destination)!],
|
||||
),
|
||||
cause,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export type SetupProviderEnvironment = Readonly<Record<string, string>>;
|
|||
/** Read-only host facts shared by the initial setup plan. */
|
||||
export interface SetupInitialState {
|
||||
readonly hopperPath?: string;
|
||||
/** Whether the caller explicitly selected a Hopper launcher in its environment. */
|
||||
readonly hopperLauncherConfigured?: boolean;
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly doctor: DoctorReport;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export interface SetupClient {
|
|||
| "pi"
|
||||
| "kimi"
|
||||
| "hermes"
|
||||
| "zcode_fallback"
|
||||
| "zcode"
|
||||
| "dsh"
|
||||
| "unsupported";
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { createSystemDoctorHost } from "./doctorRuntime.js";
|
||||
import { DOCTOR_PROVIDER_IDS } from "./application/DoctorProviderIds.js";
|
||||
import { providerRegistrationEnvironment } from "./application/SetupRegistrationEnvironment.js";
|
||||
import { createTestTempDirectory } from "../tests/fixtures/temporaryDirectory.js";
|
||||
import { writeJadxJarInventory } from "../tests/fixtures/android/jadxJar.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("exposes jadx through the doctor provider selector", () => {
|
||||
expect(DOCTOR_PROVIDER_IDS).toContain("jadx");
|
||||
});
|
||||
|
||||
it("carries a validated REA_JADX_MCP_JAR into the provider registration environment", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-registration-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const host = createSystemDoctorHost({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
const inspections = await host.providerInspections?.();
|
||||
expect(inspections?.map(({ id }) => id)).toContain("jadx");
|
||||
|
||||
const environment = providerRegistrationEnvironment(inspections ?? []);
|
||||
expect(environment).toMatchObject({
|
||||
REA_JADX_MCP_JAR: await realpath(jar),
|
||||
});
|
||||
});
|
||||
|
||||
it("omits REA_JADX_MCP_JAR from the provider registration environment when it is not configured", async () => {
|
||||
const host = createSystemDoctorHost({});
|
||||
const inspections = await host.providerInspections?.();
|
||||
|
||||
const environment = providerRegistrationEnvironment(inspections ?? []);
|
||||
expect(environment).not.toHaveProperty("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
const createFakeJdk = async (root: string): Promise<string> => {
|
||||
const javaHome = join(root, "fake-jdk");
|
||||
const bin = join(javaHome, "bin");
|
||||
const java = join(bin, "java");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(
|
||||
java,
|
||||
"#!/bin/sh\nprintf 'java.base@21\\njdk.compiler@21\\n'\n",
|
||||
);
|
||||
await chmod(java, 0o700);
|
||||
return javaHome;
|
||||
};
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import {
|
||||
effectiveClientServer,
|
||||
parseClientConfiguration,
|
||||
clientRegistrationEntry,
|
||||
} from "../../../src/application/ClientConfigurationDocument.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
client: "cursor",
|
||||
format: "json",
|
||||
path: ".cursor/mcp.json",
|
||||
setting: "HOPPER_LOADER_ARGS_JSON",
|
||||
invalid: { HOPPER_LOADER_ARGS_JSON: "{" },
|
||||
repaired: { HOPPER_LOADER_ARGS_JSON: '["", "--loader", "Mach-O"]' },
|
||||
},
|
||||
{
|
||||
client: "dsh",
|
||||
format: "dsh",
|
||||
path: ".dsh/cordis.patch.yml",
|
||||
setting: "REA_GHIDRA_COMPILER_SPEC_ID",
|
||||
invalid: { REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" },
|
||||
repaired: {
|
||||
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
|
||||
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios)
|
||||
cliTest(
|
||||
`setup and doctor reject ${scenario.client} overrides rejected by MCP startup`,
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory("rea-registration-runtime-");
|
||||
const config = join(home, scenario.path);
|
||||
await mkdir(dirname(config), { recursive: true });
|
||||
const environment = {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
DSH_HOME: join(home, ".dsh"),
|
||||
};
|
||||
const registration = (env: Readonly<Record<string, string>>) => {
|
||||
const server = {
|
||||
command: "stale-launcher",
|
||||
args: ["mcp"],
|
||||
env: { CALLER_SETTING: "keep exactly", ...env },
|
||||
};
|
||||
return scenario.client === "dsh"
|
||||
? stringify([
|
||||
{
|
||||
insert: [
|
||||
{
|
||||
id: "rea-mcp",
|
||||
name: "@deepseek-ai/dsh-mcp-client",
|
||||
config: {
|
||||
serverName: "rea",
|
||||
transport: "stdio",
|
||||
...server,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
: JSON.stringify({ mcpServers: { rea: server } });
|
||||
};
|
||||
const original = registration(scenario.invalid);
|
||||
await writeFile(config, original);
|
||||
const startup = await cli.run({
|
||||
arguments: ["mcp"],
|
||||
cwd: home,
|
||||
environment: { ...environment, ...scenario.invalid },
|
||||
});
|
||||
expect(startup.exitCode).toBe(1);
|
||||
expect(startup.stderr).toContain(scenario.setting);
|
||||
expect(startup.stdout).toBe("");
|
||||
const run = (args: readonly string[]) =>
|
||||
cli.run({ arguments: [...args, "--json"], cwd: home, environment });
|
||||
const setup = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
scenario.client,
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
]);
|
||||
expect(setup.exitCode).toBe(1);
|
||||
expect(setup.json).toMatchObject({
|
||||
status: "needs_human",
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
remediation: expect.stringContaining(scenario.setting),
|
||||
});
|
||||
const doctor = await run(["doctor", "--client", scenario.client]);
|
||||
expect(doctor.exitCode).toBe(1);
|
||||
expect(doctor.json).toMatchObject({
|
||||
identity: {
|
||||
registrations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
client: scenario.client,
|
||||
state: "invalid",
|
||||
remediation: expect.stringContaining(scenario.setting),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(await readFile(config, "utf8")).toBe(original);
|
||||
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
|
||||
// Model the caller repairing the named setting before rerunning setup.
|
||||
const repaired = registration(scenario.repaired);
|
||||
await writeFile(config, repaired);
|
||||
const configured = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
scenario.client,
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
]);
|
||||
expect(configured.exitCode, configured.stdout).toBe(0);
|
||||
const text = await readFile(config, "utf8");
|
||||
const parsed = parseClientConfiguration(text, scenario.format);
|
||||
expect(effectiveClientServer(parsed, "rea")).toMatchObject({
|
||||
env: { CALLER_SETTING: "keep exactly", ...scenario.repaired },
|
||||
});
|
||||
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(repaired);
|
||||
const repeated = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
scenario.client,
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
]);
|
||||
expect(repeated.json).toMatchObject({
|
||||
status: "ready",
|
||||
appliedActions: [],
|
||||
plannedActions: [],
|
||||
});
|
||||
expect(await readFile(config, "utf8")).toBe(text);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"OpenCode CLI validates dependent settings after merging JSON and JSONC",
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory("rea-layered-runtime-cli-");
|
||||
const directory = join(home, ".config/opencode");
|
||||
await mkdir(directory, { recursive: true });
|
||||
const lower = join(directory, "opencode.json");
|
||||
const upper = join(directory, "opencode.jsonc");
|
||||
const content = (env: Record<string, string>) =>
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
rea: clientRegistrationEntry(
|
||||
"opencode",
|
||||
[resolve("scripts/rea.mjs"), "mcp"],
|
||||
env,
|
||||
),
|
||||
},
|
||||
});
|
||||
const lowerContent = content({
|
||||
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
|
||||
});
|
||||
const upperContent = content({ REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" });
|
||||
await writeFile(lower, lowerContent);
|
||||
await writeFile(upper, upperContent);
|
||||
const environment = {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
};
|
||||
const run = (args: readonly string[]) =>
|
||||
cli.run({ arguments: [...args, "--json"], cwd: home, environment });
|
||||
const setup = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
"opencode",
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
]);
|
||||
expect(setup.exitCode, setup.stdout).toBe(0);
|
||||
expect(setup.json).toMatchObject({
|
||||
status: "ready",
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
});
|
||||
const doctor = await run(["doctor", "--client", "opencode"]);
|
||||
expect(doctor.exitCode, doctor.stdout).toBe(0);
|
||||
expect(doctor.json).toMatchObject({
|
||||
identity: {
|
||||
registrations: expect.arrayContaining([
|
||||
expect.objectContaining({ client: "opencode", state: "aligned" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(await readFile(lower, "utf8")).toBe(lowerContent);
|
||||
expect(await readFile(upper, "utf8")).toBe(upperContent);
|
||||
await writeFile(lower, content({ CALLER_SETTING: "retained" }));
|
||||
const missing = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
"opencode",
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
]);
|
||||
expect(missing.exitCode).toBe(1);
|
||||
expect(missing.json).toMatchObject({
|
||||
status: "needs_human",
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
remediation: expect.stringContaining("REA_GHIDRA_COMPILER_SPEC_ID"),
|
||||
});
|
||||
const invalidDoctor = await run(["doctor", "--client", "opencode"]);
|
||||
expect(invalidDoctor.exitCode).toBe(1);
|
||||
expect(invalidDoctor.json).toMatchObject({
|
||||
identity: {
|
||||
registrations: expect.arrayContaining([
|
||||
expect.objectContaining({ client: "opencode", state: "invalid" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(await readFile(upper, "utf8")).toBe(upperContent);
|
||||
for (const path of [lower, upper])
|
||||
await expect(access(`${path}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,396 @@
|
|||
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { inspectJadxProvider } from "../../../src/android/JadxDoctor.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
|
||||
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("publishes REA_JADX_MCP_JAR as registration environment when the configured JAR validates", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
id: "jadx",
|
||||
configured: true,
|
||||
available: true,
|
||||
providerVersion: null,
|
||||
registrationEnvironment: { REA_JADX_MCP_JAR: await realpath(jar) },
|
||||
checks: [
|
||||
{
|
||||
name: "configuration",
|
||||
ok: true,
|
||||
code: null,
|
||||
classification: "config_drift",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes JAVA_HOME alongside the JAR when the configured JAR validates", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-javahome-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection.registrationEnvironment).toEqual({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the caller-selected JAR path when it is a symlink", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-symlink-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const link = join(root, "current.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
await symlink(jar, link);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: link,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection.available).toBe(true);
|
||||
expect(inspection.registrationEnvironment["REA_JADX_MCP_JAR"]).toBe(link);
|
||||
});
|
||||
|
||||
it("guides Java selection when the Java runtime is missing", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-remediation-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: join(root, "missing-jdk"),
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("runtime_missing");
|
||||
expect(inspection.checks[0]?.remediation).toContain("JAVA_HOME");
|
||||
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
it("guides Java selection when JAVA_HOME is not an absolute path", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-options-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: "relative/path",
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("open_options_invalid");
|
||||
expect(inspection.checks[0]?.remediation).toContain("JAVA_HOME");
|
||||
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
it("publishes no registration environment when REA_JADX_MCP_JAR is unset", async () => {
|
||||
const inspection = await inspectJadxProvider({});
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
id: "jadx",
|
||||
configured: false,
|
||||
available: false,
|
||||
providerVersion: null,
|
||||
registrationEnvironment: {},
|
||||
checks: [
|
||||
{
|
||||
name: "configuration",
|
||||
ok: false,
|
||||
code: "not_configured",
|
||||
classification: "missing_analysis_engine",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes no registration environment when the configured JAR fails validation", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-invalid-");
|
||||
const jar = join(root, "engine.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeFile(jar, "not a Java archive");
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
id: "jadx",
|
||||
configured: true,
|
||||
available: false,
|
||||
registrationEnvironment: {},
|
||||
checks: [
|
||||
{
|
||||
name: "configuration",
|
||||
ok: false,
|
||||
code: "version_unresolved",
|
||||
classification: "config_drift",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(inspection.checks[0]?.detail).toContain("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
it("classifies a missing Java runtime as a missing dependency", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-runtime-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: join(root, "missing-jdk"),
|
||||
});
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
configured: true,
|
||||
available: false,
|
||||
registrationEnvironment: {},
|
||||
checks: [
|
||||
{
|
||||
name: "configuration",
|
||||
ok: false,
|
||||
code: "runtime_missing",
|
||||
classification: "missing_dependency",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("guides JVM option correction when REA_JADX_HEAP_MIB is invalid", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-heap-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
REA_JADX_HEAP_MIB: "not-a-number",
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("open_options_invalid");
|
||||
expect(inspection.checks[0]?.remediation).toContain("REA_JADX_HEAP_MIB");
|
||||
expect(inspection.checks[0]?.remediation).not.toContain("JAVA_HOME");
|
||||
});
|
||||
|
||||
it("publishes validated JVM resource overrides in the registration environment", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-overrides-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
REA_JADX_HEAP_MIB: "4096",
|
||||
REA_JADX_ACTIVE_PROCESSOR_COUNT: "8",
|
||||
});
|
||||
|
||||
expect(inspection.available).toBe(true);
|
||||
expect(inspection.registrationEnvironment).toEqual({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
REA_JADX_HEAP_MIB: "4096",
|
||||
REA_JADX_ACTIVE_PROCESSOR_COUNT: "8",
|
||||
});
|
||||
});
|
||||
|
||||
it("guides JDK selection when the Java runtime is older than 17", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-oldjdk-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root, "11");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("unsupported_version");
|
||||
expect(inspection.checks[0]?.remediation).toContain("JDK");
|
||||
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
it("guides JAR replacement when the JAR misses bridge classes", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-classes-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeOrderedZip(jar, ["com/atxx/jhmcp/JadxSession.class"]);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("unsupported_version");
|
||||
expect(inspection.checks[0]?.remediation).toContain("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
it("remediates an unsupported host with platform guidance", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "sunos",
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: "/opt/jadx-headless-mcp.jar",
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("unsupported_host");
|
||||
expect(inspection.checks[0]?.remediation).toContain("Linux");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("guides Windows native package correction when process ownership is unavailable", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
|
||||
try {
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: "/opt/jadx-headless-mcp.jar",
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("runtime_missing");
|
||||
expect(inspection.checks[0]?.remediation).toContain(
|
||||
"Windows x64 REA package",
|
||||
);
|
||||
expect(inspection.checks[0]?.remediation).not.toContain("JAVA_HOME");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", {
|
||||
value: originalArch,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("guides Java probe correction when the module listing is unparseable", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-unparseable-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
// Fake JDK whose module listing lacks a parseable java.base version.
|
||||
const javaHome = join(root, "fake-jdk");
|
||||
const bin = join(javaHome, "bin");
|
||||
const java = join(bin, "java");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(java, "#!/bin/sh\nprintf 'jdk.compiler@21\\n'\n");
|
||||
await chmod(java, 0o700);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("version_unresolved");
|
||||
expect(inspection.checks[0]?.remediation).toContain("java --list-modules");
|
||||
expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
const createFakeJdk = async (root: string, major = "21"): Promise<string> => {
|
||||
const javaHome = join(root, "fake-jdk");
|
||||
const bin = join(javaHome, "bin");
|
||||
const java = join(bin, "java");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(
|
||||
java,
|
||||
`#!/bin/sh\nprintf 'java.base@${major}\\njdk.compiler@${major}\\n'\n`,
|
||||
);
|
||||
await chmod(java, 0o700);
|
||||
return javaHome;
|
||||
};
|
||||
|
||||
it("classifies missing Windows native artifacts as missing dependencies", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
|
||||
try {
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: "/opt/jadx-headless-mcp.jar",
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("runtime_missing");
|
||||
expect(inspection.checks[0]?.classification).toBe("missing_dependency");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", {
|
||||
value: originalArch,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies malformed JAVA_HOME as configuration drift", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-javahome-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: "relative/path",
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("open_options_invalid");
|
||||
expect(inspection.checks[0]?.classification).toBe("config_drift");
|
||||
});
|
||||
|
||||
it("preserves Java probe diagnostics in doctor output", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-doctor-probe-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
const javaHome = join(root, "fake-jdk-unparseable");
|
||||
const bin = join(javaHome, "bin");
|
||||
const java = join(bin, "java");
|
||||
await mkdir(bin, { recursive: true });
|
||||
// Emits jdk.compiler but no parseable java.base version.
|
||||
await writeFile(java, `#!/bin/sh\nprintf 'jdk.compiler@21\\n'\n`);
|
||||
await chmod(java, 0o700);
|
||||
|
||||
const inspection = await inspectJadxProvider({
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
});
|
||||
|
||||
expect(inspection.checks[0]?.code).toBe("version_unresolved");
|
||||
expect(inspection.checks[0]?.details).toMatchObject({
|
||||
java_executable: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -39,6 +39,90 @@ const expectPermissionMode = async (
|
|||
expect((await stat(path)).mode & 0o777).toBe(expected);
|
||||
};
|
||||
|
||||
describe("skill recovery backup selection", () => {
|
||||
it("advertises a current recovery snapshot when the first backup is historical", async () => {
|
||||
const home = await createTestTempDirectory("rea-skill-historical-backup-");
|
||||
const [destination] = skillDestinations(home, []);
|
||||
if (destination === undefined) throw new Error("Missing shared skill root");
|
||||
const manifest = join(destination.path, "SKILL.md");
|
||||
const current = "currently installed instructions V2\n";
|
||||
const oldSnapshot = "historical instructions V0\n";
|
||||
await mkdir(dirname(manifest), { recursive: true });
|
||||
await writeFile(manifest, current);
|
||||
await writeFile(`${manifest}.rea.backup`, oldSnapshot);
|
||||
let manifestWrites = 0;
|
||||
const operations: SkillInstallFileOperations = {
|
||||
mkdir: async (path) =>
|
||||
mkdir(path, { recursive: true }).then(() => undefined),
|
||||
backup: async (source, target) =>
|
||||
copyFile(source, target, fsConstants.COPYFILE_EXCL),
|
||||
write: async (path, content, mode) => {
|
||||
if (path === manifest && ++manifestWrites === 2)
|
||||
throw new Error("injected rollback failure");
|
||||
await writeFileAtomic(path, content, { encoding: "utf8", mode });
|
||||
if (path === manifest && manifestWrites === 1)
|
||||
throw new Error("injected failure after publication");
|
||||
},
|
||||
remove: async (path) => rm(path, { force: true }),
|
||||
};
|
||||
|
||||
const result = await installCanonicalSkill(
|
||||
home,
|
||||
[],
|
||||
{},
|
||||
process.platform,
|
||||
operations,
|
||||
);
|
||||
|
||||
if (result.ok) throw new Error("Expected the injected write failure");
|
||||
expect(result.error.rollbackFailures).toEqual([
|
||||
{ path: manifest, reason: "injected rollback failure" },
|
||||
]);
|
||||
const recovery = result.error.recoveryBackupPaths.find((path) =>
|
||||
path.startsWith(`${manifest}.rea.backup.`),
|
||||
);
|
||||
expect(recovery).toBeDefined();
|
||||
expect(await readFile(recovery!, "utf8")).toBe(current);
|
||||
expect(await readFile(`${manifest}.rea.backup`, "utf8")).toBe(oldSnapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill recovery byte preservation", () => {
|
||||
it("backs up and restores the exact bytes of invalid UTF-8 before replacing a skill", async () => {
|
||||
const home = await createTestTempDirectory("rea-skill-damaged-bytes-");
|
||||
const [destination] = skillDestinations(home, []);
|
||||
if (destination === undefined) throw new Error("Missing shared skill root");
|
||||
const manifest = join(destination.path, "SKILL.md");
|
||||
const original = Buffer.from([0x63, 0x61, 0x66, 0xe9, 0xff, 0x0a]);
|
||||
const firstBackup = `${manifest}.rea.backup`;
|
||||
const recovery = `${firstBackup}.${createHash("sha256").update(original).digest("hex")}.recovery`;
|
||||
await mkdir(dirname(manifest), { recursive: true });
|
||||
await writeFile(manifest, original);
|
||||
await writeFile(firstBackup, "historical instructions V0\n");
|
||||
let manifestWrites = 0;
|
||||
const result = await installCanonicalSkill(home, [], {}, process.platform, {
|
||||
mkdir: async (path) =>
|
||||
mkdir(path, { recursive: true }).then(() => undefined),
|
||||
backup: async (source, target) =>
|
||||
copyFile(source, target, fsConstants.COPYFILE_EXCL),
|
||||
write: async (path, content, mode) => {
|
||||
if (path === manifest && ++manifestWrites === 2)
|
||||
throw new Error("injected rollback failure");
|
||||
await writeFileAtomic(path, content, { encoding: "utf8", mode });
|
||||
if (path === manifest && manifestWrites === 1)
|
||||
throw new Error("injected failure after publication");
|
||||
},
|
||||
remove: async (path) => rm(path, { force: true }),
|
||||
});
|
||||
if (result.ok) throw new Error("Expected the injected write failure");
|
||||
expect(result.error.recoveryBackupPaths).toContain(recovery);
|
||||
expect(await readFile(recovery)).toEqual(original);
|
||||
expect(await readFile(firstBackup, "utf8")).toBe(
|
||||
"historical instructions V0\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonical skill transaction", () => {
|
||||
it("does not roll back files when backup preparation fails", async () => {
|
||||
const home = await createTestTempDirectory("rea-skill-backup-failure-");
|
||||
|
|
@ -105,7 +189,7 @@ describe("canonical skill transaction", () => {
|
|||
await writeFile(connection, "old connection\n", { mode: 0o644 });
|
||||
await writeFile(native, "old native\n", { mode: 0o600 });
|
||||
const writes = new Map<string, number>();
|
||||
const nativeWrites: string[] = [];
|
||||
const nativeWrites: (string | Buffer)[] = [];
|
||||
const operations: SkillInstallFileOperations = {
|
||||
mkdir: async (path) => {
|
||||
await mkdir(path, { recursive: true });
|
||||
|
|
@ -154,7 +238,7 @@ describe("canonical skill transaction", () => {
|
|||
expect(await readFile(native, "utf8")).not.toBe("old native\n");
|
||||
expect(await readFile(`${native}.rea.backup`, "utf8")).toBe("old native\n");
|
||||
expect(nativeWrites).toHaveLength(2);
|
||||
expect(nativeWrites[1]).toBe("old native\n");
|
||||
expect(nativeWrites[1]).toEqual(Buffer.from("old native\n", "utf8"));
|
||||
expect(result.error.message).toContain(
|
||||
`Existing-file backups remain at ${manifest}.rea.backup`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue