Files
rea/src/application/SetupSkill.ts
T
02e049744f feat(environment): build agent environments from a prompt (#6946)
* feat(environment): report what an environment serves with an environment-coverage view

A new inspect_analysis_view kind over capture Evidence. It projects the
state an environment derives with the given template rules and reports
each tool as answered from state, replayed with its view reason, a served
write, or a withheld write with its reason, the records behind it and a
hint about what to record next. The hints cover every write reason, so a
new reason without one fails to compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(environment): build an environment bundle in one command

rea environment-build <capture> [directory] inspects a HAR (or reads
capture Evidence), projects the environment manifest from the in-memory
Evidence, authenticates the pair, prepares the state and writes the bundle
environment-export writes. Every bundle file is byte-identical to the
three-step path for the same capture and options.

The report adds the capture and manifest identities, the coverage summary,
warnings (a loopback capture named after its file, credential-named body
members by pointer) and the next command. --dry-run writes nothing. The
default template rules are the four the published environments use.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(environment): cover environment-build and the coverage view

Domain tests check the hint table against the state view's reasons, the
summary, and the view against the environment-state view it summarizes.
Service tests build the fixture capture and compare every file with the
three-step path, a dry run, loopback naming and the refusals. A CLI test
compares a HAR built in one step with the three CLI steps byte for byte,
and an MCP test projects the view from every source kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(environment): build the real-agentenv lane's bundle with environment-build

The lane now runs the command users run: it builds the Grafana-shaped HAR
with environment-build and the packed checkout, checks the report, then
runs the bundle with agent-env as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(environment): document environment-build and the coverage view

The agent environments guide opens with the one-step build: the report,
the hint for each withheld write reason, and what a bundle ships. The CLI
guide, the capture guide and the skill reference name the command and the
environment-coverage view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): harden environment-build after review

- The credential scan walks with its own stack, so a deeply nested body
  cannot crash the build, and it also reports credential-named header
  names, query names, form body members and string arrays.
- The build checks SIGINT between stages: cancelled before the bundle
  stage it writes nothing, during the write it names the written bundle.
- A dry run opens the package tarball, so it refuses what a build refuses.
- Repeated template rules are refused, a HAR saved as .json gets a hint,
  file-derived names follow the bundle name rules, `localhost.` counts as
  a loopback origin, and the next command is shell-quoted.
- --capture-timeout-ms passes through to the HAR inspection, and a
  missing directory is refused before inspection.
- The no-observation hint covers updates and deletes of entities the state
  never held; the coverage view states that it does not apply the
  serve-time admission checks; the inspect_analysis_view description
  names the view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(environment): state the build's conditions and record its verification

The guides now state when the one-step bundle equals the three-step one,
which headers inspection excludes, what the credential warning scans, how
cancellation behaves, and that the coverage view does not apply the
serve-time admission checks. The spec folder records the census, the end
to end runs, the gates and the review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): dispatch a pending interrupt before each build stage check

The manifest, state and coverage stages run synchronously, so a SIGINT
that arrived during them was only dispatched once the bundle write had
begun. Each check now yields first, so an interrupt before the bundle
stage cancels the build with nothing written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(browser): send declared requests from a scenario page with a request step

A capture_browser_scenario step `request` sends one declared HTTP request
through the page's own fetch, with its cookies and session, the way the
application's frontend would, and reads the whole response so the capture
records the transaction like any other. The path must resolve to the start
URL's origin, and the page must be on that origin when the step runs.
Query values and header values may be literals or declared secrets, and a
header may read its value from a cookie or a local storage entry, which is
then redacted from later records like a declared secret. `expected_status`
fails the step on any other status. Scenarios without a request step are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(browser): verify request steps in a real browser through to a built environment

verify:browser:request records a write session against a fixture item API
that refuses writes without its CSRF cookie header and stored bearer
token. It checks that every request lands in its own step, that the page
values authenticated each write and appear in no record, header or body,
that an unexpected status fails the step and cancels the rest, and that
environment-build serves the recorded create, update and delete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(environment): export environment bundles over MCP

export_environment_bundle writes the bundle environment-build writes, from
capture Evidence retained in the session or one capture Evidence file,
into an absolute output directory that must not exist and whose parent
must exist, as extract_firmware and export_web_scripts require; it never
creates the parent or reuses a directory. It returns the same report,
whose schema now lives in the domain so the CLI and the tool share it, and
dry_run reports without writing. A loopback capture without a file needs a
name. The build's refusal messages no longer name CLI flags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): scan scenario credentials, own the dry-run tarball and serialize Evidence once

A browser scenario's retained header values and the query parameters of its recorded URLs are scanned for credential
names as a HAR's are, through a review projection of its records. A dry run admits and releases the package tarball
through the export's regular-file scope, so a close it cannot confirm keeps its owner, and checks for cancellation
after that last await. Evidence is written into one exactly sized buffer instead of chunks joined by a copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(environment): record the review round's checks

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(skill): build agent environments from a prompt with request steps and the bundle tool

Add the environments reference to the skill and install it with setup: record
against a disposable instance, sign in through the UI, write with request
steps and read each write back, check coverage, re-record by withheld reason
and export the bundle. Route it from SKILL.md, name agent environments in the
skill description, and document the request step, the export tool and
recording writes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(browser): split the request lane and header resolution into smaller functions

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* style(environment): format the build spec

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): say when an untimed capture learns no write clock

Coverage of a capture whose records are not all timed, which includes every
browser scenario capture, now states that the state learns no clock, so an
update stamped with a server-set modification time stays unexplained-change
however often it is recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(skill): give agents a whole scenario shape with step timeouts

Agents guessed the scenario schema field by field and two hung on steps
without timeout_ms. The reference now starts from a complete scenario with a
timeout on every step, and says a scenario capture cannot serve updates the
application stamps with a modification time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(browser): keep request steps on their origin and their page values redacted

- Follow no redirects: a redirect fails the step and nothing reaches the
  location it names (checked in real Chrome), and check the page's origin
  again in the page as the request is sent.
- Send nothing once the step's time has run out or it was cancelled while
  header values were read; the in-page deadline is what remains of the step's.
- Keep every page value redacted under a repeated or colliding label, keep a
  value's first marker when it is read again, and leave values shorter than
  8 characters unredacted rather than rewriting ids, ports and hosts.
- Refuse a header name given twice, User-Agent (Chrome drops it) and line
  breaks in literal values and prefixes; fail before sending when a secret or
  page value holds them.
- Append query entries after the path's literal query without re-encoding it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(environment): document request step limits and word bundle refusals for both surfaces

Document redirects, timeouts, page value redaction, HttpOnly cookie sources
and header refusals for request steps. Refusals shared by environment-build
and export_environment_bundle no longer name CLI flags, and the retained
loopback refusal no longer prints an empty file name. The skill reference
asks for read-backs after each write, shows the coverage view's source, names
package_tarball for unreleased builds and the browser path a scenario ships.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): scan every form occurrence and MIME-less scenario bodies

The build's credential scan collapsed form bodies with Object.fromEntries, so a
repeated field kept only its last occurrence: `api_key=secret&api_key=` warned
about nothing although the Evidence ships the value, and repeated values were
undercounted. Form fields are now scanned as ordered name/value pairs, as
headers and query strings already are.

The review projection of a capture_browser_scenario dropped a request body
recorded without a media type, so the scan never saw it. Review keeps such a
body's text with an empty media type; the manifest projection still reads only
bodies it can classify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(browser): close request step edge cases from the second review

- Append query entries correctly after a path ending in "?", "&" or "#".
- Refuse 3xx expected statuses, which an unfollowed redirect can never
  report, and say a redirected write may already have been applied.
- Redact a page value also as fetch sends it, without surrounding spaces
  and tabs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(environment): document page value body costs, redirects and MCP dry runs

Document that reading a page value shrinks the bodies a capture keeps under
the default allocation limit, that redirected writes may have landed, that
the MCP dry_run still checks its output directory and which refusals name
the tool, and keep the .har hint in the HAR-as-JSON refusal for both
surfaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(environment): record the prompt-to-environment verification

Gates, the real-browser lanes, five environments built from prompts by an
agent on disposable VMs and evaluated with two models, and the two reviews.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(skill): count rollback failures from the managed skill files

The environments reference made the skill eight files, so the recovery tests now derive the counts they expect from
MANAGED_SKILL_FILES instead of fixing them at six and seven.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(adb): restore the portable Java float pattern a merge rewrote

A lint autofix during a merge unescaped the hyphens in javaFloatValuePattern, which is invalid in a v-mode character
class, so the advertised start_adb_activity schema failed to compile in every JS regex mode. The file now matches main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(browser): show the request step inside a whole scenario

Every JSON example in the browser scenario guide is a capture_browser_scenario input the catalog test parses, so the
request step example now sits in a launch scenario instead of standing alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): scan structured HAR form params and the request side of unattributed scenario transactions

A HAR request recorded with postData.params and no text is scanned by those parameters, each occurrence counting, and
the review projection keeps the request side of a scenario transaction whose response cannot be attributed, so the
credential warning covers what evidence.json ships in both cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): validate build paths and read dry-run tarballs

* fix(environment): report bodies too large for the credential scan

Text bodies over the scan's 4 MiB limit, including a response decoded only as a prefix, are reported as a
credential-scan-incomplete warning by record and side, never by content, so an absent credential-named-members
warning no longer reads as a complete check. The spec and agent-environments guide also state the path validation and
dry-run tarball read that e52ae1cd added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(verification): release cold source transform services

* fix(javascript): retain capture context along covered mutation paths

* fix(environment): guard request dispatch and report export limits

* fix(environment): admit serialized exports and scan HAR form fallbacks

* fix(environment): admit in-memory build inputs against heap headroom and scan SaveHar null-text forms

The inspected HAR's Evidence and the projected manifest are checked against live heap headroom before they are
serialized, with the same check the file route uses, so a capture too large to parse and seal is refused as
resource_constraint before anything is written. A request whose postData text is SaveHar's null is scanned by its
structured params.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): measure the credential scan limit in UTF-8 bytes

A body counts against the 4 MiB limit by its UTF-8 length, not its UTF-16 code units, so multibyte text over the
limit is reported as unscanned instead of parsed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(browser): send request steps only before their deadline and keep page reads on the start origin

The page receives the step's absolute deadline and a one-second start window, so a busy page that runs the request
late, or after the step is cancelled, sends nothing; local storage is read only on the start origin; the response is
drained without keeping its body; an unparseable path is a field issue; retained Evidence is refused for heap headroom
before it is serialized; and the skill says how to reach server-assigned ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* style: format integrated export admission

* fix(javascript): erase inline type imports before binding values

* fix(browser): run the request lane under the shared verifier command owner

The browser request lane runs each CLI call through createPackageVerifierCommands on a private runtime root, so a
timeout, output overflow or failure stops the CLI and the browser it launched before the root is removed, and a failed
stop is reported with the root retained. The verification record states the lane's current counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(environment): scan scenario content by event and read bodies by their media type

A browser scenario's credential scan reads every retained network-content event, including a response attached
mid-flight and a transaction no record represents, with event pointers, and each transaction URL's query once; the
review record projection it replaces is removed. A body is parsed as a form or as JSON by its recorded media type,
falling back to its shape, so valid forms such as `password=x&` are scanned and sentences are not read as forms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Kaoru0822 <[email protected]>
Co-authored-by: morluto <[email protected]>
2026-10-24 21:51:41 +08:00

715 lines
23 KiB
TypeScript

import { copyFile, lstat, mkdir, readFile } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { dirname, join } from "node:path";
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";
import {
SkillFilePublication,
SkillFilePreparationFailure,
type SkillFileSnapshot,
type SkillPublicationCleanupOwner,
} from "./SkillFilePublication.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",
"references/environments.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> =>
readSkillFileSnapshot(path, resources).then(({ bytes }) =>
bytes.toString("utf8"),
);
const readSkillFileSnapshot = async (
path: string,
resources?: ArtifactResourceScope,
): Promise<SkillFileSnapshot> =>
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, state: stats };
},
{
symlinks: "reject",
...(resources === undefined ? {} : { resources }),
},
);
interface CanonicalSkillFile {
readonly content: string;
readonly destination: string;
readonly original: SkillFileSnapshot | 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 prepare: typeof SkillFilePublication.prepare;
}
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[];
readonly cleanupOwners: readonly SkillPublicationCleanupOwner[];
/** Retry only retained staging cleanup; never reapply a pathname rollback. */
async retryCleanup(): Promise<readonly SkillInstallRollbackFailure[]> {
return closeSkillPublications(this.cleanupOwners);
}
constructor(input: {
readonly path: string;
readonly stage: "prepare" | "backup" | "write" | "readback";
readonly reason: string;
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
readonly recoveryBackupPaths: readonly string[];
readonly cleanupOwners?: readonly SkillPublicationCleanupOwner[];
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;
this.cleanupOwners = input.cleanupOwners ?? [];
}
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),
prepare: SkillFilePublication.prepare,
};
/** 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"
| "unresolved"
| "stale"
| "missing";
readonly client?: string;
readonly detail: string;
}
/** Keep unresolved selected roots as diagnostics, never as writable destinations. */
export const skillDestinationSelection = (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): {
readonly destinations: readonly SkillDestination[];
readonly issues: readonly SkillPathIssue[];
} => {
const selection = clientSkillDirectories(
home,
clientIds,
environment,
platform,
);
return {
destinations: selection.directories.map(({ client, directory }) => ({
client,
path: join(directory, PRODUCT_IDENTITY.skillName),
})),
issues: selection.unresolved.map(({ client, path, detail }) => ({
client,
path,
detail,
kind: "unresolved",
})),
};
};
/** Writable destinations are available only after all selected roots resolve. */
export const skillDestinations = (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): readonly SkillDestination[] => {
const selection = skillDestinationSelection(
home,
clientIds,
environment,
platform,
);
if (selection.issues.length > 0)
throw skillDirectoryFailure(selection.issues);
return selection.destinations;
};
/** 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 destinations: readonly SkillDestination[];
readonly issues: readonly SkillPathIssue[];
}> => {
const selection = skillDestinationSelection(
home,
undefined,
environment,
platform,
);
const issues: SkillPathIssue[] = [...selection.issues];
const existing: SkillDestination[] = [];
for (const destination of selection.destinations) {
try {
const pathIssues = await skillDirectoryIssues([destination]);
if (pathIssues.some(({ kind }) => kind === "unreadable")) {
issues.push(...pathIssues);
continue;
}
if (pathIssues.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;
issues.push({
client: destination.client,
path: destination.path,
kind: "unreadable",
detail: causeReason(cause),
});
}
}
return { destinations: existing, issues };
};
const readOptionalSkillState = async (
path: string,
resources?: ArtifactResourceScope,
): Promise<SkillFileSnapshot | undefined> => {
try {
return await readSkillFileSnapshot(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 selection = skillDestinationSelection(
home,
clientIds,
environment,
platform,
);
const issues = [
...selection.issues,
...(await skillDirectoryIssues(selection.destinations)),
];
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 selection = skillDestinationSelection(
home,
clientIds,
environment,
platform,
);
const destinations = selection.destinations;
const issues: SkillPathIssue[] = [...selection.issues];
let canonical = true;
let manifest: string | undefined;
let failureCause: unknown = issues.length > 0 ? issues : undefined;
let installedDestination = false;
for (const destination of destinations) {
const { path: root } = destination;
const pathIssues = 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: SkillFileSnapshot | 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);
if (!rootCanonical && rootIssues.length === 0)
issues.push({
client: destination.client,
path: root,
kind: rootHasFiles ? "stale" : "missing",
detail: `Selected skill copy is ${rootHasFiles ? "stale" : "missing"}`,
});
}
}
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 closeSkillPublications = async (
publications: readonly SkillPublicationCleanupOwner[],
resources?: ArtifactResourceScope,
): Promise<readonly SkillInstallRollbackFailure[]> => {
const failures: SkillInstallRollbackFailure[] = [];
for (const publication of publications) {
if (publication.lease.released) continue;
try {
if (resources === undefined) await publication.close();
else {
const released = await resources.release(publication);
if (released.kind === "failed") throw released.cause;
}
} catch (cause: unknown) {
failures.push({
path: publication.resource,
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> => {
const publications: SkillFilePublication[] = [];
const preparedCleanupOwners: SkillPublicationCleanupOwner[] = [];
let verified = false;
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;
const publication = await operations.prepare(
file.destination,
Buffer.from(file.content, "utf8"),
file.original,
);
// Ownership exists before publish can succeed and subsequently reject.
publications.push(publication);
await publication.publish();
}
stage = "readback";
for (const publication of publications) {
failingPath = publication.path;
await publication.assertCurrent();
}
verified = true;
const cleanupFailures = await closeSkillPublications(
publications,
resources,
);
if (cleanupFailures.length > 0)
throw new Error(
`Skill publication cleanup incomplete: ${cleanupFailures.map(({ path, reason }) => `${path} (${reason})`).join(", ")}`,
);
return ok("installed");
} catch (cause: unknown) {
if (stage === "prepare" && cause instanceof SetupSkillInstallError)
return err(cause);
failingPath = causePath(cause) ?? failingPath;
if (cause instanceof SkillFilePreparationFailure)
preparedCleanupOwners.push(cause.publication);
const rollbackFailures: SkillInstallRollbackFailure[] = [];
if (!verified) {
for (const publication of [...publications].reverse()) {
try {
await publication.restore();
} catch (rollbackCause: unknown) {
rollbackFailures.push({
path: publication.path,
reason: causeReason(rollbackCause),
});
}
}
}
const cleanupOwners = [...publications, ...preparedCleanupOwners];
rollbackFailures.push(
...(await closeSkillPublications(cleanupOwners, resources)),
);
return err(
new SetupSkillInstallError({
path: failingPath,
stage,
reason: causeReason(cause),
rollbackFailures,
recoveryBackupPaths: [...recoveryPaths.values()],
cleanupOwners,
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 }),
};
};