Files
mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs
T

3817 lines
154 KiB
Rust
Raw Normal View History

use super::hermes_client;
2026-05-14 15:10:33 +08:00
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
2026-05-14 15:10:33 +08:00
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";
2026-05-19 08:49:02 +08:00
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 写入审计失败"
);
}
}
2026-05-14 15:10:33 +08:00
pub async fn mnote_audit(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value, WebError> {
let context = authenticated_tool_context(context, &input)?;
2026-05-14 15:10:33 +08:00
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) {
2026-05-14 15:10:33 +08:00
"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());
2026-05-14 15:10:33 +08:00
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
2026-05-14 15:10:33 +08:00
}));
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);
}
2026-05-14 15:10:33 +08:00
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"
}));
2026-05-19 08:49:02 +08:00
record_local_agent_write_rejection(
&context,
&input,
&profile,
error.code(),
error.message(),
);
return Err(error);
}
2026-05-21 23:53:39 +08:00
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);
}
2026-05-14 15:10:33 +08:00
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);
2026-05-14 15:10:33 +08:00
}
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,
2026-05-17 16:15:52 +08:00
"mnote.doc.markdown_edit" => doc::doc_markdown_edit(&state, &context, &input).await,
2026-05-14 15:10:33 +08:00
"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,
2026-05-14 15:10:33 +08:00
"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"
);
2026-05-19 08:49:02 +08:00
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(),
);
}
2026-05-14 15:10:33 +08:00
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?;
2026-05-19 08:49:02 +08:00
if !dry_run && !is_read_tool(&input.tool_name) {
record_local_agent_tool_write(&context, &input, &profile, &result);
}
2026-05-14 15:10:33 +08:00
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)
2026-05-14 15:10:33 +08:00
}
2026-05-21 23:53:39 +08:00
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<String> {
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<String>>, 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::<Vec<_>>();
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")
}
2026-05-14 15:10:33 +08:00
fn audit_log() -> &'static Mutex<Vec<Value>> {
static LOG: OnceLock<Mutex<Vec<Value>>> = 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<Value> {
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<Value> {
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::<Value>(&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<HashMap<String, Value>> {
static CACHE: OnceLock<Mutex<HashMap<String, Value>>> = 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<String> {
if dry_run || is_read_tool(&input.tool_name) {
2026-05-14 15:10:33 +08:00
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<Value> {
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();
2026-05-14 15:10:33 +08:00
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"))
}
2026-05-16 12:34:48 +08:00
fn authenticated_tool_context(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<RequestContext, WebError> {
let context_actor = context.auth.actor_id.trim();
if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" {
2026-05-16 12:34:48 +08:00
return Ok(context.clone());
}
let has_cookie_or_auth =
context.auth.authorization.is_some() || context.auth.cookie_header.is_some();
2026-05-16 12:34:48 +08:00
let actor_id = input
.actor_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes")
2026-05-16 12:34:48 +08:00
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"mnote_tool_unauthorized",
"mnote Hermes tool 需要有效 actorId,不能使用 hermes/anonymous",
2026-05-16 12:34:48 +08:00
)
.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);
}
2026-05-16 12:34:48 +08:00
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)
}
2026-05-14 15:10:33 +08:00
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;
2026-05-14 15:10:33 +08:00
use tower::util::ServiceExt;
fn env_lock() -> &'static Mutex<()> {
crate::test_support::hermes_env_lock()
}
2026-05-14 15:10:33 +08:00
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,
2026-05-14 15:10:33 +08:00
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": "第二段" }]
2026-05-14 15:10:33 +08:00
}
],
"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(),
),
2026-05-14 15:10:33 +08:00
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<String, String> {
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()
}
2026-05-21 23:53:39 +08:00
#[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")
);
}
2026-05-14 15:10:33 +08:00
#[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"));
2026-05-16 12:34:48 +08:00
let page_save = tools
.iter()
.find(|tool| tool["name"] == "mnote.page.save")
.expect("page save tool");
assert_eq!(page_save["status"], "available");
2026-05-14 15:10:33 +08:00
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}"
);
}
}
2026-05-14 15:10:33 +08:00
#[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);
}
2026-05-16 12:34:48 +08:00
#[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);
}
2026-05-14 15:10:33 +08:00
#[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("<block id=\"heading_1\""));
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
}
#[tokio::test]
async fn hermes_tools_doc_find_and_block_fetch_use_block_projection() {
let find_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.find",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_doc_find_1",
"traceId": "trace_doc_find_1",
"capabilityScope": ["page.read"],
"args": {"query": "章节一"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(find_response.status(), StatusCode::OK);
let find_body = to_bytes(find_response.into_body(), usize::MAX)
.await
.expect("body");
let find_payload: Value = serde_json::from_slice(&find_body).expect("json");
assert_eq!(
find_payload["result"]["matches"][0]["blockId"],
json!("heading_1")
);
let fetch_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.fetch",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_block_fetch_1",
"traceId": "trace_block_fetch_1",
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.read", "page.read"],
"args": {"blockId": "heading_1", "contextBefore": 1, "contextAfter": 1}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(fetch_response.status(), StatusCode::OK);
let fetch_body = to_bytes(fetch_response.into_body(), usize::MAX)
.await
.expect("body");
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("json");
assert_eq!(
fetch_payload["result"]["block"]["blockId"],
json!("heading_1")
);
assert_eq!(fetch_payload["result"]["block"]["text"], json!("章节一"));
assert_eq!(fetch_payload["audit"]["effect"], "read");
}
#[tokio::test]
async fn hermes_tools_plan_update_and_block_move_after_are_dry_run_only() {
let heading_ref = block_revision_ref("heading_1").await;
let plan_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.plan_update",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_plan_1",
"traceId": "trace_plan_1",
"idempotencyKey": "idem_plan_1",
"dryRun": true,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["page.write", "block.write"],
"args": {
"command": "block_replace",
"blockId": "heading_1",
"content": "替换标题"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(plan_response.status(), StatusCode::OK);
let plan_body = to_bytes(plan_response.into_body(), usize::MAX)
.await
.expect("body");
let plan_payload: Value = serde_json::from_slice(&plan_body).expect("json");
assert_eq!(plan_payload["audit"]["effect"], "dry_run");
assert_eq!(plan_payload["result"]["diff"][0]["op"], "replace");
let move_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.move_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_move_1",
"traceId": "trace_move_1",
"idempotencyKey": "idem_move_1",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": "heading_1",
"anchorBlockId": "heading_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref.clone(),
"anchorRevisionRef": heading_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(move_response.status(), StatusCode::OK);
let move_body = to_bytes(move_response.into_body(), usize::MAX)
.await
.expect("body");
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
assert_eq!(move_payload["result"]["blocked"], true);
assert_eq!(
move_payload["result"]["warnings"][0]["code"],
"block_move_after_blocked"
);
}
#[tokio::test]
async fn hermes_tools_block_replace_and_insert_after_write_through_page_body_save() {
let heading_ref = block_revision_ref("heading_1").await;
let p1_ref = block_revision_ref("p_1").await;
let p2_ref = block_revision_ref("p_2").await;
let replace_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_replace_1",
"traceId": "trace_replace_1",
"idempotencyKey": "idem_replace_1",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": "heading_1",
"content": "替换后的章节",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(replace_response.status(), StatusCode::OK);
let replace_body = to_bytes(replace_response.into_body(), usize::MAX)
.await
.expect("body");
let replace_payload: Value = serde_json::from_slice(&replace_body).expect("json");
assert_eq!(replace_payload["audit"]["effect"], "write");
assert_eq!(
replace_payload["result"]["changedBlocks"][0]["blockId"],
json!("heading_1")
);
assert_eq!(
replace_payload["result"]["commandName"],
json!("page.body.save")
);
let insert_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_1",
"runId": "run_1",
"toolCallId": "call_insert_1",
"traceId": "trace_insert_1",
"idempotencyKey": "idem_insert_1",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"anchorBlockId": "heading_1",
"content": "新增段落",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"anchorRevisionRef": heading_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(insert_response.status(), StatusCode::OK);
let insert_body = to_bytes(insert_response.into_body(), usize::MAX)
.await
.expect("body");
let insert_payload: Value = serde_json::from_slice(&insert_body).expect("json");
assert_eq!(insert_payload["audit"]["effect"], "write");
assert_eq!(
insert_payload["result"]["changedBlocks"][0]["op"],
json!("insert_after")
);
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
.as_str()
.unwrap_or_default()
.starts_with("ai_block_"));
let delete_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.delete",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_delete_1",
"traceId": "trace_delete_1",
"idempotencyKey": "idem_delete_1",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": p1_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(delete_response.status(), StatusCode::OK);
let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
.await
.expect("body");
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("json");
assert_eq!(delete_payload["audit"]["effect"], "write");
assert_eq!(
delete_payload["result"]["changedBlocks"][0]["op"],
json!("delete")
);
assert_eq!(
delete_payload["result"]["changedBlocks"][0]["blockId"],
json!("p_1")
);
let move_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.move_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_move_write_1",
"traceId": "trace_move_write_1",
"idempotencyKey": "idem_move_write_1",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref.clone(),
"anchorRevisionRef": p2_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(move_response.status(), StatusCode::OK);
let move_body = to_bytes(move_response.into_body(), usize::MAX)
.await
.expect("body");
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
assert_eq!(move_payload["audit"]["effect"], "write");
assert_eq!(
move_payload["result"]["changedBlocks"][0]["op"],
json!("move_after")
);
}
#[tokio::test]
async fn hermes_tools_block_tools_selection_scope_rejects_out_of_scope_targets() {
let heading_ref = block_revision_ref("heading_1").await;
let p1_ref = block_revision_ref("p_1").await;
let p2_ref = block_revision_ref("p_2").await;
let cases = vec![
json!({
"toolName": "mnote.block.replace",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_scope_replace",
"runId": "run_scope_replace",
"toolCallId": "call_scope_replace",
"traceId": "trace_scope_replace",
"idempotencyKey": "idem_scope_replace",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": "heading_1",
"content": "越界替换",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref,
"allowedTargetBlockIds": ["p_1"]
}
}),
json!({
"toolName": "mnote.block.insert_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_scope_insert",
"runId": "run_scope_insert",
"toolCallId": "call_scope_insert",
"traceId": "trace_scope_insert",
"idempotencyKey": "idem_scope_insert",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"anchorBlockId": "heading_1",
"content": "越界插入",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"anchorRevisionRef": heading_ref,
"allowedTargetBlockIds": ["p_1"]
}
}),
json!({
"toolName": "mnote.block.delete",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_scope_delete",
"runId": "run_scope_delete",
"toolCallId": "call_scope_delete",
"traceId": "trace_scope_delete",
"idempotencyKey": "idem_scope_delete",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": p1_ref,
"allowedTargetBlockIds": ["heading_1"]
}
}),
json!({
"toolName": "mnote.block.move_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_scope_move",
"runId": "run_scope_move",
"toolCallId": "call_scope_move",
"traceId": "trace_scope_move",
"idempotencyKey": "idem_scope_move",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref,
"anchorRevisionRef": p2_ref,
"allowedTargetBlockIds": ["heading_1"]
}
}),
];
for payload in cases {
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::BAD_REQUEST);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_block_target_out_of_scope")
);
}
}
#[tokio::test]
async fn hermes_tools_block_move_after_blocks_complex_and_nested_blocks() {
let content = json!([
{
"id": "heading_parent",
"type": "heading",
"props": { "level": 2 },
"content": [{ "type": "text", "text": "带子块标题" }],
"children": [
{
"id": "heading_child",
"type": "paragraph",
"content": [{ "type": "text", "text": "标题子段落" }]
}
]
},
{
"id": "p_anchor",
"type": "paragraph",
"content": [{ "type": "text", "text": "锚点段落" }]
},
{
"id": "list_item_1",
"type": "bullet_list_item",
"content": [{ "type": "text", "text": "列表项" }]
},
{
"id": "table_1",
"type": "table",
"content": [{ "type": "text", "text": "表格块" }]
},
{
"id": "mindmap_1",
"type": "mindmap",
"props": { "mindmapId": "mindmap_fixture_1" },
"content": [{ "type": "text", "text": "思维导图块" }]
},
{
"id": "resource_1",
"type": "resource",
"content": [{ "type": "text", "text": "资源块" }]
}
]);
let refs = block_revision_refs_with_app(app_with_content(content.clone())).await;
let anchor_ref = refs.get("p_anchor").expect("anchor ref").clone();
for block_id in [
"heading_parent",
"list_item_1",
"table_1",
"mindmap_1",
"resource_1",
] {
let response = app_with_content(content.clone())
.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.move_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": format!("sess_move_blocked_{block_id}"),
"runId": format!("run_move_blocked_{block_id}"),
"toolCallId": format!("call_move_blocked_{block_id}"),
"traceId": format!("trace_move_blocked_{block_id}"),
"idempotencyKey": format!("idem_move_blocked_{block_id}"),
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"blockId": block_id,
"anchorBlockId": "p_anchor",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": refs.get(block_id).expect("block ref"),
"anchorRevisionRef": anchor_ref
}
})
.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"]["blocked"], json!(true), "{block_id}");
assert_eq!(
payload["result"]["warnings"][0]["code"],
json!("block_move_after_blocked"),
"{block_id}"
);
}
}
#[tokio::test]
async fn hermes_tools_block_insert_after_accepts_multiple_blocks_and_returns_ids() {
let heading_ref = block_revision_ref("heading_1").await;
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",
"runId": "run_multi_insert",
"toolCallId": "call_multi_insert",
"traceId": "trace_multi_insert",
"idempotencyKey": "idem_multi_insert",
"dryRun": false,
2026-05-25 23:34:03 +08:00
"capabilityScope": ["block.write", "page.write"],
"args": {
"anchorBlockId": "heading_1",
"blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}],
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"anchorRevisionRef": heading_ref
}
})
.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 inserted_ids = payload["result"]["insertedBlockIds"]
.as_array()
.expect("insertedBlockIds");
assert_eq!(inserted_ids.len(), 2);
assert!(inserted_ids
.iter()
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
assert_eq!(
payload["result"]["changedBlocks"]
.as_array()
.expect("changedBlocks")
.len(),
2
);
assert_eq!(payload["result"]["changedBlocks"][0]["op"], "insert_after");
assert_eq!(payload["result"]["changedBlocks"][1]["op"], "insert_after");
}
#[tokio::test]
async fn hermes_tools_block_insert_after_rejects_more_than_twenty_blocks() {
let heading_ref = block_revision_ref("heading_1").await;
let blocks = (0..21)
.map(|index| json!(format!("新增段落 {index}")))
.collect::<Vec<_>>();
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,
2026-05-25 23:34:03 +08:00
"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,
2026-05-25 23:34:03 +08:00
"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,
2026-05-25 23:34:03 +08:00
"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")
);
}
2026-05-14 15:10:33 +08:00
#[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() {
2026-05-19 08:49:02 +08:00
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()
));
2026-05-19 08:49:02 +08:00
let audit_dir = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-tool-write-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
2026-05-19 08:49:02 +08:00
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}");
2026-05-19 08:49:02 +08:00
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::<Value>(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);
2026-05-19 08:49:02 +08:00
let _ = fs::remove_dir_all(&audit_dir);
}
2026-05-14 15:10:33 +08:00
#[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() {
2026-05-19 08:49:02 +08:00
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()
));
2026-05-19 08:49:02 +08:00
let audit_dir = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-shared-read-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
2026-05-19 08:49:02 +08:00
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"
);
2026-05-19 08:49:02 +08:00
std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
let _ = fs::remove_dir_all(&root);
2026-05-19 08:49:02 +08:00
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::<Value>(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": "第二段 <!-- block:p_2 -->", "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段落"));
}
2026-05-14 15:10:33 +08:00
#[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);
}
2026-05-14 15:10:33 +08:00
}