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:
lix-2026
2026-05-19 08:07:17 +08:00
parent a2cb1338c8
commit 68d321e297
36 changed files with 8643 additions and 235 deletions
+202 -20
View File
@@ -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(),
},