推进资源工具与本地优先兼容链收口

This commit is contained in:
lix-2026
2026-05-19 12:03:43 +08:00
parent 94be1c927b
commit 6fcdf78603
13 changed files with 1192 additions and 30 deletions
@@ -2,7 +2,7 @@ use super::hermes_client;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput};
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
@@ -344,6 +344,10 @@ pub(crate) async fn execute_mnote_tool_call(
"mnote.page.save" => page::page_save(&state, &context, &input).await,
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
"mnote.page.update_options" => page::update_options(&state, &context, &input).await,
"mnote.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await,
"mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await,
"mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await,
"mnote.office.propose_changes" => resource::office_propose_changes(&context, &input).await,
"mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
"mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
_ => Err(
@@ -448,7 +452,13 @@ pub(crate) async fn execute_mnote_tool_call(
fn is_read_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch"
"mnote.page.get"
| "mnote.doc.fetch"
| "mnote.doc.find"
| "mnote.block.fetch"
| "mnote.mindmap.fetch"
| "mnote.office.fetch_summary"
| "mnote.office.propose_changes"
)
}
@@ -995,6 +1005,18 @@ mod tests {
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
let page_save = tools
.iter()
.find(|tool| tool["name"] == "mnote.page.save")
@@ -1148,6 +1170,324 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn hermes_tools_resource_tools_require_allowed_resource_scope() {
let root =
std::env::temp_dir().join(format!("mnote-resource-scope-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("maps")).expect("maps");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
)
.expect("mindmap");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.mindmap.fetch",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_resource_scope",
"runId": "run_resource_scope",
"toolCallId": "call_resource_scope",
"traceId": "trace_resource_scope",
"args": {
"mindmapId": "mind_allowed",
"resourcePath": "maps/idea.mindmap.json",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["mind_other"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_resource_ai_scope_forbidden")
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() {
let root = std::env::temp_dir().join(format!(
"mnote-resource-mindmap-fetch-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("maps")).expect("maps");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"data":{"text":"中心主题","uid":"root"},"children":[{"data":{"text":"分支一","uid":"child_1"},"children":[]}]}"#,
)
.expect("mindmap");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.mindmap.fetch",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_mindmap_fetch",
"runId": "run_mindmap_fetch",
"toolCallId": "call_mindmap_fetch",
"traceId": "trace_mindmap_fetch",
"args": {
"mindmapId": "mind_allowed",
"resourcePath": "maps/idea.mindmap.json",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["mind_allowed"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["toolName"], "mnote.mindmap.fetch");
assert_eq!(payload["audit"]["effect"], "read");
assert_eq!(payload["result"]["resourceKind"], "mindmap");
assert_eq!(
payload["result"]["objectIdentity"],
"resource:mindmap:local-md:README.md:mind_allowed"
);
assert!(payload["result"]["markdownSummary"]
.as_str()
.unwrap_or_default()
.contains("中心主题"));
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_mindmap_apply_ops_shared_read_is_forbidden() {
let root = std::env::temp_dir().join(format!(
"mnote-resource-mindmap-write-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("maps")).expect("maps");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
)
.expect("mindmap");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.mindmap.apply_ops",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_mindmap_write",
"runId": "run_mindmap_write",
"toolCallId": "call_mindmap_write",
"traceId": "trace_mindmap_write",
"idempotencyKey": "idem_mindmap_write",
"dryRun": false,
"args": {
"mindmapId": "mind_allowed",
"resourcePath": "maps/idea.mindmap.json",
"ops": [{"op": "update_node", "nodeId": "root", "text": "改名"}],
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["mind_allowed"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_shared_read_write_forbidden")
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_office_fetch_and_propose_changes_do_not_write_file() {
let root = std::env::temp_dir().join(format!(
"mnote-resource-office-fetch-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("office")).expect("office");
fs::write(root.join("office").join("report.docx"), "Office text").expect("office file");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let fetch = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.office.fetch_summary",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_office_fetch",
"runId": "run_office_fetch",
"toolCallId": "call_office_fetch",
"traceId": "trace_office_fetch",
"args": {
"assetId": "asset_report",
"resourcePath": "office/report.docx",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["resource:onlyoffice:local-md:README.md:asset_report"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(fetch.status(), StatusCode::OK);
let fetch_body = to_bytes(fetch.into_body(), usize::MAX)
.await
.expect("fetch body");
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("fetch json");
assert_eq!(fetch_payload["audit"]["effect"], "read");
assert_eq!(fetch_payload["result"]["resourceKind"], "only_office");
assert_eq!(fetch_payload["result"]["fileName"], "report.docx");
let before = fs::read(root.join("office").join("report.docx")).expect("before");
let propose = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.office.propose_changes",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_office_propose",
"runId": "run_office_propose",
"toolCallId": "call_office_propose",
"traceId": "trace_office_propose",
"args": {
"assetId": "asset_report",
"resourcePath": "office/report.docx",
"instructions": "补充结论",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["asset_report"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(propose.status(), StatusCode::OK);
let propose_body = to_bytes(propose.into_body(), usize::MAX)
.await
.expect("propose body");
let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json");
assert_eq!(propose_payload["audit"]["effect"], "read");
assert_eq!(propose_payload["result"]["writesBinary"], false);
assert_eq!(
fs::read(root.join("office").join("report.docx")).expect("after"),
before
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() {
let response = app()