feat(wave-1): 适配器框架与安全基座(schema校验/dry-run/exec沙箱/密钥库/日志脱敏/字段拆分)

This commit is contained in:
AgentDock 施工员
2026-08-25 00:38:10 +08:00
parent 296843215f
commit 7208586850
39 changed files with 2323 additions and 81 deletions
+89 -31
View File
@@ -1,7 +1,8 @@
//! 目录索引与占位条目加载(Wave 0
//! 目录索引与条目加载(Wave 1
//!
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`
//! 返回目录条目。仅消费 `id / name / name_zh / vendor / status` 五个字段
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`对每个工具
//! 做完整 schema 校验(含危险命令拒载、版本号校验),最后返回 UI 侧的五字段
//! `CatalogEntry` 视图(`load_catalog`)或完整 `Adapter``load_adapters`)。
use std::fs;
use std::path::Path;
@@ -9,6 +10,7 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::AdapterError;
use crate::schema::{Adapter, parse_adapter};
/// 目录索引(adapters/catalog.yaml
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -24,7 +26,7 @@ pub struct CatalogRef {
pub file: String,
}
/// 目录条目(占位 schema,Wave 0 仅五字段;完整字段见架构 §3.1
/// 目录条目(五字段视图,供 UI 展示
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct CatalogEntry {
pub id: String,
@@ -36,40 +38,58 @@ pub struct CatalogEntry {
pub status: String,
}
/// 加载目录索引与全部工具占位 YAML
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
impl From<Adapter> for CatalogEntry {
fn from(a: Adapter) -> Self {
CatalogEntry {
id: a.id,
name: a.name,
name_zh: a.name_zh,
vendor: a.vendor,
status: a.status,
}
}
}
/// 读取目录索引。
fn read_index<P: AsRef<Path>>(adapters_dir: P) -> Result<Catalog, AdapterError> {
let dir = adapters_dir.as_ref();
let catalog_text = fs::read_to_string(dir.join("catalog.yaml"))
.map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?;
let catalog: Catalog = serde_yaml::from_str(&catalog_text)
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))?;
serde_yaml::from_str(&catalog_text)
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))
}
let mut entries = Vec::with_capacity(catalog.tools.len());
/// 加载并校验全部工具适配器(完整 schema)。
pub fn load_adapters<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<Adapter>, AdapterError> {
let dir = adapters_dir.as_ref();
let catalog = read_index(dir)?;
let mut adapters = Vec::with_capacity(catalog.tools.len());
for r in &catalog.tools {
let text = fs::read_to_string(dir.join(&r.file))
.map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?;
let entry: CatalogEntry = serde_yaml::from_str(&text)
.map_err(|e| AdapterError::Parse(format!("解析 {} 失败: {e}", r.file)))?;
if entry.id != r.id {
return Err(AdapterError::Parse(format!(
let adapter = parse_adapter(&text)
.map_err(|e| AdapterError::Parse(format!("{} 校验未通过: {e}", r.file)))?;
if adapter.id != r.id {
return Err(AdapterError::Validation(format!(
"索引 id 与文件内 id 不一致: 索引={} 文件={}",
r.id, entry.id
r.id, adapter.id
)));
}
if entry.status != "available" && entry.status != "watch" {
return Err(AdapterError::Parse(format!(
"{} 的 status 非法: {}(应为 available | watch",
entry.id, entry.status
)));
}
entries.push(entry);
adapters.push(adapter);
}
Ok(entries)
Ok(adapters)
}
/// 加载目录并返回 UI 五字段视图(内部已做完整 schema 校验)。
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
Ok(load_adapters(adapters_dir)?.into_iter().map(CatalogEntry::from).collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::AdapterError;
#[test]
fn parses_minimal_tool_yaml() {
@@ -81,19 +101,11 @@ mod tests {
assert_eq!(entry.status, "available");
}
#[test]
fn rejects_invalid_status() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
let entry: Result<CatalogEntry, _> = serde_yaml::from_str(yaml);
// 解析本身成功,非法 status 由 load_catalog 校验;此处确认 schema 字段可读
assert!(entry.is_ok());
}
#[test]
fn loads_real_catalog_from_repo() {
// 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let entries = load_catalog(&dir).expect("真实目录应可加载");
let entries = load_catalog(&dir).expect("真实目录应可加载并通过校验");
assert_eq!(entries.len(), 14, "第一批应为 14 个工具");
for e in &entries {
assert!(!e.id.is_empty());
@@ -107,4 +119,50 @@ mod tests {
assert!(ids.contains(&want), "目录应包含 {want}");
}
}
/// 加载器拒载危险适配器(含 shell 元字符)并给中文错误。
#[test]
fn loader_rejects_dangerous_adapter() {
let dir = std::env::temp_dir().join(format!("agentdock-adapters-danger-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("tools")).unwrap();
fs::write(
dir.join("catalog.yaml"),
"catalog_version: 1\ntools:\n - id: evil\n file: tools/evil.yaml\n",
)
.unwrap();
fs::write(
dir.join("tools/evil.yaml"),
"id: evil\nname: Evil\nname_zh: Evil\nvendor: X\nstatus: available\ninstall:\n channels:\n - id: official_script\n command: [\"curl\", \"x | sh\"]\n",
)
.unwrap();
match load_adapters(&dir) {
Err(AdapterError::Parse(m)) => assert!(m.contains('|'), "错误应含元字符: {m}"),
other => panic!("应因危险命令拒载,实际 {other:?}"),
}
let _ = fs::remove_dir_all(&dir);
}
/// 加载器校验版本号:非法 adapter_version 拒载。
#[test]
fn loader_rejects_bad_version() {
let dir = std::env::temp_dir().join(format!("agentdock-adapters-ver-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("tools")).unwrap();
fs::write(
dir.join("catalog.yaml"),
"catalog_version: 1\ntools:\n - id: bad\n file: tools/bad.yaml\n",
)
.unwrap();
fs::write(
dir.join("tools/bad.yaml"),
"id: bad\nname: Bad\nname_zh: Bad\nvendor: X\nstatus: available\nadapter_version: not-semver\n",
)
.unwrap();
assert!(matches!(load_adapters(&dir), Err(AdapterError::Parse(_))));
let _ = fs::remove_dir_all(&dir);
}
}