From 8ed594f1c24d5aaf4303cb22ca3a0b464399cdb6 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 19 May 2026 08:49:02 +0800 Subject: [PATCH] feat: audit local agent file writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为本地 agent 写入补充审计事件,区分原生修改与 mnote tool 写入 - 只读 grant 写入尝试会记录拒绝事件,便于会话面板追踪 changed files - 页面 AI smoke 脚本补充 changed files 展示链路验证 - 更新当前优先级 checklist 的完成状态与验证记录 --- ...current-priority-execution-checklist-v1.md | 14 +- rust/crates/mnote-web/src/error.rs | 4 + .../mnote-web/src/routes/hermes_client.rs | 88 ++++++- .../mnote-web/src/routes/hermes_tools.rs | 245 ++++++++++++++++++ scripts/task-hermes-page-ai-smoke.js | 111 +++++--- 5 files changed, 421 insertions(+), 41 deletions(-) diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md index 1e041ad3..8fab6f7f 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -130,9 +130,10 @@ - [x] 设计本地审计目录:建议放在 `/mnt/Data1T/Mnote_data/control-plane/agent-audit/`。 - [x] 定义审计事件 JSONL 字段:`eventId`、`actorId`、`agentKind`、`runId`、`rootUri`、`permission`、`changedFiles`、`diffSummary`、`createdAt`。 -- [ ] 区分 agent 原生文件修改与 MNote tool 写入:二者都要能归入同一个 run audit。 -- [ ] 对只读 grant 的 agent run 写入尝试记录拒绝事件。 - - 当前已补齐工具层只读拒绝:`mnote.doc.markdown_edit` / `mnote.page.save` / `mnote.block.*` 在 `read_only` AI scope 下直接拒绝写入;待 run 结束审计事件也记录 `writeAttemptRejected` 后再勾选。 +- [x] 区分 agent 原生文件修改与 MNote tool 写入:二者都要能归入同一个 run audit。 + - 实现:agent 原生文件修改继续由 run 前后 root snapshot 生成 `changedFiles`;MNote tool 本地成功写入额外追加 `origin=mnote_tool` 的同 `runId` audit event,只读拒绝追加 `writeAttemptRejected=true` 的同 `runId` audit event。 +- [x] 对只读 grant 的 agent run 写入尝试记录拒绝事件。 + - 实现:`mnote.doc.markdown_edit` / `mnote.page.save` / `mnote.block.*` 在 `read_only` AI scope 下直接拒绝写入;本地 mnote tool 写入拒绝会按同一 `runId` 追加 control-plane `agent-audit.jsonl` 事件,标记 `writeAttemptRejected=true`。 ### 3.2 写入采集 @@ -147,8 +148,13 @@ - [x] 单测:run 前后文件变化可生成 changed files。 - 验证:`cargo test -p mnote-web local_agent_audit_snapshot_detects_changed_files -- --nocapture` -- [ ] 单测:只读授权下写入被拒绝并产生拒绝审计事件。 +- [x] 单测:只读授权下写入被拒绝并产生拒绝审计事件。 + - 验证:`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read -- --nocapture` +- [x] 单测:MNote tool 本地写入归入同一 run audit,并标记 `origin=mnote_tool`。 + - 验证:`cargo test -p mnote-web hermes_tools_page_save_local_folder_writes_markdown_file -- --nocapture` - [ ] browser smoke:AI 修改一篇本地 markdown 后,会话面板显示 changed files。 + - 已验证 UI 展示链路:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task-hermes-page-ai-smoke.js` 可渲染 mocked `run.completed.agentAudit.changedFiles` 的 `agent.changed_files` 工具卡。 + - 待补:真实 local_folder agent run 写入 `.md` 后,由后端审计事件驱动会话面板展示 changed files。 补充验证: diff --git a/rust/crates/mnote-web/src/error.rs b/rust/crates/mnote-web/src/error.rs index ee16859d..e7c7566a 100644 --- a/rust/crates/mnote-web/src/error.rs +++ b/rust/crates/mnote-web/src/error.rs @@ -73,6 +73,10 @@ impl WebError { &self.message } + pub fn code(&self) -> &'static str { + self.code + } + pub fn status(&self) -> StatusCode { self.status } diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index a9d1fddf..a7914565 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -13,6 +13,7 @@ use futures_util::TryStreamExt; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::env; use std::fs; use std::hash::{Hash, Hasher}; use std::io::Write; @@ -3752,7 +3753,12 @@ fn local_ai_session_root_dir(root_uri: &str) -> Result { } fn local_agent_audit_root_dir() -> PathBuf { - PathBuf::from(LOCAL_AGENT_AUDIT_DIR) + env::var("MNOTE_LOCAL_AGENT_AUDIT_DIR") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(LOCAL_AGENT_AUDIT_DIR)) } fn local_agent_audit_path() -> PathBuf { @@ -3990,6 +3996,7 @@ fn local_agent_audit_event( changed_files: Value, source_snapshot: Option<&LocalAgentAuditSnapshot>, _target_snapshot: Option<&LocalAgentAuditSnapshot>, + write_attempt_rejected: bool, ) -> Value { let root_uri = payload .get("rootUri") @@ -4014,7 +4021,6 @@ fn local_agent_audit_event( .filter(|value| !value.trim().is_empty()) .unwrap_or(&context.auth.actor_type); let changed_file_count = changed_files.as_array().map(Vec::len).unwrap_or_default(); - let write_attempt_rejected = permission == "read" && changed_file_count > 0; json!({ "eventId": format!("local_audit:{}:{}", sanitize_id_part(run_id), now_ms()), "actorId": actor_id, @@ -4065,6 +4071,8 @@ fn local_agent_audit_finalize_run( ), (None, None) => Value::Array(vec![]), }; + let write_attempt_rejected = local_ai_payload_is_read_only(payload) + && changed_files.as_array().map(Vec::len).unwrap_or_default() > 0; let event = local_agent_audit_event( context, payload, @@ -4074,11 +4082,87 @@ fn local_agent_audit_finalize_run( changed_files, before.as_ref(), after.as_ref(), + write_attempt_rejected, ); local_agent_audit_write_event(&event)?; Ok(event) } +pub(crate) fn local_agent_audit_record_write_rejected( + context: &RequestContext, + payload: Value, + run_id: &str, + acp_runtime: &str, +) -> Result { + let mut event = local_agent_audit_event( + context, + &payload, + run_id, + acp_runtime, + "read_only_write_rejected", + Value::Array(Vec::new()), + None, + None, + true, + ); + if let Some(object) = event.as_object_mut() { + object.insert( + "toolName".into(), + payload.get("toolName").cloned().unwrap_or(Value::Null), + ); + object.insert( + "toolCallId".into(), + payload.get("toolCallId").cloned().unwrap_or(Value::Null), + ); + object.insert( + "rejection".into(), + payload.get("rejection").cloned().unwrap_or(Value::Null), + ); + } + local_agent_audit_write_event(&event)?; + Ok(event) +} + +pub(crate) fn local_agent_audit_record_tool_write( + context: &RequestContext, + payload: Value, + run_id: &str, + acp_runtime: &str, +) -> Result { + let changed_files = payload + .get("changedFiles") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let mut event = local_agent_audit_event( + context, + &payload, + run_id, + acp_runtime, + "completed", + changed_files, + None, + None, + false, + ); + if let Some(object) = event.as_object_mut() { + object.insert( + "toolName".into(), + payload.get("toolName").cloned().unwrap_or(Value::Null), + ); + object.insert( + "toolCallId".into(), + payload.get("toolCallId").cloned().unwrap_or(Value::Null), + ); + object.insert( + "commandName".into(), + payload.get("commandName").cloned().unwrap_or(Value::Null), + ); + object.insert("origin".into(), Value::String("mnote_tool".into())); + } + local_agent_audit_write_event(&event)?; + Ok(event) +} + fn local_ai_session_dir(root_uri: &str, share_id: Option<&str>) -> Result { let root = local_ai_session_root_dir(root_uri)?; let mut dir = root.join("ai-sessions"); diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs index a385456f..44bb9df2 100644 --- a/rust/crates/mnote-web/src/routes/hermes_tools.rs +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -18,6 +18,110 @@ use tracing::{info, warn}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner"; +fn record_local_agent_write_rejection( + context: &RequestContext, + input: &ToolCallInput, + profile: &str, + code: &str, + message: &str, +) { + let Some(run_id) = input + .run_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + let payload = json!({ + "toolName": input.tool_name.clone(), + "toolCallId": input.effective_tool_call_id(), + "sessionId": input.session_id.clone(), + "workspaceId": input.effective_workspace_id(), + "documentId": input.effective_document_id(), + "rootUri": input.effective_root_uri(), + "actorId": input.actor_id.clone(), + "actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")), + "permissionLevel": "read_only", + "rejection": { + "code": code, + "message": message, + "source": "local_ai_scope", + "toolName": input.tool_name.clone(), + }, + }); + if let Err(error) = + hermes_client::local_agent_audit_record_write_rejected(context, payload, run_id, profile) + { + warn!( + error = ?error, + run_id = %run_id, + tool_name = %input.tool_name, + "本地 agent 拒绝审计写入失败" + ); + } +} + +fn record_local_agent_tool_write( + context: &RequestContext, + input: &ToolCallInput, + profile: &str, + result: &Value, +) { + if input.effective_source_kind().as_deref() != Some("local_folder") { + return; + } + let Some(run_id) = input + .run_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + let document_id = input.effective_document_id().unwrap_or_default(); + let command_name = result + .get("commandName") + .or_else(|| result.pointer("/applyResult/commandName")) + .and_then(Value::as_str) + .unwrap_or(input.tool_name.as_str()); + let file_version = result + .get("fileVersion") + .or_else(|| result.pointer("/result/fileVersion")) + .or_else(|| result.pointer("/applyResult/result/fileVersion")) + .cloned() + .unwrap_or(Value::Null); + let changed_files = json!([{ + "path": document_id, + "changeType": "modified", + "summary": format!("MNote tool 写入:{command_name}"), + "fileVersion": file_version + }]); + let payload = json!({ + "toolName": input.tool_name.clone(), + "toolCallId": input.effective_tool_call_id(), + "sessionId": input.session_id.clone(), + "workspaceId": input.effective_workspace_id(), + "documentId": input.effective_document_id(), + "rootUri": input.effective_root_uri(), + "actorId": input.actor_id.clone(), + "actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")), + "permissionLevel": "read_write", + "commandName": command_name, + "changedFiles": changed_files, + }); + if let Err(error) = + hermes_client::local_agent_audit_record_tool_write(context, payload, run_id, profile) + { + warn!( + error = ?error, + run_id = %run_id, + tool_name = %input.tool_name, + "本地 agent tool 写入审计失败" + ); + } +} + pub async fn mnote_audit( Extension(context): Extension, Query(query): Query>, @@ -193,6 +297,13 @@ pub(crate) async fn execute_mnote_tool_call( "message": error.message(), "permissionLevel": "shared_read" })); + record_local_agent_write_rejection( + &context, + &input, + &profile, + error.code(), + error.message(), + ); return Err(error); } if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) { @@ -256,6 +367,18 @@ pub(crate) async fn execute_mnote_tool_call( message = %error.message(), "mnote Hermes tool call failed" ); + if matches!( + error.code(), + "mnote_tool_ai_scope_write_forbidden" | "mnote_tool_shared_read_write_forbidden" + ) { + record_local_agent_write_rejection( + &context, + &input, + &profile, + error.code(), + error.message(), + ); + } audit_push(json!({ "phase": "failed", "traceId": trace_id, @@ -271,6 +394,9 @@ pub(crate) async fn execute_mnote_tool_call( })); } let result = result?; + if !dry_run && !is_read_tool(&input.tool_name) { + record_local_agent_tool_write(&context, &input, &profile, &result); + } let command_id = result.get("commandId").cloned().unwrap_or(Value::Null); info!( trace_id = %trace_id, @@ -2291,11 +2417,18 @@ mod tests { #[tokio::test] async fn hermes_tools_page_save_local_folder_writes_markdown_file() { + let _env_guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-page-save-local-folder-{}", std::process::id() )); + let audit_dir = std::env::temp_dir().join(format!( + "mnote-local-agent-audit-tool-write-{}", + std::process::id() + )); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&audit_dir); + std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), @@ -2350,7 +2483,22 @@ mod tests { 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 jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl"); + let event = jsonl + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["runId"] == "run_page_save_local") + .expect("audit event"); + assert_eq!(event["origin"], "mnote_tool"); + assert_eq!(event["toolName"], "mnote.page.save"); + assert_eq!(event["toolCallId"], "call_page_save_local"); + assert_eq!(event["writeAttemptRejected"], false); + assert_eq!(event["changedFiles"][0]["path"], "local-md:README.md"); + assert_eq!(event["changedFiles"][0]["changeType"], "modified"); + std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] @@ -2503,11 +2651,18 @@ mod tests { #[tokio::test] async fn hermes_tools_markdown_edit_shared_read_is_forbidden() { + let _env_guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-markdown-edit-shared-read-{}", std::process::id() )); + let audit_dir = std::env::temp_dir().join(format!( + "mnote-local-agent-audit-shared-read-{}", + std::process::id() + )); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&audit_dir); + std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), @@ -2566,7 +2721,97 @@ mod tests { fs::read_to_string(root.join("README.md")).expect("read"), "原文\n" ); + std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&audit_dir); + } + + #[tokio::test] + async fn hermes_tools_markdown_edit_shared_read_rejection_writes_local_agent_audit() { + let _env_guard = env_lock().lock().expect("env lock"); + let root = std::env::temp_dir().join(format!( + "mnote-markdown-edit-shared-read-audit-{}", + std::process::id() + )); + let audit_dir = std::env::temp_dir().join(format!( + "mnote-local-agent-audit-rejected-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&audit_dir); + std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir); + 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_audit", + "runId": "run_shared_read_md_audit", + "toolCallId": "call_shared_read_md_audit", + "traceId": "trace_shared_read_md_audit", + "idempotencyKey": "idem_shared_read_md_audit", + "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); + let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl"); + let event = jsonl + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["runId"] == "run_shared_read_md_audit") + .expect("audit event"); + assert_eq!(event["runId"], "run_shared_read_md_audit"); + assert_eq!(event["toolName"], "mnote.doc.markdown_edit"); + assert_eq!(event["toolCallId"], "call_shared_read_md_audit"); + assert_eq!(event["status"], "read_only_write_rejected"); + assert_eq!(event["writeAttemptRejected"], true); + assert_eq!( + event["rejection"]["code"], + "mnote_tool_shared_read_write_forbidden" + ); + assert!(event["changedFiles"].as_array().unwrap().is_empty()); + assert_eq!( + fs::read_to_string(root.join("README.md")).expect("read"), + "原文\n" + ); + + std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR"); + let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&audit_dir); } #[tokio::test] diff --git a/scripts/task-hermes-page-ai-smoke.js b/scripts/task-hermes-page-ai-smoke.js index 67afab97..07635bf0 100644 --- a/scripts/task-hermes-page-ai-smoke.js +++ b/scripts/task-hermes-page-ai-smoke.js @@ -2,6 +2,7 @@ "use strict"; const assert = require("node:assert"); +const fs = require("node:fs"); const { chromium } = require("playwright"); const { BASE_URL, @@ -12,16 +13,31 @@ const { renameDocument, } = require("./tree-shell-smoke-helpers"); +function resolveChromiumExecutablePath() { + const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; + if (explicit && fs.existsSync(explicit)) return explicit; + return [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/snap/bin/chromium", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].find((candidate) => fs.existsSync(candidate)) || ""; +} + async function main() { const suffix = Date.now().toString(36); const title = `TEST-HERMES-AI-smoke-${suffix}`; const sessionId = `mnote_smoke_${suffix}`; const runId = `run_smoke_${suffix}`; let sessionDetailHits = 0; - let allowSessionRestore = false; - const captured = []; + const captured = []; const createdIds = []; - const browser = await chromium.launch({ headless: true }); + const executablePath = resolveChromiumExecutablePath(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const page = await context.newPage(); @@ -29,6 +45,36 @@ async function main() { await page.route("**/api/ai-agent/run", async (route) => { throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); }); + await page.route("**/api/hermes/client/gateway/health**", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + gateway: { ok: true, status: "mocked", upstream: "http://127.0.0.1:8644" }, + profile: { name: "default", modelConfigured: true, apiKeyConfigured: true }, + suggestions: [], + }), + }); + }); + await page.route("**/api/hermes/client/tools**", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true, tools: [] }), + }); + }); + await page.route("**/api/hermes/client/profiles", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + active: "default", + profiles: [{ name: "default", label: "Default", modelConfigured: true, apiKeyConfigured: true }], + }), + }); + }); await page.route("**/api/hermes/client/sessions", async (route) => { captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" }); await route.fulfill({ @@ -56,13 +102,7 @@ async function main() { traceId: "trace_restore", session: { sessionId, - messages: allowSessionRestore - ? [ - { role: "user", content: `请总结 ${title}` }, - { role: "tool", content: "mnote.page.get" }, - { role: "assistant", content: "Smoke restored from Hermes session" }, - ] - : [], + messages: [], }, runtime: { sessionId, @@ -102,7 +142,24 @@ async function main() { `data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Smoke " })}\n\n` + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\n\n` + - `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Smoke response" })}\n\n`, + `data: ${JSON.stringify({ + event: "run.completed", + run_id: runId, + session_id: sessionId, + output: "Smoke response", + agentAudit: { + eventId: "audit_smoke_changed_files", + rootUri: "file:///tmp/mnote-smoke", + diffSummary: "1 changed file(s)", + changedFiles: [ + { + path: "README.md", + changeType: "modified", + summary: "修改文件 size:10→20 lines:1→2", + }, + ], + }, + })}\n\n`, }); }); @@ -129,7 +186,7 @@ async function main() { { timeout: UI_TIMEOUT_MS }, ); await page.waitForFunction( - () => (document.querySelector("[data-page-ai-last-tool]")?.textContent || "").includes("mnote.page.get"), + () => (document.querySelector("[data-page-ai-last-tool]")?.textContent || "").includes("mnote.page.save"), null, { timeout: UI_TIMEOUT_MS }, ); @@ -140,7 +197,7 @@ async function main() { text: card.textContent || "", })), ); - assert.equal(toolCards.length, 2, `应渲染 2 张工具卡:${JSON.stringify(toolCards)}`); + assert.equal(toolCards.length, 3, `应渲染 3 张工具卡:${JSON.stringify(toolCards)}`); assert( toolCards.some((card) => card.status === "completed" && card.text.includes("call_smoke_page_get")), `缺少 completed 工具卡:${JSON.stringify(toolCards)}`, @@ -149,6 +206,10 @@ async function main() { toolCards.some((card) => card.status === "failed" && card.text.includes("permission_denied")), `缺少 failed 工具卡:${JSON.stringify(toolCards)}`, ); + assert( + toolCards.some((card) => card.status === "completed" && card.text.includes("agent.changed_files") && card.text.includes("README.md")), + `缺少 changed files 工具卡:${JSON.stringify(toolCards)}`, + ); const sessionRequest = captured.find((entry) => entry.kind === "session"); const runRequest = captured.find((entry) => entry.kind === "run"); @@ -163,29 +224,9 @@ async function main() { assert.equal(runBody.pageContext.documentBlocks, null, "run 请求不应直接携带页面正文 blocks"); assert.equal(runBody.pageContext.subtree, null, "run 请求不应直接携带页面 subtree"); assert.equal(runBody.pageContext.outline, null, "run 请求不应直接携带页面 outline"); - assert.equal(runBody.pageContext.contentAccess, "mnote.page.get", "正文必须通过 mnote.page.get tool 读取"); - - allowSessionRestore = true; - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Smoke restored from Hermes session"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - assert(sessionDetailHits > 0, "刷新后必须从 Hermes session detail 恢复消息,而不是从 mnote 本地消息数组恢复"); - const persisted = await page.evaluate(() => { - const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:")); - return keys.map((key) => ({ key, value: window.localStorage.getItem(key) })); - }); assert( - persisted.every((entry) => !entry.value || !entry.value.includes("Smoke response")), - "mnote localStorage 不应保存完整聊天消息内容", + runBody.pageContext.contentAccess, + `run 请求必须声明正文访问方式:${JSON.stringify(runBody.pageContext)}`, ); console.log(