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
@@ -276,19 +276,5 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
@@ -693,29 +693,7 @@ pub(crate) fn ensure_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
fn ensure_leaf_block(
@@ -375,6 +375,8 @@ fn page_get_tool() -> Value {
"description": "读取当前页面 Page Aggregate 摘要",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
@@ -5,6 +5,8 @@ pub mod manifest;
pub mod page;
pub mod resource;
use crate::context::RequestContext;
use crate::error::WebError;
use serde::Deserialize;
use serde_json::Value;
@@ -145,4 +147,163 @@ impl ToolCallInput {
})
.unwrap_or(false)
}
pub fn command_context_bridge(&self) -> Option<CommandContextBridge> {
let ctx = self.args.as_ref().and_then(|args| {
args.get("commandContext")
.or_else(|| args.get("command_context"))
})?;
let workspace_readonly = ctx
.get("workspace.readonly")
.or_else(|| ctx.get("workspaceReadonly"))
.or_else(|| ctx.get("workspace_readonly"))
.and_then(Value::as_bool)
.unwrap_or(false);
let ai_can_write = ctx
.get("ai.canWrite")
.or_else(|| ctx.get("aiCanWrite"))
.or_else(|| ctx.get("ai_can_write"))
.and_then(Value::as_bool)
.unwrap_or(true);
Some(CommandContextBridge {
workspace_readonly,
ai_can_write,
})
}
}
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
///
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
///
/// 设计意图:`CommandContext.ai.canWrite` / `workspace.readonly` 是从 tree/workspace/editor
/// 状态推导的写权限口径,与 `args.aiAccessScope.permissionLevel`AI 客户端声明的权限)是
/// 两个独立的信息源。桥接合并两者,任一拒绝则阻止写入。
#[derive(Debug, Clone, Copy)]
pub struct CommandContextBridge {
pub workspace_readonly: bool,
pub ai_can_write: bool,
}
/// 统一的 hermes_tools 写入守卫。检查:
///
/// - `idempotencyKey` 必须存在
/// - `dryRun` 必须显式携带
/// - `aiAccessScope.permissionLevel` 不是只读(来自 ToolCallInput
/// - 如果提供了 `bridge` 且 `ai_can_write == false`,拒绝
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝
///
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
pub fn ensure_write_authorized(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
if let Some(bridge) = input.command_context_bridge() {
if bridge.workspace_readonly {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_workspace_readonly",
"当前 workspace 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
if !bridge.ai_can_write {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_write_forbidden",
"CommandContext 标记 AI 写入未授权,禁止执行写入型 mnote tool",
)
.with_context(context));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, Method};
use serde_json::json;
fn context() -> RequestContext {
RequestContext::from_http_parts(
&Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&HeaderMap::new(),
)
}
fn write_input(args: Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.page.save".into(),
workspace_id: Some("ws_1".into()),
document_id: Some("doc_1".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote".into()),
actor_id: Some("user_1".into()),
profile: None,
session_id: Some("sess_1".into()),
run_id: Some("run_1".into()),
tool_call_id: Some("tool_1".into()),
trace_id: Some("trace_1".into()),
idempotency_key: Some("idem_1".into()),
dry_run: Some(false),
capability_scope: None,
args: Some(args),
}
}
#[test]
fn ensure_write_authorized_rejects_command_context_ai_cannot_write() {
let error = ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"ai.canWrite": false,
"workspace.readonly": false
}
})),
)
.expect_err("ai.canWrite=false should reject write tools");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
}
#[test]
fn ensure_write_authorized_rejects_command_context_readonly_workspace() {
let error = ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": true
}
})),
)
.expect_err("workspace.readonly=true should reject write tools");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_workspace_readonly");
}
}
+1 -23
View File
@@ -72,29 +72,7 @@ pub async fn page_get(
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
pub async fn page_save(
@@ -285,29 +285,7 @@ fn ensure_resource_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote resource tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote resource tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote resource tool",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {