426 lines
14 KiB
JavaScript
426 lines
14 KiB
JavaScript
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),
|
||
};
|
||
}
|
||
}
|