From 68d321e297f619a4170c355606e6bf2d0766ef9c Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 19 May 2026 08:07:17 +0800 Subject: [PATCH] feat: advance local-first workspace checklist - add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence --- ...current-priority-execution-checklist-v1.md | 363 ++++ rust/crates/bridge-runtime/src/lib.rs | 958 +++++++- rust/crates/core-protocol/src/ai.rs | 194 +- rust/crates/core-protocol/src/kernel.rs | 87 + rust/crates/core-protocol/src/lib.rs | 9 +- .../core-protocol/src/page_aggregate.rs | 3 + rust/crates/mnote-web/src/acp_client.rs | 7 + rust/crates/mnote-web/src/acp_runtime.rs | 5 +- rust/crates/mnote-web/src/context.rs | 52 + .../mnote-web/src/hermes_tools/artifact.rs | 109 + .../mnote-web/src/hermes_tools/block.rs | 46 + rust/crates/mnote-web/src/hermes_tools/doc.rs | 222 +- .../mnote-web/src/hermes_tools/manifest.rs | 8 +- rust/crates/mnote-web/src/hermes_tools/mod.rs | 53 + .../crates/mnote-web/src/hermes_tools/page.rs | 83 +- .../mnote-web/src/page_aggregate/builder.rs | 1 + rust/crates/mnote-web/src/routes/documents.rs | 172 +- rust/crates/mnote-web/src/routes/gateway.rs | 203 +- .../mnote-web/src/routes/hermes_client.rs | 1536 ++++++++++++- .../mnote-web/src/routes/hermes_tools.rs | 527 +++++ .../src/routes/local_folder_events.rs | 32 +- .../src/routes/local_folder_source.rs | 1932 ++++++++++++++++- rust/crates/mnote-web/src/routes/mod.rs | 38 + .../mnote-web/src/routes/page_ai_workflow.rs | 132 +- rust/crates/mnote-web/src/routes/tree.rs | 89 +- rust/crates/mnote-web/src/routes/web_shell.rs | 397 +++- rust/crates/mnote-web/src/ssr/pages/admin.rs | 246 +++ .../mnote-web/src/ssr/pages/document.rs | 5 +- rust/crates/mnote-web/src/ssr/pages/home.rs | 19 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 398 +++- rust/crates/mnote-web/src/ssr/pages/mod.rs | 1 + rust/crates/mnote-web/src/ssr/styles.rs | 240 ++ rust/crates/mnote-web/src/transport/convex.rs | 34 +- scripts/check-local-first-convex-guard.js | 294 +++ .../task450-admin-access-policy-ui-smoke.js | 173 ++ ...l-markdown-conflict-resolution-ui-smoke.js | 210 ++ 36 files changed, 8643 insertions(+), 235 deletions(-) create mode 100644 design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md create mode 100644 rust/crates/mnote-web/src/ssr/pages/admin.rs create mode 100644 scripts/check-local-first-convex-guard.js create mode 100644 scripts/task450-admin-access-policy-ui-smoke.js create mode 100644 scripts/task451-local-markdown-conflict-resolution-ui-smoke.js diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md new file mode 100644 index 00000000..1e041ad3 --- /dev/null +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -0,0 +1,363 @@ +# 1-3 [process] 当前主线持续推进 checklist v1 + +> 创建时间:2026-05-19 +> +> 当前状态:`PROCESS` +> +> 上位依据: +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-3-local-workspace-access-control-productization-v1.md` +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` +> +> 目标:把 `01-05 当前主线与优先级总览` 转成可持续推进、可验证、可迁移到 `done/` 的执行清单。 + +--- + +## 0. 总原则 + +- [x] 产品形态固定为 `VSCode 简化版工作区内核 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面`。 +- [x] 本地文件夹是默认数据真相;Rust kernel 是唯一语义真相。 +- [x] Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。 +- [x] AI 默认尽量使用 agent 原生文件读写、diff、patch 能力;MNote 只提供授权 root、页面定位、必要元数据和特殊资源工具。 +- [ ] 每完成一个阶段后,把对应过程稿移动到该分类 `done/`,并在本 checklist 写入验证证据。 + +--- + +## 1. P0 管理员目录授权控制面 + +对应 `01-05` 执行顺序:`1. 管理员目录授权 UI / API`。 + +### 1.1 后端 API 与权限底座 + +- [x] 管理员身份支持 `MNOTE_ADMIN_USER_IDS`。 +- [x] access policy 默认路径固定为 `/mnt/Data1T/Mnote_data/control-plane/access-policy.json`。 +- [x] 管理员可读写任意本地目录,普通用户只能访问 owner / grant 授权目录。 +- [x] read grant 只能读,write grant 可写。 +- [x] 新增 `GET /api/admin/access-policy`。 +- [x] 新增 `POST /api/admin/access-policy/validate-root`。 +- [x] 新增 `POST /api/admin/access-policy/grants`。 +- [x] 新增 `DELETE /api/admin/access-policy/grants/{grantId}`。 +- [x] 全入口权限审计完成:local folder、page body、tree command、Hermes / Reasonix、shared AI session。 + +验证证据: + +- [x] `cargo test -p mnote-web local_access_policy -- --nocapture` +- [x] `cargo test -p mnote-web local_workspace_access -- --nocapture` +- [x] `cargo test -p mnote-web local_folder -- --nocapture` +- [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture` +- [x] `cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture` + +### 1.2 管理员 UI + +目标:让管理员不用手写 JSON 就能管理用户目录授权。 + +- [x] 定位当前 Rust SSR 设置 / 管理入口,确认管理员页面应挂在 `rust/crates/mnote-web/src/ssr/pages/` 的哪个壳内。 +- [x] 增加管理员访问入口:非管理员不可见,管理员可进入本地目录授权管理页。 + - 实现:`/admin/access-policy` + `PageLayout` 管理员 quick action。 +- [x] 增加 policy 列表:显示 `policyPath`、env admins、policy admins、grant 列表。 + - 实现:`rust/crates/mnote-web/src/ssr/pages/admin.rs` 调用 `GET /api/admin/access-policy` 并展示 JSON。 +- [x] 增加 validate root 表单:输入 `rootPath` 或 `rootUri` 后显示 canonical path / rootUri。 +- [x] 增加 create grant 表单:`userId`、`permission`、`recursive`、`capabilities`。 +- [x] 增加 delete grant 操作:删除前显示授权目录和用户,删除后刷新列表。 +- [x] 增加 UI 错误态:未登录、非管理员、目录不存在、重复授权、无效 capability。 + - 实现:页面展示 API 错误;SSR route 对非管理员返回 403;API 保持后端错误码。 +- [x] 补管理员 UI smoke:真实登录管理员可新增 read grant,普通用户可读不可写。 + - 验证:`node scripts/task450-admin-access-policy-ui-smoke.js`。 +- [x] 补普通用户 smoke:普通用户无法打开管理员授权页,直接调用 API 返回 403。 + - 验证:`cargo test -p mnote-web admin_access_policy -- --nocapture`。 + +建议验证: + +- [x] `cargo test -p mnote-web local_access_policy -- --nocapture` +- [x] `cargo test -p mnote-web local_workspace_access -- --nocapture` +- [x] `node scripts/task450-admin-access-policy-ui-smoke.js` + +--- + +## 2. P1 VSCode-like 冲突处理 UI + +对应 `01-05` 执行顺序:`2. VSCode-like 冲突处理 UI`。 + +目标:tiptap 前端保存与 agent 后台写文件同时发生时,用户可以像 VSCode 一样看见冲突、选择磁盘版本、保留编辑器版本或打开 diff 合并。 + +### 2.1 冲突模型收口 + +- [x] 盘点现有 `fileVersion` / `conflictDetectionKey` 的生成、传递和校验路径。 + - 结论:本地 markdown aggregate 已暴露 `fileVersion` alias;`documents/save` compat 会把 `expectedFileVersion` / `conflictDetectionKey` 收敛到 `PageBodyWriteRequest.expected_file_version`;`/api/page-body/write` 只接受 `expectedFileVersion`,避免 alias 重复。 +- [x] 确认所有本地 markdown 写入口都携带 expected file version:tiptap 保存、`mnote.doc.markdown_edit`、`mnote.page.save`、documents compat save。 + - 实现:tiptap local-folder 保存改走 `/api/page-body/write` 并只传 `expectedFileVersion`;`mnote.doc.markdown_edit` 从当前 aggregate 的 `fileVersion` / `conflictDetectionKey` 取 expected version;`mnote.page.save` 读取 tool 入参 `expectedFileVersion`;documents compat save 继续兼容旧 `conflictDetectionKey`。 +- [ ] 统一冲突错误 envelope:错误码、当前磁盘版本、编辑器基线版本、documentId、rootUri、建议动作。 +- [x] 让冲突错误不丢失当前编辑器内容,前端可以继续保留未保存 buffer。 + - 实现:冲突态优先从当前挂载的 ProseMirror DOM 捕获文本;“保留当前编辑器版本”会用最新 `fileVersion` 重新提交当前 buffer。 + +### 2.2 冲突交互 + +- [x] 设计冲突 modal / side panel:显示当前编辑器版本、磁盘版本、文件路径、最后修改来源。 + - 实现:文档页内嵌 `mnote-editor-conflict-panel`,展示文件标识、来源为本地文件变更,并提供磁盘 / 当前 / diff 三个动作。 +- [x] 实现“接受磁盘版本”:重新读取文件,替换编辑器 buffer。 +- [x] 实现“保留编辑器版本”:用最新 fileVersion 重新提交当前编辑器内容。 +- [x] 实现“打开 diff”:支持 markdown 文本并排查看;复杂块结构先降级为 markdown 文本。 +- [ ] 实现“合并”:支持从 diff 面板选择合并结果并写回。 +- [ ] 合并完成后写回本地 markdown,并刷新 page aggregate / file tree snapshot。 +- [ ] AI 写入导致冲突时,提示来源为 agent run,而不是普通外部修改。 + +建议验证: + +- [x] 新增单测:stale `expectedFileVersion` 被拒绝。 + - 验证:`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture` +- [x] 新增 browser smoke:浏览器打开页面后外部修改同一 `.md` 文件,保存时出现冲突 UI。 + - 验证:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js` +- [ ] 新增 browser smoke:agent 修改同一 `.md` 文件后,tiptap 保存触发冲突 UI。 + +补充验证: + +- [x] `cargo fmt --check --all` +- [x] `cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture` +- [x] `cargo test -p mnote-web local_folder -- --nocapture` +- [x] `npm run check:local-first-convex-guard` + +--- + +## 3. P2 Agent changed files / diff 审计 + +对应 `01-05` 执行顺序:`3. agent changed files / diff 审计`。 + +目标:agent 可以像在 VSCode 里一样直接改授权目录文件,但 MNote 要记录“谁、在哪个 root、通过哪个 run、改了哪些文件、diff 摘要是什么”。 + +### 3.1 审计事件模型 + +- [x] 设计本地审计目录:建议放在 `/mnt/Data1T/Mnote_data/control-plane/agent-audit/`。 +- [x] 定义审计事件 JSONL 字段:`eventId`、`actorId`、`agentKind`、`runId`、`rootUri`、`permission`、`changedFiles`、`diffSummary`、`createdAt`。 +- [ ] 区分 agent 原生文件修改与 MNote tool 写入:二者都要能归入同一个 run audit。 +- [ ] 对只读 grant 的 agent run 写入尝试记录拒绝事件。 + - 当前已补齐工具层只读拒绝:`mnote.doc.markdown_edit` / `mnote.page.save` / `mnote.block.*` 在 `read_only` AI scope 下直接拒绝写入;待 run 结束审计事件也记录 `writeAttemptRejected` 后再勾选。 + +### 3.2 写入采集 + +- [x] 在 Hermes / Reasonix run 启动前记录 root snapshot:文件 mtime、size、hash。 +- [x] run 结束后对比 root snapshot,生成 changed files。 +- [x] 对 markdown 文件生成简短 diff summary;大文件只记录 hash / size / path。 +- [x] 把审计事件落盘到 control-plane,不写进用户正文目录。 +- [ ] 在 AI 会话 UI 显示 changed files 列表,并可展开查看 diff 摘要。 + - 已接入:`run.completed.agentAudit.changedFiles` 会进入 `agent.changed_files` 工具卡并可展开查看;待真实浏览器 AI run smoke 验证后勾选。 + +建议验证: + +- [x] 单测:run 前后文件变化可生成 changed files。 + - 验证:`cargo test -p mnote-web local_agent_audit_snapshot_detects_changed_files -- --nocapture` +- [ ] 单测:只读授权下写入被拒绝并产生拒绝审计事件。 +- [ ] browser smoke:AI 修改一篇本地 markdown 后,会话面板显示 changed files。 + +补充验证: + +- [x] `cargo fmt --check --all` +- [x] `cargo test -p mnote-web hermes_client_acp_run_registers_scoped_runtime_record_in_convex -- --nocapture` +- [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture` +- [x] `cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture` + +--- + +## 4. P3 本地搜索、反链和资源引用索引 + +对应 `01-05` 执行顺序:`4. 本地搜索、反链和资源引用索引`。 + +目标:本地 workspace 不依赖 Convex search 也能搜索正文、查反链、查资源引用和标签。 + +### 4.1 索引边界 + +- [ ] 设计本地索引目录:建议放在 workspace `.mnote/index/` 或 `/mnt/Data1T/Mnote_data/control-plane/index/`,二者职责需明确。 +- [ ] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。 +- [ ] 定义索引输入:markdown 正文、frontmatter、附件引用、mindmap / office resource metadata。 +- [ ] 定义索引输出:全文 search、backlinks、resource refs、tags、recent changes。 + +### 4.2 索引更新 + +- [ ] 本地文件 watcher 事件触发增量索引。 +- [ ] 手动 refresh / resync 触发 root 全量索引。 +- [ ] 文件移动、重命名、删除、恢复后同步更新索引。 +- [ ] AI 写入和 tiptap 保存后更新索引。 +- [ ] 索引损坏时可重建,不影响正文文件。 + +### 4.3 搜索体验 + +- [ ] 全局搜索优先搜索当前 workspace 本地索引。 +- [ ] 搜索结果显示文件路径、标题、命中片段和资源类型。 +- [ ] 反链面板读取本地索引,不再依赖云端搜索。 +- [ ] 标签列表读取本地索引。 + +建议验证: + +- [ ] 单测:markdown 链接和双链可生成 backlinks。 +- [ ] 单测:附件 / mindmap / office 引用可生成 resource refs。 +- [ ] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。 + +--- + +## 5. P4 分享与同步闭环 + +对应 `01-05` 执行顺序:`5. 分享与同步闭环`。 + +目标:个人本地空间默认隔离,显式分享后才产生共享 workspace / shared AI session,Convex 只作为权限、同步和协作控制面。 + +### 5.1 分享权限 + +- [ ] 定义 share grant 与 local access grant 的关系:分享不自动扩大本机文件系统权限。 +- [ ] share grant 支持 read / write / ai capability。 +- [ ] shared AI session 只能访问 share grant 允许的资源。 +- [ ] 管理员可查看和撤销 share grant。 + +### 5.2 同步缓存 + +- [ ] 设计 shared workspace cache 目录。 +- [ ] 云端同步到本地 cache 时保留来源、版本、权限和冲突信息。 +- [ ] 本地修改同步回云端前进行权限和版本校验。 +- [ ] 离线期间记录 pending changes,恢复在线后生成同步报告。 + +### 5.3 同步冲突报告 + +- [ ] 同步冲突复用 P1 的冲突 UI。 +- [ ] 冲突报告包含本地版本、远端版本、base version、修改 actor。 +- [ ] 管理员或 owner 可导出冲突报告。 + +建议验证: + +- [ ] 单测:share read 不允许写入。 +- [ ] 单测:shared AI session 不扩大 workspace root。 +- [ ] browser smoke:共享页面只读用户无法通过 AI 写入正文。 + +--- + +## 6. P5 插件资源模型产品化 + +对应 `01-05` 执行顺序:`6. 插件资源模型产品化`。 + +目标:simplemindmap / office 是 Resource Tree 对象;Markdown 正文只保留链接或嵌入引用,不把资源内容塞回 markdown 正文真相。 + +### 6.1 Resource Tree 对象统一 + +- [ ] 盘点 simplemindmap 当前 object identity、保存路径、打开路径。 +- [ ] 盘点 office 当前 object identity、保存路径、打开路径。 +- [ ] 统一资源创建、重命名、移动、删除、恢复命令到 `tree.resource.*`。 +- [ ] filetree 显示资源行,pagetree 只显示页面导航投影。 +- [ ] Markdown 中插入资源引用时,只写相对链接或嵌入引用。 + +### 6.2 AI 资源工具 + +- [ ] 设计 `mnote.mindmap.*` 工具:读取结构、增删改节点、移动节点、导出 markdown summary。 +- [ ] 设计 `mnote.office.*` 工具:读取文本摘要、写入建议、导出变更摘要;真实编辑优先复用 officecli / OnlyOffice 保存链。 +- [ ] AI 资源工具必须走授权 root 和 resource capability。 +- [ ] agent changed files 审计包含 mindmap / office 资源文件。 + +建议验证: + +- [ ] 单测:resource rename 不走 document command。 +- [ ] 单测:mindmap resource 删除 / 恢复不破坏 markdown 页面。 +- [ ] browser smoke:从 markdown 打开 mindmap / office,修改保存后 filetree 和引用状态同步。 + +--- + +## 7. P6 旧 Convex 数据迁移产品化 + +对应 `01-05` 执行顺序:`7. 旧 Convex 数据迁移产品化`。 + +目标:把旧 Convex workspace 迁移成本变成可视、可回滚、可验证的产品流程。 + +### 7.1 导出与备份 + +- [ ] 设计导出入口:选择 Convex workspace,选择目标本地 root。 +- [ ] 导出前创建备份目录和 manifest。 +- [ ] 导出页面为 `.md`,资源为 Resource Tree 文件,附件保持相对路径。 +- [ ] 导出过程记录进度:总页面数、已完成、失败、跳过、冲突。 + +### 7.2 冲突与回滚 + +- [ ] 目标 root 已有同名文件时生成冲突报告,不直接覆盖。 +- [ ] 支持 dry run:只生成迁移计划,不写文件。 +- [ ] 支持回滚:根据 manifest 删除本次新增文件或恢复备份文件。 +- [ ] 导出完成后自动跑本地索引重建。 + +建议验证: + +- [ ] 脚本 smoke:Convex fixture 导出到本地 root。 +- [ ] 脚本 smoke:同名文件冲突时不覆盖。 +- [ ] 脚本 smoke:回滚后 root 回到导出前状态。 + +--- + +## 8. P7 Page Aggregate / tree command / realtime 兼容链瘦身 + +对应 `01-05` 执行顺序:`8. Page Aggregate / tree command / realtime 兼容链继续瘦身`。 + +目标:不是继续扩新功能,而是减少双真相、双命令面、补偿链和旧 Convex runtime fallback。 + +### 8.1 Page Aggregate 单一真源 + +- [ ] 盘点 `documents.content` 仍作为正文兼容源的入口。 +- [ ] 让本地 `.md` 与 EditorBlockDocument projection 的读写边界写入 5-5 / 5-6。 +- [ ] 标题、正文、页面设置写入后只通过 page aggregate 刷新 UI。 +- [ ] 删除或降级前端手工拼 `meta + content` 的 runtime fallback。 +- [ ] AI 页面设置写入走统一 page aggregate / page command 入口。 + +建议验证: + +- [ ] `cargo test -p mnote-web page_aggregate -- --nocapture` +- [ ] browser smoke:标题、正文、页面设置保存后刷新仍一致。 + +### 8.2 Tree command cutover + +- [ ] 盘点仍在 runtime route / adapter / bridge / CLI 中使用的 `documents.*` 命令。 +- [ ] 新增命令统一命名为 `tree.*` 或 `tree.resource.*`。 +- [ ] 对历史 `documents.*` 命令只保留 compat adapter,不再扩展新语义。 +- [ ] 资源 rename、移动、删除、恢复补齐 `tree.resource.*`。 + +建议验证: + +- [ ] `cargo test -p mnote-web tree_command -- --nocapture` +- [ ] browser smoke:filetree 新建、重命名、移动、删除、恢复不触发页面 reload。 + +### 8.3 Tree realtime live cache + +- [ ] 盘点 Sidebar、page subtree、filetree、preferred snapshot 的数据来源。 +- [ ] 统一 snapshot / delta / resync consumer 到同一 live cache。 +- [ ] SSE fallback 只作为 WS 不可用时的降级,不作为并行主链。 +- [ ] 双浏览器文件树操作无刷新同步。 +- [ ] 删除不必要的 polling / refetch 补偿链。 + +建议验证: + +- [ ] `cargo test -p mnote-web local_folder -- --nocapture` +- [ ] 双浏览器 smoke:页面、文件、垃圾箱操作互相同步且不刷新页面。 + +--- + +## 9. 每轮推进固定验证包 + +后续每次持续推进本 checklist,至少执行与改动相关的子集;跨域改动需要执行完整包。 + +- [ ] `cargo fmt --check --all` +- [ ] `cargo test -p mnote-web local_folder -- --nocapture` +- [ ] `cargo test -p mnote-web local_access_policy -- --nocapture` +- [ ] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture` +- [ ] `cargo test -p mnote-web tree_command -- --nocapture` +- [ ] `cargo test -p mnote-web page_aggregate -- --nocapture` +- [ ] `npm run check:local-first-convex-guard` +- [ ] `git diff --check -- ` +- [ ] 影响 UI / 交互时补 browser smoke,并把脚本名写回对应阶段。 + +--- + +## 10. done 迁移标准 + +本文件迁入 `done/` 前必须满足: + +- [ ] P0 管理员目录授权 UI / API 完成并有 browser smoke。 +- [ ] P1 冲突处理 UI 完成,并覆盖 tiptap 保存与 agent 写回冲突。 +- [ ] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。 +- [ ] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。 +- [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。 +- [ ] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。 +- [ ] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。 +- [ ] P7 Page Aggregate / tree command / realtime 兼容链完成阶段性瘦身,并把被替代 process 稿移入 `old/` 或 `done/`。 +- [ ] `01-05-current-priority-overview.md` 同步更新状态,不再把已完成项描述为当前第一优先级。 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 444f5f45..44aca6ba 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -5671,6 +5671,13 @@ fn build_document_content_result( Ok(DocumentContentResult { content, + editor_document: data.get("editorDocument").cloned(), + tiptap_document: data.get("tiptapDocument").cloned(), + block_document: data.get("blockDocument").cloned(), + block_projection_version: data + .get("blockProjectionVersion") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), revision, conflict_detection_key, title, @@ -5695,6 +5702,8 @@ fn build_page_aggregate_projection_result( .get("content") .filter(|value| { value.get("content").is_some() + || value.get("editorDocument").is_some() + || value.get("blockDocument").is_some() || value.get("revision").is_some() || value.get("pageSubtree").is_some() || value.get("page_subtree").is_some() @@ -5708,10 +5717,6 @@ fn build_page_aggregate_projection_result( let title = read_trimmed_string_field(meta, &["title"]).unwrap_or_else(|| "无标题".into()); let updated_at = read_trimmed_string_field(meta, &["updated_at", "updatedAt"]); let parent_id = read_trimmed_string_field(meta, &["parent_id", "parentId"]); - let content = content_result - .get("content") - .cloned() - .unwrap_or_else(|| Value::Array(vec![])); let conflict_detection_key = content_result .get("conflictDetectionKey") .or_else(|| content_result.get("conflict_detection_key")) @@ -5772,8 +5777,8 @@ fn build_page_aggregate_projection_result( let revision_ref = revision .as_u64() .map(|value| format!("{resolved_document_id}:{value}")); - let block_document = - project_legacy_content_to_block_document(&resolved_document_id, &content, &revision)?; + let (content, block_document, block_projection_version, projection_source) = + page_aggregate_block_document_projection(content_result, &resolved_document_id, &revision)?; Ok(PageAggregateProjection { schema: PageAggregateProjection::SCHEMA.into(), @@ -5811,9 +5816,10 @@ fn build_page_aggregate_projection_result( content, revision, conflict_detection_key, + file_version: Value::Null, block_document, - block_projection_version: 1, - projection_source: "documents.content".into(), + block_projection_version, + projection_source, }, tree: PageTree { page_subtree }, stats: PageStats { @@ -5845,6 +5851,176 @@ fn page_aggregate_source_for_data(data: &Value) -> PageAggregateSource { } } +fn page_aggregate_block_document_projection( + content_result: &Value, + document_id: &str, + revision: &Value, +) -> Result<(Value, Value, u32, String), BridgeError> { + if let Some(editor_document_value) = content_result + .get("editorDocument") + .filter(|value| !value.is_null()) + { + let mut editor_document = serde_json::from_value::( + editor_document_value.clone(), + ) + .map_err(|error| { + BridgeError::validation(format!("page.aggregate.get editorDocument 非法: {error}")) + })?; + validate_editor_document_structure( + editor_document_value, + &editor_document, + "page.aggregate.get", + )?; + hydrate_editor_document_props_from_raw(&mut editor_document, Some(editor_document_value)); + if editor_document.document_id.trim().is_empty() { + editor_document.document_id = document_id.to_string(); + } + if editor_document.root_block_ids.is_empty() { + editor_document.root_block_ids = editor_document + .blocks + .iter() + .map(|block| block.block_id.clone()) + .collect(); + } + let content = legacy_content_from_editor_document(&editor_document); + return Ok(( + content, + project_editor_document_to_block_document(&editor_document, revision)?, + 1, + "editorDocument".into(), + )); + } + + if let Some(block_document) = content_result + .get("blockDocument") + .filter(|value| !value.is_null()) + { + if !block_document.is_object() { + return Err(BridgeError::validation( + "page.aggregate.get blockDocument 非法: 必须是对象", + )); + } + let block_projection_version = content_result + .get("blockProjectionVersion") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1); + let content = legacy_content_from_block_document_projection(block_document)?; + return Ok(( + content, + block_document.clone(), + block_projection_version, + "blockDocument".into(), + )); + } + + let content = content_result + .get("content") + .cloned() + .unwrap_or_else(|| Value::Array(vec![])); + Ok(( + content.clone(), + project_legacy_content_to_block_document(document_id, &content, revision)?, + 1, + "documents.content".into(), + )) +} + +fn legacy_content_from_block_document_projection( + block_document: &Value, +) -> Result { + let blocks = block_document + .get("blocks") + .and_then(Value::as_array) + .ok_or_else(|| BridgeError::validation("page.aggregate.get blockDocument 缺少 blocks"))?; + let root_ids = block_document + .get("rootBlockIds") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut rendered = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + + for root_id in root_ids + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Some(block) = blocks.iter().find(|block| { + read_trimmed_string_field(block, &["blockId", "id"]).as_deref() == Some(root_id) + }) { + if !seen.contains(root_id) { + rendered.push(render_block_document_projection_to_legacy( + block, blocks, &mut seen, + )?); + } + } + } + + for block in blocks { + let block_id = read_trimmed_string_field(block, &["blockId", "id"]).unwrap_or_default(); + if !block_id.is_empty() && !seen.contains(&block_id) { + rendered.push(render_block_document_projection_to_legacy( + block, blocks, &mut seen, + )?); + } + } + + Ok(Value::Array(rendered)) +} + +fn render_block_document_projection_to_legacy( + block: &Value, + blocks: &[Value], + seen: &mut std::collections::BTreeSet, +) -> Result { + let block_id = read_trimmed_string_field(block, &["blockId", "id"]).ok_or_else(|| { + BridgeError::validation("page.aggregate.get blockDocument block 缺少 blockId") + })?; + seen.insert(block_id.clone()); + let block_type = read_trimmed_string_field(block, &["type", "blockType"]) + .unwrap_or_else(|| "paragraph".into()); + let mut value = json!({ + "id": block_id, + "type": block_type, + "content": block + .get("text") + .cloned() + .unwrap_or_else(|| Value::String(String::new())), + }); + if let Some(attrs) = block.get("attrs").and_then(Value::as_object) { + if !attrs.is_empty() { + if let Value::Object(map) = &mut value { + map.insert("props".into(), Value::Object(attrs.clone())); + } + } + } + if let Some(children) = block.get("children").and_then(Value::as_array) { + let mut legacy_children = Vec::new(); + for child_id in children.iter().filter_map(Value::as_str) { + if let Some(child) = blocks.iter().find(|candidate| { + read_trimmed_string_field(candidate, &["blockId", "id"]).as_deref() + == Some(child_id.trim()) + }) { + let child_block_id = + read_trimmed_string_field(child, &["blockId", "id"]).unwrap_or_default(); + if !child_block_id.is_empty() && !seen.contains(&child_block_id) { + legacy_children.push(render_block_document_projection_to_legacy( + child, blocks, seen, + )?); + } + } + } + if !legacy_children.is_empty() { + if let Value::Object(map) = &mut value { + map.insert("children".into(), Value::Array(legacy_children)); + } + } + } + Ok(value) +} + pub fn project_legacy_content_to_block_document( document_id: &str, content: &Value, @@ -5867,6 +6043,117 @@ pub fn project_legacy_content_to_block_document( })) } +fn project_editor_document_to_block_document( + document: &EditorBlockDocument, + revision: &Value, +) -> Result { + let mut projected_blocks = Vec::new(); + let mut root_block_ids = Vec::new(); + let root_ids = if document.root_block_ids.is_empty() { + document + .blocks + .iter() + .map(|block| block.block_id.clone()) + .collect::>() + } else { + document.root_block_ids.clone() + }; + for (index, block_id) in root_ids.iter().enumerate() { + if let Some(block) = find_editor_block(document, block_id) { + root_block_ids.push(project_editor_block( + document, + block, + None, + vec![index], + revision, + &mut projected_blocks, + )?); + } + } + Ok(json!({ + "documentId": document.document_id, + "rootBlockIds": root_block_ids, + "blocks": projected_blocks, + })) +} + +fn project_editor_block( + document: &EditorBlockDocument, + block: &EditorBlock, + parent_block_id: Option<&str>, + path: Vec, + revision: &Value, + out: &mut Vec, +) -> Result { + let block_id = block.block_id.clone(); + let block_type = legacy_type_from_editor_block(block); + let text = legacy_text_from_editor_block(block); + let attrs = editor_block_projection_attrs(block, block_type); + let mut child_block_ids = Vec::new(); + for (index, child_id) in block.child_block_ids.iter().enumerate() { + if let Some(child) = find_editor_block(document, child_id) { + let mut child_path = path.clone(); + child_path.push(index); + child_block_ids.push(project_editor_block( + document, + child, + Some(&block_id), + child_path, + revision, + out, + )?); + } + } + let editable = legacy_block_type_is_editable(block_type); + let unsupported_reason = if editable { + Value::Null + } else { + Value::String("复杂块暂不开放 AI 精确写入".into()) + }; + let revision_label = revision + .as_u64() + .map(|value| value.to_string()) + .or_else(|| revision.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".into()); + let block_value = serde_json::to_value(block) + .map_err(|error| BridgeError::transport(format!("EditorBlock 序列化失败: {error}")))?; + out.push(json!({ + "blockId": block_id, + "type": block_type, + "text": text, + "attrs": attrs, + "contentNodes": block.content_nodes, + "children": child_block_ids, + "parentBlockId": parent_block_id, + "order": format!("{:08}", path.last().copied().unwrap_or(0)), + "path": path, + "depth": path.len().saturating_sub(1), + "revisionRef": format!( + "pageRev:{revision_label}:block:{}:hash:{}", + block.block_id, + stable_json_content_hash(&block_value)? + ), + "editable": editable, + "unsupportedReason": unsupported_reason, + })); + Ok(block_id) +} + +fn find_editor_block<'a>( + document: &'a EditorBlockDocument, + block_id: &str, +) -> Option<&'a EditorBlock> { + document + .blocks + .iter() + .find(|block| block.block_id == block_id) +} + +fn editor_block_projection_attrs(block: &EditorBlock, block_type: &str) -> Value { + let props = legacy_props_from_editor_block(block).unwrap_or_else(|| json!({})); + legacy_block_projection_attrs(&json!({ "props": props }), block_type) +} + fn project_legacy_block( block: &Value, parent_block_id: Option<&str>, @@ -11178,19 +11465,33 @@ fn normalize_save_editor_document( payload: &DocumentSaveCommandPayload, ) -> Result { if let Some(editor_document) = payload.editor_document.clone() { - if let Ok(mut parsed) = serde_json::from_value::(editor_document) { - hydrate_editor_document_props_from_raw(&mut parsed, payload.editor_document.as_ref()); - if parsed.document_id.trim().is_empty() { - parsed.document_id = payload.document_id.clone(); + match serde_json::from_value::(editor_document) { + Ok(mut parsed) => { + let raw = payload.editor_document.as_ref().ok_or_else(|| { + BridgeError::validation("documents.save editorDocument 非法: 缺少原始值") + })?; + validate_editor_document_structure(raw, &parsed, "documents.save")?; + hydrate_editor_document_props_from_raw( + &mut parsed, + payload.editor_document.as_ref(), + ); + if parsed.document_id.trim().is_empty() { + parsed.document_id = payload.document_id.clone(); + } + if parsed.root_block_ids.is_empty() { + parsed.root_block_ids = parsed + .blocks + .iter() + .map(|block| block.block_id.clone()) + .collect(); + } + return Ok(parsed); } - if parsed.root_block_ids.is_empty() { - parsed.root_block_ids = parsed - .blocks - .iter() - .map(|block| block.block_id.clone()) - .collect(); + Err(error) => { + return Err(BridgeError::validation(format!( + "documents.save editorDocument 非法: {error}" + ))); } - return Ok(parsed); } } if let Some(tiptap_document) = payload.tiptap_document.clone() { @@ -11213,6 +11514,36 @@ fn normalize_save_editor_document( )) } +fn validate_editor_document_structure( + raw: &Value, + parsed: &EditorBlockDocument, + phase: &str, +) -> Result<(), BridgeError> { + raw.get("blocks").and_then(Value::as_array).ok_or_else(|| { + BridgeError::validation(format!("{phase} editorDocument 非法: 缺少 blocks")) + })?; + let block_ids = parsed + .blocks + .iter() + .map(|block| block.block_id.as_str()) + .collect::>(); + let missing_roots = parsed + .root_block_ids + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|block_id| !block_id.is_empty()) + .filter(|block_id| !block_ids.contains(block_id)) + .collect::>(); + if !missing_roots.is_empty() { + return Err(BridgeError::validation(format!( + "{phase} editorDocument 非法: rootBlockIds 引用缺失 blockId: {}", + missing_roots.join(", ") + ))); + } + Ok(()) +} + fn hydrate_editor_document_props_from_raw(parsed: &mut EditorBlockDocument, raw: Option<&Value>) { let Some(raw_blocks) = raw .and_then(|value| value.get("blocks")) @@ -11221,9 +11552,6 @@ fn hydrate_editor_document_props_from_raw(parsed: &mut EditorBlockDocument, raw: return; }; for (index, block) in parsed.blocks.iter_mut().enumerate() { - if !matches!(block.block_type, EditorBlockType::Mindmap) { - continue; - } let Some(raw_block) = raw_blocks .iter() .find(|candidate| { @@ -11234,7 +11562,27 @@ fn hydrate_editor_document_props_from_raw(parsed: &mut EditorBlockDocument, raw: else { continue; }; - hydrate_mindmap_block_props_from_raw(block, raw_block); + match block.block_type { + EditorBlockType::Mindmap => hydrate_mindmap_block_props_from_raw(block, raw_block), + EditorBlockType::Image => hydrate_image_block_props_from_raw(block, raw_block), + _ => {} + } + } +} + +fn hydrate_image_block_props_from_raw(block: &mut EditorBlock, raw_block: &Value) { + let props = raw_block.get("props").and_then(Value::as_object); + if let Some(tiptap_image) = props + .and_then(|map| map.get("tiptapImage")) + .filter(|value| value.get("type").and_then(Value::as_str) == Some("image")) + .cloned() + { + block.props.extra.insert("tiptapImage".into(), tiptap_image); + } + for key in ["src", "alt", "title"] { + if let Some(value) = props.and_then(|map| map.get(key)).cloned() { + block.props.extra.insert(key.into(), value); + } } } @@ -11314,6 +11662,11 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option { } } EditorBlockType::Image => { + for key in ["src", "alt", "title"] { + if let Some(value) = block.props.extra.get(key) { + props.insert(key.into(), value.clone()); + } + } if let Some(tiptap_image) = block.props.extra.get("tiptapImage") { props.insert("tiptapImage".into(), tiptap_image.clone()); if let Some(attrs) = tiptap_image.get("attrs").and_then(Value::as_object) { @@ -11412,6 +11765,80 @@ fn legacy_text_from_editor_block(block: &EditorBlock) -> String { } pub fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value { + fn legacy_styles_from_content_node(node: &ContentNode) -> serde_json::Map { + let mut styles = node + .attrs + .get("styles") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if let ContentNodePayload::Text { marks, .. } = &node.payload { + for mark in marks { + match mark { + core_protocol::TextMark::Bold => { + styles.insert("bold".into(), Value::Bool(true)); + } + core_protocol::TextMark::Italic => { + styles.insert("italic".into(), Value::Bool(true)); + } + core_protocol::TextMark::Underline => { + styles.insert("underline".into(), Value::Bool(true)); + } + core_protocol::TextMark::Strike => { + styles.insert("strike".into(), Value::Bool(true)); + } + core_protocol::TextMark::Code => { + styles.insert("code".into(), Value::Bool(true)); + } + }; + } + } + styles + } + + fn content_node_requires_structured_legacy(node: &ContentNode) -> bool { + !legacy_styles_from_content_node(node).is_empty() + || matches!(&node.payload, ContentNodePayload::ReferenceToken { .. }) + } + + fn legacy_inline_content_from_editor_block(block: &EditorBlock) -> Value { + if !block + .content_nodes + .iter() + .any(content_node_requires_structured_legacy) + { + return Value::String(legacy_text_from_editor_block(block)); + } + let nodes = block + .content_nodes + .iter() + .filter_map(|node| { + let text = match &node.payload { + ContentNodePayload::Text { text, .. } => text.as_str(), + ContentNodePayload::HardBreak => "\n", + ContentNodePayload::ReferenceToken { token } => { + token.label.as_deref().unwrap_or(token.target_id.as_str()) + } + }; + if text.is_empty() { + return None; + } + let styles = legacy_styles_from_content_node(node); + let mut value = json!({ + "type": "text", + "text": text, + }); + if !styles.is_empty() { + if let Value::Object(map) = &mut value { + map.insert("styles".into(), Value::Object(styles)); + } + } + Some(value) + }) + .collect::>(); + Value::Array(nodes) + } + fn block_to_legacy_value(block: &EditorBlock, document: &EditorBlockDocument) -> Value { let children = block .child_block_ids @@ -11434,7 +11861,7 @@ pub fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Va } else if matches!(block.block_type, EditorBlockType::Mindmap) { Value::String(String::new()) } else { - Value::String(legacy_text_from_editor_block(block)) + legacy_inline_content_from_editor_block(block) }, }); if !children.is_empty() { @@ -12495,6 +12922,265 @@ mod tests { ); } + #[test] + fn page_aggregate_get_prefers_editor_document_over_legacy_content() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "page.aggregate.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "meta": { + "id": "doc_1", + "workspace_id": "ws_1", + "title": "原生块文档页面" + }, + "content": { + "content": [ + { + "id": "legacy_1", + "type": "paragraph", + "content": "旧 content 不应成为块投影真相" + } + ], + "editorDocument": { + "documentId": "doc_1", + "rootBlockIds": ["editor_1"], + "blocks": [{ + "blockId": "editor_1", + "blockType": "paragraph", + "contentNodes": [{ + "payload": { + "type": "text", + "text": "来自 editorDocument 的正文" + }, + "attrs": {} + }], + "childBlockIds": [] + }] + }, + "revision": 9, + "conflict_detection_key": "doc_1:9" + } + })), + }) + .expect("page aggregate query should build"); + + assert_eq!( + result["body"]["blockDocument"]["rootBlockIds"], + json!(["editor_1"]) + ); + assert_eq!( + result["body"]["blockDocument"]["blocks"][0]["blockId"], + json!("editor_1") + ); + assert_eq!( + result["body"]["blockDocument"]["blocks"][0]["text"], + json!("来自 editorDocument 的正文") + ); + assert_eq!(result["body"]["content"][0]["id"], json!("editor_1")); + assert_eq!( + result["body"]["content"][0]["content"], + json!("来自 editorDocument 的正文") + ); + assert_eq!(result["body"]["projectionSource"], json!("editorDocument")); + } + + #[test] + fn page_aggregate_get_rejects_invalid_editor_document_instead_of_falling_back() { + let error = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "page.aggregate.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "meta": { + "id": "doc_1", + "workspace_id": "ws_1", + "title": "非法块文档页面" + }, + "content": { + "content": [ + { + "id": "legacy_1", + "type": "paragraph", + "content": "不能静默回退" + } + ], + "editorDocument": { + "documentId": "doc_1", + "rootBlockIds": ["editor_1"], + "blocks": "非法 blocks" + }, + "revision": 10, + "conflict_detection_key": "doc_1:10" + } + })), + }) + .expect_err("invalid editorDocument should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("editorDocument")); + } + + #[test] + fn page_aggregate_get_rejects_editor_document_root_ids_missing_blocks() { + let error = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "page.aggregate.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "meta": { + "id": "doc_1", + "workspace_id": "ws_1", + "title": "损坏 root 引用页面" + }, + "content": { + "content": [ + { + "id": "legacy_1", + "type": "paragraph", + "content": "不能静默回退" + } + ], + "editorDocument": { + "documentId": "doc_1", + "rootBlockIds": ["missing_root"], + "blocks": [{ + "blockId": "editor_1", + "blockType": "paragraph", + "contentNodes": [{ + "payload": { + "type": "text", + "text": "来自 editorDocument" + }, + "attrs": {} + }], + "childBlockIds": [] + }] + }, + "revision": 10, + "conflict_detection_key": "doc_1:10" + } + })), + }) + .expect_err("editorDocument rootBlockIds should reference existing blocks"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("rootBlockIds")); + } + + #[test] + fn page_aggregate_get_rejects_invalid_block_document_instead_of_falling_back() { + let error = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "page.aggregate.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "meta": { + "id": "doc_1", + "workspace_id": "ws_1", + "title": "非法块投影页面" + }, + "content": { + "content": [ + { + "id": "legacy_1", + "type": "paragraph", + "content": "不能静默回退" + } + ], + "blockDocument": "非法 blockDocument", + "revision": 11, + "conflict_detection_key": "doc_1:11" + } + })), + }) + .expect_err("invalid blockDocument should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("blockDocument")); + } + + #[test] + fn page_aggregate_get_projects_body_content_from_block_document_source() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "page.aggregate.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "meta": { + "id": "doc_1", + "workspace_id": "ws_1", + "title": "块投影来源页面" + }, + "content": { + "content": [ + { + "id": "legacy_1", + "type": "paragraph", + "content": "旧 content 不应成为 body 真相" + } + ], + "blockDocument": { + "documentId": "doc_1", + "rootBlockIds": ["block_doc_1"], + "blocks": [{ + "blockId": "block_doc_1", + "type": "paragraph", + "text": "来自 blockDocument 的正文", + "attrs": {}, + "contentNodes": [{ + "payload": { + "type": "text", + "text": "来自 blockDocument 的正文" + }, + "attrs": {} + }], + "children": [], + "parentBlockId": null + }] + }, + "blockProjectionVersion": 2, + "revision": 12, + "conflict_detection_key": "doc_1:12" + } + })), + }) + .expect("page aggregate query should build"); + + assert_eq!(result["body"]["projectionSource"], json!("blockDocument")); + assert_eq!(result["body"]["blockProjectionVersion"], json!(2)); + assert_eq!(result["body"]["content"][0]["id"], json!("block_doc_1")); + assert_eq!( + result["body"]["content"][0]["content"], + json!("来自 blockDocument 的正文") + ); + } + #[test] fn editor_command_apply_replaces_and_moves_legacy_content_through_canonical_document() { let content = json!([ @@ -12569,6 +13255,86 @@ mod tests { ); } + #[test] + fn legacy_content_from_editor_document_preserves_inline_link_styles() { + let mut attrs = BTreeMap::new(); + attrs.insert( + "styles".into(), + json!({ + "link": "/onlyoffice?assetId=asset_1", + }), + ); + let document = EditorBlockDocument { + document_id: "doc_1".into(), + root_block_ids: vec!["attachment_block".into()], + blocks: vec![EditorBlock { + block_id: "attachment_block".into(), + block_type: EditorBlockType::Paragraph, + props: BlockProps::default(), + content_nodes: vec![ContentNode { + payload: ContentNodePayload::Text { + text: "附件.docx".into(), + marks: vec![], + }, + attrs, + }], + child_block_ids: vec![], + }], + }; + + let legacy = legacy_content_from_editor_document(&document); + assert_eq!(legacy[0]["content"][0]["text"], json!("附件.docx")); + assert_eq!( + legacy[0]["content"][0]["styles"]["link"], + json!("/onlyoffice?assetId=asset_1") + ); + } + + #[test] + fn documents_save_hydrates_image_props_from_raw_editor_document() { + let payload = DocumentSaveCommandPayload { + document_id: "doc_1".into(), + workspace_id: Some("ws_1".into()), + revision: None, + editor_document: Some(json!({ + "documentId": "doc_1", + "rootBlockIds": ["image_block"], + "blocks": [{ + "blockId": "image_block", + "blockType": "image", + "props": { + "src": "http://127.0.0.1:3210/api/storage/image_1", + "alt": "图片.png", + "title": "图片.png", + "tiptapImage": { + "type": "image", + "attrs": { + "src": "http://127.0.0.1:3210/api/storage/image_1", + "alt": "图片.png", + "title": "图片.png" + } + } + }, + "contentNodes": [], + "childBlockIds": [] + }] + })), + content: json!([]), + tiptap_document: None, + conflict_detection_key: None, + }; + + let document = normalize_save_editor_document(&payload).expect("valid editor document"); + let legacy = legacy_content_from_editor_document(&document); + assert_eq!( + legacy[0]["props"]["src"], + json!("http://127.0.0.1:3210/api/storage/image_1") + ); + assert_eq!(legacy[0]["props"]["alt"], json!("图片.png")); + assert_eq!(legacy[0]["props"]["title"], json!("图片.png")); + assert_eq!(legacy[0]["props"]["tiptapImage"]["type"], json!("image")); + } + #[test] fn editor_command_apply_insert_preserves_nested_children() { let content = json!([{ @@ -14511,8 +15277,8 @@ mod tests { } #[test] - fn documents_save_command_plan_falls_back_from_invalid_editor_document_to_tiptap() { - let plan = execute_runtime_input(RuntimeInput::Command { + fn documents_save_command_plan_rejects_wrong_shape_editor_document_instead_of_falling_back() { + let error = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), @@ -14589,26 +15355,130 @@ mod tests { validate_only: false, }, }) - .expect("documents.save plan should build"); + .expect_err("documents.save should reject invalid editorDocument"); - let RuntimeExecutionPlan::Command(plan) = plan else { - panic!("expected command plan"); - }; + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("editorDocument")); + } - assert_eq!(plan.function_name, "documents:updateContent"); - assert_eq!(plan.args_json.pointer("/content/0/id"), Some(&json!("p_1"))); - assert_eq!( - plan.args_json.pointer("/content/0/content"), - Some(&json!("来自 tiptap 回退")) - ); - assert_eq!( - plan.args_json.pointer("/editorDocument/rootBlockIds/0"), - Some(&json!("p_1")) - ); - assert_eq!( - plan.args_json.pointer("/editorDocument/blocks/0/blockId"), - Some(&json!("p_1")) - ); + #[test] + fn documents_save_command_plan_rejects_editor_document_missing_blocks() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "page.body.save".into(), + command_id: "cmd_save_missing_editor_blocks".into(), + idempotency_key: Some("idem_save_missing_editor_blocks".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + "revision": 7, + "editorDocument": { + "documentId": "doc_1" + }, + "content": [ + { + "id": "legacy_1", + "type": "paragraph", + "content": "不能静默回退" + } + ], + "conflictDetectionKey": "doc_1:7" + }), + preflight_data: None, + reason: Some("保存缺少 blocks 的 editorDocument".into()), + refs: vec!["task-save-missing-editor-blocks".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("editorDocument without blocks should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("blocks")); + } + + #[test] + fn documents_save_command_plan_rejects_unparseable_editor_document_instead_of_falling_back() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "page.body.save".into(), + command_id: "cmd_save_invalid_editor_document".into(), + idempotency_key: Some("idem_save_invalid_editor_document".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + "revision": 7, + "editorDocument": { + "documentId": "doc_1", + "rootBlockIds": ["editor_block_1"], + "blocks": "非法 blocks" + }, + "tiptapDocument": { + "type": "doc", + "content": [{ + "type": "paragraph", + "attrs": { "blockId": "p_1" }, + "content": [{ "type": "text", "text": "不能静默回退" }] + }] + }, + "content": [ + { + "id": "content_block_1", + "type": "paragraph", + "content": "不能静默回退" + } + ], + "conflictDetectionKey": "doc_1:7" + }), + preflight_data: None, + reason: Some("保存正文".into()), + refs: vec!["task-save-invalid-editor-document".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("unparseable editorDocument should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("editorDocument")); } #[test] diff --git a/rust/crates/core-protocol/src/ai.rs b/rust/crates/core-protocol/src/ai.rs index e76c38fb..67e19a6c 100644 --- a/rust/crates/core-protocol/src/ai.rs +++ b/rust/crates/core-protocol/src/ai.rs @@ -1,4 +1,6 @@ -use serde::{Deserialize, Serialize}; +use crate::kernel::WorkspaceSourceKind; +use serde::de; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -66,3 +68,193 @@ pub enum AiStructuredWriteKind { ReferenceEdge, PageBody, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiAccessPermissionLevel { + Admin, + ReadOnly, + ReadWrite, + SharedRead, + SharedWrite, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiShareContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub share_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiAccessScope { + pub user_id: String, + pub workspace_id: String, + pub session_id: String, + pub source_kind: WorkspaceSourceKind, + pub permission_level: AiAccessPermissionLevel, + #[serde(default)] + pub allowed_roots: Vec, + #[serde(default)] + pub allowed_file_paths: Vec, + #[serde(default)] + pub allowed_resource_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub share_context: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AiAccessScopeRaw { + user_id: String, + workspace_id: String, + session_id: String, + source_kind: WorkspaceSourceKind, + permission_level: AiAccessPermissionLevel, + #[serde(default)] + allowed_roots: Vec, + #[serde(default)] + allowed_file_paths: Vec, + #[serde(default)] + allowed_resource_ids: Vec, + #[serde(default)] + share_context: Option, +} + +impl<'de> Deserialize<'de> for AiAccessScope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = AiAccessScopeRaw::deserialize(deserializer)?; + let scope = Self { + user_id: raw.user_id, + workspace_id: raw.workspace_id, + session_id: raw.session_id, + source_kind: raw.source_kind, + permission_level: raw.permission_level, + allowed_roots: raw.allowed_roots, + allowed_file_paths: raw.allowed_file_paths, + allowed_resource_ids: raw.allowed_resource_ids, + share_context: raw.share_context, + }; + scope.validate().map_err(de::Error::custom)?; + Ok(scope) + } +} + +impl AiAccessScope { + pub fn validate(&self) -> Result<(), String> { + reject_blank("userId", &self.user_id)?; + reject_blank("workspaceId", &self.workspace_id)?; + reject_blank("sessionId", &self.session_id)?; + reject_blank_vec("allowedRoots", &self.allowed_roots)?; + reject_blank_vec("allowedFilePaths", &self.allowed_file_paths)?; + reject_blank_vec("allowedResourceIds", &self.allowed_resource_ids)?; + if let Some(share_context) = &self.share_context { + if let Some(share_id) = &share_context.share_id { + reject_blank("shareContext.shareId", share_id)?; + } + if let Some(mode) = &share_context.mode { + reject_blank("shareContext.mode", mode)?; + } + } + Ok(()) + } +} + +fn reject_blank(field: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{field} must not be blank")); + } + Ok(()) +} + +fn reject_blank_vec(field: &str, values: &[String]) -> Result<(), String> { + for value in values { + reject_blank(field, value)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::WorkspaceSourceKind; + use serde_json::json; + + #[test] + fn ai_access_scope_uses_camel_case_payload() { + let scope: AiAccessScope = serde_json::from_value(json!({ + "userId": "user_1", + "workspaceId": "workspace_1", + "sessionId": "session_1", + "sourceKind": "local_folder", + "permissionLevel": "read_write", + "allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"], + "allowedFilePaths": ["file:///mnt/Data1T/Mnote_data/users/user_1/我的空间/README.md"], + "allowedResourceIds": ["local-md:README.md"], + "shareContext": { + "shareId": "share_1", + "mode": "shared_write" + } + })) + .expect("scope"); + + assert_eq!(scope.user_id, "user_1"); + assert_eq!(scope.workspace_id, "workspace_1"); + assert_eq!(scope.session_id, "session_1"); + assert_eq!(scope.source_kind, WorkspaceSourceKind::LocalFolder); + assert_eq!(scope.permission_level, AiAccessPermissionLevel::ReadWrite); + assert_eq!(scope.allowed_roots.len(), 1); + assert_eq!(scope.allowed_file_paths.len(), 1); + assert_eq!(scope.allowed_resource_ids, vec!["local-md:README.md"]); + assert_eq!( + scope + .share_context + .as_ref() + .and_then(|value| value.share_id.as_deref()), + Some("share_1") + ); + + let serialized = serde_json::to_value(&scope).expect("serialized"); + assert_eq!(serialized["userId"], "user_1"); + assert_eq!(serialized["permissionLevel"], "read_write"); + assert_eq!( + serialized["allowedRoots"][0], + "file:///mnt/Data1T/Mnote_data/users/user_1" + ); + } + + #[test] + fn ai_access_scope_rejects_blank_required_fields_and_paths() { + let error = serde_json::from_value::(json!({ + "userId": " ", + "workspaceId": "workspace_1", + "sessionId": "session_1", + "sourceKind": "local_folder", + "permissionLevel": "read_only", + "allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"], + "allowedFilePaths": [], + "allowedResourceIds": [] + })) + .expect_err("blank user id should be rejected"); + assert!(error.to_string().contains("userId")); + + let error = serde_json::from_value::(json!({ + "userId": "user_1", + "workspaceId": "workspace_1", + "sessionId": "session_1", + "sourceKind": "local_folder", + "permissionLevel": "read_only", + "allowedRoots": [" "], + "allowedFilePaths": [], + "allowedResourceIds": [] + })) + .expect_err("blank allowed root should be rejected"); + assert!(error.to_string().contains("allowedRoots")); + } +} diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index 972dad76..f38a0b36 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -76,6 +76,33 @@ pub struct WorkspaceSource { pub capabilities: Vec, } +fn default_page_body_content_format() -> String { + "editorBlocks".into() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PageBodyWriteRequest { + pub document_id: String, + pub workspace_id: String, + pub source_kind: WorkspaceSourceKind, + pub root_uri: String, + #[serde( + default, + alias = "expected_file_version", + alias = "conflictDetectionKey", + alias = "conflict_detection_key" + )] + pub expected_file_version: Option, + #[serde(default)] + pub base_content_hash: Option, + #[serde(default = "default_page_body_content_format")] + pub content_format: String, + pub content: Value, + #[serde(default)] + pub editor_source: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum KernelProjectionCapability { @@ -528,6 +555,14 @@ pub struct DocumentReadPageSubtree { #[serde(rename_all = "camelCase")] pub struct DocumentContentResult { pub content: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub editor_document: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tiptap_document: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub block_document: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub block_projection_version: Option, pub revision: u64, pub conflict_detection_key: String, pub title: Option, @@ -730,4 +765,56 @@ mod tests { serde_json::from_value(value).expect("workspace source 应可反序列化"); assert_eq!(decoded, source); } + + #[test] + fn page_body_write_request_uses_file_version_contract() { + let request: PageBodyWriteRequest = serde_json::from_value(json!({ + "documentId": "local-md:README.md", + "workspaceId": "local-ws:user:my-space", + "sourceKind": "local_folder", + "rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space", + "expectedFileVersion": "local-md:local-md:README.md:1:2:hash", + "baseContentHash": "sha256:base", + "contentFormat": "editorBlocks", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}], + "editorSource": "tiptap" + })) + .expect("page.body.write request 应可反序列化"); + + assert_eq!(request.document_id, "local-md:README.md"); + assert_eq!(request.source_kind, WorkspaceSourceKind::LocalFolder); + assert_eq!( + request.expected_file_version.as_deref(), + Some("local-md:local-md:README.md:1:2:hash") + ); + assert_eq!(request.content_format, "editorBlocks"); + assert_eq!(request.editor_source.as_deref(), Some("tiptap")); + + let value = serde_json::to_value(&request).expect("request 应可序列化"); + assert_eq!( + value["expectedFileVersion"], + json!("local-md:local-md:README.md:1:2:hash") + ); + assert_eq!(value["baseContentHash"], json!("sha256:base")); + assert_eq!(value["contentFormat"], json!("editorBlocks")); + assert_eq!(value["editorSource"], json!("tiptap")); + } + + #[test] + fn page_body_write_request_accepts_legacy_conflict_key_alias() { + let request: PageBodyWriteRequest = serde_json::from_value(json!({ + "documentId": "local-md:README.md", + "workspaceId": "local-ws:user:my-space", + "sourceKind": "local_folder", + "rootUri": "file:///tmp/workspace", + "conflictDetectionKey": "legacy-key", + "content": [] + })) + .expect("legacy compat request 应可反序列化"); + + assert_eq!(request.expected_file_version.as_deref(), Some("legacy-key")); + assert_eq!(request.content_format, "editorBlocks"); + assert_eq!(request.base_content_hash, None); + assert_eq!(request.editor_source, None); + } } diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index d566bdd5..a21bb226 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -11,8 +11,8 @@ pub mod search; pub mod tool; pub use ai::{ - AiEvent, AiEventKind, AiRuntimeOwner, AiSession, AiStructuredWriteKind, - AiStructuredWriteResult, AiToolCall, + AiAccessPermissionLevel, AiAccessScope, AiEvent, AiEventKind, AiRuntimeOwner, AiSession, + AiShareContext, AiStructuredWriteKind, AiStructuredWriteResult, AiToolCall, }; pub use command::{ CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace, @@ -48,8 +48,8 @@ pub use kernel::{ KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, - KernelTraverseGraph, KernelUpdateNode, WorkspaceSource, WorkspaceSourceCapability, - WorkspaceSourceKind, + KernelTraverseGraph, KernelUpdateNode, PageBodyWriteRequest, WorkspaceSource, + WorkspaceSourceCapability, WorkspaceSourceKind, }; pub use mindmap::{ MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities, @@ -289,6 +289,7 @@ mod tests { content: serde_json::json!([]), revision: serde_json::json!(7), conflict_detection_key: serde_json::json!("page_1:7"), + file_version: serde_json::json!(null), block_document: serde_json::json!({ "documentId": "page_1", "rootBlockIds": [], diff --git a/rust/crates/core-protocol/src/page_aggregate.rs b/rust/crates/core-protocol/src/page_aggregate.rs index 15df5b1a..e7409e6c 100644 --- a/rust/crates/core-protocol/src/page_aggregate.rs +++ b/rust/crates/core-protocol/src/page_aggregate.rs @@ -139,6 +139,7 @@ mod tests { assert_eq!(body.block_projection_version, 0); assert_eq!(body.block_document, json!(null)); assert_eq!(body.projection_source, ""); + assert_eq!(body.file_version, json!(null)); } } @@ -149,6 +150,8 @@ pub struct PageBody { pub revision: Value, pub conflict_detection_key: Value, #[serde(default)] + pub file_version: Value, + #[serde(default)] pub block_document: Value, #[serde(default)] pub block_projection_version: u32, diff --git a/rust/crates/mnote-web/src/acp_client.rs b/rust/crates/mnote-web/src/acp_client.rs index 0d49725a..e4c857d9 100644 --- a/rust/crates/mnote-web/src/acp_client.rs +++ b/rust/crates/mnote-web/src/acp_client.rs @@ -136,6 +136,13 @@ impl AcpClient { .stderr(std::process::Stdio::inherit()) .kill_on_drop(true); if let Some(env) = env_overrides { + if let Some(workspace_root) = env.get("MNOTE_AI_WORKSPACE_ROOT") { + let workspace_root = std::path::Path::new(workspace_root); + if workspace_root.is_dir() { + // 本地 workspace run 以授权根目录作为进程工作目录,贴近 VSCode agent 行为。 + command.current_dir(workspace_root); + } + } command.envs(env); } let mut child = command.spawn().map_err(AcpError::Spawn)?; diff --git a/rust/crates/mnote-web/src/acp_runtime.rs b/rust/crates/mnote-web/src/acp_runtime.rs index cb810a95..05720d8f 100644 --- a/rust/crates/mnote-web/src/acp_runtime.rs +++ b/rust/crates/mnote-web/src/acp_runtime.rs @@ -116,7 +116,7 @@ impl AcpRuntimeManager { /// Create a new runtime manager with built-in default configurations. /// /// Reads environment variables to configure Hermes and Reasonix runtimes. - /// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "hermes"). + /// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "reasonix"). pub fn from_env() -> Self { let mut runtimes: HashMap = HashMap::new(); @@ -153,7 +153,8 @@ impl AcpRuntimeManager { ); } - let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "hermes".into()); + let default = + env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "reasonix".into()); Self { runtimes, diff --git a/rust/crates/mnote-web/src/context.rs b/rust/crates/mnote-web/src/context.rs index e42ed4b4..cce9c6c0 100644 --- a/rust/crates/mnote-web/src/context.rs +++ b/rust/crates/mnote-web/src/context.rs @@ -17,6 +17,8 @@ const HEADER_SESSION_ID: &str = "x-mnote-session-id"; const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel"; const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client"; const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key"; +const COOKIE_ACTOR_ID: &str = "mnote_actor_id"; +const COOKIE_ACTOR_TYPE: &str = "mnote_actor_type"; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -79,8 +81,10 @@ impl RequestContext { authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()), cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()), actor_id: header_value(headers, HEADER_ACTOR_ID) + .or_else(|| cookie_value(headers, COOKIE_ACTOR_ID)) .unwrap_or_else(|| "anonymous".into()), actor_type: header_value(headers, HEADER_ACTOR_TYPE) + .or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE)) .unwrap_or_else(|| "anonymous".into()), session_id: header_value(headers, HEADER_SESSION_ID), }, @@ -106,6 +110,10 @@ impl RequestContext { if let Some(workspace_id) = &self.workspace.workspace_id { insert_header(headers, HEADER_WORKSPACE_ID, workspace_id); } + if self.auth.actor_id.trim() != "anonymous" && !self.auth.actor_id.trim().is_empty() { + append_cookie(headers, COOKIE_ACTOR_ID, self.auth.actor_id.trim()); + append_cookie(headers, COOKIE_ACTOR_TYPE, self.auth.actor_type.trim()); + } } } @@ -141,6 +149,14 @@ fn insert_header(headers: &mut HeaderMap, key: &str, value: &str) { headers.insert(name, value); } +fn append_cookie(headers: &mut HeaderMap, name: &str, value: &str) { + let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax"); + let Ok(header_value) = HeaderValue::from_str(&cookie) else { + return; + }; + headers.append(axum::http::header::SET_COOKIE, header_value); +} + #[cfg(test)] mod tests { use super::*; @@ -164,4 +180,40 @@ mod tests { assert_eq!(context.workspace.workspace_id.as_deref(), Some("ws_demo")); assert_eq!(context.auth.actor_id, "user_demo"); } + + #[test] + fn request_context_falls_back_to_actor_cookies() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::COOKIE, + HeaderValue::from_static("mnote_actor_id=user_cookie; mnote_actor_type=user"), + ); + + let context = RequestContext::from_http_parts( + &Method::GET, + &"/".parse::().expect("uri"), + &headers, + ); + + assert_eq!(context.auth.actor_id, "user_cookie"); + assert_eq!(context.auth.actor_type, "user"); + } +} + +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + let cookie_header = headers + .get(axum::http::header::COOKIE) + .and_then(|value| value.to_str().ok())?; + for part in cookie_header.split(';') { + let Some((cookie_name, cookie_value)) = part.trim().split_once('=') else { + continue; + }; + if cookie_name.trim() == name { + let value = cookie_value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None } diff --git a/rust/crates/mnote-web/src/hermes_tools/artifact.rs b/rust/crates/mnote-web/src/hermes_tools/artifact.rs index 466a1636..6566c88e 100644 --- a/rust/crates/mnote-web/src/hermes_tools/artifact.rs +++ b/rust/crates/mnote-web/src/hermes_tools/artifact.rs @@ -3,10 +3,13 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::ToolCallInput; use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts; +use crate::routes::ensure_local_workspace_access; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; pub async fn create_summary( state: &AppState, @@ -71,6 +74,85 @@ async fn create_artifact_node( format!("ai_note_{}_{}", document_id, context.trace.request_id) }; + if input.effective_source_kind().as_deref() == Some("local_folder") { + let root_uri = input.effective_root_uri().ok_or_else(|| { + WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") + .with_context(context) + })?; + ensure_local_workspace_access(context, &root_uri) + .map_err(|error| error.with_context(context))?; + if input.dry_run.unwrap_or(false) { + return Ok(json!({ + "dryRun": true, + "commandName": "tree.node.create", + "commandId": command_id, + "artifactType": node_type, + "artifactDocumentId": artifact_document_id, + "documentId": document_id, + "workspaceId": workspace_id, + "diff": [{"op": "create_artifact", "artifactType": node_type}] + })); + } + let root_path = parse_local_root_path(&root_uri)?; + let artifact_dir = root_path.join(".mnote").join("artifacts"); + fs::create_dir_all(&artifact_dir).map_err(|error| { + WebError::bad_request_code( + "local_artifact_write_failed", + format!( + "无法创建本地 artifact 目录 {}: {error}", + artifact_dir.display() + ), + ) + .with_context(context) + })?; + let artifact_path = artifact_dir.join(format!( + "{}.json", + sanitize_local_artifact_file_name(&artifact_document_id) + )); + let artifact_value = json!({ + "schema": "mnote.local_artifact.v1", + "artifactType": node_type, + "artifactDocumentId": artifact_document_id, + "documentId": document_id, + "workspaceId": workspace_id, + "content": content, + "createdAt": context.trace.trace_id, + }); + fs::write( + &artifact_path, + serde_json::to_string_pretty(&artifact_value).map_err(|error| { + WebError::internal(format!("本地 artifact 序列化失败: {error}")) + .with_context(context) + })?, + ) + .map_err(|error| { + WebError::bad_request_code( + "local_artifact_write_failed", + format!( + "无法写入本地 artifact 文件 {}: {error}", + artifact_path.display() + ), + ) + .with_context(context) + })?; + return Ok(json!({ + "dryRun": false, + "commandName": "tree.node.create", + "commandId": command_id, + "source": "local_folder", + "artifactType": node_type, + "artifactDocumentId": artifact_document_id, + "documentId": document_id, + "workspaceId": workspace_id, + "result": { + "ok": true, + "source": "local_folder", + "artifactPath": artifact_path, + "artifactDocumentId": artifact_document_id, + } + })); + } + if input.dry_run.unwrap_or(false) { return Ok(json!({ "dryRun": true, @@ -166,6 +248,33 @@ async fn create_artifact_node( })) } +fn parse_local_root_path(root_uri: &str) -> Result { + let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") { + stripped.trim() + } else { + root_uri.trim() + }; + if root_path.is_empty() { + return Err(WebError::bad_request_code( + "local_folder_root_required", + "缺少本地文件夹 rootUri", + )); + } + Ok(PathBuf::from(root_path)) +} + +fn sanitize_local_artifact_file_name(value: &str) -> String { + value + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => ch, + }) + .collect::() + .trim() + .to_string() +} + fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { if !input.has_idempotency_key() { return Err(WebError::bad_request_code( diff --git a/rust/crates/mnote-web/src/hermes_tools/block.rs b/rust/crates/mnote-web/src/hermes_tools/block.rs index 4873bf7e..a10b6bb9 100644 --- a/rust/crates/mnote-web/src/hermes_tools/block.rs +++ b/rust/crates/mnote-web/src/hermes_tools/block.rs @@ -707,6 +707,14 @@ pub(crate) fn ensure_write_contract( ) .with_context(context)); } + if input.ai_access_scope_is_read_only() { + return Err(WebError::new( + axum::http::StatusCode::FORBIDDEN, + "mnote_tool_ai_scope_write_forbidden", + "当前 AI scope 是只读权限,禁止执行写入型 mnote tool", + ) + .with_context(context)); + } Ok(()) } @@ -1490,6 +1498,44 @@ fn build_insert_block(block_id: &str, value: &Value) -> Value { mod tests { use super::*; + #[test] + fn ensure_write_contract_rejects_read_only_ai_scope() { + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/tools".parse().expect("uri"), + &axum::http::HeaderMap::new(), + ); + let input = ToolCallInput { + tool_name: "mnote.block.replace".into(), + workspace_id: Some("ws_1".into()), + document_id: Some("doc_1".into()), + source_kind: Some("local_folder".into()), + root_uri: Some("file:///tmp/mnote-readonly".into()), + actor_id: Some("user_1".into()), + profile: None, + session_id: Some("sess_1".into()), + run_id: Some("run_1".into()), + tool_call_id: Some("tool_1".into()), + trace_id: Some("trace_1".into()), + idempotency_key: Some("idem_1".into()), + dry_run: Some(false), + capability_scope: None, + args: Some(json!({ + "aiAccessScope": { + "permissionLevel": "read_only", + "allowedRoots": ["file:///tmp/mnote-readonly"] + } + })), + }; + + let error = ensure_write_contract(&context, &input).expect_err("read only rejected"); + assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN); + assert_eq!( + error.message(), + "当前 AI scope 是只读权限,禁止执行写入型 mnote tool" + ); + } + #[test] fn content_to_text_reads_projection_content_nodes() { let value = json!([ diff --git a/rust/crates/mnote-web/src/hermes_tools/doc.rs b/rust/crates/mnote-web/src/hermes_tools/doc.rs index 6d85542a..9985bf11 100644 --- a/rust/crates/mnote-web/src/hermes_tools/doc.rs +++ b/rust/crates/mnote-web/src/hermes_tools/doc.rs @@ -3,9 +3,38 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::ToolCallInput; use crate::routes::web_shell::build_page_aggregate_snapshot; +use axum::http::StatusCode; use serde_json::{json, Value}; use std::collections::HashSet; +fn file_version_from_aggregate(aggregate: &Value) -> Value { + [ + "/body/fileVersion", + "/body/file_version", + "/body/conflictDetectionKey", + "/body/conflict_detection_key", + ] + .iter() + .find_map(|pointer| { + aggregate + .pointer(pointer) + .filter(|value| !value.is_null()) + .cloned() + }) + .unwrap_or(Value::Null) +} + +fn conflict_detection_key_from_aggregate(aggregate: &Value) -> Option<&str> { + [ + "/body/conflictDetectionKey", + "/body/conflict_detection_key", + "/body/fileVersion", + "/body/file_version", + ] + .iter() + .find_map(|pointer| aggregate.pointer(pointer).and_then(Value::as_str)) +} + pub async fn doc_fetch( state: &AppState, context: &RequestContext, @@ -13,16 +42,28 @@ pub async fn doc_fetch( ) -> Result { let document_id = input.effective_document_id().unwrap_or_default(); let workspace_id = input.effective_workspace_id(); + ensure_ai_scope_resource_allowed(context, input, &document_id)?; - // 本地文件路径检测:直接读取 .md 文件,不经过 Convex - let is_local_file = document_id.starts_with('/') || document_id.starts_with("./"); + // 本地文件路径检测:直接读取授权 root 内的 .md 文件,不经过 Convex。 + let is_local_file = document_id.starts_with('/') + || document_id.starts_with("./") + || document_id.starts_with("file://"); if is_local_file { use std::fs; - let path = &document_id; + let root_uri = local_root_uri_for_tool(input).ok_or_else(|| { + WebError::new( + StatusCode::FORBIDDEN, + "ai_scope_root_uri_required", + "本地文件读取需要授权 rootUri", + ) + .with_context(context) + })?; + let path = crate::routes::ensure_local_path_read_access(context, &root_uri, &document_id) + .map_err(|error| error.with_context(context))?; let content = fs::read_to_string(path).map_err(|error| { WebError::bad_request_code( "mnote_tool_bad_request", - format!("无法读取本地文件 {path}: {error}"), + format!("无法读取本地文件: {error}"), ) .with_context(context) })?; @@ -42,6 +83,7 @@ pub async fn doc_fetch( "source": "local_fs", "documentId": document_id, "workspaceId": workspace_id, + "rootUri": root_uri, "format": "markdown", "detail": "simple", "scope": "full", @@ -221,6 +263,7 @@ pub async fn doc_fetch( "source": source, "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "fileVersion": file_version_from_aggregate(&aggregate), "format": format, "detail": detail, "scope": scope, @@ -238,6 +281,9 @@ pub async fn doc_find( context: &RequestContext, input: &ToolCallInput, ) -> Result { + if let Some(document_id) = input.effective_document_id() { + ensure_ai_scope_resource_allowed(context, input, &document_id)?; + } let aggregate = aggregate_value(state, context, input).await?; let query = input.arg_string("query").ok_or_else(|| { WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query") @@ -287,6 +333,7 @@ pub async fn doc_find( "workspaceId": input.effective_workspace_id(), "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "fileVersion": file_version_from_aggregate(&aggregate), "matches": matches })) } @@ -404,6 +451,7 @@ pub async fn plan_update( "workspaceId": input.effective_workspace_id(), "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "fileVersion": file_version_from_aggregate(&aggregate), "command": command, "diff": diff, "warnings": if plan_blocked { @@ -452,19 +500,81 @@ pub(crate) async fn aggregate_value( WebError::bad_request_code("mnote_tool_bad_request", "页面工具缺少 documentId") .with_context(context) })?; + ensure_ai_scope_resource_allowed(context, input, &document_id)?; let workspace_id = input.effective_workspace_id(); + let source_kind = input.effective_source_kind(); + let root_uri = input.effective_root_uri(); let aggregate = build_page_aggregate_snapshot( state, context, &document_id, workspace_id.as_deref(), - None, - None, + source_kind.as_deref(), + root_uri.as_deref(), ) .await?; serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string())) } +pub(crate) fn ensure_ai_scope_resource_allowed( + context: &RequestContext, + input: &ToolCallInput, + document_id: &str, +) -> Result<(), WebError> { + let Some(scope) = input.arg_value("aiAccessScope") else { + return Ok(()); + }; + let allowed = scope + .get("allowedResourceIds") + .or_else(|| scope.get("allowed_resource_ids")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + if allowed.is_empty() || allowed.contains(document_id) { + return Ok(()); + } + Err(WebError::new( + StatusCode::FORBIDDEN, + "mnote_tool_ai_scope_read_forbidden", + "当前 AI scope 不允许读取该资源", + ) + .with_context(context)) +} + +fn local_root_uri_for_tool(input: &ToolCallInput) -> Option { + input.effective_root_uri().or_else(|| { + input + .arg_value("aiAccessScope") + .and_then(|scope| { + scope + .get("allowedRoots") + .or_else(|| scope.get("allowed_roots")) + .cloned() + }) + .and_then(|allowed_roots| { + allowed_roots.as_array().and_then(|roots| { + roots + .iter() + .filter_map(|root| { + root.get("rootUri") + .or_else(|| root.get("root_uri")) + .and_then(Value::as_str) + }) + .map(str::trim) + .find(|root_uri| !root_uri.is_empty()) + .map(ToOwned::to_owned) + }) + }) + }) +} + pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec { aggregate .pointer("/body/blockDocument/blocks") @@ -1377,7 +1487,11 @@ pub async fn doc_markdown_edit( ) -> Result { let document_id = input.effective_document_id().unwrap_or_default(); let workspace_id = input.effective_workspace_id(); + let source_kind = input.effective_source_kind(); + let root_uri = input.effective_root_uri(); let is_local_file = document_id.starts_with('/') || document_id.starts_with("./"); + let is_local_workspace = + source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some(); crate::hermes_tools::block::ensure_write_contract(context, input)?; // 1. 读取当前文档内容(markdown 形式) @@ -1400,7 +1514,14 @@ pub async fn doc_markdown_edit( } else { let aggregate = aggregate_value(state, context, input).await?; let blocks = block_projection_blocks(&aggregate); - (blocks_to_markdown(&blocks, true), "convex") + ( + blocks_to_markdown(&blocks, true), + if is_local_workspace { + "local_folder" + } else { + "convex" + }, + ) }; // 2. 解析 operations @@ -1483,6 +1604,14 @@ pub async fn doc_markdown_edit( } } + if applied == 0 { + return Err(WebError::bad_request_code( + "mnote_markdown_edit_no_operations_applied", + "markdown_edit 没有任何 search/replace 操作命中,未执行写入", + ) + .with_context(context)); + } + // 4. 构建 changedText 摘要 let changed_text = if applied > 0 { operations @@ -1517,17 +1646,19 @@ pub async fn doc_markdown_edit( } else { // 7-27: 在线写回以最终 markdown 为真源,直接生成 block content // 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径 - let (blocks, original_content) = match aggregate_value(state, context, input).await { - Ok(ref agg) => ( - block_projection_blocks(agg), - crate::hermes_tools::block::current_body_content(agg), - ), - Err(_) if use_full_content.is_some() => { - // 空文档 + full_content:跳过读取 - (vec![], json!([])) - } - Err(e) => return Err(e), - }; + let (aggregate, blocks, original_content) = + match aggregate_value(state, context, input).await { + Ok(agg) => { + let blocks = block_projection_blocks(&agg); + let original_content = crate::hermes_tools::block::current_body_content(&agg); + (agg, blocks, original_content) + } + Err(_) if use_full_content.is_some() => { + // 空文档 + full_content:跳过读取 + (Value::Null, vec![], json!([])) + } + Err(e) => return Err(e), + }; let parsed = parse_final_markdown_to_blocks(&md, &blocks); let next_content = build_page_content(&original_content, &parsed); @@ -1549,11 +1680,62 @@ pub async fn doc_markdown_edit( } else { // 直接构造 RuntimeCommandEnvelopeWire(与 /api/documents/save 相同) let command_id = format!("markdown_edit_{}", context.trace.request_id); + let file_version = file_version_from_aggregate(&aggregate); + let conflict_detection_key = conflict_detection_key_from_aggregate(&aggregate); + if is_local_workspace { + let root_uri = root_uri.as_deref().ok_or_else(|| { + WebError::bad_request_code( + "local_folder_root_required", + "缺少本地文件夹 rootUri", + ) + .with_context(context) + })?; + crate::routes::ensure_local_workspace_access(context, root_uri) + .map_err(|error| error.with_context(context))?; + let expected_file_version = file_version + .as_str() + .or(conflict_detection_key) + .map(|value| value.to_string()); + let result = crate::routes::write_local_markdown_page_body( + &core_protocol::PageBodyWriteRequest { + document_id: document_id.clone(), + workspace_id: workspace_id.clone().unwrap_or_default(), + source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, + root_uri: root_uri.to_string(), + expected_file_version, + base_content_hash: None, + content_format: "editorBlocks".into(), + content: next_content, + editor_source: Some("mnote.doc.markdown_edit".into()), + }, + )?; + return Ok(json!({ + "ok": true, + "schema": "mnote.doc.markdown_edit.v1", + "source": "local_folder", + "documentId": document_id, + "workspaceId": workspace_id, + "operationsApplied": applied, + "operationsFailed": failed.len(), + "failedOperations": failed, + "changedText": changed_text, + "fileVersion": file_version, + "applyResult": { + "commandName": "page.body.write", + "commandId": command_id, + "changedBlocks": changed_blocks, + "result": result + } + })); + } let payload = json!({ "documentId": document_id, "workspaceId": workspace_id, "content": next_content, "mode": "replace", + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": conflict_detection_key.map(Value::from).unwrap_or(Value::Null), + "fileVersion": file_version, }); use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, @@ -1573,8 +1755,8 @@ pub async fn doc_markdown_edit( source: RuntimeSourceWire { channel: "mnote-hermes".into(), client: "mnote-hermes-plugin".into(), - source_kind: None, - root_uri: None, + source_kind, + root_uri, workspace_id: None, capabilities: Vec::new(), }, diff --git a/rust/crates/mnote-web/src/hermes_tools/manifest.rs b/rust/crates/mnote-web/src/hermes_tools/manifest.rs index 6a78a766..43ff730b 100644 --- a/rust/crates/mnote-web/src/hermes_tools/manifest.rs +++ b/rust/crates/mnote-web/src/hermes_tools/manifest.rs @@ -173,7 +173,7 @@ fn doc_plan_update_tool() -> Value { fn block_replace_tool() -> Value { write_tool( "mnote.block.replace", - "替换指定块内容;真实写入走 Rust page.body.save 链路", + "兼容块写工具:替换指定块内容;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,必要时再走 Rust page.body.write 兼容链路", ["block.write", "page.write"], json!({ "blockId": { "type": "string" }, @@ -394,7 +394,7 @@ fn page_get_tool() -> Value { fn page_save_tool() -> Value { json!({ "name": "mnote.page.save", - "description": "保存当前页面正文;replace 覆盖正文,append/prepend 会先读取当前 Page Aggregate 后合成完整正文再保存", + "description": "粗粒度兼容兜底:保存当前页面正文;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,只有整页覆盖/追加且其它工具无法表达时使用", "schemaVersion": TOOL_SCHEMA_VERSION, "capabilityScope": ["page.write"], "status": "available", @@ -445,7 +445,7 @@ fn available_tool( fn doc_markdown_edit_tool() -> Value { let mut tool = write_tool( "mnote.doc.markdown_edit", - "通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。", + "兼容 / 远端代理 fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 默认优先让 agent 原生 patch/diff 直接编辑授权文件;仅在需要 MNote 兼容工具、远端代理或结构校验时使用。", ["block.write", "page.write"], json!({ "operations": { @@ -489,6 +489,8 @@ fn tool_annotations( "readonly": readonly, "destructive": destructive, "idempotent": idempotent, + "readOnly": readonly, + "requiresWritePermission": !readonly, "requiresApproval": requires_approval, "approvalMode": if requires_approval { "review" } else { "yolo" }, "runtimeOwner": "mnote-web", diff --git a/rust/crates/mnote-web/src/hermes_tools/mod.rs b/rust/crates/mnote-web/src/hermes_tools/mod.rs index 9b0fb151..f3c4c636 100644 --- a/rust/crates/mnote-web/src/hermes_tools/mod.rs +++ b/rust/crates/mnote-web/src/hermes_tools/mod.rs @@ -13,6 +13,8 @@ pub struct ToolCallInput { pub tool_name: String, pub workspace_id: Option, pub document_id: Option, + pub source_kind: Option, + pub root_uri: Option, pub actor_id: Option, pub profile: Option, pub session_id: Option, @@ -58,6 +60,24 @@ impl ToolCallInput { .or_else(|| self.arg_string("documentId")) } + pub fn effective_source_kind(&self) -> Option { + self.source_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| self.arg_string("sourceKind")) + } + + pub fn effective_root_uri(&self) -> Option { + self.root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| self.arg_string("rootUri")) + } + pub fn effective_tool_call_id(&self) -> String { self.tool_call_id .as_deref() @@ -91,4 +111,37 @@ impl ToolCallInput { .filter(|value| !value.is_empty()) .is_some() } + + pub fn ai_access_scope(&self) -> Option<&Value> { + self.args.as_ref().and_then(|args| { + args.get("aiAccessScope") + .or_else(|| args.get("ai_access_scope")) + }) + } + + pub fn ai_access_permission_level(&self) -> Option { + self.ai_access_scope() + .and_then(|scope| { + scope + .get("permissionLevel") + .or_else(|| scope.get("permission_level")) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + } + + pub fn ai_access_scope_is_read_only(&self) -> bool { + self.ai_access_permission_level() + .map(|level| { + let normalized = level.trim().to_ascii_lowercase(); + normalized == "read" + || normalized == "readonly" + || normalized == "read_only" + || normalized == "shared_read" + || (normalized.contains("read") && !normalized.contains("write")) + }) + .unwrap_or(false) + } } diff --git a/rust/crates/mnote-web/src/hermes_tools/page.rs b/rust/crates/mnote-web/src/hermes_tools/page.rs index fb6249d7..ba7f3ea2 100644 --- a/rust/crates/mnote-web/src/hermes_tools/page.rs +++ b/rust/crates/mnote-web/src/hermes_tools/page.rs @@ -18,14 +18,17 @@ pub async fn page_get( WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId") .with_context(context) })?; + crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?; let workspace_id = input.effective_workspace_id(); + let source_kind = input.effective_source_kind(); + let root_uri = input.effective_root_uri(); let aggregate = build_page_aggregate_snapshot( state, context, &document_id, workspace_id.as_deref(), - None, - None, + source_kind.as_deref(), + root_uri.as_deref(), ) .await?; let aggregate_value = @@ -83,6 +86,14 @@ fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Res ) .with_context(context)); } + if input.ai_access_scope_is_read_only() { + return Err(WebError::new( + axum::http::StatusCode::FORBIDDEN, + "mnote_tool_ai_scope_write_forbidden", + "当前 AI scope 是只读权限,禁止执行写入型 mnote tool", + ) + .with_context(context)); + } Ok(()) } @@ -200,6 +211,74 @@ async fn page_command( return Ok(result); } + if input.effective_source_kind().as_deref() == Some("local_folder") { + let root_uri = input.effective_root_uri().ok_or_else(|| { + WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") + .with_context(context) + })?; + crate::routes::ensure_local_workspace_access(context, &root_uri) + .map_err(|error| error.with_context(context))?; + let local_result = match command_name { + "page.body.save" => { + let content = payload.get("content").cloned().unwrap_or(Value::Null); + crate::routes::write_local_markdown_page_body( + &core_protocol::PageBodyWriteRequest { + document_id: document_id.clone(), + workspace_id: workspace_id.clone().unwrap_or_default(), + source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, + root_uri: root_uri.clone(), + expected_file_version: input.arg_string("expectedFileVersion"), + base_content_hash: input.arg_string("baseContentHash"), + content_format: "editorBlocks".into(), + content, + editor_source: Some("mnote.page.save".into()), + }, + )? + } + "page.head.updateTitle" => { + let title = payload + .get("title") + .and_then(Value::as_str) + .ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.page.update_title 缺少 title", + ) + .with_context(context) + })?; + crate::routes::update_local_markdown_title(&root_uri, &document_id, title)? + } + "page.layout.updateOptions" => { + let options = payload.get("options").cloned().ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.page.update_options 缺少 options", + ) + .with_context(context) + })?; + crate::routes::update_local_page_options(&root_uri, &document_id, &options)? + } + _ => { + return Err(WebError::bad_request_code( + "mnote_tool_bad_request", + format!("local source 暂不支持页面命令 {command_name}"), + ) + .with_context(context)); + } + }; + let mut result = json!({ + "dryRun": false, + "source": "local_folder", + "commandName": if command_name == "page.body.save" { "page.body.write" } else { command_name }, + "commandId": command_id, + "documentId": document_id, + "workspaceId": workspace_id, + "result": local_result + }); + merge_result_extra(&mut result, result_extra); + return Ok(result); + } + let command = RuntimeCommandEnvelopeWire { name: command_name.into(), command_id: command_id.clone(), diff --git a/rust/crates/mnote-web/src/page_aggregate/builder.rs b/rust/crates/mnote-web/src/page_aggregate/builder.rs index 9e288f52..54b61d83 100644 --- a/rust/crates/mnote-web/src/page_aggregate/builder.rs +++ b/rust/crates/mnote-web/src/page_aggregate/builder.rs @@ -344,6 +344,7 @@ impl PageAggregateBuilder { content: self.content, revision: self.revision, conflict_detection_key: self.conflict_detection_key, + file_version: Value::Null, block_document: block_document.unwrap_or(Value::Null), block_projection_version, projection_source: "builder.content".into(), diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index bf501d80..c40bb321 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -5,7 +5,8 @@ use crate::routes::command_support::{ execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts, }; use crate::routes::local_folder_source::{ - save_local_markdown_page, update_local_markdown_title, update_local_page_options, + ensure_local_workspace_access, update_local_markdown_title, update_local_page_options, + write_local_markdown_page_body, }; use crate::routes::query_support::{ execute_runtime_query_via_convex, fetch_documents_meta_via_convex, @@ -46,6 +47,10 @@ pub struct DocumentSaveRequest { pub root_uri: Option, pub revision: Option, pub conflict_detection_key: Option, + pub expected_file_version: Option, + pub base_content_hash: Option, + pub content_format: Option, + pub editor_source: Option, pub editor_document: Option, pub content: Value, pub tiptap_document: Option, @@ -408,6 +413,7 @@ async fn proxy_next_documents_save( "workspaceId": effective_workspace_id, "revision": body.revision, "conflictDetectionKey": body.conflict_detection_key, + "expectedFileVersion": body.expected_file_version, "editorDocument": body.editor_document, "content": body.content, "tiptapDocument": body.tiptap_document, @@ -514,6 +520,38 @@ pub async fn content( Ok(ok_response(&context, result)) } +pub async fn page_body_write( + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let document_id = body.document_id.trim(); + if document_id.is_empty() { + return Err( + WebError::bad_request_code("document_id_required", "缺少有效 documentId") + .with_context(&context), + ); + } + if body.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder { + return Err(WebError::bad_request_code( + "page_body_write_source_unsupported", + "page.body.write 当前只支持 local_folder 本地写入", + ) + .with_context(&context)); + } + let root_uri = body.root_uri.trim(); + if root_uri.is_empty() { + return Err(WebError::bad_request_code( + "local_folder_root_required", + "缺少本地文件夹 rootUri", + ) + .with_context(&context)); + } + ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let result = write_local_markdown_page_body(&body)?; + Ok(ok_response(&context, result)) +} + pub async fn save( State(state): State, Extension(context): Extension, @@ -536,12 +574,29 @@ pub async fn save( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; - let result = save_local_markdown_page( - root_uri, - document_id, - body.conflict_detection_key.as_deref(), - &body.content, - )?; + ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let expected_file_version = body + .expected_file_version + .as_deref() + .or(body.conflict_detection_key.as_deref()); + let result = write_local_markdown_page_body(&core_protocol::PageBodyWriteRequest { + document_id: document_id.to_string(), + workspace_id: body.workspace_id.clone().unwrap_or_default(), + source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, + root_uri: root_uri.to_string(), + expected_file_version: expected_file_version.map(ToOwned::to_owned), + base_content_hash: body.base_content_hash.clone(), + content_format: body + .content_format + .clone() + .unwrap_or_else(|| "editorBlocks".into()), + content: body.content.clone(), + editor_source: body + .editor_source + .clone() + .or_else(|| Some("documents/save-compat".into())), + })?; return Ok(ok_response(&context, result)); } let effective_workspace_id = @@ -578,6 +633,7 @@ pub async fn save( "workspaceId": effective_workspace_id, "revision": body.revision, "conflictDetectionKey": body.conflict_detection_key, + "expectedFileVersion": body.expected_file_version, "editorDocument": body.editor_document, "content": body.content, "tiptapDocument": body.tiptap_document, @@ -766,6 +822,8 @@ pub async fn title( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; + ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; let result = update_local_markdown_title(root_uri, document_id, title)?; return Ok(ok_response(&context, result)); } @@ -859,6 +917,8 @@ pub async fn options( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; + ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; let result = update_local_page_options(root_uri, document_id, &body.options)?; return Ok(ok_response(&context, result)); } @@ -1311,6 +1371,11 @@ mod tests { ) .expect("write md"); let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_test", + &root_uri, + ) + .expect("init local workspace"); let document_id = "local-mdid:local-stable"; let title_response = app() @@ -1319,6 +1384,8 @@ mod tests { .method("POST") .uri("/api/documents/title") .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "documentId": document_id, @@ -1340,6 +1407,8 @@ mod tests { .method("POST") .uri("/api/documents/save") .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "documentId": document_id, @@ -1367,6 +1436,15 @@ mod tests { .await .expect("save response"); assert_eq!(save_response.status(), StatusCode::OK); + let save_body = to_bytes(save_response.into_body(), usize::MAX) + .await + .expect("save body"); + let save_payload: Value = serde_json::from_slice(&save_body).expect("save json"); + assert_eq!( + save_payload["result"]["canonicalCommand"], + "page.body.write" + ); + assert_eq!(save_payload["result"]["compatCommand"], "page.body.save"); let options_response = app() .oneshot( @@ -1374,6 +1452,8 @@ mod tests { .method("POST") .uri("/api/documents/options") .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "documentId": document_id, @@ -1406,4 +1486,82 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + + #[tokio::test] + async fn local_folder_documents_save_rejects_stale_expected_file_version() { + let root = std::env::temp_dir().join(format!( + "mnote-local-documents-expected-file-version-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create local root"); + std::fs::write( + root.join("README.md"), + "---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# Old\n", + ) + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_test", + &root_uri, + ) + .expect("init local workspace"); + let document_id = "local-mdid:expected-file-version"; + let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate( + &root_uri, + document_id, + ) + .expect("aggregate"); + let stale_file_version = aggregate + .body + .conflict_detection_key + .as_str() + .expect("file version") + .to_string(); + + std::thread::sleep(std::time::Duration::from_millis(5)); + std::fs::write( + root.join("README.md"), + "---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# External\n", + ) + .expect("external write"); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/documents/save") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::from( + serde_json::json!({ + "documentId": document_id, + "sourceKind": "local_folder", + "rootUri": root_uri, + "expectedFileVersion": stale_file_version, + "content": [ + { + "id": "heading_1", + "type": "heading", + "props": { "level": 1 }, + "content": [{ "type": "text", "text": "Editor" }] + } + ], + "blockCount": 1 + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("save response"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(markdown.contains("# External")); + assert!(!markdown.contains("# Editor")); + + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index ee4f1847..49ca9bee 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -1,7 +1,10 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot; +use crate::routes::local_folder_source::{ + ensure_local_workspace_read_access, is_local_access_policy_admin_context, + load_local_folder_page_tree_snapshot, local_access_policy_path_display, +}; use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ build_document_panes_bootstrap_json, build_editor_bootstrap_json, @@ -153,6 +156,53 @@ pub async fn auth_entry( Ok(response) } +pub async fn admin_access_policy_entry( + State(state): State, + Extension(context): Extension, +) -> Result { + if !has_real_auth_context(&context) { + let mut response = Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, "/auth") + .body(Body::empty()) + .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; + stamp_gateway_headers(response.headers_mut(), false); + return Ok(response); + } + if !is_local_access_policy_admin_context(&context) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_access_policy_admin_required", + "只有管理员可以访问目录授权页面", + ) + .with_context(&context)); + } + let workspace_name = format!("{} 的空间", state.config().dev_user_name); + let policy_path = local_access_policy_path_display(); + let content = crate::ssr::render_view(leptos::view! { + + }); + let mut response = Html(format!( + r#" + + + + 目录授权 + + + + {} + +"#, + crate::ssr::MNOTE_CSS, + escape_html(context.auth.actor_id.as_str()), + content + )) + .into_response(); + stamp_gateway_headers(response.headers_mut(), false); + Ok(response) +} + pub async fn root_entry( State(state): State, Extension(context): Extension, @@ -178,6 +228,20 @@ pub async fn root_entry( let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID); let recent_page_id = normalize_optional_id(recent_page_id.as_deref()); let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); + let should_render_local_first_landing = !is_local_folder + && query + .source_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + && query + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + && requested_page_id.is_none(); let ( workspace_id, workspace_projection, @@ -195,6 +259,7 @@ pub async fn root_entry( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; + ensure_local_workspace_read_access(&context, root_uri)?; let snapshot = load_local_folder_page_tree_snapshot(root_uri)?; let workspace_id = snapshot .dataset @@ -239,6 +304,26 @@ pub async fn root_entry( Some("local_folder".to_string()), Some(root_uri.to_string()), ) + } else if should_render_local_first_landing { + let workspace_id = "local-first-entry".to_string(); + let workspace_projection = build_workspace_shell_projection( + &json!({ + "workspaces": [{ "id": workspace_id, "name": "我的空间" }], + "documents": [], + }), + &workspace_id, + None, + "我的空间", + ); + ( + workspace_id, + workspace_projection, + String::new(), + String::new(), + None, + None, + None, + ) } else { let workspace_id = resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; @@ -303,6 +388,7 @@ pub async fn root_entry( .active_page_title .clone() .unwrap_or_default(); + let show_admin_access_policy = is_local_access_policy_admin_context(&context); let render_workspace_entry = || { crate::ssr::render_view(leptos::view! { }) }; @@ -360,6 +447,7 @@ pub async fn root_entry( workspace_name={workspace_name.clone()} workspace_sidebar_html={workspace_sidebar_html.clone()} page_subtree_json={page_subtree_json} + show_admin_access_policy={show_admin_access_policy} /> }); let body_extra = format!( @@ -387,13 +475,14 @@ pub async fn root_entry( {} - + {} {} "#, escape_html(&html_title), crate::ssr::MNOTE_CSS, + escape_html(context.auth.actor_id.as_str()), content, body_extra )) @@ -1769,6 +1858,111 @@ mod tests { assert!(html.contains("mnote.document_panes_bootstrap.v1")); } + #[tokio::test] + async fn root_entry_renders_local_first_landing_without_convex() { + let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) + .oneshot( + Request::builder() + .uri("/") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#)); + assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#)); + assert!(!html.contains("workspaces:ensureDefaultWorkspace")); + } + + #[tokio::test] + async fn root_entry_shows_admin_access_policy_entry_only_for_admin_actor() { + let admin_response = + app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) + .oneshot( + Request::builder() + .uri("/") + .header("x-mnote-actor-id", "admin_real") + .header("x-mnote-actor-type", "admin") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("admin response"); + + assert_eq!(admin_response.status(), StatusCode::OK); + let admin_body = to_bytes(admin_response.into_body(), usize::MAX) + .await + .expect("body"); + let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8"); + assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); + + let user_response = + app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) + .oneshot( + Request::builder() + .uri("/") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("user response"); + + assert_eq!(user_response.status(), StatusCode::OK); + let user_body = to_bytes(user_response.into_body(), usize::MAX) + .await + .expect("body"); + let user_html = String::from_utf8(user_body.to_vec()).expect("utf8"); + assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); + } + + #[tokio::test] + async fn admin_access_policy_entry_requires_admin_actor() { + let user_response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri("/admin/access-policy") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("user response"); + assert_eq!(user_response.status(), StatusCode::FORBIDDEN); + + let admin_response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri("/admin/access-policy") + .header("x-mnote-actor-id", "admin_real") + .header("x-mnote-actor-type", "admin") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("admin response"); + + assert_eq!(admin_response.status(), StatusCode::OK); + let body = to_bytes(admin_response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#)); + assert!(html.contains(r#"data-testid="mnote-admin-validate-root-submit""#)); + assert!(html.contains(r#"data-testid="mnote-admin-create-grant-submit""#)); + assert!(html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#)); + } + #[tokio::test] async fn root_entry_renders_local_folder_without_debug_tree_route() { let root = @@ -1780,6 +1974,11 @@ mod tests { std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset"); let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_real", + &root_uri, + ) + .expect("init local workspace"); let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index 74ce9004..a9d1fddf 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -12,8 +12,10 @@ use axum::Json; use futures_util::TryStreamExt; use serde::Deserialize; use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fs; +use std::hash::{Hash, Hasher}; +use std::io::Write; use std::path::{Path as FsPath, PathBuf}; use std::process::Command; use std::sync::{Arc, LazyLock, Mutex}; @@ -36,6 +38,11 @@ static ACP_RUN_PAYLOADS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_ACTIVE_RUNS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +static ACP_LOCAL_AUDIT_SNAPSHOTS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +const LOCAL_AGENT_AUDIT_DIR: &str = "/mnt/Data1T/Mnote_data/control-plane/agent-audit"; +const LOCAL_AGENT_AUDIT_JSONL: &str = "agent-audit.jsonl"; #[derive(Debug, Clone)] struct HermesRuntimeState { @@ -82,11 +89,29 @@ struct AcpActiveRun { acp_session_id: String, } +#[derive(Debug, Clone)] +struct LocalAgentAuditFileSnapshot { + size: u64, + modified_ms: u128, + hash: u64, + markdown_content: Option, +} + +#[derive(Debug, Clone)] +struct LocalAgentAuditSnapshot { + root_uri: String, + files: BTreeMap, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { workspace_id: Option, document_id: Option, + source_kind: Option, + root_uri: Option, + share_id: Option, + permission_level: Option, trace_id: Option, title: Option, profile: Option, @@ -227,6 +252,36 @@ async fn list_acp_sessions( context: &RequestContext, query: &HashMap, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { + if query.get("sourceKind").map(String::as_str).map(str::trim) == Some("local_folder") { + let root_uri = query + .get("rootUri") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") + .with_context(context) + })?; + crate::routes::ensure_local_workspace_access(context, root_uri) + .map_err(|error| error.with_context(context))?; + let limit = query + .get("limit") + .and_then(|value| value.parse::().ok()) + .unwrap_or(50) + .clamp(1, 100); + let sessions = list_local_ai_sessions(root_uri, limit)?; + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "persistence": "local_ai_session_jsonl", + "sessionStorage": "local_private", + "sessions": sessions + })), + )); + } let user_id = effective_session_store_user_id(state, context).await?; let workspace_id = query .get("workspaceId") @@ -311,6 +366,109 @@ pub async fn create_session( .filter(|value| !value.is_empty()) .unwrap_or("default"); let mut persistence = "hermes_on_first_run"; + let session_store_user_id = effective_session_store_user_id(&state, &context).await?; + if payload.source_kind.as_deref().map(str::trim) == Some("local_folder") { + let root_uri = payload + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + crate::routes::ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let share_id = payload + .share_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let permission_level = payload + .permission_level + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(if share_id.is_some() { + "shared_read" + } else { + "private" + }); + if share_id.is_some() && permission_level == "shared_read" { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_ai_session_shared_read_write_forbidden", + "共享只读 AI 会话不能创建可写本地会话记录", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")); + } + let shared_write = share_id.is_some() && permission_level == "shared_write"; + let visibility = if shared_write { "shared" } else { "private" }; + let session_storage = if shared_write { + "local_shared" + } else { + "local_private" + }; + let event = json!({ + "schema": "mnote.local_ai_session_event.v1", + "eventType": "session.created", + "sessionId": session_id, + "workspaceId": payload.workspace_id, + "documentId": payload.document_id, + "sourceKind": "local_folder", + "visibility": visibility, + "permissionLevel": permission_level, + "shareId": share_id, + "title": payload.title.as_deref().unwrap_or("当前页问答"), + "profile": profile, + "userId": session_store_user_id, + "traceId": trace_id, + "createdAt": now_ms() + }); + append_local_ai_session_event(root_uri, &session_id, &event, share_id) + .map_err(|error| error.with_context(&context))?; + if shared_write { + let audit_event = json!({ + "schema": "mnote.local_ai_session_event.v1", + "eventType": "audit.shared_write", + "sessionId": session_id, + "workspaceId": payload.workspace_id, + "documentId": payload.document_id, + "sourceKind": "local_folder", + "permissionLevel": permission_level, + "shareId": share_id, + "actorId": context.auth.actor_id, + "userId": session_store_user_id, + "traceId": trace_id, + "changedFiles": [], + "diffSummary": "shared AI session created", + "createdAt": now_ms() + }); + append_local_ai_session_event(root_uri, &session_id, &audit_event, share_id) + .map_err(|error| error.with_context(&context))?; + } + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "sessionId": session_id, + "workspaceId": payload.workspace_id, + "documentId": payload.document_id, + "profile": profile, + "title": payload.title.unwrap_or_else(|| "当前页问答".into()), + "traceId": trace_id, + "persistence": "local_ai_session_jsonl", + "sessionStorage": session_storage, + "permissionLevel": permission_level, + "shareId": share_id + })), + )); + } let runtime_payload = json!({ "workspaceId": payload.workspace_id.clone(), "documentId": payload.document_id.clone(), @@ -318,7 +476,8 @@ pub async fn create_session( "traceId": trace_id.clone(), "profile": profile, "title": payload.title.clone(), - "actorId": context.auth.actor_id, + "actorId": session_store_user_id, + "actorType": "user", }); if let Some(acp_runtime) = acp_runtime_for_payload(&runtime_payload, profile) { let registration = HermesRunRegistration { @@ -388,8 +547,30 @@ pub async fn gateway_health( .map(ToOwned::to_owned) .or_else(active_profile_name) .unwrap_or_else(|| "default".into()); - let upstream = configured_upstream_for_profile(&profile); let profile_status = profile_gateway_status(&profile); + if is_acp_profile(&profile) { + let runtime_name = crate::acp_bridge::runtime_name_for_profile(&profile); + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "profile": profile_status, + "gateway": { + "configured": true, + "upstream": Value::Null, + "ok": true, + "status": "acp", + "runtime": runtime_name, + "transport": "acp", + "message": "页面 AI 默认使用 ACP runtime,Hermes HTTP gateway 已退役。" + }, + "suggestions": [] + })), + )); + } + let upstream = configured_upstream_for_profile(&profile); let mut suggestions = profile_status .get("suggestions") .and_then(Value::as_array) @@ -879,6 +1060,38 @@ async fn get_acp_session( session_id: &str, query: &HashMap, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { + if query.get("sourceKind").map(String::as_str).map(str::trim) == Some("local_folder") { + let root_uri = query + .get("rootUri") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") + .with_context(context) + })?; + crate::routes::ensure_local_workspace_access(context, root_uri) + .map_err(|error| error.with_context(context))?; + let events = read_local_ai_session_events(root_uri, session_id, None)?; + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "persistence": "local_ai_session_jsonl", + "sessionStorage": "local_private", + "sessionId": session_id, + "session": { + "sessionId": session_id, + "messages": [], + "events": events + }, + "runtime": runtime_state_for_session(session_id), + "events": events + })), + )); + } let user_id = effective_session_store_user_id(state, context).await?; let workspace_id = query .get("workspaceId") @@ -1002,7 +1215,7 @@ pub async fn create_run( .lock() .expect("acp run payloads") .insert(run_id.clone(), payload.clone()); - persist_acp_runtime_run( + let persistence_result = persist_acp_runtime_run( &state, &context, ®istration, @@ -1012,6 +1225,21 @@ pub async fn create_run( &payload, ) .await?; + if payload + .get("sourceKind") + .and_then(Value::as_str) + .map(str::trim) + == Some("local_folder") + { + if let Some(root_uri) = payload.get("rootUri").and_then(Value::as_str) { + match local_agent_audit_collect_snapshot(root_uri) { + Ok(snapshot) => local_agent_audit_store_snapshot(&run_id, snapshot), + Err(error) => { + warn!(error = ?error, run_id = %run_id, "本地 agent audit 初始快照采集失败"); + } + } + } + } let response = json!({ "ok": true, "runId": run_id, @@ -1019,6 +1247,14 @@ pub async fn create_run( "profile": registration.profile, "traceId": context.trace.trace_id, "runtime": runtime_state, + "persistence": persistence_result + .get("persistence") + .and_then(Value::as_str) + .unwrap_or("convex_acp_runtime_store"), + "sessionStorage": persistence_result + .get("sessionStorage") + .cloned() + .unwrap_or(Value::Null), }); return Ok((StatusCode::OK, stamp_client_headers(), Json(response))); } @@ -1110,6 +1346,7 @@ async fn acp_stream_events( let runtime_name = acp_runtime_name; // Ensure runtime is active; switch_to either activates it or returns existing + let access_env = acp_allowed_roots_env_for_payload(&payload); let client = if runtime_name == "hermes" { let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN") .ok() @@ -1117,10 +1354,18 @@ async fn acp_stream_events( .unwrap_or_else(|| "hermes".into()); let mut config = crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile)); - config.env = acp_hermes_env_for_profile(profile); + config.env = merge_acp_runtime_env(acp_hermes_env_for_profile(profile), access_env); state.acp_runtime.switch_to_config(config).await } else { - state.acp_runtime.switch_to(runtime_name).await + match state.acp_runtime.get_config(runtime_name).cloned() { + Some(mut config) => { + config.env = merge_acp_runtime_env(config.env, access_env); + state.acp_runtime.switch_to_config(config).await + } + None => Err(crate::acp_client::AcpError::Internal(format!( + "unknown runtime: {runtime_name}" + ))), + } } .map_err(|e| { WebError::bad_gateway_code( @@ -1210,6 +1455,9 @@ async fn acp_stream_events( let run_id_owned = run_id.to_string(); let mgr_clone = Arc::clone(&mgr); let event_tx_prompt = event_tx.clone(); + let audit_context = context.clone(); + let audit_payload = payload.clone(); + let audit_runtime = acp_runtime_name.to_string(); let mnote_tool_context = crate::acp_session_manager::AcpMnoteToolContext { mnote_session_id: Some(mnote_session_id.clone()), run_id: Some(run_id.to_string()), @@ -1235,18 +1483,63 @@ async fn acp_stream_events( { Ok(result) => { info!("ACP prompt completed: stop_reason={:?}", result.stop_reason); + let agent_audit = if audit_payload + .get("sourceKind") + .and_then(Value::as_str) + .map(str::trim) + == Some("local_folder") + { + match local_agent_audit_finalize_run( + &audit_context, + &audit_payload, + &run_id_owned, + &audit_runtime, + "completed", + ) { + Ok(event) => event, + Err(error) => { + warn!(error = ?error, run_id = %run_id_owned, "本地 agent audit 完成事件写入失败"); + Value::Null + } + } + } else { + Value::Null + }; let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent { event: "run.completed".into(), data: json!({ "stopReason": format!("{:?}", result.stop_reason), + "agentAudit": agent_audit, }), }); } Err(e) => { warn!("ACP prompt failed: {e}"); + let agent_audit = if audit_payload + .get("sourceKind") + .and_then(Value::as_str) + .map(str::trim) + == Some("local_folder") + { + match local_agent_audit_finalize_run( + &audit_context, + &audit_payload, + &run_id_owned, + &audit_runtime, + "failed", + ) { + Ok(event) => event, + Err(error) => { + warn!(error = ?error, run_id = %run_id_owned, "本地 agent audit 失败事件写入失败"); + Value::Null + } + } + } else { + Value::Null + }; let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent { event: "run.failed".into(), - data: json!({ "error": e.to_string() }), + data: json!({ "error": e.to_string(), "agentAudit": agent_audit }), }); } } @@ -2388,12 +2681,37 @@ fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String { format!("{prefix}_{normalized}_{suffix}") } -/// Returns true if the given profile should use ACP instead of Hermes HTTP proxy. +fn hermes_http_proxy_enabled() -> bool { + [ + "MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", + "MNOTE_ENABLE_HERMES_HTTP_PROXY", + ] + .into_iter() + .find_map(env_or_dotenv) + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + }) + .unwrap_or(false) +} + +fn default_acp_runtime_name() -> String { + env_or_dotenv("MNOTE_WEB_ACP_DEFAULT_RUNTIME") + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| value == "hermes" || value == "reasonix") + .unwrap_or_else(|| "reasonix".into()) +} + +/// 判断当前 profile 是否应走 ACP,而不是已退役的 Hermes HTTP proxy。 /// -/// Profile names `reasonix` always use ACP. Other profiles can be configured -/// via `MNOTE_WEB__RUNTIME_TYPE=acp`. -/// Default profiles (`default`, `hermes`) use the traditional Hermes HTTP proxy. +/// Hermes HTTP 不再是页面 AI 默认主路径;仅在显式兼容开关打开时, +/// 给尚未迁移的调用方保留短期入口。 fn is_acp_profile(profile: &str) -> bool { + if !hermes_http_proxy_enabled() { + return true; + } // ACP runtimes: "reasonix" and "hermes" both use ACP protocol when selected from the UI. // The "reasonix" name is hardcoded; "hermes" as ACP is triggered by env var or UI selection. if profile == "reasonix" || profile == "hermes" { @@ -2415,9 +2733,13 @@ fn acp_runtime_for_payload(payload: &Value, profile: &str) -> Option { return Some(runtime.to_lowercase()); } if is_acp_profile(profile) { - return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string()); + return Some(default_acp_runtime_name()); + } + if hermes_http_proxy_enabled() { + None + } else { + Some(default_acp_runtime_name()) } - None } fn acp_runtime_for_run(run_id: &str, profile: &str) -> Option { @@ -2489,6 +2811,112 @@ fn acp_hermes_env_for_profile(profile: &str) -> Option> Some(env) } +fn merge_acp_runtime_env( + base: Option>, + extra: Option>, +) -> Option> { + let mut merged = base.unwrap_or_default(); + if let Some(extra) = extra { + merged.extend(extra); + } + if merged.is_empty() { + None + } else { + Some(merged) + } +} + +fn local_ai_payload_permission_level(payload: &Value) -> &'static str { + let raw = payload + .get("permissionLevel") + .or_else(|| payload.get("permission_level")) + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("read_write") + .to_ascii_lowercase(); + if raw == "read" || raw == "readonly" || raw == "read_only" || raw == "shared_read" { + "read_only" + } else { + "read_write" + } +} + +fn local_ai_payload_is_read_only(payload: &Value) -> bool { + local_ai_payload_permission_level(payload) == "read_only" +} + +fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option> { + if payload + .get("sourceKind") + .and_then(Value::as_str) + .map(str::trim) + != Some("local_folder") + { + return None; + } + let root_uri = payload + .get("rootUri") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let root_path = file_root_uri_to_permission_path(root_uri)?; + let workspace_id = payload + .get("workspaceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default"); + let session_id = payload + .get("sessionId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("session"); + let document_id = payload + .get("documentId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("current"); + let actor_id = payload + .get("actorId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("anonymous"); + let permission_level = local_ai_payload_permission_level(payload); + let access_scope = json!({ + "userId": actor_id, + "workspaceId": workspace_id, + "sessionId": session_id, + "sourceKind": "local_folder", + "permissionLevel": permission_level, + "allowedRoots": [root_uri], + "allowedFilePaths": [], + "allowedResourceIds": [document_id] + }); + let mut env = HashMap::new(); + env.insert( + "MNOTE_AI_ALLOWED_ROOTS_JSON".into(), + serde_json::to_string(&vec![root_path.clone()]).ok()?, + ); + env.insert("MNOTE_AI_WORKSPACE_ROOT".into(), root_path); + env.insert( + "MNOTE_AI_ACCESS_SCOPE_JSON".into(), + serde_json::to_string(&access_scope).ok()?, + ); + Some(env) +} + +fn file_root_uri_to_permission_path(root_uri: &str) -> Option { + let trimmed = root_uri.trim(); + let path = trimmed.strip_prefix("file://").unwrap_or(trimmed).trim(); + if path.is_empty() || path == "/" { + return None; + } + Some(path.to_string()) +} + /// Returns the ACP runtime name for a profile. /// For ACP profiles, returns the runtime backend name ("hermes" or "reasonix"). /// The profile name is used as the runtime name unless overridden by env var. @@ -2842,6 +3270,26 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result Result Result Result Value { Value::Object(sanitized) } +fn sanitize_run_local_page_context(page_context: Value) -> Value { + let Some(source) = page_context.as_object() else { + return Value::Null; + }; + let mut sanitized = serde_json::Map::new(); + for key in ["contextScope", "node", "evidence"] { + if let Some(value) = source.get(key) { + sanitized.insert(key.to_string(), value.clone()); + } + } + sanitized.insert( + "contentAccess".to_string(), + Value::String("file.reference".into()), + ); + if let Some(ai_context) = source.get("aiContext").and_then(Value::as_object) { + let mut slim_ai_context = serde_json::Map::new(); + for key in [ + "schema", + "scope", + "selectedBlockIds", + "allowedTargetBlockIds", + ] { + if let Some(value) = ai_context.get(key) { + slim_ai_context.insert(key.to_string(), value.clone()); + } + } + if !slim_ai_context.is_empty() { + sanitized.insert("aiContext".to_string(), Value::Object(slim_ai_context)); + } + } + Value::Object(sanitized) +} + fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -3205,6 +3736,474 @@ fn runtime_store_user_id(context: &RequestContext, payload: &Value) -> String { .unwrap_or_else(|| context.auth.actor_id.clone()) } +fn local_ai_session_root_dir(root_uri: &str) -> Result { + let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") { + stripped.trim() + } else { + root_uri.trim() + }; + if root_path.is_empty() { + return Err(WebError::bad_request_code( + "local_ai_session_root_required", + "缺少本地会话 rootUri", + )); + } + Ok(PathBuf::from(root_path)) +} + +fn local_agent_audit_root_dir() -> PathBuf { + PathBuf::from(LOCAL_AGENT_AUDIT_DIR) +} + +fn local_agent_audit_path() -> PathBuf { + local_agent_audit_root_dir().join(LOCAL_AGENT_AUDIT_JSONL) +} + +fn local_agent_audit_should_skip(path: &FsPath) -> bool { + matches!( + path.file_name() + .and_then(|value| value.to_str()) + .map(str::trim), + Some(".mnote") | Some("ai-sessions") + ) +} + +fn local_agent_audit_hash(text: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +fn local_agent_audit_snapshot_entry( + path: &FsPath, +) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_snapshot_failed", + format!("无法读取本地文件快照: {error}"), + ) + })?; + let modified_ms = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let size = metadata.len(); + let content = fs::read_to_string(path).ok(); + let hash = content + .as_deref() + .map(local_agent_audit_hash) + .unwrap_or_default(); + let markdown_content = content.and_then(|content| { + if path.extension().and_then(|value| value.to_str()) == Some("md") { + Some(content) + } else { + None + } + }); + Ok(LocalAgentAuditFileSnapshot { + size, + modified_ms, + hash, + markdown_content, + }) +} + +fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result { + let root = local_ai_session_root_dir(root_uri)?; + let canonical_root = root.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + let mut files = BTreeMap::new(); + let mut stack = vec![canonical_root.clone()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_snapshot_failed", + format!("读取本地文件夹失败: {error}"), + ) + })? { + let entry = entry.map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_snapshot_failed", + format!("读取本地文件夹失败: {error}"), + ) + })?; + let path = entry.path(); + if local_agent_audit_should_skip(&path) { + continue; + } + let metadata = entry.metadata().map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_snapshot_failed", + format!("读取本地文件夹失败: {error}"), + ) + })?; + if metadata.is_dir() { + stack.push(path); + continue; + } + if !metadata.is_file() { + continue; + } + let relative_path = path + .strip_prefix(&canonical_root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + if relative_path.is_empty() { + continue; + } + files.insert(relative_path, local_agent_audit_snapshot_entry(&path)?); + } + } + Ok(LocalAgentAuditSnapshot { + root_uri: root_uri.to_string(), + files, + }) +} + +fn local_agent_audit_diff_summary( + before: Option<&LocalAgentAuditFileSnapshot>, + after: Option<&LocalAgentAuditFileSnapshot>, +) -> String { + match (before, after) { + (None, Some(after)) => format!("新增文件 size={} hash={}", after.size, after.hash), + (Some(before), None) => format!("删除文件 size={} hash={}", before.size, before.hash), + (Some(before), Some(after)) => { + if before.hash == after.hash && before.size == after.size { + "内容无变化".into() + } else { + let before_lines = before + .markdown_content + .as_deref() + .map(|text| text.lines().count()) + .unwrap_or_default(); + let after_lines = after + .markdown_content + .as_deref() + .map(|text| text.lines().count()) + .unwrap_or_default(); + format!( + "修改文件 size:{}→{} lines:{}→{} hash:{}→{}", + before.size, after.size, before_lines, after_lines, before.hash, after.hash + ) + } + } + (None, None) => String::new(), + } +} + +fn local_agent_audit_change_files( + before: &LocalAgentAuditSnapshot, + after: &LocalAgentAuditSnapshot, +) -> Value { + let mut paths = BTreeMap::new(); + for path in before.files.keys().chain(after.files.keys()) { + paths.insert(path.clone(), ()); + } + let changed = paths + .into_keys() + .filter_map(|path| { + let before_entry = before.files.get(&path); + let after_entry = after.files.get(&path); + if matches!((before_entry, after_entry), (Some(before), Some(after)) if before.hash == after.hash && before.size == after.size) { + return None; + } + Some(json!({ + "path": path, + "changeType": match (before_entry, after_entry) { + (None, Some(_)) => "added", + (Some(_), None) => "deleted", + _ => "modified", + }, + "summary": local_agent_audit_diff_summary(before_entry, after_entry), + "sizeBefore": before_entry.map(|entry| entry.size).unwrap_or(0), + "sizeAfter": after_entry.map(|entry| entry.size).unwrap_or(0), + "modifiedBeforeMs": before_entry.map(|entry| entry.modified_ms).unwrap_or_default(), + "modifiedAfterMs": after_entry.map(|entry| entry.modified_ms).unwrap_or_default(), + "hashBefore": before_entry.map(|entry| entry.hash).unwrap_or_default(), + "hashAfter": after_entry.map(|entry| entry.hash).unwrap_or_default(), + })) + }) + .collect::>(); + Value::Array(changed) +} + +fn local_agent_audit_write_event(event: &Value) -> Result<(), WebError> { + let audit_dir = local_agent_audit_root_dir(); + fs::create_dir_all(&audit_dir).map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_write_failed", + format!("无法创建本地审计目录: {error}"), + ) + })?; + let path = local_agent_audit_path(); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_write_failed", + format!("无法打开本地审计文件: {error}"), + ) + })?; + let line = serde_json::to_string(event).map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_write_failed", + format!("无法序列化本地审计事件: {error}"), + ) + })?; + writeln!(file, "{line}").map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_write_failed", + format!("无法写入本地审计事件: {error}"), + ) + }) +} + +fn local_agent_audit_store_snapshot(run_id: &str, snapshot: LocalAgentAuditSnapshot) { + ACP_LOCAL_AUDIT_SNAPSHOTS + .lock() + .expect("acp local audit snapshots") + .insert(run_id.to_string(), snapshot); +} + +fn local_agent_audit_take_snapshot(run_id: &str) -> Option { + ACP_LOCAL_AUDIT_SNAPSHOTS + .lock() + .expect("acp local audit snapshots") + .remove(run_id) +} + +fn local_agent_audit_event( + context: &RequestContext, + payload: &Value, + run_id: &str, + acp_runtime: &str, + status: &str, + changed_files: Value, + source_snapshot: Option<&LocalAgentAuditSnapshot>, + _target_snapshot: Option<&LocalAgentAuditSnapshot>, +) -> Value { + let root_uri = payload + .get("rootUri") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| source_snapshot.map(|snapshot| snapshot.root_uri.as_str())) + .unwrap_or(""); + let permission = if local_ai_payload_is_read_only(payload) { + "read" + } else { + "write" + }; + let actor_id = payload + .get("actorId") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&context.auth.actor_id); + let actor_type = payload + .get("actorType") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&context.auth.actor_type); + let changed_file_count = changed_files.as_array().map(Vec::len).unwrap_or_default(); + let write_attempt_rejected = permission == "read" && changed_file_count > 0; + json!({ + "eventId": format!("local_audit:{}:{}", sanitize_id_part(run_id), now_ms()), + "actorId": actor_id, + "actorType": actor_type, + "agentKind": acp_runtime, + "runId": run_id, + "sessionId": payload.get("sessionId").cloned().unwrap_or(Value::Null), + "workspaceId": payload.get("workspaceId").cloned().unwrap_or(Value::Null), + "documentId": payload.get("documentId").cloned().unwrap_or(Value::Null), + "rootUri": root_uri, + "permission": permission, + "status": if write_attempt_rejected { "read_only_write_rejected" } else { status }, + "writeAttemptRejected": write_attempt_rejected, + "changedFiles": changed_files, + "diffSummary": format!("{changed_file_count} changed file(s)"), + "createdAt": now_ms(), + }) +} + +fn local_agent_audit_finalize_run( + context: &RequestContext, + payload: &Value, + run_id: &str, + acp_runtime: &str, + status: &str, +) -> Result { + let before = local_agent_audit_take_snapshot(run_id); + let after = payload + .get("rootUri") + .and_then(Value::as_str) + .map(local_agent_audit_collect_snapshot) + .transpose()?; + 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(), + }, + after, + ), + (Some(before), None) => local_agent_audit_change_files( + before, + &LocalAgentAuditSnapshot { + root_uri: before.root_uri.clone(), + files: BTreeMap::new(), + }, + ), + (None, None) => Value::Array(vec![]), + }; + let event = local_agent_audit_event( + context, + payload, + run_id, + acp_runtime, + status, + changed_files, + before.as_ref(), + after.as_ref(), + ); + local_agent_audit_write_event(&event)?; + Ok(event) +} + +fn local_ai_session_dir(root_uri: &str, share_id: Option<&str>) -> Result { + let root = local_ai_session_root_dir(root_uri)?; + let mut dir = root.join("ai-sessions"); + if share_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + dir = dir.join("shared").join(sanitize_id_part(share_id.unwrap())); + } else { + dir = dir.join("private"); + } + Ok(dir) +} + +fn local_ai_session_path( + root_uri: &str, + session_id: &str, + share_id: Option<&str>, +) -> Result { + Ok(local_ai_session_dir(root_uri, share_id)? + .join(format!("{}.jsonl", sanitize_id_part(session_id)))) +} + +fn append_local_ai_session_event( + root_uri: &str, + session_id: &str, + event: &Value, + share_id: Option<&str>, +) -> Result<(), WebError> { + let dir = local_ai_session_dir(root_uri, share_id)?; + fs::create_dir_all(&dir).map_err(|error| { + WebError::bad_request_code( + "local_ai_session_dir_create_failed", + format!("无法创建本地 AI 会话目录 {}: {error}", dir.display()), + ) + })?; + let path = local_ai_session_path(root_uri, session_id, share_id)?; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| { + WebError::bad_request_code( + "local_ai_session_write_failed", + format!("无法写入本地 AI 会话文件 {}: {error}", path.display()), + ) + })?; + writeln!( + file, + "{}", + serde_json::to_string(event).map_err(|error| { + WebError::bad_request_code( + "local_ai_session_serialize_failed", + format!("无法序列化本地 AI 会话事件: {error}"), + ) + })? + ) + .map_err(|error| { + WebError::bad_request_code( + "local_ai_session_write_failed", + format!("无法写入本地 AI 会话文件 {}: {error}", path.display()), + ) + })?; + Ok(()) +} + +fn read_local_ai_session_events( + root_uri: &str, + session_id: &str, + share_id: Option<&str>, +) -> Result, WebError> { + let path = local_ai_session_path(root_uri, session_id, share_id)?; + let content = fs::read_to_string(&path).unwrap_or_default(); + Ok(content + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect()) +} + +fn list_local_ai_sessions(root_uri: &str, limit: usize) -> Result { + let dir = local_ai_session_dir(root_uri, None)?; + let mut rows = Vec::new(); + let Ok(entries) = fs::read_dir(&dir) else { + return Ok(Value::Array(Vec::new())); + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + let session_id = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + let events = fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + let first = events.first().cloned().unwrap_or(Value::Null); + let last = events.last().cloned().unwrap_or(Value::Null); + rows.push(json!({ + "sessionId": session_id, + "title": first.get("title").and_then(Value::as_str).unwrap_or("当前页问答"), + "profile": first.get("profile").and_then(Value::as_str).unwrap_or("reasonix"), + "sourceKind": "local_folder", + "sessionStorage": "local_private", + "persistence": "local_ai_session_jsonl", + "createdAt": first.get("createdAt").cloned().unwrap_or(Value::Null), + "updatedAt": last.get("createdAt").cloned().unwrap_or(Value::Null), + "status": last.get("status").or_else(|| last.get("eventType")).cloned().unwrap_or(Value::Null), + "payload": first + })); + } + rows.sort_by(|a, b| { + b.get("updatedAt") + .and_then(Value::as_u64) + .cmp(&a.get("updatedAt").and_then(Value::as_u64)) + }); + rows.truncate(limit); + Ok(Value::Array(rows)) +} + async fn persist_acp_runtime_run( state: &AppState, context: &RequestContext, @@ -3222,6 +4221,41 @@ async fn persist_acp_runtime_run( runtime_state, payload, ); + if payload + .get("sourceKind") + .and_then(Value::as_str) + .map(str::trim) + == Some("local_folder") + { + let root_uri = payload + .get("rootUri") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") + .with_context(context) + })?; + crate::routes::ensure_local_workspace_access(context, root_uri) + .map_err(|error| error.with_context(context))?; + let mut event = args.clone(); + if let Value::Object(map) = &mut event { + map.insert("eventType".into(), Value::String("run.started".into())); + map.insert( + "persistence".into(), + Value::String("local_ai_session_jsonl".into()), + ); + } + append_local_ai_session_event(root_uri, ®istration.session_id, &event, None) + .map_err(|error| error.with_context(context))?; + return Ok(json!({ + "ok": true, + "persistence": "local_ai_session_jsonl", + "sessionStorage": "local_private", + "sessionId": registration.session_id, + "runId": run_id + })); + } execute_convex_mutation_by_name( state.config(), context, @@ -3295,6 +4329,40 @@ async fn persist_acp_runtime_event( event_payload, run_payload, ); + if run_payload + .get("sourceKind") + .and_then(Value::as_str) + .map(str::trim) + == Some("local_folder") + { + let root_uri = run_payload + .get("rootUri") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") + .with_context(context) + })?; + crate::routes::ensure_local_workspace_access(context, root_uri) + .map_err(|error| error.with_context(context))?; + let mut event = args.clone(); + if let Value::Object(map) = &mut event { + map.insert( + "persistence".into(), + Value::String("local_ai_session_jsonl".into()), + ); + } + append_local_ai_session_event(root_uri, ®istration.session_id, &event, None) + .map_err(|error| error.with_context(context))?; + return Ok(json!({ + "ok": true, + "persistence": "local_ai_session_jsonl", + "sessionStorage": "local_private", + "sessionId": registration.session_id, + "runId": run_id + })); + } execute_convex_mutation_by_name( state.config(), context, @@ -3985,6 +5053,43 @@ mod tests { ACP_RUN_PAYLOADS.lock().expect("acp run payloads").clear(); } + #[test] + fn local_agent_audit_snapshot_detects_changed_files() { + let root = + std::env::temp_dir().join(format!("mnote-local-agent-audit-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); + std::fs::create_dir_all(root.join("ai-sessions/private")).expect("create session dir"); + std::fs::write(root.join("a.md"), "# A\nold\n").expect("write a"); + std::fs::write(root.join(".mnote/ignored.md"), "ignored").expect("write ignored"); + std::fs::write(root.join("ai-sessions/private/event.jsonl"), "ignored") + .expect("write session"); + let root_uri = format!("file://{}", root.display()); + let before = local_agent_audit_collect_snapshot(&root_uri).expect("before snapshot"); + + std::fs::write(root.join("a.md"), "# A\nnew\n").expect("modify a"); + std::fs::write(root.join("b.md"), "# B\n").expect("write b"); + let after = local_agent_audit_collect_snapshot(&root_uri).expect("after snapshot"); + let changed = local_agent_audit_change_files(&before, &after); + let files = changed.as_array().expect("changed files"); + + assert_eq!(files.len(), 2); + assert!(files + .iter() + .any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" })); + assert!(files + .iter() + .any(|file| { file["path"] == "b.md" && file["changeType"] == "added" })); + assert!(!files.iter().any(|file| { + file["path"] + .as_str() + .map(|path| path.contains(".mnote") || path.contains("ai-sessions")) + .unwrap_or(false) + })); + + let _ = std::fs::remove_dir_all(&root); + } + fn app() -> axum::Router { app_with_config(AppConfig { service_name: "mnote-web".into(), @@ -4035,9 +5140,38 @@ mod tests { }) } + #[test] + fn empty_runtime_defaults_to_acp_reasonix_when_http_proxy_disabled() { + let _env_guard = env_lock().lock().expect("env lock"); + std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); + std::env::remove_var("MNOTE_ENABLE_HERMES_HTTP_PROXY"); + let payload = json!({ + "profile": "mnoteai", + "acpRuntime": "" + }); + assert_eq!( + acp_runtime_for_payload(&payload, "mnoteai"), + Some("reasonix".into()) + ); + } + + #[test] + fn empty_runtime_can_still_use_retired_http_proxy_behind_explicit_flag() { + let _env_guard = env_lock().lock().expect("env lock"); + std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); + std::env::remove_var("MNOTE_ENABLE_HERMES_HTTP_PROXY"); + let payload = json!({ + "profile": "mnoteai", + "acpRuntime": "" + }); + assert_eq!(acp_runtime_for_payload(&payload, "mnoteai"), None); + std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); + } + #[tokio::test] async fn hermes_client_gateway_health_reports_unconfigured_profile_settings() { let _env_guard = env_lock().lock().expect("env lock"); + std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_API_BASE_URL"); @@ -4073,12 +5207,14 @@ mod tests { assert!(suggestions.contains("model.default")); std::env::remove_var("HERMES_HOME"); + std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); let _ = fs::remove_dir_all(&hermes_home); } #[tokio::test] async fn hermes_client_gateway_health_reports_profile_model_and_api_key_gaps() { let _env_guard = env_lock().lock().expect("env lock"); + std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-hermes-health-profile-{}", std::process::id() @@ -4133,6 +5269,7 @@ mod tests { std::env::remove_var("HERMES_HOME"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); let _ = fs::remove_dir_all(&hermes_home); } @@ -4140,6 +5277,7 @@ mod tests { async fn hermes_client_upstream_error_message_points_to_hermes_settings() { let _env_guard = env_lock().lock().expect("env lock"); let _runtime_guard = runtime_lock().lock().expect("runtime lock"); + std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); clear_runtime_registry(); clear_run_queue(); let mock = axum::Router::new().route( @@ -4189,6 +5327,7 @@ mod tests { assert!(message.contains("Hermes 设置")); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); } #[tokio::test] @@ -4217,6 +5356,7 @@ mod tests { #[tokio::test] async fn hermes_client_unconfigured_run_returns_stable_error() { let _guard = env_lock().lock().expect("env lock"); + std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); let response = app() @@ -4248,6 +5388,7 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("hermes_client_unconfigured") ); + std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); } #[tokio::test] @@ -4279,10 +5420,280 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1"); - assert_eq!(payload["persistence"], "hermes_on_first_run"); + assert_eq!(payload["persistence"], "local_ai_session_jsonl"); + assert_eq!(payload["sessionStorage"], "local_private"); assert!(payload.get("messages").is_none()); } + #[tokio::test] + async fn hermes_client_local_acp_session_create_writes_private_jsonl() { + let root = std::env::temp_dir().join(format!( + "mnote-local-ai-session-private-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, + ) + .expect("manifest"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/sessions") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "traceId": "trace_local_session_1", + "profile": "reasonix", + "title": "本地会话" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["persistence"], "local_ai_session_jsonl"); + assert_eq!(payload["sessionStorage"], "local_private"); + let session_id = payload["sessionId"].as_str().expect("session id"); + let jsonl_path = root + .join("ai-sessions") + .join("private") + .join(format!("{session_id}.jsonl")); + let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl"); + assert!(jsonl.contains("\"eventType\":\"session.created\"")); + assert!(jsonl.contains("\"title\":\"本地会话\"")); + + let list_uri = format!( + "/api/hermes/client/sessions?source=acp&sourceKind=local_folder&rootUri={}&workspaceId=local-ws-user-1&documentId=local-md%3AREADME.md", + url_escape(&root_uri) + ); + let list_response = app() + .oneshot( + Request::builder() + .uri(list_uri) + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("list response"); + assert_eq!(list_response.status(), StatusCode::OK); + let list_body = to_bytes(list_response.into_body(), usize::MAX) + .await + .expect("list body"); + let list_payload: Value = serde_json::from_slice(&list_body).expect("list json"); + assert_eq!(list_payload["persistence"], "local_ai_session_jsonl"); + assert_eq!(list_payload["sessions"][0]["sessionId"], session_id); + + let detail_uri = format!( + "/api/hermes/client/sessions/{}?source=acp&sourceKind=local_folder&rootUri={}&workspaceId=local-ws-user-1", + session_id, + url_escape(&root_uri) + ); + let detail_response = app() + .oneshot( + Request::builder() + .uri(detail_uri) + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("detail response"); + assert_eq!(detail_response.status(), StatusCode::OK); + let detail_body = to_bytes(detail_response.into_body(), usize::MAX) + .await + .expect("detail body"); + let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json"); + assert_eq!(detail_payload["persistence"], "local_ai_session_jsonl"); + assert_eq!(detail_payload["events"][0]["eventType"], "session.created"); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn hermes_client_local_acp_run_writes_private_jsonl_without_convex() { + let root = + std::env::temp_dir().join(format!("mnote-local-ai-run-private-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, + ) + .expect("manifest"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/runs") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sessionId": "sess_local_run_1", + "sourceKind": "local_folder", + "rootUri": root_uri, + "traceId": "trace_local_run_1", + "profile": "reasonix", + "message": "本地运行" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["persistence"], "local_ai_session_jsonl"); + assert_eq!(payload["sessionStorage"], "local_private"); + let jsonl_path = root + .join("ai-sessions") + .join("private") + .join("sess_local_run_1.jsonl"); + let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl"); + assert!(jsonl.contains("\"eventType\":\"run.started\"")); + assert!(jsonl.contains("\"persistence\":\"local_ai_session_jsonl\"")); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn hermes_client_local_shared_write_session_writes_shared_jsonl_with_audit() { + let root = std::env::temp_dir().join(format!( + "mnote-local-ai-session-shared-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, + ) + .expect("manifest"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/sessions") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "shareId": "share_1", + "permissionLevel": "shared_write", + "traceId": "trace_shared_session_1", + "profile": "reasonix", + "title": "共享会话" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["sessionStorage"], "local_shared"); + let session_id = payload["sessionId"].as_str().expect("session id"); + let jsonl_path = root + .join("ai-sessions") + .join("shared") + .join("share_1") + .join(format!("{session_id}.jsonl")); + let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl"); + assert!(jsonl.contains("\"eventType\":\"session.created\"")); + assert!(jsonl.contains("\"eventType\":\"audit.shared_write\"")); + assert!(jsonl.contains("\"shareId\":\"share_1\"")); + + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn hermes_client_local_shared_read_session_write_is_forbidden() { + let root = std::env::temp_dir().join(format!( + "mnote-local-ai-session-shared-read-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, + ) + .expect("manifest"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/sessions") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "shareId": "share_1", + "permissionLevel": "shared_read", + "traceId": "trace_shared_read_1", + "profile": "reasonix", + "title": "共享只读会话" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(!root.join("ai-sessions").join("shared").exists()); + + let _ = fs::remove_dir_all(&root); + } + #[tokio::test] async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() { let response = app() @@ -5536,6 +6947,97 @@ mod tests { assert!(!instructions.contains("\"outline\"")); } + #[test] + fn hermes_client_run_body_local_source_uses_file_scope_not_full_page_context() { + let mut headers = HeaderMap::new(); + headers.insert("x-mnote-actor-id", "user_1".parse().unwrap()); + headers.insert("x-mnote-actor-type", "user".parse().unwrap()); + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs".parse().expect("uri"), + &headers, + ); + let body = build_run_upstream_body( + &context, + json!({ + "workspaceId": "local-workspace-1", + "documentId": "local-md:README.md", + "sessionId": "sess_local_1", + "sourceKind": "local_folder", + "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", + "message": "把选中的句子改短", + "selectedText": "选中的句子", + "pageContext": { + "node": {"documentId": "local-md:README.md", "title": "README"}, + "aiContext": { + "schema": "mnote.page_ai_context.v1", + "pageText": "完整页面正文不应进入本地 agent instructions", + "pageXml": "完整页面 XML 不应进入本地 agent instructions", + "contextBlocks": [{"blockId": "block_1", "text": "完整块内容不应进入本地 agent instructions"}], + "allowedTargetBlockIds": ["block_1"] + } + }, + "traceId": "trace_local_1" + }), + ) + .expect("body"); + + let instructions = body["instructions"].as_str().expect("instructions"); + assert!(instructions.contains("\"sourceKind\":\"local_folder\"")); + assert!(instructions.contains("\"fileReference\"")); + assert!(instructions + .contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\"")); + assert!(instructions.contains("\"aiAccessScope\"")); + assert!(instructions.contains("\"allowedRoots\"")); + assert!(instructions.contains("\"selectedText\":\"选中的句子\"")); + assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions")); + assert!(!instructions.contains("完整页面 XML 不应进入本地 agent instructions")); + assert!(!instructions.contains("完整块内容不应进入本地 agent instructions")); + assert!(!instructions.contains("\"contextBlocks\"")); + assert!(!instructions.contains("\"pageXml\"")); + assert!(!instructions.contains("\"pageText\"")); + } + + #[test] + fn acp_runtime_env_limits_local_source_to_allowed_root() { + let payload = json!({ + "workspaceId": "local-workspace-1", + "documentId": "local-md:README.md", + "sessionId": "sess_local_1", + "sourceKind": "local_folder", + "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", + "actorId": "user_1" + }); + + let env = acp_allowed_roots_env_for_payload(&payload).expect("env"); + let allowed_roots = serde_json::from_str::>( + env.get("MNOTE_AI_ALLOWED_ROOTS_JSON") + .expect("allowed roots"), + ) + .expect("allowed roots json"); + assert_eq!( + allowed_roots, + vec!["/mnt/Data1T/Mnote_data/users/user_1/我的空间"] + ); + assert_eq!( + env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str), + Some("/mnt/Data1T/Mnote_data/users/user_1/我的空间") + ); + assert!(!env + .get("MNOTE_AI_ALLOWED_ROOTS_JSON") + .unwrap() + .contains("/mnt/Data1T/Mnote_data/users/user_2")); + let scope = serde_json::from_str::( + env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"), + ) + .expect("scope json"); + assert_eq!(scope["permissionLevel"], "read_write"); + assert_eq!( + scope["allowedRoots"][0], + "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间" + ); + } + #[test] fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() { let state = test_state(); diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs index 6d1365e5..a385456f 100644 --- a/rust/crates/mnote-web/src/routes/hermes_tools.rs +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -170,6 +170,31 @@ pub(crate) async fn execute_mnote_tool_call( })); return Err(error); } + if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) { + let error = WebError::new( + StatusCode::FORBIDDEN, + "mnote_tool_shared_read_write_forbidden", + "共享只读 AI 上下文不能执行写工具", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"); + audit_push(json!({ + "phase": "failed", + "traceId": trace_id, + "sessionId": input.session_id, + "runId": input.run_id, + "toolCallId": tool_call_id, + "toolName": input.tool_name, + "workspaceId": workspace_id, + "documentId": document_id, + "actorId": input.actor_id, + "status": error.status().as_u16(), + "message": error.message(), + "permissionLevel": "shared_read" + })); + return Err(error); + } if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) { info!( trace_id = %trace_id, @@ -301,6 +326,27 @@ fn is_read_tool(tool_name: &str) -> bool { ) } +fn is_shared_read_scope(input: &ToolCallInput) -> bool { + let direct = input + .arg_string("permissionLevel") + .or_else(|| input.arg_string("permission_level")); + if matches!(direct.as_deref(), Some("shared_read")) { + return true; + } + input + .arg_value("aiAccessScope") + .and_then(|value| { + value + .get("permissionLevel") + .or_else(|| value.get("permission_level")) + .and_then(Value::as_str) + .map(str::trim) + .map(ToOwned::to_owned) + }) + .as_deref() + == Some("shared_read") +} + fn audit_log() -> &'static Mutex> { static LOG: OnceLock>> = OnceLock::new(); LOG.get_or_init(|| Mutex::new(Vec::new())) @@ -882,6 +928,47 @@ mod tests { .any(|rule| rule["required"] == json!(["full_content"]))); } + #[tokio::test] + async fn hermes_tools_manifest_marks_write_tools_as_compat_fallbacks() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/hermes/tools/mnote/manifest") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let tools = payload["manifest"]["tools"].as_array().expect("tools"); + let markdown_edit = tools + .iter() + .find(|tool| tool["name"] == "mnote.doc.markdown_edit") + .expect("markdown edit tool"); + let page_save = tools + .iter() + .find(|tool| tool["name"] == "mnote.page.save") + .expect("page save tool"); + + assert!(markdown_edit["description"] + .as_str() + .expect("description") + .contains("兼容")); + assert!(markdown_edit["description"] + .as_str() + .expect("description") + .contains("agent 原生 patch/diff")); + assert_eq!( + page_save["annotations"]["requiresWritePermission"], + Value::Bool(true) + ); + } + #[tokio::test] async fn hermes_tools_manifest_describes_block_tools_selection_scope() { let response = app() @@ -1129,6 +1216,8 @@ mod tests { assert_eq!(payload["toolName"], "mnote.doc.fetch"); assert_eq!(payload["audit"]["effect"], "read"); assert_eq!(payload["result"]["revision"], json!(7)); + assert_eq!(payload["result"]["conflictDetectionKey"], json!("doc_1:7")); + assert_eq!(payload["result"]["fileVersion"], json!("doc_1:7")); assert_eq!( payload["result"]["blocks"][0]["blockId"], json!("heading_1") @@ -1140,6 +1229,90 @@ mod tests { .starts_with("pageRev:7:block:heading_1:hash:")); } + #[tokio::test] + async fn hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.doc.fetch", + "workspaceId": "ws_demo", + "documentId": "doc_2", + "sessionId": "sess_scope_read", + "runId": "run_scope_read", + "toolCallId": "call_scope_read", + "traceId": "trace_scope_read", + "args": { + "aiAccessScope": { + "permissionLevel": "shared_read", + "allowedResourceIds": ["doc_1"], + "shareContext": {"shareId": "share_read_1"} + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("mnote_tool_ai_scope_read_forbidden") + ); + } + + #[tokio::test] + async fn hermes_tools_page_get_rejects_out_of_scope_ai_resource() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.page.get", + "workspaceId": "ws_demo", + "documentId": "doc_2", + "sessionId": "sess_page_scope_read", + "runId": "run_page_scope_read", + "toolCallId": "call_page_scope_read", + "traceId": "trace_page_scope_read", + "args": { + "aiAccessScope": { + "permissionLevel": "shared_read", + "allowedResourceIds": ["doc_1"], + "shareContext": {"shareId": "share_read_1"} + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("mnote_tool_ai_scope_read_forbidden") + ); + } + #[tokio::test] async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() { let response = app() @@ -2116,6 +2289,70 @@ mod tests { ); } + #[tokio::test] + async fn hermes_tools_page_save_local_folder_writes_markdown_file() { + let root = std::env::temp_dir().join(format!( + "mnote-page-save-local-folder-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, + ) + .expect("manifest"); + fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.page.save", + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "sessionId": "sess_page_save_local", + "runId": "run_page_save_local", + "toolCallId": "call_page_save_local", + "traceId": "trace_page_save_local", + "idempotencyKey": "idem_page_save_local", + "dryRun": false, + "args": { + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]} + ] + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert_eq!(status, StatusCode::OK, "{text}"); + let payload: Value = serde_json::from_str(&text).expect("json"); + assert_eq!(payload["result"]["source"], "local_folder"); + assert_eq!(payload["result"]["commandName"], "page.body.write"); + let saved = fs::read_to_string(root.join("README.md")).expect("read"); + assert!(saved.contains("本地 page.save 写入"), "{saved}"); + let _ = fs::remove_dir_all(&root); + } + #[tokio::test] async fn hermes_tools_page_save_dry_run_returns_diff_without_write() { let response = app() @@ -2264,6 +2501,74 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[tokio::test] + async fn hermes_tools_markdown_edit_shared_read_is_forbidden() { + let root = std::env::temp_dir().join(format!( + "mnote-markdown-edit-shared-read-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, + ) + .expect("manifest"); + fs::write(root.join("README.md"), "原文\n").expect("write markdown"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.doc.markdown_edit", + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "actorId": "user_1", + "sessionId": "sess_shared_read_md", + "runId": "run_shared_read_md", + "toolCallId": "call_shared_read_md", + "traceId": "trace_shared_read_md", + "idempotencyKey": "idem_shared_read_md", + "dryRun": false, + "args": { + "aiAccessScope": { + "permissionLevel": "shared_read", + "shareContext": {"shareId": "share_read_1"} + }, + "operations": [{"search": "原文", "replace": "不应写入"}] + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("mnote_tool_shared_read_write_forbidden") + ); + assert_eq!( + fs::read_to_string(root.join("README.md")).expect("read"), + "原文\n" + ); + let _ = fs::remove_dir_all(&root); + } + #[tokio::test] async fn hermes_tools_markdown_edit_reports_empty_block_mapping_before_apply() { // 7-27 修复后:即使 search 吃掉了块注释,也不会崩溃或泄露 apply_block_ops 错误。 @@ -2406,6 +2711,165 @@ mod tests { ); } + #[tokio::test] + async fn hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.doc.markdown_edit", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_markdown_precondition", + "runId": "run_markdown_precondition", + "toolCallId": "call_markdown_precondition", + "traceId": "trace_markdown_precondition", + "idempotencyKey": "idem_markdown_precondition", + "dryRun": false, + "args": { + "operations": [{"search": "第二段", "replace": "测试123"}] + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let command_payload = + &payload["result"]["applyResult"]["artifacts"]["commandLog"]["payload"]; + assert_eq!(command_payload["revision"], 7); + assert_eq!(command_payload["conflictDetectionKey"], "doc_1:7"); + } + + #[tokio::test] + async fn hermes_tools_markdown_edit_local_folder_writes_same_markdown_file() { + let root = std::env::temp_dir().join(format!( + "mnote-markdown-edit-local-folder-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("README.assets")).expect("create asset dir"); + fs::write( + root.join("README.md"), + "---\ntitle: AI Local\n---\n# AI Local\n\n第一段\n\n![图](README.assets/photo.png)\n", + ) + .expect("write markdown"); + fs::write(root.join("README.assets").join("photo.png"), b"png").expect("write asset"); + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_1", &root_uri, + ) + .expect("initialize workspace"); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.doc.markdown_edit", + "workspaceId": "local-ws-test", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "actorId": "user_1", + "sessionId": "sess_local_folder_md", + "runId": "run_local_folder_md", + "toolCallId": "call_local_folder_md", + "traceId": "trace_local_folder_md", + "idempotencyKey": "idem_local_folder_md", + "dryRun": false, + "args": { + "operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}] + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + let status = response.status(); + let headers = response.headers().clone(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + if status != StatusCode::OK { + panic!("status={status} headers={headers:?} body={text}"); + } + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["result"]["source"], "local_folder"); + assert_eq!( + payload["result"]["applyResult"]["commandName"], + "page.body.write" + ); + let saved = fs::read_to_string(root.join("README.md")).expect("read markdown"); + assert!(saved.contains("第一段已由 AI 修改")); + assert!(saved.contains("README.assets/photo.png")); + assert!(!saved.contains("/api/media")); + assert!(!saved.contains("assetId")); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn hermes_tools_markdown_edit_rejects_no_applied_operations() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.doc.markdown_edit", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_markdown_noop", + "runId": "run_markdown_noop", + "toolCallId": "call_markdown_noop", + "traceId": "trace_markdown_noop", + "idempotencyKey": "idem_markdown_noop", + "dryRun": false, + "args": { + "operations": [{"search": "不存在的段落", "replace": "测试123"}] + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("mnote_markdown_edit_no_operations_applied") + ); + } + #[tokio::test] async fn hermes_tools_markdown_edit_rejects_selection_out_of_scope() { let response = app() @@ -2576,4 +3040,67 @@ mod tests { assert_eq!(payload["result"]["dryRun"], true); assert_eq!(payload["result"]["artifactType"], "summary"); } + + #[tokio::test] + async fn hermes_tools_artifact_summary_local_folder_writes_sidecar_file() { + let root = std::env::temp_dir().join(format!( + "mnote-artifact-local-folder-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, + ) + .expect("manifest"); + fs::write(root.join("README.md"), "# Local\n").expect("markdown"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.artifact.create_summary", + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "sessionId": "sess_artifact_local", + "runId": "run_artifact_local", + "toolCallId": "call_artifact_local", + "traceId": "trace_artifact_local", + "idempotencyKey": "idem_artifact_local", + "dryRun": false, + "args": {"summary": "本地摘要"} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert_eq!(status, StatusCode::OK, "{text}"); + let payload: Value = serde_json::from_str(&text).expect("json"); + assert_eq!(payload["result"]["source"], "local_folder"); + let artifact_path = root + .join(".mnote") + .join("artifacts") + .join("summary_local-md_README.md.json"); + let artifact = fs::read_to_string(&artifact_path).expect("artifact"); + assert!(artifact.contains("本地摘要"), "{artifact}"); + let _ = fs::remove_dir_all(&root); + } } diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs index 9645a7d5..88b1aafa 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -1,7 +1,9 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::local_folder_source::decode_local_id_segment; +use crate::routes::local_folder_source::{ + decode_local_id_segment, ensure_local_workspace_read_access, +}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; @@ -9,7 +11,6 @@ use futures_util::stream; use serde::Deserialize; use serde_json::{json, Value}; use std::convert::Infallible; -use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::broadcast::error::RecvError; @@ -31,14 +32,8 @@ pub async fn local_folder_events( ), WebError, > { - let root = parse_file_root_uri(&query.root_uri)?; - let canonical_root = root.canonicalize().map_err(|error| { - WebError::bad_request_code( - "local_folder_unavailable", - format!("无法访问本地文件夹: {error}"), - ) - .with_context(&context) - })?; + let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri) + .map_err(|error| error.with_context(&context))?; let document_relative_path = query .document_id .as_deref() @@ -105,17 +100,6 @@ pub async fn local_folder_events( )) } -fn parse_file_root_uri(root_uri: &str) -> Result { - let trimmed = root_uri.trim(); - let Some(path) = trimmed.strip_prefix("file://") else { - return Err(WebError::bad_request_code( - "local_folder_root_invalid", - "本地文件夹 rootUri 必须是 file:// URI", - )); - }; - Ok(PathBuf::from(path)) -} - fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option { let trimmed = document_id.trim(); let encoded = trimmed.strip_prefix("local-md:")?; @@ -156,10 +140,4 @@ mod tests { Some("docs/README.md") ); } - - #[test] - fn parse_file_root_uri_requires_file_scheme() { - assert!(parse_file_root_uri("file:///tmp/example").is_ok()); - assert!(parse_file_root_uri("/tmp/example").is_err()); - } } diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 755838c9..e26e3eb2 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -1,3 +1,4 @@ +use crate::context::RequestContext; use crate::error::WebError; use crate::page_aggregate::{ PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, @@ -7,7 +8,9 @@ use crate::routes::local_markdown_parser::{ file_stem_title, parse_markdown_page, split_frontmatter, }; use crate::routes::snapshot_support::ProjectionSnapshot; -use axum::http::StatusCode; +use axum::extract::{Extension, Multipart, Path as AxumPath, Query}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::project_legacy_content_to_block_document; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; @@ -36,6 +39,53 @@ struct LocalFolderMetadata { trash_entries: BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalWorkspaceManifest { + #[serde(default, alias = "workspace_id")] + workspace_id: String, + #[serde(default, alias = "owner_id")] + owner_id: String, + #[serde(default, alias = "created_at")] + created_at: String, + #[serde(default, alias = "capabilities")] + capabilities: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalAccessPolicy { + #[serde(default)] + admins: Vec, + #[serde(default)] + grants: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalAccessGrant { + #[serde(default)] + id: String, + #[serde(default, alias = "user_id")] + user_id: String, + #[serde(default, alias = "root_uri")] + root_uri: String, + #[serde(default, alias = "root_path")] + root_path: String, + #[serde(default, alias = "access")] + permission: String, + #[serde(default = "default_true")] + recursive: bool, + #[serde(default)] + capabilities: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalAccessMode { + Read, + Write, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalTrashEntry { @@ -82,6 +132,67 @@ struct LocalFolderRow { capabilities: Vec, } +#[derive(Debug, Clone)] +pub(crate) struct LocalUploadFile { + name: String, + content_type: String, + bytes: Vec, +} + +#[derive(Debug, Clone)] +struct LocalAssetUploadFields { + file: LocalUploadFile, + root_uri: String, + document_id: String, + kind: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalFileOpenQuery { + pub root_uri: String, + pub path: String, + #[serde(default)] + pub download: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalAccessValidateRootRequest { + #[serde(default, alias = "root_uri")] + pub root_uri: String, + #[serde(default, alias = "root_path")] + pub root_path: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalAccessGrantRequest { + #[serde(default)] + pub id: String, + #[serde(default, alias = "user_id")] + pub user_id: String, + #[serde(default, alias = "root_uri")] + pub root_uri: String, + #[serde(default, alias = "root_path")] + pub root_path: String, + #[serde(default, alias = "access")] + pub permission: String, + #[serde(default = "default_true")] + pub recursive: bool, + #[serde(default)] + pub capabilities: Vec, +} + +const DEFAULT_LOCAL_WORKSPACE_BASE_DIR: &str = "/mnt/Data1T/Mnote_data"; +const DEFAULT_LOCAL_WORKSPACE_SLUG: &str = "my-space"; +const ENV_LOCAL_ADMIN_USER_IDS: &str = "MNOTE_ADMIN_USER_IDS"; +const ENV_LOCAL_ACCESS_POLICY_FILE: &str = "MNOTE_LOCAL_ACCESS_POLICY_FILE"; + +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalFolderWatchRevision { @@ -91,6 +202,878 @@ pub struct LocalFolderWatchRevision { pub latest_modified_ms: u128, } +pub(crate) fn ensure_local_workspace_access_for_actor( + actor_id: &str, + actor_type: &str, + root_uri: &str, +) -> Result { + ensure_local_workspace_access_for_actor_with_mode( + actor_id, + actor_type, + root_uri, + LocalAccessMode::Write, + ) +} + +pub(crate) fn ensure_local_workspace_read_access_for_actor( + actor_id: &str, + actor_type: &str, + root_uri: &str, +) -> Result { + ensure_local_workspace_access_for_actor_with_mode( + actor_id, + actor_type, + root_uri, + LocalAccessMode::Read, + ) +} + +fn ensure_local_workspace_access_for_actor_with_mode( + actor_id: &str, + actor_type: &str, + root_uri: &str, + mode: LocalAccessMode, +) -> Result { + let actor_id = actor_id.trim(); + let actor_type = actor_type.trim(); + if actor_id.is_empty() + || actor_id == "anonymous" + || actor_type.is_empty() + || actor_type == "anonymous" + { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_workspace_auth_required", + "本地工作区访问需要先登录", + )); + } + + let policy = load_local_access_policy().unwrap_or_default(); + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + + if is_local_admin(actor_id, actor_type, &policy) { + return Ok(canonical_root); + } + if local_access_policy_allows(&policy, actor_id, &canonical_root, mode) { + return Ok(canonical_root); + } + if let Ok(manifest) = load_local_workspace_manifest(&canonical_root) { + if manifest.owner_id.trim() == actor_id { + return Ok(canonical_root); + } + } + + Err(WebError::new( + StatusCode::FORBIDDEN, + "local_workspace_access_denied", + "当前用户无权访问该本地工作区", + )) +} + +pub(crate) fn ensure_local_workspace_read_access( + context: &RequestContext, + root_uri: &str, +) -> Result { + ensure_local_workspace_read_access_for_actor( + &context.auth.actor_id, + &context.auth.actor_type, + root_uri, + ) +} + +pub(crate) fn ensure_local_path_read_access( + context: &RequestContext, + root_uri: &str, + candidate_path: &str, +) -> Result { + let canonical_root = ensure_local_workspace_read_access(context, root_uri)?; + let candidate_path = candidate_path.trim(); + if candidate_path.is_empty() { + return Err(WebError::bad_request_code( + "local_file_path_required", + "本地文件读取缺少文件路径", + )); + } + let raw_path = candidate_path + .strip_prefix("file://") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(candidate_path)); + let target_path = if raw_path.is_absolute() { + raw_path + } else { + canonical_root.join(raw_path) + }; + let canonical_target = target_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_file_unavailable", + format!("无法访问本地文件: {error}"), + ) + })?; + if !canonical_target.starts_with(&canonical_root) { + return Err(WebError::bad_request_code( + "local_file_root_escape", + "本地文件读取不能越过授权目录", + )); + } + if !canonical_target.is_file() { + return Err(WebError::bad_request_code( + "local_file_not_file", + "本地文件读取目标必须是文件", + )); + } + Ok(canonical_target) +} + +pub(crate) fn ensure_local_workspace_access( + context: &RequestContext, + root_uri: &str, +) -> Result { + ensure_local_workspace_access_for_actor( + &context.auth.actor_id, + &context.auth.actor_type, + root_uri, + ) +} + +fn local_access_policy_path() -> PathBuf { + std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + default_local_workspace_base_dir() + .join("control-plane") + .join("access-policy.json") + }) +} + +pub(crate) fn local_access_policy_path_display() -> String { + local_access_policy_path().display().to_string() +} + +fn load_local_access_policy() -> Result { + let path = local_access_policy_path(); + if !path.exists() { + return Ok(LocalAccessPolicy::default()); + } + let content = fs::read_to_string(&path).map_err(|error| { + WebError::bad_request_code( + "local_access_policy_read_failed", + format!("无法读取本地目录授权策略 {}: {error}", path.display()), + ) + })?; + serde_json::from_str::(&content).map_err(|error| { + WebError::bad_request_code( + "local_access_policy_invalid", + format!("本地目录授权策略格式非法: {error}"), + ) + }) +} + +fn write_local_access_policy(policy: &LocalAccessPolicy) -> Result<(), WebError> { + let path = local_access_policy_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "local_access_policy_dir_create_failed", + format!("无法创建本地目录授权策略目录 {}: {error}", parent.display()), + ) + })?; + } + let content = serde_json::to_string_pretty(policy) + .map_err(|error| WebError::internal(format!("无法序列化本地目录授权策略: {error}")))?; + fs::write(&path, format!("{content}\n")).map_err(|error| { + WebError::bad_request_code( + "local_access_policy_write_failed", + format!("无法写入本地目录授权策略 {}: {error}", path.display()), + ) + }) +} + +fn require_local_access_policy_admin( + context: &RequestContext, +) -> Result { + let policy = load_local_access_policy()?; + let actor_id = context.auth.actor_id.trim(); + let actor_type = context.auth.actor_type.trim(); + if actor_id.is_empty() + || actor_id == "anonymous" + || actor_type.is_empty() + || actor_type == "anonymous" + { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_access_policy_auth_required", + "管理本地目录授权需要先登录", + )); + } + if !is_local_admin(actor_id, actor_type, &policy) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_access_policy_admin_required", + "只有管理员可以管理本地目录授权", + )); + } + Ok(policy) +} + +pub(crate) fn is_local_access_policy_admin_context(context: &RequestContext) -> bool { + let actor_id = context.auth.actor_id.trim(); + let actor_type = context.auth.actor_type.trim(); + if actor_id.is_empty() + || actor_id == "anonymous" + || actor_type.is_empty() + || actor_type == "anonymous" + { + return false; + } + let policy = load_local_access_policy().unwrap_or_default(); + is_local_admin(actor_id, actor_type, &policy) +} + +fn env_local_admin_ids() -> Vec { + std::env::var(ENV_LOCAL_ADMIN_USER_IDS) + .ok() + .map(|value| { + value + .split([',', ';', '\n', ' ']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + +fn canonical_root_from_admin_request(root_uri: &str, root_path: &str) -> Result { + let path = if !root_uri.trim().is_empty() { + parse_file_root_uri(root_uri.trim())? + } else if !root_path.trim().is_empty() { + PathBuf::from(root_path.trim()) + } else { + return Err(WebError::bad_request_code( + "local_access_policy_root_required", + "必须提供 rootUri 或 rootPath", + )); + }; + let canonical = path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_access_policy_root_unavailable", + format!("无法访问授权目录: {error}"), + ) + })?; + if !canonical.is_dir() { + return Err(WebError::bad_request_code( + "local_access_policy_root_not_directory", + "授权目标必须是目录", + )); + } + Ok(canonical) +} + +fn normalize_local_access_permission(permission: &str) -> Result { + let permission = permission.trim(); + match permission { + "read" | "write" => Ok(permission.to_string()), + _ => Err(WebError::bad_request_code( + "local_access_policy_permission_invalid", + "授权 permission 只能是 read 或 write", + )), + } +} + +fn normalize_local_access_capabilities(capabilities: &[String]) -> Result, WebError> { + let mut normalized = Vec::new(); + for capability in capabilities { + let capability = capability.trim(); + if capability.is_empty() { + continue; + } + match capability { + "ai" | "share" => { + if !normalized.iter().any(|item| item == capability) { + normalized.push(capability.to_string()); + } + } + _ => { + return Err(WebError::bad_request_code( + "local_access_policy_capability_invalid", + "授权 capability 只能是 ai 或 share", + )); + } + } + } + normalized.sort(); + Ok(normalized) +} + +fn generate_local_access_grant_id(user_id: &str, canonical_root: &Path) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let mut hasher = DefaultHasher::new(); + user_id.hash(&mut hasher); + canonical_root.display().to_string().hash(&mut hasher); + format!("grant_{now}_{:x}", hasher.finish()) +} + +fn local_access_policy_payload(policy: &LocalAccessPolicy) -> Value { + json!({ + "ok": true, + "policyPath": local_access_policy_path().display().to_string(), + "policy": policy, + "effectiveAdmins": { + "policy": &policy.admins, + "env": env_local_admin_ids(), + } + }) +} + +fn validate_local_access_root_for_context( + context: &RequestContext, + request: LocalAccessValidateRootRequest, +) -> Result { + let policy = require_local_access_policy_admin(context)?; + let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; + Ok(json!({ + "ok": true, + "policyPath": local_access_policy_path().display().to_string(), + "rootPath": canonical.display().to_string(), + "rootUri": file_uri_for_path(&canonical), + "admin": is_local_admin(&context.auth.actor_id, &context.auth.actor_type, &policy), + })) +} + +fn add_local_access_grant_for_context( + context: &RequestContext, + request: LocalAccessGrantRequest, +) -> Result { + let mut policy = require_local_access_policy_admin(context)?; + let user_id = request.user_id.trim(); + if user_id.is_empty() || user_id == "anonymous" { + return Err(WebError::bad_request_code( + "local_access_policy_user_required", + "必须提供有效 userId", + )); + } + let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; + let permission = normalize_local_access_permission(&request.permission)?; + let capabilities = normalize_local_access_capabilities(&request.capabilities)?; + let root_uri = file_uri_for_path(&canonical); + let grant_id = request.id.trim(); + let grant_id = if grant_id.is_empty() { + generate_local_access_grant_id(user_id, &canonical) + } else { + grant_id.to_string() + }; + if policy + .grants + .iter() + .any(|grant| grant.id.trim() == grant_id) + { + return Err(WebError::bad_request_code( + "local_access_policy_grant_id_exists", + "授权 grantId 已存在", + )); + } + if policy.grants.iter().any(|grant| { + grant.user_id.trim() == user_id + && grant.root_uri.trim() == root_uri + && grant.permission.trim() == permission + && grant.recursive == request.recursive + && { + let existing = + normalize_local_access_capabilities(&grant.capabilities).unwrap_or_default(); + existing == capabilities + } + }) { + return Err(WebError::bad_request_code( + "local_access_policy_grant_duplicate", + "相同目录授权已存在", + )); + } + let grant = LocalAccessGrant { + id: grant_id, + user_id: user_id.to_string(), + root_uri, + root_path: canonical.display().to_string(), + permission, + recursive: request.recursive, + capabilities, + }; + policy.grants.push(grant.clone()); + write_local_access_policy(&policy)?; + Ok(json!({ + "ok": true, + "policyPath": local_access_policy_path().display().to_string(), + "grant": grant, + "policy": policy, + })) +} + +fn delete_local_access_grant_for_context( + context: &RequestContext, + grant_id: &str, +) -> Result { + let mut policy = require_local_access_policy_admin(context)?; + let grant_id = grant_id.trim(); + if grant_id.is_empty() { + return Err(WebError::bad_request_code( + "local_access_policy_grant_id_required", + "必须提供 grantId", + )); + } + let before = policy.grants.len(); + policy.grants.retain(|grant| grant.id.trim() != grant_id); + if policy.grants.len() == before { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "local_access_policy_grant_not_found", + "未找到要删除的目录授权", + )); + } + write_local_access_policy(&policy)?; + Ok(json!({ + "ok": true, + "policyPath": local_access_policy_path().display().to_string(), + "deletedGrantId": grant_id, + "policy": policy, + })) +} + +fn is_local_admin(actor_id: &str, actor_type: &str, policy: &LocalAccessPolicy) -> bool { + if actor_type.trim() == "admin" { + return true; + } + let actor_id = actor_id.trim(); + if actor_id.is_empty() { + return false; + } + if policy.admins.iter().any(|admin| admin.trim() == actor_id) { + return true; + } + std::env::var(ENV_LOCAL_ADMIN_USER_IDS) + .ok() + .map(|value| { + value + .split([',', ';', '\n', ' ']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .any(|item| item == actor_id) + }) + .unwrap_or(false) +} + +fn local_access_policy_allows( + policy: &LocalAccessPolicy, + actor_id: &str, + canonical_root: &Path, + mode: LocalAccessMode, +) -> bool { + let actor_id = actor_id.trim(); + if actor_id.is_empty() { + return false; + } + policy.grants.iter().any(|grant| { + if grant.user_id.trim() != actor_id { + return false; + } + if !local_access_permission_allows(grant.permission.as_str(), mode) { + return false; + } + let Some(grant_root) = local_access_grant_root(grant) else { + return false; + }; + if grant.recursive { + canonical_root.starts_with(&grant_root) + } else { + canonical_root == grant_root + } + }) +} + +fn local_access_permission_allows(permission: &str, mode: LocalAccessMode) -> bool { + let permission = permission.trim(); + if permission == "admin" { + return true; + } + match mode { + LocalAccessMode::Read => permission == "read" || permission == "write", + LocalAccessMode::Write => permission == "write", + } +} + +fn local_access_grant_root(grant: &LocalAccessGrant) -> Option { + let root = grant.root_uri.trim(); + let path = if !root.is_empty() { + parse_file_root_uri(root).ok()? + } else { + let root_path = grant.root_path.trim(); + if root_path.is_empty() { + return None; + } + PathBuf::from(root_path) + }; + path.canonicalize().ok() +} + +pub(crate) fn create_default_local_workspace_for_actor( + actor_id: &str, + actor_type: &str, +) -> Result { + create_default_local_workspace_for_actor_at_base( + actor_id, + actor_type, + &default_local_workspace_base_dir(), + ) +} + +fn create_default_local_workspace_for_actor_at_base( + actor_id: &str, + actor_type: &str, + base_dir: &Path, +) -> Result { + let actor_id = actor_id.trim(); + let actor_type = actor_type.trim(); + if actor_id.is_empty() + || actor_id == "anonymous" + || actor_type.is_empty() + || actor_type == "anonymous" + { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_workspace_auth_required", + "创建本地工作区需要先登录", + )); + } + + fs::create_dir_all(base_dir).map_err(|error| { + WebError::bad_request_code( + "local_workspace_base_create_failed", + format!( + "无法创建本地工作区数据根目录 {}: {error}", + base_dir.display() + ), + ) + })?; + let canonical_base = base_dir.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_workspace_base_unavailable", + format!("无法访问本地工作区数据根目录: {error}"), + ) + })?; + let actor_segment = encode_local_id_segment(actor_id); + let workspace_root = canonical_base + .join("users") + .join(&actor_segment) + .join("workspaces") + .join(DEFAULT_LOCAL_WORKSPACE_SLUG); + fs::create_dir_all(&workspace_root).map_err(|error| { + WebError::bad_request_code( + "local_workspace_create_failed", + format!( + "无法创建默认本地工作区 {}: {error}", + workspace_root.display() + ), + ) + })?; + let canonical_root = workspace_root.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_workspace_unavailable", + format!("无法访问默认本地工作区: {error}"), + ) + })?; + if !canonical_root.starts_with(&canonical_base) { + return Err(WebError::bad_request_code( + "local_workspace_root_escape", + "默认本地工作区不能越过受管数据根目录", + )); + } + + let manifest = ensure_default_workspace_manifest(actor_id, &canonical_root)?; + ensure_default_workspace_directories(&canonical_root)?; + ensure_default_workspace_home_page(&canonical_root)?; + Ok(json!({ + "ok": true, + "workspace": { + "rootUri": file_uri_for_path(&canonical_root), + "rootPath": canonical_root.display().to_string(), + "baseDir": canonical_base.display().to_string(), + "manifest": local_workspace_manifest_json(&manifest), + } + })) +} + +pub async fn create_default_local_workspace( + Extension(context): Extension, +) -> Result<(StatusCode, Json), WebError> { + let payload = + create_default_local_workspace_for_actor(&context.auth.actor_id, &context.auth.actor_type) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn get_local_access_policy( + Extension(context): Extension, +) -> Result<(StatusCode, Json), WebError> { + let policy = require_local_access_policy_admin(&context) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(local_access_policy_payload(&policy)))) +} + +pub async fn validate_local_access_root( + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = validate_local_access_root_for_context(&context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn create_local_access_grant( + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = add_local_access_grant_for_context(&context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn delete_local_access_grant( + Extension(context): Extension, + AxumPath(grant_id): AxumPath, +) -> Result<(StatusCode, Json), WebError> { + let payload = delete_local_access_grant_for_context(&context, &grant_id) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +#[cfg(test)] +pub(crate) fn initialize_local_workspace_for_actor( + actor_id: &str, + root_uri: &str, +) -> Result { + let actor_id = actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_workspace_auth_required", + "创建本地工作区需要先登录", + )); + } + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + let manifest_path = local_workspace_manifest_path(&canonical_root); + if manifest_path.exists() { + let manifest = load_local_workspace_manifest(&canonical_root)?; + if manifest.owner_id.trim() != actor_id { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_workspace_access_denied", + "当前用户无权接管该本地工作区", + )); + } + return Ok(local_workspace_manifest_json(&manifest)); + } + let manifest = LocalWorkspaceManifest { + workspace_id: format!( + "local-ws:{}", + encode_local_id_segment(&format!("{}:{}", actor_id, canonical_root.display())) + ), + owner_id: actor_id.to_string(), + created_at: now_ms().to_string(), + capabilities: vec![ + "local_files".to_string(), + "tree_commands".to_string(), + "markdown_edit".to_string(), + "asset_upload".to_string(), + ], + }; + write_local_workspace_manifest(&canonical_root, &manifest)?; + Ok(local_workspace_manifest_json(&manifest)) +} + +fn default_local_workspace_base_dir() -> PathBuf { + std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_LOCAL_WORKSPACE_BASE_DIR)) +} + +fn ensure_default_workspace_manifest( + actor_id: &str, + root: &Path, +) -> Result { + let manifest_path = local_workspace_manifest_path(root); + if manifest_path.exists() { + let manifest = load_local_workspace_manifest(root)?; + if manifest.owner_id.trim() != actor_id { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_workspace_access_denied", + "当前用户无权接管该本地工作区", + )); + } + return Ok(manifest); + } + let manifest = LocalWorkspaceManifest { + workspace_id: format!( + "local-ws:{}:{}", + encode_local_id_segment(actor_id), + DEFAULT_LOCAL_WORKSPACE_SLUG + ), + owner_id: actor_id.to_string(), + created_at: now_ms().to_string(), + capabilities: vec![ + "local_files".to_string(), + "tree_commands".to_string(), + "markdown_edit".to_string(), + "asset_upload".to_string(), + ], + }; + write_local_workspace_manifest(root, &manifest)?; + Ok(manifest) +} + +fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> { + for relative in [ + "pages", + "assets/shared", + "assets/imports", + "mindmaps", + "ai-sessions/private", + "ai-sessions/shared", + ".mnote/trash", + ] { + let path = root.join(relative); + if !path.starts_with(root) { + return Err(WebError::bad_request_code( + "local_workspace_root_escape", + "默认本地工作区目录不能越过 root", + )); + } + fs::create_dir_all(&path).map_err(|error| { + WebError::bad_request_code( + "local_workspace_create_failed", + format!("无法创建默认本地工作区目录 {}: {error}", path.display()), + ) + })?; + } + Ok(()) +} + +fn ensure_default_workspace_home_page(root: &Path) -> Result<(), WebError> { + let page = root.join("pages").join("我的空间.md"); + if page.exists() { + return Ok(()); + } + if !page.starts_with(root) { + return Err(WebError::bad_request_code( + "local_workspace_root_escape", + "默认本地工作区首页不能越过 root", + )); + } + let content = "---\ntitle: 我的空间\nmnote_id: my-space-home\n---\n# 我的空间\n\n"; + fs::write(&page, content).map_err(|error| { + WebError::bad_request_code( + "local_workspace_create_failed", + format!("无法创建默认本地工作区首页 {}: {error}", page.display()), + ) + }) +} + +fn local_workspace_manifest_path(root: &Path) -> PathBuf { + root.join(".mnote").join("workspace.json") +} + +fn load_local_workspace_manifest(root: &Path) -> Result { + let manifest_path = local_workspace_manifest_path(root); + let content = fs::read_to_string(&manifest_path).map_err(|error| { + WebError::new( + StatusCode::FORBIDDEN, + "local_workspace_manifest_missing", + format!("缺少本地工作区清单 {}: {error}", manifest_path.display()), + ) + })?; + let manifest = serde_json::from_str::(&content).map_err(|error| { + WebError::bad_request_code( + "local_workspace_manifest_invalid", + format!("本地工作区清单格式非法: {error}"), + ) + })?; + if manifest.workspace_id.trim().is_empty() || manifest.owner_id.trim().is_empty() { + return Err(WebError::bad_request_code( + "local_workspace_manifest_invalid", + "本地工作区清单缺少 workspaceId / ownerId", + )); + } + Ok(manifest) +} + +fn write_local_workspace_manifest( + root: &Path, + manifest: &LocalWorkspaceManifest, +) -> Result<(), WebError> { + let metadata_dir = root.join(".mnote"); + fs::create_dir_all(&metadata_dir).map_err(|error| { + WebError::bad_request_code( + "local_workspace_manifest_write_failed", + format!( + "无法创建本地工作区元数据目录 {}: {error}", + metadata_dir.display() + ), + ) + })?; + let manifest_path = local_workspace_manifest_path(root); + let content = serde_json::to_string_pretty(manifest) + .map_err(|error| WebError::internal(format!("本地工作区清单序列化失败: {error}")))?; + fs::write(&manifest_path, content).map_err(|error| { + WebError::bad_request_code( + "local_workspace_manifest_write_failed", + format!( + "无法写入本地工作区清单 {}: {error}", + manifest_path.display() + ), + ) + }) +} + +fn local_workspace_manifest_json(manifest: &LocalWorkspaceManifest) -> Value { + json!({ + "workspaceId": manifest.workspace_id, + "ownerId": manifest.owner_id, + "createdAt": manifest.created_at, + "capabilities": manifest.capabilities, + }) +} + pub fn load_local_folder_file_tree_snapshot( root_uri: &str, ) -> Result { @@ -311,6 +1294,9 @@ pub fn resolve_local_markdown_page_aggregate( project_legacy_content_to_block_document(document_id, &content, &Value::Number(0.into())) .map_err(|error| WebError::internal(format!("{error:?}")))?; + let conflict_detection_key = + local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; + Ok(PageAggregate { schema: PageAggregate::SCHEMA.into(), projection_version: PageAggregate::VERSION, @@ -340,10 +1326,8 @@ pub fn resolve_local_markdown_page_aggregate( body: PageBody { content, revision: Value::Number(0.into()), - conflict_detection_key: Value::String(local_markdown_conflict_detection_key( - document_id, - &markdown_file.path, - )?), + conflict_detection_key: Value::String(conflict_detection_key.clone()), + file_version: Value::String(conflict_detection_key), block_document, block_projection_version: 1, projection_source: "local_markdown.content".into(), @@ -426,12 +1410,319 @@ pub fn save_local_markdown_page( "documentId": document_id, "revision": now_ms(), "conflict_detection_key": next_conflict_key, + "fileVersion": next_conflict_key, "executedCommand": "page.body.save", "canonicalCommand": "page.body.save", "sourceKind": "local_folder", })) } +pub fn write_local_markdown_page_body( + request: &core_protocol::PageBodyWriteRequest, +) -> Result { + if request.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder { + return Err(WebError::bad_request_code( + "page_body_write_source_unsupported", + "page.body.write 当前只支持 local_folder 本地写入", + )); + } + if request.content_format.trim() != "editorBlocks" { + return Err(WebError::bad_request_code( + "page_body_write_content_format_unsupported", + "page.body.write 当前只支持 editorBlocks 内容格式", + )); + } + + let mut result = save_local_markdown_page( + &request.root_uri, + &request.document_id, + request.expected_file_version.as_deref(), + &request.content, + )?; + if let Value::Object(map) = &mut result { + map.insert("canonicalCommand".into(), json!("page.body.write")); + map.insert("compatCommand".into(), json!("page.body.save")); + map.insert("contentFormat".into(), json!(request.content_format)); + map.insert( + "editorSource".into(), + request + .editor_source + .as_deref() + .map(Value::from) + .unwrap_or(Value::Null), + ); + map.insert( + "baseContentHash".into(), + request + .base_content_hash + .as_deref() + .map(Value::from) + .unwrap_or(Value::Null), + ); + } + Ok(result) +} + +pub async fn upload_local_markdown_asset( + Extension(context): Extension, + multipart: Multipart, +) -> Result<(StatusCode, Json), WebError> { + let fields = read_local_asset_upload_multipart(multipart).await?; + ensure_local_workspace_access(&context, &fields.root_uri) + .map_err(|error| error.with_context(&context))?; + let asset = write_local_markdown_asset( + &fields.root_uri, + &fields.document_id, + &fields.kind, + fields.file, + )?; + Ok((StatusCode::OK, Json(json!({ "ok": true, "asset": asset })))) +} + +pub async fn open_local_file( + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Vec), WebError> { + ensure_local_workspace_read_access(&context, &query.root_uri) + .map_err(|error| error.with_context(&context))?; + let target = resolve_local_file_open_path(&query.root_uri, &query.path)?; + let bytes = fs::read(&target).map_err(|error| { + WebError::bad_request_code( + "local_file_open_read_failed", + format!("无法读取本地文件 {}: {error}", target.display()), + ) + .with_context(&context) + })?; + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + if query.download.unwrap_or(false) { + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_static("attachment"), + ); + } + Ok((StatusCode::OK, headers, bytes)) +} + +fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + let requested = Path::new(relative_path); + if requested.is_absolute() + || requested + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(WebError::bad_request_code( + "local_file_open_root_escape", + "本地文件路径不能越过 root", + )); + } + let target = canonical_root + .join(requested) + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "local_file_open_not_found", + format!("找不到本地文件: {error}"), + ) + })?; + if !target.starts_with(&canonical_root) { + return Err(WebError::bad_request_code( + "local_file_open_root_escape", + "本地文件路径不能越过 root", + )); + } + if target.is_dir() { + return Err(WebError::bad_request_code( + "local_file_open_is_directory", + "不能直接读取本地目录", + )); + } + Ok(target) +} + +async fn read_local_asset_upload_multipart( + mut multipart: Multipart, +) -> Result { + let mut file: Option = None; + let mut root_uri = String::new(); + let mut document_id = String::new(); + let mut kind = String::new(); + + while let Some(field) = multipart.next_field().await.map_err(|error| { + WebError::bad_request_code( + "local_asset_upload_bad_multipart", + format!("上传表单解析失败: {error}"), + ) + })? { + let name = field.name().unwrap_or_default().to_string(); + if name == "file" { + let file_name = field + .file_name() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("附件") + .to_string(); + let content_type = field + .content_type() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("application/octet-stream") + .to_string(); + let bytes = field + .bytes() + .await + .map_err(|error| { + WebError::bad_request_code( + "local_asset_upload_file_read_failed", + format!("读取上传文件失败: {error}"), + ) + })? + .to_vec(); + file = Some(LocalUploadFile { + name: file_name, + content_type, + bytes, + }); + continue; + } + + let value = field.text().await.map_err(|error| { + WebError::bad_request_code( + "local_asset_upload_field_read_failed", + format!("读取上传字段失败: {error}"), + ) + })?; + match name.as_str() { + "rootUri" => root_uri = value.trim().to_string(), + "documentId" => document_id = value.trim().to_string(), + "kind" => kind = value.trim().to_string(), + _ => {} + } + } + + let file = file.ok_or_else(|| { + WebError::bad_request_code("local_asset_upload_file_missing", "缺少 file") + })?; + if file.bytes.is_empty() || root_uri.is_empty() || document_id.is_empty() { + return Err(WebError::bad_request_code( + "local_asset_upload_required_missing", + "缺少必要参数", + )); + } + Ok(LocalAssetUploadFields { + file, + root_uri, + document_id, + kind, + }) +} + +pub(crate) fn write_local_markdown_asset( + root_uri: &str, + document_id: &str, + kind: &str, + file: LocalUploadFile, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + + let metadata = load_local_folder_metadata(&canonical_root)?; + let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? + .ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到本地 Markdown 页面对应的文件", + ) + })?; + let markdown_dir = markdown_file.path.parent().ok_or_else(|| { + WebError::bad_request_code( + "local_asset_upload_bad_markdown_path", + "本地 Markdown 文件路径无父目录", + ) + })?; + if !markdown_dir.starts_with(&canonical_root) { + return Err(WebError::bad_request_code( + "local_folder_root_escape", + "本地 Markdown 文件路径不在 root 内", + )); + } + + let markdown_stem = Path::new(&markdown_file.file_name) + .file_stem() + .and_then(|value| value.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("page"); + let asset_dir_name = format!("{}.assets", sanitize_file_name(markdown_stem, "page")); + let asset_dir = markdown_dir.join(asset_dir_name); + if !asset_dir.starts_with(&canonical_root) { + return Err(WebError::bad_request_code( + "local_folder_root_escape", + "本地资源目录不能越过 root", + )); + } + fs::create_dir_all(&asset_dir).map_err(|error| { + WebError::bad_request_code( + "local_asset_upload_create_dir_failed", + format!("无法创建本地资源目录 {}: {error}", asset_dir.display()), + ) + })?; + + let sanitized_name = sanitize_file_name(&file.name, "附件"); + let target = next_available_asset_path(&asset_dir, &sanitized_name); + if !target.starts_with(&canonical_root) { + return Err(WebError::bad_request_code( + "local_folder_root_escape", + "本地资源文件不能越过 root", + )); + } + fs::write(&target, &file.bytes).map_err(|error| { + WebError::bad_request_code( + "local_asset_upload_write_failed", + format!("无法写入本地资源文件 {}: {error}", target.display()), + ) + })?; + + let root_relative_path = normalize_relative_path(&canonical_root, &target)?; + let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?; + let asset_type = local_upload_asset_type(kind, &file.content_type); + Ok(json!({ + "id": format!("local:asset:{root_relative_path}"), + "asset_type": asset_type, + "file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name), + "mime_type": file.content_type, + "file_size": file.bytes.len(), + "file_url": markdown_relative_path, + "sourcePath": markdown_relative_path, + "document_id": document_id, + "documentId": document_id, + "sourceKind": "local_folder", + "rootUri": file_uri_for_path(&canonical_root), + "rootRelativePath": root_relative_path, + })) +} + pub fn update_local_markdown_title( root_uri: &str, document_id: &str, @@ -2141,6 +3432,11 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { if let Some(document_id) = row.document_id.as_ref() { item["resourceMeta"]["documentId"] = Value::String(document_id.clone()); } + if row.row_kind == "asset" && !row.relative_path.trim().is_empty() { + let asset_id = format!("local-file:{}", row.relative_path); + item["assetId"] = Value::String(asset_id.clone()); + item["resourceMeta"]["assetId"] = Value::String(asset_id); + } item } @@ -2241,6 +3537,11 @@ fn page_options_from_metadata(value: &Value) -> PageOptions { } fn local_workspace_id(root: &Path) -> String { + if let Ok(manifest) = load_local_workspace_manifest(root) { + if !manifest.workspace_id.trim().is_empty() { + return manifest.workspace_id; + } + } format!( "local:{}", root.to_string_lossy() @@ -2523,6 +3824,57 @@ fn next_available_raw_path(directory: &Path, file_name: &str) -> PathBuf { directory.join(format!("{stem}-{}", std::process::id())) } +fn next_available_asset_path(directory: &Path, file_name: &str) -> PathBuf { + let first = directory.join(file_name); + if !first.exists() { + return first; + } + let path = Path::new(file_name); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + let extension = path.extension().and_then(|value| value.to_str()); + for index in 1..10_000 { + let candidate_name = if let Some(extension) = extension { + format!("{stem}-{index}.{extension}") + } else { + format!("{stem}-{index}") + }; + let candidate = directory.join(candidate_name); + if !candidate.exists() { + return candidate; + } + } + directory.join(format!("{stem}-{}", std::process::id())) +} + +fn normalize_markdown_relative_asset_path( + markdown_dir: &Path, + asset_path: &Path, +) -> Result { + let relative = asset_path.strip_prefix(markdown_dir).map_err(|_| { + WebError::bad_request_code( + "local_asset_upload_relative_path_failed", + "本地资源文件不在 Markdown 文件目录内", + ) + })?; + Ok(relative + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/")) +} + +fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str { + let normalized_kind = kind.trim().to_ascii_lowercase(); + if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") { + "image" + } else { + "file" + } +} + fn next_available_directory_path(directory: &Path, stem: &str) -> PathBuf { let first = directory.join(stem); if !first.exists() { @@ -2586,11 +3938,29 @@ fn editor_blocks_to_markdown(content: &Value) -> String { } else { inline_markdown }; - if text.is_empty() && !matches!(block_type, "divider" | "media" | "table") { + if text.is_empty() && !matches!(block_type, "divider" | "media" | "image" | "table") { lines.push(String::new()); continue; } match block_type { + "image" => { + let props = block.get("props").and_then(Value::as_object); + let src = props + .and_then(|props| props.get("src").or_else(|| props.get("sourcePath"))) + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let alt = props + .and_then(|props| props.get("alt").or_else(|| props.get("title"))) + .and_then(Value::as_str) + .unwrap_or(text.as_str()) + .trim(); + if src.is_empty() { + lines.push(text); + } else { + lines.push(format!("![{}]({src})", alt.replace(']', r"\]"))); + } + } "media" => { let props = block.get("props").and_then(Value::as_object); let url = props @@ -3039,11 +4409,26 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val #[cfg(test)] mod tests { use super::{ - initialize_local_page_id, load_local_folder_page_tree_snapshot, - local_folder_watch_revision, resolve_local_markdown_page_aggregate, - save_local_markdown_page, + add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base, + create_local_access_grant, ensure_local_path_read_access, + ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor, + get_local_access_policy, initialize_local_page_id, initialize_local_workspace_for_actor, + load_local_folder_page_tree_snapshot, local_folder_watch_revision, open_local_file, + resolve_local_markdown_page_aggregate, save_local_markdown_page, + validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body, + LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery, + LocalUploadFile, }; + use crate::context::RequestContext; + use axum::extract::{Extension, Path as AxumPath, Query}; + use axum::http::{HeaderMap, Method, StatusCode}; + use axum::Json; use serde_json::Value; + use std::sync::Mutex; + + fn env_lock() -> &'static Mutex<()> { + crate::test_support::hermes_env_lock() + } fn temp_root(name: &str) -> std::path::PathBuf { let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id())); @@ -3052,9 +4437,26 @@ mod tests { root } + fn request_context(actor_id: &str, actor_type: &str) -> RequestContext { + let mut headers = HeaderMap::new(); + headers.insert("x-mnote-actor-id", actor_id.parse().unwrap()); + headers.insert("x-mnote-actor-type", actor_type.parse().unwrap()); + RequestContext::from_http_parts( + &Method::POST, + &"/api/admin/access-policy".parse().expect("uri"), + &headers, + ) + } + + fn init_workspace(root: &std::path::Path) { + initialize_local_workspace_for_actor("user_test", &format!("file://{}", root.display())) + .expect("init local workspace"); + } + #[test] fn local_frontmatter_mnote_id_survives_rename_and_move() { let root = temp_root("mnote-local-frontmatter-id"); + init_workspace(&root); std::fs::write( root.join("page.md"), "---\nmnote_id: stable-frontmatter-id\ntitle: Stable\n---\n正文\n", @@ -3082,6 +4484,7 @@ mod tests { #[test] fn local_page_ids_metadata_overrides_path_derived_id_and_hides_mnote() { let root = temp_root("mnote-local-page-ids"); + init_workspace(&root); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("page.md"), "# Metadata Page\n").expect("write md"); @@ -3103,6 +4506,7 @@ mod tests { #[test] fn local_page_id_initialization_writes_page_ids_atomically() { let root = temp_root("mnote-local-page-id-init"); + init_workspace(&root); std::fs::write(root.join("draft.md"), "# Draft\n").expect("write md"); let root_uri = format!("file://{}", root.display()); @@ -3287,6 +4691,85 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_markdown_aggregate_exposes_file_version_alias() { + let root = temp_root("mnote-local-file-version-aggregate"); + std::fs::write(root.join("README.md"), "# Versioned\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") + .expect("aggregate"); + let body = serde_json::to_value(&aggregate.body).expect("body json"); + + assert_eq!(body["fileVersion"], body["conflictDetectionKey"]); + assert!(body["fileVersion"] + .as_str() + .expect("file version") + .starts_with("local-md:local-md:README.md:")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_returns_file_version_alias() { + let root = temp_root("mnote-local-file-version-save"); + std::fs::write(root.join("README.md"), "# Old\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let result = save_local_markdown_page( + &root_uri, + "local-md:README.md", + None, + &serde_json::json!([ + {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"New"}]} + ]), + ) + .expect("save"); + + assert_eq!(result["fileVersion"], result["conflict_detection_key"]); + assert!(result["fileVersion"] + .as_str() + .expect("file version") + .starts_with("local-md:local-md:README.md:")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_write_contract_returns_page_body_write_command() { + let root = temp_root("mnote-local-page-body-write-contract"); + std::fs::write(root.join("README.md"), "---\ntitle: Contract\n---\n# Old\n") + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") + .expect("aggregate"); + let request = core_protocol::PageBodyWriteRequest { + document_id: "local-md:README.md".into(), + workspace_id: "local-ws:test".into(), + source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, + root_uri: root_uri.clone(), + expected_file_version: aggregate.body.file_version.as_str().map(ToOwned::to_owned), + base_content_hash: Some("sha256:test-base".into()), + content_format: "editorBlocks".into(), + content: serde_json::json!([ + {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Written"}]} + ]), + editor_source: Some("unit-test".into()), + }; + + let result = write_local_markdown_page_body(&request).expect("write"); + + assert_eq!(result["canonicalCommand"], "page.body.write"); + assert_eq!(result["compatCommand"], "page.body.save"); + assert_eq!(result["contentFormat"], "editorBlocks"); + assert_eq!(result["editorSource"], "unit-test"); + assert_eq!(result["fileVersion"], result["conflict_detection_key"]); + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.contains("# Written")); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() { let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( @@ -3557,4 +5040,435 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn local_markdown_save_writes_image_blocks_as_markdown_images() { + let root = temp_root("mnote-local-markdown-save-image-block"); + std::fs::write(root.join("README.md"), "---\ntitle: Image\n---\n# Old\n") + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + + save_local_markdown_page( + &root_uri, + "local-md:README.md", + None, + &serde_json::json!([ + { + "type": "image", + "props": { + "src": "README.assets/photo.png", + "alt": "示例图片", + "title": "示例图片" + } + } + ]), + ) + .expect("save"); + + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.contains("![示例图片](README.assets/photo.png)")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_workspace_access_rejects_owner_mismatch() { + let root = temp_root("mnote-local-workspace-owner-mismatch"); + std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); + std::fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-demo","ownerId":"user_a","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, + ) + .expect("write workspace manifest"); + let root_uri = format!("file://{}", root.display()); + + let error = ensure_local_workspace_access_for_actor("user_b", "user", &root_uri) + .expect_err("owner mismatch must be rejected"); + + assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN); + assert!(error.message().contains("无权")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_workspace_access_allows_admin_without_manifest() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-workspace-admin-any-root"); + let root_uri = format!("file://{}", root.display()); + std::env::set_var("MNOTE_ADMIN_USER_IDS", "user_admin"); + + let allowed = ensure_local_workspace_access_for_actor("user_admin", "user", &root_uri) + .expect("admin can write any local directory"); + + assert_eq!(allowed, root.canonicalize().expect("canonical root")); + std::env::remove_var("MNOTE_ADMIN_USER_IDS"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_workspace_access_policy_grants_read_and_write_separately() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-workspace-policy-root"); + let policy_root = temp_root("mnote-local-workspace-policy-config"); + let policy_file = policy_root.join("access-policy.json"); + let root_uri = format!("file://{}", root.display()); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + std::fs::write( + &policy_file, + serde_json::json!({ + "grants": [ + { + "userId": "user_reader", + "rootUri": root_uri, + "permission": "read", + "recursive": true + } + ] + }) + .to_string(), + ) + .expect("write policy"); + + ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri) + .expect("read grant can read local directory"); + let write_error = ensure_local_workspace_access_for_actor("user_reader", "user", &root_uri) + .expect_err("read grant must not write"); + assert_eq!(write_error.status(), axum::http::StatusCode::FORBIDDEN); + + std::fs::write( + &policy_file, + serde_json::json!({ + "grants": [ + { + "userId": "user_writer", + "rootUri": root_uri, + "permission": "write", + "recursive": true + } + ] + }) + .to_string(), + ) + .expect("write policy"); + ensure_local_workspace_access_for_actor("user_writer", "user", &root_uri) + .expect("write grant can write local directory"); + + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&policy_root); + } + + #[test] + fn local_path_read_access_rejects_root_escape() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-path-read-root"); + let outside = temp_root("mnote-local-path-read-outside"); + init_workspace(&root); + std::fs::write(root.join("inside.md"), "inside").expect("write inside"); + std::fs::write(outside.join("outside.md"), "outside").expect("write outside"); + let context = request_context("user_test", "user"); + let root_uri = format!("file://{}", root.display()); + + let inside = ensure_local_path_read_access(&context, &root_uri, "inside.md") + .expect("relative file inside root can be read"); + assert_eq!( + inside, + root.join("inside.md").canonicalize().expect("inside") + ); + + let escape_error = ensure_local_path_read_access( + &context, + &root_uri, + &outside.join("outside.md").display().to_string(), + ) + .expect_err("absolute path outside root must be rejected"); + assert_eq!(escape_error.status(), StatusCode::BAD_REQUEST); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + } + + #[tokio::test] + async fn local_access_policy_admin_api_rejects_non_admin_and_validates_root() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-access-policy-validate-root"); + let policy_root = temp_root("mnote-local-access-policy-validate-config"); + let policy_file = policy_root.join("access-policy.json"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + + let non_admin_error = get_local_access_policy(Extension(request_context("user_1", "user"))) + .await + .expect_err("non-admin cannot read access policy"); + assert_eq!(non_admin_error.status(), StatusCode::FORBIDDEN); + + std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); + let (_, Json(payload)) = validate_local_access_root( + Extension(request_context("admin_1", "user")), + Json(LocalAccessValidateRootRequest { + root_uri: format!("file://{}", root.display()), + root_path: String::new(), + }), + ) + .await + .expect("admin can validate root"); + assert_eq!(payload["ok"], true); + assert_eq!( + payload["rootPath"], + root.canonicalize() + .expect("canonical root") + .display() + .to_string() + ); + + let invalid_error = validate_local_access_root( + Extension(request_context("admin_1", "user")), + Json(LocalAccessValidateRootRequest { + root_uri: format!("file://{}", root.join("missing").display()), + root_path: String::new(), + }), + ) + .await + .expect_err("missing root should be rejected"); + assert_eq!(invalid_error.status(), StatusCode::BAD_REQUEST); + + std::env::remove_var("MNOTE_ADMIN_USER_IDS"); + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&policy_root); + } + + #[tokio::test] + async fn local_access_policy_admin_api_creates_and_deletes_grant() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-access-policy-grant-root"); + let policy_root = temp_root("mnote-local-access-policy-grant-config"); + let policy_file = policy_root.join("access-policy.json"); + let root_uri = format!("file://{}", root.display()); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); + + let (_, Json(created)) = create_local_access_grant( + Extension(request_context("admin_1", "user")), + Json(LocalAccessGrantRequest { + id: "grant_reader".into(), + user_id: "user_reader".into(), + root_uri: root_uri.clone(), + root_path: String::new(), + permission: "read".into(), + recursive: true, + capabilities: vec!["ai".into(), "share".into()], + }), + ) + .await + .expect("admin can create read grant"); + assert_eq!(created["grant"]["id"], "grant_reader"); + assert_eq!(created["grant"]["permission"], "read"); + ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri) + .expect("created read grant can read"); + let write_error = ensure_local_workspace_access_for_actor("user_reader", "user", &root_uri) + .expect_err("created read grant cannot write"); + assert_eq!(write_error.status(), StatusCode::FORBIDDEN); + + let duplicate_error = add_local_access_grant_for_context( + &request_context("admin_1", "user"), + LocalAccessGrantRequest { + id: "grant_reader".into(), + user_id: "user_reader".into(), + root_uri: root_uri.clone(), + root_path: String::new(), + permission: "read".into(), + recursive: true, + capabilities: vec!["ai".into(), "share".into()], + }, + ) + .expect_err("duplicate grant id should be rejected"); + assert_eq!(duplicate_error.status(), StatusCode::BAD_REQUEST); + + let (_, Json(deleted)) = super::delete_local_access_grant( + Extension(request_context("admin_1", "user")), + AxumPath("grant_reader".into()), + ) + .await + .expect("admin can delete grant"); + assert_eq!(deleted["deletedGrantId"], "grant_reader"); + let read_error = + ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri) + .expect_err("deleted grant should remove read access"); + assert_eq!(read_error.status(), StatusCode::FORBIDDEN); + + std::env::remove_var("MNOTE_ADMIN_USER_IDS"); + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&policy_root); + } + + #[test] + fn create_default_local_workspace_uses_managed_data_root() { + let base = temp_root("mnote-local-managed-data-root"); + let payload = + create_default_local_workspace_for_actor_at_base("user@example.com", "user", &base) + .expect("create default workspace"); + let root_uri = payload["workspace"]["rootUri"] + .as_str() + .expect("root uri") + .to_string(); + let root_path = payload["workspace"]["rootPath"] + .as_str() + .expect("root path"); + let expected_root = base + .join("users") + .join("user~40example.com") + .join("workspaces") + .join("my-space"); + + assert_eq!(std::path::Path::new(root_path), expected_root.as_path()); + assert!(expected_root.join(".mnote").join("workspace.json").exists()); + assert!(expected_root.join("pages").join("我的空间.md").exists()); + assert!(expected_root.join("assets").join("shared").is_dir()); + assert!(expected_root.join("ai-sessions").join("private").is_dir()); + assert_eq!( + payload["workspace"]["manifest"]["workspaceId"], + "local-ws:user~40example.com:my-space" + ); + assert_eq!( + payload["workspace"]["manifest"]["ownerId"], + "user@example.com" + ); + assert!(payload["workspace"]["manifest"]["capabilities"] + .as_array() + .expect("capabilities") + .iter() + .any(|value| value.as_str() == Some("markdown_edit"))); + ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri) + .expect("owner can access managed workspace"); + + let second = + create_default_local_workspace_for_actor_at_base("user@example.com", "user", &base) + .expect("idempotent create"); + assert_eq!( + second["workspace"]["rootUri"], + payload["workspace"]["rootUri"] + ); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() { + let root = temp_root("mnote-local-markdown-asset-upload"); + init_workspace(&root); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("docs").join("README.md"), "# Assets\n").expect("write md"); + std::fs::create_dir_all(root.join("docs").join("README.assets")).expect("create assets"); + std::fs::write( + root.join("docs").join("README.assets").join("photo.png"), + b"old", + ) + .expect("write existing asset"); + let root_uri = format!("file://{}", root.display()); + + let asset = write_local_markdown_asset( + &root_uri, + "local-md:docs~2FREADME.md", + "image", + LocalUploadFile { + name: "photo.png".to_string(), + content_type: "image/png".to_string(), + bytes: b"new-image".to_vec(), + }, + ) + .expect("upload asset"); + + assert_eq!(asset["sourcePath"], "README.assets/photo-1.png"); + assert_eq!(asset["file_url"], "README.assets/photo-1.png"); + assert_eq!(asset["asset_type"], "image"); + assert_eq!(asset["sourceKind"], "local_folder"); + assert_eq!( + std::fs::read(root.join("docs").join("README.assets").join("photo-1.png")) + .expect("read copied asset"), + b"new-image" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn local_file_open_rejects_root_escape() { + let root = temp_root("mnote-local-file-open-root-escape"); + std::fs::write(root.join("README.txt"), "hello").expect("write file"); + init_workspace(&root); + let root_uri = format!("file://{}", root.display()); + let mut headers = HeaderMap::new(); + headers.insert("x-mnote-actor-id", "user_test".parse().unwrap()); + headers.insert("x-mnote-actor-type", "user".parse().unwrap()); + let context = RequestContext::from_http_parts( + &Method::GET, + &"/api/local-folder/files/open".parse().expect("uri"), + &headers, + ); + let query = LocalFileOpenQuery { + root_uri, + path: "../escape.txt".into(), + download: None, + }; + + let error = open_local_file(Extension(context), Query(query)) + .await + .expect_err("should reject root escape"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert!(error.message().contains("root")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn local_file_open_allows_read_grant() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-file-open-read-grant-root"); + let policy_root = temp_root("mnote-local-file-open-read-grant-config"); + let policy_file = policy_root.join("access-policy.json"); + let root_uri = format!("file://{}", root.display()); + std::fs::write(root.join("README.txt"), "hello").expect("write file"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + std::fs::write( + &policy_file, + serde_json::json!({ + "grants": [ + { + "userId": "user_reader", + "rootUri": root_uri, + "permission": "read", + "recursive": true + } + ] + }) + .to_string(), + ) + .expect("write policy"); + + let mut headers = HeaderMap::new(); + headers.insert("x-mnote-actor-id", "user_reader".parse().unwrap()); + headers.insert("x-mnote-actor-type", "user".parse().unwrap()); + let context = RequestContext::from_http_parts( + &Method::GET, + &"/api/local-folder/files/open".parse().expect("uri"), + &headers, + ); + + let (_, _, bytes) = open_local_file( + Extension(context), + Query(LocalFileOpenQuery { + root_uri, + path: "README.txt".into(), + download: None, + }), + ) + .await + .expect("read grant can open local file"); + assert_eq!(bytes, b"hello"); + + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&policy_root); + } } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index eda22061..44b8e7a6 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -29,6 +29,11 @@ mod tree; pub(crate) mod web_shell; mod ws; +pub(crate) use local_folder_source::{ + ensure_local_path_read_access, ensure_local_workspace_access, update_local_markdown_title, + update_local_page_options, write_local_markdown_page_body, +}; + use crate::app::AppState; use axum::routing::{any, delete, get, post, put}; use axum::Router; @@ -42,6 +47,10 @@ pub fn build_router(state: AppState) -> Router { .route("/", get(gateway::root_entry)) .route("/trash", get(gateway::trash_entry)) .route("/favicon.ico", get(gateway::favicon)) + .route( + "/admin/access-policy", + get(gateway::admin_access_policy_entry), + ) .route("/auth", get(gateway::auth_entry).post(gateway::auth_entry)) .route("/search", get(search::shell)) .route( @@ -88,6 +97,22 @@ pub fn build_router(state: AppState) -> Router { .route("/api/auth/whoami", get(session::session)) .route("/api/auth/mnote-web-token", get(session::session)) .route("/api/auth/session/refresh", post(session::refresh_session)) + .route( + "/api/admin/access-policy", + get(local_folder_source::get_local_access_policy), + ) + .route( + "/api/admin/access-policy/validate-root", + post(local_folder_source::validate_local_access_root), + ) + .route( + "/api/admin/access-policy/grants", + post(local_folder_source::create_local_access_grant), + ) + .route( + "/api/admin/access-policy/grants/{grant_id}", + delete(local_folder_source::delete_local_access_grant), + ) .route("/api/ai-agent/run", post(compat::next_ai_agent_run)) .route( "/api/page-ai/block-edit-workflow", @@ -134,6 +159,7 @@ pub fn build_router(state: AppState) -> Router { .route("/api/documents/title", post(documents::title)) .route("/api/documents/options", post(documents::options)) .route("/api/documents/save", post(documents::save)) + .route("/api/page-body/write", post(documents::page_body_write)) .route( "/api/documents/runtime/transform", post(editor::transform_runtime_snapshot), @@ -160,6 +186,18 @@ pub fn build_router(state: AppState) -> Router { "/api/local-folder/events", get(local_folder_events::local_folder_events), ) + .route( + "/api/local-folder/workspaces/default", + post(local_folder_source::create_default_local_workspace), + ) + .route( + "/api/local-folder/assets/upload", + post(local_folder_source::upload_local_markdown_asset), + ) + .route( + "/api/local-folder/files/open", + get(local_folder_source::open_local_file), + ) .route( "/api/tree/runtime/reduce", post(tree::reduce_tree_shell_runtime), diff --git a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs index 92d509dd..8ef9e883 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs @@ -67,7 +67,8 @@ pub async fn block_edit_workflow( // 退役 direct_block_edit_operations:不再走正则抠「」的本地快路径。 // 所有块编辑请求统一走模型 → search/replace 对 → doc_markdown_edit。 let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?; - let markdown_operations = extract_markdown_operations_from_model_text(&model_output)?; + let markdown_plan = extract_markdown_plan_from_model_text(&model_output)?; + let markdown_operations = markdown_plan.operations.clone(); info!( trace_id = %trace_id, run_id = %run_id, @@ -116,6 +117,8 @@ pub async fn block_edit_workflow( tool_name: "mnote.doc.markdown_edit".into(), workspace_id: Some(workspace_id.clone()), document_id: Some(document_id.clone()), + source_kind: None, + root_uri: None, actor_id: Some(actor_id), profile: Some(profile), session_id: Some(session_id), @@ -153,7 +156,9 @@ pub async fn block_edit_workflow( "operations": markdown_operations, "applyResult": apply_result, "toolExecution": tool_response, - "message": "已通过页面 markdown 编辑快路径完成写入。", + "message": markdown_plan + .summary + .unwrap_or_else(|| "已通过页面 markdown 编辑快路径完成写入。".into()), "timingsMs": { "total": started.elapsed().as_millis(), "apply": apply_ms @@ -162,21 +167,35 @@ pub async fn block_edit_workflow( )) } -fn extract_markdown_operations_from_model_text(text: &str) -> Result, WebError> { +struct MarkdownEditPlan { + operations: Vec, + summary: Option, +} + +fn extract_markdown_plan_from_model_text(text: &str) -> Result { let parsed = parse_model_json(text)?; if let Some(content) = parsed .pointer("/choices/0/message/content") .and_then(Value::as_str) { - return extract_markdown_operations_from_model_text(content); + return extract_markdown_plan_from_model_text(content); } + let summary = parsed + .get("summary") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); if let Some(operations) = parsed.get("operations").and_then(Value::as_array) { // 新格式:直接是 search/replace 对 if operations .iter() .any(|op| op.get("search").is_some() || op.get("replace").is_some()) { - return Ok(operations.clone()); + return Ok(MarkdownEditPlan { + operations: operations.clone(), + summary, + }); } // 旧格式(block ops):转换为 search/replace 对 let converted: Vec = operations @@ -204,7 +223,10 @@ fn extract_markdown_operations_from_model_text(text: &str) -> Result, }) .collect(); if !converted.is_empty() { - return Ok(converted); + return Ok(MarkdownEditPlan { + operations: converted, + summary, + }); } } Err(WebError::bad_request_code( @@ -330,7 +352,7 @@ async fn call_block_edit_model( "messages": [ { "role": "system", - "content": "你是 mnote 页面编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。禁止输出解释文字。\n\n示例:用户说\"把第一段改成你好\",若 page_text 第一段是\"旧内容\",则输出:{\"operations\":[{\"search\":\"旧内容\",\"replace\":\"你好\"}],\"summary\":\"替换了第一段\"}" + "content": "你是 mnote 页面编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。summary 要简短回答用户的读取/检查要求和写入结果;如果用户要求读取某段,summary 必须包含你从 page_text 读取到的原文。禁止输出解释文字。\n\n示例:用户说\"检查第一段并把第二段改成测试123\",若 page_text 第一段是\"第一段\",则输出:{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}" }, { "role": "user", @@ -684,6 +706,28 @@ mod tests { format!("http://{addr}") } + async fn spawn_read_and_edit_mock_model_server() -> String { + async fn completions() -> Json { + Json(json!({ + "choices": [{ + "message": { + "content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}" + } + }] + })) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind read-and-edit mock model"); + let addr = listener.local_addr().expect("mock model addr"); + let server = Router::new().route("/chat/completions", post(completions)); + tokio::spawn(async move { + let _ = axum::serve(listener, server).await; + }); + format!("http://{addr}") + } + #[test] fn extracts_operations_from_fenced_model_json() { let operations = extract_operations_from_model_text( @@ -846,4 +890,78 @@ mod tests { std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); } + + #[tokio::test] + async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() { + let _guard = env_lock().lock().expect("env lock"); + let base_url = spawn_read_and_edit_mock_model_server().await; + let hermes_home = std::env::temp_dir().join(format!( + "mnote-page-ai-workflow-read-summary-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&hermes_home); + let profile_dir = hermes_home.join("profiles").join("mnoteai"); + fs::create_dir_all(&profile_dir).expect("profile dir"); + fs::write( + profile_dir.join("config.yaml"), + format!( + "model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n" + ), + ) + .expect("profile config"); + std::env::set_var("HERMES_HOME", &hermes_home); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-ai/block-edit-workflow") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "workspaceId": "ws_demo", + "documentId": "doc_1", + "message": "检查你是否能读取到本页第一段,同时请修改第二段为:测试123", + "profile": "mnoteai", + "sessionId": "sess_page_ai_read_summary", + "runId": "run_page_ai_read_summary", + "traceId": "trace_page_ai_read_summary", + "pageContext": { + "aiContext": { + "schema": "mnote.page_ai_context.v1", + "pageText": "第一段\n\n第二段", + "pageXml": "第一段第二段", + "contextBlocks": [ + {"blockId": "p_1", "text": "第一段"}, + {"blockId": "p_2", "text": "第二段"} + ] + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["ok"], true); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("已读取第一段:第一段")); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("测试123")); + + std::env::remove_var("HERMES_HOME"); + let _ = fs::remove_dir_all(&hermes_home); + } } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index a99e199e..4b6b97be 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -6,9 +6,9 @@ use crate::routes::command_support::{ execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, }; use crate::routes::local_folder_source::{ - execute_local_tree_command, load_local_folder_file_tree_snapshot, - load_local_folder_page_tree_snapshot, local_folder_watch_revision, - local_workspace_id_from_root_uri, + ensure_local_workspace_access, ensure_local_workspace_read_access, execute_local_tree_command, + load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, + local_folder_watch_revision, local_workspace_id_from_root_uri, }; use crate::routes::query_support::{ fetch_documents_meta_via_convex, resolve_effective_workspace_id, @@ -6122,6 +6122,8 @@ pub async fn local_folder_watch( Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { + ensure_local_workspace_read_access(&context, &query.root_uri) + .map_err(|error| error.with_context(&context))?; let revision = local_folder_watch_revision(&query.root_uri)?; Ok(json_response( &context, @@ -6218,6 +6220,8 @@ pub async fn tree_shell( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; + ensure_local_workspace_read_access(&effective_context, root_uri) + .map_err(|error| error.with_context(&effective_context))?; ( local_workspace_id_from_root_uri(root_uri)?, if mode == "filetree" { @@ -6839,6 +6843,8 @@ pub async fn tree_command( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; + ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; let execution = execute_local_tree_command( root_uri, action, @@ -6959,7 +6965,7 @@ mod tests { use crate::context::RequestContext; use crate::routes::command_support::build_runtime_command_plan; use axum::body::Body; - use axum::http::{HeaderMap, Method, Request, StatusCode, Uri}; + use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use serde_json::Value; use tower::util::ServiceExt; @@ -6984,6 +6990,30 @@ mod tests { dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) + .layer(axum::middleware::from_fn(inject_test_actor)) + } + + async fn inject_test_actor( + mut request: axum::extract::Request, + next: axum::middleware::Next, + ) -> axum::response::Response { + request + .headers_mut() + .entry("x-mnote-actor-id") + .or_insert(HeaderValue::from_static("user_test")); + request + .headers_mut() + .entry("x-mnote-actor-type") + .or_insert(HeaderValue::from_static("user")); + next.run(request).await + } + + fn init_local_workspace(root: &std::path::Path, actor_id: &str) { + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + actor_id, + &format!("file://{}", root.display()), + ) + .expect("init local workspace"); } #[test] @@ -7196,12 +7226,15 @@ mod tests { std::fs::write(root.join("image.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}" )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) @@ -7236,12 +7269,15 @@ mod tests { std::fs::write(root.join("asset.txt"), "asset").expect("write local asset"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}" )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) @@ -7277,12 +7313,15 @@ mod tests { std::fs::write(root.join("image.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/tree?mode=page&sourceKind=local_folder&rootUri={root_uri}" )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) @@ -7311,6 +7350,7 @@ mod tests { let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let create_response = app() .oneshot( @@ -7498,6 +7538,7 @@ mod tests { std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let asset_id = "local:asset:docs/photo.png"; let delete_response = app() @@ -7608,6 +7649,7 @@ mod tests { let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let response = app() .oneshot( @@ -7653,6 +7695,45 @@ mod tests { ); } + #[tokio::test] + async fn tree_command_local_folder_rejects_non_owner_root() { + let root = std::env::temp_dir().join(format!( + "mnote-local-tree-owner-denied-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create local root"); + init_local_workspace(&root, "owner_user"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "other_user") + .header("x-mnote-actor-type", "user") + .body(Body::from(format!( + r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"local-md:README.md"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("error json"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(payload["ok"], false); + assert_eq!(payload["code"], "local_workspace_access_denied"); + } + #[tokio::test] async fn tree_shell_embeds_renderer_input_contract() { let filetree_response = app() diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 209aa642..85ed886a 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -7,6 +7,7 @@ use crate::routes::documents::{ DocumentMetaQuery, }; use crate::routes::local_folder_source::{ + ensure_local_workspace_read_access, is_local_access_policy_admin_context, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate, }; @@ -200,6 +201,7 @@ pub async fn document_page_shell( secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()} secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()} secondary_page_options_json={secondary_page_options_json.unwrap_or_default()} + show_admin_access_policy={is_local_access_policy_admin_context(&context)} /> }); let hermes_settings_config_script = render_hermes_settings_config_script(); @@ -327,21 +329,27 @@ pub(crate) fn build_editor_bootstrap_json_with_ids( page_aggregate_script_id: &str, pane_role: &str, ) -> String { + let normalized_source_kind = source_kind + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("convex_workspace"); + let save_endpoint = if normalized_source_kind == "local_folder" { + "/api/page-body/write" + } else { + "/api/documents/save" + }; serde_json::to_string(&json!({ "schema": "mnote.editor_bootstrap.v1", "documentId": aggregate.identity.document_id, "workspaceId": aggregate.identity.workspace_id, "paneRole": pane_role, - "sourceKind": source_kind - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("convex_workspace"), + "sourceKind": normalized_source_kind, "rootUri": root_uri .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(""), "pageAggregateScriptId": page_aggregate_script_id, - "saveEndpoint": "/api/documents/save", + "saveEndpoint": save_endpoint, "titleEndpoint": "/api/documents/title", "editorHostKind": "leptos_tiptap_island", "assetMode": "rust-web-leptos-tiptap-spike-island-bundle", @@ -1023,20 +1031,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const text = typeof child.text === 'string' ? child.text : ''; if (!text) return []; const styles = {}; + const marks = []; for (const mark of Array.isArray(child.marks) ? child.marks : []) { - if (mark?.type === 'bold') styles.bold = true; - if (mark?.type === 'italic') styles.italic = true; - if (mark?.type === 'underline') styles.underline = true; - if (mark?.type === 'strike') styles.strike = true; - if (mark?.type === 'code') styles.code = true; + if (mark?.type === 'bold') { + styles.bold = true; + marks.push('bold'); + } + if (mark?.type === 'italic') { + styles.italic = true; + marks.push('italic'); + } + if (mark?.type === 'underline') { + styles.underline = true; + marks.push('underline'); + } + if (mark?.type === 'strike') { + styles.strike = true; + marks.push('strike'); + } + if (mark?.type === 'code') { + styles.code = true; + marks.push('code'); + } if (mark?.type === 'link') { const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; if (href) styles.link = href; } } - return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }]; + return [{ + payload: { type: 'text', text, ...(marks.length ? { marks } : {}) }, + attrs: Object.keys(styles).length ? { styles } : {}, + type: 'text', + text, + ...(Object.keys(styles).length ? { styles } : {}), + }]; } - if (child?.type === 'hardBreak') return [{ type: 'text', text: '\n' }]; + if (child?.type === 'hardBreak') return [{ payload: { type: 'hard_break' }, attrs: {}, type: 'text', text: '\n' }]; return inlineTextNodes(child); }); } @@ -1133,9 +1163,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { : Array.isArray(block.contentNodes) ? block.contentNodes.map((node) => { if (!node || typeof node !== 'object') return null; - const text = typeof node.text === 'string' ? node.text : ''; + const payload = node.payload && typeof node.payload === 'object' ? node.payload : {}; + const text = typeof payload.text === 'string' + ? payload.text + : payload.type === 'hard_break' + ? '\n' + : typeof node.text === 'string' + ? node.text + : ''; if (!text) return null; - return { type: 'text', text, ...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}) }; + const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}; + const styles = attrs.styles && typeof attrs.styles === 'object' + ? attrs.styles + : node.styles && typeof node.styles === 'object' + ? node.styles + : null; + const marks = Array.isArray(payload.marks) ? payload.marks : []; + return { + type: 'text', + text, + ...(styles ? { styles } : {}), + ...(marks.length ? { marks } : {}), + }; }).filter(Boolean) : '', })); @@ -1144,7 +1193,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { ? body.conflictDetectionKey : typeof body?.conflict_detection_key === 'string' ? body.conflict_detection_key - : null; + : typeof body?.fileVersion === 'string' + ? body.fileVersion + : typeof body?.file_version === 'string' + ? body.file_version + : null; const revisionFromConflictKey = (value) => { const match = String(value || '').match(/:(\d+)$/); @@ -1206,7 +1259,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { sourceKind: descriptor.sourceKind || 'convex_workspace', rootUri: descriptor.rootUri || '', pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__', - saveEndpoint: '/api/documents/save', + saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder' + ? '/api/page-body/write' + : '/api/documents/save', titleEndpoint: '/api/documents/title', editorHostKind: 'leptos_tiptap_island', }); @@ -1469,7 +1524,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }); }; - const sessionPlainText = (session) => flattenText(session.currentTiptapDocument).replace(/\s+/g, ' ').trim(); + const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + + const sessionPlainText = (session) => { + const liveText = sessionViews(session) + .map((view) => normalizePlainText(currentEditorText(view))) + .find((text) => text); + if (liveText) return liveText; + return normalizePlainText(flattenText(session.currentTiptapDocument)); + }; const sessionHasRecentExternalSignal = (session) => ( session.sourceKind === 'local_folder' @@ -1484,6 +1547,196 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { && (Date.now() - session.lastUserInputAt) < 1500 ); + const fetchLatestSessionAggregate = async (session) => { + const response = await fetch(pageAggregateUrl({ + documentId: session.documentId, + sourceKind: session.sourceKind, + workspaceId: session.workspaceId, + rootUri: session.rootUri, + }).toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status); + const payload = await response.json(); + const nextAggregate = payload?.result; + if (!nextAggregate || typeof nextAggregate !== 'object') { + throw new Error('conflict_latest_missing_aggregate'); + } + return nextAggregate; + }; + + const aggregatePlainText = (aggregate) => { + const body = aggregate?.body || {}; + return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim(); + }; + + const clearSessionConflictSurface = (session) => { + sessionViews(session).forEach((view) => { + const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; + if (!(host instanceof HTMLElement)) return; + host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove()); + }); + }; + + const applyAggregateSnapshotToSession = (session, nextAggregate, source) => { + const nextBody = nextAggregate?.body || {}; + const nextPermissions = nextAggregate?.head?.permissions || {}; + const nextConflictKey = conflictDetectionKeyFromBody(nextBody); + const nextTiptapDocument = toTiptapDocument(nextBody.content); + const nextSerialized = JSON.stringify(nextTiptapDocument); + const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); + session.latestAggregate = nextAggregate; + syncPageAggregateScript(session, nextAggregate); + session.title = nextAggregate?.head?.title || session.title; + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = nextSerialized; + session.revision = nextRevision; + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey || ''; + session.readOnly = Boolean(nextPermissions.readOnly); + session.dirty = false; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved'); + }); + setSessionStatus(session, 'synced-external-change'); + }; + + const openConflictDiffPanel = async (session, panel) => { + const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]'); + if (!(diffPanel instanceof HTMLElement)) return; + diffPanel.hidden = false; + diffPanel.replaceChildren(); + const loading = document.createElement('div'); + loading.className = 'mnote-conflict-diff-status'; + loading.textContent = '正在读取磁盘版本...'; + diffPanel.appendChild(loading); + try { + const latest = await fetchLatestSessionAggregate(session); + diffPanel.replaceChildren(); + const current = document.createElement('pre'); + current.setAttribute('data-testid', 'mnote-conflict-current-text'); + current.textContent = sessionPlainText(session) || '(当前编辑器为空)'; + const disk = document.createElement('pre'); + disk.setAttribute('data-testid', 'mnote-conflict-disk-text'); + disk.textContent = aggregatePlainText(latest) || '(磁盘版本为空)'; + const currentTitle = document.createElement('h3'); + currentTitle.textContent = '当前编辑器版本'; + const diskTitle = document.createElement('h3'); + diskTitle.textContent = '磁盘版本'; + const currentBox = document.createElement('section'); + currentBox.append(currentTitle, current); + const diskBox = document.createElement('section'); + diskBox.append(diskTitle, disk); + diffPanel.append(currentBox, diskBox); + } catch (error) { + loading.textContent = error instanceof Error ? error.message : String(error); + diffPanel.replaceChildren(loading); + } + }; + + const acceptDiskVersion = async (session) => { + setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...'); + const latest = await fetchLatestSessionAggregate(session); + applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk'); + }; + + const keepCurrentEditorVersion = async (session) => { + setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...'); + const latest = await fetchLatestSessionAggregate(session); + const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; + if (hydrateView) { + const liveText = normalizePlainText(currentEditorText(hydrateView)); + if (liveText) { + session.currentTiptapDocument = hydrateMindmapAttrsFromDom( + textToTiptapDocument(liveText), + hydrateView.runtimeDescriptor.root, + ); + } + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + } + const nextKey = conflictDetectionKeyFromBody(latest.body || {}); + if (nextKey) { + session.conflictDetectionKey = nextKey; + session.lastExternalConflictDetectionKey = nextKey; + } + session.hasExternalConflict = false; + session.externalChangePending = false; + session.saving = false; + session.dirty = true; + clearSessionConflictSurface(session); + await persistSession(session); + }; + + const renderSessionConflictSurface = (session, message) => { + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; + if (!(host instanceof HTMLElement)) return; + const panel = document.createElement('section'); + panel.className = 'mnote-editor-conflict-panel'; + panel.setAttribute('data-testid', 'mnote-editor-conflict-panel'); + panel.setAttribute('role', 'status'); + panel.setAttribute('aria-live', 'polite'); + + const heading = document.createElement('h2'); + heading.textContent = '文件冲突'; + const text = document.createElement('p'); + text.textContent = message || externalConflictMessage; + const meta = document.createElement('div'); + meta.className = 'mnote-conflict-meta'; + meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`; + const actions = document.createElement('div'); + actions.className = 'mnote-conflict-actions'; + const acceptDisk = document.createElement('button'); + acceptDisk.type = 'button'; + acceptDisk.textContent = '接受磁盘版本'; + acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk'); + const keepCurrent = document.createElement('button'); + keepCurrent.type = 'button'; + keepCurrent.textContent = '保留当前编辑器版本'; + keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current'); + const openDiff = document.createElement('button'); + openDiff.type = 'button'; + openDiff.textContent = '打开 diff'; + openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff'); + actions.append(acceptDisk, keepCurrent, openDiff); + const diffPanel = document.createElement('div'); + diffPanel.className = 'mnote-conflict-diff-panel'; + diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel'); + diffPanel.hidden = true; + panel.append(heading, text, meta, actions, diffPanel); + + acceptDisk.addEventListener('click', () => { + acceptDiskVersion(session).catch((error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }); + }); + keepCurrent.addEventListener('click', () => { + keepCurrentEditorVersion(session).catch((error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }); + }); + openDiff.addEventListener('click', () => { + openConflictDiffPanel(session, panel); + }); + + const header = host.querySelector('.document-shell-header'); + if (header && header.parentNode) { + header.parentNode.insertBefore(panel, header.nextSibling); + } else { + host.prepend(panel); + } + }); + }; + const markSessionExternalConflict = (session, message) => { session.externalChangePending = false; session.hasExternalConflict = true; @@ -1492,6 +1745,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { session.saveTimer = 0; } setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage); + renderSessionConflictSurface(session, message || externalConflictMessage); }; const queueSessionSave = (session) => { @@ -1521,21 +1775,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { try { const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument); const content = legacyBlocksFromEditorDocument(editorDocument); - const response = await fetch(session.saveEndpoint || '/api/documents/save', { + const saveEndpoint = session.saveEndpoint || '/api/documents/save'; + const savePayload = { + documentId: session.documentId, + workspaceId: session.workspaceId, + sourceKind: session.sourceKind, + rootUri: session.rootUri, + revision: session.revision, + expectedFileVersion: session.conflictDetectionKey, + contentFormat: 'editorBlocks', + editorSource: 'tiptap', + editorDocument, + content, + tiptapDocument: session.currentTiptapDocument, + blockCount: editorDocument.blocks.length, + }; + if (session.sourceKind !== 'local_folder') { + savePayload.conflictDetectionKey = session.conflictDetectionKey; + } + const response = await fetch(saveEndpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - documentId: session.documentId, - workspaceId: session.workspaceId, - sourceKind: session.sourceKind, - rootUri: session.rootUri, - revision: session.revision, - conflictDetectionKey: session.conflictDetectionKey, - editorDocument, - content, - tiptapDocument: session.currentTiptapDocument, - blockCount: editorDocument.blocks.length, - }), + body: JSON.stringify(savePayload), }); const result = await response.json().catch(() => null); if (!response.ok || !result || result.ok !== true) { @@ -1550,6 +1811,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) { session.conflictDetectionKey = saved.conflictDetectionKey.trim(); } + if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) { + session.conflictDetectionKey = saved.fileVersion.trim(); + } if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey; session.hasExternalConflict = false; session.externalChangePending = false; @@ -1912,6 +2176,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { lastPersistedSerialized: JSON.stringify(tiptapDocument), revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision, conflictDetectionKey, + fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey, lastExternalConflictDetectionKey: conflictDetectionKey || '', readOnly: Boolean(permissions.readOnly), dirty: false, @@ -2633,6 +2898,8 @@ pub(crate) async fn build_page_aggregate_snapshot( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; + ensure_local_workspace_read_access(context, root_uri) + .map_err(|error| error.with_context(context))?; return resolve_local_markdown_page_aggregate(root_uri, document_id); } @@ -2937,7 +3204,7 @@ mod tests { use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; use axum::body::{to_bytes, Body}; - use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri}; + use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use serde_json::Value; use tower::util::ServiceExt; @@ -2970,6 +3237,19 @@ mod tests { }, "documents:getContent": { "content": [{"id": "block_1", "type": "paragraph", "content": []}], + "editorDocument": { + "documentId": "doc_1", + "rootBlockIds": ["editor_1"], + "blocks": [{ + "blockId": "editor_1", + "blockType": "paragraph", + "contentNodes": [{ + "payload": {"type": "text", "text": "来自 editorDocument 的正文"}, + "attrs": {} + }], + "childBlockIds": [] + }] + }, "revision": 7, "conflict_detection_key": "doc_1:7", "pageSubtree": {"rootNodeId": "doc_1", "outline": []} @@ -2982,6 +3262,30 @@ mod tests { dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) + .layer(axum::middleware::from_fn(inject_test_actor)) + } + + async fn inject_test_actor( + mut request: axum::extract::Request, + next: axum::middleware::Next, + ) -> axum::response::Response { + request + .headers_mut() + .entry("x-mnote-actor-id") + .or_insert(HeaderValue::from_static("user_test")); + request + .headers_mut() + .entry("x-mnote-actor-type") + .or_insert(HeaderValue::from_static("user")); + next.run(request).await + } + + fn init_local_workspace(root: &std::path::Path, actor_id: &str) { + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + actor_id, + &format!("file://{}", root.display()), + ) + .expect("init local workspace"); } fn app_with_unreachable_convex_without_fixture() -> axum::Router { @@ -3137,6 +3441,18 @@ mod tests { assert_eq!(payload["result"]["projectionVersion"], 1); assert_eq!(payload["result"]["identity"]["documentId"], "doc_1"); assert_eq!(payload["result"]["body"]["revision"], 7); + assert_eq!( + payload["result"]["body"]["projectionSource"], + "editorDocument" + ); + assert_eq!( + payload["result"]["body"]["blockDocument"]["rootBlockIds"][0], + "editor_1" + ); + assert_eq!( + payload["result"]["body"]["blockDocument"]["blocks"][0]["text"], + "来自 editorDocument 的正文" + ); } #[tokio::test] @@ -3246,12 +3562,15 @@ mod tests { .expect("write local md"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}" )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) @@ -3297,12 +3616,15 @@ mod tests { std::fs::write(root.join("asset.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree" )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) @@ -3325,6 +3647,7 @@ mod tests { let html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("Local Shell")); assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\"")); + assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\"")); assert!(html.contains("Child Page")); assert!(html.contains("asset.png")); assert!(html.contains("data-row-kind=\"markdown\"")); @@ -3349,6 +3672,10 @@ mod tests { assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')")); assert!(html.contains("command: 'replaceContent'")); assert!(html.contains("external-change-conflict")); + assert!(html.contains("mnote-editor-conflict-panel")); + assert!(html.contains("mnote-conflict-accept-disk")); + assert!(html.contains("mnote-conflict-keep-current")); + assert!(html.contains("mnote-conflict-open-diff")); assert!(!html.contains( "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" )); @@ -3420,12 +3747,15 @@ mod tests { .expect("write local md"); let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); let response = app() .oneshot( Request::builder() .uri(format!( "/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}" )) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) @@ -3495,6 +3825,11 @@ mod tests { assert!(html.contains("marks.push({ type: 'link', attrs: { href } })")); assert!(html.contains("styles.link = href")); assert!(html.contains("contentNodes.map((node) => {")); + assert!(html.contains("payload: { type: 'text', text")); + assert!(html.contains("typeof payload.text === 'string'")); + assert!(html.contains("payload.type === 'hard_break'")); + assert!(html.contains("typeof body?.fileVersion === 'string'")); + assert!(html.contains("expectedFileVersion: session.conflictDetectionKey")); assert!(html.contains("node?.attrs?.mnoteBlockType === 'mindmap'")); assert!(html.contains("blockType: 'mindmap'")); assert!(html.contains("props: mindmapPropsFromAttrs(node?.attrs, blockId)")); diff --git a/rust/crates/mnote-web/src/ssr/pages/admin.rs b/rust/crates/mnote-web/src/ssr/pages/admin.rs new file mode 100644 index 00000000..6d5aa730 --- /dev/null +++ b/rust/crates/mnote-web/src/ssr/pages/admin.rs @@ -0,0 +1,246 @@ +//! MNOTE 管理员目录授权页面组件 + +use crate::ssr::pages::layout::PageLayout; +use leptos::prelude::*; + +#[component] +pub fn AdminAccessPolicyPage( + #[prop(optional)] workspace_name: Option, + #[prop(optional)] policy_path: Option, +) -> impl IntoView { + let workspace_name = workspace_name + .unwrap_or_else(|| "开发用户 的空间".to_string()) + .trim() + .to_string(); + let policy_path = policy_path + .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string()); + view! { + +
+
+

"目录授权"

+

"管理员可以查看、验证和管理本地目录授权,普通用户不会看到入口。"

+
+ +
+
+ "工作区" + {workspace_name.clone()} +
+
+ "策略文件" + {policy_path.clone()} +
+
+ +
+
+

"当前策略"

+ +
+
{""}
+
+
+ +
+
+

"验证目录"

+ + + +

+                    
+ +
+

"新增授权"

+ + + + + + + + +

+                    
+ +
+

"删除授权"

+ + +

+                    
+
+ +
+
+ } +} + +const ADMIN_POLICY_SCRIPT: &str = r#" +(function () { + var root = document.querySelector('[data-testid="mnote-admin-access-policy-page"]'); + if (!root) return; + var message = root.querySelector('[data-testid="mnote-admin-policy-message"]'); + var policyJson = root.querySelector('[data-testid="mnote-admin-policy-json"]'); + var validateResult = root.querySelector('[data-testid="mnote-admin-validate-result"]'); + var createResult = root.querySelector('[data-testid="mnote-admin-create-result"]'); + var deleteResult = root.querySelector('[data-testid="mnote-admin-delete-result"]'); + var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]'); + + function setText(node, value) { + if (!node) return; + node.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + } + + function formValues(form) { + var data = new FormData(form); + var capabilities = String(data.get('capabilities') || '') + .split(/[,\s]+/) + .map(function (item) { return item.trim(); }) + .filter(Boolean); + return { + id: String(data.get('grantId') || '').trim(), + userId: String(data.get('userId') || '').trim(), + rootUri: String(data.get('rootUri') || '').trim(), + rootPath: String(data.get('rootPath') || '').trim(), + permission: String(data.get('permission') || 'read').trim(), + recursive: data.get('recursive') === 'on' || data.get('recursive') === 'true', + capabilities: capabilities, + }; + } + + async function requestJson(url, options) { + var response = await fetch(url, { + credentials: 'include', + headers: { 'content-type': 'application/json' }, + ...options, + }); + var payload = await response.json().catch(function () { return {}; }); + if (!response.ok || payload.ok === false) { + throw new Error((payload && payload.message) || ('请求失败: ' + response.status)); + } + return payload; + } + + async function refreshPolicy() { + var payload = await requestJson('/api/admin/access-policy', { method: 'GET', headers: {} }); + setText(policyJson, payload); + setText(message, '已刷新策略'); + } + + refreshButton && refreshButton.addEventListener('click', function () { + setText(message, '正在刷新策略...'); + refreshPolicy().catch(function (error) { setText(message, error.message || '刷新失败'); }); + }); + + root.querySelector('[data-admin-form="validate-root"]').addEventListener('submit', function (event) { + event.preventDefault(); + var values = formValues(event.currentTarget); + setText(validateResult, '正在验证...'); + requestJson('/api/admin/access-policy/validate-root', { + method: 'POST', + body: JSON.stringify({ rootUri: values.rootUri, rootPath: values.rootPath }), + }).then(function (payload) { + setText(validateResult, payload); + setText(message, '目录验证完成'); + }).catch(function (error) { + setText(validateResult, { ok: false, error: error.message || '验证失败' }); + setText(message, error.message || '验证失败'); + }); + }); + + root.querySelector('[data-admin-form="create-grant"]').addEventListener('submit', function (event) { + event.preventDefault(); + var values = formValues(event.currentTarget); + setText(createResult, '正在创建...'); + requestJson('/api/admin/access-policy/grants', { + method: 'POST', + body: JSON.stringify(values), + }).then(function (payload) { + setText(createResult, payload); + setText(message, '授权已创建'); + return refreshPolicy(); + }).catch(function (error) { + setText(createResult, { ok: false, error: error.message || '创建失败' }); + setText(message, error.message || '创建失败'); + }); + }); + + root.querySelector('[data-admin-form="delete-grant"]').addEventListener('submit', function (event) { + event.preventDefault(); + var values = formValues(event.currentTarget); + var grantId = values.id; + setText(deleteResult, '正在删除...'); + requestJson('/api/admin/access-policy/grants/' + encodeURIComponent(grantId), { + method: 'DELETE', + headers: {}, + }).then(function (payload) { + setText(deleteResult, payload); + setText(message, '授权已删除'); + return refreshPolicy(); + }).catch(function (error) { + setText(deleteResult, { ok: false, error: error.message || '删除失败' }); + setText(message, error.message || '删除失败'); + }); + }); + + refreshPolicy().catch(function (error) { + setText(message, error.message || '加载策略失败'); + }); +})(); +"#; + +#[cfg(test)] +mod tests { + use super::*; + use crate::ssr::render_view; + + #[test] + fn admin_access_policy_page_renders_admin_controls() { + let html = render_view(view! { + + }); + assert!(html.contains("mnote-admin-access-policy-page")); + assert!(html.contains("mnote-admin-policy-json")); + assert!(html.contains("mnote-admin-validate-root-submit")); + assert!(html.contains("mnote-admin-create-grant-submit")); + assert!(html.contains("mnote-admin-delete-grant-submit")); + } +} diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs index 7a73c196..47f1198d 100644 --- a/rust/crates/mnote-web/src/ssr/pages/document.rs +++ b/rust/crates/mnote-web/src/ssr/pages/document.rs @@ -188,6 +188,9 @@ pub fn DocumentPage( /// 右侧页面选项 JSON(可选) #[prop(optional)] secondary_page_options_json: String, + /// 是否显示管理员授权入口 + #[prop(optional)] + show_admin_access_policy: bool, ) -> impl IntoView { let has_page_subtree = page_subtree_json .as_deref() @@ -285,7 +288,7 @@ pub fn DocumentPage( visible: secondary_visible, }; view! { - +
, + /// 是否显示管理员授权入口 + #[prop(optional)] + show_admin_access_policy: bool, ) -> impl IntoView { let active_page_id = active_page_id.unwrap_or_default(); let active_page_title = active_page_title @@ -40,7 +43,7 @@ pub fn HomePage( .map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}")) .unwrap_or_else(|| format!("/documents/{active_page_id}")); view! { - + {move || if has_active_page { view! {
@@ -61,7 +64,19 @@ pub fn HomePage( view! {

"暂无页面"

-

"当前工作区还没有可显示的页面。"

+

"当前还没有可显示的本地工作区。"

+ + + {if show_admin_access_policy { + view! { + + + + }.into_any() + } else { + view! {}.into_any() + }}
@@ -7987,6 +8236,9 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("objectIdentity: objectIdentity")); assert!(SIDEBAR_TREE_JS.contains("workspaceId: resolveWorkspaceId(fileRow)")); assert!(SIDEBAR_TREE_JS.contains("/api/media/sign?assetId=")); + assert!(SIDEBAR_TREE_JS.contains("function localFilePathFromAssetId")); + assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/files/open")); + assert!(SIDEBAR_TREE_JS.contains("local-file:")); assert!(SIDEBAR_TREE_JS.contains("fetchCurrentOnlyOfficeUserId")); assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami")); assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl")); @@ -7998,8 +8250,11 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("tree-rename-input")); assert!(SIDEBAR_TREE_JS.contains("sidebarFileTreeClipboard")); assert!(SIDEBAR_TREE_JS.contains("pasteSidebarFileTreeClipboard")); - assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY")); + assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX")); + assert!(SIDEBAR_TREE_JS.contains("function recentLocalRootsStorageKey")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-actor-id")); assert!(SIDEBAR_TREE_JS.contains("requestOpenLocalFolder")); + assert!(SIDEBAR_TREE_JS.contains("autoOpenRecentLocalRootOnHome")); assert!(SIDEBAR_TREE_JS.contains("sourceKind', 'local_folder")); assert!(SIDEBAR_TREE_JS.contains("switchToCloudWorkspace")); assert!(SIDEBAR_TREE_JS.contains("mnote-switch-cloud-workspace")); @@ -8018,6 +8273,26 @@ mod tests { ); } + #[test] + fn page_ai_fast_path_is_not_local_first_main_path() { + assert!( + SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder') return false;"), + "local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具" + ); + } + + #[test] + fn page_ai_local_source_passes_file_reference_fields_to_agent_run() { + assert!(SIDEBAR_TREE_JS.contains("function currentRootUri()")); + assert!(SIDEBAR_TREE_JS.contains("sourceKind: currentSourceKind()")); + assert!(SIDEBAR_TREE_JS.contains("rootUri: currentRootUri()")); + assert!(SIDEBAR_TREE_JS.contains("pageContext: scopedContext.pageContext")); + assert!( + SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder') return false;"), + "local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope" + ); + } + #[test] fn page_ai_uses_backend_acp_session_runtime_store() { assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessions")); @@ -8043,6 +8318,33 @@ mod tests { ); } + #[test] + fn page_ai_session_ui_labels_local_shared_and_cloud_storage() { + assert!(SIDEBAR_TREE_JS.contains("function pageAiSessionStorageLabel")); + assert!(SIDEBAR_TREE_JS.contains("local_private")); + assert!(SIDEBAR_TREE_JS.contains("local_shared")); + assert!(SIDEBAR_TREE_JS.contains("convex_acp_runtime_store")); + assert!(SIDEBAR_TREE_JS.contains("本地私有")); + assert!(SIDEBAR_TREE_JS.contains("共享会话")); + assert!(SIDEBAR_TREE_JS.contains("云端会话")); + assert!(SIDEBAR_TREE_JS.contains("sessionStorage:")); + assert!(SIDEBAR_TREE_JS.contains("permissionLevel:")); + assert!(SIDEBAR_TREE_JS.contains("shareId:")); + } + + #[test] + fn page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch() { + assert!(SIDEBAR_TREE_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3")); + assert!(SIDEBAR_TREE_JS.contains("pageAiAcpRuntime: 'reasonix'")); + assert!(SIDEBAR_TREE_JS.contains("function pageAiNormalizeAcpRuntimes")); + assert!(SIDEBAR_TREE_JS.contains("return ['reasonix', 'hermes']")); + assert!(SIDEBAR_TREE_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)")); + assert!(SIDEBAR_TREE_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'")); + assert!(SIDEBAR_TREE_JS + .contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'")); + assert!(!SIDEBAR_TREE_JS.contains("默认 (Hermes HTTP)")); + } + #[test] fn sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector() { assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot")); @@ -8081,6 +8383,18 @@ mod tests { assert!(!SIDEBAR_TREE_JS.contains("window.location.reload")); } + #[test] + fn sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder() { + assert!(SIDEBAR_TREE_JS.contains("currentSourceKind() === 'local_folder'")); + assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/assets/upload")); + assert!(SIDEBAR_TREE_JS.contains("localForm.append('rootUri', rootUri)")); + assert!(SIDEBAR_TREE_JS.contains("localForm.append('documentId', documentId)")); + assert!(SIDEBAR_TREE_JS.contains("isLocalUploadedAsset(asset)")); + assert!(SIDEBAR_TREE_JS.contains("if (isLocalUploadedAsset(asset)) return '';")); + assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot")); + assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed")); + } + #[test] fn sidebar_tree_runtime_contains_dev_hot_reload_client() { assert!(SIDEBAR_TREE_JS.contains("installMnoteDevHotReload")); diff --git a/rust/crates/mnote-web/src/ssr/pages/mod.rs b/rust/crates/mnote-web/src/ssr/pages/mod.rs index 5dd7d7a4..db7c3624 100644 --- a/rust/crates/mnote-web/src/ssr/pages/mod.rs +++ b/rust/crates/mnote-web/src/ssr/pages/mod.rs @@ -1,5 +1,6 @@ //! SSR 页面组件 +pub mod admin; pub mod auth; pub mod document; pub mod home; diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 22d648f2..1022aea2 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -1876,6 +1876,147 @@ body { background: var(--atelier-document); } +.mnote-admin-policy-page { + width: min(1080px, calc(100vw - 64px)); + margin: 40px auto 64px; + color: var(--atelier-text); +} + +.mnote-admin-policy-header { + margin-bottom: 24px; +} + +.mnote-admin-policy-header h1 { + font-size: 28px; + font-weight: 650; + margin-bottom: 8px; +} + +.mnote-admin-policy-header p, +.mnote-admin-policy-note { + color: var(--wolai-text-secondary); + font-size: 14px; +} + +.mnote-admin-policy-summary, +.mnote-admin-policy-panel, +.mnote-admin-policy-form { + border: 1px solid var(--wolai-border); + border-radius: 8px; + background: #fff; +} + +.mnote-admin-policy-summary { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(280px, 2fr); + gap: 16px; + padding: 16px; + margin-bottom: 16px; +} + +.mnote-admin-policy-summary-item { + min-width: 0; +} + +.mnote-admin-policy-summary-label { + display: block; + margin-bottom: 6px; + color: var(--wolai-text-secondary); + font-size: 12px; +} + +.mnote-admin-policy-summary code, +.mnote-admin-policy-json { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 12px; +} + +.mnote-admin-policy-panel { + padding: 16px; + margin-bottom: 16px; +} + +.mnote-admin-policy-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.mnote-admin-policy-panel h2, +.mnote-admin-policy-form h2 { + font-size: 16px; + font-weight: 650; +} + +.mnote-admin-policy-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.mnote-admin-policy-form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; +} + +.mnote-admin-policy-form label { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--wolai-text-secondary); + font-size: 12px; +} + +.mnote-admin-policy-form input, +.mnote-admin-policy-form select { + min-height: 34px; + border: 1px solid var(--wolai-border); + border-radius: 6px; + padding: 6px 8px; + color: var(--wolai-text-primary); + background: #fff; + font-size: 13px; +} + +.mnote-admin-policy-form button, +.mnote-admin-policy-panel button { + min-height: 34px; + border: 1px solid var(--wolai-border); + border-radius: 6px; + padding: 6px 12px; + color: var(--wolai-text-primary); + background: var(--wolai-bg-sidebar); + cursor: pointer; +} + +.mnote-admin-policy-form button:hover, +.mnote-admin-policy-panel button:hover { + background: var(--wolai-bg-hover); +} + +.mnote-admin-policy-json { + min-height: 48px; + max-height: 320px; + overflow: auto; + border-radius: 6px; + background: var(--wolai-bg-sidebar); + padding: 10px; + color: var(--wolai-text-primary); +} + +@media (max-width: 960px) { + .mnote-admin-policy-summary, + .mnote-admin-policy-grid { + grid-template-columns: 1fr; + } +} + .mnote-trash-workbench { width: min(860px, calc(100vw - 64px)); margin: 52px auto; @@ -2330,6 +2471,99 @@ body { gap: 6px; } +.mnote-editor-conflict-panel { + display: flex; + flex-direction: column; + gap: 10px; + margin: -28px 0 28px; + padding: 14px 16px; + border: 1px solid #E2B86C; + border-radius: 8px; + background: #FFF8E6; + color: var(--wolai-text-primary); +} + +.mnote-editor-conflict-panel h2 { + margin: 0; + font-size: 15px; + font-weight: 650; + line-height: 22px; +} + +.mnote-editor-conflict-panel p { + margin: 0; + color: var(--wolai-text-secondary); + font-size: 13px; + line-height: 20px; +} + +.mnote-conflict-meta { + margin: 0; + color: var(--wolai-text-secondary); + font-size: 12px; + line-height: 18px; +} + +.mnote-conflict-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mnote-conflict-actions button { + min-height: 30px; + border: 1px solid rgba(27, 28, 28, 0.16); + border-radius: 6px; + padding: 5px 10px; + background: #fff; + color: var(--wolai-text-primary); + cursor: pointer; + font-size: 13px; +} + +.mnote-conflict-actions button:hover { + background: var(--wolai-bg-hover); +} + +.mnote-conflict-diff-panel { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 4px; +} + +.mnote-conflict-diff-panel[hidden] { + display: none; +} + +.mnote-conflict-diff-panel h3 { + margin: 0 0 6px; + font-size: 12px; + font-weight: 650; + color: var(--wolai-text-secondary); +} + +.mnote-conflict-diff-panel pre, +.mnote-conflict-diff-status { + min-height: 96px; + max-height: 260px; + overflow: auto; + border-radius: 6px; + background: rgba(255, 255, 255, 0.7); + padding: 10px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 12px; + line-height: 18px; +} + +@media (max-width: 768px) { + .mnote-conflict-diff-panel { + grid-template-columns: 1fr; + } +} + #mnote-editor-island { width: 100%; max-width: none; @@ -3195,6 +3429,12 @@ body { white-space: nowrap; } +.wolai-page-ai-changed-files { + overflow: auto; + text-overflow: clip; + white-space: pre-wrap; +} + .wolai-page-ai-provider-group { display: flex; flex-wrap: wrap; diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index a38490fb..d380bec7 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -129,6 +129,8 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result Result { + // sidebar:datasetList 只保留为 cloud source / compat projection source; + // 默认 local-first 首屏应优先读取 LocalFS projection。 execute_convex_query_plan(config, context, plan).await } @@ -410,6 +414,8 @@ pub async fn execute_convex_query_by_name( } fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { + // 这里是 Rust command plan 到 legacy Convex validator 的兼容适配层。 + // documents:* 属于 compat / cloud source / sync replica,mediaAssets:* 属于 cloud resource replica。 let mut args = plan.args_json.clone(); if matches!( plan.command_name.as_str(), @@ -448,13 +454,11 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { plan.command_name.as_str(), "documents.save" | "page.body.save" ) { - if let Value::Object(map) = &mut args { - // 当前自托管 Convex 的 documents:updateContent 仍是 legacy validator。 - // Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。 - map.remove("editorDocument"); - map.remove("tiptapDocument"); - } + // 当前 Convex documents:updateContent 仍是 legacy content substrate: + // 真实可见保存必须只发送 validator 接受的字段;原生编辑器快照留在 + // Rust plan / artifact 侧,待 Convex schema 正式迁移后再进入持久层。 strip_tree_artifact_fields(&mut args); + strip_editor_snapshot_fields(&mut args); } if matches!( plan.command_name.as_str(), @@ -552,6 +556,15 @@ fn strip_tree_artifact_fields(args: &mut Value) { } } +fn strip_editor_snapshot_fields(args: &mut Value) { + if let Value::Object(map) = args { + map.remove("editorDocument"); + map.remove("tiptapDocument"); + map.remove("blockDocument"); + map.remove("blockProjectionVersion"); + } +} + pub async fn execute_convex_command_plan( config: &AppConfig, context: &RequestContext, @@ -850,6 +863,7 @@ pub async fn persist_runtime_command_artifacts( context: &RequestContext, artifacts: &RuntimeCommandArtifactPlan, ) -> Result<(), WebError> { + // bridgeLogs 只作为控制面审计与事件副本,不承载 local-first 页面正文真相。 execute_convex_mutation_by_name( config, context, @@ -958,9 +972,9 @@ mod tests { } #[test] - fn convex_command_args_strips_editor_runtime_fields_for_legacy_document_save() { + fn convex_command_args_strips_editor_runtime_fields_for_legacy_page_body_save() { let plan = RuntimeCommandExecutionPlan { - command_name: "documents.save".into(), + command_name: "page.body.save".into(), command_id: "cmd_1".into(), function_name: "documents:updateContent".into(), workspace_id: Some("ws_1".into()), @@ -975,7 +989,7 @@ mod tests { "content": [], "expectedRevision": 0, "conflictDetectionKey": "doc_1:0", - "editorDocument": {"rootBlockIds": []}, + "editorDocument": {"documentId": "doc_1", "rootBlockIds": []}, "tiptapDocument": {"type": "doc", "content": []}, "streamDeltaHint": {"family": "tree"}, "domainEventHint": {"eventType": "page.body.saved"}, @@ -1133,7 +1147,7 @@ mod tests { } #[test] - fn convex_resource_lifecycle_args_keep_effective_user_id() { + fn convex_cloud_media_resource_lifecycle_args_keep_effective_user_id() { let plan = RuntimeCommandExecutionPlan { command_name: "tree.resource.archive".into(), command_id: "cmd_resource_archive_1".into(), diff --git a/scripts/check-local-first-convex-guard.js b/scripts/check-local-first-convex-guard.js new file mode 100644 index 00000000..ec8d8db2 --- /dev/null +++ b/scripts/check-local-first-convex-guard.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node +"use strict"; + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WAIVER = "local-first-allow-convex-main-storage"; + +const RUNTIME_FORBIDDEN = [ + { + name: "未标注的 Convex documents 直连", + pattern: /documents:(?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge)\b/, + }, + { + name: "未标注的 Convex media 直连", + pattern: /mediaAssets:(?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/, + }, + { + name: "未标注的 Convex AI session 直连", + pattern: + /aiSessions:(?:upsertRuntimeRun|appendRuntimeEvent|getRuntimeRun|listRuntimeRuns|listRuntimeEvents|renameRuntimeSession|autoTitleRuntimeSession|deleteRuntimeSession|searchRuntimeSessions)\b/, + }, + { + name: "Convex media URL 主路径", + pattern: /\/api\/media\/(?:upload|sign|batch)|assetId=/, + }, +]; + +const DESIGN_DEFAULT_CONVEX = /(?:Convex|convex).*(?:默认|default).*(?:主存储|主数据层|数据真相|页面正文真相|文件树真相|附件|AI 会话|session)/; +const DESIGN_NEGATION = + /(?:不应|不得|不再|禁止|只作为|降级|控制面|检查|若把|应要求改成|不是|不作为|不依赖|可选|sync replica|cloud source|compat|依赖证据|代码盘点|未完成|部分完成|当前状态)/; +const ACTIVE_CONVEX_FORBIDDEN = [ + { + name: "未标注的 Convex documents 表", + pattern: /\bdocuments\s*:\s*defineTable\b/, + }, + { + name: "未标注的 Convex media_assets 表", + pattern: /\bmedia_assets\s*:\s*defineTable\b/, + }, + { + name: "未标注的 Convex documents 函数", + pattern: /export const (?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge|listByWorkspace)\b/, + }, + { + name: "未标注的 Convex mediaAssets 函数", + pattern: /export const (?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/, + }, +]; + +function usage() { + return [ + "用法:", + " node scripts/check-local-first-convex-guard.js", + " node scripts/check-local-first-convex-guard.js --base origin/main", + " node scripts/check-local-first-convex-guard.js --staged", + " node scripts/check-local-first-convex-guard.js --files ", + " node scripts/check-local-first-convex-guard.js --self-test", + "", + `如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}。`, + ].join("\n"); +} + +function parseArgs(argv) { + const result = { + base: "HEAD", + staged: false, + files: [], + selfTest: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + console.log(usage()); + process.exit(0); + } + if (arg === "--staged") { + result.staged = true; + continue; + } + if (arg === "--self-test") { + result.selfTest = true; + continue; + } + if (arg === "--base") { + const value = argv[index + 1]; + if (!value) throw new Error("--base 需要一个 git ref"); + result.base = value; + index += 1; + continue; + } + if (arg === "--files") { + result.files = argv.slice(index + 1); + break; + } + throw new Error(`未知参数:${arg}\n${usage()}`); + } + return result; +} + +function isDesignPath(filePath) { + return filePath.startsWith("design/") && !filePath.startsWith("design/old/") && filePath.endsWith(".md"); +} + +function isRuntimePath(filePath) { + return filePath.startsWith("rust/crates/mnote-web/src/") && filePath.endsWith(".rs"); +} + +function isActiveConvexPath(filePath) { + return filePath.startsWith("convex/") && filePath.endsWith(".ts") && !filePath.startsWith("convex/_generated/"); +} + +function isAllowedRuntimeAdapter(filePath) { + return ( + filePath === "rust/crates/mnote-web/src/transport/convex.rs" || + filePath === "rust/crates/mnote-web/src/routes/compat.rs" + ); +} + +function normalizePath(filePath) { + return filePath.split(path.sep).join("/"); +} + +function readDiffLines(options) { + const args = ["diff", "--unified=0", "--no-ext-diff"]; + if (options.staged) { + args.push("--cached"); + } else { + args.push(options.base); + } + args.push("--"); + const output = execFileSync("git", args, { encoding: "utf8" }); + return output.split(/\r?\n/); +} + +function collectAddedLinesFromDiff(lines) { + const added = []; + let currentFile = null; + let newLine = 0; + + for (const line of lines) { + const fileMatch = line.match(/^\+\+\+ b\/(.+)$/); + if (fileMatch) { + currentFile = fileMatch[1]; + continue; + } + if (line.startsWith("+++ /dev/null")) { + currentFile = null; + continue; + } + const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunkMatch) { + newLine = Number(hunkMatch[1]); + continue; + } + if (!currentFile || line.startsWith("diff --git") || line.startsWith("index ")) { + continue; + } + if (line.startsWith("+") && !line.startsWith("+++")) { + added.push({ filePath: currentFile, lineNumber: newLine, text: line.slice(1) }); + newLine += 1; + continue; + } + if (!line.startsWith("-")) { + newLine += 1; + } + } + + return added; +} + +function collectLinesFromFiles(files) { + const rows = []; + for (const rawPath of files) { + const filePath = normalizePath(rawPath); + const text = fs.readFileSync(filePath, "utf8"); + text.split(/\r?\n/).forEach((line, index) => { + rows.push({ filePath, lineNumber: index + 1, text: line }); + }); + } + return rows; +} + +function checkLine(row) { + if (row.text.includes(WAIVER)) { + return []; + } + + if (isDesignPath(row.filePath)) { + if (DESIGN_DEFAULT_CONVEX.test(row.text) && !DESIGN_NEGATION.test(row.text)) { + return [ + { + ...row, + reason: "设计稿新增口径把 Convex 描述成默认主存储;请改成 LocalFS/WorkspaceSource 默认,或明确标注 cloud/control-plane/compat。", + }, + ]; + } + return []; + } + + if (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) { + if (!isActiveConvexPath(row.filePath)) { + return []; + } + return ACTIVE_CONVEX_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({ + ...row, + reason: `${entry.name} 出现在 active convex 目录;新增 Convex 代码只能是 auth、membership、share grants、sync state、AI policy、cloud source、compat 或 sync replica,并需用 ${WAIVER} 标注例外。`, + })); + } + + return RUNTIME_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({ + ...row, + reason: `${entry.name} 出现在非 Convex adapter 路径;请先走 WorkspaceSource / Rust kernel / LocalFS executor,或加 ${WAIVER} 并说明原因。`, + })); +} + +function runSelfTest() { + const rows = [ + { + filePath: "convex/schema.ts", + lineNumber: 1, + text: " documents: defineTable({", + }, + { + filePath: "convex/mediaAssets.ts", + lineNumber: 1, + text: "export const generateUploadUrl = mutation({", + }, + { + filePath: "convex/shareGrants.ts", + lineNumber: 1, + text: "export const upsertShareGrant = mutation({", + }, + { + filePath: "rust/crates/mnote-web/src/routes/example.rs", + lineNumber: 1, + text: '"documents:updateContent"', + }, + { + filePath: "rust/crates/mnote-web/src/transport/convex.rs", + lineNumber: 1, + text: '"documents:updateContent"', + }, + ]; + const violations = rows.flatMap(checkLine); + const labels = violations.map((item) => item.filePath); + if (!labels.includes("convex/schema.ts")) { + throw new Error("self-test 失败:未拦截 active convex documents 表"); + } + if (!labels.includes("convex/mediaAssets.ts")) { + throw new Error("self-test 失败:未拦截 active convex media 函数"); + } + if (!labels.includes("rust/crates/mnote-web/src/routes/example.rs")) { + throw new Error("self-test 失败:未拦截非 adapter Rust Convex 直连"); + } + if (labels.includes("convex/shareGrants.ts")) { + throw new Error("self-test 失败:误拦截控制面 share grants"); + } + if (labels.includes("rust/crates/mnote-web/src/transport/convex.rs")) { + throw new Error("self-test 失败:误拦截 Convex adapter"); + } + console.log(JSON.stringify({ ok: true, guard: "local-first-convex", selfTest: true }, null, 2)); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.selfTest) { + runSelfTest(); + return; + } + const rows = options.files.length > 0 ? collectLinesFromFiles(options.files) : collectAddedLinesFromDiff(readDiffLines(options)); + const violations = rows.flatMap(checkLine); + + if (violations.length > 0) { + console.error("local-first Convex guard 发现新增主存储绑定:"); + for (const violation of violations) { + console.error(`- ${violation.filePath}:${violation.lineNumber} ${violation.reason}`); + console.error(` ${violation.text.trim()}`); + } + process.exit(1); + } + + console.log(JSON.stringify({ ok: true, checkedLines: rows.length, guard: "local-first-convex" }, null, 2)); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/task450-admin-access-policy-ui-smoke.js b/scripts/task450-admin-access-policy-ui-smoke.js new file mode 100644 index 00000000..688aaaa6 --- /dev/null +++ b/scripts/task450-admin-access-policy-ui-smoke.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { chromium } = require("playwright"); + +const BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000"; +const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000); + +function resolveChromiumExecutablePath() { + const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; + if (explicit && fs.existsSync(explicit)) return explicit; + return [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/snap/bin/chromium", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].find((candidate) => fs.existsSync(candidate)) || ""; +} + +function fileUrl(filePath) { + return `file://${filePath.split(path.sep).map((part, index) => ( + index === 0 ? "" : encodeURIComponent(part) + )).join("/")}`; +} + +async function waitForText(page, selector, expected) { + await page.waitForFunction( + ({ selector: targetSelector, expectedText }) => { + const node = document.querySelector(targetSelector); + return Boolean(node && node.textContent && node.textContent.includes(expectedText)); + }, + { selector, expectedText: expected }, + { timeout: UI_TIMEOUT_MS }, + ); + return page.locator(selector).innerText({ timeout: UI_TIMEOUT_MS }); +} + +async function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-admin-access-policy-")); + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync(path.join(root, "README.md"), "# Admin Smoke\n", "utf8"); + + const executablePath = resolveChromiumExecutablePath(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); + + const adminContext = await browser.newContext({ + extraHTTPHeaders: { + "x-mnote-actor-id": "admin_smoke", + "x-mnote-actor-type": "admin", + }, + }); + const adminPage = await adminContext.newPage(); + const readerContext = await browser.newContext({ + extraHTTPHeaders: { + "x-mnote-actor-id": "reader_smoke", + "x-mnote-actor-type": "user", + }, + }); + const readerPage = await readerContext.newPage(); + + const grantId = `grant_${Date.now()}`; + const rootUri = fileUrl(root); + + try { + await adminPage.goto(`${BASE_URL}/admin/access-policy`, { + waitUntil: "domcontentloaded", + timeout: UI_TIMEOUT_MS, + }); + await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + assert( + await adminPage.locator('[data-testid="mnote-admin-policy-path"]').innerText(), + "管理页应显示策略路径", + ); + + await adminPage.locator('[data-testid="mnote-admin-root-uri"]').fill(rootUri); + await adminPage.locator('[data-testid="mnote-admin-validate-root-submit"]').click({ + timeout: UI_TIMEOUT_MS, + }); + const validateResult = await waitForText( + adminPage, + '[data-testid="mnote-admin-validate-result"]', + root, + ); + assert(validateResult.includes(root), "验证目录结果应包含 canonical root"); + + await adminPage.locator('[data-testid="mnote-admin-grant-id"]').fill(grantId); + await adminPage.locator('[data-testid="mnote-admin-grant-user-id"]').fill("reader_smoke"); + await adminPage.locator('[data-testid="mnote-admin-grant-root-uri"]').fill(rootUri); + await adminPage.locator('[data-testid="mnote-admin-grant-permission"]').selectOption("read"); + await adminPage.locator('[data-testid="mnote-admin-grant-recursive"]').check(); + await adminPage.locator('[data-testid="mnote-admin-grant-capabilities"]').fill("ai,share"); + await adminPage.locator('[data-testid="mnote-admin-create-grant-submit"]').click({ + timeout: UI_TIMEOUT_MS, + }); + const createResult = await waitForText( + adminPage, + '[data-testid="mnote-admin-create-result"]', + grantId, + ); + assert(createResult.includes(grantId), "创建结果应返回 grantId"); + + await readerPage.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const openResponse = await readerPage.evaluate(async ({ rootUriValue }) => { + const url = new URL("/api/local-folder/files/open", window.location.origin); + url.searchParams.set("rootUri", rootUriValue); + url.searchParams.set("path", "README.md"); + const response = await fetch(url.toString(), { credentials: "include" }); + return { status: response.status, text: await response.text() }; + }, { rootUriValue: rootUri }); + assert.equal(openResponse.status, 200, "read grant 应可打开本地文件"); + assert(openResponse.text.includes("Admin Smoke"), "打开的文件内容应正确"); + + const writeResponse = await readerPage.evaluate(async ({ rootUriValue }) => { + const response = await fetch("/api/page-body/write", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + documentId: "local-md:README.md", + workspaceId: "local-ws-admin-smoke", + sourceKind: "local_folder", + rootUri: rootUriValue, + contentFormat: "editorBlocks", + content: [], + }), + }); + return { status: response.status, text: await response.text() }; + }, { rootUriValue: rootUri }); + assert.equal(writeResponse.status, 403, "read grant 不应允许写入"); + + await adminPage.locator('[data-testid="mnote-admin-delete-grant-id"]').fill(grantId); + await adminPage.locator('[data-testid="mnote-admin-delete-grant-submit"]').click({ + timeout: UI_TIMEOUT_MS, + }); + const deleteResult = await waitForText( + adminPage, + '[data-testid="mnote-admin-delete-result"]', + grantId, + ); + assert(deleteResult.includes(grantId), "删除结果应返回 grantId"); + + await adminPage.waitForFunction( + ({ selector, removedGrantId }) => { + const node = document.querySelector(selector); + return Boolean(node && node.textContent && !node.textContent.includes(removedGrantId)); + }, + { selector: '[data-testid="mnote-admin-policy-json"]', removedGrantId: grantId }, + { timeout: UI_TIMEOUT_MS }, + ); + const policyJson = await adminPage.locator('[data-testid="mnote-admin-policy-json"]').innerText(); + assert(!policyJson.includes(grantId), "删除后策略面板不应再包含已删授权"); + } finally { + await adminContext.close().catch(() => {}); + await readerContext.close().catch(() => {}); + await browser.close().catch(() => {}); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/task451-local-markdown-conflict-resolution-ui-smoke.js b/scripts/task451-local-markdown-conflict-resolution-ui-smoke.js new file mode 100644 index 00000000..55f7bb6c --- /dev/null +++ b/scripts/task451-local-markdown-conflict-resolution-ui-smoke.js @@ -0,0 +1,210 @@ +#!/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 = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task451-local-markdown-conflict-resolution-ui-smoke"); +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)); +const debug = {}; + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +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)); + return url.toString(); +} + +function markdown(title, lines) { + return [ + "---", + `title: ${title}`, + "---", + "", + ...lines, + "", + ].join("\n"); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ownerId}:task451`, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function openDocument(page, root, relativePath) { + await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function waitForEditorText(page, text) { + await page.waitForFunction( + (expected) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + return (editor?.textContent || "").includes(expected); + }, + text, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForEditorStatus(page, status) { + await page.waitForFunction( + (expected) => { + const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + return root?.getAttribute("data-runtime-editor-status") === expected; + }, + status, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function typeDirtyText(page, text) { + const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.type(text, { delay: 8 }); + await waitForEditorText(page, text.trim()); +} + +async function waitForFileText(filePath, expected) { + const deadline = Date.now() + UI_TIMEOUT_MS; + while (Date.now() < deadline) { + const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; + if (content.includes(expected)) return content; + await new Promise((resolve) => setTimeout(resolve, 120)); + } + throw new Error(`文件未出现期望内容: ${expected}`); +} + +async function run() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-conflict-ui-")); + const acceptFile = "accept-disk.md"; + const keepFile = "keep-current.md"; + writeWorkspaceManifest(root, "user_real"); + fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept"]), "utf8"); + fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep"]), "utf8"); + + const browser = await chromium.launch({ + headless: true, + ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), + }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 860 }, + extraHTTPHeaders: { + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + }); + const page = await context.newPage(); + debug.network = []; + page.on("response", async (response) => { + const url = response.url(); + if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return; + let body = ""; + try { + body = await response.text(); + } catch (_) { + body = ""; + } + debug.network.push({ url, status: response.status(), body: body.slice(0, 800) }); + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return; + debug.network.push({ url, failed: request.failure()?.errorText || "request_failed" }); + }); + const steps = []; + + try { + await openDocument(page, root, acceptFile); + await waitForEditorText(page, "initial accept"); + const localAcceptToken = `local-accept-${Date.now()}`; + const diskAcceptToken = `disk-accept-${Date.now()}`; + await typeDirtyText(page, ` ${localAcceptToken}`); + fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept", diskAcceptToken]), "utf8"); + await waitForEditorStatus(page, "external-change-conflict"); + await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + (expected) => { + const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]'); + return (panel?.textContent || "").includes(expected); + }, + diskAcceptToken, + { timeout: UI_TIMEOUT_MS }, + ); + const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS }); + debug.acceptDiffText = diffText; + assert(diffText.includes(localAcceptToken), "diff 应显示当前编辑器版本"); + assert(diffText.includes(diskAcceptToken), "diff 应显示磁盘版本"); + await page.locator('[data-testid="mnote-conflict-accept-disk"]').click({ timeout: UI_TIMEOUT_MS }); + await waitForEditorText(page, diskAcceptToken); + steps.push({ label: "accept-disk", ok: true }); + + await openDocument(page, root, keepFile); + await waitForEditorText(page, "initial keep"); + const localKeepToken = `local-keep-${Date.now()}`; + const diskKeepToken = `disk-keep-${Date.now()}`; + await typeDirtyText(page, ` ${localKeepToken}`); + fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep", diskKeepToken]), "utf8"); + await waitForEditorStatus(page, "external-change-conflict"); + await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-conflict-keep-current"]').click({ timeout: UI_TIMEOUT_MS }); + await waitForFileText(path.join(root, keepFile), localKeepToken); + const keepContent = fs.readFileSync(path.join(root, keepFile), "utf8"); + assert(!keepContent.includes(diskKeepToken), "保留当前版本后磁盘版本内容不应覆盖当前编辑器内容"); + steps.push({ label: "keep-current", ok: true }); + + const result = { ok: true, baseUrl: BASE_URL, root, steps }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(`task451 local markdown conflict resolution UI smoke passed: ${RESULT_PATH}`); + } finally { + await browser.close().catch(() => {}); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +run().catch((error) => { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ + ok: false, + error: String(error && error.stack || error), + debug, + }, null, 2)}\n`, "utf8"); + console.error(error); + process.exitCode = 1; +});