feat(ai): harden local agent file edit guards

This commit is contained in:
lix-2026
2026-06-07 01:11:54 +08:00
parent 1f25364374
commit b65501bf86
8 changed files with 1445 additions and 60 deletions
@@ -0,0 +1,151 @@
# 7-18 Agent edit clean / dirty smoke closure checklist v1
> 创建时间:2026-06-06
>
> 状态:`process`
>
> Owner07-ai / 05-editor-mainline / 03-rust-web
>
> 父设计:`design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
## 1. 边界
本 checklist 只推进 local-first 普通 Markdown 的真实 agent 文件编辑闭环,不碰 LightRAG、Knowledge RAG、OCR、evidence index 或 OnlyOffice recipe 扩展。当前另一个 agent 正在修改 LightRAG 相关文件,本轮不得编辑:
- `rust/crates/mnote-web/src/routes/knowledge_rag.rs`
- `rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs`
- `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
- `scripts/task529*``scripts/task534*`
## 2. 当前代码证据
已有基础能力:
- Page AI target 会生成 `mnote.agent_target_package.v1`,并给目标写入 `allowedFiles``policy.conflictPolicy=fail_on_dirty_or_stale`
- 发送 run 前会通过 `/api/documents/buffer-state` 检查 dirty / external modified 状态;阻塞时抛 `page_ai_target_buffer_not_writable`
- 后端会生成 `mnote.agent_run_envelope.v1`,其中 `resultPolicy.changedFiles=required``refresh=watcher_or_explicit_resync`
- `scripts/reasonix-acp-wrapper.mjs` 会把 `agentRunEnvelope` 写进模型可见 prompt,要求 agent 使用自身文件工具编辑真实文件。
- `local_agent_audit_finalize_run` 会基于前后快照生成 `changedFiles``mnote.agent_run_receipt.v1`
- 前端 `pageAiDispatchReceiptRefresh(...)` 会把 receipt 转成 `tree:local-folder-watch-batch``mnote:page-ai-tool-write-completed`
当前缺口:
- 没有一条真实浏览器 smoke 证明 agent 原生 patch 修改当前 `.md` 后,前台 tiptap 在 clean buffer 下可见更新。
- dirty buffer 的阻塞虽然已有函数,但缺少端到端 smoke 证明不会启动可写 run 或不会静默覆盖。
- readonly 写入目前仍偏后置审计:`read_only_write_rejected` 可以记录结果,但还需要前置拒绝或 tool/ACP 层明确失败。
- 审计快照在 folder context 下会退回 full snapshot;产品化前需要限制为 `allowedFiles` / changedFiles 优先,并对超限降级有可见记录。
## 3. 非目标
- 不新增 MNote 普通 Markdown 写入工具。
- 不把 `mnote.doc.markdown_edit` 恢复为 local-first fallback。
- 不实现 streaming apply、review session、GhostTextOverlay。
- 不要求真实外部大模型;smoke 可以用受控 ACP stub / Hermes test runtime 模拟 agent 原生文件 patch,但必须真实写磁盘文件。
- 不把 LightRAG retrieval、evidence citation 或 OCR sidecar 混入本闭环。
## 4. Phase Aclean buffer 真实写入回收 smoke
目标:证明 agent 原生文件编辑 -> watcher / receipt -> 当前前台 tiptap 可见更新。
Checklist
- [x] 新增 `scripts/task535-page-ai-local-agent-clean-edit-smoke.js`
- [x] smoke 创建临时 local-folder workspace 和目标 `AgentClean.md`
- [x] 浏览器打开该 Markdown,确认 `mnote-leptos-tiptap-island-editor-root` ready。
- [x] 通过 Page AI 发起本地 agent runrun payload 必须包含 `targetPackage.currentFile.relativePath``allowedFiles`
- [x] agent stub / ACP test runtime 只能在 `allowedFiles` 指向的真实 `.md` 上做最小 patch。
- [x] run completed 后必须返回或触发 `agentRunReceipt.changedFiles`,路径为目标 `.md`
- [x] 前端必须触发 `tree:local-folder-watch-batch`,并对当前文档触发 `mnote:page-ai-tool-write-completed`
- [x] tiptap 可见正文更新为 agent 写入后的内容,不需要手动 reload。
- [x] 网络断言本路径不调用 `/api/documents/save`
- [x] 工具事件断言本路径不调用 `mnote.doc.markdown_edit`
验收:
- [x] smoke 输出 `tmp/task535-page-ai-local-agent-clean-edit-smoke/result.json`,包含 run payload、changedFiles、current refresh、最终 editor text。
- [x] `result.json``ok=true``usedDocumentsSave=false``usedMarkdownEdit=false`
## 5. Phase Bdirty buffer 不静默覆盖 smoke
目标:证明用户本地未保存时,agent 写入不会绕过 buffer 冲突模型。
Checklist
- [x] 新增 `scripts/task536-page-ai-local-agent-dirty-guard-smoke.js`
- [x] 打开目标 Markdown 后在 tiptap 中输入未保存内容,制造 BufferStore dirty 状态。
- [x] Page AI target chip / run 前检查能读到 dirty 状态。
- [x] 点击发送可写 run 时,应出现明确阻塞或确认流程;当前最低验收是阻塞并返回 `page_ai_target_buffer_not_writable`
- [x] smoke 断言没有启动可写 ACP run。
- [x] smoke 断言磁盘 `.md` 没有被 agent stub 修改。
- [x] smoke 断言 editor 中未保存内容仍可见。
验收:
- [x] smoke 输出 `tmp/task536-page-ai-local-agent-dirty-guard-smoke/result.json`
- [x] `result.json``ok=true``blockedBeforeRun=true``diskChanged=false``editorDirtyTextStillVisible=true`
## 6. Phase Creadonly 前置拒绝 smoke
目标:证明 readonly target 不靠事后审计才发现写入失败。
Checklist
- [x] 新增 `scripts/task537-page-ai-local-agent-readonly-write-guard-smoke.js`
- [x] 构造只读授权或只读 targetPackage`permission=read``allowedFiles` 可读但不可写。
- [x] 发起“请修改当前文件”的 Page AI run。
- [x] 前端或后端应在启动可写 ACP run 前拒绝,错误码稳定,例如 `page_ai_target_readonly``local_agent_write_not_allowed`
- [x] 若当前实现只能后置审计,应先把 smoke 写成 RED,记录实际行为,不伪造通过。
- [x] smoke 断言磁盘文件未变化。
- [x] smoke 断言 audit 中没有把 readonly 写入说成成功;若出现 `read_only_write_rejected`,必须在 UI 可见错误里体现。
验收:
- [x] smoke 输出 `tmp/task537-page-ai-local-agent-readonly-write-guard-smoke/result.json`
- [x] `result.json``ok=true` 仅在前置拒绝落地后允许;当前若为 RED,应输出 `ok=false` 和真实行为证据。
## 7. Phase D:审计快照范围收口
目标:避免 local agent audit 因 folder context 退回扫全 root,影响大 workspace 和 run 返回速度。
Checklist
- [x] 后端 `local_agent_audit_relative_paths_from_payload(...)` 优先消费 `agentRunEnvelope.allowedFiles` / `targetPackage.allowedFiles`
- [x] folder context 不再直接强制 full snapshot;除非用户明确选择 folder-wide edit 且有上限。
- [x] 增加审计上限:文件数、总字节数、耗时;超限时进入 `auditScope=truncated`,并在 receipt 中可见。
- [x] 单测覆盖 allowedFiles 优先、folder context 不扫全 root、超限截断。
验收:
- [x] `cargo test -p mnote-web local_agent_audit -- --test-threads=1` 或等价 targeted tests 通过。
- [ ] clean smoke 中 audit snapshot 只包含目标文件及必要 changed file。
## 8. Phase E:文档与 manifest 退役口径
目标:锁死 local-first 普通 Markdown 不走旧 MNote 写入工具。
Checklist
- [x] Page AI / Hermes guidance 对 local-folder 普通 Markdown 明确优先 agent 原生 patch/diff。
- [x] `mnote.doc.markdown_edit` 在 manifest 中只标注为 online/cloud/compat 历史工具或结构校验辅助。
- [x] `reasonix-acp-wrapper.mjs` selftest 断言 prompt 包含“不使用 MNote doc/page write tools for ordinary local Markdown edits”。
- [x] smoke 断言 local-first agent edit 不调用 `/api/documents/save``mnote.doc.markdown_edit``mnote.page.save`
验收:
- [x] `7-18-local-first-agent-file-editing-control-plane-v1.md` Phase B/C 可勾选。
- [ ] 本 checklist 可移动到 `design/07-ai/done/`,父设计 `7-18` 仅剩跨 workspace / 多 target 的产品确认项。
## 9. 推荐执行顺序
1. 先写 `task535` clean smoke,允许 RED,固定真实 run payload / receipt / 前台刷新证据。
2. 再写 `task536` dirty guard smoke,优先验证当前已有 dirty blocker。
3. 再写 `task537` readonly guard smoke,若当前只能后置审计则保持 RED。
4. 最后做审计快照范围收口和 manifest/guidance 口径收紧。
## 10. 归档条件
- clean buffer smoke 真实通过。
- dirty buffer smoke 证明不会静默覆盖。
- readonly write smoke 证明前置拒绝或 UI 明确失败。
- local-first 普通 Markdown agent edit 不调用旧 MNote 写入工具。
- LightRAG 相关 diff 不在本 checklist 中被修改或作为验收前置。
@@ -287,30 +287,36 @@ Rust SQLite control-plane 是默认控制面,负责:
- [ ] 从文档页发起 Hermes / Reasonix run 时,request body 包含 `currentFile``allowedRoots``allowedFiles`、selection 和 dirty / readonly context。 - [ ] 从文档页发起 Hermes / Reasonix run 时,request body 包含 `currentFile``allowedRoots``allowedFiles`、selection 和 dirty / readonly context。
- [ ] run body 包含 `targetPackage`,且 `allowedRoots` / `allowedFiles` 从 targetPackage 推导。 - [ ] run body 包含 `targetPackage`,且 `allowedRoots` / `allowedFiles` 从 targetPackage 推导。
- [ ] local-first 普通 Markdown 编辑 run 不包含 `mnote.doc.markdown_edit` 推荐工具。 - [x] local-first 普通 Markdown 编辑 run 不包含 `mnote.doc.markdown_edit` 推荐工具。
- [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。 - [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。
- [ ] readonly 页面不会启动可写 run,或只启动只读问答 run。 - [x] readonly 页面不会启动可写 run,或只启动只读问答 run。
2026-06-01 进展:已补第一版 `mnote.agent_target_package.v1` 运行时输入。前端 Page AI run body 会从当前 `editorTarget` / `WorkspacePath` 生成 `targetPackage`;后端会 sanitize 该包,并把 local-first `aiAccessScope.allowedFiles` / `allowedFilePaths``agentRunEnvelope.allowedFiles` 从该包派生。`task502-page-ai-agent-selector-context-smoke.js` 已覆盖 page / mindmap / OnlyOffice target picker 与 payload 冻结;`task520-page-ai-raw-resource-target-smoke.js` 已覆盖真实 raw local resource tab,并断言 `objectIdentity` 不退化为 `[object Object]`。当前仍未完成跨 workspace 多选确认和真实 agent 写入回收 smoke,因此本文继续保持 `process` 2026-06-01 进展:已补第一版 `mnote.agent_target_package.v1` 运行时输入。前端 Page AI run body 会从当前 `editorTarget` / `WorkspacePath` 生成 `targetPackage`;后端会 sanitize 该包,并把 local-first `aiAccessScope.allowedFiles` / `allowedFilePaths``agentRunEnvelope.allowedFiles` 从该包派生。`task502-page-ai-agent-selector-context-smoke.js` 已覆盖 page / mindmap / OnlyOffice target picker 与 payload 冻结;`task520-page-ai-raw-resource-target-smoke.js` 已覆盖真实 raw local resource tab,并断言 `objectIdentity` 不退化为 `[object Object]`。当前仍未完成跨 workspace 多选确认和真实 agent 写入回收 smoke,因此本文继续保持 `process`
### Phase B:写入回收与前台同步 ### Phase B:写入回收与前台同步
- [ ] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。 - [x] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。
- [ ] clean buffer 下 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新通过 browser smoke。 - [x] clean buffer 下 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新通过 browser smoke。
- [ ] dirty buffer 下外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。 - [x] dirty buffer 下外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。
- [ ] changed files / diff 与 session / run / actor 关联写入审计。 - [x] changed files / diff 与 session / run / actor 关联写入审计。
2026-06-06 执行拆分:Phase B/C 的真实 smoke 与审计收口已拆到 `design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md`。该拆分只覆盖 local-first 普通 Markdown agent 原生文件编辑,不碰 LightRAG / Knowledge RAG / OCR / evidence index。
2026-06-07 进展:`task535` 已证明 clean buffer 下 agent 原生文件 patch 写入真实 `.md` 后,receipt 触发当前文档与 filetree 刷新,前台 tiptap 无需 reload 即可见更新;`task536` 已证明 dirty buffer 在启动可写 run 前阻塞且磁盘不变;`local_agent_audit` targeted tests 已覆盖 allowedFiles 优先、folder context 不扫全 root、超限 auditScope 可见。
### Phase C:旧工具退役 ### Phase C:旧工具退役
- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `/api/documents/save` - [x] local-first 普通 Markdown 编辑 smoke 断言未调用 `/api/documents/save`
- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `mnote.doc.markdown_edit` - [x] local-first 普通 Markdown 编辑 smoke 断言未调用 `mnote.doc.markdown_edit`
- [ ] Hermes / Reasonix manifest 或 system prompt 不再鼓励普通 Markdown 编辑调用 `mnote.doc.markdown_edit` - [x] Hermes / Reasonix manifest 或 system prompt 不再鼓励普通 Markdown 编辑调用 `mnote.doc.markdown_edit`
- [ ]`markdown_edit` 测试只保留为历史 online / compat 回归,并在文档中标明不指导新实现。 - [x]`markdown_edit` 测试只保留为历史 online / compat 回归,并在文档中标明不指导新实现。
2026-06-07 进展:Page AI / Hermes run guidance 明确禁止 local-first 普通 Markdown 编辑调用 `mnote_doc_markdown_edit``mnote_page_save``mnote.doc.markdown_edit` manifest 描述已收口为 compat / remote / cloud fallback 或结构校验辅助;Reasonix ACP wrapper selftest 已断言 prompt 包含禁止使用 MNote doc/page write tools 的口径;`task535` 同时断言未调用 `/api/documents/save``mnote.doc.markdown_edit``mnote.page.save`
### Phase D:结构辅助边界 ### Phase D:结构辅助边界
- [ ] `mnote.block.*` 只在复杂结构辅助场景出现,例如结构块排序、非 Markdown 资源辅助。 - [ ] `mnote.block.*` 只在复杂结构辅助场景出现,例如结构块排序、非 Markdown 资源辅助。
- [ ] `mnote.page.save` 只作为页面级兜底写入,不作为默认精确编辑入口。 - [x] `mnote.page.save` 只作为页面级兜底写入,不作为默认精确编辑入口。
- [ ] `mnote.doc.fetch` 不作为 local-first 正文读取主入口;需要结构化上下文时由 MNote 在 run input 中提供摘要或 projection。 - [ ] `mnote.doc.fetch` 不作为 local-first 正文读取主入口;需要结构化上下文时由 MNote 在 run input 中提供摘要或 projection。
## 9. 不做事项 ## 9. 不做事项
@@ -6,7 +6,7 @@
> >
> 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。 > 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。
> >
> 2026-06-01 口径补充:本文是 2026-05-17 的历史 review 快照。文中“`mnote.doc.markdown_edit` 是简单正文编辑主路径”的结论已被后续 local-first agent 文件编辑控制面覆盖;当前 local-first 普通 Markdown 编辑主路径以 `AGENTS.md`、`ARCHITECTURE.md` 与 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准,即授权文件引用 + allowed roots/files + agent 原生 patch/diff + watcher/BufferStore/Page Aggregate 同步。`mnote.doc.markdown_edit` 只保留为 remote/cloud/compat fallback 或结构校验辅助 > 2026-06-01 口径补充2026-06-06 收紧:本文是 2026-05-17 的历史 review 快照。文中“`mnote.doc.markdown_edit` 是简单正文编辑主路径”的结论已被后续 local-first agent 文件编辑控制面覆盖;当前 local-first 普通 Markdown 编辑主路径以 `AGENTS.md`、`ARCHITECTURE.md` 与 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准,即授权文件引用 + allowed roots/files + agent 原生 patch/diff + watcher/BufferStore/Page Aggregate 同步。`mnote.doc.markdown_edit` 只保留为历史 online/cloud/compat 回归和结构校验对照,不作为 local-first 新 agent run 的推荐 fallback。
## 1. 本轮结论 ## 1. 本轮结论
@@ -1859,6 +1859,17 @@ export function createSidebarPageAiRuntime(context) {
}); });
} }
function assertPageAiLocalWritePermission(prompt, targetPackage) {
if (currentSourceKind() !== 'local_folder') return;
if (!pageAiLooksLikeBlockEdit(prompt)) return;
var target = pageAiNormalizeArray(targetPackage && targetPackage.targets)[0] || null;
var permission = String(target && target.policy && target.policy.permission || '').trim();
if (permission === 'read_write') return;
var error = new Error('当前本地工作区只读授权,不能让 AI 写入目标文件。请切换到有写权限的工作区或调整授权后重试。');
error.code = 'page_ai_target_readonly';
throw error;
}
async function pageAiTryBlockEditWorkflow(prompt, scopedContext, runTargetSnapshot) { async function pageAiTryBlockEditWorkflow(prompt, scopedContext, runTargetSnapshot) {
if (currentSourceKind() === 'local_folder') return false; if (currentSourceKind() === 'local_folder') return false;
if (!pageAiLooksLikeBlockEdit(prompt)) return false; if (!pageAiLooksLikeBlockEdit(prompt)) return false;
@@ -1955,14 +1966,15 @@ export function createSidebarPageAiRuntime(context) {
var scopedContext = pageAiScopedPageContext(contextSnapshot); var scopedContext = pageAiScopedPageContext(contextSnapshot);
scopedContext.editorTarget = currentPageAiScopedEditorTarget(); scopedContext.editorTarget = currentPageAiScopedEditorTarget();
assertPageAiTargetInCurrentWorkspace(scopedContext.editorTarget); assertPageAiTargetInCurrentWorkspace(scopedContext.editorTarget);
await assertPageAiTargetWritable(scopedContext.editorTarget);
var runTargetSnapshot = pageAiBuildRunTargetSnapshot(scopedContext, prompt);
if (currentSourceKind() === 'local_folder' && !pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).length) { if (currentSourceKind() === 'local_folder' && !pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).length) {
await pageAiLoadAllowedRoots(); await pageAiLoadAllowedRoots();
} }
await assertPageAiTargetWritable(scopedContext.editorTarget);
var runTargetSnapshot = pageAiBuildRunTargetSnapshot(scopedContext, prompt);
var contextRefs = pageAiBuildContextRefs(scopedContext, runTargetSnapshot); var contextRefs = pageAiBuildContextRefs(scopedContext, runTargetSnapshot);
var allowedRoots = pageAiBuildAllowedRoots(); var allowedRoots = pageAiBuildAllowedRoots();
var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot); var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot);
assertPageAiLocalWritePermission(prompt, agentTargetPackage);
if (typeof pageAiEnrichOcrContextRefs === 'function') { if (typeof pageAiEnrichOcrContextRefs === 'function') {
var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget); var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget);
contextRefs = ocrContext.contextRefs || contextRefs; contextRefs = ocrContext.contextRefs || contextRefs;
+315 -46
View File
@@ -24,7 +24,7 @@ use std::io::Write;
use std::path::{Path as FsPath, PathBuf}; use std::path::{Path as FsPath, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::{Arc, LazyLock, Mutex}; use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration; use std::time::{Duration, Instant};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -74,6 +74,9 @@ static ACP_LOCAL_AUDIT_SNAPSHOTS: LazyLock<Mutex<HashMap<String, LocalAgentAudit
const LOCAL_AGENT_AUDIT_DIR: &str = "/mnt/Data1T/Mnote_data/control-plane/agent-audit"; const LOCAL_AGENT_AUDIT_DIR: &str = "/mnt/Data1T/Mnote_data/control-plane/agent-audit";
const LOCAL_AGENT_AUDIT_JSONL: &str = "agent-audit.jsonl"; const LOCAL_AGENT_AUDIT_JSONL: &str = "agent-audit.jsonl";
const LOCAL_AGENT_AUDIT_MAX_FILES: usize = 512;
const LOCAL_AGENT_AUDIT_MAX_BYTES: u64 = 32 * 1024 * 1024;
const LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS: u128 = 2_500;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct HermesRuntimeState { struct HermesRuntimeState {
@@ -133,6 +136,12 @@ struct LocalAgentAuditFileSnapshot {
struct LocalAgentAuditSnapshot { struct LocalAgentAuditSnapshot {
root_uri: String, root_uri: String,
files: BTreeMap<String, LocalAgentAuditFileSnapshot>, files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
scope: String,
truncated: bool,
truncated_reason: Option<String>,
file_count: usize,
total_bytes: u64,
elapsed_ms: u128,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -6287,6 +6296,7 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
"并遵守 fileReference.rootUri、aiAccessScope.allowedRoots、MNOTE_AI_ALLOWED_ROOTS_JSON 与 MNOTE_AI_ACCESS_SCOPE_JSON 的授权边界。", "并遵守 fileReference.rootUri、aiAccessScope.allowedRoots、MNOTE_AI_ALLOWED_ROOTS_JSON 与 MNOTE_AI_ACCESS_SCOPE_JSON 的授权边界。",
"不要调用已退役 cloud/Convex 文档读取工具读取本地 Markdown。", "不要调用已退役 cloud/Convex 文档读取工具读取本地 Markdown。",
"普通 Markdown 编辑优先使用 agent 原生 patch/diff 写入真实文件;写入后回读文件确认结果。", "普通 Markdown 编辑优先使用 agent 原生 patch/diff 写入真实文件;写入后回读文件确认结果。",
"不要调用 mnote_doc_markdown_edit 或 mnote_page_save 处理 local-first 普通 Markdown 编辑。",
"只有复杂结构性块操作或 remote/cloud 兼容场景,才考虑 mnote block/doc 工具;", "只有复杂结构性块操作或 remote/cloud 兼容场景,才考虑 mnote block/doc 工具;",
"mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级或文件 patch 无法表达时作为高风险兜底。", "mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级或文件 patch 无法表达时作为高风险兜底。",
"当 agentRunEnvelope.targetPackage.resourceKind 为 only_office 或 targets 中包含 only_office 时,", "当 agentRunEnvelope.targetPackage.resourceKind 为 only_office 或 targets 中包含 only_office 时,",
@@ -6471,6 +6481,7 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
json!([ json!([
"agent native file read/write within allowed roots", "agent native file read/write within allowed roots",
"agent native patch/diff for plain Markdown edits", "agent native patch/diff for plain Markdown edits",
"do not use mnote_doc_markdown_edit or mnote_page_save for ordinary local Markdown edits",
"mnote block/doc tools only for complex structural or remote/cloud fallback operations", "mnote block/doc tools only for complex structural or remote/cloud fallback operations",
"read back the changed file after writing" "read back the changed file after writing"
]) ])
@@ -6508,7 +6519,8 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
"blockEditingToolOrder": block_editing_tool_order, "blockEditingToolOrder": block_editing_tool_order,
"pageSavePolicy": { "pageSavePolicy": {
"mnote_page_save": "fallback_only_for_explicit_whole_page_write", "mnote_page_save": "fallback_only_for_explicit_whole_page_write",
"forBlockEditing": "forbidden_as_first_choice" "forBlockEditing": "forbidden_as_first_choice",
"forOrdinaryLocalMarkdown": if is_local_source { "forbidden_use_agent_native_file_patch" } else { "not_applicable" }
}, },
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null), "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
@@ -7505,6 +7517,7 @@ fn build_agent_run_receipt(
status: &str, status: &str,
permission: &str, permission: &str,
changed_files: Value, changed_files: Value,
audit_scope: Value,
write_attempt_rejected: bool, write_attempt_rejected: bool,
) -> Value { ) -> Value {
let touches_current_file = local_agent_audit_touches_current_file(payload, &changed_files); let touches_current_file = local_agent_audit_touches_current_file(payload, &changed_files);
@@ -7520,6 +7533,7 @@ fn build_agent_run_receipt(
"permission": permission, "permission": permission,
"writeAttemptRejected": write_attempt_rejected, "writeAttemptRejected": write_attempt_rejected,
"changedFiles": changed_files, "changedFiles": changed_files,
"auditScope": audit_scope,
"refresh": { "refresh": {
"touchesCurrentFile": touches_current_file, "touchesCurrentFile": touches_current_file,
"currentDocumentId": payload.get("documentId").cloned().unwrap_or(Value::Null), "currentDocumentId": payload.get("documentId").cloned().unwrap_or(Value::Null),
@@ -8566,7 +8580,59 @@ fn local_agent_audit_snapshot_entry(
}) })
} }
fn local_agent_audit_snapshot(
root_uri: &str,
files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
scope: &str,
truncated_reason: Option<String>,
elapsed_ms: u128,
) -> LocalAgentAuditSnapshot {
let total_bytes = files.values().map(|entry| entry.size).sum();
let file_count = files.len();
LocalAgentAuditSnapshot {
root_uri: root_uri.to_string(),
files,
scope: scope.to_string(),
truncated: truncated_reason.is_some(),
truncated_reason,
file_count,
total_bytes,
elapsed_ms,
}
}
fn local_agent_audit_empty_snapshot(root_uri: &str, scope: &str) -> LocalAgentAuditSnapshot {
local_agent_audit_snapshot(root_uri, BTreeMap::new(), scope, None, 0)
}
fn local_agent_audit_scope_value(snapshot: Option<&LocalAgentAuditSnapshot>) -> Value {
let Some(snapshot) = snapshot else {
return Value::Null;
};
json!({
"scope": snapshot.scope.clone(),
"truncated": snapshot.truncated,
"truncatedReason": snapshot.truncated_reason.clone(),
"fileCount": snapshot.file_count,
"totalBytes": snapshot.total_bytes,
"elapsedMs": snapshot.elapsed_ms,
"limits": {
"maxFiles": LOCAL_AGENT_AUDIT_MAX_FILES,
"maxBytes": LOCAL_AGENT_AUDIT_MAX_BYTES,
"maxElapsedMs": LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS
}
})
}
fn local_agent_audit_effective_scope_value(
source_snapshot: Option<&LocalAgentAuditSnapshot>,
target_snapshot: Option<&LocalAgentAuditSnapshot>,
) -> Value {
local_agent_audit_scope_value(target_snapshot.or(source_snapshot))
}
fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditSnapshot, WebError> { fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditSnapshot, WebError> {
let started = Instant::now();
let root = local_ai_session_root_dir(root_uri)?; let root = local_ai_session_root_dir(root_uri)?;
let canonical_root = root.canonicalize().map_err(|error| { let canonical_root = root.canonicalize().map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
@@ -8574,9 +8640,17 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
format!("无法访问本地文件夹: {error}"), format!("无法访问本地文件夹: {error}"),
) )
})?; })?;
let mut files = BTreeMap::new(); let mut files: BTreeMap<String, LocalAgentAuditFileSnapshot> = BTreeMap::new();
let mut truncated_reason = None;
let mut stack = vec![canonical_root.clone()]; let mut stack = vec![canonical_root.clone()];
while let Some(dir) = stack.pop() { while let Some(dir) = stack.pop() {
if truncated_reason.is_some() {
break;
}
if started.elapsed().as_millis() > LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS {
truncated_reason = Some("max_elapsed_ms".to_string());
break;
}
for entry in fs::read_dir(&dir).map_err(|error| { for entry in fs::read_dir(&dir).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
"local_ai_audit_snapshot_failed", "local_ai_audit_snapshot_failed",
@@ -8606,6 +8680,10 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
if !metadata.is_file() { if !metadata.is_file() {
continue; continue;
} }
if files.len() >= LOCAL_AGENT_AUDIT_MAX_FILES {
truncated_reason = Some("max_files".to_string());
break;
}
let relative_path = path let relative_path = path
.strip_prefix(&canonical_root) .strip_prefix(&canonical_root)
.unwrap_or(&path) .unwrap_or(&path)
@@ -8614,13 +8692,23 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
if relative_path.is_empty() { if relative_path.is_empty() {
continue; continue;
} }
files.insert(relative_path, local_agent_audit_snapshot_entry(&path)?); let snapshot_entry = local_agent_audit_snapshot_entry(&path)?;
let next_total =
files.values().map(|entry| entry.size).sum::<u64>() + snapshot_entry.size;
if next_total > LOCAL_AGENT_AUDIT_MAX_BYTES {
truncated_reason = Some("max_bytes".to_string());
break;
}
files.insert(relative_path, snapshot_entry);
} }
} }
Ok(LocalAgentAuditSnapshot { Ok(local_agent_audit_snapshot(
root_uri: root_uri.to_string(), root_uri,
files, files,
}) "full_root",
truncated_reason,
started.elapsed().as_millis(),
))
} }
fn local_agent_audit_context_ref_requires_full_snapshot(ref_value: &Value) -> bool { fn local_agent_audit_context_ref_requires_full_snapshot(ref_value: &Value) -> bool {
@@ -8642,8 +8730,41 @@ fn local_agent_audit_push_relative_path(paths: &mut Vec<String>, value: Option<&
paths.push(normalized); paths.push(normalized);
} }
fn local_agent_audit_push_allowed_files(paths: &mut Vec<String>, value: Option<&Value>) {
let Some(value) = value else {
return;
};
for relative_path in local_agent_target_allowed_files(value) {
local_agent_audit_push_relative_path(paths, Some(&Value::String(relative_path)));
}
}
fn local_agent_audit_relative_paths_from_payload(payload: &Value) -> Option<Vec<String>> { fn local_agent_audit_relative_paths_from_payload(payload: &Value) -> Option<Vec<String>> {
let mut paths = Vec::new(); let mut paths = Vec::new();
local_agent_audit_push_allowed_files(&mut paths, payload.get("targetPackage"));
local_agent_audit_push_allowed_files(
&mut paths,
payload
.get("pageContext")
.and_then(|page_context| page_context.get("aiContext"))
.and_then(|ai_context| ai_context.get("agentTargetPackage")),
);
local_agent_audit_push_allowed_files(
&mut paths,
payload
.get("agentRunEnvelope")
.and_then(|envelope| envelope.get("targetPackage")),
);
if let Some(items) = payload.get("allowedFiles").and_then(Value::as_array) {
for item in items {
local_agent_audit_push_relative_path(&mut paths, Some(item));
}
}
if !paths.is_empty() {
paths.sort();
paths.dedup();
return Some(paths);
}
if let Some(items) = payload.get("contextRefs").and_then(Value::as_array) { if let Some(items) = payload.get("contextRefs").and_then(Value::as_array) {
if items if items
.iter() .iter()
@@ -8687,6 +8808,7 @@ fn local_agent_audit_collect_snapshot_for_paths(
root_uri: &str, root_uri: &str,
relative_paths: &[String], relative_paths: &[String],
) -> Result<LocalAgentAuditSnapshot, WebError> { ) -> Result<LocalAgentAuditSnapshot, WebError> {
let started = Instant::now();
let root = local_ai_session_root_dir(root_uri)?; let root = local_ai_session_root_dir(root_uri)?;
let canonical_root = root.canonicalize().map_err(|error| { let canonical_root = root.canonicalize().map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
@@ -8694,8 +8816,17 @@ fn local_agent_audit_collect_snapshot_for_paths(
format!("无法访问本地文件夹: {error}"), format!("无法访问本地文件夹: {error}"),
) )
})?; })?;
let mut files = BTreeMap::new(); let mut files: BTreeMap<String, LocalAgentAuditFileSnapshot> = BTreeMap::new();
let mut truncated_reason = None;
for relative_path in relative_paths { for relative_path in relative_paths {
if started.elapsed().as_millis() > LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS {
truncated_reason = Some("max_elapsed_ms".to_string());
break;
}
if files.len() >= LOCAL_AGENT_AUDIT_MAX_FILES {
truncated_reason = Some("max_files".to_string());
break;
}
let normalized = relative_path.replace('\\', "/"); let normalized = relative_path.replace('\\', "/");
if normalized.is_empty() if normalized.is_empty()
|| normalized.starts_with('/') || normalized.starts_with('/')
@@ -8716,15 +8847,21 @@ fn local_agent_audit_collect_snapshot_for_paths(
if !canonical_path.starts_with(&canonical_root) || !canonical_path.is_file() { if !canonical_path.starts_with(&canonical_root) || !canonical_path.is_file() {
continue; continue;
} }
files.insert( let snapshot_entry = local_agent_audit_snapshot_entry(&canonical_path)?;
normalized, let next_total = files.values().map(|entry| entry.size).sum::<u64>() + snapshot_entry.size;
local_agent_audit_snapshot_entry(&canonical_path)?, if next_total > LOCAL_AGENT_AUDIT_MAX_BYTES {
); truncated_reason = Some("max_bytes".to_string());
break;
}
files.insert(normalized, snapshot_entry);
} }
Ok(LocalAgentAuditSnapshot { Ok(local_agent_audit_snapshot(
root_uri: root_uri.to_string(), root_uri,
files, files,
}) "allowed_files",
truncated_reason,
started.elapsed().as_millis(),
))
} }
fn local_agent_audit_collect_snapshot_for_payload( fn local_agent_audit_collect_snapshot_for_payload(
@@ -8876,7 +9013,7 @@ fn local_agent_audit_event(
status: &str, status: &str,
changed_files: Value, changed_files: Value,
source_snapshot: Option<&LocalAgentAuditSnapshot>, source_snapshot: Option<&LocalAgentAuditSnapshot>,
_target_snapshot: Option<&LocalAgentAuditSnapshot>, target_snapshot: Option<&LocalAgentAuditSnapshot>,
write_attempt_rejected: bool, write_attempt_rejected: bool,
) -> Value { ) -> Value {
let root_uri = payload let root_uri = payload
@@ -8902,6 +9039,7 @@ fn local_agent_audit_event(
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.unwrap_or(&context.auth.actor_type); .unwrap_or(&context.auth.actor_type);
let changed_file_count = changed_files.as_array().map(Vec::len).unwrap_or_default(); let changed_file_count = changed_files.as_array().map(Vec::len).unwrap_or_default();
let audit_scope = local_agent_audit_effective_scope_value(source_snapshot, target_snapshot);
let agent_run_receipt = build_agent_run_receipt( let agent_run_receipt = build_agent_run_receipt(
payload, payload,
run_id, run_id,
@@ -8909,6 +9047,7 @@ fn local_agent_audit_event(
status, status,
permission, permission,
changed_files.clone(), changed_files.clone(),
audit_scope.clone(),
write_attempt_rejected, write_attempt_rejected,
); );
json!({ json!({
@@ -8925,6 +9064,7 @@ fn local_agent_audit_event(
"status": if write_attempt_rejected { "read_only_write_rejected" } else { status }, "status": if write_attempt_rejected { "read_only_write_rejected" } else { status },
"writeAttemptRejected": write_attempt_rejected, "writeAttemptRejected": write_attempt_rejected,
"changedFiles": changed_files, "changedFiles": changed_files,
"auditScope": audit_scope,
"agentRunReceipt": agent_run_receipt, "agentRunReceipt": agent_run_receipt,
"diffSummary": format!("{changed_file_count} changed file(s)"), "diffSummary": format!("{changed_file_count} changed file(s)"),
"createdAt": now_ms(), "createdAt": now_ms(),
@@ -8947,18 +9087,12 @@ fn local_agent_audit_finalize_run(
let changed_files = match (&before, &after) { let changed_files = match (&before, &after) {
(Some(before), Some(after)) => local_agent_audit_change_files(before, after), (Some(before), Some(after)) => local_agent_audit_change_files(before, after),
(None, Some(after)) => local_agent_audit_change_files( (None, Some(after)) => local_agent_audit_change_files(
&LocalAgentAuditSnapshot { &local_agent_audit_empty_snapshot(&after.root_uri, "empty"),
root_uri: after.root_uri.clone(),
files: BTreeMap::new(),
},
after, after,
), ),
(Some(before), None) => local_agent_audit_change_files( (Some(before), None) => local_agent_audit_change_files(
before, before,
&LocalAgentAuditSnapshot { &local_agent_audit_empty_snapshot(&before.root_uri, "empty"),
root_uri: before.root_uri.clone(),
files: BTreeMap::new(),
},
), ),
(None, None) => Value::Array(vec![]), (None, None) => Value::Array(vec![]),
}; };
@@ -10515,25 +10649,26 @@ mod tests {
.all(|capability| capability["id"] != "mnote-chat-only"), .all(|capability| capability["id"] != "mnote-chat-only"),
"纯聊天是 agent 模式,不应作为 MNote 公共能力展示" "纯聊天是 agent 模式,不应作为 MNote 公共能力展示"
); );
let local_index = payload["categories"] assert!(!payload["categories"]
.as_array() .as_array()
.expect("categories") .expect("categories")
.iter() .iter()
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten()) .flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.find(|capability| capability["id"] == "mnote-local-index") .any(|capability| capability["id"] == "mnote-local-index"));
.expect("local index capability"); let knowledge_rag = payload["categories"]
assert_eq!(local_index["enabled"], true);
assert_eq!(local_index["uiKind"], "ai_capability");
assert!(local_index["tools"]
.as_array() .as_array()
.expect("local index tools") .expect("categories")
.iter() .iter()
.any(|tool| tool["name"] == "mnote.index.status")); .flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
assert!(local_index["tools"] .find(|capability| capability["id"] == "mnote-knowledge-rag")
.expect("knowledge rag capability");
assert_eq!(knowledge_rag["enabled"], true);
assert_eq!(knowledge_rag["uiKind"], "ai_capability");
assert!(knowledge_rag["tools"]
.as_array() .as_array()
.expect("local index tools") .expect("knowledge rag tools")
.iter() .iter()
.any(|tool| tool["name"] == "mnote.index.update_settings")); .any(|tool| tool["name"] == "mnote.knowledge_rag.query"));
let toggle_response = app let toggle_response = app
.clone() .clone()
@@ -10548,7 +10683,7 @@ mod tests {
json!({ json!({
"runtime": "mnote", "runtime": "mnote",
"profile": "chemist", "profile": "chemist",
"id": "mnote-local-index", "id": "mnote-knowledge-rag",
"enabled": false "enabled": false
}) })
.to_string(), .to_string(),
@@ -10577,20 +10712,20 @@ mod tests {
.await .await
.expect("body"); .expect("body");
let payload: Value = serde_json::from_slice(&body).expect("capabilities json"); let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
let local_index = payload["categories"] let knowledge_rag = payload["categories"]
.as_array() .as_array()
.expect("categories") .expect("categories")
.iter() .iter()
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten()) .flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.find(|capability| capability["id"] == "mnote-local-index") .find(|capability| capability["id"] == "mnote-knowledge-rag")
.expect("local index capability"); .expect("knowledge rag capability");
assert_eq!(local_index["enabled"], false); assert_eq!(knowledge_rag["enabled"], false);
assert_eq!(local_index["status"], "disabled"); assert_eq!(knowledge_rag["status"], "disabled");
assert!(local_index["tools"] assert!(knowledge_rag["tools"]
.as_array() .as_array()
.expect("local index tools") .expect("knowledge rag tools")
.iter() .iter()
.any(|tool| tool["name"] == "mnote.index.status" && tool["enabled"] == false)); .any(|tool| tool["name"] == "mnote.knowledge_rag.query" && tool["enabled"] == false));
let tools_response = app let tools_response = app
.oneshot( .oneshot(
@@ -10619,8 +10754,7 @@ mod tests {
) )
}) })
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
assert_eq!(tools["mnote.index.status"]["enabled"], false); assert_eq!(tools["mnote.knowledge_rag.query"]["enabled"], false);
assert_eq!(tools["mnote.index.update_settings"]["enabled"], false);
std::env::remove_var("HERMES_HOME"); std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home); let _ = fs::remove_dir_all(&hermes_home);
@@ -10963,6 +11097,124 @@ mod tests {
let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&root);
} }
#[test]
fn local_agent_audit_allowed_files_override_folder_context() {
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-allowed-files-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(root.join("a.md"), "# A\nold\n").expect("write a");
std::fs::write(root.join("b.md"), "# B\nold\n").expect("write b");
let root_uri = format!("file://{}", root.display());
let payload = json!({
"documentId": "local-md:a.md",
"rootUri": root_uri,
"contextRefs": [{
"kind": "folder",
"rootUri": root_uri,
"relativePath": ""
}],
"targetPackage": {
"schema": "mnote.agent_target_package.v1",
"allowedFiles": ["a.md"],
"currentFile": {
"relativePath": "a.md"
}
}
});
let before = local_agent_audit_collect_snapshot_for_payload(&payload, None)
.expect("before scoped snapshot");
assert!(before.files.contains_key("a.md"));
assert!(!before.files.contains_key("b.md"));
std::fs::write(root.join("a.md"), "# A\nnew\n").expect("modify a");
std::fs::write(root.join("b.md"), "# B\nnew\n").expect("modify b");
let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before))
.expect("after scoped snapshot");
let changed = local_agent_audit_change_files(&before, &after);
let files = changed.as_array().expect("changed files");
assert_eq!(files.len(), 1);
assert_eq!(files[0]["path"], "a.md");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_agent_audit_reads_allowed_files_from_agent_run_envelope() {
let payload = json!({
"contextRefs": [{
"kind": "folder",
"relativePath": ""
}],
"agentRunEnvelope": {
"targetPackage": {
"allowedFiles": ["nested/a.md", "../blocked.md", "/abs.md", ""],
"currentFile": {
"relativePath": "nested/a.md"
}
}
}
});
let paths = local_agent_audit_relative_paths_from_payload(&payload)
.expect("allowed files should avoid full snapshot");
assert_eq!(paths, vec!["abs.md".to_string(), "nested/a.md".to_string()]);
}
#[test]
fn local_agent_audit_full_snapshot_truncates_and_receipt_exposes_scope() {
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-truncated-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create root");
for index in 0..(LOCAL_AGENT_AUDIT_MAX_FILES + 1) {
std::fs::write(
root.join(format!("file-{index}.md")),
format!("# {index}\n"),
)
.expect("write file");
}
let root_uri = format!("file://{}", root.display());
let snapshot = local_agent_audit_collect_snapshot(&root_uri).expect("snapshot");
assert!(snapshot.truncated);
assert_eq!(snapshot.truncated_reason.as_deref(), Some("max_files"));
assert_eq!(snapshot.file_count, LOCAL_AGENT_AUDIT_MAX_FILES);
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/client/runs".parse().expect("uri"),
&HeaderMap::new(),
);
let event = local_agent_audit_event(
&context,
&json!({
"workspaceId": "local-workspace-1",
"documentId": "local-md:file-0.md",
"sessionId": "sess_local_1",
"rootUri": root_uri,
}),
"run_truncated",
"reasonix",
"completed",
json!([]),
Some(&snapshot),
Some(&snapshot),
false,
);
assert_eq!(event["auditScope"]["truncated"], true);
assert_eq!(event["auditScope"]["truncatedReason"], "max_files");
assert_eq!(event["agentRunReceipt"]["auditScope"]["truncated"], true);
assert_eq!(
event["agentRunReceipt"]["auditScope"]["limits"]["maxFiles"],
LOCAL_AGENT_AUDIT_MAX_FILES
);
let _ = std::fs::remove_dir_all(&root);
}
fn app() -> axum::Router { fn app() -> axum::Router {
app_with_config(AppConfig { app_with_config(AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
@@ -13995,6 +14247,13 @@ mod tests {
assert!(instructions.contains("\"relativePath\":\"README.md\"")); assert!(instructions.contains("\"relativePath\":\"README.md\""));
assert!(instructions.contains("本地文件夹上下文")); assert!(instructions.contains("本地文件夹上下文"));
assert!(instructions.contains("agent 自身文件读取/编辑能力")); assert!(instructions.contains("agent 自身文件读取/编辑能力"));
assert!(instructions
.contains("不要调用 mnote_doc_markdown_edit 或 mnote_page_save 处理 local-first 普通 Markdown 编辑"));
assert!(instructions.contains(
"do not use mnote_doc_markdown_edit or mnote_page_save for ordinary local Markdown edits"
));
assert!(instructions
.contains("\"forOrdinaryLocalMarkdown\":\"forbidden_use_agent_native_file_patch\""));
assert!(!instructions.contains("mnote_doc_fetch")); assert!(!instructions.contains("mnote_doc_fetch"));
assert!(instructions.contains("\"selectedText\":\"选中的句子\"")); assert!(instructions.contains("\"selectedText\":\"选中的句子\""));
assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions")); assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions"));
@@ -14932,7 +15191,17 @@ mod tests {
assert!(tools.contains_key("mnote.block.delete")); assert!(tools.contains_key("mnote.block.delete"));
assert!(tools.contains_key("mnote.block.move_after")); assert!(tools.contains_key("mnote.block.move_after"));
assert!(tools.contains_key("mnote.doc.apply_block_ops")); assert!(tools.contains_key("mnote.doc.apply_block_ops"));
assert!(tools.contains_key("mnote.doc.markdown_edit"));
assert!(tools.contains_key("mnote.page.save"));
assert_eq!(tools["mnote.doc.fetch"]["enabled"], true); assert_eq!(tools["mnote.doc.fetch"]["enabled"], true);
assert!(tools["mnote.doc.markdown_edit"]["description"]
.as_str()
.unwrap_or_default()
.contains("local-first 本地 workspace 的普通 Markdown 编辑禁止使用该工具"));
assert!(tools["mnote.page.save"]["description"]
.as_str()
.unwrap_or_default()
.contains("local-first 本地 Markdown 普通编辑禁止使用该工具"));
assert_eq!(tools["mnote.block.replace"]["enabled"], false); assert_eq!(tools["mnote.block.replace"]["enabled"], false);
assert_eq!(tools["mnote.block.replace"]["status"], "disabled"); assert_eq!(tools["mnote.block.replace"]["status"], "disabled");
assert_eq!( assert_eq!(
@@ -0,0 +1,381 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task535-page-ai-local-agent-clean-edit-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
function parseJsonBody(record) {
try {
return JSON.parse(record.body || "{}");
} catch (error) {
throw new Error(`无法解析 JSON body: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task535`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task535-agent-clean-"));
const rootUri = fileUrl(root);
const relativePath = "AgentClean.md";
const documentId = localMdDocumentId(relativePath);
const filePath = path.join(root, relativePath);
const initialToken = `task535-initial-${suffix}`;
const patchedToken = `task535-agent-patched-${suffix}`;
const runId = `run_task535_${suffix}`;
const sessionId = `mnote_task535_${suffix}`;
const captured = [];
const blockedRequests = [];
let eventStreamRequested = false;
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
filePath,
["# Agent Clean", "", initialToken, ""].join("\n"),
"utf8",
);
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/documents/save") || url.includes("/api/page-body/write")) {
blockedRequests.push({ url, method: request.method(), body: request.postData() || "" });
}
});
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task535_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
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" },
profile: { name: "reasonix", 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/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "reasonix",
profiles: [
{ profileId: "shared_gemini_chat", kind: "shared", displayName: "Gemini Chat", canRun: true, readonly: true },
{ profileId: "usr_task535_default", kind: "personal", displayName: "我的 Hermes", baseProfile: "default", isolatedProfile: "mnote-u-task535-default", canRun: true },
],
}),
});
});
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: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
title: "task535",
traceId: `trace_task535_session_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
runId,
events: [],
traceId: `trace_task535_run_${suffix}`,
}),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
eventStreamRequested = true;
fs.writeFileSync(
filePath,
["# Agent Clean", "", initialToken, "", patchedToken, ""].join("\n"),
"utf8",
);
const completed = {
event: "run.completed",
run_id: runId,
output: "Task535 response",
agentAudit: {
rootUri,
actorId,
actorType: "user",
agentKind: "reasonix",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task535 native file patch" }],
agentRunReceipt: {
schema: "mnote.agent_run_receipt.v1",
runId,
sessionId,
workspaceId,
documentId,
rootUri,
agentKind: "reasonix",
status: "completed",
permission: "write",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task535 native file patch" }],
refresh: {
touchesCurrentFile: true,
currentDocumentId: documentId,
strategy: "refresh_current_file",
},
},
},
};
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, delta: "Task535 response" })}\n\n`
+ `data: ${JSON.stringify(completed)}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForEditorText(page, initialToken);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("请用原生文件编辑能力追加 task535 标记", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task535 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") === "true"
&& document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh") === "true",
null,
{ timeout: UI_TIMEOUT_MS },
);
await waitForEditorText(page, patchedToken);
const runs = captured.filter((item) => item.kind === "run");
assert.strictEqual(runs.length, 1, `应只启动一次 Page AI run,实际 ${runs.length}`);
assert(eventStreamRequested, "Page AI run 应继续读取 SSE events");
const runBody = parseJsonBody(runs[0]);
assert.strictEqual(runBody.sourceKind, "local_folder", `run sourceKind 应为 local_folder: ${JSON.stringify(runBody)}`);
assert.strictEqual(runBody.rootUri, rootUri, `run rootUri 应指向测试工作区: ${JSON.stringify(runBody)}`);
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "run payload 应携带 targetPackage");
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, relativePath, `currentFile 应指向当前 Markdown: ${JSON.stringify(runBody.targetPackage)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(relativePath), `allowedFiles 应包含当前 Markdown: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
assert.strictEqual(
runBody.targetPackage?.targets?.[0]?.policy?.permission,
"read_write",
`write grant 下 target policy 应为 read_write: ${JSON.stringify(runBody.targetPackage?.targets?.[0]?.policy)}`,
);
const serializedRunBody = JSON.stringify(runBody);
const usedMarkdownEdit = serializedRunBody.includes("mnote.doc.markdown_edit");
const usedPageSave = serializedRunBody.includes("mnote.page.save");
const usedDocumentsSave = blockedRequests.length > 0;
assert(!usedMarkdownEdit, "local-first 普通 Markdown run payload 不应要求 mnote.doc.markdown_edit");
assert(!usedPageSave, "local-first 普通 Markdown run payload 不应要求 mnote.page.save");
assert(!usedDocumentsSave, `clean agent 原生文件编辑 smoke 不应调用页面保存接口: ${JSON.stringify(blockedRequests)}`);
const finalDiskContent = fs.readFileSync(filePath, "utf8");
assert(finalDiskContent.includes(patchedToken), "磁盘文件应包含 agent 原生写入内容");
const screenshot = await saveScreenshot(page, "01-clean-agent-edit");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
relativePath,
patchedToken,
screenshot,
captured,
blockedRequests,
usedDocumentsSave,
usedMarkdownEdit,
usedPageSave,
currentRefresh: true,
filetreeRefresh: true,
finalDiskContent,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
@@ -0,0 +1,309 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task536-page-ai-local-agent-dirty-guard-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForEditorStatus(page, expected) {
await page.waitForFunction(
(status) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return root?.getAttribute("data-runtime-editor-status") === status;
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function typeDirtyText(page, text) {
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type(text, { delay: 5 });
await waitForEditorText(page, text.trim());
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task536`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task536-dirty-"));
const rootUri = fileUrl(root);
const relativePath = "DirtyGuard.md";
const documentId = localMdDocumentId(relativePath);
const filePath = path.join(root, relativePath);
const initialToken = `task536-initial-${suffix}`;
const dirtyToken = `task536-dirty-${suffix}`;
const forbiddenToken = `task536-forbidden-${suffix}`;
const captured = [];
const blockedRequests = [];
const bufferStateRequests = [];
let forceDirtyState = false;
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
filePath,
["# Dirty Guard", "", initialToken, ""].join("\n"),
"utf8",
);
const originalDiskContent = fs.readFileSync(filePath, "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/documents/save") || url.includes("/api/page-body/write")) {
blockedRequests.push({ url, method: request.method(), body: request.postData() || "" });
}
});
try {
await page.route("**/api/documents/buffer-state?**", async (route) => {
bufferStateRequests.push(route.request().url());
if (!forceDirtyState) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
result: {
documentId,
sourceKind: "local_folder",
rootUri,
relativePath,
dirtyState: "Dirty",
externalActor: null,
fileVersion: `task536-dirty-buffer-${suffix}`,
},
}),
});
});
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task536_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }) });
});
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 }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
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/ai/agent-profiles**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }) });
});
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: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: `mnote_task536_${suffix}`, title: "task536", traceId: `trace_task536_session_${suffix}` }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 500,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: false, code: "task536_runs_should_not_be_called" }),
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForEditorText(page, initialToken);
await typeDirtyText(page, ` ${dirtyToken}`);
await waitForEditorStatus(page, "dirty");
forceDirtyState = true;
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请新增 ${forbiddenToken}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "failed"
&& (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("未保存或外部变更"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
const blockedBeforeRun = runs.length === 0;
assert(blockedBeforeRun, `dirty buffer 应在 /runs 前被拦截,实际捕获 ${runs.length} 次 run`);
assert(bufferStateRequests.length >= 1, "dirty guard 应查询 /api/documents/buffer-state");
assert.strictEqual(blockedRequests.length, 0, `dirty guard 不应调用页面写入接口: ${JSON.stringify(blockedRequests)}`);
const finalDiskContent = fs.readFileSync(filePath, "utf8");
const diskChanged = finalDiskContent !== originalDiskContent;
const editorDirtyTextStillVisible = await page.evaluate(
(expected) => (document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror')?.textContent || "").includes(expected),
dirtyToken,
);
assert(!diskChanged, "dirty guard 后磁盘内容必须保持不变");
assert(editorDirtyTextStillVisible, "dirty guard 后编辑器中未保存内容应仍可见");
assert(!finalDiskContent.includes(forbiddenToken), "dirty guard 后磁盘不应包含 forbidden token");
const screenshot = await saveScreenshot(page, "01-dirty-guard");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
relativePath,
dirtyToken,
forbiddenToken,
screenshot,
captured,
blockedRequests,
bufferStateRequests,
blockedBeforeRun,
diskChanged,
editorDirtyTextStillVisible,
finalDiskContent,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
@@ -0,0 +1,257 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task537-page-ai-local-agent-readonly-write-guard-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task537`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task537-readonly-"));
const rootUri = fileUrl(root);
const relativePath = "ReadonlyGuard.md";
const documentId = localMdDocumentId(relativePath);
const filePath = path.join(root, relativePath);
const initialToken = `task537-initial-${suffix}`;
const forbiddenToken = `task537-forbidden-${suffix}`;
const captured = [];
const blockedRequests = [];
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
filePath,
["# Readonly Guard", "", initialToken, ""].join("\n"),
"utf8",
);
const originalDiskContent = fs.readFileSync(filePath, "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/documents/save") || url.includes("/api/page-body/write")) {
blockedRequests.push({ url, method: request.method(), body: request.postData() || "" });
}
});
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task537_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "read",
recursive: true,
capabilities: ["ai"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
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 }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
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/ai/agent-profiles**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }) });
});
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: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }) });
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: `mnote_task537_${suffix}`, title: "task537", traceId: `trace_task537_session_${suffix}` }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 500,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: false, code: "task537_runs_should_not_be_called" }),
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForEditorText(page, initialToken);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请新增 ${forbiddenToken}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "failed"
&& (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("只读授权"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
const blockedBeforeRun = runs.length === 0;
assert(blockedBeforeRun, `只读写入应在 /runs 前被拦截,实际捕获 ${runs.length} 次 run`);
assert.strictEqual(blockedRequests.length, 0, `只读写入不应调用页面写入接口: ${JSON.stringify(blockedRequests)}`);
const finalDiskContent = fs.readFileSync(filePath, "utf8");
const diskChanged = finalDiskContent !== originalDiskContent;
assert(!diskChanged, "只读 guard 后磁盘内容必须保持不变");
assert(!finalDiskContent.includes(forbiddenToken), "只读 guard 后磁盘不应包含 forbidden token");
const screenshot = await saveScreenshot(page, "01-readonly-guard");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
relativePath,
forbiddenToken,
screenshot,
captured,
blockedRequests,
blockedBeforeRun,
diskChanged,
finalDiskContent,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}