import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { TextDecoder } from 'node:util'; export const SKILL_PACK_SCHEMA_VERSION = 'agc-skill-pack.v1'; export const EXPECTED_SKILL_NAMES = Object.freeze([ 'agc-browser-playtest', 'agc-client-projection', 'agc-game-production-workflow', 'agc-project-structure', 'agc-web-game-development', 'taonier-art-assets', ]); const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); const defaultRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '../src-tauri/resources/agc-skills', ); function canonicalTextBytes(filePath) { const decoded = utf8Decoder.decode(fs.readFileSync(filePath)); return Buffer.from(decoded.replaceAll('\r\n', '\n'), 'utf8'); } export function isSafeSkillRelativePath(value) { if ( typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes(':') || value.startsWith('/') ) { return false; } return value .split('/') .every( (segment) => segment.length > 0 && segment !== '.' && segment !== '..', ); } function skillFilePath(rootDir, skillName, relativePath) { if (!isSafeSkillRelativePath(relativePath)) { throw new Error(`Skill ${skillName} 包含不安全相对路径: ${relativePath}`); } const target = path.resolve(rootDir, skillName, ...relativePath.split('/')); const skillRoot = path.resolve(rootDir, skillName); const prefix = `${skillRoot}${path.sep}`; if (!target.startsWith(prefix)) { throw new Error(`Skill ${skillName} 路径越过审核根目录: ${relativePath}`); } return target; } export function computeSkillContentFingerprint(rootDir, entry) { const digest = crypto.createHash('sha256'); for (const relativePath of [...entry.files].sort()) { const filePath = skillFilePath(rootDir, entry.name, relativePath); const bytes = canonicalTextBytes(filePath); digest.update(relativePath, 'utf8'); digest.update(Buffer.from([0])); digest.update(bytes); digest.update(Buffer.from([0])); } return digest.digest('hex'); } function collectBundledFiles(rootDir) { const files = []; const walk = (directory, prefix) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; const absolutePath = path.join(directory, entry.name); if (entry.isSymbolicLink()) { throw new Error(`内置 AGC Skill 不允许符号链接: ${relativePath}`); } if (entry.isDirectory()) { walk(absolutePath, relativePath); } else if (entry.isFile()) { files.push(relativePath.replaceAll('\\', '/')); } else { throw new Error(`内置 AGC Skill 文件类型不受支持: ${relativePath}`); } } }; walk(rootDir, ''); return files.filter((file) => file !== 'manifest.json').sort(); } function readManifest(rootDir) { const manifestPath = path.join(rootDir, 'manifest.json'); return { manifestPath, manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')), }; } function validateManifestShape(rootDir, manifest) { if (manifest?.schemaVersion !== SKILL_PACK_SCHEMA_VERSION) { throw new Error('内置 AGC Skill 清单 schemaVersion 不受支持'); } if (typeof manifest.version !== 'string' || manifest.version.trim() === '') { throw new Error('内置 AGC Skill 清单缺少版本'); } if (!Array.isArray(manifest.skills)) { throw new Error('内置 AGC Skill 清单缺少 skills 数组'); } const names = manifest.skills.map((entry) => entry?.name); if ( names.length !== EXPECTED_SKILL_NAMES.length || [...names].sort().join('\n') !== [...EXPECTED_SKILL_NAMES].sort().join('\n') ) { throw new Error('内置 AGC Skill 清单不等于审核白名单'); } const declaredFiles = new Set(); const mismatches = []; for (const entry of manifest.skills) { if ( typeof entry.name !== 'string' || !Array.isArray(entry.files) || entry.files.length === 0 || !entry.files.includes('SKILL.md') || new Set(entry.files).size !== entry.files.length ) { throw new Error( `内置 AGC Skill ${entry.name ?? ''} 元数据不完整`, ); } for (const relativePath of entry.files) { if (!isSafeSkillRelativePath(relativePath)) { throw new Error( `内置 AGC Skill ${entry.name} 包含不安全相对路径: ${relativePath}`, ); } declaredFiles.add(`${entry.name}/${relativePath}`); } const actual = computeSkillContentFingerprint(rootDir, entry); if (actual !== entry.sha256) { mismatches.push({ name: entry.name, expected: entry.sha256, actual, }); } } const bundledFiles = collectBundledFiles(rootDir); if ( declaredFiles.size !== bundledFiles.length || [...declaredFiles].sort().join('\n') !== bundledFiles.join('\n') ) { throw new Error('内置 AGC Skill 文件集合与审核清单不一致'); } return { mismatches }; } export function inspectSkillPack(rootDir = defaultRoot) { const resolvedRoot = path.resolve(rootDir); const { manifestPath, manifest } = readManifest(resolvedRoot); const { mismatches } = validateManifestShape(resolvedRoot, manifest); return { manifestPath, manifest, mismatches }; } function incrementPackVersion(version) { const match = /^(\d{4}-\d{2}-\d{2})\.(\d+)$/u.exec(version); if (!match) { throw new Error( `无法自动递增 Skill pack 版本 ${version},请使用 YYYY-MM-DD.N 格式`, ); } return `${match[1]}.${Number(match[2]) + 1}`; } export function syncSkillPackManifest(rootDir = defaultRoot) { const inspection = inspectSkillPack(rootDir); if (inspection.mismatches.length === 0) { return { changed: false, version: inspection.manifest.version, mismatches: [], }; } const mismatchByName = new Map( inspection.mismatches.map((mismatch) => [mismatch.name, mismatch.actual]), ); const nextManifest = { ...inspection.manifest, version: incrementPackVersion(inspection.manifest.version), skills: inspection.manifest.skills.map((entry) => mismatchByName.has(entry.name) ? { ...entry, sha256: mismatchByName.get(entry.name) } : entry, ), }; fs.writeFileSync( inspection.manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8', ); const verified = inspectSkillPack(rootDir); if (verified.mismatches.length > 0) { throw new Error('Skill pack manifest 同步后仍存在内容指纹不匹配'); } return { changed: true, version: nextManifest.version, mismatches: inspection.mismatches, }; }