1f904d28e9
## 目标 保留现有客户端对话与 Codex app-server 链路,把客户端自身受控业务能力通过 MCP 暴露给 Codex。 ## 范围 - 客户端会话、项目文件、资源、画布、生成、预览等稳定能力 - 审核 Skill 的索引与按需指导资源 - 复用现有账号、项目路径、权限、计费、幂等、锁和恢复边界 ## 明确不做 - 不替换客户端对话入口或 Codex app-server - 不让客户端替 Codex 判断高层意图、完成状态或规划 - 不暴露任意 Tauri command、shell、凭据、内部 URL、数据库和管理能力 当前 PR 先建立独立分支与审查边界,后续提交实现与定向验证。 Reviewed-on: #274
380 lines
14 KiB
Rust
380 lines
14 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; 5] = [
|
|
"agc-browser-playtest",
|
|
"agc-client-projection",
|
|
"agc-project-structure",
|
|
"agc-web-game-development",
|
|
"taonier-art-assets",
|
|
];
|
|
|
|
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [
|
|
(
|
|
"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-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_five_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(), 5);
|
|
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"));
|
|
}
|
|
}
|