Files
rea/src/application/SetupSkill.ts
T

628 lines
20 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 } from "../filesystem/OwnedRegularFile.js";
import { withRegularFile } from "./RegularFileRead.js";
import { clientSkillDirectories } from "./SupportedClients.js";
import { preserveCurrentRecoveryBackup } from "./SetupRecoveryBackup.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(({ bytes }) =>
bytes.toString("utf8"),
);
interface SkillFileState {
readonly bytes: Buffer;
readonly mode: number;
}
const readSkillFileState = async (
path: string,
resources?: ArtifactResourceScope,
): Promise<SkillFileState> =>
withRegularFile(
path,
async (handle, stats) => {
const bytes = await readBoundedFileBytes(handle, stats.size, {
expectedSize: stats.size,
});
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return { bytes, 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 | Buffer,
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: (source, destination) =>
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
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;
readonly issues?: readonly SkillPathIssue[];
}
/** Known management blockers and unresolved filesystem observations. */
export interface SkillPathIssue {
readonly path: string;
readonly kind: "symbolic_link" | "not_directory" | "unreadable";
readonly detail: string;
}
/** 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 issues = await skillDirectoryIssues([destination]);
if (issues.some(({ kind }) => kind === "unreadable"))
throw skillDirectoryFailure(issues);
if (issues.length > 0) 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 inspectSkillDirectory = async (
path: string,
): Promise<Result<void, SkillPathIssue>> => {
try {
const directory = await lstat(path);
if (directory.isSymbolicLink())
return err({
path,
kind: "symbolic_link",
detail: "Skill directory is a symbolic link.",
});
if (!directory.isDirectory())
return err({
path,
kind: "not_directory",
detail: "Skill path is not a directory.",
});
return ok(undefined);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
return err({
path,
kind: "unreadable",
detail: cause instanceof Error ? cause.message : String(cause),
});
}
};
const skillDirectoryIssues = async (
destinations: readonly SkillDestination[],
): Promise<readonly SkillPathIssue[]> => {
const issues: SkillPathIssue[] = [];
for (const { path } of destinations) {
const root = await inspectSkillDirectory(path);
if (!root.ok) {
issues.push(root.error);
continue;
}
const references = await inspectSkillDirectory(join(path, "references"));
if (!references.ok) issues.push(references.error);
}
return issues;
};
const skillDirectoryFailure = (
issues: readonly SkillPathIssue[],
): SetupSkillInstallError =>
new SetupSkillInstallError({
path: issues[0]?.path ?? "skill bundle",
stage: "prepare",
reason: `${issues.map(({ path, detail }) => `${detail} (${path})`).join(" ")} Use a regular skill directory, or run setup with --skill=false to configure the client without installing the skill`,
rollbackFailures: [],
recoveryBackupPaths: [],
cause: issues,
});
/** Validate only final selected roots; permission to install is separate from freshness. */
export const validateCanonicalSkillDirectories = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<Result<void, SetupSkillInstallError>> => {
const issues = await skillDirectoryIssues(
skillDestinations(home, clientIds, environment, platform),
);
return issues.length === 0
? ok(undefined)
: err(skillDirectoryFailure(issues));
};
const canonicalSkillFiles = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
): Promise<readonly CanonicalSkillFile[]> => {
const destinations = skillDestinations(
home,
clientIds,
environment,
platform,
);
const admission = await validateCanonicalSkillDirectories(
home,
clientIds,
environment,
platform,
);
if (!admission.ok) throw admission.error;
return Promise.all(
destinations.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),
};
}),
),
);
};
interface CanonicalSkillInspection {
readonly canonical: boolean;
readonly manifest: string | undefined;
readonly installedDestination: boolean;
}
interface SkillInspectionFailure {
readonly _tag: "SkillInspectionFailure";
readonly issues: readonly SkillPathIssue[];
readonly cause: unknown;
readonly manifest: string | undefined;
}
const inspectCanonicalSkill = async (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
resources?: ArtifactResourceScope,
): Promise<Result<CanonicalSkillInspection, SkillInspectionFailure>> => {
const destinations = skillDestinations(
home,
clientIds,
environment,
platform,
);
const issues =
clientIds === undefined
? []
: [...(await skillDirectoryIssues(destinations))];
if (issues.length > 0)
return err({
_tag: "SkillInspectionFailure",
issues,
cause: issues,
manifest: undefined,
});
let canonical = true;
let manifest: string | undefined;
let failureCause: unknown;
let installedDestination = false;
for (const destination of destinations) {
const { path: root } = destination;
const pathIssues =
clientIds === undefined ? await skillDirectoryIssues([destination]) : [];
if (pathIssues.length > 0) {
issues.push(...pathIssues);
if (failureCause === undefined) failureCause = pathIssues;
installedDestination = true;
canonical = false;
continue;
}
let rootCanonical = true;
let rootHasFiles = false;
const rootIssues: SkillPathIssue[] = [];
for (const relativePath of MANAGED_SKILL_FILES) {
const destination = join(root, relativePath);
let original: SkillFileState | undefined;
try {
original = await readOptionalSkillState(destination, resources);
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||
cause instanceof ArtifactReaderFailure
)
throw cause;
rootIssues.push({
path: destination,
kind: "unreadable",
detail: causeReason(cause),
});
if (failureCause === undefined) failureCause = cause;
rootCanonical = false;
rootHasFiles = true;
continue;
}
if (original !== undefined) rootHasFiles = true;
if (relativePath === "SKILL.md" && manifest === undefined)
manifest = original?.bytes.toString("utf8");
// A mismatch settles freshness, but not coverage of later read failures.
if (original === undefined) rootCanonical = false;
else if (rootCanonical) {
const content = await canonicalSkillContent(relativePath);
rootCanonical = original.bytes.equals(Buffer.from(content, "utf8"));
}
}
if (clientIds !== undefined || rootHasFiles || rootIssues.length > 0) {
installedDestination ||= rootHasFiles;
canonical = canonical && rootCanonical;
issues.push(...rootIssues);
}
}
return issues.length === 0
? ok({ canonical, manifest, installedDestination })
: err({
_tag: "SkillInspectionFailure",
issues,
cause: failureCause,
manifest,
});
};
/** 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 {
const inspection = await inspectCanonicalSkill(
home,
clientIds,
environment,
platform,
resources,
);
return !inspection.ok || !inspection.value.canonical;
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||
cause instanceof ArtifactReaderFailure
)
throw cause;
// A copy needs attention; selected-root admission decides whether it may be installed.
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.bytes, 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";
const recoveryPaths = new Map<string, string>();
try {
const canonical = await canonicalSkillFiles(
home,
clientIds,
environment,
platform,
resources,
);
const changed = canonical.filter(
({ content, original }) =>
original?.bytes.equals(Buffer.from(content, "utf8")) !== true,
);
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) {
const preserved = await preserveCurrentRecoveryBackup(
`${destination}.rea.backup`,
destination,
original.bytes,
{
copy: operations.backup,
...(resources === undefined ? {} : { resources }),
},
);
if (!preserved.ok) throw new Error(preserved.error.detail);
recoveryPaths.set(destination, preserved.value);
}
}
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 readSkillFileState(destination, resources)).bytes.equals(
Buffer.from(content, "utf8"),
)
)
throw new Error(`skill readback mismatch: ${destination}`);
}
return ok("installed");
} catch (cause: unknown) {
if (stage === "prepare" && cause instanceof SetupSkillInstallError)
return err(cause);
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 || recoveryPaths.get(destination) === undefined
? []
: [recoveryPaths.get(destination)!],
),
cause,
}),
);
}
};
/** Read installed skill metadata and compare every managed location. */
export const readInstalledSkillIdentity = async (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
): Promise<InstalledSkillIdentity | undefined> => {
const inspection = await inspectCanonicalSkill(
home,
clientIds,
environment,
platform,
resources,
);
if (inspection.ok && !inspection.value.installedDestination) return undefined;
const content = inspection.ok
? inspection.value.manifest
: inspection.error.manifest;
const issues = inspection.ok ? [] : inspection.error.issues;
const toolCount =
content === undefined
? undefined
: /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
return {
canonical: inspection.ok && inspection.value.canonical,
version:
content === undefined
? null
: (/^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null),
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
...(issues.length === 0 ? {} : { issues }),
};
};