feat(ai): harden local agent file edit guards
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 7-18 Agent edit clean / dirty smoke closure checklist v1
|
||||
|
||||
> 创建时间:2026-06-06
|
||||
>
|
||||
> 状态:`process`
|
||||
>
|
||||
> Owner:07-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 A:clean 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 run,run 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 B:dirty 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 C:readonly 前置拒绝 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。
|
||||
- [ ] 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 审计层拒绝。
|
||||
- [ ] 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`。
|
||||
|
||||
### Phase B:写入回收与前台同步
|
||||
|
||||
- [ ] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。
|
||||
- [ ] clean buffer 下 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新通过 browser smoke。
|
||||
- [ ] dirty buffer 下外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。
|
||||
- [ ] changed files / diff 与 session / run / actor 关联写入审计。
|
||||
- [x] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。
|
||||
- [x] clean buffer 下 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新通过 browser smoke。
|
||||
- [x] dirty buffer 下外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。
|
||||
- [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:旧工具退役
|
||||
|
||||
- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `/api/documents/save`。
|
||||
- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `mnote.doc.markdown_edit`。
|
||||
- [ ] Hermes / Reasonix manifest 或 system prompt 不再鼓励普通 Markdown 编辑调用 `mnote.doc.markdown_edit`。
|
||||
- [ ] 旧 `markdown_edit` 测试只保留为历史 online / compat 回归,并在文档中标明不指导新实现。
|
||||
- [x] local-first 普通 Markdown 编辑 smoke 断言未调用 `/api/documents/save`。
|
||||
- [x] local-first 普通 Markdown 编辑 smoke 断言未调用 `mnote.doc.markdown_edit`。
|
||||
- [x] Hermes / Reasonix manifest 或 system prompt 不再鼓励普通 Markdown 编辑调用 `mnote.doc.markdown_edit`。
|
||||
- [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:结构辅助边界
|
||||
|
||||
- [ ] `mnote.block.*` 只在复杂结构辅助场景出现,例如结构块排序、非 Markdown 资源辅助。
|
||||
- [ ] `mnote.page.save` 只作为页面级兜底写入,不作为默认精确编辑入口。
|
||||
- [x] `mnote.page.save` 只作为页面级兜底写入,不作为默认精确编辑入口。
|
||||
- [ ] `mnote.doc.fetch` 不作为 local-first 正文读取主入口;需要结构化上下文时由 MNote 在 run input 中提供摘要或 projection。
|
||||
|
||||
## 9. 不做事项
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
>
|
||||
> 范围:当前主线中 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. 本轮结论
|
||||
|
||||
|
||||
@@ -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) {
|
||||
if (currentSourceKind() === 'local_folder') return false;
|
||||
if (!pageAiLooksLikeBlockEdit(prompt)) return false;
|
||||
@@ -1955,14 +1966,15 @@ export function createSidebarPageAiRuntime(context) {
|
||||
var scopedContext = pageAiScopedPageContext(contextSnapshot);
|
||||
scopedContext.editorTarget = currentPageAiScopedEditorTarget();
|
||||
assertPageAiTargetInCurrentWorkspace(scopedContext.editorTarget);
|
||||
await assertPageAiTargetWritable(scopedContext.editorTarget);
|
||||
var runTargetSnapshot = pageAiBuildRunTargetSnapshot(scopedContext, prompt);
|
||||
if (currentSourceKind() === 'local_folder' && !pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).length) {
|
||||
await pageAiLoadAllowedRoots();
|
||||
}
|
||||
await assertPageAiTargetWritable(scopedContext.editorTarget);
|
||||
var runTargetSnapshot = pageAiBuildRunTargetSnapshot(scopedContext, prompt);
|
||||
var contextRefs = pageAiBuildContextRefs(scopedContext, runTargetSnapshot);
|
||||
var allowedRoots = pageAiBuildAllowedRoots();
|
||||
var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot);
|
||||
assertPageAiLocalWritePermission(prompt, agentTargetPackage);
|
||||
if (typeof pageAiEnrichOcrContextRefs === 'function') {
|
||||
var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget);
|
||||
contextRefs = ocrContext.contextRefs || contextRefs;
|
||||
|
||||
@@ -24,7 +24,7 @@ use std::io::Write;
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast;
|
||||
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_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)]
|
||||
struct HermesRuntimeState {
|
||||
@@ -133,6 +136,12 @@ struct LocalAgentAuditFileSnapshot {
|
||||
struct LocalAgentAuditSnapshot {
|
||||
root_uri: String,
|
||||
files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
|
||||
scope: String,
|
||||
truncated: bool,
|
||||
truncated_reason: Option<String>,
|
||||
file_count: usize,
|
||||
total_bytes: u64,
|
||||
elapsed_ms: u128,
|
||||
}
|
||||
|
||||
#[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 的授权边界。",
|
||||
"不要调用已退役 cloud/Convex 文档读取工具读取本地 Markdown。",
|
||||
"普通 Markdown 编辑优先使用 agent 原生 patch/diff 写入真实文件;写入后回读文件确认结果。",
|
||||
"不要调用 mnote_doc_markdown_edit 或 mnote_page_save 处理 local-first 普通 Markdown 编辑。",
|
||||
"只有复杂结构性块操作或 remote/cloud 兼容场景,才考虑 mnote block/doc 工具;",
|
||||
"mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级或文件 patch 无法表达时作为高风险兜底。",
|
||||
"当 agentRunEnvelope.targetPackage.resourceKind 为 only_office 或 targets 中包含 only_office 时,",
|
||||
@@ -6471,6 +6481,7 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
|
||||
json!([
|
||||
"agent native file read/write within allowed roots",
|
||||
"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",
|
||||
"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,
|
||||
"pageSavePolicy": {
|
||||
"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),
|
||||
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
|
||||
@@ -7505,6 +7517,7 @@ fn build_agent_run_receipt(
|
||||
status: &str,
|
||||
permission: &str,
|
||||
changed_files: Value,
|
||||
audit_scope: Value,
|
||||
write_attempt_rejected: bool,
|
||||
) -> Value {
|
||||
let touches_current_file = local_agent_audit_touches_current_file(payload, &changed_files);
|
||||
@@ -7520,6 +7533,7 @@ fn build_agent_run_receipt(
|
||||
"permission": permission,
|
||||
"writeAttemptRejected": write_attempt_rejected,
|
||||
"changedFiles": changed_files,
|
||||
"auditScope": audit_scope,
|
||||
"refresh": {
|
||||
"touchesCurrentFile": touches_current_file,
|
||||
"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> {
|
||||
let started = Instant::now();
|
||||
let root = local_ai_session_root_dir(root_uri)?;
|
||||
let canonical_root = root.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -8574,9 +8640,17 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
|
||||
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()];
|
||||
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| {
|
||||
WebError::bad_request_code(
|
||||
"local_ai_audit_snapshot_failed",
|
||||
@@ -8606,6 +8680,10 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
if files.len() >= LOCAL_AGENT_AUDIT_MAX_FILES {
|
||||
truncated_reason = Some("max_files".to_string());
|
||||
break;
|
||||
}
|
||||
let relative_path = path
|
||||
.strip_prefix(&canonical_root)
|
||||
.unwrap_or(&path)
|
||||
@@ -8614,13 +8692,23 @@ fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result<LocalAgentAuditS
|
||||
if relative_path.is_empty() {
|
||||
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 {
|
||||
root_uri: root_uri.to_string(),
|
||||
Ok(local_agent_audit_snapshot(
|
||||
root_uri,
|
||||
files,
|
||||
})
|
||||
"full_root",
|
||||
truncated_reason,
|
||||
started.elapsed().as_millis(),
|
||||
))
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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>> {
|
||||
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 items
|
||||
.iter()
|
||||
@@ -8687,6 +8808,7 @@ fn local_agent_audit_collect_snapshot_for_paths(
|
||||
root_uri: &str,
|
||||
relative_paths: &[String],
|
||||
) -> Result<LocalAgentAuditSnapshot, WebError> {
|
||||
let started = Instant::now();
|
||||
let root = local_ai_session_root_dir(root_uri)?;
|
||||
let canonical_root = root.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -8694,8 +8816,17 @@ fn local_agent_audit_collect_snapshot_for_paths(
|
||||
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 {
|
||||
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('\\', "/");
|
||||
if normalized.is_empty()
|
||||
|| 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() {
|
||||
continue;
|
||||
}
|
||||
files.insert(
|
||||
normalized,
|
||||
local_agent_audit_snapshot_entry(&canonical_path)?,
|
||||
);
|
||||
let snapshot_entry = local_agent_audit_snapshot_entry(&canonical_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;
|
||||
}
|
||||
Ok(LocalAgentAuditSnapshot {
|
||||
root_uri: root_uri.to_string(),
|
||||
files.insert(normalized, snapshot_entry);
|
||||
}
|
||||
Ok(local_agent_audit_snapshot(
|
||||
root_uri,
|
||||
files,
|
||||
})
|
||||
"allowed_files",
|
||||
truncated_reason,
|
||||
started.elapsed().as_millis(),
|
||||
))
|
||||
}
|
||||
|
||||
fn local_agent_audit_collect_snapshot_for_payload(
|
||||
@@ -8876,7 +9013,7 @@ fn local_agent_audit_event(
|
||||
status: &str,
|
||||
changed_files: Value,
|
||||
source_snapshot: Option<&LocalAgentAuditSnapshot>,
|
||||
_target_snapshot: Option<&LocalAgentAuditSnapshot>,
|
||||
target_snapshot: Option<&LocalAgentAuditSnapshot>,
|
||||
write_attempt_rejected: bool,
|
||||
) -> Value {
|
||||
let root_uri = payload
|
||||
@@ -8902,6 +9039,7 @@ 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 audit_scope = local_agent_audit_effective_scope_value(source_snapshot, target_snapshot);
|
||||
let agent_run_receipt = build_agent_run_receipt(
|
||||
payload,
|
||||
run_id,
|
||||
@@ -8909,6 +9047,7 @@ fn local_agent_audit_event(
|
||||
status,
|
||||
permission,
|
||||
changed_files.clone(),
|
||||
audit_scope.clone(),
|
||||
write_attempt_rejected,
|
||||
);
|
||||
json!({
|
||||
@@ -8925,6 +9064,7 @@ fn local_agent_audit_event(
|
||||
"status": if write_attempt_rejected { "read_only_write_rejected" } else { status },
|
||||
"writeAttemptRejected": write_attempt_rejected,
|
||||
"changedFiles": changed_files,
|
||||
"auditScope": audit_scope,
|
||||
"agentRunReceipt": agent_run_receipt,
|
||||
"diffSummary": format!("{changed_file_count} changed file(s)"),
|
||||
"createdAt": now_ms(),
|
||||
@@ -8947,18 +9087,12 @@ fn local_agent_audit_finalize_run(
|
||||
let changed_files = match (&before, &after) {
|
||||
(Some(before), Some(after)) => local_agent_audit_change_files(before, after),
|
||||
(None, Some(after)) => local_agent_audit_change_files(
|
||||
&LocalAgentAuditSnapshot {
|
||||
root_uri: after.root_uri.clone(),
|
||||
files: BTreeMap::new(),
|
||||
},
|
||||
&local_agent_audit_empty_snapshot(&after.root_uri, "empty"),
|
||||
after,
|
||||
),
|
||||
(Some(before), None) => local_agent_audit_change_files(
|
||||
before,
|
||||
&LocalAgentAuditSnapshot {
|
||||
root_uri: before.root_uri.clone(),
|
||||
files: BTreeMap::new(),
|
||||
},
|
||||
&local_agent_audit_empty_snapshot(&before.root_uri, "empty"),
|
||||
),
|
||||
(None, None) => Value::Array(vec![]),
|
||||
};
|
||||
@@ -10515,25 +10649,26 @@ mod tests {
|
||||
.all(|capability| capability["id"] != "mnote-chat-only"),
|
||||
"纯聊天是 agent 模式,不应作为 MNote 公共能力展示"
|
||||
);
|
||||
let local_index = payload["categories"]
|
||||
assert!(!payload["categories"]
|
||||
.as_array()
|
||||
.expect("categories")
|
||||
.iter()
|
||||
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||
.find(|capability| capability["id"] == "mnote-local-index")
|
||||
.expect("local index capability");
|
||||
assert_eq!(local_index["enabled"], true);
|
||||
assert_eq!(local_index["uiKind"], "ai_capability");
|
||||
assert!(local_index["tools"]
|
||||
.any(|capability| capability["id"] == "mnote-local-index"));
|
||||
let knowledge_rag = payload["categories"]
|
||||
.as_array()
|
||||
.expect("local index tools")
|
||||
.expect("categories")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.index.status"));
|
||||
assert!(local_index["tools"]
|
||||
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||
.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()
|
||||
.expect("local index tools")
|
||||
.expect("knowledge rag tools")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.index.update_settings"));
|
||||
.any(|tool| tool["name"] == "mnote.knowledge_rag.query"));
|
||||
|
||||
let toggle_response = app
|
||||
.clone()
|
||||
@@ -10548,7 +10683,7 @@ mod tests {
|
||||
json!({
|
||||
"runtime": "mnote",
|
||||
"profile": "chemist",
|
||||
"id": "mnote-local-index",
|
||||
"id": "mnote-knowledge-rag",
|
||||
"enabled": false
|
||||
})
|
||||
.to_string(),
|
||||
@@ -10577,20 +10712,20 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
|
||||
let local_index = payload["categories"]
|
||||
let knowledge_rag = payload["categories"]
|
||||
.as_array()
|
||||
.expect("categories")
|
||||
.iter()
|
||||
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||
.find(|capability| capability["id"] == "mnote-local-index")
|
||||
.expect("local index capability");
|
||||
assert_eq!(local_index["enabled"], false);
|
||||
assert_eq!(local_index["status"], "disabled");
|
||||
assert!(local_index["tools"]
|
||||
.find(|capability| capability["id"] == "mnote-knowledge-rag")
|
||||
.expect("knowledge rag capability");
|
||||
assert_eq!(knowledge_rag["enabled"], false);
|
||||
assert_eq!(knowledge_rag["status"], "disabled");
|
||||
assert!(knowledge_rag["tools"]
|
||||
.as_array()
|
||||
.expect("local index tools")
|
||||
.expect("knowledge rag tools")
|
||||
.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
|
||||
.oneshot(
|
||||
@@ -10619,8 +10754,7 @@ mod tests {
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
assert_eq!(tools["mnote.index.status"]["enabled"], false);
|
||||
assert_eq!(tools["mnote.index.update_settings"]["enabled"], false);
|
||||
assert_eq!(tools["mnote.knowledge_rag.query"]["enabled"], false);
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
@@ -10963,6 +11097,124 @@ mod tests {
|
||||
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 {
|
||||
app_with_config(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -13995,6 +14247,13 @@ mod tests {
|
||||
assert!(instructions.contains("\"relativePath\":\"README.md\""));
|
||||
assert!(instructions.contains("本地文件夹上下文"));
|
||||
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("\"selectedText\":\"选中的句子\""));
|
||||
assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions"));
|
||||
@@ -14932,7 +15191,17 @@ mod tests {
|
||||
assert!(tools.contains_key("mnote.block.delete"));
|
||||
assert!(tools.contains_key("mnote.block.move_after"));
|
||||
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!(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"]["status"], "disabled");
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user