This commit is contained in:
lix-2026
2026-05-16 12:34:48 +08:00
parent f9336257a5
commit d8bfaea306
17 changed files with 2817 additions and 543 deletions
@@ -1,7 +1,9 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::execute_convex_query_by_name;
use axum::body::Body;
use axum::extract::{Extension, Path, Query};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
use axum::Json;
@@ -56,6 +58,8 @@ struct HermesQueuedRun {
profile: String,
document_id: String,
trace_id: String,
actor_id: String,
actor_type: String,
input: String,
context_summary: Value,
queued_at: u128,
@@ -450,10 +454,13 @@ async fn load_session_from_hermes_cli(session_id: &str) -> Option<Value> {
}
pub async fn create_run(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
Json(mut payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let (actor_id, actor_type) = resolve_run_actor(&state, &context).await;
stamp_run_actor(&mut payload, &actor_id, &actor_type);
let registration = run_registration_from_payload(&context, &payload);
if session_has_active_run(&registration.session_id) {
let queued = enqueue_run(&context, &registration, &payload)?;
@@ -1553,14 +1560,14 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
"workspaceId": workspace_id,
"documentId": document_id,
"profile": profile.unwrap_or("default"),
"actorId": context.auth.actor_id,
"actorType": context.auth.actor_type,
"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),
"sessionId": session_id,
"traceId": trace_id,
"toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。不要只依据 pageContext 猜测。",
"toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。写入正文时如需追加使用 mnote_page_save mode=append,覆盖全文才使用 mode=replace。不要只依据 pageContext 猜测。",
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
"pageContext": page_context
"pageContext": sanitize_run_page_context(page_context)
})
.to_string();
@@ -1582,6 +1589,87 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
Ok(body)
}
fn effective_run_actor(state: &AppState, context: &RequestContext) -> (String, String) {
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
return (actor_id.to_string(), context.auth.actor_type.clone());
}
if context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
return (state.config().dev_user_id.clone(), "devFallback".into());
}
("anonymous".into(), "anonymous".into())
}
async fn resolve_run_actor(state: &AppState, context: &RequestContext) -> (String, String) {
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
return (actor_id.to_string(), context.auth.actor_type.clone());
}
if context.auth.authorization.is_none() && context.auth.cookie_header.is_none() {
return ("anonymous".into(), "anonymous".into());
}
if let Ok(Some(user_id)) = resolve_current_convex_user_id(state, context).await {
return (user_id, "user".into());
}
effective_run_actor(state, context)
}
async fn resolve_current_convex_user_id(
state: &AppState,
context: &RequestContext,
) -> Result<Option<String>, WebError> {
let payload = execute_convex_query_by_name(
state.config(),
context,
"users:currentUser",
json!({}),
None,
"hermes_run_current_user",
)
.await?;
Ok(payload
.get("_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned))
}
fn stamp_run_actor(payload: &mut Value, actor_id: &str, actor_type: &str) {
let Value::Object(map) = payload else {
return;
};
map.insert("actorId".into(), Value::String(actor_id.to_string()));
map.insert("actorType".into(), Value::String(actor_type.to_string()));
}
fn sanitize_run_page_context(page_context: Value) -> Value {
let Some(source) = page_context.as_object() else {
return Value::Null;
};
let mut sanitized = serde_json::Map::new();
for key in [
"contextScope",
"node",
"pageSubtreeSource",
"evidence",
"pageOptions",
"contentAccess",
] {
if let Some(value) = source.get(key) {
sanitized.insert(key.to_string(), value.clone());
}
}
sanitized.insert(
"contentAccess".to_string(),
sanitized
.get("contentAccess")
.cloned()
.unwrap_or_else(|| Value::String("mnote.page.get".into())),
);
Value::Object(sanitized)
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -2029,6 +2117,16 @@ fn enqueue_run(
profile: registration.profile.clone(),
document_id: registration.document_id.clone(),
trace_id: registration.trace_id.clone(),
actor_id: payload
.get("actorId")
.and_then(Value::as_str)
.unwrap_or("anonymous")
.to_string(),
actor_type: payload
.get("actorType")
.and_then(Value::as_str)
.unwrap_or("anonymous")
.to_string(),
input,
context_summary: queue_context_summary(payload),
queued_at: now_ms(),
@@ -2092,6 +2190,8 @@ fn queued_run_payload(queued: &HermesQueuedRun) -> Value {
"profile": queued.profile,
"message": queued.input,
"traceId": queued.trace_id,
"actorId": queued.actor_id,
"actorType": queued.actor_type,
"contextScope": queued.context_summary.get("contextScope").cloned().unwrap_or(Value::Null)
})
}
@@ -2330,6 +2430,28 @@ mod tests {
}))
}
fn test_state() -> AppState {
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: None,
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_client_gateway_health_reports_unconfigured_profile_settings() {
let _env_guard = env_lock().lock().expect("env lock");
@@ -3048,7 +3170,7 @@ mod tests {
}
#[test]
fn hermes_client_run_body_carries_page_context_into_run_input() {
fn hermes_client_run_body_carries_minimal_page_context_into_run_input() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/client/runs".parse().expect("uri"),
@@ -3061,7 +3183,14 @@ mod tests {
"documentId": "doc_1",
"sessionId": "sess_1",
"message": "概括当前页面",
"pageContext": {"title": "页面标题"},
"pageContext": {
"contextScope": "page",
"node": {"documentId": "doc_1", "title": "页面标题"},
"documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}],
"subtree": {"children": [{"title": "不应进入 Hermes instructions"}]},
"outline": [{"title": "不应进入 Hermes instructions"}],
"contentAccess": "mnote.page.get"
},
"selectedBlockId": "block_1",
"selectedText": "选中文本",
"traceId": "trace_1"
@@ -3075,6 +3204,33 @@ mod tests {
assert!(instructions.contains("\"documentId\":\"doc_1\""));
assert!(instructions.contains("\"title\":\"页面标题\""));
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\""));
assert!(!instructions.contains("不应进入 Hermes instructions"));
assert!(!instructions.contains("\"documentBlocks\""));
assert!(!instructions.contains("\"subtree\""));
assert!(!instructions.contains("\"outline\""));
}
#[test]
fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() {
let state = test_state();
let mut headers = HeaderMap::new();
headers.insert("cookie", "mnote_web_convex_token=token_1".parse().unwrap());
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/client/runs".parse().expect("uri"),
&headers,
);
let (actor_id, actor_type) = effective_run_actor(&state, &context);
assert_eq!(actor_id, "dev-user");
assert_eq!(actor_type, "devFallback");
let mut payload = json!({"message": "读取当前页面"});
stamp_run_actor(&mut payload, &actor_id, &actor_type);
let body = build_run_upstream_body(&context, payload).expect("body");
let instructions = body["instructions"].as_str().expect("instructions");
assert!(instructions.contains("\"actorId\":\"dev-user\""));
assert!(instructions.contains("\"actorType\":\"devFallback\""));
assert!(!instructions.contains("\"actorId\":\"anonymous\""));
}
#[test]
@@ -65,7 +65,7 @@ pub async fn mnote_call(
Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let context = authenticated_tool_context(&context, &input)?;
let trace_id = input
.effective_trace_id(&context.trace.trace_id)
.to_string();
@@ -373,6 +373,71 @@ fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}
fn authenticated_tool_context(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<RequestContext, WebError> {
if ensure_authenticated(context).is_ok() {
return Ok(context.clone());
}
let actor_id = input
.actor_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "anonymous")
.ok_or_else(|| {
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")
})?;
let has_run_identity = input
.session_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
&& input
.run_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
&& input
.tool_call_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
&& input
.trace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
if !has_run_identity {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"mnote_tool_unauthorized",
"mnote Hermes tool 委托调用缺少 sessionId/runId/toolCallId/traceId",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"));
}
let mut next = context.clone();
next.auth.actor_id = actor_id.to_string();
next.auth.actor_type = "user".into();
if next.auth.session_id.is_none() {
next.auth.session_id = input.session_id.clone();
}
Ok(next)
}
fn ensure_workspace_context(
context: &RequestContext,
input_workspace_id: Option<&str>,
@@ -493,6 +558,11 @@ mod tests {
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"));
let page_save = tools
.iter()
.find(|tool| tool["name"] == "mnote.page.save")
.expect("page save tool");
assert_eq!(page_save["status"], "available");
assert_eq!(
payload["manifest"]["schemaVersion"],
"mnote.hermes_tool_manifest.v1"
@@ -517,6 +587,41 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.body(Body::from(
json!({
"toolName": "mnote.page.get",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"actorId": "user_hermes",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"capabilityScope": ["page.read"]
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["audit"]["actorId"], "user_hermes");
assert_eq!(payload["result"]["title"], "服务端页面");
}
#[tokio::test]
async fn hermes_tools_write_tools_require_auth() {
let response = app()
+54 -4
View File
@@ -1607,12 +1607,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = toTiptapDocument(nextBody.content);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
return;
if (!contentChanged) return;
}
if (nextConflictKey === session.lastExternalConflictDetectionKey) return;
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, externalConflictMessage);
return;
@@ -1620,8 +1623,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = toTiptapDocument(nextBody.content);
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
session.lastPersistedSerialized = session.currentSerialized;
session.revision = nextRevision;
session.conflictDetectionKey = nextConflictKey;
@@ -1840,8 +1843,38 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
});
};
const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => {
if (!session) return false;
const doc = String(documentId || '').trim();
const workspace = String(workspaceId || '').trim();
if (doc && session.documentId !== doc) return false;
if (workspace && session.workspaceId && session.workspaceId !== workspace) return false;
return true;
};
const refreshDocumentSessionsFromExternalWrite = (detail, source) => {
const documentId = String(detail?.documentId || '').trim();
const workspaceId = String(detail?.workspaceId || '').trim();
let scheduled = 0;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, treeExternalConflictMessage);
return;
}
scheduled += 1;
scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write');
});
return scheduled;
};
window.addEventListener('tree:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange);
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
});
const createDocumentSession = (runtimeDescriptor) => {
const pageBody = runtimeDescriptor.aggregate.body || {};
@@ -2407,6 +2440,23 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
closeSecondaryPane(url instanceof URL ? url : currentUrl());
return true;
},
refreshDocument: async ({ documentId, workspaceId, source } = {}) => {
const targets = Array.from(documentSessionRegistry.values()).filter((session) => (
sessionMatchesDocumentWorkspace(session, documentId, workspaceId)
));
await Promise.all(targets.map((session) => refreshSessionFromExternalChange(
session,
source || 'mnote-web-programmatic-refresh',
)));
return targets.length;
},
refreshPrimaryDocument: async ({ documentId, workspaceId, source } = {}) => {
const primary = paneViewRegistry.get('primary');
if (!primary?.session) return 0;
if (!sessionMatchesDocumentWorkspace(primary.session, documentId, workspaceId)) return 0;
await refreshSessionFromExternalChange(primary.session, source || 'mnote-web-programmatic-refresh');
return 1;
},
};
window.addEventListener('pagehide', () => {