import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import { basename, dirname, join, relative, resolve } from "node:path"; function trimToDefined(value) { const trimmed = String(value ?? "").trim(); return trimmed ? trimmed : undefined; } function parseSourceList(value) { if (Array.isArray(value)) { return value.map(trimToDefined).filter(Boolean); } if (typeof value === "string") { return value.split(",").map(trimToDefined).filter(Boolean); } return []; } function parseTrustedSources(value) { if (!value) { return {}; } if (typeof value === "object" && !Array.isArray(value)) { return Object.fromEntries( Object.entries(value) .map(([key, source]) => [trimToDefined(key), trimToDefined(source)]) .filter(([key, source]) => key && source), ); } if (typeof value === "string") { try { return parseTrustedSources(JSON.parse(value)); } catch { return {}; } } return {}; } export function getSkillLifecycleRunnerConfig(env = process.env, config = {}) { const enabledValue = config.skillLifecycleEnabled ?? env.BOSS_SKILL_LIFECYCLE_ENABLED; const enabled = enabledValue === undefined ? true : enabledValue !== false && enabledValue !== "false"; const skillsDir = resolve( trimToDefined(config.skillsDir || env.BOSS_SKILLS_DIR) ?? join(os.homedir(), ".codex", "skills"), ); const timeoutMs = Number(config.skillLifecycleTimeoutMs ?? env.BOSS_SKILL_LIFECYCLE_TIMEOUT_MS ?? 120_000); const allowedSources = parseSourceList( config.skillLifecycleAllowedSources ?? env.BOSS_SKILL_LIFECYCLE_ALLOWED_SOURCES, ); const trustedSources = parseTrustedSources( config.skillLifecycleTrustedSources ?? env.BOSS_SKILL_LIFECYCLE_TRUSTED_SOURCES, ); return { enabled, skillsDir, timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120_000, allowedSources, trustedSources, }; } export function slugifySkillName(value) { return String(value || "") .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-") .replace(/^-+|-+$/g, "") .slice(0, 48) || "skill"; } function isInside(parent, child) { const diff = relative(resolve(parent), resolve(child)); return diff === "" || (!diff.startsWith("..") && !diff.startsWith("/")); } function assertInsideSkillsDir(skillsDir, targetPath) { if (!isInside(skillsDir, targetPath)) { throw new Error("SKILL_PATH_OUTSIDE_SKILLS_DIR"); } } function sourceName(sourceUrl) { try { const parsed = new URL(sourceUrl); return basename(parsed.pathname).replace(/\.git$/i, "") || "remote-skill"; } catch { return basename(sourceUrl).replace(/\.git$/i, "") || "remote-skill"; } } function resolveRequestSourceUrl(request, runnerConfig) { const sourceUrl = trimToDefined(request.sourceUrl); if (sourceUrl) { return sourceUrl; } const trustedSource = trimToDefined(request.trustedSource ?? request.trustedSourceId); return trustedSource ? runnerConfig.trustedSources[trustedSource] : undefined; } function parseUrlOrNull(value) { try { return new URL(value); } catch { return null; } } function normalizeTrailingSlashes(value) { return String(value ?? "").replace(/[\\/]+$/g, ""); } function urlMatchesSourceBoundary(sourceUrl, allowedSource) { const source = parseUrlOrNull(sourceUrl); const allowed = parseUrlOrNull(allowedSource); if (!source || !allowed || source.origin !== allowed.origin) { return false; } if (allowed.search || allowed.hash) { return source.href === allowed.href; } const sourcePath = normalizeTrailingSlashes(source.pathname); const allowedPath = normalizeTrailingSlashes(allowed.pathname); return sourcePath === allowedPath || sourcePath.startsWith(`${allowedPath}/`); } function sourceLooksLikePath(value) { return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~"); } function sourceMatchesAllowedBoundary(sourceUrl, allowedSource) { const source = trimToDefined(sourceUrl); const allowed = trimToDefined(allowedSource); if (!source || !allowed) { return false; } if (source === allowed) { return true; } if (parseUrlOrNull(source) || parseUrlOrNull(allowed)) { return urlMatchesSourceBoundary(source, allowed); } if (sourceLooksLikePath(source) || sourceLooksLikePath(allowed)) { return isInside(resolve(allowed), resolve(source)); } const normalizedAllowed = normalizeTrailingSlashes(allowed); return source === normalizedAllowed || source.startsWith(`${normalizedAllowed}/`) || source.startsWith(`${normalizedAllowed}\\`); } function isAllowedSource(sourceUrl, runnerConfig) { if (!sourceUrl) { return true; } return runnerConfig.allowedSources.some((allowedSource) => sourceMatchesAllowedBoundary(sourceUrl, allowedSource)) || Object.values(runnerConfig.trustedSources).some((trustedSource) => sourceUrl === trustedSource); } function skillIdFor(deviceId, skill) { return `${deviceId}:${slugifySkillName(skill?.name)}`; } function findRuntimeSkill(runtime, deviceId, skillId) { return (runtime.lastSkills ?? []).find((skill) => skillIdFor(deviceId, skill) === skillId); } async function ensureSkillDirectory(skillsDir, skill) { if (!skill?.path) { throw new Error("SKILL_NOT_FOUND_ON_DEVICE"); } const skillFile = resolve(skill.path); assertInsideSkillsDir(skillsDir, skillFile); const skillDir = dirname(skillFile); await stat(skillFile); return skillDir; } async function fileExists(filePath) { try { await stat(filePath); return true; } catch { return false; } } async function checksumFileForSkill(targetDir) { const manifestPath = join(targetDir, "manifest.json"); if (await fileExists(manifestPath)) { return manifestPath; } const skillPath = join(targetDir, "SKILL.md"); if (await fileExists(skillPath)) { return skillPath; } throw new Error("SKILL_CHECKSUM_TARGET_NOT_FOUND"); } async function verifyChecksumIfRequested(request, targetDir) { const expectedChecksum = trimToDefined(request.expectedChecksum ?? request.checksum); if (!expectedChecksum) { return; } const checksumPath = await checksumFileForSkill(targetDir); const actualChecksum = createHash("sha256").update(await readFile(checksumPath)).digest("hex"); if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) { throw new Error("SKILL_CHECKSUM_MISMATCH"); } } async function backupSkillDirectory(runnerConfig, skillDir, skillName) { assertInsideSkillsDir(runnerConfig.skillsDir, skillDir); const backupsDir = resolve(runnerConfig.skillsDir, ".boss-skill-backups"); assertInsideSkillsDir(runnerConfig.skillsDir, backupsDir); await mkdir(backupsDir, { recursive: true }); const backupName = `${new Date().toISOString().replace(/[:.]/g, "-")}-${slugifySkillName(skillName || basename(skillDir))}`; const backupDir = resolve(backupsDir, backupName); assertInsideSkillsDir(backupsDir, backupDir); await cp(skillDir, backupDir, { recursive: true, force: false }); return backupDir; } async function restoreSkillBackup(runnerConfig, skillDir, backupDir) { if (!backupDir) { return; } assertInsideSkillsDir(runnerConfig.skillsDir, skillDir); assertInsideSkillsDir(resolve(runnerConfig.skillsDir, ".boss-skill-backups"), backupDir); await rm(skillDir, { recursive: true, force: true }); await cp(backupDir, skillDir, { recursive: true, force: true }); } async function runCommand(command, args, options = {}) { const timeoutMs = options.timeoutMs ?? 120_000; return await new Promise((resolveCommand, rejectCommand) => { const child = spawn(command, args, { cwd: options.cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; const timeout = setTimeout(() => { child.kill("SIGKILL"); rejectCommand(new Error(`${command} timeout after ${timeoutMs}ms`)); }, timeoutMs); child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (error) => { clearTimeout(timeout); rejectCommand(error); }); child.on("close", (code) => { clearTimeout(timeout); if (code === 0) { resolveCommand({ stdout, stderr }); return; } rejectCommand(new Error(stderr.trim() || stdout.trim() || `${command} exited ${code}`)); }); }); } async function git(args, options) { return runCommand("git", args, options); } async function installOrUpdateFromSource(request, runnerConfig, existingSkill) { const sourceUrl = resolveRequestSourceUrl(request, runnerConfig); if (!sourceUrl && !existingSkill) { throw new Error("SOURCE_URL_REQUIRED"); } if (sourceUrl && !isAllowedSource(sourceUrl, runnerConfig)) { throw new Error("SKILL_SOURCE_NOT_ALLOWED"); } await mkdir(join(runnerConfig.skillsDir, "remote"), { recursive: true }); const targetDir = existingSkill ? await ensureSkillDirectory(runnerConfig.skillsDir, existingSkill) : resolve(runnerConfig.skillsDir, "remote", slugifySkillName(sourceName(sourceUrl))); assertInsideSkillsDir(runnerConfig.skillsDir, targetDir); const backupDir = existingSkill ? await backupSkillDirectory(runnerConfig, targetDir, existingSkill.name) : null; try { if (sourceUrl && !existingSkill) { await git(["clone", "--depth", "1", sourceUrl, targetDir], { timeoutMs: runnerConfig.timeoutMs, }); } else { await git(["fetch", "--all", "--tags", "--prune"], { cwd: targetDir, timeoutMs: runnerConfig.timeoutMs, }); } const targetVersion = trimToDefined(request.targetVersion); if (targetVersion) { await git(["checkout", targetVersion], { cwd: targetDir, timeoutMs: runnerConfig.timeoutMs, }); } else if (existingSkill) { await git(["pull", "--ff-only"], { cwd: targetDir, timeoutMs: runnerConfig.timeoutMs, }); } await verifyChecksumIfRequested(request, targetDir); } catch (error) { if (existingSkill) { await restoreSkillBackup(runnerConfig, targetDir, backupDir).catch(() => {}); } else { await rm(targetDir, { recursive: true, force: true }); } throw error; } return `Skill 已${existingSkill ? "更新" : "安装"}:${targetDir}`; } async function rollbackSkill(request, runnerConfig, runtime) { const skill = findRuntimeSkill(runtime, request.deviceId, request.skillId); const targetVersion = trimToDefined(request.rollbackToVersion); if (!targetVersion) { throw new Error("ROLLBACK_VERSION_REQUIRED"); } const skillDir = await ensureSkillDirectory(runnerConfig.skillsDir, skill); const backupDir = await backupSkillDirectory(runnerConfig, skillDir, skill?.name); try { await git(["fetch", "--all", "--tags", "--prune"], { cwd: skillDir, timeoutMs: runnerConfig.timeoutMs, }); await git(["checkout", targetVersion], { cwd: skillDir, timeoutMs: runnerConfig.timeoutMs, }); await verifyChecksumIfRequested(request, skillDir); } catch (error) { await restoreSkillBackup(runnerConfig, skillDir, backupDir).catch(() => {}); throw error; } return `Skill 已回滚到 ${targetVersion}`; } async function uninstallSkill(request, runnerConfig, runtime) { const skill = findRuntimeSkill(runtime, request.deviceId, request.skillId); const skillDir = await ensureSkillDirectory(runnerConfig.skillsDir, skill); const backupDir = await backupSkillDirectory(runnerConfig, skillDir, skill?.name); try { await rm(skillDir, { recursive: true, force: true }); } catch (error) { await restoreSkillBackup(runnerConfig, skillDir, backupDir).catch(() => {}); throw error; } return `Skill 已卸载:${skill?.name ?? request.skillId}`; } async function lockSkillVersion(request, runnerConfig) { const lockedVersion = trimToDefined(request.lockedVersion); if (!lockedVersion) { throw new Error("LOCKED_VERSION_REQUIRED"); } const lockPath = resolve(runnerConfig.skillsDir, ".boss-skill-locks.json"); assertInsideSkillsDir(runnerConfig.skillsDir, lockPath); let locks = {}; try { locks = JSON.parse(await readFile(lockPath, "utf8")); } catch { locks = {}; } locks[request.skillId || request.sourceUrl] = { lockedVersion, updatedAt: new Date().toISOString(), }; await mkdir(runnerConfig.skillsDir, { recursive: true }); await writeFile(lockPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8"); return `Skill 已锁定版本:${lockedVersion}`; } export async function executeSkillLifecycleRequest(request, config, runtime) { const runnerConfig = getSkillLifecycleRunnerConfig(process.env, config); if (!runnerConfig.enabled) { return { status: "failed", error: "SKILL_LIFECYCLE_RUNNER_DISABLED", }; } try { const existingSkill = request.skillId ? findRuntimeSkill(runtime, request.deviceId, request.skillId) : null; let resultSummary = ""; if (request.action === "install") { resultSummary = await installOrUpdateFromSource(request, runnerConfig, existingSkill); } else if (request.action === "update") { resultSummary = await installOrUpdateFromSource(request, runnerConfig, existingSkill); } else if (request.action === "uninstall") { resultSummary = await uninstallSkill(request, runnerConfig, runtime); } else if (request.action === "rollback") { resultSummary = await rollbackSkill(request, runnerConfig, runtime); } else if (request.action === "version_lock") { resultSummary = await lockSkillVersion(request, runnerConfig); } else { throw new Error("SKILL_LIFECYCLE_ACTION_UNSUPPORTED"); } return { status: "completed", resultSummary, }; } catch (error) { return { status: "failed", error: error instanceof Error ? error.message : String(error), }; } }