Files
reverse-engineering-anything/src/doctorRuntime.ts
T
240f08d406 feat(desktop): set up and check a desktop build host in one command (#9199)
* fix(desktop): keep an empty env value empty

An empty assignment is not a path. Resolving it named the working directory
and rewrote the value when that directory's spelling moved.

* fix(desktop): keep a run's font on the run, not the frame's text

A formatting attribute such as Scribus FONT is not the document's words.
Joining it across runs made a frame-wide text check of ItalicBold that
failed when a run was split. The font stays a value check on its own run.

* fix(desktop): do not pair JSON records by a selector-shaped field

A field named @record_id matched the id suffix and became [@record_id=one],
which reads an XML attribute. Records paired while their pointers matched
nothing. Names that start with @ or > are not id fields.

* fix(desktop): count a save-numbered id once when pairing members

xml:id and draw:id values such as id123456 were both generated and
numbered, so the same selector was counted twice and refused. A text
edit of two elements keyed only by those ids now keeps its check.

* fix(desktop): count members of root JSON arrays

* fix(desktop): preserve selected state directory aliases

* docs(desktop): explain tool and skill refresh prerequisites

* fix(desktop): align nested state selections with aliases

* fix(desktop): check build runtime export capability

* fix(desktop): provide complete runtime repack command

* fix(desktop): retain runtime probe failures

* test(desktop): identify capture independently of schema preflight

* fix(desktop): preserve document URLs and value formats

* fix(desktop): bound added checks and omit noisy text guards

* fix(desktop): exclude unseeded locks from task checks

* fix(desktop): preserve draft bounds and launch URLs

* fix(desktop): render draft values using their source format

* fix(desktop): preserve launch URLs and JSON draft values

* fix(desktop): recognize captured file URL documents

* fix(desktop): render captured file URLs for runtime home

* fix(desktop): distinguish phone numbers from save metadata

* fix(desktop): preserve launch URLs and user text

* fix(desktop): preserve captured URLs and user text

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): render captured file URLs at launch

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): retain clean text in noisy record fields

* fix(desktop): classify added line values in one pass

* fix(desktop): apply XML quote rules to text drafts

* fix(desktop): distinguish XML attributes from quoted text

* fix(desktop): preserve parsed XML line context

* Format appTaskChecks.ts with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): retain partial XML syntax facts

* fix(desktop): recognize markdown autolinks in drafts

* fix(desktop): reuse export exclusions in task drafts

* fix(desktop): report unchecked client build paths

* docs(desktop): clarify gzip scrub limits

* fix(desktop): reuse export exclusions in task drafts

* docs(desktop): clarify gzip scrub limits

* fix(setup): own desktop build process cleanup

* fix(doctor): compare desktop settings with resolved defaults

* style(desktop): format the noisy-record draft files with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): give the root-array draft test its own describe

The records describe grew past the 150-line lint limit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(doctor): inspect client-recorded desktop runners

* fix(doctor): reuse matching runner probes

* fix(setup): propagate cancellation to desktop actions

* fix(setup): stop Hopper on cancel, never report a cancelled setup ready, and keep a failed action's client setting

- The Hopper install gets setup's signal: Linux already honoured it, and the
  macOS installer now stops its downloads and stops before mounting or copying.
- runSetup checks the signal after the final doctor and close, so a
  cancellation there raises instead of returning ready.
- A failed pack or agentenv install no longer writes its archive or runner to
  client configs, so the setting the client already had stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(setup): keep the completed setup result in a cancellation after cleanup

A cancellation during the final doctor or host close carried only the applied
actions, dropping each client's result and backup path and the doctor report.
It now carries the whole result as setup_result, as doctor's cancellation
carries its report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): move launch execution to boundary suite

* fix(desktop): redact build schema failure output

* refactor(desktop): normalize schema failure once

* fix(desktop): omit unsafe schema parser snippets

* fix(desktop): preserve draft change and selected path evidence

* test(desktop): group retained dconf note regressions

* fix(desktop): respect archive entry format in draft checks

* test(desktop): separate unowned-file draft note cases

* fix(desktop): preserve explicitly selected journal documents

* fix(xml): avoid scanning selected ancestors per tag

* fix(desktop): exclude covered text runs from draft counts

* fix: preserve text when drafting desktop checks

* fix: restore nested desktop state aliases

* refactor: share UUID recognition across draft checks

* fix: retain multiline XML attribute context

* fix: retain relationship identifiers in task drafts

* docs: clarify desktop draft identifier handling

* test(desktop): type formatting run cases as pairs

* fix(desktop): carry document format into selector evaluation

* test(desktop): separate markup boundary regression group

* fix(desktop): propagate formats through draft selectors

* fix(desktop): switch selector format within archive entries

* test(desktop): remove duplicate seed filename case

* fix(setup): retain diagnostics when cancellation overlaps failure

* refactor(setup): keep command cancellation at its owner

* test(setup): verify cancellation at the application boundary

* test(desktop): pass JSON format in exported index parity

* docs(desktop): clarify task checks before scratch export

* fix(desktop): respell selected paths in file URLs

* test(desktop): group captured document spelling cases

* fix(desktop): bound draft preview serialization

* fix(desktop): restore selected state aliases

* fix(desktop): respect entry format in draft noise

* test(desktop): match exported alias evidence at its JSON boundary

* fix(desktop): retain app state logs in exported seeds

* fix: align desktop numeric seed comparisons

* test(desktop): keep one draft exclusion fixture import

* test(desktop): retain log eligibility across draft test groups

* fix(doctor): resolve empty registered desktop runner

* style: fix desktop launch test formatting

* style: format native command path schema

* fix(desktop): map selected external launch paths

* style(desktop): format seed document regressions

* test(desktop): group launch URL regressions

* fix(desktop): omit redundant nested state aliases

* fix(desktop): compare added free text as a whole

* fix(desktop): check recompressed seeds as shipped

* docs(desktop): clarify recovered seed format

* test(desktop): group shipped gzip seed checks

* fix: retain quoted identifier text in task drafts

* fix: recognize Unicode XML in desktop state readers

* fix(desktop): bound nested draft checks

* fix(desktop): explain missing arrays in truncated views

* fix(desktop): find XML type references in archives

* test(desktop): remove duplicated capture assertions

* fix(desktop): retain nested omitted text boundaries

* style(desktop): format draft value imports

* test(desktop): group model and draft value checks

* fix(desktop): map captured launch paths once

* refactor: remove unused whole-text guard state

* refactor: stop passing unused text children

* test: align free-text checks with whole-note policy

* fix: validate recompressed desktop archive seeds

* style(desktop): format archive seed regression

* fix(desktop): retain placed-item sibling guards

* fix(desktop): preserve archive paths and bound doctor reads

* fix(doctor): refuse non-files before descriptor admission

* fix(setup): track remaining cleanup resources across retries

* test(desktop): specify XML format in heap pairing fixture

* fix(doctor): query agentenv for model endpoint

* fix(doctor): preserve uncertainty for empty endpoint override

* fix(desktop): redact runtime schema errors

* fix(desktop): normalize captured file URLs

* fix(desktop): report seeded files by capture path

* fix(desktop): retain startup readiness failure stage

* fix(desktop): retain JSON date checks

* fix(desktop): preserve archive formatting checks

* fix(desktop): preserve explicitly selected journal files

* fix(desktop): report build command transport limits

* docs(desktop): clarify unreadable dconf versions

* test(desktop): remove duplicate history regression

* fix(desktop): align XML names with exported reader

* fix(desktop): create selected state alias targets

* fix(desktop): inspect supported runtime archives

* fix(desktop): retain record identity in draft checks

* fix(desktop): scrub XML independently of reader compatibility

* fix(desktop): preserve file aliases and reader diagnostics

* style(desktop): format review fixes

* style(desktop): format capture review fixes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:02:31 +08:00

567 lines
20 KiB
TypeScript

import {
systemDoctorHost,
type DoctorCheck,
type DoctorHost,
type DoctorProviderInspection,
} from "./application/Doctor.js";
import { fileURLToPath } from "node:url";
import { readDesktopBuildFacts } from "./desktop/DesktopBuildDoctorFacts.js";
import {
JADX_DOCTOR_SETTINGS,
inspectJadxProvider,
} from "./android/JadxDoctor.js";
import {
GHIDRA_DOCTOR_SETTINGS,
inspectSystemGhidraProvider,
} from "./ghidra/GhidraDoctor.js";
import {
IDA_DOCTOR_SETTINGS,
inspectIdaRegistration,
} from "./ida/IdaDoctor.js";
import {
KUNA_DOCTOR_SETTINGS,
inspectKunaProvider,
} from "./kuna/KunaDoctor.js";
import { readIdaConfiguration } from "./ida/IdaConfiguration.js";
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
import { ok } from "./domain/result.js";
import { AnalysisCancelledError } from "./domain/analysisErrorCore.js";
import { ProviderCleanupError } from "./domain/providerCleanupError.js";
import { AnalysisCleanupLeaseGroup } from "./artifacts/AnalysisCleanupLease.js";
import type { RetainedResourceScope } from "./application/AnalysisProvider.js";
import { RegularFileCleanupFailure } from "./filesystem/OwnedRegularFile.js";
import { readEffectiveClientRegistrationConfiguration } from "./application/ClientConfigurationFiles.js";
import { clientRegistrationEnvironmentDetails } from "./application/ClientRegistrationEnvironment.js";
import {
mergeEnvironmentOverrides,
snapshotEnvironment,
} from "./process/snapshotEnvironment.js";
import { supportedClients } from "./application/SupportedClients.js";
import { homeDirectoryFromEnvironment } from "./config/homeDirectory.js";
import {
inspectLinuxHopperPythonRuntime,
linuxHopperPythonUnavailable,
} from "./application/LinuxHopperPython.js";
/**
* A selected client's own environment, when its registration records one.
* Provider checks read the terminal by default; a caller that asked about one
* client wants the values that client passes to the server it starts.
*/
interface SelectedRegistrationEnvironment {
/** The terminal a selected client inherits, overlaid by its registration. */
readonly environment: Readonly<Record<string, string | undefined>>;
/** Names the client records as expressions this process cannot evaluate. */
readonly unresolved: ReadonlySet<string>;
/** The registration could not be read or its effective settings resolved. */
readonly unknown?: boolean;
}
const selectedRegistrationEnvironment = async (
clients: readonly string[],
processEnvironment: Readonly<NodeJS.ProcessEnv>,
resourceScope: RetainedResourceScope,
signal: AbortSignal,
): Promise<SelectedRegistrationEnvironment | undefined> => {
const [selected] = clients;
// Only an unambiguous selection can stand for the terminal's environment.
if (selected === undefined || clients.length !== 1) return undefined;
signal.throwIfAborted();
const home = homeDirectoryFromEnvironment(
processEnvironment,
process.platform,
);
const client = supportedClients(
home,
process.platform,
processEnvironment,
).find(({ name }) => name === selected);
if (client === undefined || client.format === "unsupported")
return { environment: {}, unresolved: new Set(), unknown: true };
const registered = await readSelectedRegistration(
client,
resourceScope,
process.platform,
);
if (registered === undefined) return undefined;
if ("unknown" in registered)
return { environment: {}, unresolved: new Set(), unknown: true };
return {
environment: registered.values,
unresolved: registered.unresolvedSettings,
};
};
/**
* Fold a selected registration and the terminal it inherits into the canonical
* names a provider check reads: the registration's recorded values win, names
* it records as unreadable expressions are dropped, and everything else
* survives from the terminal.
*
* Windows treats setting names case-insensitively, so a registration recording
* `ghidra_install_dir` must override the terminal's `GHIDRA_INSTALL_DIR`
* rather than sit beside it, and a dropped expression must be found under
* either spelling. `snapshotEnvironment` performs that fold;
* `mergeEnvironmentOverrides` alone keeps the winning layer's spelling.
*/
export const selectedProviderSettings = (
selected: SelectedRegistrationEnvironment | undefined,
terminal: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
): Readonly<Record<string, string | undefined>> => {
if (selected === undefined) return terminal;
const settings: Record<string, string | undefined> = {
...snapshotEnvironment(
mergeEnvironmentOverrides(terminal, selected.environment, platform),
platform,
),
};
for (const name of Object.keys(
snapshotEnvironment(
Object.fromEntries(
[...selected.unresolved].map((entry): [string, string] => [entry, ""]),
),
platform,
),
))
delete settings[name];
return settings;
};
type SelectedRegistration = {
readonly values: Readonly<Record<string, string>>;
readonly unresolvedSettings: ReadonlySet<string>;
};
/** Read the selected client's registration, or report it as unreadable. */
const readSelectedRegistration = async (
client: Parameters<typeof readEffectiveClientRegistrationConfiguration>[0],
resourceScope: RetainedResourceScope,
platform: NodeJS.Platform,
): Promise<SelectedRegistration | { readonly unknown: true } | undefined> => {
// A failed close is a retained owner, not a read outcome: transfer it to the
// caller's scope so `close()` retries it instead of losing it.
const configuration = await resourceScope
.run(() =>
readEffectiveClientRegistrationConfiguration(client, undefined, platform),
)
.catch((cause: unknown) => {
if (cause instanceof RegularFileCleanupFailure) {
resourceScope.retain(cause.owner, cause.path);
return undefined;
}
throw cause;
});
// A registration this process cannot read says nothing about the values the
// client passes on; the terminal is not those values.
if (configuration === undefined) return { unknown: true };
if (!configuration.ok) {
const cleanup = configuration.error.cleanupFailure;
if (cleanup !== undefined)
resourceScope.retain(cleanup.owner, cleanup.path);
return { unknown: true };
}
if (configuration.value.document === undefined) return undefined;
const details = clientRegistrationEnvironmentDetails(
configuration.value.document,
client.name,
{},
"effective",
platform,
);
return details.ok
? {
values: details.value.values,
unresolvedSettings: details.value.unresolvedSettings,
}
: { unknown: true };
};
/** Registration settings read by each provider's readiness probe. */
const PROVIDER_SETTINGS: Readonly<Record<string, readonly string[]>> = {
ghidra: GHIDRA_DOCTOR_SETTINGS,
ida: IDA_DOCTOR_SETTINGS,
jadx: JADX_DOCTOR_SETTINGS,
kuna: KUNA_DOCTOR_SETTINGS,
};
/**
* Whether a setting an unresolved expression removed is one this probe actually
* reads for the configuration the client supplied. A literal `JAVA_HOME` and
* jar path make JADX never consult `PATH`, so failing that probe closed would
* report `unknown` for a provider that has everything it needs.
*/
export const settingAffectsProbe = (
id: string,
name: string,
environment: Readonly<Record<string, string | undefined>>,
platform: NodeJS.Platform,
): boolean => {
const read = (key: string) =>
environment[platform === "win32" ? key.toUpperCase() : key];
switch (id) {
case "jadx":
// Java comes from JAVA_HOME when it is explicit; only then is PATH unused.
return name !== "PATH" || read("JAVA_HOME") === undefined;
case "ghidra":
// Both the install directory and the JDK fall back to PATH lookups.
return (
name !== "PATH" ||
read("GHIDRA_INSTALL_DIR") === undefined ||
read("JAVA_HOME") === undefined
);
case "kuna": {
// Only cache-location defaults are decided by REA_KUNA_CACHE_DIR. An
// unresolved command, job or timeout setting still leaves the probe's
// answer unknown, so those keep reporting unknown.
if (
name !== "HOME" &&
name !== "USERPROFILE" &&
name !== "XDG_CACHE_HOME"
)
return true;
// A literal cache directory decides the root on its own.
if (read("REA_KUNA_CACHE_DIR") !== undefined) return false;
if (name === "XDG_CACHE_HOME") return platform !== "darwin";
if (
name === "USERPROFILE" &&
platform !== "win32" &&
(read("HOME")?.length ?? 0) > 0
)
return false;
return true;
}
default:
return true;
}
};
const unresolvedProvider = (
id: string,
selected: SelectedRegistrationEnvironment | undefined,
environment: Readonly<Record<string, string | undefined>>,
platform: NodeJS.Platform,
): DoctorProviderInspection | undefined => {
if (selected === undefined) return undefined;
const normalize = (key: string) =>
platform === "win32" ? key.toUpperCase() : key;
const read = new Set((PROVIDER_SETTINGS[id] ?? []).map(normalize));
const unresolved = [...selected.unresolved].filter(
(name) =>
read.has(normalize(name)) &&
settingAffectsProbe(id, normalize(name), environment, platform),
);
if (selected.unknown !== true && unresolved.length === 0) return undefined;
const configurationKeys: Readonly<Record<string, string>> = {
ghidra: "GHIDRA_INSTALL_DIR",
ida: "REA_IDA_MCP_CONFIG",
jadx: "REA_JADX_MCP_JAR",
kuna: "REA_KUNA_COMMAND",
};
const configurationKey = configurationKeys[id];
const unknownConfiguration =
selected.unknown === true ||
configurationKey === undefined ||
unresolved.some((name) => normalize(name) === normalize(configurationKey));
const configuration =
configurationKey === undefined ? undefined : environment[configurationKey];
const configured = unknownConfiguration
? null
: id === "kuna"
? configuration !== undefined && configuration.length > 0
: configuration !== undefined;
return {
id,
configured,
available: null,
providerVersion: null,
registrationEnvironment: {},
checks: [
{
name: "configuration",
ok: false,
code: "registration_environment_unknown",
classification: "unknown",
detail:
selected.unknown === true
? "The selected client's registration could not be read or resolved."
: `The selected client's registration has unresolved settings: ${unresolved.join(", ")}.`,
remediation:
"Inspect the selected client's registration on the host and rerun doctor.",
},
],
};
};
/** Compose provider diagnostics at the outer CLI adapter boundary. */
export const createSystemDoctorHost = (
environment: Readonly<NodeJS.ProcessEnv> = process.env,
signal?: AbortSignal,
): DoctorHost => {
const registrationReads = new WeakMap<
RetainedResourceScope,
Map<string, ReturnType<typeof readIdaConfiguration>>
>();
const readRegistration = (
path: string,
resourceScope: RetainedResourceScope,
inspectionSignal: AbortSignal,
) => {
let byPath = registrationReads.get(resourceScope);
if (byPath === undefined) {
byPath = new Map();
registrationReads.set(resourceScope, byPath);
}
let registration = byPath.get(path);
if (registration === undefined) {
registration = readIdaConfiguration(path, {
resourceScope,
signal: inspectionSignal,
});
byPath.set(path, registration);
}
return registration;
};
return systemDoctorHost({
environment,
...(signal === undefined ? {} : { signal }),
resolveProviderRequestBudget: async (
analysisProvider,
registrationEnvironment,
resourceScope,
inspectionSignal,
) => {
if (analysisProvider !== "ida" && analysisProvider !== "auto")
return ok(undefined);
const path = registrationEnvironment.REA_IDA_MCP_CONFIG;
if (path === undefined) return ok(undefined);
const registration = await readRegistration(
path,
resourceScope,
inspectionSignal,
);
if (registration.ok) return ok(registration.value.timeoutMs);
// An invalid IDA registration cannot be selected automatically. Keep it
// out of recommendations for another available provider, but surface the
// parser-owned error when IDA itself was explicitly selected.
return analysisProvider === "ida" ? registration : ok(undefined);
},
providerInspections: async (
resourceScope: RetainedResourceScope,
inspectionSignal: AbortSignal,
clients: readonly string[],
) => {
const selected = await selectedRegistrationEnvironment(
clients,
environment,
resourceScope,
inspectionSignal,
);
const inspectionEnvironment = selectedProviderSettings(
selected,
environment,
);
return [
unresolvedProvider(
"ghidra",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectSystemGhidraProvider(
inspectionEnvironment,
resourceScope,
inspectionSignal,
)),
unresolvedProvider(
"ida",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectIdaRegistration(
inspectionEnvironment.REA_IDA_MCP_CONFIG,
resourceScope,
inspectionSignal,
inspectionEnvironment.REA_IDA_MCP_CONFIG === undefined
? undefined
: readRegistration(
inspectionEnvironment.REA_IDA_MCP_CONFIG,
resourceScope,
inspectionSignal,
),
)),
unresolvedProvider(
"jadx",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectJadxProvider(
inspectionEnvironment,
resourceScope,
inspectionSignal,
)),
unresolvedProvider(
"kuna",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectKunaProvider(inspectionEnvironment, process.platform, {
resourceScope,
signal: inspectionSignal,
})),
] satisfies readonly DoctorProviderInspection[];
},
desktopBuildFacts: readDesktopBuildFacts,
linuxDemoRuntimeCheck: (resourceScope, inspectionSignal) =>
inspectLinuxHopperDemoRuntime(
environment,
resourceScope,
inspectionSignal,
),
});
};
const HOPPER_DEMO_HELPER_PATH = fileURLToPath(
new URL("../scripts/hopper-demo-x11.py", import.meta.url),
);
const inspectLinuxHopperDemoRuntime = async (
environment: Readonly<NodeJS.ProcessEnv>,
resourceScope: RetainedResourceScope,
signal?: AbortSignal,
): Promise<DoctorCheck> => {
const selection = await selectLinuxPrivateDisplayStrategy({
helperPath: HOPPER_DEMO_HELPER_PATH,
environment,
...(signal === undefined ? {} : { signal }),
});
if (!selection.ok && selection.cleanup !== undefined) {
const { lease, reason } = selection.cleanup;
resourceScope.retain(
{
close: () => lease.release(),
get cleanupResources() {
return lease.cleanupResources;
},
},
"hopper-private-display-probe",
);
const cleanup = {
reason,
resources: [
...(lease.cleanupResources ?? ["hopper-private-display-probe"]),
],
};
const diagnostics = {
hopper_demo_runtime: { private_display: { ...selection.diagnostic } },
};
const failure =
signal?.aborted === true
? new AnalysisCancelledError("doctor", {
cause: signal.reason,
cleanup,
diagnostics,
})
: new ProviderCleanupError(
"hopper",
cleanup.resources,
{ reason, ...diagnostics },
{ operation: "doctor" },
);
new AnalysisCleanupLeaseGroup(failure, [lease]);
throw failure;
}
if (signal?.aborted === true)
throw new AnalysisCancelledError("doctor", {
cause: signal.reason,
diagnostics: {
hopper_demo_runtime: { private_display: { ...selection.diagnostic } },
},
});
const python = await inspectLinuxHopperPythonRuntime(environment, signal);
const pythonFailure = !python.ok
? python.error
: python.value.library.status !== "loadable"
? linuxHopperPythonUnavailable(python.value.library)
: undefined;
const pythonDiagnostic = {
scope: "system_interpreter",
hopper_interpreter: "unknown",
...(python.ok
? { status: "observed", ...python.value }
: {
status: "unknown",
reason: python.error.reason,
message: python.error.message,
}),
};
const value = selection.diagnostic;
const detail = [
`strategy=${selection.strategy}`,
`socket_directory=${value.socket_directory}`,
`mode=${value.socket_directory_mode ?? "unknown"}`,
`mount_read_only=${String(value.mount_read_only)}`,
`effective_mode=${value.effective_socket_directory_mode ?? "unknown"}`,
`effective_mount_read_only=${String(value.effective_mount_read_only)}`,
`wsl=${String(value.wsl)}`,
`system_python=${python.ok ? python.value.library.status : "unknown"}`,
].join("; ");
const details = {
private_display: { ...value },
embedded_python: pythonDiagnostic,
};
if (!python.ok && python.error.reason === "cancelled")
throw new AnalysisCancelledError("doctor", {
cause: python.error.cause,
diagnostics: { hopper_demo_runtime: details },
});
if (selection.ok && pythonFailure === undefined)
return {
name: "hopper-demo-runtime",
ok: true,
classification: "healthy",
detail,
details,
};
const missingDependency =
value.failure_code === "runtime_dependency_unavailable" ||
value.failure_code === "x11_authorization_failed" ||
(python.ok && python.value.library.status === "unavailable");
const pythonRemediation =
pythonFailure === undefined
? undefined
: pythonFailure.reason === "runtime_unavailable"
? pythonFailure.message +
" On a supported system run rea setup --install-hopper, then rerun rea doctor --provider hopper. This checks the system interpreter; a custom Hopper Python interpreter needs its own matching shared runtime."
: pythonFailure.reason === "configuration_unknown"
? pythonFailure.message +
" Inspect this interpreter's shared-runtime configuration, then rerun rea doctor --provider hopper."
: pythonFailure.message +
" Retry rea doctor --provider hopper and inspect /usr/bin/python3 if its runtime remains unknown.";
const displayRemediation = selection.ok
? undefined
: value.failure_code === "x11_socket_directory_unusable"
? "Enable unprivileged user and mount namespaces, or provide a writable mode-1777 /tmp/.X11-unix directory. Do not mutate the current host mount with sudo; then rerun rea doctor --provider hopper."
: value.failure_code === "runtime_dependency_unavailable" ||
value.failure_code === "x11_authorization_failed"
? "Install Xvfb, xauth, Python 3, libX11, and libXtst, then rerun rea doctor --provider hopper."
: "Inspect the private-display diagnostic, correct the reported local condition, and rerun rea doctor --provider hopper.";
return {
name: "hopper-demo-runtime",
ok: false,
classification: missingDependency
? "missing_dependency"
: selection.ok
? "unknown"
: "config_drift",
detail,
details,
remediation: [pythonRemediation, displayRemediation]
.filter((message) => message !== undefined)
.join(" "),
};
};