Unlucky Launcher v0.2.0
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
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
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
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
首个 iced + Material 3 Expressive 版本的源码快照。 完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -0,0 +1,248 @@
|
||||
//! 对话循环集成测试:mock provider 驱动**完整链路**(provider → 工具编排 → 审计 → 事件)。
|
||||
//!
|
||||
//! 这里测"循环的骨架":多轮工具调用、步数上限、流式增量、禁用态、provider 失败。
|
||||
//! 安全边界(白名单/确认/YOLO/审计标记)在 `tools_security.rs`。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use support::{
|
||||
block_on, list_args, tool_call_reply, Harness, MockProvider, AGENT_SESSION, SESSION,
|
||||
};
|
||||
use unlucky_agent::audit::AuditOutcome;
|
||||
use unlucky_agent::brain::{BrainError, ChatMessage, ChatReply, StopReason, ToolCall};
|
||||
use unlucky_agent::event::AgentEvent;
|
||||
|
||||
#[test]
|
||||
fn single_turn_without_tools_returns_text() {
|
||||
let harness = Harness::new(vec![ChatReply::text("你好,需要我看看最近的崩溃吗?")]);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
|
||||
|
||||
assert_eq!(outcome.text, "你好,需要我看看最近的崩溃吗?");
|
||||
assert_eq!(outcome.steps, 1);
|
||||
assert_eq!(outcome.tool_calls, 0);
|
||||
assert_eq!(outcome.stopped_reason, StopReason::Completed);
|
||||
assert_eq!(history.len(), 2, "应只有 user + assistant");
|
||||
assert!(harness.audit.is_empty(), "没调工具就不该有审计记录");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_prompt_leads_every_request_and_tools_are_projected() {
|
||||
let harness = Harness::new(vec![ChatReply::text("好的")]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "看看配置"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let requests = harness.provider.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
assert!(
|
||||
matches!(&request.messages[0], ChatMessage::System { content } if content.contains("Unlucky-Launcher")),
|
||||
"system prompt 必须在首位"
|
||||
);
|
||||
assert_eq!(request.model, "mock-model");
|
||||
let names: Vec<&str> = request.tools.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"get_config",
|
||||
"set_config",
|
||||
"get_config_schema",
|
||||
"list_instances",
|
||||
"list_versions",
|
||||
"get_java_status",
|
||||
"list_accounts",
|
||||
"list_game_sessions",
|
||||
"read_game_log",
|
||||
"diagnose_crash",
|
||||
],
|
||||
"注册表的双投影载荷必须原样交给 provider"
|
||||
);
|
||||
// 双投影契约:provider 收到的 schema 与注册表定义逐字节相等。
|
||||
assert_eq!(request.tools, harness.registry.definitions());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_round_trip_feeds_result_back_and_finishes() {
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply("call_1", "list_game_sessions", list_args()),
|
||||
ChatReply::text("最近一次会话崩溃了,要不要跑诊断?"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "最近崩了吗"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.tool_calls, 1);
|
||||
assert_eq!(outcome.steps, 2);
|
||||
assert_eq!(outcome.stopped_reason, StopReason::Completed);
|
||||
|
||||
let requests = harness.provider.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let tool_message = requests[1]
|
||||
.messages
|
||||
.iter()
|
||||
.find_map(|m| match m {
|
||||
ChatMessage::Tool {
|
||||
call_id,
|
||||
name,
|
||||
content,
|
||||
} => Some((call_id.clone(), name.clone(), content.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("必须有工具结果消息");
|
||||
assert_eq!(tool_message.0, "call_1");
|
||||
assert_eq!(tool_message.1, "list_game_sessions");
|
||||
assert!(
|
||||
tool_message.2.contains(SESSION),
|
||||
"工具结果应包含会话 id:{}",
|
||||
tool_message.2
|
||||
);
|
||||
|
||||
let audits = harness.audit.records();
|
||||
assert_eq!(audits.len(), 1);
|
||||
assert_eq!(audits[0].tool, "list_game_sessions");
|
||||
assert_eq!(audits[0].outcome, AuditOutcome::Ok);
|
||||
assert!(!audits[0].yolo);
|
||||
assert!(!audits[0].confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_tool_calls_in_one_step_all_execute() {
|
||||
let reply = ChatReply::with_tool_calls(vec![
|
||||
ToolCall {
|
||||
id: "c1".to_string(),
|
||||
name: "list_game_sessions".to_string(),
|
||||
arguments: list_args(),
|
||||
},
|
||||
ToolCall {
|
||||
id: "c2".to_string(),
|
||||
name: "diagnose_crash".to_string(),
|
||||
arguments: json!({ "session_id": SESSION }),
|
||||
},
|
||||
]);
|
||||
let harness = Harness::new(vec![reply, ChatReply::text("诊断完成")]);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "查一下"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.tool_calls, 2);
|
||||
assert_eq!(harness.audit.len(), 2);
|
||||
assert_eq!(
|
||||
harness
|
||||
.audit
|
||||
.records()
|
||||
.iter()
|
||||
.map(|r| r.tool.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["list_game_sessions", "diagnose_crash"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_limit_stops_runaway_loop() {
|
||||
// 每一步都要求调工具,永远不会给最终回答。
|
||||
let script: Vec<ChatReply> = (0..16)
|
||||
.map(|i| tool_call_reply(&format!("c{i}"), "get_config", json!({})))
|
||||
.collect();
|
||||
let harness = Harness::new(script);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "死循环"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.stopped_reason, StopReason::StepLimit);
|
||||
assert_eq!(outcome.steps, unlucky_agent::brain::DEFAULT_MAX_STEPS);
|
||||
assert_eq!(outcome.tool_calls, unlucky_agent::brain::DEFAULT_MAX_STEPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_deltas_are_forwarded_as_events() {
|
||||
let harness = Harness::new(vec![ChatReply::text("日志里是 NoClassDefFoundError")]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "什么错误"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
harness.events.streamed_text(),
|
||||
"日志里是 NoClassDefFoundError"
|
||||
);
|
||||
let events = harness.events.events();
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::TurnStarted { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::AssistantText { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::TurnFinished { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_provider_fails_fast() {
|
||||
let harness = Harness::from_provider(Arc::new(MockProvider::disabled()));
|
||||
let mut history = Vec::new();
|
||||
|
||||
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
|
||||
assert_eq!(err, BrainError::Disabled);
|
||||
assert!(err.to_string().contains("规则引擎"), "要提示降级能力");
|
||||
assert!(
|
||||
harness.provider.requests().is_empty(),
|
||||
"禁用态不该发起任何请求"
|
||||
);
|
||||
assert!(history.is_empty(), "禁用态不该改动历史");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_error_surfaces_as_brain_error_and_event() {
|
||||
let harness = Harness::from_provider(Arc::new(MockProvider::failing(
|
||||
"连接 http://127.0.0.1:8080 失败",
|
||||
)));
|
||||
let mut history = Vec::new();
|
||||
|
||||
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
BrainError::Provider(unlucky_agent::brain::ProviderError::new(
|
||||
"连接 http://127.0.0.1:8080 失败"
|
||||
))
|
||||
);
|
||||
assert!(harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::Error { message } if message.contains("8080"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_script_yields_empty_completion_turn() {
|
||||
let harness = Harness::new(vec![]);
|
||||
let mut history = Vec::new();
|
||||
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
|
||||
assert_eq!(outcome.text, "");
|
||||
assert_eq!(outcome.stopped_reason, StopReason::Completed);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。
|
||||
//!
|
||||
//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层
|
||||
//! 只做直投」,M39 补上 rmcp 侧:
|
||||
//!
|
||||
//! 1. `mcp::projection::tool_attrs(®istry)` 的 `(name, description, input_schema)`
|
||||
//! 与 `registry.definitions()` **逐项相等**(JSON 层面,不是"看起来差不多");
|
||||
//! 2. `tools/list` 的顺序与注册顺序一致,数量等于注册表长度;
|
||||
//! 3. 每个 schema 根是 `object`(rig 与 rmcp 的共同前提);
|
||||
//! 4. `read_only_hint` 与 `Access` 一致(外部客户端据此决定能不能静默调用);
|
||||
//! 5. 只读子集不会漏出写工具(`agent.mcp.allow_write=false` 的默认形态)。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_agent::mcp::projection::{tool_attr, tool_attrs};
|
||||
use unlucky_agent::port::Ports;
|
||||
use unlucky_agent::registry::{Access, ToolDefinition, ToolRegistry};
|
||||
use unlucky_agent::testing::mock_ports;
|
||||
use unlucky_agent::tools::build_v1_registry;
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
let (ports, _settings) = mock_ports();
|
||||
build_v1_registry(&ports).expect("注册表应装配成功")
|
||||
}
|
||||
|
||||
fn ports() -> Ports {
|
||||
mock_ports().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_projection_equals_registry_definition_item_by_item() {
|
||||
let registry = registry();
|
||||
let definitions = registry.definitions();
|
||||
let attrs = tool_attrs(®istry);
|
||||
assert_eq!(attrs.len(), definitions.len());
|
||||
|
||||
for (attr, definition) in attrs.iter().zip(definitions.iter()) {
|
||||
assert_eq!(attr.name.as_ref(), definition.name, "工具名必须相等");
|
||||
assert_eq!(
|
||||
attr.description.as_deref(),
|
||||
Some(definition.description.as_str()),
|
||||
"{} 的 description 必须逐字相等",
|
||||
definition.name
|
||||
);
|
||||
assert_eq!(
|
||||
Value::Object((*attr.input_schema).clone()),
|
||||
definition.input_schema,
|
||||
"{} 的 JSON Schema 必须逐项相等",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_order_matches_registration_order() {
|
||||
let registry = registry();
|
||||
let projected: Vec<String> = tool_attrs(®istry)
|
||||
.iter()
|
||||
.map(|attr| attr.name.to_string())
|
||||
.collect();
|
||||
assert_eq!(projected, registry.names(), "两侧顺序必须一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_projected_schema_is_an_object_and_round_trips() {
|
||||
for attr in tool_attrs(®istry()) {
|
||||
let schema = Value::Object((*attr.input_schema).clone());
|
||||
assert_eq!(
|
||||
schema["type"],
|
||||
json!("object"),
|
||||
"{} 的 schema 根必须是 object",
|
||||
attr.name
|
||||
);
|
||||
// rmcp 会把 Tool 序列化进 tools/list 的响应体:必须能反序列化回来。
|
||||
let json = serde_json::to_string(&attr).expect("Tool 可序列化");
|
||||
let back: rmcp::model::Tool = serde_json::from_str(&json).expect("Tool 可反序列化");
|
||||
assert_eq!(back, attr);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_hint_tracks_access() {
|
||||
let registry = registry();
|
||||
for (attr, spec) in tool_attrs(®istry).iter().zip(registry.specs()) {
|
||||
let hint = attr
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|annotations| annotations.read_only_hint)
|
||||
.expect("每个工具都应有 read_only_hint");
|
||||
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_subset_projects_without_the_writing_tool() {
|
||||
let subset = registry().read_only_subset();
|
||||
assert_eq!(subset.len(), 9);
|
||||
let names: Vec<String> = tool_attrs(&subset)
|
||||
.iter()
|
||||
.map(|attr| attr.name.to_string())
|
||||
.collect();
|
||||
assert!(!names.iter().any(|name| name == "set_config"), "{names:?}");
|
||||
assert!(subset.access_of("set_config").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_attrs_are_pure_functions_of_the_definition() {
|
||||
let definition = ToolDefinition {
|
||||
name: "demo".to_string(),
|
||||
description: "演示".to_string(),
|
||||
input_schema: json!({ "type": "object", "properties": {}, "required": [] }),
|
||||
};
|
||||
let first = tool_attr(&definition, Access::ReadOnly);
|
||||
let second = tool_attr(&definition, Access::ReadOnly);
|
||||
assert_eq!(first, second);
|
||||
|
||||
// 副作用等级换一下,只有 annotations 变,name/description/schema 不变。
|
||||
let mutating = tool_attr(&definition, Access::Mutating);
|
||||
assert_eq!(first.name, mutating.name);
|
||||
assert_eq!(first.input_schema, mutating.input_schema);
|
||||
assert_ne!(first.annotations, mutating.annotations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_ports_cover_every_port_the_registry_needs() {
|
||||
// 装配完整性:五个端口都在,注册表才有这十个工具(少一个端口 = 少工具)。
|
||||
let ports = ports();
|
||||
let registry = build_v1_registry(&ports).expect("注册表应装配成功");
|
||||
assert_eq!(registry.len(), 10);
|
||||
assert!(unlucky_agent::testing::expected_v1_tool_names()
|
||||
.iter()
|
||||
.all(|name| registry.get(name).is_some()));
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! MCP over Streamable HTTP 的端到端测试:真起监听、打裸 JSON-RPC,断言工具返回与审计。
|
||||
//!
|
||||
//! 为什么不用 MCP 客户端 crate:客户端依赖会再拖一棵 reqwest 树(且 3.8 的客户端 feature
|
||||
//! 与我们的服务端 feature 不是一组)。裸 HTTP 反而更贴近「用户用 Claude Code 连上来」
|
||||
//! 的实际形状——握手、会话头、`tools/list`、`tools/call` 一个都不省。
|
||||
//!
|
||||
//! 覆盖的安全语义(02 §3.1 / §4b):
|
||||
//! - 只读模式下 `set_config` 根本不在工具表里,硬调 → tool-level 错误 + `unknown_tool` 审计;
|
||||
//! - 普通键(`download.concurrency`,Low)直接写入,审计 `outcome=ok`;
|
||||
//! - 敏感键(`java.selected_path`,High)在**没有弹窗**的外部场景被拒,文案引导去设置页/YOLO;
|
||||
//! - 打开 YOLO 后同一个写入成功,审计 `yolo=true`。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use unlucky_agent::audit::{AuditOutcome, AuditRecord, JsonlAudit};
|
||||
use unlucky_agent::brain::exec::ToolExecutor;
|
||||
use unlucky_agent::clock::{FixedClock, SharedClock};
|
||||
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
|
||||
use unlucky_agent::event::NoopSink;
|
||||
use unlucky_agent::mcp::{bind, serve_http_on, McpConfig, RegistryServer};
|
||||
use unlucky_agent::testing::mock_ports;
|
||||
use unlucky_agent::tools::build_v1_registry;
|
||||
|
||||
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ull-test","version":"0.1"}}}"#;
|
||||
|
||||
struct Harness {
|
||||
addr: SocketAddr,
|
||||
audit_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
fn audit_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ull-mcp-http-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn server(read_only: bool, yolo: bool, audit: Arc<JsonlAudit>) -> RegistryServer {
|
||||
let (ports, _settings) = mock_ports();
|
||||
let registry = Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"));
|
||||
let exposed = if read_only {
|
||||
Arc::new(registry.read_only_subset())
|
||||
} else {
|
||||
Arc::clone(®istry)
|
||||
};
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let executor = Arc::new(
|
||||
ToolExecutor::new(
|
||||
exposed,
|
||||
audit,
|
||||
Arc::new(NoUiGate),
|
||||
ConfirmPolicy { yolo },
|
||||
clock,
|
||||
Arc::new(NoopSink),
|
||||
)
|
||||
.with_guidance(DenyGuidance::External),
|
||||
);
|
||||
RegistryServer::new(registry, executor, "mcp-http", read_only)
|
||||
}
|
||||
|
||||
async fn spawn(tag: &str, read_only: bool, yolo: bool) -> Harness {
|
||||
let dir = audit_dir(tag);
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let audit = Arc::new(JsonlAudit::new(&dir, clock));
|
||||
let server = server(read_only, yolo, audit);
|
||||
|
||||
let cfg = McpConfig {
|
||||
port: 0,
|
||||
..McpConfig::default()
|
||||
};
|
||||
let (addr, listener) = bind(&cfg).await.expect("应能绑定临时端口");
|
||||
tokio::spawn(async move {
|
||||
let _ = serve_http_on(server, cfg, addr, listener).await;
|
||||
});
|
||||
Harness {
|
||||
addr,
|
||||
audit_dir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一条 HTTP POST(`Connection: close` 让读取变成 read_to_end)。
|
||||
async fn post(
|
||||
addr: SocketAddr,
|
||||
body: &str,
|
||||
session: Option<&str>,
|
||||
token: Option<&str>,
|
||||
) -> (u16, Option<String>, String) {
|
||||
let mut stream = TcpStream::connect(addr).await.expect("连接失败");
|
||||
let mut head = format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
|
||||
Accept: application/json, text/event-stream\r\nConnection: close\r\n\
|
||||
Content-Length: {}\r\n",
|
||||
body.len()
|
||||
);
|
||||
if let Some(session) = session {
|
||||
head.push_str(&format!("Mcp-Session-Id: {session}\r\n"));
|
||||
}
|
||||
if let Some(token) = token {
|
||||
head.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||||
}
|
||||
head.push_str("\r\n");
|
||||
stream
|
||||
.write_all(format!("{head}{body}").as_bytes())
|
||||
.await
|
||||
.expect("写请求失败");
|
||||
stream.flush().await.expect("刷新失败");
|
||||
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).await.expect("读响应失败");
|
||||
let text = String::from_utf8_lossy(&raw).to_string();
|
||||
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_str(), ""));
|
||||
let status = head
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|code| code.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
let session = head.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("mcp-session-id")
|
||||
.then(|| value.trim().to_string())
|
||||
});
|
||||
(status, session, body.to_string())
|
||||
}
|
||||
|
||||
/// 响应体可能是裸 JSON,也可能是 SSE(`data: {...}`);两种都解出第一条 JSON-RPC 响应。
|
||||
fn parse_rpc(body: &str) -> Value {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(body.trim()) {
|
||||
return value;
|
||||
}
|
||||
for line in body.lines() {
|
||||
let Some(data) = line.strip_prefix("data: ") else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(value) = serde_json::from_str::<Value>(data.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
panic!("响应体既不是 JSON 也不是 SSE:{body}");
|
||||
}
|
||||
|
||||
async fn initialize(addr: SocketAddr) -> Option<String> {
|
||||
let (status, session, body) = post(addr, INITIALIZE, None, None).await;
|
||||
assert_eq!(status, 200, "initialize 应成功:{body}");
|
||||
let response = parse_rpc(&body);
|
||||
assert_eq!(
|
||||
response["result"]["serverInfo"]["name"],
|
||||
json!("unlucky-launcher")
|
||||
);
|
||||
// 按 MCP 规范补一条 initialized 通知(无 id、无响应体)。
|
||||
let _ = post(
|
||||
addr,
|
||||
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
|
||||
session.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
}
|
||||
|
||||
async fn rpc(
|
||||
addr: SocketAddr,
|
||||
session: Option<&str>,
|
||||
id: u32,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Value {
|
||||
let body =
|
||||
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
|
||||
let (status, _session, text) = post(addr, &body, session, None).await;
|
||||
assert_eq!(status, 200, "{method} 应成功:{text}");
|
||||
parse_rpc(&text)
|
||||
}
|
||||
|
||||
fn audit_records(dir: &std::path::Path) -> Vec<AuditRecord> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
||||
for line in text.lines() {
|
||||
if let Ok(record) = serde_json::from_str::<AuditRecord>(line) {
|
||||
out.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn readonly_server_lists_and_serves_only_read_tools() {
|
||||
let harness = spawn("readonly", true, false).await;
|
||||
let session = initialize(harness.addr).await;
|
||||
|
||||
let list = rpc(harness.addr, session.as_deref(), 2, "tools/list", json!({})).await;
|
||||
let tools = list["result"]["tools"].as_array().expect("应有 tools 数组");
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.filter_map(|tool| tool["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(names.len(), 9, "{names:?}");
|
||||
assert!(!names.contains(&"set_config"), "只读模式不得暴露写工具");
|
||||
assert!(names.contains(&"get_config"));
|
||||
|
||||
// 真调一个只读工具:返回真实配置概要(mock 端口)。
|
||||
let call = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
3,
|
||||
"tools/call",
|
||||
json!({ "name": "get_config", "arguments": {} }),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(call["result"]["isError"], json!(true), "{call}");
|
||||
let text = call["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(text.contains("download.concurrency"), "{text}");
|
||||
|
||||
// 硬调写工具:tool-level 错误(客户端能看见文案)+ unknown_tool 审计。
|
||||
let denied = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
4,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
|
||||
|
||||
let records = audit_records(&harness.audit_dir);
|
||||
assert!(records.iter().any(|r| r.tool == "get_config"
|
||||
&& r.outcome == AuditOutcome::Ok
|
||||
&& r.agent_session_id == "mcp-http"));
|
||||
assert!(records
|
||||
.iter()
|
||||
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::UnknownTool));
|
||||
let _ = std::fs::remove_dir_all(&harness.audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn write_server_enforces_sensitive_key_confirmation() {
|
||||
let harness = spawn("write", false, false).await;
|
||||
let session = initialize(harness.addr).await;
|
||||
|
||||
// 普通键:直接写。
|
||||
let ok = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
2,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(ok["result"]["isError"], json!(true), "{ok}");
|
||||
|
||||
// 敏感键:外部客户端没有弹窗 → 拒绝,文案要能指导用户下一步。
|
||||
let denied = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
3,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
|
||||
let text = denied["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
text.contains("YOLO"),
|
||||
"拒绝文案应引导到 YOLO/设置页:{text}"
|
||||
);
|
||||
assert!(text.contains("设置页"), "{text}");
|
||||
|
||||
let records = audit_records(&harness.audit_dir);
|
||||
assert!(records
|
||||
.iter()
|
||||
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::AwaitingConfirmation));
|
||||
assert!(records
|
||||
.iter()
|
||||
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Denied && !r.yolo));
|
||||
let _ = std::fs::remove_dir_all(&harness.audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn yolo_lets_the_same_write_through_and_audits_the_flag() {
|
||||
let harness = spawn("yolo", false, true).await;
|
||||
let session = initialize(harness.addr).await;
|
||||
|
||||
let written = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
2,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(written["result"]["isError"], json!(true), "{written}");
|
||||
|
||||
let records = audit_records(&harness.audit_dir);
|
||||
let yolo_ok = records
|
||||
.iter()
|
||||
.find(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Ok);
|
||||
let record = yolo_ok.expect("应有一条 ok 审计");
|
||||
assert!(record.yolo, "YOLO 放行必须标记 yolo=true");
|
||||
assert!(!record.confirmed, "YOLO 自动放行不算用户确认");
|
||||
let _ = std::fs::remove_dir_all(&harness.audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bearer_token_is_enforced_when_configured() {
|
||||
let dir = audit_dir("token");
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let audit = Arc::new(JsonlAudit::new(&dir, clock));
|
||||
let server = server(true, false, audit);
|
||||
let cfg = McpConfig {
|
||||
port: 0,
|
||||
token: Some("s3cret".to_string()),
|
||||
..McpConfig::default()
|
||||
};
|
||||
let (addr, listener) = bind(&cfg).await.expect("应能绑定");
|
||||
tokio::spawn(async move {
|
||||
let _ = serve_http_on(server, cfg, addr, listener).await;
|
||||
});
|
||||
|
||||
let (status, _, _) = post(addr, INITIALIZE, None, None).await;
|
||||
assert_eq!(status, 401, "没带令牌必须 401");
|
||||
|
||||
let (status, _, body) = post(addr, INITIALIZE, None, Some("s3cret")).await;
|
||||
assert_eq!(status, 200, "带对令牌应通过:{body}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! **双投影契约测试**(research/02 §6.2 建议进 CI 的那一条)。
|
||||
//!
|
||||
//! rig 侧(`DynamicTool::new`)与 rmcp 侧(`ToolRoute::new_dyn`)吃的都是
|
||||
//! `(name, description, serde_json::Value schema)`。本 crate 只维护一份 `ToolSpec`,
|
||||
//! 所以「两侧相等」在这里等价于三条可断言的性质:
|
||||
//! 1. [`unlucky_agent::registry::ToolRegistry::definitions`] 是纯投影(多次调用逐字节相等);
|
||||
//! 2. 每个 schema 根是 `type: object`(两侧都要求对象入参)且拒绝多余参数;
|
||||
//! 3. 名字唯一,`required` 真的被执行(缺参能被校验器抓到)。
|
||||
//!
|
||||
//! 本文件只锁**投影载荷本身**;两侧的一致性各有专测:
|
||||
//! - rig 侧:`tests/brain_loop.rs` 断言 `provider` 收到的 `request.tools` 与
|
||||
//! `registry.definitions()` 逐字节相等(`brain/rig.rs` 只做直投);
|
||||
//! - MCP 侧:`tests/mcp_contract.rs` 断言 rmcp `Tool` 的 `(name, description, schema)` 与
|
||||
//! 注册表逐项相等(M39 补的那条)。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_agent::registry::validate::validate;
|
||||
use unlucky_agent::registry::{Access, ToolDefinition};
|
||||
use unlucky_agent::testing::mock_ports;
|
||||
|
||||
fn registry() -> unlucky_agent::registry::ToolRegistry {
|
||||
let (ports, _settings) = mock_ports();
|
||||
unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ships_exactly_the_v1_tools() {
|
||||
let registry = registry();
|
||||
assert_eq!(
|
||||
registry.names(),
|
||||
vec![
|
||||
"get_config",
|
||||
"set_config",
|
||||
"get_config_schema",
|
||||
"list_instances",
|
||||
"list_versions",
|
||||
"get_java_status",
|
||||
"list_accounts",
|
||||
"list_game_sessions",
|
||||
"read_game_log",
|
||||
"diagnose_crash",
|
||||
]
|
||||
);
|
||||
assert_eq!(registry.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definitions_are_pure_projections() {
|
||||
let registry = registry();
|
||||
let first = registry.definitions();
|
||||
let second = registry.definitions();
|
||||
assert_eq!(first, second, "投影必须是纯函数");
|
||||
|
||||
for definition in &first {
|
||||
// 序列化后再反序列化必须完全一样(rig 与 rmcp 都走 JSON)。
|
||||
let json = serde_json::to_string(definition).expect("可序列化");
|
||||
let round_trip: ToolDefinition = serde_json::from_str(&json).expect("可反序列化");
|
||||
assert_eq!(&round_trip, definition);
|
||||
assert!(!definition.description.trim().is_empty());
|
||||
assert_eq!(
|
||||
definition.input_schema["type"],
|
||||
json!("object"),
|
||||
"{} 的 schema 根必须是 object",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_set_config_is_mutating() {
|
||||
let registry = registry();
|
||||
for name in registry.names() {
|
||||
let expected = if name == "set_config" {
|
||||
Access::Mutating
|
||||
} else {
|
||||
Access::ReadOnly
|
||||
};
|
||||
assert_eq!(registry.access_of(name), Some(expected), "{name}");
|
||||
}
|
||||
let read_only: Vec<String> = registry
|
||||
.read_only_definitions()
|
||||
.into_iter()
|
||||
.map(|d| d.name)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
read_only,
|
||||
vec![
|
||||
"get_config",
|
||||
"get_config_schema",
|
||||
"list_instances",
|
||||
"list_versions",
|
||||
"get_java_status",
|
||||
"list_accounts",
|
||||
"list_game_sessions",
|
||||
"read_game_log",
|
||||
"diagnose_crash",
|
||||
],
|
||||
"只读 MCP 模式只暴露这九个(写侧只有 set_config)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_rejects_unknown_arguments() {
|
||||
for definition in registry().definitions() {
|
||||
let err = validate(&definition.input_schema, &json!({ "__unexpected": 1 }))
|
||||
.expect_err(&format!("{} 应拒绝野参数", definition.name));
|
||||
assert!(
|
||||
err.iter().any(|e| e.contains("不是可接受的参数名")),
|
||||
"{}: {err:?}",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fields_are_actually_enforced() {
|
||||
for definition in registry().definitions() {
|
||||
let required = definition
|
||||
.input_schema
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if required.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let err = validate(&definition.input_schema, &json!({}))
|
||||
.expect_err(&format!("{} 应有必填参数", definition.name));
|
||||
for field in required {
|
||||
let name = field.as_str().unwrap_or_default();
|
||||
assert!(
|
||||
err.iter().any(|e| e.contains(name)),
|
||||
"{} 的必填 `{name}` 没被校验:{err:?}",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_bounds_are_declared_and_enforced() {
|
||||
let registry = registry();
|
||||
let read_log = registry.get("read_game_log").expect("工具存在");
|
||||
let max_events = &read_log.input_schema["properties"]["max_events"];
|
||||
assert_eq!(max_events["minimum"], json!(1));
|
||||
assert_eq!(max_events["maximum"], json!(500));
|
||||
|
||||
assert!(validate(
|
||||
&read_log.input_schema,
|
||||
&json!({ "session_id": "s", "max_events": 0 })
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate(
|
||||
&read_log.input_schema,
|
||||
&json!({ "session_id": "s", "max_events": 501 })
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate(
|
||||
&read_log.input_schema,
|
||||
&json!({ "session_id": "s", "max_events": 500 })
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! 集成测试的公共脚手架:**mock provider** + 迷你 `block_on` + 装配好的 harness。
|
||||
//!
|
||||
//! `block_on` 是手写的 20 行:mock provider 的 future 永远立刻 Ready,所以不需要运行时。
|
||||
//! 这样 agent 的循环测试**零 tokio 依赖**,阶段 B 换成 rig 后才需要真运行时。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_agent::audit::{AuditSink, MemoryAudit};
|
||||
use unlucky_agent::brain::{Brain, BrainConfig, BrainDeps};
|
||||
use unlucky_agent::brain::{
|
||||
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderConfig, ProviderError,
|
||||
ProviderFuture, ToolCall,
|
||||
};
|
||||
use unlucky_agent::clock::{FixedClock, SharedClock};
|
||||
use unlucky_agent::confirm::{AutoApproveGate, ConfirmGate, ConfirmPolicy, NoUiGate};
|
||||
use unlucky_agent::event::{AgentEventSink, MemoryEventSink};
|
||||
use unlucky_agent::registry::ToolRegistry;
|
||||
use unlucky_agent::testing::{mock_ports, MockSettings};
|
||||
|
||||
/// 立刻完成的 future 直接 poll;挂起就是测试写错了。
|
||||
pub fn block_on<F: Future>(future: F) -> F::Output {
|
||||
let mut future: Pin<Box<F>> = Box::pin(future);
|
||||
let waker = Waker::noop();
|
||||
let mut context = Context::from_waker(waker);
|
||||
match future.as_mut().poll(&mut context) {
|
||||
Poll::Ready(value) => value,
|
||||
Poll::Pending => panic!("mock provider 不应挂起:测试里没有执行器"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本化 provider:按顺序回放 [`ChatReply`],并记录收到的请求。
|
||||
pub struct MockProvider {
|
||||
config: ProviderConfig,
|
||||
script: Mutex<VecDeque<ChatReply>>,
|
||||
requests: Mutex<Vec<ChatRequest>>,
|
||||
fail_with: Option<String>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
/// 用默认模型名(本地 llama.cpp 形态)回放脚本。
|
||||
#[must_use]
|
||||
pub fn new(script: Vec<ChatReply>) -> Self {
|
||||
Self {
|
||||
config: ProviderConfig::llamacpp_local("mock-model"),
|
||||
script: Mutex::new(script.into()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
fail_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 禁用态(测 `BrainError::Disabled`)。
|
||||
#[must_use]
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
config: ProviderConfig::disabled(),
|
||||
script: Mutex::new(VecDeque::new()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
fail_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 每次补全都失败(测 provider 错误透传)。
|
||||
#[must_use]
|
||||
pub fn failing(message: &str) -> Self {
|
||||
Self {
|
||||
fail_with: Some(message.to_string()),
|
||||
..Self::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 收到的请求(断言 system prompt / 工具定义 / 回灌的工具消息)。
|
||||
#[must_use]
|
||||
pub fn requests(&self) -> Vec<ChatRequest> {
|
||||
self.requests.lock().map(|r| r.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatProvider for MockProvider {
|
||||
fn config(&self) -> &ProviderConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
fn complete<'a>(
|
||||
&'a self,
|
||||
request: &'a ChatRequest,
|
||||
observer: &'a dyn DeltaObserver,
|
||||
) -> ProviderFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if let Ok(mut requests) = self.requests.lock() {
|
||||
requests.push(request.clone());
|
||||
}
|
||||
if let Some(message) = &self.fail_with {
|
||||
return Err(ProviderError::new(message.clone()));
|
||||
}
|
||||
let reply = self
|
||||
.script
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut script| script.pop_front())
|
||||
.unwrap_or_default();
|
||||
// 按 4 字符切块吐增量,模拟流式。
|
||||
if !reply.text.is_empty() {
|
||||
for chunk in reply.text.chars().collect::<Vec<_>>().chunks(4) {
|
||||
observer.on_delta(&chunk.iter().collect::<String>());
|
||||
}
|
||||
}
|
||||
Ok(reply)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 装配好的测试环境。
|
||||
pub struct Harness {
|
||||
/// 对话循环。
|
||||
pub brain: Brain,
|
||||
/// 审计出口(内存)。
|
||||
pub audit: Arc<MemoryAudit>,
|
||||
/// 事件出口(内存)。
|
||||
pub events: Arc<MemoryEventSink>,
|
||||
/// mock provider(查请求)。
|
||||
pub provider: Arc<MockProvider>,
|
||||
/// mock 配置端口(查是否真的写入)。
|
||||
pub settings: Arc<MockSettings>,
|
||||
/// 工具注册表。
|
||||
pub registry: Arc<ToolRegistry>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// P0 默认形态:无确认 UI(`NoUiGate`,敏感操作 fail-closed)+ 非 YOLO。
|
||||
/// 确认请求本身仍会作为 [`unlucky_agent::event::AgentEvent::ConfirmRequested`] 发出。
|
||||
#[must_use]
|
||||
pub fn new(script: Vec<ChatReply>) -> Self {
|
||||
Self::with_gate(script, ConfirmPolicy::default(), |_sink| Arc::new(NoUiGate))
|
||||
}
|
||||
|
||||
/// YOLO:自动放行全部(除破坏性)。
|
||||
#[must_use]
|
||||
pub fn yolo(script: Vec<ChatReply>) -> Self {
|
||||
Self::with_gate(script, ConfirmPolicy { yolo: true }, |_sink| {
|
||||
Arc::new(AutoApproveGate)
|
||||
})
|
||||
}
|
||||
|
||||
/// 用指定的(可能失效/禁用的)provider 装配,其余同 [`Harness::new`]。
|
||||
#[must_use]
|
||||
pub fn from_provider(provider: Arc<MockProvider>) -> Self {
|
||||
Self::build(provider, ConfirmPolicy::default(), |_sink| {
|
||||
Arc::new(NoUiGate)
|
||||
})
|
||||
}
|
||||
|
||||
/// 自定义闸门 + 策略。
|
||||
#[must_use]
|
||||
pub fn with_gate(
|
||||
script: Vec<ChatReply>,
|
||||
policy: ConfirmPolicy,
|
||||
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
|
||||
) -> Self {
|
||||
Self::build(Arc::new(MockProvider::new(script)), policy, make_gate)
|
||||
}
|
||||
|
||||
fn build(
|
||||
provider: Arc<MockProvider>,
|
||||
policy: ConfirmPolicy,
|
||||
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
|
||||
) -> Self {
|
||||
let (ports, settings) = mock_ports();
|
||||
let registry =
|
||||
Arc::new(unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功"));
|
||||
let audit = Arc::new(MemoryAudit::new());
|
||||
let events = Arc::new(MemoryEventSink::new());
|
||||
let clock: SharedClock = Arc::new(FixedClock::with_step(1_793_314_353_123, 3));
|
||||
let gate = make_gate(events.clone());
|
||||
let deps = BrainDeps::new(
|
||||
audit.clone() as Arc<dyn AuditSink>,
|
||||
gate,
|
||||
policy,
|
||||
clock,
|
||||
events.clone() as Arc<dyn AgentEventSink>,
|
||||
);
|
||||
let brain = Brain::new(
|
||||
provider.clone(),
|
||||
Arc::clone(®istry),
|
||||
deps,
|
||||
BrainConfig::default(),
|
||||
);
|
||||
Self {
|
||||
brain,
|
||||
audit,
|
||||
events,
|
||||
provider,
|
||||
settings,
|
||||
registry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一条工具调用回复。
|
||||
#[must_use]
|
||||
pub fn tool_call_reply(id: &str, name: &str, arguments: Value) -> ChatReply {
|
||||
ChatReply::with_tool_calls(vec![ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments,
|
||||
}])
|
||||
}
|
||||
|
||||
/// 便捷:游戏会话 id(与 `MockSessions::crashing_session` 一致)。
|
||||
pub const SESSION: &str = "20261029T124501-9f3c2a";
|
||||
|
||||
/// 便捷:agent 对话会话 id。
|
||||
pub const AGENT_SESSION: &str = "chat-1";
|
||||
|
||||
/// 便捷:`list_game_sessions` 的最小参数。
|
||||
#[must_use]
|
||||
pub fn list_args() -> Value {
|
||||
json!({ "limit": 5 })
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。
|
||||
//!
|
||||
//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8
|
||||
//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本;
|
||||
//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use support::{block_on, tool_call_reply, Harness, AGENT_SESSION};
|
||||
use unlucky_agent::audit::AuditOutcome;
|
||||
use unlucky_agent::brain::ChatReply;
|
||||
use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate};
|
||||
use unlucky_agent::event::AgentEvent;
|
||||
|
||||
fn set_config_reply(path: &str, value: Value) -> ChatReply {
|
||||
tool_call_reply("c1", "set_config", json!({ "path": path, "value": value }))
|
||||
}
|
||||
|
||||
fn tool_results(harness: &Harness) -> Vec<String> {
|
||||
harness
|
||||
.provider
|
||||
.requests()
|
||||
.last()
|
||||
.map(|request| {
|
||||
request
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
unlucky_agent::brain::ChatMessage::Tool { content, .. } => {
|
||||
Some(content.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn confirm_events(harness: &Harness) -> usize {
|
||||
harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_key_writes_silently_without_confirmation() {
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("appearance.theme_mode", json!("metro_light")),
|
||||
ChatReply::text("已切换主题"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换成浅色"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1);
|
||||
assert_eq!(
|
||||
harness.settings.value_of("appearance.theme_mode"),
|
||||
Some(json!("metro_light"))
|
||||
);
|
||||
assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户");
|
||||
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].outcome, AuditOutcome::Ok);
|
||||
assert!(!records[0].confirmed, "静默生效不是「用户确认」");
|
||||
assert!(!records[0].yolo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_key_also_writes_silently() {
|
||||
// M12:Medium = 静默生效但审计标记(不弹窗)
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("download.region", json!("cn")),
|
||||
ChatReply::text("已切到国内源"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换下载源"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1);
|
||||
assert_eq!(confirm_events(&harness), 0);
|
||||
assert_eq!(
|
||||
harness.audit.records_for("set_config")[0].outcome,
|
||||
AuditOutcome::Ok
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_key_is_fail_closed_without_ui() {
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
|
||||
ChatReply::text("需要你确认"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
harness.settings.set_calls(),
|
||||
0,
|
||||
"P0 无 UI 时必须 fail-closed"
|
||||
);
|
||||
assert_eq!(harness.settings.value_of("java.selected_path"), None);
|
||||
assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出");
|
||||
|
||||
let outcomes: Vec<AuditOutcome> = harness
|
||||
.audit
|
||||
.records_for("set_config")
|
||||
.iter()
|
||||
.map(|r| r.outcome)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied],
|
||||
"先记「AI 想改」,再记「被拒绝」"
|
||||
);
|
||||
assert!(tool_results(&harness)
|
||||
.iter()
|
||||
.any(|content| content.contains("denied")));
|
||||
assert!(harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::ToolDenied { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_approval_writes_and_marks_confirmed() {
|
||||
let harness = Harness::with_gate(
|
||||
vec![
|
||||
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
|
||||
ChatReply::text("已更新"),
|
||||
],
|
||||
ConfirmPolicy::default(),
|
||||
|_sink| {
|
||||
Arc::new(ScriptedGate::new(
|
||||
[ConfirmDecision::Approved],
|
||||
ConfirmDecision::Denied,
|
||||
))
|
||||
},
|
||||
);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次");
|
||||
let records = harness.audit.records_for("set_config");
|
||||
let last = records.last().expect("必须有审计记录");
|
||||
assert_eq!(last.outcome, AuditOutcome::Ok);
|
||||
assert!(last.confirmed, "用户点了允许");
|
||||
assert!(!last.yolo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yolo_skips_confirm_but_audits_flag() {
|
||||
let harness = Harness::yolo(vec![
|
||||
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
|
||||
ChatReply::text("已更新"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1);
|
||||
assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户");
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records.len(), 1, "YOLO 只留结局记录");
|
||||
assert!(records[0].yolo, "审计必须标记 yolo");
|
||||
assert!(!records[0].confirmed, "自动放行不是用户确认");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitelist_violation_is_rejected_and_audited() {
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("java.home.mystery", json!("x")),
|
||||
ChatReply::text("换个方案"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "改个不存在的键"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 0);
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
|
||||
let results = tool_results(&harness);
|
||||
assert!(
|
||||
results.iter().any(|c| c.contains("白名单")),
|
||||
"模型需要知道为什么被拒:{results:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_args_are_rejected_before_any_side_effect() {
|
||||
// 缺 value(schema required)
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply(
|
||||
"c1",
|
||||
"set_config",
|
||||
json!({ "path": "appearance.theme_mode" }),
|
||||
),
|
||||
ChatReply::text("参数补上"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "改主题"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 0);
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
|
||||
assert!(tool_results(&harness)
|
||||
.iter()
|
||||
.any(|c| c.contains("缺少必填参数")));
|
||||
|
||||
// 多余参数(additionalProperties: false)
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply(
|
||||
"c2",
|
||||
"read_game_log",
|
||||
json!({ "session_id": "x", "path": "evil" }),
|
||||
),
|
||||
ChatReply::text("好"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "读日志"),
|
||||
)
|
||||
.unwrap();
|
||||
let records = harness.audit.records_for("read_game_log");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tool_lists_available_names() {
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply("c1", "delete_everything", json!({})),
|
||||
ChatReply::text("抱歉"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "删库跑路"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let records = harness.audit.records_for("delete_everything");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::UnknownTool);
|
||||
let results = tool_results(&harness);
|
||||
assert!(
|
||||
results.iter().any(|c| c.contains("get_config")),
|
||||
"{results:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_credential_key_is_refused_even_under_yolo() {
|
||||
// M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围
|
||||
let harness = Harness::yolo(vec![
|
||||
set_config_reply("ai.api_key", json!("sk-super-secret-value")),
|
||||
ChatReply::text("换不了"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 key"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 0);
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
|
||||
|
||||
let args = serde_json::to_string(&records[0].args).unwrap();
|
||||
assert!(
|
||||
!args.contains("sk-super-secret-value"),
|
||||
"审计绝不能落明文密钥:{args}"
|
||||
);
|
||||
assert!(args.contains("***"), "{args}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_config_refuses_credential_keys() {
|
||||
// F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })),
|
||||
ChatReply::text("读不到"),
|
||||
]);
|
||||
// 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读)
|
||||
harness
|
||||
.settings
|
||||
.seed_value("ai.api_key", json!("sk-live-credential-value"));
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "看看我的 key"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1) 工具报错并被审计
|
||||
let records = harness.audit.records_for("get_config");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
|
||||
// 2) 模型拿到的是拒绝文案,不是值
|
||||
let results = tool_results(&harness);
|
||||
assert!(
|
||||
results.iter().any(|c| c.contains("凭据")),
|
||||
"应给出可读拒绝:{results:?}"
|
||||
);
|
||||
assert!(
|
||||
!results
|
||||
.iter()
|
||||
.any(|c| c.contains("sk-live-credential-value")),
|
||||
"工具结果绝不能带凭据:{results:?}"
|
||||
);
|
||||
// 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化)
|
||||
let leaked_in_events = harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| format!("{e:?}").contains("sk-live-credential-value"));
|
||||
assert!(!leaked_in_events, "事件流不能带凭据明文");
|
||||
let leaked_in_history = history
|
||||
.iter()
|
||||
.any(|m| format!("{m:?}").contains("sk-live-credential-value"));
|
||||
assert!(!leaked_in_history, "对话历史不能带凭据明文");
|
||||
}
|
||||
Reference in new issue
Block a user