Files

536 lines
19 KiB
TypeScript

import { randomBytes } from "node:crypto";
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync
} from "node:fs";
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
boundSkillText,
firstManifestField,
firstMarkdownSummary,
parseFrontmatter
} from "./codex-stdio-session-helpers.ts";
import { createHwlabAgentRunAssemblySummary } from "../agent/agentrun-dispatch.mjs";
export const DEFAULT_PREINSTALLED_SKILLS_DIR = "/app/skills";
export const DEFAULT_USER_SKILLS_DIR = "/data/user-skills";
export const USER_SKILL_METADATA_FILE = ".hwlab-skill.json";
export const USER_SKILLS_INDEX_FILE = ".hwlab-user-skills.json";
const DEFAULT_CODE_AGENT_WORKSPACE = "/workspace/hwlab";
export function configureSkillRuntime(env = process.env) {
const config = skillRuntimeConfig(env);
env.HWLAB_PREINSTALLED_SKILLS_DIR ||= config.preinstalledDir;
env.HWLAB_USER_SKILLS_DIR ||= config.userSkillsDir;
const configured = splitSkillDirs(env.HWLAB_CODE_AGENT_SKILLS_DIRS || env.UNIDESK_SKILLS_PATH || "");
const strict = env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1";
const nextDirs = strict && configured.length > 0
? configured
: [...configured, config.preinstalledDir, config.userSkillsDir];
env.HWLAB_CODE_AGENT_SKILLS_DIRS = uniquePaths(nextDirs).join(path.delimiter);
return config;
}
export function skillRuntimeConfig(env = process.env) {
const workspace = path.resolve(String(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || DEFAULT_CODE_AGENT_WORKSPACE));
const preinstalledDir = path.resolve(String(env.HWLAB_PREINSTALLED_SKILLS_DIR || DEFAULT_PREINSTALLED_SKILLS_DIR));
const userSkillsDir = path.resolve(String(env.HWLAB_USER_SKILLS_DIR || DEFAULT_USER_SKILLS_DIR));
return {
workspace,
preinstalledDir,
userSkillsDir,
codexSkillsDir: path.join(workspace, ".agents", "skills")
};
}
export function splitSkillDirs(value) {
return String(value ?? "")
.split(/[,;]/u)
.flatMap((part) => part.split(path.delimiter))
.map((dir) => dir.trim())
.filter(Boolean);
}
export async function listSkills(options = {}) {
const env = options.env ?? process.env;
const config = skillRuntimeConfig(env);
await mkdir(config.userSkillsDir, { recursive: true });
const [preinstalled, uploaded] = await Promise.all([
listPreinstalledSkills(config.preinstalledDir),
listUploadedSkills(config.userSkillsDir)
]);
ensureCodexSkillsAggregationSync(config);
const skills = [...preinstalled, ...uploaded].sort(compareSkillItems);
return {
serviceId: "hwlab-cloud-api",
route: "/v1/skills",
status: "ok",
roots: {
preinstalled: config.preinstalledDir,
uploaded: config.userSkillsDir,
codexAggregation: config.codexSkillsDir
},
agentRunAssembly: createHwlabAgentRunAssemblySummary({ env }),
count: skills.length,
skills
};
}
export async function uploadUserSkill(payload, options = {}) {
const env = options.env ?? process.env;
const config = skillRuntimeConfig(env);
const prepared = prepareUploadedFiles(payload?.files);
if (prepared.length === 0) {
throw httpError(400, "invalid_skill_upload", "Skill upload body must include files.");
}
await mkdir(config.userSkillsDir, { recursive: true });
const id = await allocateUserSkillId(config.userSkillsDir);
const rootDir = path.join(config.userSkillsDir, id);
await mkdir(rootDir, { recursive: false });
let sizeBytes = 0;
try {
for (const file of prepared) {
const target = safeJoin(rootDir, file.relativePath);
if (!target) {
throw httpError(400, "invalid_skill_path", `Skill upload file path is outside the skill root: ${file.relativePath}`);
}
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content, { flag: "wx" });
sizeBytes += file.content.length;
}
const now = new Date().toISOString();
const manifest = await readSkillManifestSummary(path.join(rootDir, "SKILL.md"));
const metadata = {
id,
source: "uploaded",
name: nonEmptyString(payload?.name) ?? manifest?.name ?? id,
description: manifest?.description ?? "Uploaded skill",
version: manifest?.version ?? null,
commitId: manifest?.commitId ?? null,
rootDir,
manifestPath: existsSync(path.join(rootDir, "SKILL.md")) ? path.join(rootDir, "SKILL.md") : null,
createdAt: now,
updatedAt: now,
fileCount: prepared.length,
sizeBytes
};
await writeJson(path.join(rootDir, USER_SKILL_METADATA_FILE), metadata);
await writeUserSkillsIndex(config.userSkillsDir);
ensureCodexSkillsAggregationSync(config);
return {
serviceId: "hwlab-cloud-api",
route: "/v1/skills/uploads",
status: "ok",
accepted: true,
skill: metadata
};
} catch (error) {
await rmIfEmptyOrCreated(rootDir);
throw error;
}
}
export async function getSkillTree(id, options = {}) {
const skill = await resolveSkillById(id, options);
if (!skill) throw httpError(404, "skill_not_found", `Skill not found: ${id}`);
const tree = await walkSkillTree(skill.rootDir);
return {
serviceId: "hwlab-cloud-api",
route: `/v1/skills/${encodeURIComponent(id)}/tree`,
status: "ok",
skill,
tree,
count: tree.length
};
}
export async function readSkillTextFile(id, relativePath = "SKILL.md", options = {}) {
const skill = await resolveSkillById(id, options);
if (!skill) throw httpError(404, "skill_not_found", `Skill not found: ${id}`);
const safeRelativePath = nonEmptyString(relativePath) ?? "SKILL.md";
const filePath = safeJoin(skill.rootDir, safeRelativePath);
if (!filePath) throw httpError(400, "invalid_skill_path", "Requested skill file path is outside the skill root.");
let content;
try {
content = await readFile(filePath);
} catch {
throw httpError(404, "skill_file_not_found", `Skill file not found: ${safeRelativePath}`);
}
if (looksBinary(content)) {
throw httpError(415, "skill_file_binary", "Binary skill files are not returned inline.");
}
return {
serviceId: "hwlab-cloud-api",
route: `/v1/skills/${encodeURIComponent(id)}/files`,
status: "ok",
skill,
file: {
path: normalizeRelativePath(safeRelativePath),
sizeBytes: content.length,
contentType: "text/plain; charset=utf-8",
content: content.toString("utf8")
}
};
}
export async function resolveSkillById(id, options = {}) {
const skillId = nonEmptyString(id);
if (!skillId) return null;
const payload = await listSkills(options);
return payload.skills.find((skill) => skill.id === skillId) ?? null;
}
export async function listPreinstalledSkills(rootDir) {
return listSkillRoots(rootDir, "preinstalled");
}
export async function listUploadedSkills(rootDir) {
await mkdir(rootDir, { recursive: true });
return listSkillRoots(rootDir, "uploaded");
}
export function ensureCodexSkillsAggregationSync(input = process.env) {
const config = input?.codexSkillsDir ? input : skillRuntimeConfig(input);
try {
mkdirSync(config.codexSkillsDir, { recursive: true });
} catch {
return;
}
for (const record of skillRootRecordsSync(config.preinstalledDir, "preinstalled")) {
const linkName = `preinstalled-${safeLinkSegment(record.name)}`;
upsertSkillSymlink(path.join(config.codexSkillsDir, linkName), record.rootDir);
}
for (const record of skillRootRecordsSync(config.userSkillsDir, "uploaded")) {
const linkName = `uploaded-${safeLinkSegment(record.id)}`;
upsertSkillSymlink(path.join(config.codexSkillsDir, linkName), record.rootDir);
}
}
async function listSkillRoots(rootDir, source) {
const records = [];
const root = path.resolve(rootDir);
if (!existsSync(root)) return records;
if (existsSync(path.join(root, "SKILL.md"))) {
records.push(await skillItemFromRoot({ rootDir: root, source, idSeed: path.basename(root) || source }));
}
let entries = [];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return records;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const childRoot = path.join(root, entry.name);
if (source === "preinstalled" && !existsSync(path.join(childRoot, "SKILL.md"))) continue;
if (source === "uploaded" && !isUploadedSkillRoot(childRoot, entry.name)) continue;
records.push(await skillItemFromRoot({ rootDir: childRoot, source, idSeed: entry.name }));
}
return records.filter(Boolean);
}
async function skillItemFromRoot({ rootDir, source, idSeed }) {
const manifestPath = path.join(rootDir, "SKILL.md");
const manifest = await readSkillManifestSummary(manifestPath);
const metadata = source === "uploaded" ? await readJson(path.join(rootDir, USER_SKILL_METADATA_FILE)) : null;
const id = source === "uploaded"
? nonEmptyString(metadata?.id) ?? path.basename(rootDir)
: `preinstalled-${safeLinkSegment(idSeed)}`;
const stats = await skillRootStats(rootDir);
return {
id,
source,
name: nonEmptyString(metadata?.name) ?? manifest?.name ?? path.basename(rootDir),
description: nonEmptyString(metadata?.description) ?? manifest?.description ?? "No description provided.",
version: nonEmptyString(metadata?.version) ?? manifest?.version ?? null,
commitId: nonEmptyString(metadata?.commitId) ?? manifest?.commitId ?? null,
rootDir,
manifestPath: existsSync(manifestPath) ? manifestPath : null,
createdAt: nonEmptyString(metadata?.createdAt) ?? null,
updatedAt: nonEmptyString(metadata?.updatedAt) ?? null,
fileCount: Number.isInteger(metadata?.fileCount) ? metadata.fileCount : stats.fileCount,
sizeBytes: Number.isFinite(Number(metadata?.sizeBytes)) ? Number(metadata.sizeBytes) : stats.sizeBytes
};
}
async function readSkillManifestSummary(manifestPath) {
try {
const text = await readFile(manifestPath, "utf8");
const frontmatter = parseFrontmatter(text);
const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, "");
const name = nonEmptyString(frontmatter.name) ?? path.basename(path.dirname(manifestPath));
return {
name,
description: boundSkillText(frontmatter.description ?? firstMarkdownSummary(body) ?? "No description provided."),
version: firstManifestField(frontmatter, ["version", "skillVersion"]),
commitId: firstManifestField(frontmatter, ["commitId", "commit", "gitCommit", "revision"])
};
} catch {
return null;
}
}
function prepareUploadedFiles(files) {
const rawFiles = Array.isArray(files) ? files : [];
const prepared = [];
for (const file of rawFiles) {
const relativePath = normalizeRelativePath(file?.relativePath ?? file?.path ?? file?.name);
if (!relativePath) continue;
const content = decodeUploadContent(file);
if (!content) continue;
prepared.push({ relativePath, content });
}
return stripSingleTopLevelSkillFolder(prepared);
}
function stripSingleTopLevelSkillFolder(files) {
if (files.some((file) => file.relativePath === "SKILL.md")) return files;
const firstParts = files.map((file) => file.relativePath.split("/")).filter((parts) => parts.length > 1);
if (firstParts.length !== files.length) return files;
const [top] = firstParts[0] ?? [];
if (!top || firstParts.some((parts) => parts[0] !== top)) return files;
if (!files.some((file) => file.relativePath === `${top}/SKILL.md`)) return files;
return files.map((file) => ({ ...file, relativePath: file.relativePath.slice(top.length + 1) }));
}
function decodeUploadContent(file) {
if (typeof file?.contentBase64 === "string") return Buffer.from(file.contentBase64, "base64");
if (typeof file?.contentText === "string") return Buffer.from(file.contentText, "utf8");
if (typeof file?.content === "string") return Buffer.from(file.content, "utf8");
return null;
}
async function allocateUserSkillId(rootDir) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const id = `skill-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
if (!existsSync(path.join(rootDir, id))) return id;
}
throw httpError(500, "skill_id_allocation_failed", "Could not allocate uploaded skill id.");
}
async function writeUserSkillsIndex(rootDir) {
const skills = await listSkillRoots(rootDir, "uploaded");
await writeJson(path.join(rootDir, USER_SKILLS_INDEX_FILE), {
version: 1,
updatedAt: new Date().toISOString(),
skills: skills.map((skill) => ({
id: skill.id,
name: skill.name,
source: skill.source,
rootDir: skill.rootDir,
manifestPath: skill.manifestPath,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
fileCount: skill.fileCount,
sizeBytes: skill.sizeBytes
}))
});
}
async function walkSkillTree(rootDir, options = {}) {
const limit = Number.isInteger(options.limit) ? options.limit : 500;
const result = [];
async function walk(current, relativeRoot, depth) {
if (result.length >= limit || depth > 8) return;
let entries = [];
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) {
if (entry.name === USER_SKILL_METADATA_FILE || entry.name === USER_SKILLS_INDEX_FILE) continue;
const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
result.push({ path: relativePath, type: "directory" });
await walk(fullPath, relativePath, depth + 1);
} else if (entry.isFile()) {
const buffer = await readFile(fullPath).catch(() => null);
result.push({ path: relativePath, type: "file", sizeBytes: buffer?.length ?? null });
}
if (result.length >= limit) return;
}
}
await walk(rootDir, "", 0);
return result;
}
async function skillRootStats(rootDir) {
const tree = await walkSkillTree(rootDir, { limit: 500 });
return {
fileCount: tree.filter((entry) => entry.type === "file").length,
sizeBytes: tree.reduce((total, entry) => total + (Number(entry.sizeBytes) || 0), 0)
};
}
function skillRootRecordsSync(rootDir, source) {
const root = path.resolve(rootDir);
if (!existsSync(root)) return [];
const records = [];
if (existsSync(path.join(root, "SKILL.md"))) {
records.push(skillRootRecordSync(root, source, path.basename(root) || source));
}
let entries = [];
try {
entries = readdirSync(root, { withFileTypes: true });
} catch {
return records.filter(Boolean);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const childRoot = path.join(root, entry.name);
if (source === "preinstalled" && !existsSync(path.join(childRoot, "SKILL.md"))) continue;
if (source === "uploaded" && !isUploadedSkillRoot(childRoot, entry.name)) continue;
records.push(skillRootRecordSync(childRoot, source, entry.name));
}
return records.filter(Boolean);
}
function skillRootRecordSync(rootDir, source, idSeed) {
const metadata = source === "uploaded" ? readJsonSync(path.join(rootDir, USER_SKILL_METADATA_FILE)) : null;
const manifest = readSkillManifestSummarySync(path.join(rootDir, "SKILL.md"));
return {
id: source === "uploaded" ? nonEmptyString(metadata?.id) ?? path.basename(rootDir) : `preinstalled-${safeLinkSegment(idSeed)}`,
name: nonEmptyString(metadata?.name) ?? manifest?.name ?? path.basename(rootDir),
rootDir
};
}
function readSkillManifestSummarySync(manifestPath) {
try {
const text = readFileSync(manifestPath, "utf8");
const frontmatter = parseFrontmatter(text);
return {
name: nonEmptyString(frontmatter.name) ?? path.basename(path.dirname(manifestPath))
};
} catch {
return null;
}
}
function isUploadedSkillRoot(rootDir, name) {
if (name === "lost+found") return false;
return name.startsWith("skill-") ||
existsSync(path.join(rootDir, USER_SKILL_METADATA_FILE)) ||
existsSync(path.join(rootDir, "SKILL.md"));
}
function upsertSkillSymlink(linkPath, target) {
try {
const current = lstatSync(linkPath);
if (!current.isSymbolicLink()) return;
const linked = path.resolve(path.dirname(linkPath), readlinkSync(linkPath));
if (linked === path.resolve(target)) return;
rmSync(linkPath, { force: true });
} catch {
// Missing links are created below.
}
try {
symlinkSync(target, linkPath, "dir");
} catch {
// Runtime health and API list expose source roots; symlink aggregation is best-effort.
}
}
function normalizeRelativePath(value) {
const text = String(value ?? "").replace(/\\/gu, "/").replace(/^\/+/, "").trim();
const parts = text.split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) return null;
return parts.join("/");
}
function safeJoin(rootDir, relativePath) {
const normalized = normalizeRelativePath(relativePath);
if (!normalized) return null;
const root = path.resolve(rootDir);
const target = path.resolve(root, normalized);
return target === root || target.startsWith(`${root}${path.sep}`) ? target : null;
}
function looksBinary(buffer) {
if (!Buffer.isBuffer(buffer)) return false;
return buffer.subarray(0, Math.min(buffer.length, 8000)).includes(0);
}
async function readJson(filePath) {
try {
return JSON.parse(await readFile(filePath, "utf8"));
} catch {
return null;
}
}
function readJsonSync(filePath) {
try {
return JSON.parse(readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
async function writeJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function rmIfEmptyOrCreated(rootDir) {
await rm(rootDir, { recursive: true, force: true }).catch(() => {});
}
function nonEmptyString(value) {
const text = typeof value === "string" ? value.trim() : "";
return text || null;
}
function uniquePaths(values) {
const seen = new Set();
const result = [];
for (const value of values) {
const text = String(value ?? "").trim();
if (!text) continue;
const resolved = path.resolve(text);
if (seen.has(resolved)) continue;
seen.add(resolved);
result.push(resolved);
}
return result;
}
function compareSkillItems(a, b) {
return String(a.name).localeCompare(String(b.name), "en") ||
String(a.source).localeCompare(String(b.source), "en") ||
String(a.id).localeCompare(String(b.id), "en");
}
function safeLinkSegment(value) {
return String(value ?? "skill")
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/giu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 80) || "skill";
}
function httpError(statusCode, code, message) {
const error = new Error(message);
Object.assign(error, { statusCode, code });
return error;
}