feat: audit local agent file writes
- 为本地 agent 写入补充审计事件,区分原生修改与 mnote tool 写入 - 只读 grant 写入尝试会记录拒绝事件,便于会话面板追踪 changed files - 页面 AI smoke 脚本补充 changed files 展示链路验证 - 更新当前优先级 checklist 的完成状态与验证记录
This commit is contained in:
+10
-4
@@ -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。
|
||||
|
||||
补充验证:
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ impl WebError {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status(&self) -> StatusCode {
|
||||
self.status
|
||||
}
|
||||
|
||||
@@ -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<PathBuf, WebError> {
|
||||
}
|
||||
|
||||
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<Value, WebError> {
|
||||
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<Value, WebError> {
|
||||
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<PathBuf, WebError> {
|
||||
let root = local_ai_session_root_dir(root_uri)?;
|
||||
let mut dir = root.join("ai-sessions");
|
||||
|
||||
@@ -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<RequestContext>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
@@ -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::<Value>(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::<Value>(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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user