feat(wave-1): 适配器框架与安全基座(schema校验/dry-run/exec沙箱/密钥库/日志脱敏/字段拆分)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user