Files
Unlucky-Launcher/crates/cli/src/commands/agent/ports/diagnose/tests.rs
T
AskaEth 0522a41a52
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.5
2026-11-12 12:06:59 +08:00

188 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M242:AI 侧 `diagnose_crash` 的结论来源(落盘那份优先,老会话回落重跑)。
//!
//! 两条端到端用例都走**真链路**:真 `SessionWriter` 写盘 → 真工具注册(
//! `unlucky_agent::tools::diagnose::register`)→ 真 handler。判据是「命中哪条规则」,
//! 因为脱敏有损:在脱敏文本上重跑会**换一条**规则(`fabric.loader_0_12` →
//! `integrity.no_class_def_found`)。
//!
//! ## 夹具为什么把类路径登记成 secret
//!
//! M240 收窄了「`≥64` 不透明串」那条规则(路径形状的长串现在保留),所以 `SessionWriter`
//! 落盘时这条证据行今天**不会被抹**。要复现「读回脱敏文本就丢了证据」这个世界(M239 实测的
//! 老落盘形状),夹具把类路径登记成 secret:写盘前它变成 `<redacted:token>`,而结论是在
//! **原始行**上算的 —— 与用户盘上真实存在的那类会话同样失真,足以分辨两条路。
//!
//! 变异证明(改完已还原):
//! - 删掉 `diagnose()` 里的 `StoredRecord::find(&events)` 分支(等于每次都重跑)⇒
//! `ai_gets_the_stored_conclusion...` 红(`candidates[0].id` 变成 `integrity.no_class_def_found`)。
use std::path::Path;
use std::sync::Arc;
use serde_json::{json, Value};
use unlucky_agent::port::DiagnosePort;
use unlucky_agent::registry::{ToolCtx, ToolRegistry};
use unlucky_agent::tools::diagnose::DIAGNOSE_CRASH;
use unlucky_core::launch::exit_analysis::{self, ExitSignals};
use unlucky_core::launch::pump::{LogLine, Stream};
use unlucky_core::launch::redact::Redactor;
use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter};
use unlucky_core::launch::session_index;
use super::CoreDiagnose;
/// `fabric.loader_0_12`(critical / priority 80)唯一的证据行(`rules.d/fabric.yaml`)里的类路径。
const CLASS_PATH: &str = "org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy";
/// 真凭据(写盘前必须被抹掉的形状)。
const TOKEN: &str = "SECRET-TOKEN-123456";
/// 那条完整的证据行。
fn evidence_line() -> String {
format!("java.lang.NoClassDefFoundError: {CLASS_PATH}")
}
/// 一个临时数据根(`<root>` 当数据根,会话根就是 `<root>`——端口只认会话根这一层)。
fn temp_root(tag: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("ull-m242-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
root
}
/// 与生产写入同形(M212/D2):`log_path` 相对会话根,事件流落在 `<根>/game/<id>.jsonl`。
fn seed(root: &std::path::Path, stderr_line: &str) {
let sid = "20261029T124501-9f3c2a";
let log_path = session_index::log_path_for(sid);
let index = session_index::index_path(root);
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
let entry = json!({
"v": 1,
"session_id": sid,
"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": log_path.clone(),
"crash_found": true,
"diagnosis_top": null
});
std::fs::write(index, 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(
session_index::event_file(root, &log_path),
format!("{event}\n"),
)
.expect("写事件");
}
/// 一局真会话:`SessionWriter` 写盘(写前脱敏)→ `finish` 在原始行上补结论。
fn write_real_session(sessions_root: &Path) -> String {
let session_id = "20261102T091500-m242ai";
let mut writer = SessionWriter::create_with_id(
sessions_root,
session_id,
SessionMeta::new("inst-1", "1.20.1 Fabric", "1.20.1"),
// 类路径登记成 secret ⇒ 盘上那条证据行被抹掉(见模块文档:为什么要人为造这份失真)。
Redactor::new().with_secret(TOKEN).with_secret(CLASS_PATH),
)
.expect("建会话");
writer.session_start().expect("session_start");
let lines = vec![
LogLine::new(Stream::Stderr, "Failed to start Fabric loader"),
LogLine::new(Stream::Stderr, evidence_line()),
LogLine::new(Stream::Stdout, format!("auth: token={TOKEN}")),
];
for line in &lines {
writer.line(line).expect("写行");
}
let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines));
writer.exit(&analysis).expect("exit");
writer.finish(EndReason::Exit).expect("finish");
session_id.to_string()
}
/// 走真工具入口调 `diagnose_crash`:注册表 + schema + handler 与 `ull agent serve` 同一份。
fn call_diagnose_crash(sessions_root: &Path, session_id: &str) -> Value {
let mut registry = ToolRegistry::new();
unlucky_agent::tools::diagnose::register(
&mut registry,
Arc::new(CoreDiagnose::new(sessions_root)),
)
.expect("注册 diagnose_crash");
let spec = registry.get(DIAGNOSE_CRASH).expect("工具已注册").clone();
let ctx = ToolCtx {
agent_session_id: "m242".to_string(),
confirmed: false,
};
let output =
(spec.handler)(&ctx, &json!({ "session_id": session_id })).expect("diagnose_crash 应成功");
output.content
}
#[test]
fn ai_gets_the_stored_conclusion_instead_of_a_rerun_on_redacted_text() {
let root = temp_root("ai-stored");
let session_id = write_real_session(&root);
let content = call_diagnose_crash(&root, &session_id);
assert_eq!(content["session_id"], session_id.as_str());
assert_eq!(
content["candidates"][0]["id"], "fabric.loader_0_12",
"落盘的结论必须原样交给模型(重跑会降级成 integrity.no_class_def_found):{content}"
);
assert_eq!(content["needs_more_info"], false);
let confidence = content["top_confidence"].as_f64().expect("置信度是数字");
assert!(
(confidence - 0.85).abs() < 1e-3,
"落盘结论的置信度应原样透传:{content}"
);
// 隐私选择不变:给模型的文本片段仍是脱敏的(类路径与凭据都不许出现)。
let rendered = content.to_string();
assert!(
!rendered.contains("FabricMixinTransformerProxy"),
"{rendered}"
);
assert!(!rendered.contains(TOKEN), "{rendered}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn runs_the_rule_engine_over_a_session() {
let root = temp_root("diagnose");
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));
// 老会话(没有 `diagnosis` 事件)→ 回落重跑:规则集里应有完整性类结论命中这条 stderr。
assert!(!report.candidates.is_empty(), "应至少命中一条规则");
assert_eq!(report.candidates[0].id, "integrity.no_class_def_found");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn unknown_session_is_a_readable_error() {
let root = temp_root("diagnose-none");
let port = CoreDiagnose::new(root);
let err = port.diagnose("nope").unwrap_err();
assert!(err.contains("找不到会话"), "{err}");
}