use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::{artifact, manifest, page, ToolCallInput}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::Json; use serde_json::{json, Value}; use std::collections::HashMap; use std::env; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use tracing::{info, warn}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner"; pub async fn mnote_audit( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let trace_id = query.get("traceId").map(String::as_str); let tool_call_id = query.get("toolCallId").map(String::as_str); let persisted_only = query .get("persistedOnly") .map(|value| value == "true" || value == "1") .unwrap_or(false); let events = if persisted_only { audit_persisted_events(trace_id, tool_call_id) } else { audit_events(trace_id, tool_call_id) }; Ok(( StatusCode::OK, stamp_tool_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "auditStore": if persisted_only { "jsonl" } else { "memory" }, "events": events })), )) } pub async fn mnote_manifest( Extension(context): Extension, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; Ok(( StatusCode::OK, stamp_tool_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "manifest": manifest::manifest() })), )) } pub async fn mnote_call( State(state): State, Extension(context): Extension, Json(input): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let trace_id = input .effective_trace_id(&context.trace.trace_id) .to_string(); let tool_call_id = input.effective_tool_call_id(); let workspace_id = input.effective_workspace_id(); let document_id = input.effective_document_id(); let dry_run = input.dry_run.unwrap_or(false); let effect = if dry_run { "dry_run" } else if input.tool_name == "mnote.page.get" { "read" } else { "write" }; let idempotency_key = idempotency_cache_key( &input, workspace_id.as_deref(), document_id.as_deref(), dry_run, ); info!( trace_id = %trace_id, session_id = input.session_id.as_deref().unwrap_or(""), run_id = input.run_id.as_deref().unwrap_or(""), tool_call_id = %tool_call_id, tool_name = %input.tool_name, workspace_id = workspace_id.as_deref().unwrap_or(""), document_id = document_id.as_deref().unwrap_or(""), actor_id = input.actor_id.as_deref().unwrap_or(""), dry_run, "mnote Hermes tool call started" ); audit_push(json!({ "phase": "started", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "dryRun": dry_run })); if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) { audit_push(json!({ "phase": "failed", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "status": error.status().as_u16(), "message": error.message() })); return Err(error); } if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) { info!( trace_id = %trace_id, session_id = input.session_id.as_deref().unwrap_or(""), run_id = input.run_id.as_deref().unwrap_or(""), tool_call_id = %tool_call_id, tool_name = %input.tool_name, workspace_id = workspace_id.as_deref().unwrap_or(""), document_id = document_id.as_deref().unwrap_or(""), actor_id = input.actor_id.as_deref().unwrap_or(""), "mnote Hermes tool call idempotency replay" ); audit_push(json!({ "phase": "idempotency_replay", "traceId": trace_id, "sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null), "runId": cached.get("runId").cloned().unwrap_or(Value::Null), "toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null), "toolName": cached.get("toolName").cloned().unwrap_or(Value::Null), "audit": cached.get("audit").cloned().unwrap_or(Value::Null) })); return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached))); } let result = match input.tool_name.as_str() { "mnote.page.get" => page::page_get(&state, &context, &input).await, "mnote.page.save" => page::page_save(&state, &context, &input).await, "mnote.page.update_title" => page::update_title(&state, &context, &input).await, "mnote.page.update_options" => page::update_options(&state, &context, &input).await, "mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await, "mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await, _ => Err( WebError::bad_request_code("mnote_tool_unknown", "未知 mnote Hermes tool") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"), ), }; if let Err(error) = &result { warn!( trace_id = %trace_id, session_id = input.session_id.as_deref().unwrap_or(""), run_id = input.run_id.as_deref().unwrap_or(""), tool_call_id = %tool_call_id, tool_name = %input.tool_name, workspace_id = workspace_id.as_deref().unwrap_or(""), document_id = document_id.as_deref().unwrap_or(""), actor_id = input.actor_id.as_deref().unwrap_or(""), status = %error.status(), message = %error.message(), "mnote Hermes tool call failed" ); audit_push(json!({ "phase": "failed", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "status": error.status().as_u16(), "message": error.message() })); } let result = result?; let command_id = result.get("commandId").cloned().unwrap_or(Value::Null); info!( trace_id = %trace_id, session_id = input.session_id.as_deref().unwrap_or(""), run_id = input.run_id.as_deref().unwrap_or(""), tool_call_id = %tool_call_id, tool_name = %input.tool_name, workspace_id = workspace_id.as_deref().unwrap_or(""), document_id = document_id.as_deref().unwrap_or(""), actor_id = input.actor_id.as_deref().unwrap_or(""), effect, "mnote Hermes tool call completed" ); let response_body = json!({ "ok": true, "toolName": input.tool_name, "toolCallId": tool_call_id, "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "result": result, "audit": { "effect": effect, "commandId": command_id, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "dryRun": dry_run, "idempotencyKey": input.idempotency_key, "capabilityScope": input.capability_scope }, "error": null }); if let Some(key) = idempotency_key { idempotency_cache_put(key, response_body.clone()); } audit_push(json!({ "phase": "completed", "traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null), "sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null), "runId": response_body.get("runId").cloned().unwrap_or(Value::Null), "toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null), "toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null), "audit": response_body.get("audit").cloned().unwrap_or(Value::Null) })); Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body))) } fn audit_log() -> &'static Mutex> { static LOG: OnceLock>> = OnceLock::new(); LOG.get_or_init(|| Mutex::new(Vec::new())) } fn audit_push(event: Value) { if let Ok(mut log) = audit_log().lock() { log.push(event.clone()); let overflow = log.len().saturating_sub(500); if overflow > 0 { log.drain(0..overflow); } } if let Err(error) = audit_append_persistent(&event) { warn!(message = %error, "mnote Hermes tool audit 持久化失败"); } } fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec { let Ok(log) = audit_log().lock() else { return Vec::new(); }; log.iter() .filter(|event| { trace_id .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .filter(|event| { tool_call_id .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .cloned() .collect() } fn audit_log_path() -> PathBuf { env::var("MNOTE_HERMES_TOOL_AUDIT_LOG") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("tmp").join("mnote-hermes-tool-audit.jsonl")) } fn audit_append_persistent(event: &Value) -> Result<(), String> { let path = audit_log_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|error| error.to_string())?; let line = serde_json::to_string(event).map_err(|error| error.to_string())?; writeln!(file, "{line}").map_err(|error| error.to_string()) } fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec { let path = audit_log_path(); let Ok(file) = File::open(path) else { return Vec::new(); }; BufReader::new(file) .lines() .map_while(Result::ok) .filter_map(|line| serde_json::from_str::(&line).ok()) .filter(|event| { trace_id .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .filter(|event| { tool_call_id .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .collect() } fn idempotency_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } fn idempotency_cache_key( input: &ToolCallInput, workspace_id: Option<&str>, document_id: Option<&str>, dry_run: bool, ) -> Option { if dry_run || input.tool_name == "mnote.page.get" { return None; } let idempotency_key = input.idempotency_key.as_deref()?.trim(); if idempotency_key.is_empty() { return None; } Some(format!( "{}|{}|{}|{}", input.tool_name, workspace_id.unwrap_or(""), document_id.unwrap_or(""), idempotency_key )) } fn idempotency_cache_get(key: &str) -> Option { idempotency_cache().lock().ok()?.get(key).cloned() } fn idempotency_cache_put(key: String, response: Value) { if let Ok(mut cache) = idempotency_cache().lock() { cache.insert(key, response); } } fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { let has_actor = context.auth.actor_id.trim() != "anonymous"; if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { return Ok(()); } Err(WebError::new( StatusCode::UNAUTHORIZED, "mnote_tool_unauthorized", "mnote Hermes tool 需要登录后访问", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")) } fn ensure_workspace_context( context: &RequestContext, input_workspace_id: Option<&str>, ) -> Result<(), WebError> { let Some(input_workspace_id) = input_workspace_id .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(()); }; let Some(header_workspace_id) = context .workspace .workspace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(()); }; if input_workspace_id == header_workspace_id { return Ok(()); } Err(WebError::new( StatusCode::FORBIDDEN, "workspace_context_conflict", "mnote Hermes tool 请求的 workspaceId 与请求上下文不一致", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")) } fn stamp_tool_headers() -> HeaderMap { let mut headers = HeaderMap::new(); if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); } if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_TOOL_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web-hermes-tools")); } headers } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::{json, Value}; use tower::util::ServiceExt; fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some( r#"{ "documents:getMeta": { "id": "doc_1", "workspace_id": "ws_demo", "title": "服务端页面", "can_edit": true, "wide_layout": false, "use_small_text": false, "show_toc": true, "block_count": 1 }, "documents:getContent": { "title": "服务端页面", "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "章节一" }] } ], "revision": 7, "conflict_detection_key": "doc_1:7" } }"# .into(), ), mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } #[tokio::test] async fn hermes_tools_manifest_returns_first_batch_tools() { let response = app() .oneshot( Request::builder() .uri("/api/hermes/tools/mnote/manifest") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let tools = payload["manifest"]["tools"].as_array().expect("tools"); assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save")); assert_eq!( payload["manifest"]["schemaVersion"], "mnote.hermes_tool_manifest.v1" ); } #[tokio::test] async fn hermes_tools_page_get_requires_auth() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .body(Body::from( json!({"toolName":"mnote.page.get","documentId":"doc_1"}).to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn hermes_tools_write_tools_require_auth() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .body(Body::from( json!({ "toolName": "mnote.page.save", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_1", "dryRun": false, "args": {"content": []} }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn hermes_tools_page_get_returns_page_aggregate_summary() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.page.get", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "capabilityScope": ["page.read"] }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["toolName"], "mnote.page.get"); assert_eq!(payload["toolCallId"], "call_1"); assert_eq!(payload["result"]["title"], "服务端页面"); assert!(payload["result"]["bodySummary"] .as_str() .unwrap_or_default() .contains("章节一")); assert_eq!(payload["audit"]["effect"], "read"); } #[tokio::test] async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-workspace-id", "ws_other") .body(Body::from( json!({ "toolName": "mnote.page.get", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "capabilityScope": ["page.read"] }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("workspace_context_conflict") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(!text.contains("服务端页面")); assert!(!text.contains("章节一")); } #[tokio::test] async fn hermes_tools_write_tools_require_idempotency_and_dry_run_flag() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.page.save", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "args": {"content": []} }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_tool_idempotency_required") ); } #[tokio::test] async fn hermes_tools_page_save_dry_run_returns_diff_without_write() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.page.save", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_save_1", "dryRun": true, "args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]} }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["audit"]["effect"], "dry_run"); assert_eq!(payload["result"]["dryRun"], true); assert_eq!(payload["result"]["commandName"], "page.body.save"); } #[tokio::test] async fn hermes_tools_update_options_dry_run_filters_unwired_fields() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.page.update_options", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_options_1", "dryRun": true, "args": {"options": {"wideLayout": true, "pageFont": "serif"}} }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let options = &payload["result"]["diff"][0]["payload"]["options"]; assert_eq!(options["wideLayout"], true); assert!(options.get("pageFont").is_none()); assert_eq!(payload["result"]["ignoredOptions"][0], "pageFont"); assert_eq!( payload["result"]["warnings"][0]["code"], "page_option_not_wired" ); } #[tokio::test] async fn hermes_tools_artifact_dry_run_returns_artifact_plan() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/tools/mnote/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.artifact.create_summary", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_summary_1", "dryRun": true, "args": {"summary": "摘要内容"} }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["dryRun"], true); assert_eq!(payload["result"]["artifactType"], "summary"); } }