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