feat: advance local-first workspace checklist
- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
This commit is contained in:
@@ -3,10 +3,13 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::ensure_local_workspace_access;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn create_summary(
|
||||
state: &AppState,
|
||||
@@ -71,6 +74,85 @@ async fn create_artifact_node(
|
||||
format!("ai_note_{}_{}", document_id, context.trace.request_id)
|
||||
};
|
||||
|
||||
if input.effective_source_kind().as_deref() == Some("local_folder") {
|
||||
let root_uri = input.effective_root_uri().ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(context)
|
||||
})?;
|
||||
ensure_local_workspace_access(context, &root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(json!({
|
||||
"dryRun": true,
|
||||
"commandName": "tree.node.create",
|
||||
"commandId": command_id,
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"diff": [{"op": "create_artifact", "artifactType": node_type}]
|
||||
}));
|
||||
}
|
||||
let root_path = parse_local_root_path(&root_uri)?;
|
||||
let artifact_dir = root_path.join(".mnote").join("artifacts");
|
||||
fs::create_dir_all(&artifact_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_artifact_write_failed",
|
||||
format!(
|
||||
"无法创建本地 artifact 目录 {}: {error}",
|
||||
artifact_dir.display()
|
||||
),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let artifact_path = artifact_dir.join(format!(
|
||||
"{}.json",
|
||||
sanitize_local_artifact_file_name(&artifact_document_id)
|
||||
));
|
||||
let artifact_value = json!({
|
||||
"schema": "mnote.local_artifact.v1",
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"content": content,
|
||||
"createdAt": context.trace.trace_id,
|
||||
});
|
||||
fs::write(
|
||||
&artifact_path,
|
||||
serde_json::to_string_pretty(&artifact_value).map_err(|error| {
|
||||
WebError::internal(format!("本地 artifact 序列化失败: {error}"))
|
||||
.with_context(context)
|
||||
})?,
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_artifact_write_failed",
|
||||
format!(
|
||||
"无法写入本地 artifact 文件 {}: {error}",
|
||||
artifact_path.display()
|
||||
),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
return Ok(json!({
|
||||
"dryRun": false,
|
||||
"commandName": "tree.node.create",
|
||||
"commandId": command_id,
|
||||
"source": "local_folder",
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"source": "local_folder",
|
||||
"artifactPath": artifact_path,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(json!({
|
||||
"dryRun": true,
|
||||
@@ -166,6 +248,33 @@ async fn create_artifact_node(
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_local_root_path(root_uri: &str) -> Result<PathBuf, WebError> {
|
||||
let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") {
|
||||
stripped.trim()
|
||||
} else {
|
||||
root_uri.trim()
|
||||
};
|
||||
if root_path.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_required",
|
||||
"缺少本地文件夹 rootUri",
|
||||
));
|
||||
}
|
||||
Ok(PathBuf::from(root_path))
|
||||
}
|
||||
|
||||
fn sanitize_local_artifact_file_name(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
|
||||
@@ -707,6 +707,14 @@ pub(crate) fn ensure_write_contract(
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -1490,6 +1498,44 @@ fn build_insert_block(block_id: &str, value: &Value) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_write_contract_rejects_read_only_ai_scope() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/tools".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let input = ToolCallInput {
|
||||
tool_name: "mnote.block.replace".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-readonly".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(json!({
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "read_only",
|
||||
"allowedRoots": ["file:///tmp/mnote-readonly"]
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
let error = ensure_write_contract(&context, &input).expect_err("read only rejected");
|
||||
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
error.message(),
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_to_text_reads_projection_content_nodes() {
|
||||
let value = json!([
|
||||
|
||||
@@ -3,9 +3,38 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn file_version_from_aggregate(aggregate: &Value) -> Value {
|
||||
[
|
||||
"/body/fileVersion",
|
||||
"/body/file_version",
|
||||
"/body/conflictDetectionKey",
|
||||
"/body/conflict_detection_key",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pointer| {
|
||||
aggregate
|
||||
.pointer(pointer)
|
||||
.filter(|value| !value.is_null())
|
||||
.cloned()
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn conflict_detection_key_from_aggregate(aggregate: &Value) -> Option<&str> {
|
||||
[
|
||||
"/body/conflictDetectionKey",
|
||||
"/body/conflict_detection_key",
|
||||
"/body/fileVersion",
|
||||
"/body/file_version",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pointer| aggregate.pointer(pointer).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
pub async fn doc_fetch(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
@@ -13,16 +42,28 @@ pub async fn doc_fetch(
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id = input.effective_document_id().unwrap_or_default();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
|
||||
// 本地文件路径检测:直接读取 .md 文件,不经过 Convex
|
||||
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||
// 本地文件路径检测:直接读取授权 root 内的 .md 文件,不经过 Convex。
|
||||
let is_local_file = document_id.starts_with('/')
|
||||
|| document_id.starts_with("./")
|
||||
|| document_id.starts_with("file://");
|
||||
if is_local_file {
|
||||
use std::fs;
|
||||
let path = &document_id;
|
||||
let root_uri = local_root_uri_for_tool(input).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"ai_scope_root_uri_required",
|
||||
"本地文件读取需要授权 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let path = crate::routes::ensure_local_path_read_access(context, &root_uri, &document_id)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let content = fs::read_to_string(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("无法读取本地文件 {path}: {error}"),
|
||||
format!("无法读取本地文件: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
@@ -42,6 +83,7 @@ pub async fn doc_fetch(
|
||||
"source": "local_fs",
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"format": "markdown",
|
||||
"detail": "simple",
|
||||
"scope": "full",
|
||||
@@ -221,6 +263,7 @@ pub async fn doc_fetch(
|
||||
"source": source,
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"fileVersion": file_version_from_aggregate(&aggregate),
|
||||
"format": format,
|
||||
"detail": detail,
|
||||
"scope": scope,
|
||||
@@ -238,6 +281,9 @@ pub async fn doc_find(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
if let Some(document_id) = input.effective_document_id() {
|
||||
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
}
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let query = input.arg_string("query").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query")
|
||||
@@ -287,6 +333,7 @@ pub async fn doc_find(
|
||||
"workspaceId": input.effective_workspace_id(),
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"fileVersion": file_version_from_aggregate(&aggregate),
|
||||
"matches": matches
|
||||
}))
|
||||
}
|
||||
@@ -404,6 +451,7 @@ pub async fn plan_update(
|
||||
"workspaceId": input.effective_workspace_id(),
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"fileVersion": file_version_from_aggregate(&aggregate),
|
||||
"command": command,
|
||||
"diff": diff,
|
||||
"warnings": if plan_blocked {
|
||||
@@ -452,19 +500,81 @@ pub(crate) async fn aggregate_value(
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "页面工具缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let source_kind = input.effective_source_kind();
|
||||
let root_uri = input.effective_root_uri();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
state,
|
||||
context,
|
||||
&document_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
source_kind.as_deref(),
|
||||
root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_ai_scope_resource_allowed(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
document_id: &str,
|
||||
) -> Result<(), WebError> {
|
||||
let Some(scope) = input.arg_value("aiAccessScope") else {
|
||||
return Ok(());
|
||||
};
|
||||
let allowed = scope
|
||||
.get("allowedResourceIds")
|
||||
.or_else(|| scope.get("allowed_resource_ids"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if allowed.is_empty() || allowed.contains(document_id) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_read_forbidden",
|
||||
"当前 AI scope 不允许读取该资源",
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn local_root_uri_for_tool(input: &ToolCallInput) -> Option<String> {
|
||||
input.effective_root_uri().or_else(|| {
|
||||
input
|
||||
.arg_value("aiAccessScope")
|
||||
.and_then(|scope| {
|
||||
scope
|
||||
.get("allowedRoots")
|
||||
.or_else(|| scope.get("allowed_roots"))
|
||||
.cloned()
|
||||
})
|
||||
.and_then(|allowed_roots| {
|
||||
allowed_roots.as_array().and_then(|roots| {
|
||||
roots
|
||||
.iter()
|
||||
.filter_map(|root| {
|
||||
root.get("rootUri")
|
||||
.or_else(|| root.get("root_uri"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.find(|root_uri| !root_uri.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
|
||||
aggregate
|
||||
.pointer("/body/blockDocument/blocks")
|
||||
@@ -1377,7 +1487,11 @@ pub async fn doc_markdown_edit(
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id = input.effective_document_id().unwrap_or_default();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let source_kind = input.effective_source_kind();
|
||||
let root_uri = input.effective_root_uri();
|
||||
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||
let is_local_workspace =
|
||||
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
|
||||
crate::hermes_tools::block::ensure_write_contract(context, input)?;
|
||||
|
||||
// 1. 读取当前文档内容(markdown 形式)
|
||||
@@ -1400,7 +1514,14 @@ pub async fn doc_markdown_edit(
|
||||
} else {
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
(blocks_to_markdown(&blocks, true), "convex")
|
||||
(
|
||||
blocks_to_markdown(&blocks, true),
|
||||
if is_local_workspace {
|
||||
"local_folder"
|
||||
} else {
|
||||
"convex"
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
// 2. 解析 operations
|
||||
@@ -1483,6 +1604,14 @@ pub async fn doc_markdown_edit(
|
||||
}
|
||||
}
|
||||
|
||||
if applied == 0 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_markdown_edit_no_operations_applied",
|
||||
"markdown_edit 没有任何 search/replace 操作命中,未执行写入",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
|
||||
// 4. 构建 changedText 摘要
|
||||
let changed_text = if applied > 0 {
|
||||
operations
|
||||
@@ -1517,17 +1646,19 @@ pub async fn doc_markdown_edit(
|
||||
} else {
|
||||
// 7-27: 在线写回以最终 markdown 为真源,直接生成 block content
|
||||
// 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径
|
||||
let (blocks, original_content) = match aggregate_value(state, context, input).await {
|
||||
Ok(ref agg) => (
|
||||
block_projection_blocks(agg),
|
||||
crate::hermes_tools::block::current_body_content(agg),
|
||||
),
|
||||
Err(_) if use_full_content.is_some() => {
|
||||
// 空文档 + full_content:跳过读取
|
||||
(vec![], json!([]))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (aggregate, blocks, original_content) =
|
||||
match aggregate_value(state, context, input).await {
|
||||
Ok(agg) => {
|
||||
let blocks = block_projection_blocks(&agg);
|
||||
let original_content = crate::hermes_tools::block::current_body_content(&agg);
|
||||
(agg, blocks, original_content)
|
||||
}
|
||||
Err(_) if use_full_content.is_some() => {
|
||||
// 空文档 + full_content:跳过读取
|
||||
(Value::Null, vec![], json!([]))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
|
||||
let next_content = build_page_content(&original_content, &parsed);
|
||||
@@ -1549,11 +1680,62 @@ pub async fn doc_markdown_edit(
|
||||
} else {
|
||||
// 直接构造 RuntimeCommandEnvelopeWire(与 /api/documents/save 相同)
|
||||
let command_id = format!("markdown_edit_{}", context.trace.request_id);
|
||||
let file_version = file_version_from_aggregate(&aggregate);
|
||||
let conflict_detection_key = conflict_detection_key_from_aggregate(&aggregate);
|
||||
if is_local_workspace {
|
||||
let root_uri = root_uri.as_deref().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_root_required",
|
||||
"缺少本地文件夹 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::ensure_local_workspace_access(context, root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let expected_file_version = file_version
|
||||
.as_str()
|
||||
.or(conflict_detection_key)
|
||||
.map(|value| value.to_string());
|
||||
let result = crate::routes::write_local_markdown_page_body(
|
||||
&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.clone(),
|
||||
workspace_id: workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version,
|
||||
base_content_hash: None,
|
||||
content_format: "editorBlocks".into(),
|
||||
content: next_content,
|
||||
editor_source: Some("mnote.doc.markdown_edit".into()),
|
||||
},
|
||||
)?;
|
||||
return Ok(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.doc.markdown_edit.v1",
|
||||
"source": "local_folder",
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"operationsApplied": applied,
|
||||
"operationsFailed": failed.len(),
|
||||
"failedOperations": failed,
|
||||
"changedText": changed_text,
|
||||
"fileVersion": file_version,
|
||||
"applyResult": {
|
||||
"commandName": "page.body.write",
|
||||
"commandId": command_id,
|
||||
"changedBlocks": changed_blocks,
|
||||
"result": result
|
||||
}
|
||||
}));
|
||||
}
|
||||
let payload = json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"content": next_content,
|
||||
"mode": "replace",
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": conflict_detection_key.map(Value::from).unwrap_or(Value::Null),
|
||||
"fileVersion": file_version,
|
||||
});
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -1573,8 +1755,8 @@ pub async fn doc_markdown_edit(
|
||||
source: RuntimeSourceWire {
|
||||
channel: "mnote-hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
source_kind,
|
||||
root_uri,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -173,7 +173,7 @@ fn doc_plan_update_tool() -> Value {
|
||||
fn block_replace_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.block.replace",
|
||||
"替换指定块内容;真实写入走 Rust page.body.save 链路",
|
||||
"兼容块写工具:替换指定块内容;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,必要时再走 Rust page.body.write 兼容链路",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"blockId": { "type": "string" },
|
||||
@@ -394,7 +394,7 @@ fn page_get_tool() -> Value {
|
||||
fn page_save_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.page.save",
|
||||
"description": "保存当前页面正文;replace 覆盖正文,append/prepend 会先读取当前 Page Aggregate 后合成完整正文再保存",
|
||||
"description": "粗粒度兼容兜底:保存当前页面正文;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,只有整页覆盖/追加且其它工具无法表达时使用",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.write"],
|
||||
"status": "available",
|
||||
@@ -445,7 +445,7 @@ fn available_tool(
|
||||
fn doc_markdown_edit_tool() -> Value {
|
||||
let mut tool = write_tool(
|
||||
"mnote.doc.markdown_edit",
|
||||
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
|
||||
"兼容 / 远端代理 fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 默认优先让 agent 原生 patch/diff 直接编辑授权文件;仅在需要 MNote 兼容工具、远端代理或结构校验时使用。",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"operations": {
|
||||
@@ -489,6 +489,8 @@ fn tool_annotations(
|
||||
"readonly": readonly,
|
||||
"destructive": destructive,
|
||||
"idempotent": idempotent,
|
||||
"readOnly": readonly,
|
||||
"requiresWritePermission": !readonly,
|
||||
"requiresApproval": requires_approval,
|
||||
"approvalMode": if requires_approval { "review" } else { "yolo" },
|
||||
"runtimeOwner": "mnote-web",
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ToolCallInput {
|
||||
pub tool_name: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
@@ -58,6 +60,24 @@ impl ToolCallInput {
|
||||
.or_else(|| self.arg_string("documentId"))
|
||||
}
|
||||
|
||||
pub fn effective_source_kind(&self) -> Option<String> {
|
||||
self.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.arg_string("sourceKind"))
|
||||
}
|
||||
|
||||
pub fn effective_root_uri(&self) -> Option<String> {
|
||||
self.root_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.arg_string("rootUri"))
|
||||
}
|
||||
|
||||
pub fn effective_tool_call_id(&self) -> String {
|
||||
self.tool_call_id
|
||||
.as_deref()
|
||||
@@ -91,4 +111,37 @@ impl ToolCallInput {
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn ai_access_scope(&self) -> Option<&Value> {
|
||||
self.args.as_ref().and_then(|args| {
|
||||
args.get("aiAccessScope")
|
||||
.or_else(|| args.get("ai_access_scope"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ai_access_permission_level(&self) -> Option<String> {
|
||||
self.ai_access_scope()
|
||||
.and_then(|scope| {
|
||||
scope
|
||||
.get("permissionLevel")
|
||||
.or_else(|| scope.get("permission_level"))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn ai_access_scope_is_read_only(&self) -> bool {
|
||||
self.ai_access_permission_level()
|
||||
.map(|level| {
|
||||
let normalized = level.trim().to_ascii_lowercase();
|
||||
normalized == "read"
|
||||
|| normalized == "readonly"
|
||||
|| normalized == "read_only"
|
||||
|| normalized == "shared_read"
|
||||
|| (normalized.contains("read") && !normalized.contains("write"))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,17 @@ pub async fn page_get(
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let source_kind = input.effective_source_kind();
|
||||
let root_uri = input.effective_root_uri();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
state,
|
||||
context,
|
||||
&document_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
source_kind.as_deref(),
|
||||
root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let aggregate_value =
|
||||
@@ -83,6 +86,14 @@ fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Res
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -200,6 +211,74 @@ async fn page_command(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if input.effective_source_kind().as_deref() == Some("local_folder") {
|
||||
let root_uri = input.effective_root_uri().ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::ensure_local_workspace_access(context, &root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let local_result = match command_name {
|
||||
"page.body.save" => {
|
||||
let content = payload.get("content").cloned().unwrap_or(Value::Null);
|
||||
crate::routes::write_local_markdown_page_body(
|
||||
&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.clone(),
|
||||
workspace_id: workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.clone(),
|
||||
expected_file_version: input.arg_string("expectedFileVersion"),
|
||||
base_content_hash: input.arg_string("baseContentHash"),
|
||||
content_format: "editorBlocks".into(),
|
||||
content,
|
||||
editor_source: Some("mnote.page.save".into()),
|
||||
},
|
||||
)?
|
||||
}
|
||||
"page.head.updateTitle" => {
|
||||
let title = payload
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.page.update_title 缺少 title",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::update_local_markdown_title(&root_uri, &document_id, title)?
|
||||
}
|
||||
"page.layout.updateOptions" => {
|
||||
let options = payload.get("options").cloned().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.page.update_options 缺少 options",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::update_local_page_options(&root_uri, &document_id, &options)?
|
||||
}
|
||||
_ => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("local source 暂不支持页面命令 {command_name}"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
};
|
||||
let mut result = json!({
|
||||
"dryRun": false,
|
||||
"source": "local_folder",
|
||||
"commandName": if command_name == "page.body.save" { "page.body.write" } else { command_name },
|
||||
"commandId": command_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"result": local_result
|
||||
});
|
||||
merge_result_extra(&mut result, result_extra);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: command_name.into(),
|
||||
command_id: command_id.clone(),
|
||||
|
||||
Reference in New Issue
Block a user