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
@@ -170,6 +170,31 @@ pub(crate) async fn execute_mnote_tool_call(
}));
return Err(error);
}
if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) {
let error = WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_shared_read_write_forbidden",
"共享只读 AI 上下文不能执行写工具",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
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(),
"permissionLevel": "shared_read"
}));
return Err(error);
}
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
info!(
trace_id = %trace_id,
@@ -301,6 +326,27 @@ fn is_read_tool(tool_name: &str) -> bool {
)
}
fn is_shared_read_scope(input: &ToolCallInput) -> bool {
let direct = input
.arg_string("permissionLevel")
.or_else(|| input.arg_string("permission_level"));
if matches!(direct.as_deref(), Some("shared_read")) {
return true;
}
input
.arg_value("aiAccessScope")
.and_then(|value| {
value
.get("permissionLevel")
.or_else(|| value.get("permission_level"))
.and_then(Value::as_str)
.map(str::trim)
.map(ToOwned::to_owned)
})
.as_deref()
== Some("shared_read")
}
fn audit_log() -> &'static Mutex<Vec<Value>> {
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
LOG.get_or_init(|| Mutex::new(Vec::new()))
@@ -882,6 +928,47 @@ mod tests {
.any(|rule| rule["required"] == json!(["full_content"])));
}
#[tokio::test]
async fn hermes_tools_manifest_marks_write_tools_as_compat_fallbacks() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/hermes/tools/mnote/manifest")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.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");
let tools = payload["manifest"]["tools"].as_array().expect("tools");
let markdown_edit = tools
.iter()
.find(|tool| tool["name"] == "mnote.doc.markdown_edit")
.expect("markdown edit tool");
let page_save = tools
.iter()
.find(|tool| tool["name"] == "mnote.page.save")
.expect("page save tool");
assert!(markdown_edit["description"]
.as_str()
.expect("description")
.contains("兼容"));
assert!(markdown_edit["description"]
.as_str()
.expect("description")
.contains("agent 原生 patch/diff"));
assert_eq!(
page_save["annotations"]["requiresWritePermission"],
Value::Bool(true)
);
}
#[tokio::test]
async fn hermes_tools_manifest_describes_block_tools_selection_scope() {
let response = app()
@@ -1129,6 +1216,8 @@ mod tests {
assert_eq!(payload["toolName"], "mnote.doc.fetch");
assert_eq!(payload["audit"]["effect"], "read");
assert_eq!(payload["result"]["revision"], json!(7));
assert_eq!(payload["result"]["conflictDetectionKey"], json!("doc_1:7"));
assert_eq!(payload["result"]["fileVersion"], json!("doc_1:7"));
assert_eq!(
payload["result"]["blocks"][0]["blockId"],
json!("heading_1")
@@ -1140,6 +1229,90 @@ mod tests {
.starts_with("pageRev:7:block:heading_1:hash:"));
}
#[tokio::test]
async fn hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource() {
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.doc.fetch",
"workspaceId": "ws_demo",
"documentId": "doc_2",
"sessionId": "sess_scope_read",
"runId": "run_scope_read",
"toolCallId": "call_scope_read",
"traceId": "trace_scope_read",
"args": {
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["doc_1"],
"shareContext": {"shareId": "share_read_1"}
}
}
})
.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_ai_scope_read_forbidden")
);
}
#[tokio::test]
async fn hermes_tools_page_get_rejects_out_of_scope_ai_resource() {
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.get",
"workspaceId": "ws_demo",
"documentId": "doc_2",
"sessionId": "sess_page_scope_read",
"runId": "run_page_scope_read",
"toolCallId": "call_page_scope_read",
"traceId": "trace_page_scope_read",
"args": {
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["doc_1"],
"shareContext": {"shareId": "share_read_1"}
}
}
})
.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_ai_scope_read_forbidden")
);
}
#[tokio::test]
async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() {
let response = app()
@@ -2116,6 +2289,70 @@ mod tests {
);
}
#[tokio::test]
async fn hermes_tools_page_save_local_folder_writes_markdown_file() {
let root = std::env::temp_dir().join(format!(
"mnote-page-save-local-folder-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
let root_uri = format!("file://{}", root.display());
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.page.save",
"workspaceId": "local-ws-user-1",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"sessionId": "sess_page_save_local",
"runId": "run_page_save_local",
"toolCallId": "call_page_save_local",
"traceId": "trace_page_save_local",
"idempotencyKey": "idem_page_save_local",
"dryRun": false,
"args": {
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
]
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert_eq!(status, StatusCode::OK, "{text}");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["result"]["source"], "local_folder");
assert_eq!(payload["result"]["commandName"], "page.body.write");
let saved = fs::read_to_string(root.join("README.md")).expect("read");
assert!(saved.contains("本地 page.save 写入"), "{saved}");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
let response = app()
@@ -2264,6 +2501,74 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_markdown_edit_shared_read_is_forbidden() {
let root = std::env::temp_dir().join(format!(
"mnote-markdown-edit-shared-read-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "原文\n").expect("write markdown");
let root_uri = format!("file://{}", root.display());
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.doc.markdown_edit",
"workspaceId": "local-ws-user-1",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_shared_read_md",
"runId": "run_shared_read_md",
"toolCallId": "call_shared_read_md",
"traceId": "trace_shared_read_md",
"idempotencyKey": "idem_shared_read_md",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "shared_read",
"shareContext": {"shareId": "share_read_1"}
},
"operations": [{"search": "原文", "replace": "不应写入"}]
}
})
.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")
);
assert_eq!(
fs::read_to_string(root.join("README.md")).expect("read"),
"原文\n"
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_markdown_edit_reports_empty_block_mapping_before_apply() {
// 7-27 修复后:即使 search 吃掉了块注释,也不会崩溃或泄露 apply_block_ops 错误。
@@ -2406,6 +2711,165 @@ mod tests {
);
}
#[tokio::test]
async fn hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() {
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.doc.markdown_edit",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_markdown_precondition",
"runId": "run_markdown_precondition",
"toolCallId": "call_markdown_precondition",
"traceId": "trace_markdown_precondition",
"idempotencyKey": "idem_markdown_precondition",
"dryRun": false,
"args": {
"operations": [{"search": "第二段", "replace": "测试123"}]
}
})
.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");
let command_payload =
&payload["result"]["applyResult"]["artifacts"]["commandLog"]["payload"];
assert_eq!(command_payload["revision"], 7);
assert_eq!(command_payload["conflictDetectionKey"], "doc_1:7");
}
#[tokio::test]
async fn hermes_tools_markdown_edit_local_folder_writes_same_markdown_file() {
let root = std::env::temp_dir().join(format!(
"mnote-markdown-edit-local-folder-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("README.assets")).expect("create asset dir");
fs::write(
root.join("README.md"),
"---\ntitle: AI Local\n---\n# AI Local\n\n第一段\n\n![图](README.assets/photo.png)\n",
)
.expect("write markdown");
fs::write(root.join("README.assets").join("photo.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("initialize 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.doc.markdown_edit",
"workspaceId": "local-ws-test",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_local_folder_md",
"runId": "run_local_folder_md",
"toolCallId": "call_local_folder_md",
"traceId": "trace_local_folder_md",
"idempotencyKey": "idem_local_folder_md",
"dryRun": false,
"args": {
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let headers = response.headers().clone();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
if status != StatusCode::OK {
panic!("status={status} headers={headers:?} body={text}");
}
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["source"], "local_folder");
assert_eq!(
payload["result"]["applyResult"]["commandName"],
"page.body.write"
);
let saved = fs::read_to_string(root.join("README.md")).expect("read markdown");
assert!(saved.contains("第一段已由 AI 修改"));
assert!(saved.contains("README.assets/photo.png"));
assert!(!saved.contains("/api/media"));
assert!(!saved.contains("assetId"));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_markdown_edit_rejects_no_applied_operations() {
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.doc.markdown_edit",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_markdown_noop",
"runId": "run_markdown_noop",
"toolCallId": "call_markdown_noop",
"traceId": "trace_markdown_noop",
"idempotencyKey": "idem_markdown_noop",
"dryRun": false,
"args": {
"operations": [{"search": "不存在的段落", "replace": "测试123"}]
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_markdown_edit_no_operations_applied")
);
}
#[tokio::test]
async fn hermes_tools_markdown_edit_rejects_selection_out_of_scope() {
let response = app()
@@ -2576,4 +3040,67 @@ mod tests {
assert_eq!(payload["result"]["dryRun"], true);
assert_eq!(payload["result"]["artifactType"], "summary");
}
#[tokio::test]
async fn hermes_tools_artifact_summary_local_folder_writes_sidecar_file() {
let root = std::env::temp_dir().join(format!(
"mnote-artifact-local-folder-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "# Local\n").expect("markdown");
let root_uri = format!("file://{}", root.display());
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.artifact.create_summary",
"workspaceId": "local-ws-user-1",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"sessionId": "sess_artifact_local",
"runId": "run_artifact_local",
"toolCallId": "call_artifact_local",
"traceId": "trace_artifact_local",
"idempotencyKey": "idem_artifact_local",
"dryRun": false,
"args": {"summary": "本地摘要"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert_eq!(status, StatusCode::OK, "{text}");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["result"]["source"], "local_folder");
let artifact_path = root
.join(".mnote")
.join("artifacts")
.join("summary_local-md_README.md.json");
let artifact = fs::read_to_string(&artifact_path).expect("artifact");
assert!(artifact.contains("本地摘要"), "{artifact}");
let _ = fs::remove_dir_all(&root);
}
}