e8403e547b
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m15s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m33s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m19s
Project CI / AI game creator shell Rust crates (push) Successful in 1m53s
Project CI / Frontend tests (push) Successful in 4m10s
Project CI / Repository checks (push) Successful in 3m19s
Project CI / Native shell tests (push) Successful in 6m39s
Project CI / Backend tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m6s
新增 AGC 完整游戏生产 workflow Skill,定义策划、资源、实现、构建、试玩和交付阶段。 将真实美术生成、处理、登记和接入从可选提示改为新游戏交付链路要求。 同步 Skill Pack 清单、系统提示、测试契约和实施计划文档。
395 lines
15 KiB
Rust
395 lines
15 KiB
Rust
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::borrow::Cow;
|
|
use std::collections::BTreeSet;
|
|
use std::path::{Component, Path};
|
|
|
|
const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json");
|
|
const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1";
|
|
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 6] = [
|
|
"agc-browser-playtest",
|
|
"agc-client-projection",
|
|
"agc-game-production-workflow",
|
|
"agc-project-structure",
|
|
"agc-web-game-development",
|
|
"taonier-art-assets",
|
|
];
|
|
|
|
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 18] = [
|
|
(
|
|
"agc-browser-playtest/SKILL.md",
|
|
include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"),
|
|
),
|
|
(
|
|
"agc-browser-playtest/agents/openai.yaml",
|
|
include_bytes!("../../resources/agc-skills/agc-browser-playtest/agents/openai.yaml"),
|
|
),
|
|
(
|
|
"agc-browser-playtest/references/browser-evidence-contract.md",
|
|
include_bytes!(
|
|
"../../resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md"
|
|
),
|
|
),
|
|
(
|
|
"agc-client-projection/SKILL.md",
|
|
include_bytes!("../../resources/agc-skills/agc-client-projection/SKILL.md"),
|
|
),
|
|
(
|
|
"agc-client-projection/agents/openai.yaml",
|
|
include_bytes!("../../resources/agc-skills/agc-client-projection/agents/openai.yaml"),
|
|
),
|
|
(
|
|
"agc-client-projection/references/projection-contract.md",
|
|
include_bytes!(
|
|
"../../resources/agc-skills/agc-client-projection/references/projection-contract.md"
|
|
),
|
|
),
|
|
(
|
|
"agc-game-production-workflow/SKILL.md",
|
|
include_bytes!("../../resources/agc-skills/agc-game-production-workflow/SKILL.md"),
|
|
),
|
|
(
|
|
"agc-game-production-workflow/agents/openai.yaml",
|
|
include_bytes!("../../resources/agc-skills/agc-game-production-workflow/agents/openai.yaml"),
|
|
),
|
|
(
|
|
"agc-game-production-workflow/references/workflow-contract.md",
|
|
include_bytes!(
|
|
"../../resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md"
|
|
),
|
|
),
|
|
(
|
|
"agc-project-structure/SKILL.md",
|
|
include_bytes!("../../resources/agc-skills/agc-project-structure/SKILL.md"),
|
|
),
|
|
(
|
|
"agc-project-structure/agents/openai.yaml",
|
|
include_bytes!("../../resources/agc-skills/agc-project-structure/agents/openai.yaml"),
|
|
),
|
|
(
|
|
"agc-project-structure/references/structure-contract.md",
|
|
include_bytes!(
|
|
"../../resources/agc-skills/agc-project-structure/references/structure-contract.md"
|
|
),
|
|
),
|
|
(
|
|
"agc-web-game-development/SKILL.md",
|
|
include_bytes!("../../resources/agc-skills/agc-web-game-development/SKILL.md"),
|
|
),
|
|
(
|
|
"agc-web-game-development/agents/openai.yaml",
|
|
include_bytes!("../../resources/agc-skills/agc-web-game-development/agents/openai.yaml"),
|
|
),
|
|
(
|
|
"agc-web-game-development/references/game-quality-checklist.md",
|
|
include_bytes!(
|
|
"../../resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md"
|
|
),
|
|
),
|
|
(
|
|
"taonier-art-assets/SKILL.md",
|
|
include_bytes!("../../resources/agc-skills/taonier-art-assets/SKILL.md"),
|
|
),
|
|
(
|
|
"taonier-art-assets/agents/openai.yaml",
|
|
include_bytes!("../../resources/agc-skills/taonier-art-assets/agents/openai.yaml"),
|
|
),
|
|
(
|
|
"taonier-art-assets/references/platform-art-contract.md",
|
|
include_bytes!(
|
|
"../../resources/agc-skills/taonier-art-assets/references/platform-art-contract.md"
|
|
),
|
|
),
|
|
];
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct AgcSkillPackManifest {
|
|
schema_version: String,
|
|
version: String,
|
|
skills: Vec<AgcSkillManifestEntry>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct AgcSkillManifestEntry {
|
|
name: String,
|
|
purpose: String,
|
|
triggers: Vec<String>,
|
|
required_tools: Vec<String>,
|
|
files: Vec<String>,
|
|
sha256: String,
|
|
}
|
|
|
|
fn is_safe_skill_relative_path(value: &str) -> bool {
|
|
let path = Path::new(value);
|
|
!value.is_empty()
|
|
&& !value.contains('\\')
|
|
&& !value.contains(':')
|
|
&& !path.is_absolute()
|
|
&& path
|
|
.components()
|
|
.all(|component| matches!(component, Component::Normal(part) if !part.is_empty()))
|
|
}
|
|
|
|
fn bundled_skill_file(path: &str) -> Option<&'static [u8]> {
|
|
AGC_SKILL_PACK_FILES
|
|
.iter()
|
|
.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();
|
|
let mut digest = Sha256::new();
|
|
for relative in files {
|
|
if !is_safe_skill_relative_path(&relative) {
|
|
return Err(format!("AGC Skill {} 包含不安全路径", entry.name));
|
|
}
|
|
let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/"));
|
|
let bytes = bundled_skill_file(&bundled_path)
|
|
.ok_or_else(|| format!("AGC Skill {} 缺少审核文件 {relative}", entry.name))?;
|
|
update_skill_content_digest(&mut digest, &relative, &bundled_path, bytes)?;
|
|
}
|
|
Ok(format!("{:x}", digest.finalize()))
|
|
}
|
|
|
|
fn validated_skill_pack_manifest() -> Result<AgcSkillPackManifest, String> {
|
|
let manifest = serde_json::from_slice::<AgcSkillPackManifest>(AGC_SKILL_PACK_MANIFEST)
|
|
.map_err(|error| format!("解析内置 AGC Skill 清单失败:{error}"))?;
|
|
if manifest.schema_version != AGC_SKILL_PACK_SCHEMA_VERSION {
|
|
return Err("内置 AGC Skill 清单版本不受支持".to_string());
|
|
}
|
|
if manifest.version.trim().is_empty() {
|
|
return Err("内置 AGC Skill 清单缺少版本".to_string());
|
|
}
|
|
let names = manifest
|
|
.skills
|
|
.iter()
|
|
.map(|entry| entry.name.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
if names != AGC_SKILL_PACK_EXPECTED_NAMES.into_iter().collect() {
|
|
return Err("内置 AGC Skill 清单不等于审核白名单".to_string());
|
|
}
|
|
let mut declared_files = BTreeSet::new();
|
|
for entry in &manifest.skills {
|
|
if entry.purpose.trim().is_empty()
|
|
|| entry.triggers.is_empty()
|
|
|| entry
|
|
.triggers
|
|
.iter()
|
|
.any(|trigger| trigger.trim().is_empty())
|
|
|| !entry.files.iter().any(|path| path == "SKILL.md")
|
|
|| entry.files.is_empty()
|
|
{
|
|
return Err(format!("内置 AGC Skill {} 元数据不完整", entry.name));
|
|
}
|
|
if entry.files.iter().collect::<BTreeSet<_>>().len() != entry.files.len() {
|
|
return Err(format!("内置 AGC Skill {} 文件声明重复", entry.name));
|
|
}
|
|
let actual = skill_content_fingerprint(entry)?;
|
|
if actual != entry.sha256 {
|
|
return Err(format!("内置 AGC Skill {} 内容指纹不匹配", entry.name));
|
|
}
|
|
for relative in &entry.files {
|
|
declared_files.insert(format!("{}/{}", entry.name, relative.replace('\\', "/")));
|
|
}
|
|
}
|
|
let bundled_files = AGC_SKILL_PACK_FILES
|
|
.iter()
|
|
.map(|(path, _)| (*path).to_string())
|
|
.collect::<BTreeSet<_>>();
|
|
if declared_files != bundled_files {
|
|
return Err("内置 AGC Skill 文件集合与审核清单不一致".to_string());
|
|
}
|
|
Ok(manifest)
|
|
}
|
|
|
|
pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
|
|
validated_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> {
|
|
let manifest = validated_skill_pack_manifest()?;
|
|
let mut lines = vec![format!(
|
|
"审核 AGC Skill 索引({};完整正文由 Codex 命中后按需读取):",
|
|
manifest.version
|
|
)];
|
|
for entry in manifest.skills {
|
|
lines.push(format!(
|
|
"- {} | 用途:{} | 触发:{} | 工具:{} | sha256:{}",
|
|
entry.name,
|
|
entry.purpose,
|
|
entry.triggers.join("、"),
|
|
if entry.required_tools.is_empty() {
|
|
"无".to_string()
|
|
} else {
|
|
entry.required_tools.join("、")
|
|
},
|
|
&entry.sha256[..12],
|
|
));
|
|
}
|
|
Ok(lines.join("\n"))
|
|
}
|
|
|
|
pub(crate) fn read_agc_skill_resource(resource: &str) -> Result<String, String> {
|
|
let manifest = validated_skill_pack_manifest()?;
|
|
let normalized = resource.trim().trim_start_matches('/').replace('\\', "/");
|
|
let (skill_name, relative) = normalized
|
|
.split_once('/')
|
|
.ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?;
|
|
let entry = manifest
|
|
.skills
|
|
.iter()
|
|
.find(|entry| entry.name == skill_name)
|
|
.ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?;
|
|
if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) {
|
|
return Err("未登记或不安全的 AGC Skill 资源".to_string());
|
|
}
|
|
let bundled_path = format!("{skill_name}/{relative}");
|
|
let bytes =
|
|
bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?;
|
|
let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?;
|
|
String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string())
|
|
}
|
|
|
|
pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String, String> {
|
|
let manifest = validated_skill_pack_manifest()?;
|
|
let skills_root = isolated_os_home.join(".agents").join("skills");
|
|
std::fs::create_dir_all(&skills_root)
|
|
.map_err(|error| format!("创建隔离 AGC Skill 目录失败:{error}"))?;
|
|
for entry in &manifest.skills {
|
|
for relative in &entry.files {
|
|
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, canonical_bytes.as_ref())
|
|
.map_err(|error| format!("安装隔离 AGC Skill 文件失败:{error}"))?;
|
|
}
|
|
}
|
|
agc_skill_pack_fingerprint()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn bundled_skill_pack_is_exactly_the_six_reviewed_skills() {
|
|
let manifest = validated_skill_pack_manifest().expect("validated manifest");
|
|
assert_eq!(manifest.schema_version, "agc-skill-pack.v1");
|
|
assert_eq!(manifest.skills.len(), 6);
|
|
assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64));
|
|
let serialized = serde_json::to_string(
|
|
&manifest
|
|
.skills
|
|
.iter()
|
|
.map(|entry| &entry.name)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.expect("serialize names");
|
|
for denied in [
|
|
"spacetimedb",
|
|
"wechatpay",
|
|
"genarrative-play-type-integration",
|
|
] {
|
|
assert!(!serialized.contains(denied));
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
let fingerprint = install_agc_skill_pack(home.path()).expect("install skill pack");
|
|
assert_eq!(fingerprint.len(), 64);
|
|
assert!(home
|
|
.path()
|
|
.join(".agents/skills/taonier-art-assets/SKILL.md")
|
|
.is_file());
|
|
assert!(home
|
|
.path()
|
|
.join(".agents/skills/agc-project-structure/references/structure-contract.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"));
|
|
assert!(index.contains("agc_tools.agc_list_registered_assets"));
|
|
assert!(index.contains("agc_tools.agc_list_project_files"));
|
|
assert!(index.contains("agc_tools.agc_list_account_assets"));
|
|
assert!(index.contains("agc_tools.agc_import_account_assets"));
|
|
assert!(index.contains("agc_tools.agc_create_or_derive_resource"));
|
|
assert!(!index.contains("Use real platform assets only"));
|
|
assert!(!index.contains("postprocess-failed-source-preserved"));
|
|
}
|
|
|
|
#[test]
|
|
fn skill_relative_paths_reject_escape_and_absolute_forms() {
|
|
assert!(is_safe_skill_relative_path("references/contract.md"));
|
|
assert!(!is_safe_skill_relative_path("../SKILL.md"));
|
|
assert!(!is_safe_skill_relative_path("/tmp/SKILL.md"));
|
|
assert!(!is_safe_skill_relative_path(r"C:\\temp\\SKILL.md"));
|
|
assert!(!is_safe_skill_relative_path("C:/temp/SKILL.md"));
|
|
assert!(!is_safe_skill_relative_path(r"\\server\share\SKILL.md"));
|
|
assert!(!is_safe_skill_relative_path(r"references\contract.md"));
|
|
}
|
|
}
|