修复AGC Skill跨平台指纹漂移
Project CI / Native shell tests (push) Failing after 2m5s
Project CI / Repository checks (push) Successful in 3m34s
Project CI / Frontend tests (push) Successful in 4m14s
Project CI / Backend tests (push) Successful in 5m58s

统一以UTF-8 LF规范化内容计算Skill与清单指纹
安装隔离Skill时写入规范化文本避免Windows混合换行
增加LF与CRLF等价及安装结果回归测试
同步Skill Pack版本与实施文档、技术方案和踩坑记录
This commit is contained in:
Git Hooks Test
2026-08-23 01:20:28 +08:00
parent b7a6f853d5
commit 32da013545
5 changed files with 62 additions and 11 deletions
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-22.2",
"version": "2026-08-22.3",
"skills": [
{
"name": "agc-project-structure",
@@ -1,5 +1,6 @@
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::path::{Component, Path};
@@ -122,6 +123,29 @@ fn bundled_skill_file(path: &str) -> Option<&'static [u8]> {
.find_map(|(candidate, bytes)| (*candidate == path).then_some(*bytes))
}
fn canonical_skill_text_bytes<'a>(path: &str, bytes: &'a [u8]) -> Result<Cow<'a, [u8]>, String> {
let text = std::str::from_utf8(bytes)
.map_err(|_| format!("内置 AGC Skill 审核文件 {path} 不是 UTF-8 文本"))?;
if !text.contains("\r\n") {
return Ok(Cow::Borrowed(bytes));
}
Ok(Cow::Owned(text.replace("\r\n", "\n").into_bytes()))
}
fn update_skill_content_digest(
digest: &mut Sha256,
relative: &str,
bundled_path: &str,
bytes: &[u8],
) -> Result<(), String> {
let canonical_bytes = canonical_skill_text_bytes(bundled_path, bytes)?;
digest.update(relative.as_bytes());
digest.update([0]);
digest.update(canonical_bytes.as_ref());
digest.update([0]);
Ok(())
}
fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result<String, String> {
let mut files = entry.files.clone();
files.sort();
@@ -133,10 +157,7 @@ fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result<String, St
let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/"));
let bytes = bundled_skill_file(&bundled_path)
.ok_or_else(|| format!("AGC Skill {} 缺少审核文件 {relative}", entry.name))?;
digest.update(relative.as_bytes());
digest.update([0]);
digest.update(bytes);
digest.update([0]);
update_skill_content_digest(&mut digest, &relative, &bundled_path, bytes)?;
}
Ok(format!("{:x}", digest.finalize()))
}
@@ -194,7 +215,8 @@ fn validated_skill_pack_manifest() -> Result<AgcSkillPackManifest, String> {
pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
validated_skill_pack_manifest()?;
Ok(format!("{:x}", Sha256::digest(AGC_SKILL_PACK_MANIFEST)))
let canonical_manifest = canonical_skill_text_bytes("manifest.json", AGC_SKILL_PACK_MANIFEST)?;
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
@@ -260,12 +282,13 @@ pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String,
let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/"));
let bytes = bundled_skill_file(&bundled_path)
.ok_or_else(|| format!("内置 AGC Skill 缺少审核文件 {bundled_path}"))?;
let canonical_bytes = canonical_skill_text_bytes(&bundled_path, bytes)?;
let target = skills_root.join(&entry.name).join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("创建隔离 AGC Skill 子目录失败:{error}"))?;
}
std::fs::write(&target, bytes)
std::fs::write(&target, canonical_bytes.as_ref())
.map_err(|error| format!("安装隔离 AGC Skill 文件失败:{error}"))?;
}
}
@@ -299,6 +322,26 @@ mod tests {
}
}
#[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String {
let mut digest = Sha256::new();
update_skill_content_digest(
&mut digest,
"agents/openai.yaml",
"agc-project-structure/agents/openai.yaml",
bytes,
)
.expect("hash reviewed skill text");
format!("{:x}", digest.finalize())
}
assert_eq!(
digest(b"interface:\n display_name: AGC\n short_description: test\n"),
digest(b"interface:\r\n display_name: AGC\n short_description: test\r\n")
);
}
#[test]
fn skill_pack_installs_under_isolated_home_without_full_body_in_index() {
let home = tempfile::tempdir().expect("temporary home");
@@ -308,6 +351,14 @@ mod tests {
.path()
.join(".agents/skills/taonier-art-assets/SKILL.md")
.is_file());
let installed_agent_metadata = std::fs::read(
home.path()
.join(".agents/skills/agc-project-structure/agents/openai.yaml"),
)
.expect("read installed agent metadata");
assert!(!installed_agent_metadata
.windows(2)
.any(|pair| pair == b"\r\n"));
let index = render_agc_skill_pack_index().expect("render index");
assert!(index.contains("taonier-art-assets"));
assert!(index.contains("agc_tools.taonier_prepare_game_art"));
@@ -310,7 +310,7 @@
### 14.2 审核索引与五类 Skill
客户端内置 `agc-skill-pack.v1` 清单。每项只公开名称、用途、触发条件、所需工具、版本和内容 SHA-256;审核文变化时必须在同次变更重算对应指纹。启动时逐文件复核清单和编译进客户端的内容,任何缺失、额外文件、路径越界指纹不匹配都失败关闭;路径边界显式拒绝反斜杠、Windows 盘符、UNC、绝对路径和 `..`,不能因测试运行在 Linux 就把 Windows 绝对路径当作普通相对文件名。审核包只包含:
客户端内置 `agc-skill-pack.v1` 清单。每项只公开名称、用途、触发条件、所需工具、版本和内容 SHA-256;审核文本统一按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免编辑器产生的混合换行让同一 Git 内容在 Windows 与 Linux 上得到不同结果。审核文件的语义内容变化时必须在同次变更重算对应指纹并提升版本。启动时逐文件复核清单和编译进客户端的内容,任何缺失、额外文件、路径越界、非 UTF-8 内容或规范化后的指纹不匹配都失败关闭;路径边界显式拒绝反斜杠、Windows 盘符、UNC、绝对路径和 `..`,不能因测试运行在 Linux 就把 Windows 绝对路径当作普通相对文件名。审核包只包含:
1. `agc-project-structure`:项目根、`game/``assets/``.agent/` 的职责和禁止创建平行项目的约束。
2. `taonier-art-assets`:陶泥儿标准美术包、平台来源、警告语义和真实素材使用;`grid-2x2` 与四切片只是推荐路径,不是所有游戏的完成门。
@@ -4235,8 +4235,8 @@
- 现象:内置 Skill 文件集合没有缺失,原生测试却统一报内容指纹不匹配;另一个测试在 Linux 上把 `C:\\temp\\SKILL.md` 判为安全相对路径,受控资源工具可能继续处理 Windows 盘符或反斜杠遍历形式。
- 原因:审核文件定稿后未按最终字节重新生成 manifest SHA-256;同时 `std::path::Path` 只按当前宿主语义解析路径,Linux 不会把 Windows 盘符和反斜杠视为绝对路径或分隔符。
- 处理:Skill 文件变化与 manifest 指纹更新必须同次提交,并提升审核包版本;资源引用只接受使用 `/` 的普通相对段,显式拒绝反斜杠、冒号盘符、UNC、绝对路径和父目录段,再查询审核清单。不要先把反斜杠替换成 `/` 后再做安全检查。
- 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树最终字节现场重算,并在提交前运行原生 Skill Pack 校验。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。
- 验证:逐项按排序后的 `relativePath + NUL + file bytes + NUL` 重算并核对 manifestRust 单测同时覆盖 POSIX 绝对路径、`..``C:\\...``C:/...`、UNC 和反斜杠相对路径,受控 MCP 工具也必须把 Windows 绝对路径投影为 `isError=true`
- 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树按 UTF-8 读取、将 CRLF 规范为 LF 后现场重算,并在提交前运行原生 Skill Pack 校验。Git 的 `eol=lf` 不能阻止编辑器在干净工作树里留下少量混合 CRLF,而 Cargo `include_bytes!` 会读取这些原始字节;因此运行时计算与安装也必须使用同一规范化函数。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。
- 验证:逐项按排序后的 `relativePath + NUL + canonical UTF-8 LF bytes + NUL` 重算并核对 manifest;Rust 单测同时覆盖 LF / CRLF 指纹等价、安装结果只含 LF、POSIX 绝对路径、`..``C:\\...``C:/...`、UNC 和反斜杠相对路径,受控 MCP 工具也必须把 Windows 绝对路径投影为 `isError=true`
## Gitea CI 预构建镜像不能只靠 tag 判断内容
@@ -1162,7 +1162,7 @@ game-project/
- 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents/.hermes` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。
- 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。
- `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;任何审核文变化必须同步重算对应清单指纹。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。
- `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化必须同步重算对应清单指纹并提升版本。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。
- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP,工具固定为审核引用读取、标准陶泥儿美术准备和 desktop/mobile 浏览器试玩。MCP 进程只做协议;真实浏览器和付费 External v1 调用通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key 或项目路径到模型上下文。三项工具固定自动批准,通用 shell、任意网络、多 Agent、插件和外部 MCP 继续关闭。
- 陶泥儿生成继续复用既有私有 Key、持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记。完整可信图集缺切片可以继续,固定四切片只是推荐路径;凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。
- 自定义 LLM API Key 路由只在 DirectHome/DirectProject 经 loopback `/responses` 流式代理转发。代理不注入 Key,只要求请求自带 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,防止隔离 app-server 把 API Provider 误判为余额 0;旧 ToolHost 保持原 Provider 行为。