use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::mnote_agent_tools::{ artifact, block, context_tools, doc, knowledge_rag, manifest, onlyoffice_live, page, resource, skill, ToolCallInput, }; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::Json; use serde_json::{json, Value}; use std::collections::HashMap; use std::env; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use tracing::{info, warn}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_AGENT_TOOL_OWNER: &str = "x-mnote-agent-tool-owner"; /// 本地 agent 审计(原 agent 审计子集,预发瘦身后留在 tools 路由侧) const LOCAL_AGENT_AUDIT_DIR: &str = "/mnt/Data1T/Mnote_data/control-plane/agent-audit"; const LOCAL_AGENT_AUDIT_JSONL: &str = "agent-audit.jsonl"; fn local_agent_audit_root_dir() -> PathBuf { env::var("MNOTE_LOCAL_AGENT_AUDIT_DIR") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(LOCAL_AGENT_AUDIT_DIR)) } fn local_agent_audit_path() -> PathBuf { local_agent_audit_root_dir().join(LOCAL_AGENT_AUDIT_JSONL) } fn local_agent_audit_write_event(event: &Value) -> Result<(), WebError> { let audit_dir = local_agent_audit_root_dir(); fs::create_dir_all(&audit_dir).map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法创建本地审计目录: {error}"), ) })?; let path = local_agent_audit_path(); let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法打开本地审计文件: {error}"), ) })?; let line = serde_json::to_string(event).map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法序列化本地审计事件: {error}"), ) })?; writeln!(file, "{line}").map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法写入本地审计事件: {error}"), ) }) } fn local_agent_audit_record_write_rejected( context: &RequestContext, payload: Value, run_id: &str, agent_kind: &str, ) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let event = json!({ "eventId": format!("local_audit:{run_id}:{now}"), "actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or(&context.auth.actor_id), "actorType": payload.get("actorType").and_then(Value::as_str).unwrap_or(&context.auth.actor_type), "agentKind": agent_kind, "runId": run_id, "sessionId": payload.get("sessionId").cloned().unwrap_or(Value::Null), "workspaceId": payload.get("workspaceId").cloned().unwrap_or(Value::Null), "documentId": payload.get("documentId").cloned().unwrap_or(Value::Null), "rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null), "permission": "read", "status": "read_only_write_rejected", "writeAttemptRejected": true, "changedFiles": [], "toolName": payload.get("toolName").cloned().unwrap_or(Value::Null), "toolCallId": payload.get("toolCallId").cloned().unwrap_or(Value::Null), "rejection": payload.get("rejection").cloned().unwrap_or(Value::Null), "createdAt": now, }); local_agent_audit_write_event(&event)?; Ok(event) } fn local_agent_audit_record_tool_write( context: &RequestContext, payload: Value, run_id: &str, agent_kind: &str, ) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let changed_files = payload .get("changedFiles") .cloned() .unwrap_or_else(|| Value::Array(Vec::new())); let event = json!({ "eventId": format!("local_audit:{run_id}:{now}"), "actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or(&context.auth.actor_id), "actorType": payload.get("actorType").and_then(Value::as_str).unwrap_or(&context.auth.actor_type), "agentKind": agent_kind, "runId": run_id, "sessionId": payload.get("sessionId").cloned().unwrap_or(Value::Null), "workspaceId": payload.get("workspaceId").cloned().unwrap_or(Value::Null), "documentId": payload.get("documentId").cloned().unwrap_or(Value::Null), "rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null), "permission": "write", "status": "completed", "writeAttemptRejected": false, "changedFiles": changed_files, "toolName": payload.get("toolName").cloned().unwrap_or(Value::Null), "toolCallId": payload.get("toolCallId").cloned().unwrap_or(Value::Null), "commandName": payload.get("commandName").cloned().unwrap_or(Value::Null), "origin": "mnote_tool", "createdAt": now, }); local_agent_audit_write_event(&event)?; Ok(event) } /// 从 profile 本地 config.yaml 读取禁用的 mnote tools。 /// 路径:`$MNOTE_AGENT_HOME/profiles/{profile}/config.yaml` 中 `mnote.tools.disabled`。 /// 兼容读取:`HERMES_HOME` / `~/.hermes`(仅 profile 配置路径,非产品面)。 fn agent_profile_home() -> PathBuf { env::var("MNOTE_AGENT_HOME") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| { env::var("HERMES_HOME") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) }) .or_else(|| { env::var("HOME").ok().and_then(|home| { let preferred = PathBuf::from(&home).join(".mnote-agent"); if preferred.exists() { return Some(preferred); } let legacy = PathBuf::from(&home).join(".hermes"); if legacy.exists() { return Some(legacy); } Some(preferred) }) }) .unwrap_or_else(|| PathBuf::from(".mnote-agent")) } /// profile 段只允许单层安全名,拒绝 `/`、`\`、`..`,防止拼到 profiles/ 外。 fn sanitize_agent_profile_segment(profile: &str) -> Option<&str> { let profile = profile.trim(); if profile.is_empty() || profile == "default" { return None; } if profile.contains('/') || profile.contains('\\') || profile.contains('\0') || profile == "." || profile == ".." || profile .split(['/', '\\']) .any(|seg| seg.is_empty() || seg == "." || seg == "..") { return None; } // 仅允许常见 profile 标识字符,避免奇怪路径段。 if !profile .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') { return None; } Some(profile) } fn agent_profile_config_path(profile: &str) -> PathBuf { let home = agent_profile_home(); let Some(safe) = sanitize_agent_profile_segment(profile) else { return home.join("config.yaml"); }; let candidate = home.join("profiles").join(safe); if candidate.exists() { candidate.join("config.yaml") } else { home.join("config.yaml") } } fn yaml_disabled_list(content: &str, path: &[&str]) -> Vec { let mut stack: Vec<(usize, String)> = Vec::new(); let mut values = Vec::new(); for raw_line in content.lines() { let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); if let Some(value) = trimmed.strip_prefix("- ") { let keys = stack .iter() .map(|(_, key)| key.as_str()) .collect::>(); if keys == path { let normalized = value.trim().trim_matches('"').trim_matches('\''); if !normalized.is_empty() { values.push(normalized.to_string()); } } continue; } while stack .last() .map(|(level, _)| *level >= indent) .unwrap_or(false) { stack.pop(); } let Some((key, _value)) = trimmed.split_once(':') else { continue; }; let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); stack.push((indent, key)); } values } fn disabled_mnote_tools(profile: &str) -> Vec { let content = fs::read_to_string(agent_profile_config_path(profile)).unwrap_or_default(); yaml_disabled_list(&content, &["mnote", "tools", "disabled"]) } fn is_mnote_tool_disabled(profile: &str, name: &str) -> bool { disabled_mnote_tools(profile) .iter() .any(|item| item == name) } 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) = 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) = local_agent_audit_record_tool_write(context, payload, run_id, profile) { warn!( error = ?error, run_id = %run_id, tool_name = %input.tool_name, "本地 agent tool 写入审计失败" ); } } pub async fn mnote_audit( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let trace_id = query.get("traceId").map(String::as_str); let tool_call_id = query.get("toolCallId").map(String::as_str); let persisted_only = query .get("persistedOnly") .map(|value| value == "true" || value == "1") .unwrap_or(false); let events = if persisted_only { audit_persisted_events(trace_id, tool_call_id) } else { audit_events(trace_id, tool_call_id) }; Ok(( StatusCode::OK, stamp_tool_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "auditStore": if persisted_only { "jsonl" } else { "memory" }, "events": events })), )) } pub async fn mnote_manifest( Extension(context): Extension, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; Ok(( StatusCode::OK, stamp_tool_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "manifest": manifest::manifest() })), )) } pub async fn mnote_call( State(state): State, Extension(context): Extension, Json(input): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { // 7-76:外部 AI 经 tools 调用时按读写工具要求 notes scope。 let required = if is_read_tool(&input.tool_name) { crate::routes::api_access_token::SCOPE_NOTES_READ } else { crate::routes::api_access_token::SCOPE_NOTES_WRITE }; crate::routes::api_access_token::ensure_scope(&context, required)?; let response_body = execute_mnote_tool_call(&state, &context, input).await?; Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body))) } pub(crate) async fn execute_mnote_tool_call( state: &AppState, context: &RequestContext, input: ToolCallInput, ) -> Result { let context = authenticated_tool_context(context, &input)?; let trace_id = input .effective_trace_id(&context.trace.trace_id) .to_string(); let tool_call_id = input.effective_tool_call_id(); let workspace_id = input.effective_workspace_id(); let document_id = input.effective_document_id(); let dry_run = input.dry_run.unwrap_or(false); let effect = if dry_run { "dry_run" } else if is_read_tool(&input.tool_name) { "read" } else { "write" }; let idempotency_key = idempotency_cache_key( &input, workspace_id.as_deref(), document_id.as_deref(), dry_run, ); info!( trace_id = %trace_id, session_id = input.session_id.as_deref().unwrap_or(""), run_id = input.run_id.as_deref().unwrap_or(""), tool_call_id = %tool_call_id, tool_name = %input.tool_name, workspace_id = workspace_id.as_deref().unwrap_or(""), document_id = document_id.as_deref().unwrap_or(""), actor_id = input.actor_id.as_deref().unwrap_or(""), dry_run, "mnote 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(|| None) .unwrap_or_else(|| "default".into()); audit_push(json!({ "phase": "started", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "dryRun": dry_run })); if is_mnote_tool_disabled(&profile, &input.tool_name) { let error = WebError::new( StatusCode::FORBIDDEN, "mnote_tool_disabled", "当前 profile 已关闭该 mnote tool", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-tools"); audit_push(json!({ "phase": "failed", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "profile": profile, "status": error.status().as_u16(), "message": error.message() })); return Err(error); } if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) { audit_push(json!({ "phase": "failed", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "status": error.status().as_u16(), "message": error.message() })); return Err(error); } if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) { let error = WebError::new( StatusCode::FORBIDDEN, "mnote_tool_shared_read_write_forbidden", "共享只读 AI 上下文不能执行写工具", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-tools"); audit_push(json!({ "phase": "failed", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "status": error.status().as_u16(), "message": error.message(), "permissionLevel": "shared_read" })); record_local_agent_write_rejection( &context, &input, &profile, error.code(), error.message(), ); return Err(error); } if let Err(error) = ensure_tool_capability_scope(&context, &input) { audit_push(json!({ "phase": "failed", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "status": error.status().as_u16(), "message": error.message(), "capabilityScope": input.capability_scope })); return Err(error); } if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) { info!( trace_id = %trace_id, session_id = input.session_id.as_deref().unwrap_or(""), run_id = input.run_id.as_deref().unwrap_or(""), tool_call_id = %tool_call_id, tool_name = %input.tool_name, workspace_id = workspace_id.as_deref().unwrap_or(""), document_id = document_id.as_deref().unwrap_or(""), actor_id = input.actor_id.as_deref().unwrap_or(""), "mnote agent tool call idempotency replay" ); audit_push(json!({ "phase": "idempotency_replay", "traceId": trace_id, "sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null), "runId": cached.get("runId").cloned().unwrap_or(Value::Null), "toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null), "toolName": cached.get("toolName").cloned().unwrap_or(Value::Null), "audit": cached.get("audit").cloned().unwrap_or(Value::Null) })); return Ok(cached); } let result = match input.tool_name.as_str() { "mnote.skill.read" => skill::skill_read(&context, &input).await, "mnote.context.snapshot" => context_tools::context_snapshot(&state, &context, &input).await, "mnote.context.read_current_page" => { context_tools::read_current_page(&state, &context, &input).await } "mnote.context.resolve_target" => { context_tools::resolve_target(&state, &context, &input).await } "mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await, "mnote.doc.find" => doc::doc_find(&state, &context, &input).await, "docs_search" | "docs_read" | "mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" => { Err(WebError::new( StatusCode::GONE, "mnote_evidence_tools_retired", "旧 docs/evidence/LiteParse tools 已退役;请使用兼容 mnote.knowledge_rag.query/open_reference", ) .with_context(&context)) } "mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await, "mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await, "mnote.knowledge_rag.section_context" => { knowledge_rag::section_context(&state, &context, &input).await } "mnote.knowledge_rag.open_reference" => { knowledge_rag::open_reference(&state, &context, &input).await } "mnote.index.status" | "mnote.index.refresh" | "mnote.index.update_settings" => Err( WebError::new( StatusCode::GONE, "mnote_index_tools_retired", "旧本地索引 tools 已退役;资料索引统一由当前知识库 provider 处理", ) .with_context(&context), ), "mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await, "mnote.block.fetch" => block::block_fetch(&state, &context, &input).await, "mnote.block.replace" => block::block_replace(&state, &context, &input).await, "mnote.block.insert_after" => block::block_insert_after(&state, &context, &input).await, "mnote.block.delete" => block::block_delete(&state, &context, &input).await, "mnote.block.move_after" => block::block_move_after(&state, &context, &input).await, "mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await, "mnote.doc.markdown_edit" => doc::doc_markdown_edit(&state, &context, &input).await, "mnote.page.get" => page::page_get(&state, &context, &input).await, "mnote.page.save" => page::page_save(&state, &context, &input).await, "mnote.page.update_title" => page::update_title(&state, &context, &input).await, "mnote.page.update_options" => page::update_options(&state, &context, &input).await, "mnote.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await, "mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await, "mnote.mindmap.create_from_outline" => { resource::mindmap_create_from_outline(&context, &input).await } "mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await, "mnote.office.propose_changes" => resource::office_propose_changes(&context, &input).await, "mnote.onlyoffice.session.current" => { onlyoffice_live::session_current(&context, &input).await } "mnote.onlyoffice.capabilities" => onlyoffice_live::capabilities(&context, &input).await, "mnote.onlyoffice.selection.get" => onlyoffice_live::selection_get(&context, &input).await, "mnote.onlyoffice.document.insert_text" => { onlyoffice_live::document_insert_text(&context, &input).await } "mnote.onlyoffice.document.replace_selection" => { onlyoffice_live::document_replace_selection(&context, &input).await } "mnote.onlyoffice.document.insert_html" => { onlyoffice_live::document_insert_html(&context, &input).await } "mnote.onlyoffice.document.export" => { onlyoffice_live::document_export(&context, &input).await } "mnote.onlyoffice.document.search_replace" => { onlyoffice_live::document_search_replace(&context, &input).await } "mnote.onlyoffice.document.insert_table" => { onlyoffice_live::document_insert_table(&context, &input).await } "mnote.onlyoffice.document.get_comments" => { onlyoffice_live::document_get_comments(&context, &input).await } "mnote.onlyoffice.document.add_comment" => { onlyoffice_live::document_add_comment(&context, &input).await } "mnote.onlyoffice.sheet.get_sheets" => { onlyoffice_live::sheet_get_sheets(&context, &input).await } "mnote.onlyoffice.sheet.add_sheet" => { onlyoffice_live::sheet_add_sheet(&context, &input).await } "mnote.onlyoffice.sheet.rename_sheet" => { onlyoffice_live::sheet_rename_sheet(&context, &input).await } "mnote.onlyoffice.sheet.get_range" => { onlyoffice_live::sheet_get_range(&context, &input).await } "mnote.onlyoffice.sheet.get_range_values" => { onlyoffice_live::sheet_get_range_values(&context, &input).await } "mnote.onlyoffice.sheet.get_values" => { onlyoffice_live::sheet_get_values(&context, &input).await } "mnote.onlyoffice.sheet.set_value" => { onlyoffice_live::sheet_set_value(&context, &input).await } "mnote.onlyoffice.sheet.set_formula" => { onlyoffice_live::sheet_set_formula(&context, &input).await } "mnote.onlyoffice.sheet.batch_set_values" => { onlyoffice_live::sheet_batch_set_values(&context, &input).await } "mnote.onlyoffice.sheet.set_range_values" => { onlyoffice_live::sheet_set_range_values(&context, &input).await } "mnote.onlyoffice.sheet.format_range" => { onlyoffice_live::sheet_format_range(&context, &input).await } "mnote.onlyoffice.sheet.set_dimensions" => { onlyoffice_live::sheet_set_dimensions(&context, &input).await } "mnote.onlyoffice.sheet.sort_range" => { onlyoffice_live::sheet_sort_range(&context, &input).await } "mnote.onlyoffice.sheet.add_chart" => { onlyoffice_live::sheet_add_chart(&context, &input).await } "mnote.onlyoffice.presentation.get_slides" => { onlyoffice_live::presentation_get_slides(&context, &input).await } "mnote.onlyoffice.presentation.get_slide_texts" => { onlyoffice_live::presentation_get_slide_texts(&context, &input).await } "mnote.onlyoffice.presentation.get_shapes" => { onlyoffice_live::presentation_get_shapes(&context, &input).await } "mnote.onlyoffice.presentation.add_text_slide" => { onlyoffice_live::presentation_add_text_slide(&context, &input).await } "mnote.onlyoffice.presentation.replace_text" => { onlyoffice_live::presentation_replace_text(&context, &input).await } "mnote.onlyoffice.presentation.set_shape_text" => { onlyoffice_live::presentation_set_shape_text(&context, &input).await } "mnote.onlyoffice.presentation.delete_slide" => { onlyoffice_live::presentation_delete_slide(&context, &input).await } "mnote.onlyoffice.presentation.add_table" => { onlyoffice_live::presentation_add_table(&context, &input).await } "mnote.onlyoffice.presentation.clear_slide" => { onlyoffice_live::presentation_clear_slide(&context, &input).await } "mnote.onlyoffice.presentation.add_shape" => { onlyoffice_live::presentation_add_shape(&context, &input).await } "mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await, "mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await, _ => Err( WebError::bad_request_code("mnote_tool_unknown", "未知 mnote agent tool") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-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 agent tool call failed" ); if matches!( error.code(), "mnote_tool_ai_scope_write_forbidden" | "mnote_tool_shared_read_write_forbidden" ) { record_local_agent_write_rejection( &context, &input, &profile, error.code(), error.message(), ); } audit_push(json!({ "phase": "failed", "traceId": trace_id, "sessionId": input.session_id, "runId": input.run_id, "toolCallId": tool_call_id, "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, "actorId": input.actor_id, "status": error.status().as_u16(), "message": error.message() })); } let mut result = result?; if !dry_run && !is_read_tool(&input.tool_name) { record_local_agent_tool_write(&context, &input, &profile, &result); } let evidence_receipt = if is_evidence_receipt_tool(&input.tool_name) { let evidence_ids = evidence_ids_for_result(&result); let receipt = json!({ "schema": "mnote.agent_run_receipt.evidence.v1", "traceId": trace_id.clone(), "sessionId": input.session_id.clone(), "runId": input.run_id.clone(), "toolCallId": tool_call_id.clone(), "toolName": input.tool_name.clone(), "workspaceId": workspace_id.clone(), "documentId": document_id.clone(), "rootUri": input.effective_root_uri(), "evidenceIds": evidence_ids, }); if let Some(result_object) = result.as_object_mut() { result_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone()); result_object.insert("runReceipt".into(), receipt.clone()); } Some(receipt) } else { None }; 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 agent tool call completed" ); let mut audit = json!({ "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 }); if let Some(receipt) = &evidence_receipt { if let Some(audit_object) = audit.as_object_mut() { audit_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone()); audit_object.insert("runReceipt".into(), receipt.clone()); } } let mut 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": audit, "error": null }); if let Some(receipt) = &evidence_receipt { if let Some(response_object) = response_body.as_object_mut() { response_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone()); response_object.insert("runReceipt".into(), receipt.clone()); } } if let Some(key) = idempotency_key { idempotency_cache_put(key, response_body.clone()); } audit_push(json!({ "phase": "completed", "traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null), "sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null), "runId": response_body.get("runId").cloned().unwrap_or(Value::Null), "toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null), "toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null), "audit": response_body.get("audit").cloned().unwrap_or(Value::Null) })); Ok(response_body) } fn ensure_tool_capability_scope( context: &RequestContext, input: &ToolCallInput, ) -> Result<(), WebError> { let required = required_capability_scope(&input.tool_name); if required.is_empty() || declared_capability_scope_covers(input.capability_scope.as_ref(), &required) { return Ok(()); } Err(WebError::new( StatusCode::FORBIDDEN, "mnote_tool_capability_scope_forbidden", "调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-tools")) } fn required_capability_scope(tool_name: &str) -> Vec { manifest::manifest() .get("tools") .and_then(Value::as_array) .into_iter() .flatten() .find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name)) .and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array)) .into_iter() .flatten() .filter_map(Value::as_str) .map(normalize_capability_scope) .filter(|value| !value.is_empty()) .collect() } fn declared_capability_scope_covers(declared: Option<&Vec>, required: &[String]) -> bool { // required 为空时由 ensure_tool_capability_scope 短路。 let Some(declared) = declared else { // 兼容旧调用方:完全未声明 capabilityScope 时不改变既有执行路径。 // 写工具仍由 shared_read / aiAccessScope / commandContext 等合同 fail-closed。 // 注意:显式声明 `[]` 与“未声明”语义不同——空数组表示调用方主动声明无能力,必须拒绝。 return true; }; let declared = declared .iter() .map(|value| normalize_capability_scope(value)) .filter(|value| !value.is_empty()) .collect::>(); // 显式空 capabilityScope → fail-closed(禁止 None 与 [] 混同为“放行”)。 if declared.is_empty() { return false; } 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.skill.read" | "mnote.context.snapshot" | "mnote.context.read_current_page" | "mnote.context.resolve_target" | "mnote.doc.fetch" | "mnote.doc.find" | "docs_search" | "docs_read" | "mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" | "mnote.knowledge_rag.status" | "mnote.knowledge_rag.query" | "mnote.knowledge_rag.section_context" | "mnote.knowledge_rag.open_reference" | "mnote.index.status" | "mnote.index.refresh" | "mnote.block.fetch" | "mnote.mindmap.fetch" | "mnote.office.fetch_summary" | "mnote.office.propose_changes" | "mnote.onlyoffice.session.current" | "mnote.onlyoffice.capabilities" | "mnote.onlyoffice.selection.get" | "mnote.onlyoffice.document.export" | "mnote.onlyoffice.document.get_comments" | "mnote.onlyoffice.sheet.get_sheets" | "mnote.onlyoffice.sheet.get_range" | "mnote.onlyoffice.sheet.get_range_values" | "mnote.onlyoffice.sheet.get_values" | "mnote.onlyoffice.presentation.get_slides" | "mnote.onlyoffice.presentation.get_slide_texts" | "mnote.onlyoffice.presentation.get_shapes" ) } fn is_evidence_receipt_tool(tool_name: &str) -> bool { matches!( tool_name, "docs_search" | "docs_read" | "mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" | "mnote.knowledge_rag.status" | "mnote.knowledge_rag.query" | "mnote.knowledge_rag.section_context" | "mnote.knowledge_rag.open_reference" ) } fn evidence_ids_for_result(result: &Value) -> Vec { let mut ids = Vec::new(); collect_evidence_ids(result, &mut ids); ids } fn collect_evidence_ids(value: &Value, ids: &mut Vec) { match value { Value::Object(object) => { if let Some(id) = object .get("evidenceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { let id = id.to_string(); if !ids.iter().any(|existing| existing == &id) { ids.push(id); } } for child in object.values() { collect_evidence_ids(child, ids); } } Value::Array(items) => { for item in items { collect_evidence_ids(item, ids); } } _ => {} } } fn is_shared_read_scope(input: &ToolCallInput) -> bool { let direct = input .arg_string("permissionLevel") .or_else(|| input.arg_string("permission_level")); if matches!(direct.as_deref(), Some("shared_read")) { return true; } input .arg_value("aiAccessScope") .and_then(|value| { value .get("permissionLevel") .or_else(|| value.get("permission_level")) .and_then(Value::as_str) .map(str::trim) .map(ToOwned::to_owned) }) .as_deref() == Some("shared_read") } fn audit_log() -> &'static Mutex> { static LOG: OnceLock>> = OnceLock::new(); LOG.get_or_init(|| Mutex::new(Vec::new())) } fn audit_push(event: Value) { if let Ok(mut log) = audit_log().lock() { log.push(event.clone()); let overflow = log.len().saturating_sub(500); if overflow > 0 { log.drain(0..overflow); } } if let Err(error) = audit_append_persistent(&event) { warn!(message = %error, "mnote agent tool audit 持久化失败"); } } fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec { let Ok(log) = audit_log().lock() else { return Vec::new(); }; log.iter() .filter(|event| { trace_id .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .filter(|event| { tool_call_id .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .cloned() .collect() } fn audit_log_path() -> PathBuf { env::var("MNOTE_AGENT_TOOL_AUDIT_LOG") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| { // 兼容旧 env 名 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-agent-tool-audit.jsonl")) } fn audit_append_persistent(event: &Value) -> Result<(), String> { let path = audit_log_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|error| error.to_string())?; let line = serde_json::to_string(event).map_err(|error| error.to_string())?; writeln!(file, "{line}").map_err(|error| error.to_string()) } fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec { let path = audit_log_path(); let Ok(file) = File::open(path) else { return Vec::new(); }; BufReader::new(file) .lines() .map_while(Result::ok) .filter_map(|line| serde_json::from_str::(&line).ok()) .filter(|event| { trace_id .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .filter(|event| { tool_call_id .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected)) .unwrap_or(true) }) .collect() } fn idempotency_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } fn idempotency_cache_key( input: &ToolCallInput, workspace_id: Option<&str>, document_id: Option<&str>, dry_run: bool, ) -> Option { if dry_run || is_read_tool(&input.tool_name) { return None; } let idempotency_key = input.idempotency_key.as_deref()?.trim(); if idempotency_key.is_empty() { return None; } Some(format!( "{}|{}|{}|{}", input.tool_name, workspace_id.unwrap_or(""), document_id.unwrap_or(""), idempotency_key )) } fn idempotency_cache_get(key: &str) -> Option { idempotency_cache().lock().ok()?.get(key).cloned() } fn idempotency_cache_put(key: String, response: Value) { if let Ok(mut cache) = idempotency_cache().lock() { cache.insert(key, response); } } fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { let actor = context.auth.actor_id.trim(); let has_actor = actor != "anonymous" && actor != "hermes" && !actor.is_empty(); if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { return Ok(()); } Err(WebError::new( StatusCode::UNAUTHORIZED, "mnote_tool_unauthorized", "mnote agent tool 需要登录后访问", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-tools")) } fn authenticated_tool_context( context: &RequestContext, input: &ToolCallInput, ) -> Result { let context_actor = context.auth.actor_id.trim(); if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" { return Ok(context.clone()); } let has_cookie_or_auth = context.auth.authorization.is_some() || context.auth.cookie_header.is_some(); let actor_id = input .actor_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes") .ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, "mnote_tool_unauthorized", "mnote agent tool 需要有效 actorId,不能使用 anonymous/hermes 等占位 id", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-tools") })?; if has_cookie_or_auth { let mut next = context.clone(); next.auth.actor_id = actor_id.to_string(); next.auth.actor_type = input .arg_string("actorType") .or_else(|| input.arg_string("actor_type")) .unwrap_or_else(|| "user".into()); if next.auth.session_id.is_none() { next.auth.session_id = input.session_id.clone(); } return Ok(next); } let has_run_identity = input .session_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() && input .run_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() && input .tool_call_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() && input .trace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some(); if !has_run_identity { return Err(WebError::new( StatusCode::UNAUTHORIZED, "mnote_tool_unauthorized", "mnote agent tool 委托调用缺少 sessionId/runId/toolCallId/traceId", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-tools")); } let mut next = context.clone(); next.auth.actor_id = actor_id.to_string(); next.auth.actor_type = "user".into(); if next.auth.session_id.is_none() { next.auth.session_id = input.session_id.clone(); } Ok(next) } fn ensure_workspace_context( context: &RequestContext, input_workspace_id: Option<&str>, ) -> Result<(), WebError> { let Some(input_workspace_id) = input_workspace_id .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(()); }; let Some(header_workspace_id) = context .workspace .workspace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(()); }; if input_workspace_id == header_workspace_id { return Ok(()); } Err(WebError::new( StatusCode::FORBIDDEN, "workspace_context_conflict", "mnote agent tool 请求的 workspaceId 与请求上下文不一致", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_AGENT_TOOL_OWNER, "mnote-web-agent-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_AGENT_TOOL_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web-agent-tools")); } headers } #[cfg(test)] mod tests { use super::{agent_profile_config_path, sanitize_agent_profile_segment}; 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::path::PathBuf; use std::sync::Mutex; use tower::util::ServiceExt; fn env_lock() -> &'static Mutex<()> { crate::test_support::agent_env_lock() } #[test] fn agent_profile_segment_rejects_path_traversal() { assert!(sanitize_agent_profile_segment("ok-profile").is_some()); assert!(sanitize_agent_profile_segment("../etc").is_none()); assert!(sanitize_agent_profile_segment("a/b").is_none()); assert!(sanitize_agent_profile_segment("..").is_none()); assert!(sanitize_agent_profile_segment("default").is_none()); // 危险段回落到 default config.yaml,路径中不得含攻击串 let path = agent_profile_config_path("../../../etc/passwd"); let s = path.to_string_lossy(); assert!(!s.contains("etc/passwd"), "{s}"); assert!(s.ends_with("config.yaml"), "{s}"); } #[test] fn declared_capability_scope_empty_vec_is_fail_closed() { use super::declared_capability_scope_covers; let required = vec!["page.write".to_string()]; // 未声明:兼容旧路径 assert!(declared_capability_scope_covers(None, &required)); // 显式空:拒绝 let empty: Vec = vec![]; assert!(!declared_capability_scope_covers(Some(&empty), &required)); // 显式覆盖:通过 let ok = vec!["page.write".to_string()]; assert!(declared_capability_scope_covers(Some(&ok), &required)); // 仅 read 不覆盖 write let read_only = vec!["page.read".to_string()]; assert!(!declared_capability_scope_covers(Some(&read_only), &required)); } 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, enable_page_ai_pi_lab: false, compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some( r#"{ "documents:getMeta": { "id": "doc_1", "workspace_id": "ws_demo", "title": "服务端页面", "can_edit": true, "wide_layout": false, "use_small_text": false, "show_toc": true, "block_count": 1 }, "documents:getContent": { "title": "服务端页面", "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "章节一" }] }, { "id": "p_1", "type": "paragraph", "content": [{ "type": "text", "text": "第一段" }] }, { "id": "p_2", "type": "paragraph", "content": [{ "type": "text", "text": "第二段" }] } ], "revision": 7, "conflict_detection_key": "doc_1:7" } }"# .into(), ), mutation_fixtures_json: Some( r#"{ "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"}, "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"}, "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"} }"# .into(), ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), environment: "dev".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, enable_page_ai_pi_lab: false, 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(), environment: "dev".into(), })) } fn write_mindmap_apply_fixture(test_name: &str, content: Value) -> (PathBuf, String) { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-{test_name}-{}", 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"), serde_json::to_string_pretty(&content).expect("mindmap json"), ) .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"); (root, root_uri) } async fn post_mindmap_apply_ops( root_uri: &str, dry_run: bool, args: Value, id_suffix: &str, ) -> axum::response::Response { app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": format!("sess_mindmap_apply_{id_suffix}"), "runId": format!("run_mindmap_apply_{id_suffix}"), "toolCallId": format!("call_mindmap_apply_{id_suffix}"), "traceId": format!("trace_mindmap_apply_{id_suffix}"), "idempotencyKey": format!("idem_mindmap_apply_{id_suffix}"), "dryRun": dry_run, "args": args }) .to_string(), )) .expect("request"), ) .await .expect("response") } async fn call_tool_ok(payload: Value) -> Value { let app = app(); maybe_register_onlyoffice_test_session(&app, &payload).await; let response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 { maybe_register_onlyoffice_test_session(&app, &payload).await; let response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 maybe_register_onlyoffice_test_session(app: &axum::Router, payload: &Value) { let Some(tool_name) = payload.get("toolName").and_then(Value::as_str) else { return; }; if !tool_name.starts_with("mnote.onlyoffice.") || tool_name == "mnote.onlyoffice.capabilities" || tool_name == "mnote.onlyoffice.session.current" { return; } let Some(session_id) = payload .get("args") .and_then(|args| { args.get("onlyofficeSessionId") .or_else(|| args.get("bridgeSessionId")) }) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let allowed_resource_id = payload .get("args") .and_then(|args| args.get("aiAccessScope")) .and_then(|scope| { scope .get("allowedResourceIds") .or_else(|| scope.get("allowed_resource_ids")) }) .and_then(Value::as_array) .and_then(|values| values.iter().find_map(Value::as_str)) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(session_id); let token = format!("token-{session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": session_id, "token": token, "editorType": "cell", "documentId": "doc_1", "assetId": allowed_resource_id, "fileType": "xlsx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); } async fn block_revision_ref(block_id: &str) -> String { let payload = call_tool_ok(json!({ "toolName": "mnote.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_ref", "runId": "run_ref", "toolCallId": format!("call_ref_{block_id}"), "traceId": format!("trace_ref_{block_id}"), "capabilityScope": ["page.read"], "args": {"scope": "full", "detail": "with_ids"} })) .await; payload["result"]["blocks"] .as_array() .expect("blocks") .iter() .find(|block| block["blockId"] == json!(block_id)) .and_then(|block| block["revisionRef"].as_str()) .expect("revisionRef") .to_string() } async fn block_revision_refs_with_app(app: axum::Router) -> BTreeMap { let payload = call_tool_ok_with_app( app, json!({ "toolName": "mnote.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_ref_complex", "runId": "run_ref_complex", "toolCallId": "call_ref_complex", "traceId": "trace_ref_complex", "capabilityScope": ["page.read"], "args": {"scope": "full", "detail": "with_ids"} }), ) .await; payload["result"]["blocks"] .as_array() .expect("blocks") .iter() .filter_map(|block| { Some(( block["blockId"].as_str()?.to_string(), block["revisionRef"].as_str()?.to_string(), )) }) .collect() } #[tokio::test] async fn mnote_tools_call_rejects_declared_scope_that_does_not_cover_tool() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.page.save", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_scope", "runId": "run_scope", "toolCallId": "call_scope", "traceId": "trace_scope", "actorId": "user_1", "capabilityScope": ["page.read"], "dryRun": true, "idempotencyKey": "scope-mismatch", "args": {"content": "不会写入"} }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_tool_capability_scope_forbidden") ); } #[tokio::test] async fn mnote_tools_manifest_returns_first_batch_tools() { let response = app() .oneshot( Request::builder() .uri("/api/mnote/tools/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")); let mindmap_create = tools .iter() .find(|tool| tool["name"] == "mnote.mindmap.create_from_outline") .expect("mindmap create_from_outline tool"); assert_eq!( mindmap_create["inputSchema"]["properties"]["outline"]["type"], "array" ); assert!(mindmap_create["capabilityScope"] .as_array() .expect("capability scope") .iter() .any(|scope| scope == "mindmap.write")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.office.fetch_summary")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.office.propose_changes")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.session.current")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.capabilities")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_value")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.document.export")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.get_values")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.document.search_replace")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.document.insert_table")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.document.get_comments")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.document.add_comment")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.get_sheets")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.get_range_values")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_range_values")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.format_range")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_dimensions")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.sort_range")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.add_chart")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.presentation.get_slide_texts")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.presentation.get_shapes")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.presentation.replace_text")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.presentation.set_shape_text")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.presentation.delete_slide")); assert!(tools .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.presentation.add_table")); let page_save = tools .iter() .find(|tool| tool["name"] == "mnote.page.save") .expect("page save tool"); assert_eq!(page_save["status"], "available"); assert_eq!( payload["manifest"]["schemaVersion"], "mnote.agent_tool_manifest.v1" ); } #[tokio::test] async fn mnote_tools_manifest_exposes_mnote_capability_packs() { let response = app() .oneshot( Request::builder() .uri("/api/mnote/tools/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 manifest = &payload["manifest"]; let capabilities = manifest["capabilities"].as_array().expect("capabilities"); assert!(!capabilities .iter() .any(|capability| capability["id"] == "mnote-local-index")); assert!(capabilities .iter() .any(|capability| capability["id"] == "mnote-knowledge-rag")); assert!(capabilities .iter() .any(|capability| capability["id"] == "mnote-onlyoffice-live")); let tools = manifest["tools"].as_array().expect("tools"); assert!(!tools .iter() .any(|tool| tool["name"] == "mnote.index.status")); let knowledge_rag_query = tools .iter() .find(|tool| tool["name"] == "mnote.knowledge_rag.query") .expect("knowledge rag query tool"); assert!(knowledge_rag_query["capabilityIds"] .as_array() .expect("knowledge rag capability ids") .iter() .any(|id| id == "mnote-knowledge-rag")); let onlyoffice_batch_set = tools .iter() .find(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values") .expect("onlyoffice batch set tool"); assert_eq!( onlyoffice_batch_set["capabilityId"], "mnote-onlyoffice-live" ); assert!(onlyoffice_batch_set["capabilityIds"] .as_array() .expect("onlyoffice capability ids") .iter() .any(|id| id == "mnote-onlyoffice-live")); let context_snapshot = tools .iter() .find(|tool| tool["name"] == "mnote.context.snapshot") .expect("context snapshot tool"); assert!( context_snapshot["capabilityIds"] .as_array() .expect("context capability ids") .len() > 1 ); } #[tokio::test] async fn mnote_tools_onlyoffice_session_current_reads_registered_bridge_session() { let app = app(); let bridge_session_id = format!("mnote-oo-tool-test-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "cell", "documentId": "doc_meta", "assetId": "asset_meta", "fileType": "xlsx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let payload = call_tool_ok_with_app( app, json!({ "toolName": "mnote.onlyoffice.session.current", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_current", "runId": "run_oo_current", "toolCallId": "call_oo_current", "traceId": "trace_oo_current", "capabilityScope": ["office.read"], "args": { "onlyofficeSessionId": bridge_session_id, "aiAccessScope": { "permissionLevel": "read", "allowedResourceIds": ["asset_meta"] } } }), ) .await; assert_eq!(payload["result"]["schema"], "mnote.onlyoffice.session.v1"); assert_eq!(payload["result"]["session"]["sessionId"], bridge_session_id); assert_eq!(payload["result"]["session"]["editorType"], "cell"); assert_eq!(payload["result"]["session"]["documentId"], "doc_meta"); assert_eq!(payload["result"]["session"]["assetId"], "asset_meta"); assert_eq!(payload["result"]["session"]["fileType"], "xlsx"); } #[tokio::test] async fn mnote_tools_onlyoffice_session_current_requires_explicit_authorized_scope() { let app = app(); let bridge_session_id = format!("mnote-oo-current-scope-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "word", "documentId": "doc_current_b", "assetId": "asset_current_b", "fileType": "docx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let implicit_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.onlyoffice.session.current", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_current_implicit", "runId": "run_oo_current_implicit", "toolCallId": "call_oo_current_implicit", "traceId": "trace_oo_current_implicit", "capabilityScope": ["office.read"], "args": { "aiAccessScope": { "permissionLevel": "read", "allowedResourceIds": ["asset_current_b"] } } }) .to_string(), )) .expect("request"), ) .await .expect("implicit response"); assert_eq!(implicit_response.status(), StatusCode::BAD_REQUEST); assert_eq!( implicit_response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_onlyoffice_session_explicit_required") ); let forbidden_response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.onlyoffice.session.current", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_current_forbidden", "runId": "run_oo_current_forbidden", "toolCallId": "call_oo_current_forbidden", "traceId": "trace_oo_current_forbidden", "capabilityScope": ["office.read"], "args": { "onlyofficeSessionId": bridge_session_id, "aiAccessScope": { "permissionLevel": "read", "allowedResourceIds": ["asset_current_a"] } } }) .to_string(), )) .expect("request"), ) .await .expect("forbidden response"); assert_eq!(forbidden_response.status(), StatusCode::FORBIDDEN); assert_eq!( forbidden_response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_onlyoffice_resource_scope_forbidden") ); } #[tokio::test] async fn mnote_tools_onlyoffice_capabilities_lists_shape_and_range_actions() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.capabilities", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_caps", "runId": "run_oo_caps", "toolCallId": "call_oo_caps", "traceId": "trace_oo_caps", "capabilityScope": ["office.read"] })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.bridge_capabilities.v1" ); let actions = payload["result"]["actions"].as_array().expect("actions"); assert!(actions .iter() .any(|action| action == "sheet.set_range_values")); assert!(actions.iter().any(|action| action == "sheet.format_range")); assert!(actions .iter() .any(|action| action == "sheet.set_dimensions")); assert!(actions.iter().any(|action| action == "sheet.sort_range")); assert!(actions.iter().any(|action| action == "sheet.add_chart")); assert!(actions .iter() .any(|action| action == "presentation.set_shape_text")); assert!(actions .iter() .any(|action| action == "presentation.delete_slide")); assert!(actions .iter() .any(|action| action == "presentation.add_table")); assert!(actions .iter() .any(|action| action == "presentation.clear_slide")); assert!(actions .iter() .any(|action| action == "presentation.add_shape")); assert!(actions .iter() .any(|action| action == "document.insert_table")); assert!(actions .iter() .any(|action| action == "document.get_comments")); assert!(actions .iter() .any(|action| action == "document.add_comment")); } #[tokio::test] async fn mnote_tools_onlyoffice_selection_get_round_trips_bridge_command() { let app = app(); let bridge_session_id = format!("mnote-oo-selection-test-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "word" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let worker_app = app.clone(); let worker_session_id = bridge_session_id.clone(); let worker = tokio::spawn(async move { let next = worker_app .clone() .oneshot( Request::builder() .uri(format!( "/api/onlyoffice/bridge/commands/next?sessionId={worker_session_id}&token={bridge_token}&timeoutMs=5000" )) .body(Body::empty()) .expect("next request"), ) .await .expect("next response"); assert_eq!(next.status(), StatusCode::OK); let body = to_bytes(next.into_body(), usize::MAX) .await .expect("next body"); let command: Value = serde_json::from_slice(&body).expect("command json"); assert_eq!(command["action"], "selection.get"); let command_id = command["id"].as_str().expect("command id"); let posted = worker_app .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/results") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": worker_session_id, "token": bridge_token, "id": command_id, "ok": true, "result": { "editorType": "word", "text": "选中文本" } }) .to_string(), )) .expect("post result request"), ) .await .expect("post result response"); assert_eq!(posted.status(), StatusCode::OK); }); let payload = call_tool_ok_with_app( app, json!({ "toolName": "mnote.onlyoffice.selection.get", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_select", "runId": "run_oo_select", "toolCallId": "call_oo_select", "traceId": "trace_oo_select", "capabilityScope": ["office.read"], "args": { "onlyofficeSessionId": bridge_session_id, "aiAccessScope": { "permissionLevel": "read", "allowedResourceIds": [bridge_session_id] }, "timeoutMs": 5000 } }), ) .await; worker.await.expect("worker"); assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_result.v1" ); assert_eq!(payload["result"]["action"], "selection.get"); assert_eq!(payload["result"]["result"]["text"], "选中文本"); } #[tokio::test] async fn mnote_tools_onlyoffice_write_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.sheet.set_value", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_sheet", "runId": "run_oo_sheet", "toolCallId": "call_oo_sheet", "traceId": "trace_oo_sheet", "idempotencyKey": "idem_oo_sheet", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "address": "A1", "value": "after" } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "sheet.set_value"); assert_eq!(payload["result"]["payload"]["address"], "A1"); assert_eq!(payload["result"]["payload"]["value"], "after"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_write_tool_rejects_implicit_current_session() { let app = app(); let bridge_session_id = format!("mnote-oo-implicit-write-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "cell", "documentId": "doc_office", "assetId": "asset_office", "fileType": "xlsx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.onlyoffice.sheet.set_value", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_implicit", "runId": "run_oo_implicit", "toolCallId": "call_oo_implicit", "traceId": "trace_oo_implicit", "idempotencyKey": "idem_oo_implicit", "dryRun": true, "capabilityScope": ["office.write"], "args": { "aiAccessScope": { "permissionLevel": "read_write" }, "address": "A1", "value": "after" } }) .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_onlyoffice_session_explicit_required") ); } #[tokio::test] async fn mnote_tools_onlyoffice_read_tool_rejects_implicit_current_session() { let app = app(); let bridge_session_id = format!("mnote-oo-read-implicit-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "word", "documentId": "doc_office", "assetId": "asset_office", "fileType": "docx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.onlyoffice.document.export", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_read_implicit", "runId": "run_oo_read_implicit", "toolCallId": "call_oo_read_implicit", "traceId": "trace_oo_read_implicit", "capabilityScope": ["office.read"], "args": { "format": "markdown", "aiAccessScope": { "permissionLevel": "read", "allowedResourceIds": ["asset_office"] } } }) .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_onlyoffice_session_explicit_required") ); } #[tokio::test] async fn mnote_tools_onlyoffice_write_tool_rejects_missing_resource_scope() { let app = app(); let bridge_session_id = format!("mnote-oo-missing-scope-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "cell", "documentId": "doc_office", "assetId": "asset_office", "fileType": "xlsx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.onlyoffice.sheet.set_value", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_missing_scope", "runId": "run_oo_missing_scope", "toolCallId": "call_oo_missing_scope", "traceId": "trace_oo_missing_scope", "idempotencyKey": "idem_oo_missing_scope", "dryRun": true, "capabilityScope": ["office.write"], "args": { "aiAccessScope": { "permissionLevel": "read_write" }, "onlyofficeSessionId": bridge_session_id, "address": "A1", "value": "after" } }) .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_onlyoffice_resource_scope_required") ); } #[tokio::test] async fn mnote_tools_onlyoffice_write_tool_requires_allowed_resource_scope() { let app = app(); let bridge_session_id = format!("mnote-oo-scope-write-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "cell", "documentId": "doc_office", "assetId": "asset_b", "fileType": "xlsx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.onlyoffice.sheet.set_value", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_scope", "runId": "run_oo_scope", "toolCallId": "call_oo_scope", "traceId": "trace_oo_scope", "idempotencyKey": "idem_oo_scope", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": bridge_session_id, "address": "A1", "value": "after", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["asset_a"] } } }) .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_onlyoffice_resource_scope_forbidden") ); } #[tokio::test] async fn mnote_tools_onlyoffice_write_tool_allows_authorized_resource_scope() { let app = app(); let bridge_session_id = format!("mnote-oo-scope-allow-{}", std::process::id()); let bridge_token = format!("token-{bridge_session_id}"); let register = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/onlyoffice/bridge/session") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": bridge_session_id, "token": bridge_token, "editorType": "cell", "documentId": "doc_office", "assetId": "asset_allowed", "fileType": "xlsx" }) .to_string(), )) .expect("request"), ) .await .expect("register response"); assert_eq!(register.status(), StatusCode::OK); let payload = call_tool_ok_with_app( app, json!({ "toolName": "mnote.onlyoffice.sheet.set_value", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_scope_allow", "runId": "run_oo_scope_allow", "toolCallId": "call_oo_scope_allow", "traceId": "trace_oo_scope_allow", "idempotencyKey": "idem_oo_scope_allow", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": bridge_session_id, "address": "A1", "value": "after", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["resource:onlyoffice:doc_office:asset_allowed"] } } }), ) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["sessionId"], bridge_session_id); assert_eq!(payload["result"]["action"], "sheet.set_value"); } #[tokio::test] async fn mnote_tools_onlyoffice_batch_write_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.sheet.batch_set_values", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_batch", "runId": "run_oo_batch", "toolCallId": "call_oo_batch", "traceId": "trace_oo_batch", "idempotencyKey": "idem_oo_batch", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "startRow": 1, "startCol": 2, "values": [["A", "B"], ["C", "D"]] } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "sheet.batch_set_values"); assert_eq!(payload["result"]["payload"]["startRow"], 1); assert_eq!(payload["result"]["payload"]["startCol"], 2); assert_eq!(payload["result"]["payload"]["values"][1][1], "D"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_range_write_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.sheet.set_range_values", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_range", "runId": "run_oo_range", "toolCallId": "call_oo_range", "traceId": "trace_oo_range", "idempotencyKey": "idem_oo_range", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "address": "B2:C3", "values": [["A", "B"], ["C", "D"]] } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "sheet.set_range_values"); assert_eq!(payload["result"]["payload"]["address"], "B2:C3"); assert_eq!(payload["result"]["payload"]["values"][1][1], "D"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_search_replace_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.document.search_replace", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_doc_replace", "runId": "run_oo_doc_replace", "toolCallId": "call_oo_doc_replace", "traceId": "trace_oo_doc_replace", "idempotencyKey": "idem_oo_doc_replace", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "search": "old", "replace": "new", "matchCase": true } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "document.search_replace"); assert_eq!(payload["result"]["payload"]["search"], "old"); assert_eq!(payload["result"]["payload"]["replace"], "new"); assert_eq!(payload["result"]["payload"]["matchCase"], true); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_insert_table_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.document.insert_table", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_table", "runId": "run_oo_table", "toolCallId": "call_oo_table", "traceId": "trace_oo_table", "idempotencyKey": "idem_oo_table", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "rows": 2, "cols": 3, "data": [["A", "B", "C"], ["1", "2", "3"]] } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "document.insert_table"); assert_eq!(payload["result"]["payload"]["rows"], 2); assert_eq!(payload["result"]["payload"]["cols"], 3); assert_eq!(payload["result"]["payload"]["data"][1][2], "3"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_add_comment_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.document.add_comment", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_comment", "runId": "run_oo_comment", "toolCallId": "call_oo_comment", "traceId": "trace_oo_comment", "idempotencyKey": "idem_oo_comment", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "text": "Review this paragraph", "author": "Agent", "target": "document_start" } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "document.add_comment"); assert_eq!( payload["result"]["payload"]["text"], "Review this paragraph" ); assert_eq!(payload["result"]["payload"]["author"], "Agent"); assert_eq!(payload["result"]["payload"]["target"], "document_start"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_presentation_replace_text_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.presentation.replace_text", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_ppt_replace", "runId": "run_oo_ppt_replace", "toolCallId": "call_oo_ppt_replace", "traceId": "trace_oo_ppt_replace", "idempotencyKey": "idem_oo_ppt_replace", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "search": "Quarterly", "replace": "Monthly", "slideIndex": 0 } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "presentation.replace_text"); assert_eq!(payload["result"]["payload"]["search"], "Quarterly"); assert_eq!(payload["result"]["payload"]["replace"], "Monthly"); assert_eq!(payload["result"]["payload"]["slideIndex"], 0); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_shape_text_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.presentation.set_shape_text", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_ppt_shape", "runId": "run_oo_ppt_shape", "toolCallId": "call_oo_ppt_shape", "traceId": "trace_oo_ppt_shape", "idempotencyKey": "idem_oo_ppt_shape", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "slideIndex": 0, "shapeIndex": 1, "text": "Precise shape text" } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "presentation.set_shape_text"); assert_eq!(payload["result"]["payload"]["slideIndex"], 0); assert_eq!(payload["result"]["payload"]["shapeIndex"], 1); assert_eq!(payload["result"]["payload"]["text"], "Precise shape text"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_sheet_format_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.sheet.format_range", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_sheet_format", "runId": "run_oo_sheet_format", "toolCallId": "call_oo_sheet_format", "traceId": "trace_oo_sheet_format", "idempotencyKey": "idem_oo_sheet_format", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "address": "B2:C3", "bold": true, "fillColor": "#FFE599", "fontColor": "#CC0000", "horizontalAlign": "center", "numberFormat": "0.00" } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "sheet.format_range"); assert_eq!(payload["result"]["payload"]["address"], "B2:C3"); assert_eq!(payload["result"]["payload"]["bold"], true); assert_eq!(payload["result"]["payload"]["fillColor"], "#FFE599"); assert_eq!(payload["result"]["payload"]["fontColor"], "#CC0000"); assert_eq!(payload["result"]["payload"]["horizontalAlign"], "center"); assert_eq!(payload["result"]["payload"]["numberFormat"], "0.00"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_sheet_dimensions_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.sheet.set_dimensions", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_sheet_dimensions", "runId": "run_oo_sheet_dimensions", "toolCallId": "call_oo_sheet_dimensions", "traceId": "trace_oo_sheet_dimensions", "idempotencyKey": "idem_oo_sheet_dimensions", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "columnIndex": 2, "columnWidth": 24, "rowIndex": 3, "rowHeight": 28 } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "sheet.set_dimensions"); assert_eq!(payload["result"]["payload"]["columnIndex"], 2); assert_eq!(payload["result"]["payload"]["columnWidth"], 24.0); assert_eq!(payload["result"]["payload"]["rowIndex"], 3); assert_eq!(payload["result"]["payload"]["rowHeight"], 28.0); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_delete_slide_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.presentation.delete_slide", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_ppt_delete", "runId": "run_oo_ppt_delete", "toolCallId": "call_oo_ppt_delete", "traceId": "trace_oo_ppt_delete", "idempotencyKey": "idem_oo_ppt_delete", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "slideIndex": 1 } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "presentation.delete_slide"); assert_eq!(payload["result"]["payload"]["slideIndex"], 1); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_sheet_sort_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.sheet.sort_range", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_sheet_sort", "runId": "run_oo_sheet_sort", "toolCallId": "call_oo_sheet_sort", "traceId": "trace_oo_sheet_sort", "idempotencyKey": "idem_oo_sheet_sort", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "address": "A1:B4", "keyColumn": 2, "order": "descending", "header": true } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "sheet.sort_range"); assert_eq!(payload["result"]["payload"]["address"], "A1:B4"); assert_eq!(payload["result"]["payload"]["keyColumn"], 2); assert_eq!(payload["result"]["payload"]["order"], "descending"); assert_eq!(payload["result"]["payload"]["header"], true); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_sheet_chart_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.sheet.add_chart", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_sheet_chart", "runId": "run_oo_sheet_chart", "toolCallId": "call_oo_sheet_chart", "traceId": "trace_oo_sheet_chart", "idempotencyKey": "idem_oo_sheet_chart", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "address": "A1:B4", "chartType": "bar", "widthMm": 110, "heightMm": 70 } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "sheet.add_chart"); assert_eq!(payload["result"]["payload"]["address"], "A1:B4"); assert_eq!(payload["result"]["payload"]["chartType"], "bar"); assert_eq!(payload["result"]["payload"]["widthMm"], 110.0); assert_eq!(payload["result"]["payload"]["heightMm"], 70.0); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_presentation_table_tool_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.presentation.add_table", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_ppt_table", "runId": "run_oo_ppt_table", "toolCallId": "call_oo_ppt_table", "traceId": "trace_oo_ppt_table", "idempotencyKey": "idem_oo_ppt_table", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "slideIndex": 0, "rows": 2, "cols": 2, "data": [["Metric", "Value"], ["Bridge", "PPT table"]] } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "presentation.add_table"); assert_eq!(payload["result"]["payload"]["slideIndex"], 0); assert_eq!(payload["result"]["payload"]["rows"], 2); assert_eq!(payload["result"]["payload"]["cols"], 2); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_presentation_clear_slide_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.presentation.clear_slide", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_ppt_clear", "runId": "run_oo_ppt_clear", "toolCallId": "call_oo_ppt_clear", "traceId": "trace_oo_ppt_clear", "idempotencyKey": "idem_oo_ppt_clear", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "slideIndex": 2 } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "presentation.clear_slide"); assert_eq!(payload["result"]["payload"]["slideIndex"], 2); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_onlyoffice_presentation_shape_supports_dry_run_plan() { let payload = call_tool_ok(json!({ "toolName": "mnote.onlyoffice.presentation.add_shape", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_1", "sessionId": "sess_oo_ppt_shape_add", "runId": "run_oo_ppt_shape_add", "toolCallId": "call_oo_ppt_shape_add", "traceId": "trace_oo_ppt_shape_add", "idempotencyKey": "idem_oo_ppt_shape_add", "dryRun": true, "capabilityScope": ["office.write"], "args": { "onlyofficeSessionId": "mnote-oo-dry-run", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["mnote-oo-dry-run"] }, "slideIndex": 1, "shapeType": "roundRect", "text": "Bridge shape", "fillColor": "#4F81BD", "widthMm": 150, "heightMm": 60 } })) .await; assert_eq!( payload["result"]["schema"], "mnote.onlyoffice.action_plan.v1" ); assert_eq!(payload["result"]["action"], "presentation.add_shape"); assert_eq!(payload["result"]["payload"]["slideIndex"], 1); assert_eq!(payload["result"]["payload"]["shapeType"], "roundRect"); assert_eq!(payload["result"]["payload"]["text"], "Bridge shape"); assert_eq!(payload["result"]["payload"]["fillColor"], "#4F81BD"); assert_eq!(payload["audit"]["effect"], "dry_run"); } #[tokio::test] async fn mnote_tools_manifest_describes_markdown_edit_write_contract() { let response = app() .oneshot( Request::builder() .uri("/api/mnote/tools/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 mnote_tools_manifest_describes_onlyoffice_live_scope() { let response = app() .oneshot( Request::builder() .uri("/api/mnote/tools/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 write_tool = tools .iter() .find(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_value") .expect("onlyoffice write tool"); let schema = &write_tool["inputSchema"]; let required = schema["required"].as_array().expect("required"); assert!(required.iter().any(|value| value == "aiAccessScope")); assert!( schema["properties"]["aiAccessScope"]["properties"]["allowedResourceIds"].is_object() ); assert_eq!( schema["properties"]["aiAccessScope"]["properties"]["allowedResourceIds"]["minItems"], 1 ); let any_of = schema["anyOf"].as_array().expect("anyOf"); assert!(any_of .iter() .any(|rule| rule["required"] == json!(["onlyofficeSessionId"]))); assert!(any_of .iter() .any(|rule| rule["required"] == json!(["bridgeSessionId"]))); } #[tokio::test] async fn mnote_tools_manifest_marks_write_tools_as_compat_fallbacks() { let response = app() .oneshot( Request::builder() .uri("/api/mnote/tools/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("compat")); 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 mnote_tools_manifest_describes_block_tools_selection_scope() { let response = app() .oneshot( Request::builder() .uri("/api/mnote/tools/manifest") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let tools = payload["manifest"]["tools"].as_array().expect("tools"); for name in [ "mnote.block.replace", "mnote.block.insert_after", "mnote.block.delete", "mnote.block.move_after", ] { let tool = tools .iter() .find(|tool| tool["name"] == name) .unwrap_or_else(|| panic!("missing tool {name}")); assert!( tool["inputSchema"]["properties"]["allowedTargetBlockIds"].is_object(), "{name}" ); } } #[tokio::test] async fn mnote_tools_page_get_requires_auth() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_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/mnote/tools/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 mnote_tools_context_read_current_page_reads_local_markdown() { let root = std::env::temp_dir().join(format!( "mnote-context-read-current-page-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).expect("root"); fs::write( root.join("README.md"), "# 当前页\n\n来自 mnote.context.read_current_page 的正文。\n", ) .expect("markdown"); 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/mnote/tools/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.context.read_current_page", "workspaceId": "local-ws-context", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "profile": "pi", "actorId": "user_1", "sessionId": "sess_context_read", "runId": "run_context_read", "toolCallId": "call_context_read", "traceId": "trace_context_read", "args": { "format": "markdown", "contextRefs": [{ "kind": "current_page" }], "aiAccessScope": { "permissionLevel": "read_write", "allowedRoots": [{ "rootUri": root_uri, "permission": "write" }], "allowedResourceIds": ["local-md:README.md"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{payload}"); assert_eq!(payload["ok"], true); assert_eq!(payload["result"]["ok"], true); assert!( payload["result"]["content"] .as_str() .unwrap_or_default() .contains("mnote.context.read_current_page"), "{payload}" ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_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/mnote/tools/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 mnote_tools_mindmap_fetch_reads_default_envelope() { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-envelope-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("maps")).expect("maps"); fs::write( root.join("maps").join("default.mindmap.json"), json!({ "data": { "children": [ { "data": { "expand": true, "isActive": false, "text": "分支", "uid": "node_1" }, "children": [] } ], "data": { "expand": true, "isActive": false, "text": "KMIND", "uid": "root" } }, "view": { "state": { "scale": 1, "sx": 0, "sy": 0, "x": 0, "y": 0 }, "transform": { "a": 1, "b": 0, "c": 0, "d": 1, "e": 0, "f": 0 } } }) .to_string(), ) .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/mnote/tools/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_envelope", "runId": "run_mindmap_envelope", "toolCallId": "call_mindmap_envelope", "traceId": "trace_mindmap_envelope", "args": { "mindmapId": "mind_envelope", "resourcePath": "maps/default.mindmap.json", "scope": "full_envelope", "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["mind_envelope"] } } }) .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"]["root"]["data"]["text"], "KMIND"); assert_eq!(payload["result"]["nodes"][0]["id"], "root"); assert_eq!(payload["result"]["nodes"][1]["text"], "分支"); assert_eq!(payload["result"]["envelope"]["data"]["data"]["uid"], "root"); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_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/mnote/tools/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 mnote_tools_mindmap_apply_ops_writes_and_preserves_envelope_fields() { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-apply-{}", 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"), serde_json::to_string_pretty(&json!({ "data": { "data": { "text": "中心主题", "uid": "root", "customRootField": "keep-root" }, "children": [ { "data": { "text": "旧分支", "uid": "node_1", "style": { "color": "red" } }, "children": [] } ] }, "view": { "state": { "scale": 2 } }, "unknownTop": { "keep": true } })) .expect("json"), ) .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/mnote/tools/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_apply", "runId": "run_mindmap_apply", "toolCallId": "call_mindmap_apply", "traceId": "trace_mindmap_apply", "idempotencyKey": "idem_mindmap_apply", "dryRun": false, "args": { "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "ops": [ { "op": "updateText", "nodeId": "node_1", "text": "新分支" }, { "op": "insertChild", "parentId": "root", "node": { "id": "node_new", "text": "新子节点", "metadata": { "sourceRefs": [{ "page": 1 }] } } } ], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{payload}"); assert_eq!( payload["result"]["root"]["children"][0]["data"]["text"], "新分支" ); assert_eq!( payload["result"]["root"]["children"][1]["data"]["text"], "新子节点" ); assert!(payload["result"]["changedFiles"] .as_array() .expect("changed files") .iter() .any(|value| value == "maps/idea.mindmap.json")); let written = fs::read_to_string(root.join("maps").join("idea.mindmap.json")) .expect("written mindmap"); let written: Value = serde_json::from_str(&written).expect("written json"); assert_eq!(written["data"]["children"][0]["data"]["text"], "新分支"); assert_eq!( written["data"]["children"][0]["data"]["style"]["color"], "red" ); assert_eq!(written["data"]["children"][1]["data"]["uid"], "node_new"); assert_eq!(written["view"]["state"]["scale"], 2); assert_eq!(written["unknownTop"]["keep"], true); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_apply_ops_rejects_stale_revision() { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-conflict-{}", 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":{"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/mnote/tools/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_conflict", "runId": "run_mindmap_conflict", "toolCallId": "call_mindmap_conflict", "traceId": "trace_mindmap_conflict", "idempotencyKey": "idem_mindmap_conflict", "dryRun": false, "args": { "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "expectedRevision": "stale-revision", "ops": [{ "op": "updateText", "nodeId": "root", "text": "改名" }], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } } }) .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_resource_revision_conflict") ); let written = fs::read_to_string(root.join("maps").join("idea.mindmap.json")) .expect("written mindmap"); assert!(written.contains("中心主题")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_apply_ops_deletes_child_node() { let (root, root_uri) = write_mindmap_apply_fixture( "delete-node", json!({ "data": { "data": { "text": "中心主题", "uid": "root" }, "children": [ { "data": { "text": "保留节点", "uid": "keep" }, "children": [] }, { "data": { "text": "删除节点", "uid": "remove_me" }, "children": [] } ] } }), ); let response = post_mindmap_apply_ops( &root_uri, false, json!({ "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "ops": [{ "op": "deleteNode", "nodeId": "remove_me" }], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } }), "delete_node", ) .await; let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{payload}"); assert_eq!( payload["result"]["root"]["children"] .as_array() .unwrap() .len(), 1 ); assert_eq!( payload["result"]["root"]["children"][0]["data"]["uid"], "keep" ); let written = fs::read_to_string(root.join("maps").join("idea.mindmap.json")).expect("written"); assert!(written.contains("保留节点")); assert!(!written.contains("删除节点")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_apply_ops_accepts_common_aliases() { let (root, root_uri) = write_mindmap_apply_fixture( "aliases", json!({ "data": { "data": { "text": "中心主题", "uid": "root" }, "children": [ { "data": { "text": "旧标题", "uid": "child_1" }, "children": [] } ] } }), ); let response = post_mindmap_apply_ops( &root_uri, false, json!({ "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "ops": [ { "type": "update_node", "id": "child_1", "title": "别名更新" }, { "action": "add-child", "nodeId": "root", "id": "alias_child", "title": "别名新增" } ], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } }), "aliases", ) .await; let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{payload}"); assert_eq!( payload["result"]["root"]["children"][0]["data"]["text"], "别名更新" ); assert_eq!( payload["result"]["root"]["children"][1]["data"]["uid"], "alias_child" ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_apply_ops_dry_run_returns_diff_without_writing() { let (root, root_uri) = write_mindmap_apply_fixture( "dry-run", json!({ "data": { "data": { "text": "中心主题", "uid": "root" }, "children": [] } }), ); let response = post_mindmap_apply_ops( &root_uri, true, json!({ "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "ops": [{ "op": "updateText", "nodeId": "root", "text": "dry-run 改名" }], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } }), "dry_run", ) .await; let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{payload}"); assert_eq!(payload["result"]["dryRun"], true); assert_eq!(payload["result"]["diff"][0]["op"], "mindmap.apply_ops"); let written = fs::read_to_string(root.join("maps").join("idea.mindmap.json")).expect("written"); assert!(written.contains("中心主题")); assert!(!written.contains("dry-run 改名")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_apply_ops_rejects_unsupported_op() { let (root, root_uri) = write_mindmap_apply_fixture( "unsupported-op", json!({ "data": { "data": { "text": "中心主题", "uid": "root" }, "children": [] } }), ); let response = post_mindmap_apply_ops( &root_uri, false, json!({ "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "ops": [{ "op": "moveNode", "nodeId": "root" }], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } }), "unsupported_op", ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_resource_op_unsupported") ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_apply_ops_rejects_invalid_ops_payload() { let (root, root_uri) = write_mindmap_apply_fixture( "invalid-ops", json!({ "data": { "data": { "text": "中心主题", "uid": "root" }, "children": [] } }), ); let response = post_mindmap_apply_ops( &root_uri, false, json!({ "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "ops": { "op": "updateText", "nodeId": "root", "text": "bad" }, "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } }), "invalid_ops", ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_resource_ops_invalid") ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_apply_ops_rejects_root_delete() { let (root, root_uri) = write_mindmap_apply_fixture( "root-delete", json!({ "data": { "data": { "text": "中心主题", "uid": "root" }, "children": [] } }), ); let response = post_mindmap_apply_ops( &root_uri, false, json!({ "mindmapId": "maps/idea.mindmap.json", "resourcePath": "maps/idea.mindmap.json", "ops": [{ "op": "deleteNode", "nodeId": "root" }], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/idea.mindmap.json"] } }), "root_delete", ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_resource_root_delete_forbidden") ); let written = fs::read_to_string(root.join("maps").join("idea.mindmap.json")).expect("written"); assert!(written.contains("中心主题")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_create_from_outline_writes_default_envelope() { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-create-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("maps")).expect("maps"); 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/mnote/tools/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.create_from_outline", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_mindmap_create", "runId": "run_mindmap_create", "toolCallId": "call_mindmap_create", "traceId": "trace_mindmap_create", "idempotencyKey": "idem_mindmap_create", "dryRun": false, "args": { "mindmapId": "maps/generated.mindmap.json", "resourcePath": "maps/generated.mindmap.json", "embedIntoPage": false, "title": "PDF 摘要导图", "outline": [ { "text": "章节一", "children": [ { "text": "要点 1", "children": [] } ] } ], "sourceRefs": [ { "kind": "pdf", "title": "source.pdf", "page": 1 } ], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/generated.mindmap.json"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{payload}"); assert_eq!(payload["result"]["resourceKind"], "mindmap"); assert_eq!(payload["result"]["root"]["data"]["text"], "PDF 摘要导图"); assert_eq!( payload["result"]["root"]["children"][0]["data"]["text"], "章节一" ); assert_eq!(payload["result"]["envelope"]["data"]["data"]["uid"], "root"); assert!(root.join("maps").join("generated.mindmap.json").exists()); let written = fs::read_to_string(root.join("maps").join("generated.mindmap.json")) .expect("written mindmap"); let written: Value = serde_json::from_str(&written).expect("written json"); assert_eq!(written["data"]["data"]["text"], "PDF 摘要导图"); assert_eq!(written["data"]["children"][0]["data"]["text"], "章节一"); assert!(written["view"]["transform"].is_object()); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_mindmap_create_from_outline_can_embed_into_local_markdown_page() { let root = std::env::temp_dir().join(format!( "mnote-resource-mindmap-create-embed-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("maps")).expect("maps"); fs::write(root.join("README.md"), "# README\n").expect("markdown"); 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/mnote/tools/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.create_from_outline", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_mindmap_create_embed", "runId": "run_mindmap_create_embed", "toolCallId": "call_mindmap_create_embed", "traceId": "trace_mindmap_create_embed", "idempotencyKey": "idem_mindmap_create_embed", "dryRun": false, "args": { "mindmapId": "maps/generated-embed.mindmap.json", "resourcePath": "maps/generated-embed.mindmap.json", "title": "PDF 摘要导图", "outline": [ { "text": "章节一", "children": [] } ], "embedIntoPage": true, "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["maps/generated-embed.mindmap.json"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{payload}"); assert_eq!(payload["result"]["embedResult"]["status"], "embedded"); assert!(payload["result"]["changedFiles"] .as_array() .expect("changed files") .iter() .any(|value| value == "README.md")); let markdown = fs::read_to_string(root.join("README.md")).expect("markdown"); assert!(markdown.contains("[PDF 摘要导图](maps/generated-embed.mindmap.json)")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_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/mnote/tools/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/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.office.propose_changes", "workspaceId": "local-ws-resource", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_office_propose", "runId": "run_office_propose", "toolCallId": "call_office_propose", "traceId": "trace_office_propose", "args": { "assetId": "asset_report", "resourcePath": "office/report.docx", "instructions": "补充结论", "aiAccessScope": { "permissionLevel": "shared_read", "allowedResourceIds": ["asset_report"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(propose.status(), StatusCode::OK); let propose_body = to_bytes(propose.into_body(), usize::MAX) .await .expect("propose body"); let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json"); assert_eq!(propose_payload["audit"]["effect"], "read"); assert_eq!(propose_payload["result"]["writesBinary"], false); assert_eq!( fs::read(root.join("office").join("report.docx")).expect("after"), before ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_page_get_accepts_delegated_actor_from_payload() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .body(Body::from( json!({ "toolName": "mnote.page.get", "workspaceId": "ws_demo", "documentId": "doc_1", "actorId": "user_agent_delegate", "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_agent_delegate"); assert_eq!(payload["result"]["title"], "服务端页面"); } #[tokio::test] async fn mnote_tools_call_rejects_profile_disabled_tool() { let _guard = env_lock().lock().expect("env lock"); let agent_home = std::env::temp_dir().join(format!( "mnote-web-agent-tool-disabled-{}", std::process::id() )); let _ = fs::remove_dir_all(&agent_home); let profile_dir = agent_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("MNOTE_AGENT_HOME", &agent_home); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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("MNOTE_AGENT_HOME"); let _ = fs::remove_dir_all(&agent_home); } #[tokio::test] async fn mnote_tools_write_tools_require_auth() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_tools_page_get_returns_page_aggregate_summary() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_tools_doc_fetch_returns_block_projection() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_tools_doc_fetch_rejects_out_of_scope_ai_resource() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_tools_page_get_rejects_out_of_scope_ai_resource() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_tools_doc_fetch_supports_selection_and_page_xml() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.doc.fetch", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_doc_fetch_selection_1", "traceId": "trace_doc_fetch_selection_1", "capabilityScope": ["page.read"], "args": { "scope": "selection", "selectedBlockIds": ["heading_1"], "format": "page_xml" } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["schema"], "mnote.page_ai_context.v1"); assert_eq!(payload["result"]["scope"], "selection"); assert_eq!(payload["result"]["format"], "page_xml"); assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1); assert!(payload["result"]["content"] .as_str() .unwrap_or_default() .contains(">(); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.block.insert_after", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_multi_insert_limit", "runId": "run_multi_insert_limit", "toolCallId": "call_multi_insert_limit", "traceId": "trace_multi_insert_limit", "idempotencyKey": "idem_multi_insert_limit", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_tools_block_write_requires_fresh_revision_and_block_ref() { let missing_revision_response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.block.replace", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_missing_revision_1", "traceId": "trace_missing_revision_1", "idempotencyKey": "idem_missing_revision_1", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "aiAccessScope": { "permissionLevel": "read_write" }, "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/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "toolName": "mnote.block.replace", "workspaceId": "ws_demo", "documentId": "doc_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_stale_ref_1", "traceId": "trace_stale_ref_1", "idempotencyKey": "idem_stale_ref_1", "dryRun": false, "capabilityScope": ["block.write", "page.write"], "args": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_tools_apply_block_ops_requires_page_preconditions() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "revision": 7, "conflictDetectionKey": "doc_1:7", "operations": [{ "op": "replace", "blockId": "p_2", "content": "不应写入" }] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(missing_block_ref_response.status(), StatusCode::BAD_REQUEST); assert_eq!( missing_block_ref_response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_tool_write_precondition_required") ); } #[tokio::test] async fn mnote_tools_legacy_docs_search_is_retired() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "toolName": "docs_search", "workspaceId": "local-ws-docs-search", "sourceKind": "local_folder", "rootUri": "file:///tmp/mnote-retired-docs-search", "sessionId": "sess_docs_search", "runId": "run_docs_search", "toolCallId": "call_docs_search", "traceId": "trace_docs_search", "args": { "query": "compat-evidence-token", "includeOcr": true, "limit": 5 } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::GONE); 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"].as_str(), Some("mnote_evidence_tools_retired") ); } #[tokio::test] async fn mnote_tools_local_index_tools_are_retired() { let root = std::env::temp_dir().join(format!("mnote-index-tool-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join("docs")).expect("docs"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-index-tool","ownerId":"user_1","createdAt":"2026-06-04T00:00:00Z","capabilities":["local_files","search"]}"#, ) .expect("manifest"); fs::write(root.join("docs").join("indexed.md"), "# Indexed\n").expect("markdown"); let root_uri = format!("file://{}", root.display()); let app = app(); let update_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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.index.update_settings", "workspaceId": "local-ws-index-tool", "sourceKind": "local_folder", "rootUri": root_uri, "sessionId": "sess_index_tool", "runId": "run_index_tool", "toolCallId": "call_index_update", "traceId": "trace_index_tool", "dryRun": false, "idempotencyKey": "idem_index_tool_update", "args": { "includePaths": ["docs"], "scheduleMode": "manual", "scheduleTime": "02:00", "runOnChange": false } }) .to_string(), )) .expect("update request"), ) .await .expect("update response"); assert_eq!(update_response.status(), StatusCode::GONE); let update_body = to_bytes(update_response.into_body(), usize::MAX) .await .expect("update body"); let update_payload: Value = serde_json::from_slice(&update_body).expect("update json"); assert_eq!( update_payload["code"].as_str(), Some("mnote_index_tools_retired") ); assert!(!root.join(".mnote/index/search-index.json").exists()); let status_response = app .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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.index.status", "workspaceId": "local-ws-index-tool", "sourceKind": "local_folder", "rootUri": root_uri, "sessionId": "sess_index_tool", "runId": "run_index_tool", "toolCallId": "call_index_status", "traceId": "trace_index_tool", "args": {} }) .to_string(), )) .expect("status request"), ) .await .expect("status response"); assert_eq!(status_response.status(), StatusCode::GONE); let status_body = to_bytes(status_response.into_body(), usize::MAX) .await .expect("status body"); let status_payload: Value = serde_json::from_slice(&status_body).expect("status json"); assert_eq!( status_payload["code"].as_str(), Some("mnote_index_tools_retired") ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_legacy_docs_read_is_retired() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "toolName": "docs_read", "workspaceId": "local-ws-docs-read", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": "file:///tmp/mnote-retired-docs-read", "sessionId": "sess_docs_read", "runId": "run_docs_read", "toolCallId": "call_docs_read", "traceId": "trace_docs_read", "args": { "documentId": "local-md:README.md", "includeContent": true } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::GONE); 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"].as_str(), Some("mnote_evidence_tools_retired") ); } #[tokio::test] async fn mnote_tools_reject_workspace_context_conflict_without_content_leak() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_tools_write_tools_require_idempotency_and_dry_run_flag() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 mnote_tools_page_save_local_folder_writes_markdown_file() { let _env_guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-page-save-local-folder-{}", std::process::id() )); let audit_dir = std::env::temp_dir().join(format!( "mnote-local-agent-audit-tool-write-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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 写入"}]} ], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["local-md:README.md"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert_eq!(status, StatusCode::OK, "{text}"); let payload: Value = serde_json::from_str(&text).expect("json"); assert_eq!(payload["result"]["source"], "local_folder"); assert_eq!(payload["result"]["commandName"], "page.body.write"); let saved = fs::read_to_string(root.join("README.md")).expect("read"); assert!(saved.contains("本地 page.save 写入"), "{saved}"); let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl"); let event = jsonl .lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| serde_json::from_str::(line).ok()) .find(|event| event["runId"] == "run_page_save_local") .expect("audit event"); assert_eq!(event["origin"], "mnote_tool"); assert_eq!(event["toolName"], "mnote.page.save"); assert_eq!(event["toolCallId"], "call_page_save_local"); assert_eq!(event["writeAttemptRejected"], false); assert_eq!(event["changedFiles"][0]["path"], "local-md:README.md"); assert_eq!(event["changedFiles"][0]["changeType"], "modified"); std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] async fn mnote_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() { let root = std::env::temp_dir().join(format!( "mnote-page-options-local-folder-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.page.update_options", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "sessionId": "sess_page_options_local", "runId": "run_page_options_local", "toolCallId": "call_page_options_local", "traceId": "trace_page_options_local", "idempotencyKey": "idem_page_options_local", "dryRun": false, "args": { "options": { "wideLayout": true, "showHeadingNumbers": true, "hideTitleHeader": false }, "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["local-md:README.md"] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert_eq!(status, StatusCode::OK, "{text}"); let payload: Value = serde_json::from_str(&text).expect("json"); assert_eq!(payload["result"]["source"], "local_folder"); assert_eq!( payload["result"]["commandName"], "page.layout.updateOptions" ); assert_eq!( payload["result"]["result"]["pageOptions"]["wideLayout"], true ); assert_eq!( payload["result"]["result"]["pageOptions"]["showHeadingNumbers"], true ); assert_eq!( payload["result"]["result"]["pageOptions"]["hideTitleHeader"], false ); assert!( !root.join(".mnote").join("page-options.json").exists(), "AI 页面设置工具不应继续写入 .mnote/page-options.json" ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_page_save_dry_run_returns_diff_without_write() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_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/mnote/tools/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 mnote_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"); fs::write(&root.join("page.md"), "第一段\n\n第二段\n").expect("write markdown"); 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/mnote/tools/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-dry-run", "documentId": "local-md:page.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_1", "runId": "run_1", "toolCallId": "call_1", "traceId": "trace_1", "idempotencyKey": "idem_markdown_local_1", "dryRun": true, "args": { "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["local-md:page.md"] }, "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); // dry-run 不得改盘 assert_eq!( fs::read_to_string(root.join("page.md")).expect("read markdown"), "第一段\n\n第二段\n" ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn mnote_tools_markdown_edit_shared_read_is_forbidden() { let _env_guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-markdown-edit-shared-read-{}", std::process::id() )); let audit_dir = std::env::temp_dir().join(format!( "mnote-local-agent-audit-shared-read-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "原文\n").expect("write markdown"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.doc.markdown_edit", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_shared_read_md", "runId": "run_shared_read_md", "toolCallId": "call_shared_read_md", "traceId": "trace_shared_read_md", "idempotencyKey": "idem_shared_read_md", "dryRun": false, "args": { "aiAccessScope": { "permissionLevel": "shared_read", "shareContext": {"shareId": "share_read_1"} }, "operations": [{"search": "原文", "replace": "不应写入"}] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("mnote_tool_shared_read_write_forbidden") ); assert_eq!( fs::read_to_string(root.join("README.md")).expect("read"), "原文\n" ); std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] async fn mnote_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/mnote/tools/call") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "toolName": "mnote.doc.markdown_edit", "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "actorId": "user_1", "sessionId": "sess_shared_read_md_audit", "runId": "run_shared_read_md_audit", "toolCallId": "call_shared_read_md_audit", "traceId": "trace_shared_read_md_audit", "idempotencyKey": "idem_shared_read_md_audit", "dryRun": false, "args": { "aiAccessScope": { "permissionLevel": "shared_read", "shareContext": {"shareId": "share_read_1"} }, "operations": [{"search": "原文", "replace": "不应写入"}] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::FORBIDDEN); let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl"); let event = jsonl .lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| serde_json::from_str::(line).ok()) .find(|event| event["runId"] == "run_shared_read_md_audit") .expect("audit event"); assert_eq!(event["runId"], "run_shared_read_md_audit"); assert_eq!(event["toolName"], "mnote.doc.markdown_edit"); assert_eq!(event["toolCallId"], "call_shared_read_md_audit"); assert_eq!(event["status"], "read_only_write_rejected"); assert_eq!(event["writeAttemptRejected"], true); assert_eq!( event["rejection"]["code"], "mnote_tool_shared_read_write_forbidden" ); assert!(event["changedFiles"].as_array().unwrap().is_empty()); assert_eq!( fs::read_to_string(root.join("README.md")).expect("read"), "原文\n" ); std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] async fn mnote_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/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "operations": [{"search": "第二段 ", "replace": "测试123"}] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["operationsApplied"], 1); // 确认没有泄露 apply_block_ops 错误 let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(!text.contains("mnote.doc.apply_block_ops operations 不能为空")); } #[tokio::test] async fn mnote_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping() { // 7-27 修复后:full_content 不再被拒绝。新行为:纯文本(无块注释)全部识别为新块, // 写入后与原有复杂块(如有)合并保留。 let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_tools_markdown_edit_maps_normalized_search_to_block() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": true, "args": { "aiAccessScope": { "permissionLevel": "read_write" }, "operations": [{"search": "第二 段", "replace": "测试123"}] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&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"]["diff"] .as_array() .expect("diff"); assert!(!changed.is_empty(), "diff should not be empty"); assert_eq!( payload["result"]["applyResult"]["diff"][0]["blockId"], "p_2" ); assert_eq!(payload["result"]["applyResult"]["diff"][0]["op"], "replace"); } #[tokio::test] async fn mnote_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_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/mnote/tools/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 修改"}], "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["local-md:README.md"] } } }) .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 mnote_tools_markdown_edit_rejects_no_applied_operations() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_tools_markdown_edit_rejects_selection_out_of_scope() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_tools_markdown_edit_merges_same_block_operations() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "operations": [ {"search": "二", "replace": "2"}, {"search": "2段", "replace": "2段落"} ] } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["operationsApplied"], 2); let diff = payload["result"]["applyResult"]["diff"] .as_array() .expect("diff"); assert_eq!(diff.len(), 1); assert_eq!(diff[0]["blockId"], "p_2"); // dryRun 时文本不落盘,但 diff 记录预期修改后的内容 assert!(diff[0]["content"].as_str().unwrap_or("").contains("2段落")); } #[tokio::test] async fn mnote_tools_update_options_dry_run_filters_unwired_fields() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_tools_artifact_dry_run_returns_artifact_plan() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/mnote/tools/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": { "aiAccessScope": { "permissionLevel": "read_write" }, "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 mnote_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/mnote/tools/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": "本地摘要", "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": ["local-md:README.md"] } } }) .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); } }