* fix(host): separate REA host support from Hopper installer targets LinuxDistribution.supported carried two meanings at once: that the host runs REA, and that the automated Hopper installer is tested on it. The doctor read the first meaning from the second, so Debian-derived hosts such as Kali reported unsupported_host while their providers worked normally. Rename the field to hopperSetupSupported, which only the Hopper installer and setup planning consume, and derive host support from the package family through supportsReaHost(). Any distribution with a recognized apt, dnf, or pacman family is now a REA host. The tested Hopper installer list is unchanged, so Debian and Kali keep falling back to configuring an existing provider instead of downloading an untested package. Verified on Kali 2026.3 (Debian testing, x86_64): rea doctor reports a healthy environment with Ghidra 12.1.4, and analyze_function decompiles an x86_64 ELF through the MCP server. npm run check, npm run docs:check, and the application and Ghidra suites pass. No Hopper or IDA host workflow was exercised. * fix(setup): stop skill checks after first drift --------- Co-authored-by: morluto <[email protected]>
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import { copyFile, lstat, mkdir, readFile, rm } from "node:fs/promises";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import writeFileAtomic from "write-file-atomic";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
|
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import {
|
|
NonRegularFileReadError,
|
|
RegularFileChangedError,
|
|
} from "../filesystem/RegularFile.js";
|
|
import {
|
|
RegularFileCleanupFailure,
|
|
withRegularFile,
|
|
} from "./RegularFileRead.js";
|
|
import { clientSkillDirectories } from "./SupportedClients.js";
|
|
|
|
export const MANAGED_SKILL_FILES = [
|
|
"SKILL.md",
|
|
"references/connection-and-recovery.md",
|
|
"references/native-and-artifacts.md",
|
|
"references/javascript-applications.md",
|
|
"references/android-applications.md",
|
|
"references/runtime-observation.md",
|
|
"references/evidence-workflows.md",
|
|
] as const;
|
|
|
|
export const isManagedSkillManifest = (content: string): boolean =>
|
|
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
|
|
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
|
|
|
|
export const readSkillFile = (
|
|
path: string,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<string> =>
|
|
readSkillFileState(path, resources).then(({ content }) => content);
|
|
|
|
interface SkillFileState {
|
|
readonly content: string;
|
|
readonly mode: number;
|
|
}
|
|
|
|
const readSkillFileState = async (
|
|
path: string,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<SkillFileState> =>
|
|
withRegularFile(
|
|
path,
|
|
async (handle, stats) => {
|
|
const bytes = await readBoundedFileBytes(
|
|
handle,
|
|
stats.size,
|
|
undefined,
|
|
stats.size,
|
|
);
|
|
if (bytes === undefined || bytes.length !== stats.size)
|
|
throw new RegularFileChangedError(path);
|
|
return { content: bytes.toString("utf8"), mode: stats.mode & 0o7777 };
|
|
},
|
|
{
|
|
symlinks: "reject",
|
|
...(resources === undefined ? {} : { resources }),
|
|
},
|
|
);
|
|
|
|
interface CanonicalSkillFile {
|
|
readonly content: string;
|
|
readonly destination: string;
|
|
readonly original: SkillFileState | undefined;
|
|
}
|
|
|
|
const canonicalSkillContent = (relativePath: string): Promise<string> =>
|
|
readFile(
|
|
new URL(
|
|
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
export interface SkillInstallFileOperations {
|
|
readonly mkdir: (path: string) => Promise<void>;
|
|
readonly backup: (source: string, destination: string) => Promise<void>;
|
|
readonly write: (
|
|
path: string,
|
|
content: string,
|
|
mode: number,
|
|
) => Promise<void>;
|
|
readonly remove: (path: string) => Promise<void>;
|
|
}
|
|
|
|
export interface SkillInstallRollbackFailure {
|
|
readonly path: string;
|
|
readonly reason: string;
|
|
}
|
|
|
|
/** Expected failure while replacing a managed skill bundle. */
|
|
export class SetupSkillInstallError extends Error {
|
|
readonly _tag = "SetupSkillInstallError";
|
|
readonly path: string;
|
|
readonly stage: "prepare" | "backup" | "write" | "readback";
|
|
readonly reason: string;
|
|
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
|
|
readonly recoveryBackupPaths: readonly string[];
|
|
|
|
constructor(input: {
|
|
readonly path: string;
|
|
readonly stage: "prepare" | "backup" | "write" | "readback";
|
|
readonly reason: string;
|
|
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
|
|
readonly recoveryBackupPaths: readonly string[];
|
|
readonly cause: unknown;
|
|
}) {
|
|
const message = [
|
|
`REA skill ${input.stage} failed at ${input.path}: ${input.reason}.`,
|
|
...(input.rollbackFailures.length === 0
|
|
? []
|
|
: [
|
|
`Rollback also failed for ${input.rollbackFailures.map(({ path, reason }) => `${path} (${reason})`).join(", ")}.`,
|
|
...(input.recoveryBackupPaths.length === 0
|
|
? []
|
|
: [
|
|
`Existing-file backups remain at ${input.recoveryBackupPaths.join(", ")}.`,
|
|
]),
|
|
]),
|
|
].join(" ");
|
|
super(message, { cause: input.cause });
|
|
this.path = input.path;
|
|
this.stage = input.stage;
|
|
this.reason = input.reason;
|
|
this.rollbackFailures = input.rollbackFailures;
|
|
this.recoveryBackupPaths = input.recoveryBackupPaths;
|
|
}
|
|
|
|
get remediation(): string {
|
|
return `${this.message} Check permissions and rerun setup.`;
|
|
}
|
|
}
|
|
|
|
export type SkillInstallValue = "installed" | "unchanged";
|
|
export type SkillInstallResult = Result<
|
|
SkillInstallValue,
|
|
SetupSkillInstallError
|
|
>;
|
|
|
|
const systemSkillInstallFileOperations: SkillInstallFileOperations = {
|
|
mkdir: async (path) => {
|
|
await mkdir(path, { recursive: true });
|
|
},
|
|
backup: async (source, destination) => {
|
|
try {
|
|
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
|
return;
|
|
throw cause;
|
|
}
|
|
},
|
|
write: (path, content, mode) =>
|
|
writeFileAtomic(path, content, { encoding: "utf8", mode }),
|
|
remove: (path) => rm(path, { force: true }),
|
|
};
|
|
|
|
/** Managed skill location selected for a client family. */
|
|
export interface SkillDestination {
|
|
readonly client: string;
|
|
readonly path: string;
|
|
}
|
|
|
|
/** Metadata and byte alignment observed in selected skill installations. */
|
|
export interface InstalledSkillIdentity {
|
|
readonly version: string | null;
|
|
readonly toolCount: number | null;
|
|
readonly canonical: boolean;
|
|
}
|
|
|
|
/** Resolve the skill roots required by a client selection, or all owned roots. */
|
|
export const skillDestinations = (
|
|
home: string,
|
|
clientIds: readonly string[] | undefined,
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
): readonly SkillDestination[] =>
|
|
clientSkillDirectories(home, clientIds, environment, platform).map(
|
|
({ client, directory }) => ({
|
|
client,
|
|
path: join(directory, PRODUCT_IDENTITY.skillName),
|
|
}),
|
|
);
|
|
|
|
/** Resolve existing REA-owned skill roots using setup's canonical destinations. */
|
|
export const existingSkillDestinations = async (
|
|
home: string,
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<readonly SkillDestination[]> => {
|
|
const existing: SkillDestination[] = [];
|
|
for (const destination of skillDestinations(
|
|
home,
|
|
undefined,
|
|
environment,
|
|
platform,
|
|
)) {
|
|
try {
|
|
const root = await lstat(destination.path);
|
|
if (root.isSymbolicLink() || !root.isDirectory()) continue;
|
|
const manifest = join(destination.path, "SKILL.md");
|
|
const content = await readSkillFile(manifest, resources);
|
|
if (isManagedSkillManifest(content)) existing.push(destination);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
continue;
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure ||
|
|
cause instanceof ArtifactReaderFailure
|
|
)
|
|
throw cause;
|
|
if (cause instanceof NonRegularFileReadError) continue;
|
|
throw cause;
|
|
}
|
|
}
|
|
return existing;
|
|
};
|
|
|
|
const readOptionalSkillState = async (
|
|
path: string,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<SkillFileState | undefined> => {
|
|
try {
|
|
return await readSkillFileState(path, resources);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
return undefined;
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const canonicalSkillFiles = async (
|
|
home: string,
|
|
clientIds: readonly string[],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<readonly CanonicalSkillFile[]> =>
|
|
Promise.all(
|
|
skillDestinations(home, clientIds, environment, platform).flatMap(
|
|
({ path: root }) =>
|
|
MANAGED_SKILL_FILES.map(async (relativePath) => {
|
|
const destination = join(root, relativePath);
|
|
return {
|
|
destination,
|
|
content: await canonicalSkillContent(relativePath),
|
|
original: await readOptionalSkillState(destination, resources),
|
|
};
|
|
}),
|
|
),
|
|
);
|
|
|
|
const canonicalSkillNeedsInstallByReading = async (
|
|
home: string,
|
|
clientIds: readonly string[],
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
platform: NodeJS.Platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<boolean> => {
|
|
for (const { path: root } of skillDestinations(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
)) {
|
|
for (const relativePath of MANAGED_SKILL_FILES) {
|
|
const destination = join(root, relativePath);
|
|
const content = await canonicalSkillContent(relativePath);
|
|
const original = await readOptionalSkillState(destination, resources);
|
|
if (original?.content !== content) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/** Report whether setup would change any file in the managed REA skill bundle. */
|
|
export const canonicalSkillNeedsInstall = async (
|
|
home: string,
|
|
clientIds: readonly string[] = [],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<boolean> => {
|
|
try {
|
|
return await canonicalSkillNeedsInstallByReading(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
resources,
|
|
);
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure ||
|
|
cause instanceof ArtifactReaderFailure
|
|
)
|
|
throw cause;
|
|
// Unreadable skill state fails open to install so setup can repair it.
|
|
void cause;
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const causeReason = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
const causePath = (cause: unknown): string | undefined =>
|
|
cause instanceof Error && "path" in cause && typeof cause.path === "string"
|
|
? cause.path
|
|
: undefined;
|
|
|
|
const restoreSkillFiles = async (
|
|
attempted: readonly CanonicalSkillFile[],
|
|
operations: SkillInstallFileOperations,
|
|
): Promise<readonly SkillInstallRollbackFailure[]> => {
|
|
const failures: SkillInstallRollbackFailure[] = [];
|
|
for (const { destination, original } of [...attempted].reverse()) {
|
|
try {
|
|
if (original === undefined) await operations.remove(destination);
|
|
else await operations.write(destination, original.content, original.mode);
|
|
} catch (cause: unknown) {
|
|
failures.push({ path: destination, reason: causeReason(cause) });
|
|
}
|
|
}
|
|
return failures;
|
|
};
|
|
|
|
/** Transactionally install the canonical skill bundle and optional references. */
|
|
export const installCanonicalSkill = async (
|
|
home: string,
|
|
clientIds: readonly string[] = [],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
operations: SkillInstallFileOperations = systemSkillInstallFileOperations,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<SkillInstallResult> => {
|
|
let attempted: CanonicalSkillFile[] = [];
|
|
let stage: "prepare" | "backup" | "write" | "readback" = "prepare";
|
|
let failingPath = "skill bundle";
|
|
try {
|
|
const canonical = await canonicalSkillFiles(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
resources,
|
|
);
|
|
const changed = canonical.filter(
|
|
({ content, original }) => original?.content !== content,
|
|
);
|
|
if (changed.length === 0) return ok("unchanged");
|
|
|
|
stage = "backup";
|
|
for (const { destination, original } of changed) {
|
|
failingPath = destination;
|
|
await operations.mkdir(dirname(destination));
|
|
if (original !== undefined)
|
|
await operations.backup(destination, `${destination}.rea.backup`);
|
|
}
|
|
stage = "write";
|
|
for (const file of changed) {
|
|
failingPath = file.destination;
|
|
// An atomic writer can publish and then reject; include the target before
|
|
// entering it so rollback covers that ambiguous outcome.
|
|
attempted.push(file);
|
|
await operations.write(
|
|
file.destination,
|
|
file.content,
|
|
file.original?.mode ?? 0o600,
|
|
);
|
|
}
|
|
stage = "readback";
|
|
for (const { destination, content } of changed) {
|
|
failingPath = destination;
|
|
if ((await readSkillFile(destination, resources)) !== content)
|
|
throw new Error(`skill readback mismatch: ${destination}`);
|
|
}
|
|
return ok("installed");
|
|
} catch (cause: unknown) {
|
|
failingPath = causePath(cause) ?? failingPath;
|
|
const rollbackFailures = await restoreSkillFiles(attempted, operations);
|
|
return err(
|
|
new SetupSkillInstallError({
|
|
path: failingPath,
|
|
stage,
|
|
reason: causeReason(cause),
|
|
rollbackFailures,
|
|
recoveryBackupPaths: attempted.flatMap(({ destination, original }) =>
|
|
original === undefined ? [] : [`${destination}.rea.backup`],
|
|
),
|
|
cause,
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Read installed skill metadata and compare every managed location. */
|
|
export const readInstalledSkillIdentity = async (
|
|
home: string,
|
|
clientIds: readonly string[],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<InstalledSkillIdentity | undefined> => {
|
|
const destinations = skillDestinations(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
);
|
|
let content: string | undefined;
|
|
for (const destination of destinations) {
|
|
try {
|
|
content = await readSkillFile(
|
|
join(destination.path, "SKILL.md"),
|
|
resources,
|
|
);
|
|
break;
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure ||
|
|
cause instanceof ArtifactReaderFailure
|
|
)
|
|
throw cause;
|
|
// Another selected copy may provide metadata; canonical validation below
|
|
// still marks missing or unreadable copies as stale.
|
|
}
|
|
}
|
|
if (content === undefined) return undefined;
|
|
const toolCount = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
|
|
let canonical: boolean;
|
|
try {
|
|
canonical = !(await canonicalSkillNeedsInstall(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
resources,
|
|
));
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
|
canonical = false;
|
|
}
|
|
return {
|
|
canonical,
|
|
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
|
|
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
|
|
};
|
|
};
|