Files
boss/local-agent/skill-lifecycle-runner.mjs

426 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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