Unlucky Launcher v0.0.1-alpha
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

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+345
View File
@@ -0,0 +1,345 @@
//! `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("磁盘只读"));
}
}