Files
Unlucky-Launcher/crates/agent/src/tools/config.rs
T
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

346 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `get_config` / `set_config`(02 文档 §3.1「有权限修改 ULL 各项配置」)。
//!
//! 安全边界都落在这里,但**分级确认的"要不要问"不在这里**:
//! 工具只负责「白名单过滤 → 生成 diff → 需要确认时只报价不执行」,
//! 问不问由编排层按 [`crate::confirm::ConfirmPolicy`] 决定(YOLO 也只改那一处)。
//!
//! 值校验(类型/范围/schema)归 M12 的配置 schema 所有:agent 不复制一份,
//! 否则两处规则迟早漂移(02 文档 §3.1「schema 强校验」)。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::confirm::Sensitivity;
use crate::port::{SettingKey, SettingPort};
use crate::registry::schema::{any, non_empty_string, object, string};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 读配置。
pub const GET_CONFIG: &str = "get_config";
/// 写配置。
pub const SET_CONFIG: &str = "set_config";
/// 错误提示里最多列多少个可写键(防"把白名单灌给模型")。
pub const MAX_LISTED_KEYS: usize = 16;
/// 摘要里值的最大展示长度。
const BRIEF_CHARS: usize = 60;
/// 注册两个配置工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn SettingPort>,
) -> Result<(), RegistryError> {
registry.register(get_config_spec(Arc::clone(&port)))?;
registry.register(set_config_spec(port))
}
fn get_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let path = args
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|p| !p.is_empty());
match path {
None => {
let summary = port.summary();
let writable = port.writable_keys();
Ok(ToolOutput::ok(
json!({
"scope": "all",
"values": summary,
"writable_keys": writable.iter().map(describe_key).collect::<Vec<_>>(),
}),
format!(
"读取全部配置概要({} 个键,其中 {} 个 AI 可写)",
summary.as_object().map_or(0, serde_json::Map::len),
writable.len()
),
))
}
Some(path) => {
// 凭据类键一律不读:值会进 ToolOutput.content → 回灌模型 → 发往远端 LLM,
// 违反本 crate 的硬边界(AI 永不接触账户凭据,见 `lib.rs`)。
// 这是 agent 侧的第二道防线:第一道是 M12 不把凭据放白名单/概要。
if crate::redact::is_sensitive_key(path) {
return Err(ToolError::NotAllowed(format!(
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看。"
)));
}
let value = port.get(path).map_err(ToolError::Port)?;
Ok(ToolOutput::ok(
json!({ "scope": "key", "path": path, "value": value }),
format!("读取配置 `{path}` = {}", brief(&value)),
))
}
}
});
ToolSpec::read_only(
GET_CONFIG,
"读取启动器配置。不带 path 返回全部配置概要(含 AI 可写键及其敏感等级);带 path 返回单个键的值。",
object(
&[(
"path",
string("配置键点分路径,如 `download.concurrency`;省略则返回全部概要"),
)],
&[],
),
handler,
)
}
fn set_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
let handler: ToolFn =
Arc::new(move |ctx: &ToolCtx, args: &Value| apply_set_config(port.as_ref(), ctx, args));
ToolSpec::mutating(
SET_CONFIG,
"修改启动器配置(仅限白名单内的键)。普通键立即生效;敏感键(Java 路径/数据目录/代理/AI 端点)会先返回 needs_confirmation,等用户确认后才会真正写入。值必须符合该键的类型与范围。",
object(
&[
("path", non_empty_string("配置键点分路径,必须是 get_config 列出的可写键")),
("value", any()),
],
&["path", "value"],
),
handler,
)
}
/// `set_config` 的实现(独立成函数便于单测直接调用)。
///
/// # Errors
/// 键不在白名单、值缺失、端口写入失败时返回 [`ToolError`]。
pub fn apply_set_config(
port: &dyn SettingPort,
ctx: &ToolCtx,
args: &Value,
) -> Result<ToolOutput, ToolError> {
let path = args
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|p| !p.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `path`".to_string()))?
.to_string();
let value = args
.get("value")
.cloned()
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `value`".to_string()))?;
let keys = port.writable_keys();
let key = keys
.iter()
.find(|k| k.path == path)
.ok_or_else(|| ToolError::NotAllowed(not_writable_message(&keys, &path)))?;
// 旧值读不到不影响写(首次设置),只是 diff 少一半。
let old = port.get(&path).ok();
if !ctx.confirmed && key.sensitivity == Sensitivity::High {
let diff = json!({
"path": path,
"old": old,
"new": value,
"sensitivity": key.sensitivity,
"description": key.description,
});
return Ok(ToolOutput::needs_confirmation(
format!(
"AI 请求把 `{path}` 从 {} 改为 {}({})",
brief_opt(&old),
brief(&value),
sensitivity_label(key.sensitivity)
),
diff,
key.sensitivity,
));
}
let change = port.set(&path, &value).map_err(ToolError::Port)?;
Ok(ToolOutput::ok(
json!({
"status": "written",
"path": change.path,
"old": change.old,
"new": change.new,
"sensitivity": key.sensitivity,
}),
format!(
"已把 `{}` 从 {} 改为 {}",
change.path,
brief_opt(&change.old),
brief(&change.new)
),
))
}
/// 白名单拒绝的文案:说清"不能写"并给出可写键(模型会据此改用正确路径)。
fn not_writable_message(keys: &[SettingKey], path: &str) -> String {
let listed: Vec<&str> = keys
.iter()
.take(MAX_LISTED_KEYS)
.map(|k| k.path.as_str())
.collect();
let rest = keys.len().saturating_sub(listed.len());
let tail = if rest > 0 {
format!(",另有 {rest} 个键见 get_config 概要")
} else {
String::new()
};
format!(
"配置键 `{path}` 不在 AI 可写白名单内。可写键({} 个):{}{tail}",
keys.len(),
listed.join("、")
)
}
fn describe_key(key: &SettingKey) -> Value {
json!({
"path": key.path,
"sensitivity": key.sensitivity,
"value_type": key.value_type,
"description": key.description,
})
}
fn sensitivity_label(sensitivity: Sensitivity) -> &'static str {
match sensitivity {
Sensitivity::Low => "普通",
Sensitivity::Medium => "中等(静默但审计)",
Sensitivity::High => "敏感,需确认",
}
}
fn brief(value: &Value) -> String {
let rendered = match value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
truncate(&rendered)
}
fn brief_opt(value: &Option<Value>) -> String {
value
.as_ref()
.map_or_else(|| "(未设置)".to_string(), brief)
}
fn truncate(text: &str) -> String {
if text.chars().count() <= BRIEF_CHARS {
return text.to_string();
}
let head: String = text.chars().take(BRIEF_CHARS).collect();
format!("{head}…")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockSettings;
fn ctx(confirmed: bool) -> ToolCtx {
ToolCtx {
agent_session_id: "s1".to_string(),
confirmed,
}
}
#[test]
fn rejects_keys_outside_whitelist() {
let port = MockSettings::new();
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "java.random.thing", "value": 1 }),
)
.unwrap_err();
assert!(matches!(err, ToolError::NotAllowed(_)));
assert!(err.message().contains("不在 AI 可写白名单内"));
assert!(err.message().contains("download.concurrency"));
assert_eq!(port.set_calls(), 0);
}
#[test]
fn normal_key_writes_without_confirmation() {
let port = MockSettings::new();
let out = apply_set_config(
&port,
&ctx(false),
&json!({ "path": "download.concurrency", "value": 16 }),
)
.unwrap();
assert!(out.confirmation.is_none());
assert_eq!(out.content["status"], json!("written"));
assert_eq!(port.value_of("download.concurrency"), Some(json!(16)));
}
#[test]
fn medium_key_writes_silently_too() {
// M12:Medium = 静默生效但审计标记,不弹窗
let port = MockSettings::new();
let out = apply_set_config(
&port,
&ctx(false),
&json!({ "path": "download.region", "value": "cn" }),
)
.unwrap();
assert!(out.confirmation.is_none());
assert_eq!(port.set_calls(), 1);
}
#[test]
fn high_key_quotes_first_then_writes_after_confirm() {
let port = MockSettings::new();
let args = json!({ "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" });
let quoted = apply_set_config(&port, &ctx(false), &args).unwrap();
let confirmation = quoted.confirmation.expect("High 键应要求确认");
assert_eq!(confirmation.sensitivity, Sensitivity::High);
assert_eq!(confirmation.details["new"], json!("D:/jdk17/bin/java.exe"));
assert_eq!(port.set_calls(), 0, "未确认前不得写入");
let written = apply_set_config(&port, &ctx(true), &args).unwrap();
assert!(written.confirmation.is_none());
assert_eq!(port.set_calls(), 1);
assert_eq!(
port.value_of("java.selected_path"),
Some(json!("D:/jdk17/bin/java.exe"))
);
}
#[test]
fn locked_credential_key_is_not_writable() {
// M12:ai.api_key 是 writable=false,不在白名单里 → 任何情况下都拒绝
let port = MockSettings::new();
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "ai.api_key", "value": "sk-x" }),
)
.unwrap_err();
assert!(matches!(err, ToolError::NotAllowed(_)));
assert_eq!(port.set_calls(), 0);
}
#[test]
fn port_error_is_passed_through() {
let port = MockSettings::new();
port.fail_set_with("磁盘只读");
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "appearance.theme_mode", "value": "metro_light" }),
)
.unwrap_err();
assert_eq!(err.code(), "port_error");
assert!(err.message().contains("磁盘只读"));
}
}