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

+135
View File
@@ -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()
}
+204
View 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()
);
}
+218
View File
@@ -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(&registry)
};
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);
}
}