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

+283
View File
@@ -0,0 +1,283 @@
//! 审计日志:**每一次工具调用**都留痕(02 文档 §3.1 末条 / mission M16 任务 4)。
//!
//! 落盘位置由装配处给(`<data_root>/agent/audit/audit-<日期>.jsonl`,按天分文件,
//! 见 `PlanDoc/04-data-and-config.md` §2)。一行一条 JSON,只追加不修改。
//!
//! 三条纪律:
//! 1. **参数先脱敏再落盘**([`crate::redact::redact_args`])——`set_config` 能改 AI 端点,
//! 参数里会有 api_key;
//! 2. 写失败**不打断**工具执行(审计是旁路),但要把失败打到 stderr 留痕;
//! 3. YOLO 与「是否经确认」分别记录(`yolo=true, confirmed=false` 与
//! `yolo=false, confirmed=true` 是两件事,事故复盘的差别就在这)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::clock::{date_of_unix_ms, format_unix_ms, SharedClock};
use crate::redact::redact_args;
/// 一次工具调用的结局(审计用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
/// 成功返回。
Ok,
/// 工具返回错误(含端口失败、未找到会话等)。
ToolError,
/// 参数没过 schema 校验(模型给错了参数)。
InvalidArgs,
/// 用户/策略拒绝。
Denied,
/// 只发出了确认请求,尚未执行(编排层在把请求交给闸门之前先记一条,
/// 这样「AI 想改 X 但被拒」和「用户点了允许」在审计里是两条独立的证据)。
AwaitingConfirmation,
/// 模型调了不存在的工具。
UnknownTool,
}
/// 一条审计记录。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuditRecord {
/// RFC3339 UTC。
pub ts: String,
/// 会话 id(agent 对话框的会话,不是游戏会话)。
pub agent_session_id: String,
/// 工具名。
pub tool: String,
/// 参数(已脱敏)。
pub args: Value,
/// 结局。
pub outcome: AuditOutcome,
/// 一句话结果摘要。
pub summary: String,
/// 是否运行在 YOLO 模式。
pub yolo: bool,
/// 是否经过用户确认(YOLO 下为 false——自动放行不是"确认")。
pub confirmed: bool,
/// 工具执行耗时(毫秒)。
pub duration_ms: u64,
}
/// 审计出口。
pub trait AuditSink: Send + Sync {
/// 记录一条(实现方不应阻塞工具执行链;失败自行吞掉并留痕)。
fn record(&self, record: &AuditRecord);
}
/// 按天滚动的 JSONL 审计(生产实现)。
pub struct JsonlAudit {
dir: PathBuf,
clock: SharedClock,
guard: Mutex<()>,
}
impl JsonlAudit {
/// `dir` 通常是 `<data_root>/agent/audit`;目录按需创建。
#[must_use]
pub fn new(dir: impl Into<PathBuf>, clock: SharedClock) -> Self {
Self {
dir: dir.into(),
clock,
guard: Mutex::new(()),
}
}
/// 给定时刻对应的文件路径(测试与 UI「查看审计」共用)。
#[must_use]
pub fn path_for(&self, unix_ms: u64) -> PathBuf {
self.dir
.join(format!("audit-{}.jsonl", date_of_unix_ms(unix_ms)))
}
/// 追加一行;失败返回 `std::io::Error`(由 [`AuditSink::record`] 吞掉并打日志)。
fn append(&self, record: &AuditRecord) -> std::io::Result<()> {
let path = self.path_for(self.clock.now_unix_ms());
let line = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let _guard = self.guard.lock();
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")
}
}
impl AuditSink for JsonlAudit {
fn record(&self, record: &AuditRecord) {
if let Err(err) = self.append(record) {
// 审计是旁路:写不进去也不能让工具调用失败,但要留下痕迹。
eprintln!("[unlucky-agent] 审计写入失败: {err}");
}
}
}
/// 内存审计(测试 + 「P0 不落盘」的降级路径)。
#[derive(Debug, Default)]
pub struct MemoryAudit {
records: Mutex<Vec<AuditRecord>>,
}
impl MemoryAudit {
/// 空审计。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 已记录条数。
#[must_use]
pub fn len(&self) -> usize {
self.records.lock().map(|r| r.len()).unwrap_or(0)
}
/// 是否一条都没有。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 取快照。
#[must_use]
pub fn records(&self) -> Vec<AuditRecord> {
self.records.lock().map(|r| r.clone()).unwrap_or_default()
}
/// 按工具名筛出记录(测试断言用)。
#[must_use]
pub fn records_for(&self, tool: &str) -> Vec<AuditRecord> {
self.records()
.into_iter()
.filter(|r| r.tool == tool)
.collect()
}
}
impl AuditSink for MemoryAudit {
fn record(&self, record: &AuditRecord) {
if let Ok(mut records) = self.records.lock() {
records.push(record.clone());
}
}
}
/// 丢弃审计(不推荐,只给"连内存都不想留"的极端场景/基准测试)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NullAudit;
impl AuditSink for NullAudit {
fn record(&self, _record: &AuditRecord) {}
}
/// 组装一条记录时的"其余字段"(时间戳从时钟取,参数先脱敏)。
#[derive(Debug, Clone, Copy)]
pub struct AuditDraft<'a> {
/// 工具名。
pub tool: &'a str,
/// 原始参数(内部会脱敏)。
pub args: &'a Value,
/// 结局。
pub outcome: AuditOutcome,
/// 一句话结果摘要。
pub summary: &'a str,
/// 是否 YOLO。
pub yolo: bool,
/// 是否经用户确认。
pub confirmed: bool,
/// 耗时(毫秒)。
pub duration_ms: u64,
}
/// 组装一条审计记录(时间戳从时钟取、参数脱敏,调用方不用自己拼)。
#[must_use]
pub fn make_record(
clock: &dyn crate::clock::Clock,
agent_session_id: &str,
draft: AuditDraft<'_>,
) -> AuditRecord {
AuditRecord {
ts: format_unix_ms(clock.now_unix_ms()),
agent_session_id: agent_session_id.to_string(),
tool: draft.tool.to_string(),
args: redact_args(draft.args),
outcome: draft.outcome,
summary: draft.summary.to_string(),
yolo: draft.yolo,
confirmed: draft.confirmed,
duration_ms: draft.duration_ms,
}
}
/// 便捷:给出数据根下的审计目录(`<data_root>/agent/audit`)。
#[must_use]
pub fn audit_dir(data_root: &Path) -> PathBuf {
data_root.join("agent").join("audit")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FixedClock;
use serde_json::json;
use std::sync::Arc;
#[test]
fn record_redacts_args_before_store() {
let audit = MemoryAudit::new();
let clock = FixedClock::new(1_793_304_753_123);
let args = json!({ "path": "ai.api_key", "value": "sk-x" });
let rec = make_record(
&clock,
"s1",
AuditDraft {
tool: "set_config",
args: &args,
outcome: AuditOutcome::Ok,
summary: "ok",
yolo: false,
confirmed: true,
duration_ms: 3,
},
);
audit.record(&rec);
let stored = &audit.records()[0];
assert_eq!(stored.args["value"], json!("***"), "敏感路径的值必须掩码");
assert_eq!(stored.ts, "2026-10-29T20:12:33.123Z");
}
#[test]
fn jsonl_audit_writes_one_line_per_record_per_day() {
let dir = std::env::temp_dir().join(format!("unlucky-agent-audit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = JsonlAudit::new(&dir, Arc::clone(&clock));
let args = json!({});
let rec = make_record(
clock.as_ref(),
"s1",
AuditDraft {
tool: "get_config",
args: &args,
outcome: AuditOutcome::Ok,
summary: "ok",
yolo: false,
confirmed: false,
duration_ms: 1,
},
);
audit.record(&rec);
audit.record(&rec);
let path = dir.join("audit-2026-10-29.jsonl");
let content = std::fs::read_to_string(&path).expect("审计文件应存在");
assert_eq!(content.lines().count(), 2);
let first: AuditRecord = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(first.tool, "get_config");
let _ = std::fs::remove_dir_all(&dir);
}
}