advance 1-8 post-mvp execution batches

This commit is contained in:
lix-2026
2026-05-21 23:53:39 +08:00
parent 3ebcbff728
commit fdb20300e9
67 changed files with 4378 additions and 275 deletions
@@ -306,6 +306,23 @@ pub(crate) async fn execute_mnote_tool_call(
);
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,
@@ -449,6 +466,74 @@ pub(crate) async fn execute_mnote_tool_call(
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_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}
fn required_capability_scope(tool_name: &str) -> Vec<String> {
manifest::manifest()
.get("tools")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
.and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array))
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(normalize_capability_scope)
.filter(|value| !value.is_empty())
.collect()
}
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
let Some(declared) = declared else {
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
return true;
};
let declared = declared
.iter()
.map(|value| normalize_capability_scope(value))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
required.iter().all(|scope| {
declared
.iter()
.any(|candidate| capability_scope_satisfies(candidate, scope))
})
}
fn capability_scope_satisfies(candidate: &str, required: &str) -> bool {
if candidate == required {
return true;
}
required
.strip_suffix(".read")
.map(|prefix| format!("{prefix}.write"))
.as_deref()
== Some(candidate)
}
fn normalize_capability_scope(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn is_read_tool(tool_name: &str) -> bool {
matches!(
tool_name,
@@ -964,6 +1049,46 @@ mod tests {
.collect()
}
#[tokio::test]
async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.page.save",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_scope",
"runId": "run_scope",
"toolCallId": "call_scope",
"traceId": "trace_scope",
"actorId": "user_1",
"capabilityScope": ["page.read"],
"dryRun": true,
"idempotencyKey": "scope-mismatch",
"args": {"content": "不会写入"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_capability_scope_forbidden")
);
}
#[tokio::test]
async fn hermes_tools_manifest_returns_first_batch_tools() {
let response = app()