openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
288 lines (287 loc) • 13.3 kB
JavaScript
import { t as containsAsciiControlCharacter } from "./string-normalization-CECOqf64.mjs";
import { O as walkDirectory, o as ensureAbsoluteDirectory, w as root } from "./fs-safe-BUXBSN-4.mjs";
import { n as isErrno, t as hasErrnoCode } from "./errno-FR4CRyya.mjs";
import { r as resolveStateDir } from "./state-dir-BHJORNly.mjs";
import "./paths-DGIwWbpu.mjs";
import { n as sha256Hex } from "./node-crypto-BG4u3UnX.mjs";
import "./crypto-digest-y6oZN-_F.mjs";
import { t as SkillLibraryError } from "./skill-library-error-z-Nwz4n5.mjs";
import { r as SKILL_LIBRARY_MAX_FILE_BYTES } from "./skill-library-Bgib5R_8.mjs";
import { t as retainMutationAuthority } from "./mutation-authority-E8BC9dNY.mjs";
import { t as parseSkillFrontmatter } from "./frontmatter-CrckYGsg.mjs";
import path from "node:path";
import fs from "node:fs/promises";
import { Type } from "typebox";
import { Value } from "typebox/value";
const SKILL_LIBRARY_MAX_TREE_ENTRIES = 512;
/** Identifies the exact directory failure that prevented complete skill-tree traversal. */
var SkillTreeDirectoryError = class extends SkillLibraryError {
constructor(rootPath, failedPath, cause) {
super("INVALID_BUNDLE", `Skill tree directory could not be read: root=${JSON.stringify(rootPath)} path=${JSON.stringify(failedPath)} error=${describeSkillTreeFailure(cause)}`, void 0, { cause });
this.rootPath = rootPath;
this.failedPath = failedPath;
}
};
const portableCompare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
const manifestSchema = Type.Array(Type.Object({
path: Type.String({ maxLength: 512 }),
sha256: Type.String({ pattern: "^[a-f0-9]{64}$" }),
sizeBytes: Type.Integer({
minimum: 0,
maximum: SKILL_LIBRARY_MAX_FILE_BYTES
}),
executable: Type.Boolean()
}, { additionalProperties: false }), {
minItems: 1,
maxItems: 256
});
/** Published executable flags are portable metadata; host mode bits are not their authority. */
async function readSkillLibraryManifestTree(directory, manifestJson, revision) {
const manifest = JSON.parse(manifestJson);
if (!Value.Check(manifestSchema, manifest)) throw new SkillLibraryError("INVALID_BUNDLE", "Published skill manifest is invalid.");
const safeRoot = await root(directory);
const files = [];
let total = 0;
for (const file of manifest) {
validateSkillLibraryPath(file.path);
total += file.sizeBytes;
if (total > 8388608) throw new SkillLibraryError("INVALID_BUNDLE", "Published skill manifest exceeds bundle limits.");
const { buffer } = await safeRoot.read(file.path, {
hardlinks: "reject",
symlinks: "reject",
maxBytes: file.sizeBytes
});
if (buffer.length !== file.sizeBytes || sha256Hex(buffer) !== file.sha256) throw new SkillLibraryError("INVALID_BUNDLE", `Published skill file failed integrity verification: ${file.path}`);
files.push({
path: file.path,
content: buffer.toString("base64"),
encoding: "base64",
executable: file.executable
});
}
if (prepareSkillLibraryBundle(files).revision !== revision) throw new SkillLibraryError("INVALID_BUNDLE", "Published skill revision failed integrity verification.");
return files;
}
function validateSkillLibraryPath(filePath) {
validateSkillBundlePath(filePath);
if (filePath.split("/").some((part) => [
".git",
"node_modules",
".openclaw"
].includes(part.toLowerCase()))) throw new SkillLibraryError("INVALID_BUNDLE", `Non-portable skill file path: ${filePath}`);
}
function validateSkillBundlePath(filePath) {
const parts = filePath.split("/");
if (filePath.length > 512 || Buffer.from(filePath, "utf8").toString("utf8") !== filePath || parts.length > 16 || parts.some((part) => !part || part === "." || part === ".." || /[\\<>:"|?*]/u.test(part) || containsAsciiControlCharacter(part) || /[ .]$/u.test(part) || part !== part.normalize("NFC") || /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(part) || /^(conin|conout)\$$/iu.test(part))) throw new SkillLibraryError("INVALID_BUNDLE", `Non-portable skill file path: ${filePath}`);
}
function decodeSkillLibraryFile(file) {
const bytes = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8");
if (file.encoding === "base64" && bytes.toString("base64") !== file.content) throw new SkillLibraryError("INVALID_BUNDLE", `Invalid base64: ${file.path}`);
return bytes;
}
/** Validate exact portable artifacts without imposing publication metadata on loaded skills. */
function prepareSkillBundle(files) {
if (files.length > 256) throw new SkillLibraryError("INVALID_BUNDLE", "Skill bundle exceeds 256 files.");
const paths = /* @__PURE__ */ new Set();
let total = 0;
const prepared = files.map((file) => {
validateSkillBundlePath(file.path);
const folded = file.path.toLowerCase();
if (paths.has(folded)) throw new SkillLibraryError("INVALID_BUNDLE", `Duplicate skill file: ${file.path}`);
paths.add(folded);
const bytes = decodeSkillLibraryFile(file);
total += bytes.length;
if (bytes.length > 1048576 || total > 8388608) throw new SkillLibraryError("INVALID_BUNDLE", "Skill bundle exceeds file (1 MiB) or total (8 MiB) limit.");
return {
path: file.path,
bytes,
sha256: sha256Hex(bytes),
sizeBytes: bytes.length,
executable: file.executable === true
};
}).toSorted((a, b) => portableCompare(a.path, b.path));
for (const file of prepared) {
const parts = file.path.toLowerCase().split("/");
parts.pop();
while (parts.length) {
if (paths.has(parts.join("/"))) throw new SkillLibraryError("INVALID_BUNDLE", `File/directory collision: ${file.path}`);
parts.pop();
}
}
const skillMd = prepared.find((file) => file.path === "SKILL.md");
if (!skillMd || !Buffer.from(skillMd.bytes.toString("utf8")).equals(skillMd.bytes)) throw new SkillLibraryError("INVALID_BUNDLE", "Bundle requires a UTF-8 SKILL.md.");
const manifest = prepared.map(({ bytes: _bytes, ...file }) => file);
return {
revision: sha256Hex(JSON.stringify(["openclaw.skill-library.tree.v1", manifest])),
files: prepared
};
}
function prepareSkillLibraryBundle(files) {
const bundle = prepareSkillBundle(files);
for (const file of bundle.files) validateSkillLibraryPath(file.path);
const frontmatter = parseSkillFrontmatter(bundle.files.find((file) => file.path === "SKILL.md").bytes.toString("utf8"));
if (!frontmatter.name?.trim() || !frontmatter.description?.trim() || frontmatter.description.length > 1024) throw new SkillLibraryError("INVALID_BUNDLE", "SKILL.md requires name and description (at most 1,024 characters).");
return {
...bundle,
description: frontmatter.description
};
}
function skillLibraryRevisionDir(skillId, revision, env) {
if (!/^[a-f0-9-]{36}$/u.test(skillId) || !/^[a-f0-9]{64}$/u.test(revision)) throw new SkillLibraryError("INVALID_BUNDLE", "Invalid skill revision reference.");
return path.join(resolveStateDir(env), "skill-library", skillId, "revisions", revision);
}
async function syncDirectory(directory) {
try {
const handle = await fs.open(directory, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
} catch (error) {
if (!isErrno(error) || ![
"EINVAL",
"ENOTSUP",
"EISDIR",
"EPERM"
].includes(error.code ?? "")) throw error;
}
}
async function cleanAbandonedSkillStaging(parent) {
for (const entry of await fs.readdir(parent, { withFileTypes: true })) {
const match = /^\.staging-([0-9]+)-[a-zA-Z0-9]+$/u.exec(entry.name);
if (!match || !entry.isDirectory()) continue;
const staging = path.join(parent, entry.name);
const stat = await fs.lstat(staging);
if (Date.now() - stat.mtimeMs < 36e5) continue;
try {
process.kill(Number(match[1]), 0);
} catch (error) {
if (hasErrnoCode(error, "ESRCH")) await fs.rm(staging, {
recursive: true,
force: true
});
}
}
}
async function stageSkillLibraryBundle(skillId, bundle, env, assertFileMutationAllowed) {
const assertCurrent = assertFileMutationAllowed ? retainMutationAuthority(assertFileMutationAllowed) : void 0;
assertCurrent?.();
const destination = skillLibraryRevisionDir(skillId, bundle.revision, env);
const parent = path.dirname(destination);
const ensured = await ensureAbsoluteDirectory(parent, { mode: 448 });
if (!ensured.ok) throw ensured.error;
await cleanAbandonedSkillStaging(parent);
assertCurrent?.();
const staging = await fs.mkdtemp(path.join(parent, `.staging-${process.pid}-`));
try {
const stagingRoot = await root(staging, { assertBeforeMutation: assertCurrent });
const directories = /* @__PURE__ */ new Set([staging]);
for (const file of bundle.files) {
const target = path.join(staging, file.path);
assertCurrent?.();
await fs.mkdir(path.dirname(target), {
recursive: true,
mode: 448
});
let directory = path.dirname(target);
while (directory !== parent) {
directories.add(directory);
directory = path.dirname(directory);
}
await stagingRoot.create(`./${file.path}`, file.bytes, {
mode: (file.executable ? 320 : 256) & ~process.umask(),
mkdir: false,
durable: "file"
});
}
for (const directory of [...directories].toSorted((a, b) => b.length - a.length)) await syncDirectory(directory);
return {
staging,
async publish() {
assertCurrent?.();
try {
await fs.rename(staging, destination);
} catch (error) {
if (!isErrno(error) || !["EEXIST", "ENOTEMPTY"].includes(error.code ?? "")) throw error;
await readSkillLibraryManifestTree(destination, JSON.stringify(bundle.files.map(({ bytes: _bytes, ...file }) => file)), bundle.revision);
}
await syncDirectory(parent);
await syncDirectory(path.dirname(parent));
await syncDirectory(path.dirname(path.dirname(parent)));
await syncDirectory(path.dirname(path.dirname(path.dirname(parent))));
return destination;
},
async cleanup() {
await fs.rm(staging, {
recursive: true,
force: true
});
}
};
} catch (error) {
await fs.rm(staging, {
recursive: true,
force: true
});
throw error;
}
}
async function readSkillLibraryTree(directory) {
const files = await readSkillBundleTree(directory);
for (const file of files) validateSkillLibraryPath(file.path);
return files;
}
function describeSkillTreeFailure(error) {
if (isErrno(error) && error.code) return `${error.code}: ${error.message}`;
return error instanceof Error ? error.message : String(error);
}
async function readSkillBundleTree(directory, includePath, options) {
const symlinks = options?.symlinks ?? "reject";
const include = includePath ? (entry) => includePath(entry.path) : void 0;
const walked = await walkDirectory(directory, {
maxDepth: 17,
maxEntries: SKILL_LIBRARY_MAX_TREE_ENTRIES,
symlinks: "include",
include,
descend: include
});
if (walked.truncated || walked.entries.some((entry) => entry.depth > 16)) throw new SkillLibraryError("INVALID_BUNDLE", "Skill tree exceeds traversal limits.");
if (walked.failedDirs.length) {
const failed = walked.failedDirs[0];
throw new SkillTreeDirectoryError(directory, failed.path, failed.error);
}
const safeRoot = await root(directory).catch((error) => {
throw new SkillTreeDirectoryError(directory, directory, error);
});
const includedFiles = new Set(walked.entries.filter((entry) => entry.kind === "file").map((entry) => entry.relativePath));
const files = [];
let total = 0;
for (const entry of walked.entries) {
if (entry.kind === "directory") continue;
if (entry.kind !== "file" && !(entry.kind === "symlink" && symlinks === "follow-within-root")) throw new SkillLibraryError("INVALID_BUNDLE", `Skill trees cannot contain links or special files: root=${JSON.stringify(directory)} path=${JSON.stringify(entry.path)} kind=${entry.kind}.`);
const portablePath = entry.relativePath.split(path.sep).join("/");
validateSkillBundlePath(portablePath);
const read = await safeRoot.read(entry.relativePath, {
hardlinks: "reject",
symlinks,
maxBytes: SKILL_LIBRARY_MAX_FILE_BYTES
}).catch((error) => {
throw new SkillLibraryError("INVALID_BUNDLE", `Skill tree file could not be read: root=${JSON.stringify(directory)} path=${JSON.stringify(entry.path)} error=${describeSkillTreeFailure(error)}`, void 0, { cause: error });
});
if (symlinks === "follow-within-root" && !includedFiles.has(path.relative(safeRoot.rootReal, read.realPath))) throw new SkillLibraryError("INVALID_BUNDLE", `Skill tree link target is not an included regular file: root=${JSON.stringify(directory)} path=${JSON.stringify(entry.path)}.`);
options?.assertFileAccess?.(entry.path, read.realPath);
const { buffer, stat } = read;
total += buffer.length;
if (total > 8388608 || files.length >= 256) throw new SkillLibraryError("INVALID_BUNDLE", "Skill tree exceeds bundle limits.");
files.push({
path: portablePath,
content: buffer.toString("base64"),
encoding: "base64",
executable: (stat.mode & 73) !== 0
});
}
return files.toSorted((a, b) => portableCompare(a.path, b.path));
}
//#endregion
export { prepareSkillLibraryBundle as a, readSkillLibraryTree as c, validateSkillLibraryPath as d, prepareSkillBundle as i, skillLibraryRevisionDir as l, SkillTreeDirectoryError as n, readSkillBundleTree as o, decodeSkillLibraryFile as r, readSkillLibraryManifestTree as s, SKILL_LIBRARY_MAX_TREE_ENTRIES as t, stageSkillLibraryBundle as u };