合并 origin/master:DirectProject 输入校验取回空白片段回归用例
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m47s
Project CI / Native shell tests (pull_request) Failing after 50s
Project CI / Frontend tests (pull_request) Failing after 1m50s
Project CI / Backend tests (pull_request) Successful in 4m45s
Project CI / Repository checks (pull_request) Successful in 2m1s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m31s

- validation.rs 保留本分支的 content_has_meaningful_input 与 Skill/附件独立上限,空输入报错取回 master 的用户可见文案“聊天内容不能为空”
- wire.rs 取回 master 新增的 text_projection / user_input_rejects / valid_references / nonempty_text 四条回归用例,测试导入合并两侧
- resourceReferenceInput.test.tsx 取 master 的注释口径:草稿 text 投影会 trim,提交用的 content 保留 chip 后的分隔空格
- pitfalls.md 与《AGC聊天素材引用》改为「任意非文本 part 均算有效内容」,与四类引用的实际校验对齐
- 本地私有改动的 .env 不进入本次合并
This commit is contained in:
2026-09-21 21:42:41 +08:00
122 changed files with 6525 additions and 604 deletions
+82 -20
View File
@@ -25,6 +25,8 @@ import { deflateRawSync } from 'node:zlib';
const SCHEMA_VERSION = 'agc-template-library.v1';
const TEMPLATE_SCHEMA_VERSION = 'agc-template.v1';
/** 说明文档上限:它随每次发布覆盖写 `templates/README.md`,不做内容寻址。 */
const README_MAX_BYTES = 64 * 1024;
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
const RUNTIMES = new Set(['html', 'unity', 'godot', 'cocos']);
@@ -198,12 +200,39 @@ function buildZip(entries) {
}
function readProjectFiles(projectRoot) {
/** 正文任何层级都不允许出现的目录段:项目身份与版本库 / 依赖元数据。 */
const forbiddenSegments = new Set(['.agent', '.git', '.svn', 'node_modules']);
/** 只允许出现在正文根目录之外的构建产物与编辑器工作区目录。 */
const forbiddenRootSegments = new Set([
'dist',
'build',
'library',
'temp',
'local',
'.idea',
'.vscode',
]);
// 与后台「上传模板」共用同一条门禁,依据 docs/【模板规范】AGC模板包组织指南-2026-09-21.md。
const violation = (relative) => {
const segments = relative.split('/');
for (const segment of segments) {
if (forbiddenSegments.has(segment)) {
return `模板正文不允许包含 ${segment} 目录:${relative}`;
}
}
if (forbiddenRootSegments.has(segments[0])) {
return `模板正文根目录不允许包含 ${segments[0]} 目录:${relative}`;
}
return null;
};
const files = [];
const walk = (directory, prefix) => {
for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
(left, right) => left.name.localeCompare(right.name),
)) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const rejected = violation(relative);
if (rejected) throw new Error(rejected);
const full = join(directory, entry.name);
if (entry.isDirectory()) walk(full, relative);
else if (entry.isFile())
@@ -380,7 +409,14 @@ export function buildLibrary(source, prefix, only = []) {
body: Buffer.from(`${JSON.stringify(indexJson, null, 2)}\n`, 'utf8'),
contentType: 'application/json',
});
return { objects, indexJson };
// 说明文档不是内容寻址对象:它固定挂在 `templates/README.md`,随每次发布覆盖,
// 客户端从不读取它,但运维和后台都需要看到与当次契约一致的说明。
const readmePath = join(source, 'README.md');
const readmeBody = existsSync(readmePath) ? readFileSync(readmePath) : null;
if (readmeBody && readmeBody.length > README_MAX_BYTES) {
throw new Error(`README.md 超过 ${README_MAX_BYTES} 字节上限`);
}
return { objects, indexJson, readmeBody };
}
export function mergeLibraryIndex(existing, selected) {
@@ -769,25 +805,43 @@ export async function publishLibrary(
`${JSON.stringify(indexJson, null, 2)}\n`,
'utf8',
);
// 指针写入结果不明时旧请求可能晚到,必须留锁,不能让后续发布者越过它。
releaseAllowed = false;
const published = await checkedRequest(
() => client.put(indexKey, indexBytes, 'application/json'),
'发布清单',
);
if (published.ok) {
releaseAllowed = true;
} else if (
published.status >= 400 &&
published.status < 500 &&
published.status !== 408
) {
releaseAllowed = true;
throw new Error(`发布清单被拒绝:HTTP ${published.status}`);
} else {
throw new Error(`发布清单结果不明:HTTP ${published.status}`);
// 可变指针(说明文档、库清单)写入结果不明时旧请求可能晚到,必须留锁,
// 不能让后续发布者越过它。清单必须最后写,否则会出现「清单已指新对象、
// 说明还是旧版」的可观测窗口。
const pointerWrites = [
...(library.readmeBody
? [
{
key: `${prefix}/README.md`,
body: library.readmeBody,
contentType: 'text/markdown; charset=utf-8',
},
]
: []),
{ key: indexKey, body: indexBytes, contentType: 'application/json' },
];
for (const pointer of pointerWrites) {
releaseAllowed = false;
const published = await checkedRequest(
() => client.put(pointer.key, pointer.body, pointer.contentType),
`发布 ${pointer.key}`,
);
if (published.ok) {
releaseAllowed = true;
} else if (
published.status >= 400 &&
published.status < 500 &&
published.status !== 408
) {
releaseAllowed = true;
throw new Error(`发布 ${pointer.key} 被拒绝:HTTP ${published.status}`);
} else {
throw new Error(
`发布 ${pointer.key} 结果不明:HTTP ${published.status}`,
);
}
await verifyObject(client, pointer.key, pointer.body);
}
await verifyObject(client, indexKey, indexBytes);
} catch (error) {
failure = error;
} finally {
@@ -858,6 +912,11 @@ async function main() {
for (const object of library.objects.slice(0, -1)) {
console.log(` PUT ${object.key} (${object.body.length} B)`);
}
if (library.readmeBody) {
console.log(
` PUT ${args.prefix}/README.md (${library.readmeBody.length} B,覆盖写)`,
);
}
console.log(
` PUT ${args.prefix}/index.json (${Buffer.byteLength(`${JSON.stringify(indexJson, null, 2)}\n`)} B)`,
);
@@ -871,6 +930,9 @@ if (
) {
main().catch((error) => {
console.error(`[agc-template-library-publish] ${error.message}`);
process.exit(1);
// 直接 `process.exit(1)` 会在 fetch 句柄尚未关闭时触发 libuv 断言崩溃
// Windows 上实测只剩一句 Assertion failed,看不到拒绝原因);
// 交给事件循环自然退出,保留真实错误信息。
process.exitCode = 1;
});
}
+124 -2
View File
@@ -1,9 +1,15 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHash, createHmac } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, resolve } from 'node:path';
import { basename, dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -87,6 +93,86 @@ test('官方 Cocos 模板生成完整且可重复的原生项目包', async () =
}
});
function fixtureSource(projectEntries = []) {
const directory = mkdtempSync(join(tmpdir(), 'agc-template-publish-'));
const templateRoot = join(directory, 'v1', 'fixture-template');
const write = (relative, bytes) => {
const full = join(templateRoot, relative);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, bytes);
};
write(
'meta.json',
`${JSON.stringify(
{
id: 'fixture-template',
title: '夹具模板',
summary: '',
tags: ['fixture'],
runtime: 'html',
entry: 'game/index.html',
templateVersion: '0.1.0',
},
null,
2,
)}\n`,
);
write('cover.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>\n');
write('game/index.html', '<html></html>\n');
for (const relative of projectEntries) write(`project/${relative}`, 'x\n');
return directory;
}
test('模板正文含身份/版本库/依赖/构建目录时拒绝打包', async () => {
const rejected = [
['.agent/manifest.json', '不允许包含 .agent 目录'],
['game/.agent/ledger.json', '不允许包含 .agent 目录'],
['.git/config', '不允许包含 .git 目录'],
['.svn/entries', '不允许包含 .svn 目录'],
['node_modules/three/package.json', '不允许包含 node_modules 目录'],
['assets/node_modules/keep.txt', '不允许包含 node_modules 目录'],
['dist/game.js', '根目录不允许包含 dist 目录'],
['build/index.html', '根目录不允许包含 build 目录'],
['library/import.json', '根目录不允许包含 library 目录'],
['temp/asset.json', '根目录不允许包含 temp 目录'],
['local/settings.json', '根目录不允许包含 local 目录'],
['.vscode/settings.json', '根目录不允许包含 .vscode 目录'],
['.idea/misc.xml', '根目录不允许包含 .idea 目录'],
];
for (const [relative, expected] of rejected) {
const directory = fixtureSource([relative]);
try {
assert.throws(
() => buildLibrary(directory, 'templates'),
(error) => String(error.message).includes(expected),
`${relative} 必须被拒绝`,
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
// 同名目录段只在根目录被拒绝:正文内 game/dist/** 属于模板自身内容,.gitignore 也不是 .git。
const directory = fixtureSource(['game/dist/app.js', 'game/.gitignore']);
try {
const built = buildLibrary(directory, 'templates');
assert.deepEqual(
built.indexJson.templates.map((entry) => entry.id),
['fixture-template'],
);
const zip = await JSZip.loadAsync(
built.objects.find(
(object) => object.key === built.indexJson.templates[0].zipKey,
).body,
{ checkCRC32: true },
);
assert.ok(Object.keys(zip.files).includes('game/dist/app.js'));
assert.ok(Object.keys(zip.files).includes('game/.gitignore'));
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
const indexKey = 'templates/index.json';
const lockKey = 'templates/.publish-lock.json';
@@ -190,6 +276,42 @@ const publish = (built, store) =>
log: () => {},
});
test('说明文档随发布覆盖写线上 README,且写在清单之前', async () => {
const store = memoryOss(oldPublishedLibrary());
const built = {
...library(),
readmeBody: Buffer.from('# AGC 游戏模板库\n\n- 契约说明\n', 'utf8'),
};
await publish(built, store);
assert.equal(
store.objects.get('templates/README.md').toString('utf8'),
built.readmeBody.toString('utf8'),
);
const putKeys = store.calls
.filter((call) => call.method === 'PUT')
.map((call) => call.key);
assert.deepEqual(putKeys.slice(-2), [
'templates/README.md',
'templates/index.json',
]);
});
test('源目录没有 README 时不写线上说明文档', async () => {
const store = memoryOss(oldPublishedLibrary());
await publish({ ...library(), readmeBody: null }, store);
assert.equal(store.objects.has('templates/README.md'), false);
assert.equal(
store.calls.some(
(call) => call.method === 'PUT' && call.key === 'templates/README.md',
),
false,
);
});
test('所有发布正文均使用自身字节摘要定位,重复打包复用ZIP与封面地址', () => {
const built = library();
for (const object of built.objects.filter(