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; }