use super::hermes_client; use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, 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"; fn record_local_agent_write_rejection( context: &RequestContext, input: &ToolCallInput, profile: &str, code: &str, message: &str, ) { let Some(run_id) = input .run_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let payload = json!({ "toolName": input.tool_name.clone(), "toolCallId": input.effective_tool_call_id(), "sessionId": input.session_id.clone(), "workspaceId": input.effective_workspace_id(), "documentId": input.effective_document_id(), "rootUri": input.effective_root_uri(), "actorId": input.actor_id.clone(), "actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")), "permissionLevel": "read_only", "rejection": { "code": code, "message": message, "source": "local_ai_scope", "toolName": input.tool_name.clone(), }, }); if let Err(error) = hermes_client::local_agent_audit_record_write_rejected(context, payload, run_id, profile) { warn!( error = ?error, run_id = %run_id, tool_name = %input.tool_name, "本地 agent 拒绝审计写入失败" ); } } fn record_local_agent_tool_write( context: &RequestContext, input: &ToolCallInput, profile: &str, result: &Value, ) { if input.effective_source_kind().as_deref() != Some("local_folder") { return; } let Some(run_id) = input .run_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let document_id = input.effective_document_id().unwrap_or_default(); let command_name = result .get("commandName") .or_else(|| result.pointer("/applyResult/commandName")) .and_then(Value::as_str) .unwrap_or(input.tool_name.as_str()); let file_version = result .get("fileVersion") .or_else(|| result.pointer("/result/fileVersion")) .or_else(|| result.pointer("/applyResult/result/fileVersion")) .cloned() .unwrap_or(Value::Null); let changed_files = json!([{ "path": document_id, "changeType": "modified", "summary": format!("MNote tool 写入:{command_name}"), "fileVersion": file_version }]); let payload = json!({ "toolName": input.tool_name.clone(), "toolCallId": input.effective_tool_call_id(), "sessionId": input.session_id.clone(), "workspaceId": input.effective_workspace_id(), "documentId": input.effective_document_id(), "rootUri": input.effective_root_uri(), "actorId": input.actor_id.clone(), "actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")), "permissionLevel": "read_write", "commandName": command_name, "changedFiles": changed_files, }); if let Err(error) = hermes_client::local_agent_audit_record_tool_write(context, payload, run_id, profile) { warn!( error = ?error, run_id = %run_id, tool_name = %input.tool_name, "本地 agent tool 写入审计失败" ); } } 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> { let response_body = execute_mnote_tool_call(&state, &context, input).await?; Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body))) } pub(crate) async fn execute_mnote_tool_call( state: &AppState, context: &RequestContext, input: ToolCallInput, ) -> Result { let context = authenticated_tool_context(context, &input)?; 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 is_read_tool(&input.tool_name) { "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" ); let profile = input .profile .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| input.arg_string("profile")) .or_else(hermes_client::active_profile_name) .unwrap_or_else(|| "default".into()); 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 hermes_client::is_mnote_tool_disabled(&profile, &input.tool_name) { let error = WebError::new( StatusCode::FORBIDDEN, "mnote_tool_disabled", "当前 Hermes profile 已关闭该 mnote tool", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"); 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, "profile": profile, "status": error.status().as_u16(), "message": error.message() })); return Err(error); } 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 !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) { let error = WebError::new( StatusCode::FORBIDDEN, "mnote_tool_shared_read_write_forbidden", "共享只读 AI 上下文不能执行写工具", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"); 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(), "permissionLevel": "shared_read" })); record_local_agent_write_rejection( &context, &input, &profile, error.code(), error.message(), ); return Err(error); } if let Err(error) = ensure_tool_capability_scope(&context, &input) { 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(), "capabilityScope": input.capability_scope })); 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(cached); } let result = match input.tool_name.as_str() { "mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await, "mnote.doc.find" => doc::doc_find(&state, &context, &input).await, "mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await, "mnote.block.fetch" => block::block_fetch(&state, &context, &input).await, "mnote.block.replace" => block::block_replace(&state, &context, &input).await, "mnote.block.insert_after" => block::block_insert_after(&state, &context, &input).await, "mnote.block.delete" => block::block_delete(&state, &context, &input).await, "mnote.block.move_after" => block::block_move_after(&state, &context, &input).await, "mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await, "mnote.doc.markdown_edit" => doc::doc_markdown_edit(&state, &context, &input).await, "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.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await, "mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await, "mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await, "mnote.office.propose_changes" => resource::office_propose_changes(&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" ); if matches!( error.code(), "mnote_tool_ai_scope_write_forbidden" | "mnote_tool_shared_read_write_forbidden" ) { record_local_agent_write_rejection( &context, &input, &profile, error.code(), error.message(), ); } 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?; if !dry_run && !is_read_tool(&input.tool_name) { record_local_agent_tool_write(&context, &input, &profile, &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(response_body) } fn ensure_tool_capability_scope( context: &RequestContext, input: &ToolCallInput, ) -> Result<(), WebError> { let required = required_capability_scope(&input.tool_name); if required.is_empty() || declared_capability_scope_covers(input.capability_scope.as_ref(), &required) { return Ok(()); } Err(WebError::new( StatusCode::FORBIDDEN, "mnote_tool_capability_scope_forbidden", "调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")) } fn required_capability_scope(tool_name: &str) -> Vec { manifest::manifest() .get("tools") .and_then(Value::as_array) .into_iter() .flatten() .find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name)) .and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array)) .into_iter() .flatten() .filter_map(Value::as_str) .map(normalize_capability_scope) .filter(|value| !value.is_empty()) .collect() } fn declared_capability_scope_covers(declared: Option<&Vec>, required: &[String]) -> bool { let Some(declared) = declared else { // 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。 return true; }; let declared = declared .iter() .map(|value| normalize_capability_scope(value)) .filter(|value| !value.is_empty()) .collect::>(); required.iter().all(|scope| { declared .iter() .any(|candidate| capability_scope_satisfies(candidate, scope)) }) } fn capability_scope_satisfies(candidate: &str, required: &str) -> bool { if candidate == required { return true; } required .strip_suffix(".read") .map(|prefix| format!("{prefix}.write")) .as_deref() == Some(candidate) } fn normalize_capability_scope(value: &str) -> String { value.trim().to_ascii_lowercase() } fn is_read_tool(tool_name: &str) -> bool { matches!( tool_name, "mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch" | "mnote.mindmap.fetch" | "mnote.office.fetch_summary" | "mnote.office.propose_changes" ) } fn is_shared_read_scope(input: &ToolCallInput) -> bool { let direct = input .arg_string("permissionLevel") .or_else(|| input.arg_string("permission_level")); if matches!(direct.as_deref(), Some("shared_read")) { return true; } input .arg_value("aiAccessScope") .and_then(|value| { value .get("permissionLevel") .or_else(|| value.get("permission_level")) .and_then(Value::as_str) .map(str::trim) .map(ToOwned::to_owned) }) .as_deref() == Some("shared_read") } 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 || is_read_tool(&input.tool_name) { 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 actor = context.auth.actor_id.trim(); let has_actor = actor != "anonymous" && actor != "hermes" && !actor.is_empty(); 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 authenticated_tool_context( context: &RequestContext, input: &ToolCallInput, ) -> Result { let context_actor = context.auth.actor_id.trim(); if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" { return Ok(context.clone()); } let has_cookie_or_auth = context.auth.authorization.is_some() || context.auth.cookie_header.is_some(); let actor_id = input .actor_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes") .ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, "mnote_tool_unauthorized", "mnote Hermes tool 需要有效 actorId,不能使用 hermes/anonymous", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools") })?; if has_cookie_or_auth { let mut next = context.clone(); next.auth.actor_id = actor_id.to_string(); next.auth.actor_type = input .arg_string("actorType") .or_else(|| input.arg_string("actor_type")) .unwrap_or_else(|| "user".into()); if next.auth.session_id.is_none() { next.auth.session_id = input.session_id.clone(); } return Ok(next); } let has_run_identity = input .session_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() && input .run_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() && input .tool_call_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() && input .trace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some(); if !has_run_identity { return Err(WebError::new( StatusCode::UNAUTHORIZED, "mnote_tool_unauthorized", "mnote Hermes tool 委托调用缺少 sessionId/runId/toolCallId/traceId", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")); } let mut next = context.clone(); next.auth.actor_id = actor_id.to_string(); next.auth.actor_type = "user".into(); if next.auth.session_id.is_none() { next.auth.session_id = input.session_id.clone(); } Ok(next) } 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 std::collections::BTreeMap; use std::fs; use std::sync::Mutex; use tower::util::ServiceExt; fn env_lock() -> &'static Mutex<()> { crate::test_support::hermes_env_lock() } 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, enable_editor_actor: true, 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": "章节一" }] }, { "id": "p_1", "type": "paragraph", "content": [{ "type": "text", "text": "第一段" }] }, { "id": "p_2", "type": "paragraph", "content": [{ "type": "text", "text": "第二段" }] } ], "revision": 7, "conflict_detection_key": "doc_1:7" } }"# .into(), ), mutation_fixtures_json: Some( r#"{ "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"}, "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"}, "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"} }"# .into(), ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } fn app_with_content(content: Value) -> 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, enable_editor_actor: true, 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( json!({ "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": 6 }, "documents:getContent": { "title": "复杂块阻断页面", "content": content, "revision": 7, "conflict_detection_key": "doc_1:7" } }) .to_string(), ), mutation_fixtures_json: Some( r#"{ "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"}, "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"}, "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"} }"# .into(), ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } async fn call_tool_ok(payload: Value) -> Value { 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(payload.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"); serde_json::from_slice(&body).expect("json") } async fn call_tool_ok_with_app(app: axum::Router, payload: Value) -> Value { 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(payload.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"); serde_json::from_slice(&body).expect("json") } async fn block_revision_ref(block_id: &str) -> String { let payload = call_tool_ok(json!({ "toolName": "mnote.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_ref", "runId": "run_ref", "toolCallId": format!("call_ref_{block_id}"), "traceId": format!("trace_ref_{block_id}"), "capabilityScope": ["page.read"], "args": {"scope": "full", "detail": "with_ids"} })) .await; payload["result"]["blocks"] .as_array() .expect("blocks") .iter() .find(|block| block["blockId"] == json!(block_id)) .and_then(|block| block["revisionRef"].as_str()) .expect("revisionRef") .to_string() } async fn block_revision_refs_with_app(app: axum::Router) -> BTreeMap { let payload = call_tool_ok_with_app( app, json!({ "toolName": "mnote.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_ref_complex", "runId": "run_ref_complex", "toolCallId": "call_ref_complex", "traceId": "trace_ref_complex", "capabilityScope": ["page.read"], "args": {"scope": "full", "detail": "with_ids"} }), ) .await; payload["result"]["blocks"] .as_array() .expect("blocks") .iter() .filter_map(|block| { Some(( block["blockId"].as_str()?.to_string(), block["revisionRef"].as_str()?.to_string(), )) }) .collect() } #[tokio::test] async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() { 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_scope", "runId": "run_scope", "toolCallId": "call_scope", "traceId": "trace_scope", "actorId": "user_1", "capabilityScope": ["page.read"], "dryRun": true, "idempotencyKey": "scope-mismatch", "args": {"content": "不会写入"} }) .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("mnote_tool_capability_scope_forbidden") ); } #[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!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.doc.plan_update")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.block.replace")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.block.insert_after")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.block.delete")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.block.move_after")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.doc.apply_block_ops")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.mindmap.fetch")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.mindmap.apply_ops")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.office.fetch_summary")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.office.propose_changes")); let page_save = tools .iter() .find(|tool| tool["name"] == "mnote.page.save") .expect("page save tool"); assert_eq!(page_save["status"], "available"); assert_eq!( payload["manifest"]["schemaVersion"], "mnote.hermes_tool_manifest.v1" ); } #[tokio::test] async fn hermes_tools_manifest_describes_markdown_edit_write_contract() { 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"); let markdown_edit = tools .iter() .find(|tool| tool["name"] == "mnote.doc.markdown_edit") .expect("markdown edit tool"); let required = markdown_edit["inputSchema"]["required"] .as_array() .expect("required"); for field in [ "workspaceId", "documentId", "actorId", "dryRun", "idempotencyKey", ] { assert!(required.iter().any(|value| value == field)); } assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object()); assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object()); assert!(markdown_edit["inputSchema"]["anyOf"] .as_array() .expect("anyOf") .iter() .any(|rule| rule["required"] == json!(["operations"]))); assert!(markdown_edit["inputSchema"]["anyOf"] .as_array() .expect("anyOf") .iter() .any(|rule| rule["required"] == json!(["full_content"]))); } #[tokio::test] async fn hermes_tools_manifest_marks_write_tools_as_compat_fallbacks() { 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"); let markdown_edit = tools .iter() .find(|tool| tool["name"] == "mnote.doc.markdown_edit") .expect("markdown edit tool"); let page_save = tools .iter() .find(|tool| tool["name"] == "mnote.page.save") .expect("page save tool"); assert!(markdown_edit["description"] .as_str() .expect("description") .contains("兼容")); assert!(markdown_edit["description"] .as_str() .expect("description") .contains("agent 原生 patch/diff")); assert_eq!( page_save["annotations"]["requiresWritePermission"], Value::Bool(true) ); } #[tokio::test] async fn hermes_tools_manifest_describes_block_tools_selection_scope() { 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"); for name in [ "mnote.block.replace", "mnote.block.insert_after", "mnote.block.delete", "mnote.block.move_after", ] { let tool = tools .iter() .find(|tool| tool["name"] == name) .unwrap_or_else(|| panic!("missing tool {name}")); assert!( tool["inputSchema"]["properties"]["allowedTargetBlockIds"].is_object(), "{name}" ); } } #[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_resource_tools_require_allowed_resource_scope() { let root = std::env::temp_dir().join(format!("mnote-resource-scope-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("maps")).expect("maps"); fs::write( root.join("maps").join("idea.mindmap.json"), r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#, ) .expect("mindmap"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_1", &root_uri, ) .expect("workspace"); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.mindmap.fetch", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_resource_scope", "runId": "run_resource_scope", "toolCallId": "call_resource_scope", "traceId": "trace_resource_scope", "args": { "mindmapId": "mind_allowed", "resourcePath": "maps/idea.mindmap.json", "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["mind_other"] } } }) .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("mnote_resource_ai_scope_forbidden") ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-fetch-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("maps")).expect("maps"); fs::write( root.join("maps").join("idea.mindmap.json"), r#"{"data":{"text":"中心主题","uid":"root"},"children":[{"data":{"text":"分支一","uid":"child_1"},"children":[]}]}"#, ) .expect("mindmap"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_1", &root_uri, ) .expect("workspace"); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.mindmap.fetch", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_mindmap_fetch", "runId": "run_mindmap_fetch", "toolCallId": "call_mindmap_fetch", "traceId": "trace_mindmap_fetch", "args": { "mindmapId": "mind_allowed", "resourcePath": "maps/idea.mindmap.json", "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["mind_allowed"] } } }) .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.mindmap.fetch"); assert_eq!(payload["audit"]["effect"], "read"); assert_eq!(payload["result"]["resourceKind"], "mindmap"); assert_eq!( payload["result"]["objectIdentity"], "resource:mindmap:local-md:README.md:mind_allowed" ); assert!(payload["result"]["markdownSummary"] .as_str() .unwrap_or_default() .contains("中心主题")); assert_eq!(payload["result"]["nodes"][1]["text"], "分支一"); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_tools_mindmap_apply_ops_shared_read_is_forbidden() { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-write-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("maps")).expect("maps"); fs::write( root.join("maps").join("idea.mindmap.json"), r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#, ) .expect("mindmap"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_1", &root_uri, ) .expect("workspace"); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.mindmap.apply_ops", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_mindmap_write", "runId": "run_mindmap_write", "toolCallId": "call_mindmap_write", "traceId": "trace_mindmap_write", "idempotencyKey": "idem_mindmap_write", "dryRun": false, "args": { "mindmapId": "mind_allowed", "resourcePath": "maps/idea.mindmap.json", "ops": [{"op": "update_node", "nodeId": "root", "text": "改名"}], "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["mind_allowed"] } } }) .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("mnote_tool_shared_read_write_forbidden") ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_tools_office_fetch_and_propose_changes_do_not_write_file() { let root = std::env::temp_dir().join(format!( "mnote-resource-office-fetch-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("office")).expect("office"); fs::write(root.join("office").join("report.docx"), "Office text").expect("office file"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_1", &root_uri, ) .expect("workspace"); let fetch = 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.office.fetch_summary", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_office_fetch", "runId": "run_office_fetch", "toolCallId": "call_office_fetch", "traceId": "trace_office_fetch", "args": { "assetId": "asset_report", "resourcePath": "office/report.docx", "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["resource:onlyoffice:local-md:README.md:asset_report"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(fetch.status(), StatusCode::OK); let fetch_body = to_bytes(fetch.into_body(), usize::MAX) .await .expect("fetch body"); let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("fetch json"); assert_eq!(fetch_payload["audit"]["effect"], "read"); assert_eq!(fetch_payload["result"]["resourceKind"], "only_office"); assert_eq!(fetch_payload["result"]["fileName"], "report.docx"); let before = fs::read(root.join("office").join("report.docx")).expect("before"); let propose = 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.office.propose_changes", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_office_propose", "runId": "run_office_propose", "toolCallId": "call_office_propose", "traceId": "trace_office_propose", "args": { "assetId": "asset_report", "resourcePath": "office/report.docx", "instructions": "补充结论", "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["asset_report"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(propose.status(), StatusCode::OK); let propose_body = to_bytes(propose.into_body(), usize::MAX) .await .expect("propose body"); let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json"); assert_eq!(propose_payload["audit"]["effect"], "read"); assert_eq!(propose_payload["result"]["writesBinary"], false); assert_eq!( fs::read(root.join("office").join("report.docx")).expect("after"), before ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() { 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", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_hermes", "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["audit"]["actorId"], "user_hermes"); assert_eq!(payload["result"]["title"], "服务端页面"); } #[tokio::test] async fn hermes_tools_call_rejects_profile_disabled_tool() { let _guard = env_lock().lock().expect("env lock"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-hermes-tool-disabled-{}", std::process::id() )); let _ = fs::remove_dir_all(&hermes_home); let profile_dir = hermes_home.join("profiles").join("blocked"); fs::create_dir_all(&profile_dir).expect("profile dir"); fs::write( profile_dir.join("config.yaml"), "mnote:\n tools:\n disabled:\n - mnote.page.get\n", ) .expect("profile config"); std::env::set_var("HERMES_HOME", &hermes_home); 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_disabled", "runId": "run_disabled", "toolCallId": "call_disabled", "traceId": "trace_disabled", "profile": "blocked", "capabilityScope": ["page.read"] }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::FORBIDDEN); 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["code"], "mnote_tool_disabled"); std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); } #[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_doc_fetch_returns_block_projection() { 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.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_doc_fetch_1", "traceId": "trace_doc_fetch_1", "capabilityScope": ["page.read"], "args": {"scope": "full", "detail": "with_ids"} }) .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.doc.fetch"); assert_eq!(payload["audit"]["effect"], "read"); assert_eq!(payload["result"]["revision"], json!(7)); assert_eq!(payload["result"]["conflictDetectionKey"], json!("doc_1:7")); assert_eq!(payload["result"]["fileVersion"], json!("doc_1:7")); assert_eq!( payload["result"]["blocks"][0]["blockId"], json!("heading_1") ); assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一")); assert!(payload["result"]["blocks"][0]["revisionRef"] .as_str() .unwrap_or_default() .starts_with("pageRev:7:block:heading_1:hash:")); } #[tokio::test] async fn hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource() { 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.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_2", "sessionId": "sess_scope_read", "runId": "run_scope_read", "toolCallId": "call_scope_read", "traceId": "trace_scope_read", "args": { "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["doc_1"], "shareContext": {"shareId": "share_read_1"} } } }) .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("mnote_tool_ai_scope_read_forbidden") ); } #[tokio::test] async fn hermes_tools_page_get_rejects_out_of_scope_ai_resource() { 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_2", "sessionId": "sess_page_scope_read", "runId": "run_page_scope_read", "toolCallId": "call_page_scope_read", "traceId": "trace_page_scope_read", "args": { "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["doc_1"], "shareContext": {"shareId": "share_read_1"} } } }) .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("mnote_tool_ai_scope_read_forbidden") ); } #[tokio::test] async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() { 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.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_doc_fetch_selection_1", "traceId": "trace_doc_fetch_selection_1", "capabilityScope": ["page.read"], "args": { "scope": "selection", "selectedBlockIds": ["heading_1"], "format": "page_xml" } }) .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"]["schema"], "mnote.page_ai_context.v1"); assert_eq!(payload["result"]["scope"], "selection"); assert_eq!(payload["result"]["format"], "page_xml"); assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1); assert!(payload["result"]["content"] .as_str() .unwrap_or_default() .contains(">(); 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.block.insert_after", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_multi_insert_limit", "runId": "run_multi_insert_limit", "toolCallId": "call_multi_insert_limit", "traceId": "trace_multi_insert_limit", "idempotencyKey": "idem_multi_insert_limit", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "anchorBlockId": "heading_1", "blocks": blocks, "revision": 7, "conflictDetectionKey": "doc_1:7", "anchorRevisionRef": heading_ref } }) .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_bad_request") ); } #[tokio::test] async fn hermes_tools_block_write_requires_fresh_revision_and_block_ref() { let missing_revision_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.block.replace", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_missing_revision_1", "traceId": "trace_missing_revision_1", "idempotencyKey": "idem_missing_revision_1", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "blockId": "heading_1", "content": "不应写入" } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(missing_revision_response.status(), StatusCode::BAD_REQUEST); assert_eq!( missing_revision_response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_tool_write_precondition_required") ); let stale_ref_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.block.replace", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_stale_ref_1", "traceId": "trace_stale_ref_1", "idempotencyKey": "idem_stale_ref_1", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "blockId": "heading_1", "content": "不应写入", "revision": 7, "conflictDetectionKey": "doc_1:7", "blockRevisionRef": "pageRev:old:block:heading_1:hash:stale" } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(stale_ref_response.status(), StatusCode::BAD_REQUEST); assert_eq!( stale_ref_response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_tool_conflict") ); } #[tokio::test] async fn hermes_tools_apply_block_ops_requires_page_preconditions() { 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.doc.apply_block_ops", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_apply_missing_revision_1", "traceId": "trace_apply_missing_revision_1", "idempotencyKey": "idem_apply_missing_revision_1", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "operations": [{ "op": "replace", "blockId": "p_2", "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_write_precondition_required") ); let missing_block_ref_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.doc.apply_block_ops", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_apply_missing_block_ref_1", "traceId": "trace_apply_missing_block_ref_1", "idempotencyKey": "idem_apply_missing_block_ref_1", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "revision": 7, "conflictDetectionKey": "doc_1:7", "operations": [{ "op": "replace", "blockId": "p_2", "content": "不应写入" }] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(missing_block_ref_response.status(), StatusCode::BAD_REQUEST); assert_eq!( missing_block_ref_response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_tool_write_precondition_required") ); } #[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_local_folder_writes_markdown_file() { let _env_guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-page-save-local-folder-{}", std::process::id() )); let audit_dir = std::env::temp_dir().join(format!( "mnote-local-agent-audit-tool-write-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown"); let root_uri = format!("file://{}", root.display()); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.page.save", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "sessionId": "sess_page_save_local", "runId": "run_page_save_local", "toolCallId": "call_page_save_local", "traceId": "trace_page_save_local", "idempotencyKey": "idem_page_save_local", "dryRun": false, "args": { "content": [ {"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]} ] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert_eq!(status, StatusCode::OK, "{text}"); let payload: Value = serde_json::from_str(&text).expect("json"); assert_eq!(payload["result"]["source"], "local_folder"); assert_eq!(payload["result"]["commandName"], "page.body.write"); let saved = fs::read_to_string(root.join("README.md")).expect("read"); assert!(saved.contains("本地 page.save 写入"), "{saved}"); let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl"); let event = jsonl .lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| serde_json::from_str::(line).ok()) .find(|event| event["runId"] == "run_page_save_local") .expect("audit event"); assert_eq!(event["origin"], "mnote_tool"); assert_eq!(event["toolName"], "mnote.page.save"); assert_eq!(event["toolCallId"], "call_page_save_local"); assert_eq!(event["writeAttemptRejected"], false); assert_eq!(event["changedFiles"][0]["path"], "local-md:README.md"); assert_eq!(event["changedFiles"][0]["changeType"], "modified"); std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] async fn hermes_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() { let root = std::env::temp_dir().join(format!( "mnote-page-options-local-folder-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown"); let root_uri = format!("file://{}", root.display()); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.page.update_options", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "sessionId": "sess_page_options_local", "runId": "run_page_options_local", "toolCallId": "call_page_options_local", "traceId": "trace_page_options_local", "idempotencyKey": "idem_page_options_local", "dryRun": false, "args": { "options": { "wideLayout": true, "showHeadingNumbers": true, "hideTitleHeader": false } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert_eq!(status, StatusCode::OK, "{text}"); let payload: Value = serde_json::from_str(&text).expect("json"); assert_eq!(payload["result"]["source"], "local_folder"); assert_eq!( payload["result"]["commandName"], "page.layout.updateOptions" ); assert_eq!( payload["result"]["result"]["pageOptions"]["wideLayout"], true ); assert_eq!( payload["result"]["result"]["pageOptions"]["showHeadingNumbers"], true ); assert_eq!( payload["result"]["result"]["pageOptions"]["hideTitleHeader"], false ); assert!( !root.join(".mnote").join("page-options.json").exists(), "AI 页面设置工具不应继续写入 .mnote/page-options.json" ); let _ = fs::remove_dir_all(&root); } #[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_markdown_edit_local_requires_write_contract() { let root = std::env::temp_dir().join(format!( "mnote-markdown-edit-contract-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).expect("create temp root"); let path = root.join("page.md"); fs::write(&path, "第一段\n\n第二段\n").expect("write markdown"); 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": path.to_string_lossy(), "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "dryRun": false, "args": { "operations": [{"search": "第二段", "replace": "测试123"}] } }) .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") ); assert_eq!( fs::read_to_string(&path).expect("read markdown"), "第一段\n\n第二段\n" ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_tools_markdown_edit_local_dry_run_does_not_write() { let root = std::env::temp_dir().join(format!( "mnote-markdown-edit-dry-run-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).expect("create temp root"); let path = root.join("page.md"); fs::write(&path, "第一段\n\n第二段\n").expect("write markdown"); 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": path.to_string_lossy(), "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_markdown_local_1", "dryRun": true, "args": { "operations": [{"search": "第二段", "replace": "测试123"}] } }) .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"]["operationsApplied"], 1); assert_eq!(payload["result"]["applyResult"]["written"], false); assert_eq!(payload["result"]["applyResult"]["dryRun"], true); assert_eq!( fs::read_to_string(&path).expect("read markdown"), "第一段\n\n第二段\n" ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_tools_markdown_edit_shared_read_is_forbidden() { let _env_guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-markdown-edit-shared-read-{}", std::process::id() )); let audit_dir = std::env::temp_dir().join(format!( "mnote-local-agent-audit-shared-read-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "原文\n").expect("write markdown"); let root_uri = format!("file://{}", root.display()); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.doc.markdown_edit", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_shared_read_md", "runId": "run_shared_read_md", "toolCallId": "call_shared_read_md", "traceId": "trace_shared_read_md", "idempotencyKey": "idem_shared_read_md", "dryRun": false, "args": { "aiAccessScope": { "permissionLevel": "shared_read", "shareContext": {"shareId": "share_read_1"} }, "operations": [{"search": "原文", "replace": "不应写入"}] } }) .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("mnote_tool_shared_read_write_forbidden") ); assert_eq!( fs::read_to_string(root.join("README.md")).expect("read"), "原文\n" ); std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] async fn hermes_tools_markdown_edit_shared_read_rejection_writes_local_agent_audit() { let _env_guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-markdown-edit-shared-read-audit-{}", std::process::id() )); let audit_dir = std::env::temp_dir().join(format!( "mnote-local-agent-audit-rejected-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "原文\n").expect("write markdown"); let root_uri = format!("file://{}", root.display()); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.doc.markdown_edit", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_shared_read_md_audit", "runId": "run_shared_read_md_audit", "toolCallId": "call_shared_read_md_audit", "traceId": "trace_shared_read_md_audit", "idempotencyKey": "idem_shared_read_md_audit", "dryRun": false, "args": { "aiAccessScope": { "permissionLevel": "shared_read", "shareContext": {"shareId": "share_read_1"} }, "operations": [{"search": "原文", "replace": "不应写入"}] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::FORBIDDEN); let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl"); let event = jsonl .lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| serde_json::from_str::(line).ok()) .find(|event| event["runId"] == "run_shared_read_md_audit") .expect("audit event"); assert_eq!(event["runId"], "run_shared_read_md_audit"); assert_eq!(event["toolName"], "mnote.doc.markdown_edit"); assert_eq!(event["toolCallId"], "call_shared_read_md_audit"); assert_eq!(event["status"], "read_only_write_rejected"); assert_eq!(event["writeAttemptRejected"], true); assert_eq!( event["rejection"]["code"], "mnote_tool_shared_read_write_forbidden" ); assert!(event["changedFiles"].as_array().unwrap().is_empty()); assert_eq!( fs::read_to_string(root.join("README.md")).expect("read"), "原文\n" ); std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] async fn hermes_tools_markdown_edit_reports_empty_block_mapping_before_apply() { // 7-27 修复后:即使 search 吃掉了块注释,也不会崩溃或泄露 apply_block_ops 错误。 // 新行为:search 命中后标记为 applied,块文本变为纯文本「测试123」(无块注释), // parse_final_markdown_to_blocks 将其识别为新块,原 p_2 块保留。 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_markdown_mapping_empty_2", "dryRun": false, "args": { "operations": [{"search": "第二段 ", "replace": "测试123"}] } }) .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"]["operationsApplied"], 1); // 确认没有泄露 apply_block_ops 错误 let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(!text.contains("mnote.doc.apply_block_ops operations 不能为空")); } #[tokio::test] async fn hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping() { // 7-27 修复后:full_content 不再被拒绝。新行为:纯文本(无块注释)全部识别为新块, // 写入后与原有复杂块(如有)合并保留。 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_markdown_full_content_online_2", "dryRun": false, "args": { "full_content": "章节一\n\n第一段已改\n\n第二段已改" } }) .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"]["operationsApplied"], 1); // 确认没有泄露旧错误 let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(!text.contains("mnote_doc_markdown_edit_block_mapping_empty")); assert!(!text.contains("mnote.doc.apply_block_ops operations 不能为空")); } #[tokio::test] async fn hermes_tools_markdown_edit_maps_normalized_search_to_block() { 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_markdown_normalized_1", "dryRun": false, "args": { "operations": [{"search": "第二 段", "replace": "测试123"}] } }) .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"]["operationsApplied"], 1); // 7-27: 新路径 changedBlocks 格式验证 let changed = payload["result"]["applyResult"]["changedBlocks"] .as_array() .expect("changedBlocks"); assert!(!changed.is_empty(), "changedBlocks should not be empty"); assert_eq!( payload["result"]["applyResult"]["changedBlocks"][0]["blockId"], "p_2" ); assert_eq!( payload["result"]["applyResult"]["changedBlocks"][0]["op"], "replace" ); } #[tokio::test] async fn hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() { 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_markdown_precondition", "runId": "run_markdown_precondition", "toolCallId": "call_markdown_precondition", "traceId": "trace_markdown_precondition", "idempotencyKey": "idem_markdown_precondition", "dryRun": false, "args": { "operations": [{"search": "第二段", "replace": "测试123"}] } }) .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 command_payload = &payload["result"]["applyResult"]["artifacts"]["commandLog"]["payload"]; assert_eq!(command_payload["revision"], 7); assert_eq!(command_payload["conflictDetectionKey"], "doc_1:7"); } #[tokio::test] async fn hermes_tools_markdown_edit_local_folder_writes_same_markdown_file() { let root = std::env::temp_dir().join(format!( "mnote-markdown-edit-local-folder-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("README.assets")).expect("create asset dir"); fs::write( root.join("README.md"), "---\ntitle: AI Local\n---\n# AI Local\n\n第一段\n\n![图](README.assets/photo.png)\n", ) .expect("write markdown"); fs::write(root.join("README.assets").join("photo.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_1", &root_uri, ) .expect("initialize workspace"); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.doc.markdown_edit", "workspaceId": "local-ws-test", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_local_folder_md", "runId": "run_local_folder_md", "toolCallId": "call_local_folder_md", "traceId": "trace_local_folder_md", "idempotencyKey": "idem_local_folder_md", "dryRun": false, "args": { "operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let headers = response.headers().clone(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); if status != StatusCode::OK { panic!("status={status} headers={headers:?} body={text}"); } let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["source"], "local_folder"); assert_eq!( payload["result"]["applyResult"]["commandName"], "page.body.write" ); let saved = fs::read_to_string(root.join("README.md")).expect("read markdown"); assert!(saved.contains("第一段已由 AI 修改")); assert!(saved.contains("README.assets/photo.png")); assert!(!saved.contains("/api/media")); assert!(!saved.contains("assetId")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_tools_markdown_edit_rejects_no_applied_operations() { 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_markdown_noop", "runId": "run_markdown_noop", "toolCallId": "call_markdown_noop", "traceId": "trace_markdown_noop", "idempotencyKey": "idem_markdown_noop", "dryRun": false, "args": { "operations": [{"search": "不存在的段落", "replace": "测试123"}] } }) .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_markdown_edit_no_operations_applied") ); } #[tokio::test] async fn hermes_tools_markdown_edit_rejects_selection_out_of_scope() { 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_markdown_scope", "runId": "run_markdown_scope", "toolCallId": "call_markdown_scope", "traceId": "trace_markdown_scope", "idempotencyKey": "idem_markdown_scope", "dryRun": true, "args": { "allowedTargetBlockIds": ["p_2"], "operations": [{"search": "第一段", "replace": "不应越权修改"}] } }) .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_markdown_edit_target_out_of_scope") ); } #[tokio::test] async fn hermes_tools_markdown_edit_merges_same_block_operations() { 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.doc.markdown_edit", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_markdown_same_block_1", "dryRun": true, "args": { "operations": [ {"search": "二", "replace": "2"}, {"search": "2段", "replace": "2段落"} ] } }) .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"]["operationsApplied"], 2); let diff = payload["result"]["applyResult"]["diff"] .as_array() .expect("diff"); assert_eq!(diff.len(), 1); assert_eq!(diff[0]["blockId"], "p_2"); // dryRun 时文本不落盘,但 diff 记录预期修改后的内容 assert!(diff[0]["content"].as_str().unwrap_or("").contains("2段落")); } #[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"); } #[tokio::test] async fn hermes_tools_artifact_summary_local_folder_writes_sidecar_file() { let root = std::env::temp_dir().join(format!( "mnote-artifact-local-folder-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "# Local\n").expect("markdown"); let root_uri = format!("file://{}", root.display()); 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-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.artifact.create_summary", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "sessionId": "sess_artifact_local", "runId": "run_artifact_local", "toolCallId": "call_artifact_local", "traceId": "trace_artifact_local", "idempotencyKey": "idem_artifact_local", "dryRun": false, "args": {"summary": "本地摘要"} }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert_eq!(status, StatusCode::OK, "{text}"); let payload: Value = serde_json::from_str(&text).expect("json"); assert_eq!(payload["result"]["source"], "local_folder"); let artifact_path = root .join(".mnote") .join("artifacts") .join("summary_local-md_README.md.json"); let artifact = fs::read_to_string(&artifact_path).expect("artifact"); assert!(artifact.contains("本地摘要"), "{artifact}"); let _ = fs::remove_dir_all(&root); } }