d75547b36d
新增 skill-pack-manifest 脚本统一计算并校验内置 AGC Skill 内容摘要,--write 模式自动递增清单版本并同步 SHA-256。 新增 check-skill-pack 只读门禁与 Node 回归测试,无参数默认只读,只有单个 --write 才进入写入模式。 AGC typecheck 与 release build 接入 Skill 清单指纹校验,内容漂移时直接列出 Skill 与实际摘要阻断构建。 修正 agc-client-projection 清单指纹并升级 Skill pack 版本到 2026-08-26.3。 升级 AGC 标准版到 0.1.10,同步 package、Cargo、Tauri 与 npm 工作区锁文件。 首页空输入占位符锚定到编辑区,避免整页滚动后占位文本脱离输入框。 更新 AGC 实施计划中 Skill 指纹同步与只读门禁说明。
214 lines
6.6 KiB
JavaScript
214 lines
6.6 KiB
JavaScript
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-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 ?? '<unknown>'} 元数据不完整`,
|
|
);
|
|
}
|
|
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,
|
|
};
|
|
}
|