diff --git a/design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md b/design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md new file mode 100644 index 00000000..588b57ae --- /dev/null +++ b/design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md @@ -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 中被修改或作为验收前置。 diff --git a/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md index f129fc23..fb65daa8 100644 --- a/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md +++ b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md @@ -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. 不做事项 diff --git a/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md b/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md index 0a2f9864..a7db6c1f 100644 --- a/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md +++ b/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md @@ -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. 本轮结论 diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js index e2a1e361..a2008662 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -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; diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index b50b9cf8..2c9493b9 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -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, + scope: String, + truncated: bool, + truncated_reason: Option, + 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 Result Result 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, + scope: &str, + truncated_reason: Option, + 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 { + 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 = 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= 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() + 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, value: Option<& paths.push(normalized); } +fn local_agent_audit_push_allowed_files(paths: &mut Vec, 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> { 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 { + 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 = 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::() + snapshot_entry.size; + if next_total > LOCAL_AGENT_AUDIT_MAX_BYTES { + truncated_reason = Some("max_bytes".to_string()); + break; + } + files.insert(normalized, snapshot_entry); } - Ok(LocalAgentAuditSnapshot { - root_uri: root_uri.to_string(), + 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::>(); - 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!( diff --git a/scripts/task535-page-ai-local-agent-clean-edit-smoke.js b/scripts/task535-page-ai-local-agent-clean-edit-smoke.js new file mode 100644 index 00000000..a3ce9335 --- /dev/null +++ b/scripts/task535-page-ai-local-agent-clean-edit-smoke.js @@ -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); + }); +} diff --git a/scripts/task536-page-ai-local-agent-dirty-guard-smoke.js b/scripts/task536-page-ai-local-agent-dirty-guard-smoke.js new file mode 100644 index 00000000..2ea90dc5 --- /dev/null +++ b/scripts/task536-page-ai-local-agent-dirty-guard-smoke.js @@ -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); + }); +} diff --git a/scripts/task537-page-ai-local-agent-readonly-write-guard-smoke.js b/scripts/task537-page-ai-local-agent-readonly-write-guard-smoke.js new file mode 100644 index 00000000..4d629f26 --- /dev/null +++ b/scripts/task537-page-ai-local-agent-readonly-write-guard-smoke.js @@ -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); + }); +}