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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,135 @@
|
||||
//! `ull agent`:对外 MCP server 的 headless 入口(02 文档 §1 的第二形态)。
|
||||
//!
|
||||
//! 两个子命令:
|
||||
//! - `agent serve`:长驻服务(`--stdio` 或 Streamable HTTP on 127.0.0.1);
|
||||
//! - `agent config`:打印 Claude Code / Cursor 的配置样例(含当前端口与令牌状态)。
|
||||
//!
|
||||
//! 为什么 `serve` 不能走 `commands::run` 的「返回 Report 再打印」套路:stdio 形态下
|
||||
//! **stdout 就是 JSON-RPC 流**,任何一行多余输出都会让客户端解析失败。所以 runner
|
||||
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
|
||||
|
||||
pub mod ports;
|
||||
pub mod serve;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_agent::mcp::config_samples;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::cli::AgentServeArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// `ull agent serve`:起服务(阻塞)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 见 [`serve::run`]。
|
||||
pub fn run_serve(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
|
||||
serve::run(ctx, args)
|
||||
}
|
||||
|
||||
/// runner 的分流入口:解析数据根 → 起服务。错误由 runner 统一打印。
|
||||
///
|
||||
/// # Errors
|
||||
/// 数据根缺失、平台不达基线、端口占用、非回环监听等返回用户错误。
|
||||
pub fn run_serve_with(
|
||||
globals: &crate::cli::Globals,
|
||||
args: &AgentServeArgs,
|
||||
err: &mut dyn std::io::Write,
|
||||
) -> Result<i32, CliError> {
|
||||
let ctx = Ctx::resolve(globals)?;
|
||||
ctx.ensure_supported()?;
|
||||
for note in ctx.notes() {
|
||||
let _ = writeln!(err, "提示: {note}");
|
||||
}
|
||||
run_serve(&ctx, args)
|
||||
}
|
||||
|
||||
/// `ull agent config`:输出两份可粘贴的客户端配置 + 当前设置状态。
|
||||
///
|
||||
/// # Errors
|
||||
/// 配置不可读时返回用户错误(schema 不认识的键不影响)。
|
||||
pub fn config_report(ctx: &Ctx, port: Option<u16>) -> Result<Report, CliError> {
|
||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
||||
let doc = store.doc();
|
||||
let enabled = doc
|
||||
.effective("agent.mcp.enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let allow_write = doc
|
||||
.effective("agent.mcp.allow_write")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let yolo = doc
|
||||
.effective("ai.yolo")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let token = doc
|
||||
.effective_str("agent.mcp.token")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_string);
|
||||
let configured_port = doc
|
||||
.effective("agent.mcp.port")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(47823);
|
||||
let port = port.unwrap_or(configured_port);
|
||||
let url = format!("http://127.0.0.1:{port}/mcp");
|
||||
let samples = config_samples(&url, token.as_deref());
|
||||
let registry = unlucky_agent::tools::build_v1_registry(&ports::build(ctx))
|
||||
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?;
|
||||
let read_only_tools = registry.read_only_definitions().len();
|
||||
let all_tools = registry.len();
|
||||
|
||||
let data = json!({
|
||||
"url": url,
|
||||
"enabled": enabled,
|
||||
"allow_write": allow_write,
|
||||
"yolo": yolo,
|
||||
"token_configured": token.is_some(),
|
||||
"read_only_tools": read_only_tools,
|
||||
"all_tools": all_tools,
|
||||
"claude_code": samples.claude_code,
|
||||
"cursor": samples.cursor,
|
||||
});
|
||||
|
||||
let mut human = vec![
|
||||
format!("MCP 端点:{url}"),
|
||||
format!(
|
||||
"开关:{} · 写权限:{} · YOLO:{} · 令牌:{}",
|
||||
if enabled {
|
||||
"已启用"
|
||||
} else {
|
||||
"未启用(serve 仍可手动起)"
|
||||
},
|
||||
if allow_write {
|
||||
"开"
|
||||
} else {
|
||||
"关(只读)"
|
||||
},
|
||||
if yolo { "开" } else { "关" },
|
||||
if token.is_some() {
|
||||
"已设置"
|
||||
} else {
|
||||
"未设置"
|
||||
}
|
||||
),
|
||||
"Claude Code(~/.claude.json 或 `claude mcp add --json`):".to_string(),
|
||||
];
|
||||
human.extend(pretty_lines(&samples.claude_code));
|
||||
human.push("Cursor(.cursor/mcp.json):".to_string());
|
||||
human.extend(pretty_lines(&samples.cursor));
|
||||
human.push(
|
||||
"启动:ull agent serve(默认 Streamable HTTP) / ull agent serve --stdio".to_string(),
|
||||
);
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
fn pretty_lines(value: &Value) -> Vec<String> {
|
||||
match serde_json::to_string_pretty(value) {
|
||||
Ok(text) => text.lines().map(|line| format!(" {line}")).collect(),
|
||||
Err(_) => vec![" (序列化失败)".to_string()],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 账户端口:只读 `AccountStore::accounts()`(公开字段)。**凭据红线在这里落闸**。
|
||||
//!
|
||||
//! `AccountStore::credentials()` 存在,但本文件**不调用**它——agent 侧也没有能承接
|
||||
//! `Credentials` 的类型([`unlucky_agent::port::AccountSummary`])。这条界线不是靠自觉:
|
||||
//! 只要有人想从工具结果里带出 token,就必须先改 `AccountSummary`,而 `port::game` 的
|
||||
//! 单测会先炸(序列化结果里不许出现 token/password/secret)。
|
||||
|
||||
use unlucky_agent::port::game::{AccountPort, AccountSummary};
|
||||
use unlucky_agent::port::PortResult;
|
||||
use unlucky_core::auth::AccountStore;
|
||||
|
||||
/// M31 账户库的只读端口。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreAccounts {
|
||||
root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl CoreAccounts {
|
||||
/// `root` = `<数据根>/.ULL/data/auth`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountPort for CoreAccounts {
|
||||
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
|
||||
let store = AccountStore::open(self.root.clone());
|
||||
if !store.warnings().is_empty() {
|
||||
// 账户文件有坏条目:不阻断(用户还能看到其余账户),但要留痕到 stderr。
|
||||
eprintln!(
|
||||
"[unlucky-agent] 账户库有 {} 条提示(只读本工具不受影响)",
|
||||
store.warnings().len()
|
||||
);
|
||||
}
|
||||
Ok(store
|
||||
.accounts()
|
||||
.iter()
|
||||
.map(|meta| AccountSummary {
|
||||
id: meta.id.as_str().to_string(),
|
||||
kind: meta.kind.as_str().to_string(),
|
||||
profile_id: meta.profile_id.clone(),
|
||||
profile_name: meta.profile_name.clone(),
|
||||
login_name: meta.login_name.clone(),
|
||||
created_at_ms: meta.created_at_ms,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。
|
||||
//!
|
||||
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算
|
||||
//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_agent::port::{
|
||||
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
|
||||
PortResult,
|
||||
};
|
||||
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding};
|
||||
use unlucky_core::diagnose::loader::RuleLoader;
|
||||
use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind;
|
||||
|
||||
use super::events::{tail_join, SessionSource};
|
||||
|
||||
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
|
||||
pub const DEFAULT_TAIL: usize = 2000;
|
||||
|
||||
/// M15 规则引擎的端口实现。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreDiagnose {
|
||||
source: SessionSource,
|
||||
tail: usize,
|
||||
}
|
||||
|
||||
impl CoreDiagnose {
|
||||
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
source: SessionSource::new(root),
|
||||
tail: DEFAULT_TAIL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_kind(kind: CoreFixKind) -> FixActionKind {
|
||||
match kind {
|
||||
CoreFixKind::ChangeJava => FixActionKind::ChangeJava,
|
||||
CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory,
|
||||
CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics,
|
||||
CoreFixKind::RemoveMod => FixActionKind::RemoveMod,
|
||||
CoreFixKind::Reinstall => FixActionKind::Reinstall,
|
||||
CoreFixKind::SeekHelp => FixActionKind::SeekHelp,
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_of(finding: &Finding) -> DiagnosisCandidate {
|
||||
let core_candidate = finding.to_candidate();
|
||||
DiagnosisCandidate {
|
||||
id: core_candidate.id,
|
||||
severity: core_candidate.severity.to_string(),
|
||||
category: core_candidate.category.to_string(),
|
||||
confidence: core_candidate.confidence,
|
||||
message_key: core_candidate.message_key,
|
||||
message: core_candidate.message,
|
||||
detail: core_candidate.detail.unwrap_or_default(),
|
||||
evidence: core_candidate.evidence,
|
||||
fix_actions: core_candidate
|
||||
.fix_actions
|
||||
.into_iter()
|
||||
.map(|action| FixAction {
|
||||
kind: map_kind(action.kind),
|
||||
tool: action.tool.to_string(),
|
||||
label: String::new(),
|
||||
args: action
|
||||
.args
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<String, String>>(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagnosePort for CoreDiagnose {
|
||||
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
|
||||
let entry = self.source.find(session_id)?;
|
||||
let events = self.source.events(&entry)?;
|
||||
let (stdout, stderr, error_lines) = SessionSource::split_text(&events);
|
||||
|
||||
let exit = ExitSignal::classify(
|
||||
entry.exit_code,
|
||||
Some(entry.duration_ms),
|
||||
error_lines,
|
||||
&stderr.join("\n"),
|
||||
);
|
||||
let mut input = DiagnosisInput::new().with_exit(exit);
|
||||
let stdout_text = tail_join(&stdout, self.tail);
|
||||
if !stdout_text.is_empty() {
|
||||
input.push_text(SignalOrigin::Stdout, stdout_text);
|
||||
}
|
||||
let stderr_text = tail_join(&stderr, self.tail);
|
||||
if !stderr_text.is_empty() {
|
||||
input.push_text(SignalOrigin::Stderr, stderr_text);
|
||||
}
|
||||
|
||||
let loader = RuleLoader::embedded();
|
||||
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
|
||||
let report = diagnoser.analyze(&input);
|
||||
|
||||
Ok(CrashDiagnosis {
|
||||
session_id: entry.session_id.clone(),
|
||||
source: "rules".to_string(),
|
||||
needs_more_info: report.needs_more_info(),
|
||||
candidates: report.findings.iter().map(candidate_of).collect(),
|
||||
secondary: report.secondary.iter().map(candidate_of).collect(),
|
||||
evidence: stderr.iter().rev().take(3).cloned().collect(),
|
||||
exit: ExitSummary {
|
||||
code: entry.exit_code,
|
||||
exit_type: entry.exit_type.clone(),
|
||||
silent_failure: entry.silent_failure,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn seed(root: &std::path::Path, stderr_line: &str) {
|
||||
let game = root.join("game");
|
||||
std::fs::create_dir_all(&game).expect("建目录");
|
||||
let entry = json!({
|
||||
"v": 1,
|
||||
"session_id": "20261029T124501-9f3c2a",
|
||||
"instance_id": "inst-1",
|
||||
"instance_name": "1.20.1 Forge",
|
||||
"game_version": "1.20.1",
|
||||
"started_at": "2026-10-29T12:45:01.123Z",
|
||||
"ended_at": "2026-10-29T12:45:05.123Z",
|
||||
"duration_ms": 4000,
|
||||
"exit_code": 1,
|
||||
"exit_type": "application_error",
|
||||
"silent_failure": false,
|
||||
"window_detected": false,
|
||||
"event_count": 1,
|
||||
"log_path": "20261029T124501-9f3c2a.jsonl",
|
||||
"crash_found": true,
|
||||
"diagnosis_top": null
|
||||
});
|
||||
std::fs::write(game.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
|
||||
let event = json!({
|
||||
"seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0,
|
||||
"kind": "stderr",
|
||||
"payload": { "line": stderr_line, "level": "error" }
|
||||
});
|
||||
std::fs::write(
|
||||
game.join("20261029T124501-9f3c2a.jsonl"),
|
||||
format!("{event}\n"),
|
||||
)
|
||||
.expect("写事件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_the_rule_engine_over_a_session() {
|
||||
let root = std::env::temp_dir().join(format!("ull-mcp-diagnose-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
seed(
|
||||
&root,
|
||||
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher",
|
||||
);
|
||||
|
||||
let port = CoreDiagnose::new(root.clone());
|
||||
let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断");
|
||||
assert_eq!(report.session_id, "20261029T124501-9f3c2a");
|
||||
assert_eq!(report.source, "rules");
|
||||
assert_eq!(report.exit.code, Some(1));
|
||||
// 规则集里应有 JVM/完整性类结论命中这条 stderr
|
||||
assert!(!report.candidates.is_empty(), "应至少命中一条规则");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_session_is_a_readable_error() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ull-mcp-diagnose-none-{}", std::process::id()));
|
||||
let port = CoreDiagnose::new(root);
|
||||
let err = port.diagnose("nope").unwrap_err();
|
||||
assert!(err.contains("找不到会话"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 会话数据的读取底座:索引(`index.jsonl`)+ 事件流(`<id>.jsonl`)。
|
||||
//!
|
||||
//! 三个端口([`super::session::CoreSessions`] 的列表/切片、[`super::diagnose::CoreDiagnose`]
|
||||
//! 的规则跑批)共用这一份读取逻辑——「同一份 jsonl 两种读法」若各写一遍,
|
||||
//! 迟早出现「列表说有 5 条事件、诊断只看到 3 条」这种对不上的账。
|
||||
//!
|
||||
//! 形状依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||
|
||||
use unlucky_agent::port::PortResult;
|
||||
|
||||
/// 一个数据根下的会话索引 + 事件流。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionSource {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionSource {
|
||||
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// 索引文件路径。
|
||||
#[must_use]
|
||||
pub fn index(&self) -> PathBuf {
|
||||
session_index::index_path(&self.root)
|
||||
}
|
||||
|
||||
/// 全部会话(按 `started_at` 倒序,同 id 取最后一行)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。
|
||||
pub fn entries(&self) -> PortResult<Vec<SessionIndexEntry>> {
|
||||
let index = self.index();
|
||||
let mut entries = session_index::read_all(&index)
|
||||
.map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?;
|
||||
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 按完整 id 或唯一前缀找会话。
|
||||
///
|
||||
/// # Errors
|
||||
/// 找不到 / 前缀歧义时返回用户可读错误。
|
||||
pub fn find(&self, key: &str) -> PortResult<SessionIndexEntry> {
|
||||
let entries = self.entries()?;
|
||||
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
|
||||
return Ok(entry.clone());
|
||||
}
|
||||
let matches: Vec<&SessionIndexEntry> = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.session_id.starts_with(key))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[only] => Ok((*only).clone()),
|
||||
[] => Err(format!(
|
||||
"找不到会话 `{key}`(索引:{})",
|
||||
self.index().display()
|
||||
)),
|
||||
_ => Err(format!(
|
||||
"`{key}` 匹配到 {} 个会话,请给更长的前缀",
|
||||
matches.len()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个会话的原始事件行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 事件流文件读不了时返回用户可读错误。
|
||||
pub fn events(&self, entry: &SessionIndexEntry) -> PortResult<Vec<Value>> {
|
||||
let path = self.event_path(entry);
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读会话事件流 {} 失败:{error}", path.display()))?;
|
||||
Ok(text
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 事件流文件路径。
|
||||
#[must_use]
|
||||
pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf {
|
||||
self.root.join("game").join(&entry.log_path)
|
||||
}
|
||||
|
||||
/// 事件里的 stdout / stderr 文本(诊断与日志阅读共用)。
|
||||
///
|
||||
/// 返回 `(stdout 行, stderr 行, stderr 里 level=error 的条数)`。
|
||||
#[must_use]
|
||||
pub fn split_text(events: &[Value]) -> (Vec<String>, Vec<String>, u32) {
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let mut error_lines = 0u32;
|
||||
for event in events {
|
||||
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
match event.get("kind").and_then(Value::as_str) {
|
||||
Some("stdout") => stdout.push(line.to_string()),
|
||||
Some("stderr") => {
|
||||
stderr.push(line.to_string());
|
||||
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
|
||||
error_lines += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(stdout, stderr, error_lines)
|
||||
}
|
||||
}
|
||||
|
||||
/// 取末尾 `tail` 行并拼成文本(诊断输入用)。
|
||||
#[must_use]
|
||||
pub fn tail_join(lines: &[String], tail: usize) -> String {
|
||||
let start = lines.len().saturating_sub(tail.max(1));
|
||||
let mut text = lines[start..].join("\n");
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// 索引里有没有这个会话(存在性判断,不读事件流)。
|
||||
#[must_use]
|
||||
pub fn index_exists(root: &Path) -> bool {
|
||||
session_index::index_path(root).is_file()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 实例 / 版本 / Java 端口(M14 + M33)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_agent::port::game::{
|
||||
GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, VersionSummary,
|
||||
};
|
||||
use unlucky_agent::port::PortResult;
|
||||
use unlucky_core::game::java::{self, JavaOrigin};
|
||||
use unlucky_core::game::{install::instance::read_profiles, GameLayout};
|
||||
use unlucky_core::platform::data_dir::DataMode;
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_core::platform::paths::{Platform, ProcessEnv};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
/// 实例 / 版本 / Java 的只读端口。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreGame {
|
||||
layout: LauncherLayout,
|
||||
game: GameLayout,
|
||||
launcher_config: PathBuf,
|
||||
mode: DataMode,
|
||||
platform: Platform,
|
||||
}
|
||||
|
||||
impl CoreGame {
|
||||
/// 装配:`layout` 是 `.ULL/`,`game` 是 `.minecraft/`。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
layout: LauncherLayout,
|
||||
game: GameLayout,
|
||||
mode: DataMode,
|
||||
platform: Platform,
|
||||
) -> Self {
|
||||
let launcher_config = layout.launcher_config();
|
||||
Self {
|
||||
layout,
|
||||
game,
|
||||
launcher_config,
|
||||
mode,
|
||||
platform,
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self) -> SettingStore {
|
||||
SettingStore::load(&self.launcher_config, launcher_schema())
|
||||
}
|
||||
|
||||
fn managed_roots(&self) -> Vec<PathBuf> {
|
||||
let store = self.store();
|
||||
let install_root = store
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(PathBuf::from);
|
||||
java::location::scan_roots(
|
||||
self.mode,
|
||||
&self.layout,
|
||||
install_root.as_deref(),
|
||||
self.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl GamePort for CoreGame {
|
||||
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
|
||||
let profiles =
|
||||
read_profiles(&self.layout).map_err(|error| format!("读实例清单失败:{error}"))?;
|
||||
let mut out: Vec<InstanceSummary> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| InstanceSummary {
|
||||
id: profile.id.as_str().to_string(),
|
||||
name: profile.name.clone(),
|
||||
version_id: profile.version_id.clone(),
|
||||
version_present: self.game.is_version_installed(&profile.version_id),
|
||||
game_dir: profile.game_dir.as_ref().map(|p| p.display().to_string()),
|
||||
java_path: profile.java_path.as_ref().map(|p| p.display().to_string()),
|
||||
last_played_at: profile.last_played_at.as_deref().and_then(parse_rfc3339_ms),
|
||||
})
|
||||
.collect();
|
||||
// `profiles.json` 的顺序是 UI 顺序;这里按最近启动时间倒序(最近的先看)。
|
||||
out.sort_by_key(|instance| std::cmp::Reverse(instance.last_played_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
|
||||
let versions = self
|
||||
.game
|
||||
.installed_versions()
|
||||
.map_err(|error| format!("读已安装版本失败:{error}"))?;
|
||||
let profiles = read_profiles(&self.layout).map_err(|error| error.to_string())?;
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let instance_count = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.version_id == id)
|
||||
.count();
|
||||
VersionSummary {
|
||||
id,
|
||||
kind: None,
|
||||
instance_count,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn java_status(&self) -> PortResult<JavaStatus> {
|
||||
let roots = self.managed_roots();
|
||||
let mut candidates = java::candidates_in_roots(&roots);
|
||||
if let Ok(profiles) = read_profiles(&self.layout) {
|
||||
for profile in &profiles.profiles {
|
||||
if let Some(path) = &profile.java_path {
|
||||
candidates.push(java::probe(path, JavaOrigin::Instance));
|
||||
}
|
||||
}
|
||||
}
|
||||
let candidates = java::discover::dedupe(candidates);
|
||||
let summary: Vec<JavaRuntimeSummary> = candidates
|
||||
.iter()
|
||||
.map(|runtime| JavaRuntimeSummary {
|
||||
binary: runtime.binary.display().to_string(),
|
||||
version: runtime.version.clone(),
|
||||
major: runtime.major,
|
||||
vendor: runtime.vendor.clone(),
|
||||
origin: runtime.origin.category().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let default_memory = self
|
||||
.store()
|
||||
.effective("java.default_memory_mb")
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
|
||||
Ok(JavaStatus {
|
||||
default_memory_mb: default_memory,
|
||||
managed_roots: roots
|
||||
.iter()
|
||||
.map(|root| root.display().to_string())
|
||||
.collect(),
|
||||
candidates: summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC3339 → Unix 毫秒(解析失败返回 `None`,不让一个坏时间戳毁掉整个列表)。
|
||||
///
|
||||
/// 只处理 launch 写出来的那一种形态(总是 UTC、总是 `Z` 结尾)。不引第三方日期库:
|
||||
/// agent 侧只需要「排序用的毫秒」,多一棵依赖树不值当。
|
||||
fn parse_rfc3339_ms(text: &str) -> Option<u64> {
|
||||
let body = text.trim().trim_end_matches('Z');
|
||||
let (date, time) = body.split_once('T')?;
|
||||
let mut date_parts = date.split('-');
|
||||
let year: i64 = date_parts.next()?.parse().ok()?;
|
||||
let month: i64 = date_parts.next()?.parse().ok()?;
|
||||
let day: i64 = date_parts.next()?.parse().ok()?;
|
||||
let mut time_parts = time.split(':');
|
||||
let hour: i64 = time_parts.next()?.parse().ok()?;
|
||||
let minute: i64 = time_parts.next()?.parse().ok()?;
|
||||
let seconds_text = time_parts.next()?;
|
||||
let (secs, millis) = match seconds_text.split_once('.') {
|
||||
Some((secs, frac)) => (secs.parse::<i64>().ok()?, {
|
||||
let padded = format!("{frac:0<3}");
|
||||
padded.get(..3)?.parse::<i64>().ok()?
|
||||
}),
|
||||
None => (seconds_text.parse::<i64>().ok()?, 0),
|
||||
};
|
||||
|
||||
let days = days_from_civil(year, month, day);
|
||||
let total_ms = (((days * 24 + hour) * 60 + minute) * 60 + secs) * 1000 + millis;
|
||||
u64::try_from(total_ms).ok()
|
||||
}
|
||||
|
||||
/// Howard Hinnant 的 `days_from_civil`(1970-01-01 为 0)。
|
||||
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||
let y = if month <= 2 { year - 1 } else { year };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = (month + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + day - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_launch_timestamps() {
|
||||
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00.000Z"), Some(0));
|
||||
// 与既有审计测试同源:1_793_304_753_123 = 2026-10-29T20:12:33.123Z
|
||||
assert_eq!(
|
||||
parse_rfc3339_ms("2026-10-29T20:12:33.123Z"),
|
||||
Some(1_793_304_753_123)
|
||||
);
|
||||
assert_eq!(parse_rfc3339_ms("not-a-time"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 端口装配:把 `unlucky-core` 的实现塞进 agent 的五个端口。
|
||||
//!
|
||||
//! 为什么这一层在 CLI 而不在 agent:agent 只知道 trait(`#!` 依赖倒置),
|
||||
//! `crates/app` 是 GUI 的装配处,而 `ull agent serve` 是**headless 的另一个装配入口**。
|
||||
//! 放这里让 CLI 自洽,也让 GUI 后续复用同一批适配器(把本目录上移到 app 是一次纯搬家)。
|
||||
//!
|
||||
//! 每个文件一个端口,与 `crates/agent/src/port*.rs` 一一对应,读起来不用跳来跳去。
|
||||
|
||||
pub mod accounts;
|
||||
pub mod diagnose;
|
||||
pub mod events;
|
||||
pub mod game;
|
||||
pub mod session;
|
||||
pub mod setting;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_agent::port::Ports;
|
||||
use unlucky_core::platform::data_dir::DataMode;
|
||||
use unlucky_core::platform::paths::Platform;
|
||||
|
||||
use crate::context::Ctx;
|
||||
|
||||
pub use accounts::CoreAccounts;
|
||||
pub use diagnose::CoreDiagnose;
|
||||
pub use game::CoreGame;
|
||||
pub use session::CoreSessions;
|
||||
pub use setting::CoreSetting;
|
||||
|
||||
/// 按一次 CLI 上下文装配五个端口。
|
||||
#[must_use]
|
||||
pub fn build(ctx: &Ctx) -> Ports {
|
||||
Ports {
|
||||
// 数据根键(`core.*`)落便携目录那份,与 `Ctx::resolve` 同源——CLI `config set`
|
||||
// 与 AI `set_config` 共用 `Ctx` 给出的两个落点(finding …-agent-settingport-core-ai)。
|
||||
setting: Arc::new(CoreSetting::split(
|
||||
ctx.launcher_config(),
|
||||
ctx.portable_launcher_config(),
|
||||
)),
|
||||
sessions: Arc::new(CoreSessions::new(ctx.sessions_root())),
|
||||
diagnose: Arc::new(CoreDiagnose::new(ctx.sessions_root())),
|
||||
game: Arc::new(CoreGame::new(
|
||||
ctx.layout(),
|
||||
ctx.game(),
|
||||
data_mode(ctx),
|
||||
platform(ctx),
|
||||
)),
|
||||
accounts: Arc::new(CoreAccounts::new(ctx.auth_data())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn data_mode(ctx: &Ctx) -> DataMode {
|
||||
ctx.data.mode
|
||||
}
|
||||
|
||||
pub(crate) fn platform(ctx: &Ctx) -> Platform {
|
||||
ctx.platform
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 会话端口:索引 → `list_game_sessions`,事件流切片 → `read_game_log`。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use unlucky_agent::port::{
|
||||
GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult,
|
||||
};
|
||||
|
||||
use super::events::SessionSource;
|
||||
|
||||
/// M18 的会话索引 + 事件流。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreSessions {
|
||||
source: SessionSource,
|
||||
}
|
||||
|
||||
impl CoreSessions {
|
||||
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
source: SessionSource::new(root),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_of(
|
||||
entry: &unlucky_core::launch::session_index::SessionIndexEntry,
|
||||
) -> GameSessionSummary {
|
||||
GameSessionSummary {
|
||||
session_id: entry.session_id.clone(),
|
||||
instance_id: Some(entry.instance_id.clone()).filter(|id| !id.is_empty()),
|
||||
instance_name: Some(entry.instance_name.clone()).filter(|name| !name.is_empty()),
|
||||
game_version: Some(entry.game_version.clone()).filter(|v| !v.is_empty()),
|
||||
started_at: Some(entry.started_at.clone()).filter(|v| !v.is_empty()),
|
||||
ended_at: Some(entry.ended_at.clone()).filter(|v| !v.is_empty()),
|
||||
duration_ms: entry.duration_ms,
|
||||
exit_code: entry.exit_code,
|
||||
exit_type: entry.exit_type.clone(),
|
||||
silent_failure: entry.silent_failure,
|
||||
window_detected: entry.window_detected,
|
||||
event_count: entry.event_count,
|
||||
crash_found: entry.crash_found,
|
||||
diagnosis_top: entry.diagnosis_top.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_log_event(event: &Value, fallback_seq: u64) -> LogEvent {
|
||||
LogEvent {
|
||||
seq: event
|
||||
.get("seq")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(fallback_seq),
|
||||
ts: event
|
||||
.get("ts")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
t_ms: event.get("t_ms").and_then(Value::as_u64).unwrap_or(0),
|
||||
kind: event
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
payload: event.get("payload").cloned().unwrap_or(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
impl GameSessionPort for CoreSessions {
|
||||
fn list_sessions(
|
||||
&self,
|
||||
limit: usize,
|
||||
instance_id: Option<&str>,
|
||||
) -> PortResult<Vec<GameSessionSummary>> {
|
||||
let mut entries = self.source.entries()?;
|
||||
if let Some(instance) = instance_id {
|
||||
entries.retain(|entry| entry.instance_id == instance);
|
||||
}
|
||||
entries.truncate(limit);
|
||||
Ok(entries.iter().map(summary_of).collect())
|
||||
}
|
||||
|
||||
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
|
||||
let entry = self.source.find(session_id)?;
|
||||
let raw = self.source.events(&entry)?;
|
||||
let events: Vec<LogEvent> = raw
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, event)| to_log_event(event, index as u64))
|
||||
.collect();
|
||||
|
||||
let mut kind_counts = std::collections::BTreeMap::new();
|
||||
for event in &events {
|
||||
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
|
||||
}
|
||||
let matched: Vec<&LogEvent> = events
|
||||
.iter()
|
||||
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
|
||||
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
|
||||
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
|
||||
.collect();
|
||||
let max = if query.max_events == 0 {
|
||||
matched.len()
|
||||
} else {
|
||||
query.max_events
|
||||
};
|
||||
let truncated = matched.len() > max;
|
||||
|
||||
Ok(LogPage {
|
||||
session_id: entry.session_id,
|
||||
total_events: events.len() as u64,
|
||||
matched_events: matched.len() as u64,
|
||||
truncated,
|
||||
kind_counts,
|
||||
first_ts: events.first().map(|e| e.ts.clone()),
|
||||
last_ts: events.last().map(|e| e.ts.clone()),
|
||||
events: matched.into_iter().take(max).cloned().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话摘要 + 事件流 → JSON 里用得到的稳定形状(`read_game_log` 的 payload 直接透传)。
|
||||
#[must_use]
|
||||
pub fn log_event_json(event: &LogEvent) -> Value {
|
||||
json!({
|
||||
"seq": event.seq,
|
||||
"ts": event.ts,
|
||||
"t_ms": event.t_ms,
|
||||
"kind": event.kind,
|
||||
"payload": event.payload,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn seed_session(root: &Path) {
|
||||
let game_dir = root.join("game");
|
||||
std::fs::create_dir_all(&game_dir).expect("建目录");
|
||||
let entry = json!({
|
||||
"v": 1,
|
||||
"session_id": "20261029T124501-9f3c2a",
|
||||
"instance_id": "inst-1",
|
||||
"instance_name": "1.20.1 Forge",
|
||||
"game_version": "1.20.1",
|
||||
"started_at": "2026-10-29T12:45:01.123Z",
|
||||
"ended_at": "2026-10-29T12:46:31.123Z",
|
||||
"duration_ms": 90000,
|
||||
"exit_code": 1,
|
||||
"exit_type": "application_error",
|
||||
"silent_failure": false,
|
||||
"window_detected": true,
|
||||
"event_count": 2,
|
||||
"log_path": "20261029T124501-9f3c2a.jsonl",
|
||||
"crash_found": true,
|
||||
"diagnosis_top": "integrity.forge_missing_library"
|
||||
});
|
||||
std::fs::write(game_dir.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
|
||||
let events = [
|
||||
json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}),
|
||||
json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}),
|
||||
];
|
||||
let text: String = events
|
||||
.iter()
|
||||
.map(|event| format!("{event}\n"))
|
||||
.collect::<String>();
|
||||
std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_slices_a_session() {
|
||||
let root = std::env::temp_dir().join(format!("ull-mcp-sessions-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
seed_session(&root);
|
||||
|
||||
let port = CoreSessions::new(root.clone());
|
||||
let sessions = port.list_sessions(10, None).expect("应能列出");
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].exit_code, Some(1));
|
||||
assert!(sessions[0].crash_found);
|
||||
|
||||
let page = port
|
||||
.read_log(
|
||||
"20261029T124501",
|
||||
&LogQuery {
|
||||
max_events: 1,
|
||||
kinds: vec!["stderr".to_string()],
|
||||
..LogQuery::default()
|
||||
},
|
||||
)
|
||||
.expect("应能读");
|
||||
assert_eq!(page.total_events, 2);
|
||||
assert_eq!(page.events.len(), 1);
|
||||
assert_eq!(page.events[0].kind, "stderr");
|
||||
assert!(page.kind_counts.contains_key("stdout"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! 配置端口:`core::setting` 的直译(**唯一真相源**在 core,这里不复制校验规则)。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 1. 每次调用都重新 `SettingStore::load`——外部 MCP 客户端可能刚改了配置,
|
||||
//! 缓存一份快照会让审计里的 `old` 骗人;
|
||||
//! 2. **敏感值一律掩码**(M12 `Sensitivity::High` + agent 的凭据词表两道):
|
||||
//! 工具结果会回灌模型、也可能发往远端 LLM,AI 只需要知道「这个键是什么、怎么改」;
|
||||
//! 3. **落点与 `ull config` 同源**(分流规则仍只在 `crate::context` 一处):数据根键
|
||||
//! 读写便携目录那份 `launcher.json`,其余键读写解析后的数据根。只持一份路径时
|
||||
//! standard / `--data-dir` 模式下 AI 改 `core.data_mode` 会「写了没反应」
|
||||
//! (finding 20261031-worker-chore40-improve-agent-settingport-core-ai)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use unlucky_agent::confirm::Sensitivity;
|
||||
use unlucky_agent::port::{PortResult, SettingChange, SettingKey, SettingPort};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::spec::{Sensitivity as CoreSensitivity, ValueKind};
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
|
||||
|
||||
use crate::context::is_data_root_key;
|
||||
|
||||
/// 掩码占位(与 CLI `config get` 同一串,读输出的人不用学两套)。
|
||||
pub const MASK: &str = "<redacted>";
|
||||
|
||||
/// 把 `core::setting` 包成 agent 的 [`SettingPort`]。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreSetting {
|
||||
/// 解析后的数据根那份 `launcher.json`(非数据根键的落点)。
|
||||
path: PathBuf,
|
||||
/// 便携目录那份 `launcher.json`(数据根键的落点);与 `path` 重合或拿不到
|
||||
/// exe 目录时为 `None`。
|
||||
core_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl CoreSetting {
|
||||
/// 只指向数据根那份 `<数据根>/.ULL/config/launcher.json`(两份落点重合时够用)。
|
||||
#[must_use]
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self::split(path, None)
|
||||
}
|
||||
|
||||
/// 数据根那份 + 便携目录那份(`Ctx::portable_launcher_config`,与数据根解析同源)。
|
||||
///
|
||||
/// 两份路径重合(便携模式)时只留一份,免得同一个文件被加载两次。
|
||||
#[must_use]
|
||||
pub fn split(path: impl Into<PathBuf>, core_path: Option<PathBuf>) -> Self {
|
||||
let path = path.into();
|
||||
let core_path = core_path.filter(|core| core.as_path() != path.as_path());
|
||||
Self { path, core_path }
|
||||
}
|
||||
|
||||
/// 某个键的落点文件(数据根键 → 便携目录那份,其余 → 数据根那份)。
|
||||
fn path_for(&self, key: &str) -> &Path {
|
||||
match &self.core_path {
|
||||
Some(core) if is_data_root_key(key) => core.as_path(),
|
||||
_ => self.path.as_path(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按落点加载 store(读写都用它,`get`/`set` 不会各看一份文件)。
|
||||
fn store_for(&self, key: &str) -> SettingStore {
|
||||
SettingStore::load(self.path_for(key), launcher_schema())
|
||||
}
|
||||
|
||||
/// 数据根那份(schema / 白名单等与落点无关的元数据都从它出)。
|
||||
fn store(&self) -> SettingStore {
|
||||
SettingStore::load(&self.path, launcher_schema())
|
||||
}
|
||||
}
|
||||
|
||||
/// `summary` 里某个键的**原始**取值来源(掩码在 `summary` 里做):数据根键取便携目录
|
||||
/// 那份——也就是 `Ctx::resolve` 读的同一份,否则概要和「真正生效值」是两套真相。
|
||||
fn raw_value<'a>(
|
||||
data: &'a SettingStore,
|
||||
core: Option<&'a SettingStore>,
|
||||
key: &str,
|
||||
) -> Option<&'a Value> {
|
||||
match core {
|
||||
Some(core) if is_data_root_key(key) => core.effective(key),
|
||||
_ => data.effective(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn sensitivity_of(value: CoreSensitivity) -> Sensitivity {
|
||||
match value {
|
||||
CoreSensitivity::Low => Sensitivity::Low,
|
||||
CoreSensitivity::Medium => Sensitivity::Medium,
|
||||
CoreSensitivity::High => Sensitivity::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_label(kind: &ValueKind) -> &'static str {
|
||||
match kind {
|
||||
ValueKind::Bool => "boolean",
|
||||
ValueKind::Int { .. } => "integer",
|
||||
ValueKind::Float { .. } => "number",
|
||||
ValueKind::Text { .. } => "string",
|
||||
ValueKind::Path => "path",
|
||||
ValueKind::Enum(_) => "enum",
|
||||
ValueKind::TextList => "string[]",
|
||||
ValueKind::EnumList(_) => "enum[]",
|
||||
ValueKind::Any => "json",
|
||||
}
|
||||
}
|
||||
|
||||
/// 该键的值要不要在 AI 侧掩码:凭据词表命中,或 schema 标 `High`。
|
||||
fn mask(value: Value, sensitive: bool) -> Value {
|
||||
if sensitive {
|
||||
json!(MASK)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingPort for CoreSetting {
|
||||
/// 全量概要:每个键的生效值都取自**它自己的落点文件**(数据根键取便携目录那份)。
|
||||
fn summary(&self) -> Value {
|
||||
let store = self.store();
|
||||
let core = self
|
||||
.core_path
|
||||
.as_ref()
|
||||
.map(|path| SettingStore::load(path, launcher_schema()));
|
||||
let schema = store.doc().schema().clone();
|
||||
let mut map = Map::new();
|
||||
for spec in schema.specs() {
|
||||
if spec.is_deprecated() {
|
||||
continue;
|
||||
}
|
||||
let sensitive = spec.ai.sensitivity == CoreSensitivity::High
|
||||
|| unlucky_agent::redact::is_sensitive_key(spec.key);
|
||||
let value = raw_value(&store, core.as_ref(), spec.key)
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
map.insert(spec.key.to_string(), mask(value, sensitive));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> PortResult<Value> {
|
||||
if unlucky_agent::redact::is_sensitive_key(path) {
|
||||
return Err(format!(
|
||||
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看"
|
||||
));
|
||||
}
|
||||
let store = self.store_for(path);
|
||||
let schema = store.doc().schema().clone();
|
||||
let Some(value) = store.effective(path).cloned() else {
|
||||
return Err(format!("配置键 `{path}` 不在 schema 里"));
|
||||
};
|
||||
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
|
||||
Ok(mask(value, sensitive))
|
||||
}
|
||||
|
||||
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
|
||||
let file = self.path_for(path);
|
||||
let mut store = self.store_for(path);
|
||||
let schema = store.doc().schema().clone();
|
||||
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
|
||||
let old = store
|
||||
.effective(path)
|
||||
.cloned()
|
||||
.map(|old| mask(old, sensitive));
|
||||
store
|
||||
.set(path, value.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
match store.save() {
|
||||
Ok(SaveOutcome::Saved(_)) | Ok(SaveOutcome::Skipped(SkipReason::NotDirty)) => {}
|
||||
// 只读/不可信:**不能**报成功——外部客户端会以为改完了。
|
||||
Ok(SaveOutcome::Skipped(reason)) => {
|
||||
return Err(format!(
|
||||
"{} 当前不可写({});请让用户检查文件权限或内容",
|
||||
file.display(),
|
||||
reason.as_str()
|
||||
))
|
||||
}
|
||||
Err(error) => return Err(format!("写 {} 失败:{error}", file.display())),
|
||||
}
|
||||
Ok(SettingChange {
|
||||
path: path.to_string(),
|
||||
old,
|
||||
new: mask(value.clone(), sensitive),
|
||||
})
|
||||
}
|
||||
|
||||
fn writable_keys(&self) -> Vec<SettingKey> {
|
||||
let store = self.store();
|
||||
let schema = store.doc().schema().clone();
|
||||
schema
|
||||
.specs()
|
||||
.iter()
|
||||
.filter(|spec| spec.ai.writable && !spec.is_deprecated())
|
||||
.map(|spec| SettingKey {
|
||||
path: spec.key.to_string(),
|
||||
sensitivity: sensitivity_of(spec.ai.sensitivity),
|
||||
value_type: kind_label(&spec.kind).to_string(),
|
||||
description: spec.doc.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
// core 的 `to_json_schema()` 只遍历 `ai.writable && !deprecated`,
|
||||
// 凭据键(`locked`)天然不在其中——这正是 `get_config_schema` 要的东西。
|
||||
self.store().doc().schema().to_json_schema()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,226 @@
|
||||
//! [`super`] 的单测:数据根键与其余键的落点分流、AI 侧写入真的生效。
|
||||
//!
|
||||
//! 与 `commands/config/tests.rs` 同一套造场手法:临时目录 + `Ctx::resolve_in`
|
||||
//! 把便携目录钉死在临时路径里,不动真实数据根。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::*;
|
||||
use crate::cli::Globals;
|
||||
use crate::commands::agent::ports::build;
|
||||
use crate::context::Ctx;
|
||||
|
||||
fn temp_config() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-core-setting-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
dir.join("launcher.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_omits_credential_keys_and_writable_keys_match_policy() {
|
||||
let port = CoreSetting::new(temp_config());
|
||||
let schema = port.schema();
|
||||
let text = schema.to_string();
|
||||
assert!(!text.contains("ai.api_key"), "凭据键不得进 AI schema");
|
||||
assert!(text.contains("agent.mcp.port"), "M39 的新键应可写:{text}");
|
||||
|
||||
let paths: Vec<String> = port.writable_keys().into_iter().map(|k| k.path).collect();
|
||||
assert!(paths.contains(&"download.concurrency".to_string()));
|
||||
assert!(!paths.contains(&"ai.api_key".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_masks_high_sensitivity_but_keeps_low() {
|
||||
let port = CoreSetting::new(temp_config());
|
||||
let summary = port.summary();
|
||||
assert_eq!(summary["ai.base_url"], json!(MASK), "High 键要掩码");
|
||||
assert_ne!(
|
||||
summary["download.concurrency"],
|
||||
json!(MASK),
|
||||
"Low 键不必掩码"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_key_get_is_refused() {
|
||||
let port = CoreSetting::new(temp_config());
|
||||
let err = port.get("ai.api_key").unwrap_err();
|
||||
assert!(err.contains("凭据类配置"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_then_get_round_trips_a_low_key() {
|
||||
let path = temp_config();
|
||||
let port = CoreSetting::new(path.clone());
|
||||
let change = port
|
||||
.set("download.concurrency", &json!(16))
|
||||
.expect("应写入");
|
||||
assert_eq!(change.new, json!(16));
|
||||
assert_eq!(port.get("download.concurrency").unwrap(), json!(16));
|
||||
assert!(path.is_file(), "应落盘 {}", path.display());
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ull-ai-setting-{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 正确的 `$schema`(认不出的前缀会让加载降级成只读)。
|
||||
fn schema_url() -> String {
|
||||
use unlucky_core::setting::keys::launcher_version;
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
|
||||
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) {
|
||||
std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录");
|
||||
std::fs::write(path, value.to_string()).expect("写配置");
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Value {
|
||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display()));
|
||||
serde_json::from_slice(&bytes).expect("合法 JSON")
|
||||
}
|
||||
|
||||
/// standard 模式现场:便携目录那份说 standard + 自定义 launcher home(数据根留在临时目录里)。
|
||||
fn standard_fixture(temp: &TempDir) -> (PathBuf, PathBuf, PathBuf) {
|
||||
let exe = temp.path().join("exe");
|
||||
let launcher_home = temp.path().join("lhome");
|
||||
let portable = exe.join(".ULL").join("config").join("launcher.json");
|
||||
write_json(
|
||||
&portable,
|
||||
&json!({
|
||||
"$schema": schema_url(),
|
||||
"schemaVersion": 1,
|
||||
"core": {
|
||||
"data_mode": "standard",
|
||||
"launcher_home": launcher_home.display().to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
(exe, portable, launcher_home)
|
||||
}
|
||||
|
||||
/// **AI 面改 `core.data_mode` 真的生效**:写便携目录那份,下一次解析换模式。
|
||||
///
|
||||
/// 走的是 `ports::build` 装出来的同一个端口(`set_config` 工具背后就是它)。
|
||||
#[test]
|
||||
fn ai_set_of_core_data_mode_takes_effect_on_the_next_resolve() {
|
||||
let temp = TempDir::new("core-ai");
|
||||
let (exe, portable, launcher_home) = standard_fixture(&temp);
|
||||
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe.clone())).expect("解析数据根");
|
||||
assert_eq!(ctx.data.mode.as_str(), "standard");
|
||||
let ports = build(&ctx);
|
||||
|
||||
ports
|
||||
.setting
|
||||
.set("core.data_mode", &json!("portable"))
|
||||
.expect("AI 侧写 core.data_mode");
|
||||
|
||||
assert_eq!(
|
||||
read_json(&portable)["core"]["data_mode"],
|
||||
json!("portable"),
|
||||
"数据根键要落在便携目录那份"
|
||||
);
|
||||
let data_file = launcher_home.join("config").join("launcher.json");
|
||||
assert!(
|
||||
!data_file.exists() || read_json(&data_file)["core"].get("data_mode").is_none(),
|
||||
"数据根那份不该被写入 core.data_mode"
|
||||
);
|
||||
|
||||
let next = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("重新解析");
|
||||
assert_eq!(
|
||||
next.data.mode.as_str(),
|
||||
"portable",
|
||||
"改了没反应到此为止:下一次解析必须换模式"
|
||||
);
|
||||
|
||||
// 其余键仍走数据根那份,且不污染便携目录那份。
|
||||
ports
|
||||
.setting
|
||||
.set("download.concurrency", &json!(16))
|
||||
.expect("AI 侧写普通键");
|
||||
assert_eq!(read_json(&data_file)["download"]["concurrency"], json!(16));
|
||||
assert!(
|
||||
read_json(&portable)["download"]
|
||||
.get("concurrency")
|
||||
.is_none(),
|
||||
"普通键不该写进便携目录那份"
|
||||
);
|
||||
}
|
||||
|
||||
/// 概要的取值来源与解析同源:数据根键取便携那份(哪怕数据根那份留着陈旧值)。
|
||||
#[test]
|
||||
fn summary_takes_data_root_keys_from_the_portable_file() {
|
||||
let temp = TempDir::new("core-summary");
|
||||
let (exe, portable, launcher_home) = standard_fixture(&temp);
|
||||
// 老版本 AI 写错文件留下的现场:数据根那份有个陈旧的 core.data_mode。
|
||||
let data_file = launcher_home.join("config").join("launcher.json");
|
||||
write_json(
|
||||
&data_file,
|
||||
&json!({
|
||||
"$schema": schema_url(),
|
||||
"schemaVersion": 1,
|
||||
"core": { "data_mode": "custom" },
|
||||
"download": { "concurrency": 4 },
|
||||
}),
|
||||
);
|
||||
|
||||
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("解析数据根");
|
||||
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
|
||||
let data = port.store();
|
||||
let core = SettingStore::load(&portable, launcher_schema());
|
||||
|
||||
assert_eq!(
|
||||
raw_value(&data, Some(&core), "core.data_mode"),
|
||||
Some(&json!("standard")),
|
||||
"数据根键取便携那份 = 解析读到的那份"
|
||||
);
|
||||
assert_eq!(
|
||||
raw_value(&data, Some(&core), "download.concurrency"),
|
||||
Some(&json!(4)),
|
||||
"其余键仍取数据根那份"
|
||||
);
|
||||
// `core.*` 是 M12 的 High 键,概要里照旧掩码(值不可见,但来源必须对)。
|
||||
assert_eq!(port.summary()["core.data_mode"], json!(MASK));
|
||||
}
|
||||
|
||||
/// 便携模式下两份落点本就是同一份文件:不留第二份,免得重复加载。
|
||||
#[test]
|
||||
fn portable_mode_keeps_a_single_landing_file() {
|
||||
let temp = TempDir::new("core-portable");
|
||||
let ctx =
|
||||
Ctx::resolve_in(&Globals::default(), Some(temp.path().to_path_buf())).expect("解析数据根");
|
||||
assert_eq!(ctx.data.mode.as_str(), "portable");
|
||||
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
|
||||
assert_eq!(
|
||||
port.path_for("core.data_mode"),
|
||||
ctx.launcher_config().as_path()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! `ull agent serve`:把注册表挂成 MCP server,长驻运行。
|
||||
//!
|
||||
//! 装配顺序(每一步都对应 02 文档的一条约束):
|
||||
//! 1. 设置 → `McpConfig`(`agent.mcp.*`,命令行可覆盖);
|
||||
//! 2. 端口([`super::ports`])→ 注册表(`tools::build_v1_registry`);
|
||||
//! 3. 只读模式 → `read_only_subset()`(物理上不含写工具,列表/调用天然一致);
|
||||
//! 4. 执行器:`JsonlAudit`(落盘 `<data>/agent/audit`)+ fail-closed 闸门 +
|
||||
//! `ConfirmPolicy { yolo }`(读 `ai.yolo`)+ `DenyGuidance::External`(没有弹窗可点);
|
||||
//! 5. 传输:stdio 或 Streamable HTTP(127.0.0.1)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_agent::audit::{audit_dir, JsonlAudit};
|
||||
use unlucky_agent::brain::exec::ToolExecutor;
|
||||
use unlucky_agent::clock::SystemClock;
|
||||
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
|
||||
use unlucky_agent::event::NoopSink;
|
||||
use unlucky_agent::mcp::{serve_http, serve_stdio, McpConfig, RegistryServer};
|
||||
use unlucky_agent::tools::build_v1_registry;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::cli::AgentServeArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
|
||||
/// 起一次 server(阻塞直到进程被杀/stdio 对端关闭)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 注册表装配失败、端口被占用、非回环监听等返回用户错误。
|
||||
pub fn run(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
|
||||
let settings = Settings::load(ctx);
|
||||
let config = settings.mcp_config(args);
|
||||
let yolo = settings.yolo();
|
||||
let read_only = config.read_only;
|
||||
if !settings.enabled {
|
||||
eprintln!(
|
||||
"[unlucky-agent] 提示:设置键 agent.mcp.enabled=false,本次是手动启动(GUI 不会自动起)"
|
||||
);
|
||||
}
|
||||
|
||||
let ports = super::ports::build(ctx);
|
||||
let registry = Arc::new(
|
||||
build_v1_registry(&ports)
|
||||
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?,
|
||||
);
|
||||
// 路由注册表始终是完整的那份;「哪些工具真能跑」由执行器持有的注册表决定
|
||||
// (只读模式下是 read_only_subset):硬调写工具会得到可读的 tool-level 错误 + 审计,
|
||||
// 而不是 rmcp 的协议级 -32602(客户端只会显示 "internal error")。
|
||||
let exposed = if read_only {
|
||||
Arc::new(registry.read_only_subset())
|
||||
} else {
|
||||
Arc::clone(®istry)
|
||||
};
|
||||
|
||||
let audit = Arc::new(JsonlAudit::new(
|
||||
audit_dir(&ctx.layout().data()),
|
||||
Arc::new(SystemClock),
|
||||
));
|
||||
let executor = Arc::new(
|
||||
ToolExecutor::new(
|
||||
Arc::clone(&exposed),
|
||||
audit,
|
||||
Arc::new(NoUiGate),
|
||||
ConfirmPolicy { yolo },
|
||||
Arc::new(SystemClock),
|
||||
Arc::new(NoopSink),
|
||||
)
|
||||
.with_guidance(DenyGuidance::External),
|
||||
);
|
||||
|
||||
let server = RegistryServer::new(registry, executor, transport_label(args), read_only);
|
||||
if args.stdio {
|
||||
// stdio:stdout 是协议流,人类提示一律走 stderr。
|
||||
eprintln!(
|
||||
"[unlucky-agent] MCP server 走 stdio({} 个工具;写权限:{};YOLO:{})",
|
||||
server.tool_count(),
|
||||
if read_only { "关(只读)" } else { "开" },
|
||||
yolo
|
||||
);
|
||||
return block_on(async move { serve_stdio(server).await })
|
||||
.map(|()| crate::error::EXIT_OK)
|
||||
.map_err(|error| CliError::user("mcp_stdio", error.to_string()));
|
||||
}
|
||||
|
||||
let url = config.url();
|
||||
eprintln!("[unlucky-agent] MCP 端点:{url}");
|
||||
block_on(async move { serve_http(server, config).await })
|
||||
.map(|()| crate::error::EXIT_OK)
|
||||
.map_err(|error| CliError::user("mcp_http", error.to_string()))
|
||||
}
|
||||
|
||||
fn transport_label(args: &AgentServeArgs) -> &'static str {
|
||||
if args.stdio {
|
||||
"mcp-stdio"
|
||||
} else {
|
||||
"mcp-http"
|
||||
}
|
||||
}
|
||||
|
||||
/// 在独立线程里跑一个 tokio 运行时(stdio 形态要独占 stdin/stdout,不能被主线程抢)。
|
||||
fn block_on<F, T>(future: F) -> Result<T, unlucky_agent::mcp::McpServeError>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, unlucky_agent::mcp::McpServeError>> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| unlucky_agent::mcp::McpServeError::Io(error.to_string()))?;
|
||||
runtime.block_on(future)
|
||||
}
|
||||
|
||||
/// 从 `launcher.json` 读 MCP 相关设置(坏文件退化成默认值,不阻断启动)。
|
||||
struct Settings {
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
allow_write: bool,
|
||||
token: Option<String>,
|
||||
yolo: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
fn load(ctx: &Ctx) -> Self {
|
||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
||||
let doc = store.doc();
|
||||
let port = doc
|
||||
.effective("agent.mcp.port")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(47823);
|
||||
Self {
|
||||
enabled: doc
|
||||
.effective("agent.mcp.enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
port,
|
||||
allow_write: doc
|
||||
.effective("agent.mcp.allow_write")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
token: doc
|
||||
.effective_str("agent.mcp.token")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_string),
|
||||
yolo: doc
|
||||
.effective("ai.yolo")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_config(&self, args: &AgentServeArgs) -> McpConfig {
|
||||
McpConfig {
|
||||
host: args.host.clone().unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
port: args.port.unwrap_or(self.port),
|
||||
token: self.token.clone(),
|
||||
// 命令行 --allow-write 只能**收紧或放开到设置允许的程度**:
|
||||
// 设置里没开写权限时,命令行也不放开(设置是用户的意思表示,命令行是运行参数)。
|
||||
read_only: !(self.allow_write || args.allow_write),
|
||||
}
|
||||
}
|
||||
|
||||
fn yolo(&self) -> bool {
|
||||
self.yolo
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args() -> AgentServeArgs {
|
||||
AgentServeArgs::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_default_to_read_only_on_47823() {
|
||||
// Settings 需要 Ctx(要真实数据根),这里只测「无设置时的合成规则」:
|
||||
// 用 Default 的字段逐项对照,避免为此造一个假 Ctx。
|
||||
let defaults = Settings {
|
||||
enabled: false,
|
||||
port: 47823,
|
||||
allow_write: false,
|
||||
token: None,
|
||||
yolo: false,
|
||||
};
|
||||
let config = defaults.mcp_config(&args());
|
||||
assert_eq!(config.port, 47823);
|
||||
assert!(config.read_only);
|
||||
assert_eq!(config.host, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_flags_override_port_and_write_switch() {
|
||||
let settings = Settings {
|
||||
enabled: true,
|
||||
port: 47823,
|
||||
allow_write: true,
|
||||
token: Some("t".to_string()),
|
||||
yolo: true,
|
||||
};
|
||||
let overridden = settings.mcp_config(&AgentServeArgs {
|
||||
port: Some(50000),
|
||||
allow_write: false,
|
||||
..AgentServeArgs::default()
|
||||
});
|
||||
assert_eq!(overridden.port, 50000);
|
||||
assert!(!overridden.read_only, "设置里允许写时命令行不必再开");
|
||||
assert_eq!(overridden.token.as_deref(), Some("t"));
|
||||
assert!(settings.yolo());
|
||||
// `enabled` 只是 GUI 的自动启动开关:命令行显式 `agent serve` 不看它
|
||||
// (所以这里 enabled=true 既不影响 mcp_config,也不影响能不能起服务)。
|
||||
assert!(settings.enabled);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user