Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
+3
View File
@@ -31,6 +31,9 @@ env-archive/
/.serena/ /.serena/
/tmp-block-handle-qa.js /tmp-block-handle-qa.js
# open-code-review (ocr) local scan reports / slices / scan scripts output
/.ocr-reports/
# Retired root-level agent/harness/browser evidence artifacts. # Retired root-level agent/harness/browser evidence artifacts.
/REASONIX.md /REASONIX.md
/reasonix.toml /reasonix.toml
+9 -2
View File
@@ -102,7 +102,7 @@
## 常用命令 ## 常用命令
- 热启动:`npm run desktop:hot``http://localhost:3000` - 热启动:`npm run dev:hot``http://localhost:3000`
- 测试:`cargo test -p mnote-web` - 测试:`cargo test -p mnote-web`
- Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` - Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local`
- 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote` - 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote`
@@ -111,7 +111,14 @@
- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth` - 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth`
- 禁止 smoke 用 `sqlite3` CLI 直写 control-planeseed 走 Rust API / `scripts/lib/control-plane-dev-seed.js` - 禁止 smoke 用 `sqlite3` CLI 直写 control-planeseed 走 Rust API / `scripts/lib/control-plane-dev-seed.js`
- 测试账号:`mnote.e2e@example.com` / `MnoteE2E123!` / `mnote-e2e`;优先 `/auth`「测试账号快速登录」。 - 账号角色(7-76 方案 A,**勿混用**):
| 账号 | 定位 | 密码(本地/dev | admin 能力 |
|------|------|------------------|------------|
| `mnote-admin` / `mnote.admin@example.com` | **ops admin**`/admin/*`、代签、policy | `MnoteAdmin123!` | 是 |
| `mnote-e2e` / `mnote.e2e@example.com` | **AI 主体** `ai_service`(私有工作区/知识库) | `MnoteE2E123!` | **否** |
| `liaibo` 等 | 普通 human user | 各自密码 | **否** |
- 登录走 **标准** `/auth` 表单或 `/api/auth`(已移除「测试账号快速登录」);开发对标生产。
- Admin 列表真源:`access-policy.json``admins` + `MNOTE_ADMIN_USER_IDS`(默认仅 `mnote-admin`)。
## 前端测试 ## 前端测试
@@ -6,7 +6,7 @@
> >
> 2026-05-22 口径补充:本文完成的是“local-first workspace + Convex 降级控制面”的阶段性方案;后续 `2-8` 已继续把默认控制面从 Convex 替换为 Rust SQLite `control-plane`。因此本文中“Convex / 服务端控制面”的表述只作为阶段性历史背景理解;当前默认 auth、membership、share grants、sync state、AI policy、Page AI runtime session 已由 SQLite control-plane 承接,Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。 > 2026-05-22 口径补充:本文完成的是“local-first workspace + Convex 降级控制面”的阶段性方案;后续 `2-8` 已继续把默认控制面从 Convex 替换为 Rust SQLite `control-plane`。因此本文中“Convex / 服务端控制面”的表述只作为阶段性历史背景理解;当前默认 auth、membership、share grants、sync state、AI policy、Page AI runtime session 已由 SQLite control-plane 承接,Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。
> >
> 2026-06-28 口径补充:Hermes / Reasonix / LightRAG 已退出当前新增主线;当前 Page AI 与知识库融合方向按 OpenHub / opencode / WeKnora 理解,旧名称只作为历史命名、legacy route 或迁移对照。 > 2026-06-28 口径补充:Hermes / Reasonix / LightRAG 已退出当前新增主线;当前 Page AI 与知识库融合方向按 OpenHub / opencode / LightRAG 理解,旧名称只作为历史命名、legacy route 或迁移对照。
> >
> 本稿目的: > 本稿目的:
> 1. 固定早期产品形态:10 人以内、用户笔记默认独立、可分享、AI 隔离且可共享。 > 1. 固定早期产品形态:10 人以内、用户笔记默认独立、可分享、AI 隔离且可共享。
@@ -81,13 +81,13 @@ User
更准确的产品定义是: 更准确的产品定义是:
> **MNote = VSCode 简化版工作区内核 + tiptap markdown 编辑器 + OpenHub / opencode agent + WeKnora 知识库融合 + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面。** > **MNote = VSCode 简化版工作区内核 + tiptap markdown 编辑器 + OpenHub / opencode agent + LightRAG 知识库融合 + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面。**
拆开看: 拆开看:
- **VSCode 简化版工作区内核**:本地文件夹、目录树、页面树、资源树、拖拽、重命名、移动、回收站、导入导出。 - **VSCode 简化版工作区内核**:本地文件夹、目录树、页面树、资源树、拖拽、重命名、移动、回收站、导入导出。
- **tiptap markdown 编辑器**:页面正文的前端显示与交互层,消费本地文件投影,不承担 agent 主编辑面职责。 - **tiptap markdown 编辑器**:页面正文的前端显示与交互层,消费本地文件投影,不承担 agent 主编辑面职责。
- **OpenHub / opencode agent**:在 MNote 宿主授权 scope 内处理 Page AI 对话、文件引用和 WeKnora 知识库引用;旧 Hermes / Reasonix 只作为 legacy/debug 对照。 - **OpenHub / opencode agent**:在 MNote 宿主授权 scope 内处理 Page AI 对话、文件引用和 LightRAG 知识库引用;旧 Hermes / Reasonix 只作为 legacy/debug 对照。
- **simplemindmap / office 插件**:作为资源型编辑器或附件打开器存在,不把它们重新降格成 markdown 文本块。 - **simplemindmap / office 插件**:作为资源型编辑器或附件打开器存在,不把它们重新降格成 markdown 文本块。
- **Wolai 风格 web 壳**:提供统一导航、主题、页面壳、AI 面板、侧边栏和分享入口。 - **Wolai 风格 web 壳**:提供统一导航、主题、页面壳、AI 面板、侧边栏和分享入口。
- **鉴权控制面**:负责登录、用户隔离、目录授权、分享授权、同步状态和审计,而不是默认正文存储。 - **鉴权控制面**:负责登录、用户隔离、目录授权、分享授权、同步状态和审计,而不是默认正文存储。
@@ -900,7 +900,7 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编
#### A. 防走偏护栏与口径同步 #### A. 防走偏护栏与口径同步
- [x] A1. 固定主路径口径:`MNote = VSCode 简化版工作区 + tiptap markdown 显示/编辑层 + OpenHub/opencode agent + WeKnora 知识库融合 + 插件 + Wolai web 壳 + 鉴权控制面` - [x] A1. 固定主路径口径:`MNote = VSCode 简化版工作区 + tiptap markdown 显示/编辑层 + OpenHub/opencode agent + LightRAG 知识库融合 + 插件 + Wolai web 壳 + 鉴权控制面`
- 文档:本稿第 2.1、2.4、5、6 节。 - 文档:本稿第 2.1、2.4、5、6 节。
- 验收:普通 local-first Markdown 编辑不再被描述为必须走 `mnote.block.*``mnote.doc.markdown_edit` 或 Convex documents。 - 验收:普通 local-first Markdown 编辑不再被描述为必须走 `mnote.block.*``mnote.doc.markdown_edit` 或 Convex documents。
- [x] A2. 建立 Convex 主存储新增检查。 - [x] A2. 建立 Convex 主存储新增检查。
@@ -67,7 +67,7 @@ Turso 的收益不在于“SQLite 语法换个驱动”本身,而在于把控
- `.mnote/index/evidence.sqlite`。该库已从当前主线退役,本轮不迁移。 - `.mnote/index/evidence.sqlite`。该库已从当前主线退役,本轮不迁移。
- LightRAG 自身存储。知识库/RAG provider 存储不在 control-plane 切换范围内。 - LightRAG 自身存储。知识库/RAG provider 存储不在 control-plane 切换范围内。
- OpenHub 自身 SQLite 会话库。它可以作为第二阶段单独迁移,不应和 Rust control-plane 首次切换绑死。 - OpenHub 自身 SQLite 会话库。它可以作为第二阶段单独迁移,不应和 Rust control-plane 首次切换绑死。
- WeKnora / RAGFlow。当前不作为主线迁移目标。 - RAGFlow。当前不作为主线迁移目标。
- 浏览器直连 Turso。所有读写仍必须经过 Rust / OpenHub API。 - 浏览器直连 Turso。所有读写仍必须经过 Rust / OpenHub API。
--- ---
@@ -435,7 +435,7 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan
| P0 | Argon2id password hash migration | 未完成 | remote 成为长期默认前必须完成,避免把 `sha256-v1` 占位哈希带入云端。 | | P0 | Argon2id password hash migration | 未完成 | remote 成为长期默认前必须完成,避免把 `sha256-v1` 占位哈希带入云端。 |
| P1 | 读写并发性能设计 | 仅完成安全基线,未实现性能优化 | 当前 `TursoControlPlaneStore``Mutex<TursoConnection>`,读写全部串行;这能保证简单安全,但不能发挥 remote / replica 的读并发优势。 | | P1 | 读写并发性能设计 | 仅完成安全基线,未实现性能优化 | 当前 `TursoControlPlaneStore``Mutex<TursoConnection>`,读写全部串行;这能保证简单安全,但不能发挥 remote / replica 的读并发优势。 |
| P2 | CDC / change data capture | 未采用 | 当前 control-plane 已有显式 `audit_log``outbox_events`,CDC 只适合作为后续审计/同步 spike,不进入本轮。 | | P2 | CDC / change data capture | 未采用 | 当前 control-plane 已有显式 `audit_log``outbox_events`,CDC 只适合作为后续审计/同步 spike,不进入本轮。 |
| P2 | Tantivy-powered full-text search | 未采用 | 当前 control-plane 表主要是元数据,全文搜索收益有限;正文和知识库搜索仍属于 OpenHub/WeKnora/local search 方向,不跟 control-plane 绑死。 | | P2 | Tantivy-powered full-text search | 未采用 | 当前 control-plane 表主要是元数据,全文搜索收益有限;正文和知识库搜索仍属于 OpenHub/LightRAG/local search 方向,不跟 control-plane 绑死。 |
| P3 | Browser WASM / OPFS database | 未采用 | 对纯 Web 离线笔记有价值,但当前 MNote 主形态是本地文件夹 + Rust mnote-web + 文件系统 watcher;浏览器内 SQL 不能替代本地 `.md` 真相,也不适合作为 control-plane 权限真源。 | | P3 | Browser WASM / OPFS database | 未采用 | 对纯 Web 离线笔记有价值,但当前 MNote 主形态是本地文件夹 + Rust mnote-web + 文件系统 watcher;浏览器内 SQL 不能替代本地 `.md` 真相,也不适合作为 control-plane 权限真源。 |
### 11.4 读写并发设计原则 ### 11.4 读写并发设计原则
@@ -486,9 +486,9 @@ Turso 文档显示 FTS 通过 `CREATE INDEX ... USING fts` 和 `fts_match` / `ft
对 MNote 的判断: 对 MNote 的判断:
- 当前 control-plane 不需要:users、workspaces、grants、recent、shortcuts、policy 都是轻量元数据,`LIKE` 或精确索引足够。 - 当前 control-plane 不需要:users、workspaces、grants、recent、shortcuts、policy 都是轻量元数据,`LIKE` 或精确索引足够。
- 不迁正文:页面正文 `.md` 仍在本地文件系统,知识库问答主线是 OpenHub/WeKnora,不把正文全文塞进 control-plane。 - 不迁正文:页面正文 `.md` 仍在本地文件系统,知识库问答主线是 OpenHub/LightRAG,不把正文全文塞进 control-plane。
- 可作为 P2/P3:如果以后要做轻量“标题 / 文件路径 / 页面摘要 / AI 会话标题”的本地快速搜索,可以单独设计 Turso FTS 索引。 - 可作为 P2/P3:如果以后要做轻量“标题 / 文件路径 / 页面摘要 / AI 会话标题”的本地快速搜索,可以单独设计 Turso FTS 索引。
- 不替代 WeKnora/OpenHub RAGTantivy FTS 是关键词检索,不等于知识库引用、OCR、语义检索和 agent citation 链。 - 不替代 LightRAG/OpenHub RAGTantivy FTS 是关键词检索,不等于知识库引用、OCR、语义检索和 agent citation 链。
结论:**对当前 control-plane 价值低;对未来轻量元数据搜索有价值;不进入本轮。** 结论:**对当前 control-plane 价值低;对未来轻量元数据搜索有价值;不进入本轮。**
@@ -662,7 +662,7 @@ cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-a
- `.mnote/index/evidence.sqlite`(已退役)→ 未迁移 - `.mnote/index/evidence.sqlite`(已退役)→ 未迁移
- LightRAG 自身存储 → 未迁移 - LightRAG 自身存储 → 未迁移
- OpenHub 自身 SQLite 会话库 → 未迁移(Phase 7 推迟) - OpenHub 自身 SQLite 会话库 → 未迁移(Phase 7 推迟)
- WeKnora / RAGFlow → 未迁移 - RAGFlow → 未迁移
- 浏览器直连 Turso → 未实现(所有读写经过 Rust API) - 浏览器直连 Turso → 未实现(所有读写经过 Rust API)
### 2026-07-03 最终执行记录 ### 2026-07-03 最终执行记录
@@ -4,7 +4,7 @@
> >
> 当前状态:`DONE` > 当前状态:`DONE`
> >
> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认知识库 provider 的完成基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 口径已标记 stale,仅保留为历史设计参考实现或备用 provider 边界。 > 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认知识库 provider 的完成基线;历史非 LightRAG 默认 provider 口径已标记 stale,仅保留为历史设计参考边界。
> >
> Owner07-ai / knowledge-rag / plugin-ui / external-provider > Owner07-ai / knowledge-rag / plugin-ui / external-provider
> >
@@ -4,7 +4,7 @@
> >
> 状态:`done` > 状态:`done`
> >
> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 hardening 基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 口径已标记 stale。 > 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 hardening 基线;历史非 LightRAG 默认 provider 口径已标记 stale。
> >
> Owner07-ai / knowledge-rag / 03-rust-web / plugin-ui > Owner07-ai / knowledge-rag / 03-rust-web / plugin-ui
> >
@@ -4,7 +4,7 @@
> >
> 当前状态:`DONE` > 当前状态:`DONE`
> >
> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的图片 OCR 与搜索召回链路 hardening 基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 口径已标记 stale。 > 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的图片 OCR 与搜索召回链路 hardening 基线;历史非 LightRAG 默认 provider 口径已标记 stale。
> >
> Owner07-ai / knowledge-rag / local-search > Owner07-ai / knowledge-rag / local-search
> >
@@ -4,7 +4,7 @@
> >
> 当前状态:`done` > 当前状态:`done`
> >
> 2026-07-19 口径回正:当前 runtime 是 Pi Rust Page AI + LightRAG + Turso/libSQL。本文保留为当前默认 LightRAG provider 的 DOCX 引用与定位合同基线;WeKnora 仅为历史/备用边界。 > 2026-07-19 口径回正:当前 runtime 是 Pi Rust Page AI + LightRAG + Turso/libSQL。本文保留为当前默认 LightRAG provider 的 DOCX 引用与定位合同基线;历史非 LightRAG provider 仅为归档边界。
> >
> Owner07-ai / knowledge-rag / 03-rust-web / office-preview > Owner07-ai / knowledge-rag / 03-rust-web / office-preview
> >
@@ -4,7 +4,7 @@
> >
> 当前状态:`done` > 当前状态:`done`
> >
> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 native DOCX sidecar 定位基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 口径已标记 stale。 > 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 native DOCX sidecar 定位基线;历史非 LightRAG 默认 provider 口径已标记 stale。
> >
> Owner07-ai / knowledge-rag / LightRAG native parser / office-preview > Owner07-ai / knowledge-rag / LightRAG native parser / office-preview
> >
@@ -4,7 +4,7 @@
> >
> 当前状态:`DONE` > 当前状态:`DONE`
> >
> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文中的 Knowledge RAG facade 重新作为当前 LightRAG 默认 provider 的 run/config/facade 参考;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 口径已标记 stale。 > 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文中的 Knowledge RAG facade 重新作为当前 LightRAG 默认 provider 的 run/config/facade 参考;历史非 LightRAG 默认 provider 口径已标记 stale。
> >
> Owner07-ai / Page AI runtime / Hermes client runs / Reasonix ACP / Knowledge RAG facade > Owner07-ai / Page AI runtime / Hermes client runs / Reasonix ACP / Knowledge RAG facade
> >
@@ -33,7 +33,7 @@ OpenHub 的 admin 面板(历史)证明了正确产品结构:provider/model
不应直接搬的部分: 不应直接搬的部分:
- opencode 服务管理、飞书渠道、WeKnora 配置导出。 - opencode 服务管理、飞书渠道、知识库配置导出。
- React/AntD 组件主链。 - React/AntD 组件主链。
- OpenHub SQLite 表作为 MNote 真相层。 - OpenHub SQLite 表作为 MNote 真相层。
@@ -91,7 +91,7 @@ Knowledge RAG 面板应回答用户四个问题:
显示: 显示:
- provider 名称:`LightRAG / WeKnora / RAGFlow / legacy fallback` - provider 名称:`LightRAG / RAGFlow / legacy fallback`
- health:在线、不可达、API key 错误、pipeline busy。 - health:在线、不可达、API key 错误、pipeline busy。
- dashboard 链接。 - dashboard 链接。
- VLM/OCR 能力:图片解析是否启用、MinerU/Docling/native 路由摘要。 - VLM/OCR 能力:图片解析是否启用、MinerU/Docling/native 路由摘要。
@@ -129,7 +129,7 @@ Knowledge RAG 面板应回答用户四个问题:
|----|------| |----|------|
| Source | root-relative path | | Source | root-relative path |
| 类型 | markdown / image / pdf / office | | 类型 | markdown / image / pdf / office |
| Provider | LightRAG / WeKnora / RAGFlow | | Provider | LightRAG / RAGFlow |
| 状态 | registry + provider status 合并结果 | | 状态 | registry + provider status 合并结果 |
| 更新时间 | registry updatedAt / provider updatedAt | | 更新时间 | registry updatedAt / provider updatedAt |
| 引用 | open_reference 可否定位 | | 引用 | open_reference 可否定位 |
@@ -334,7 +334,7 @@ AI 行为:
### Phase 1:只读状态面板 ### Phase 1:只读状态面板
- [ ] 统一 Knowledge RAG provider 文案,避免 LightRAG / WeKnora / RAGFlow legacy fallback 在用户界面混乱。 - [ ] 统一 Knowledge RAG provider 文案,避免 LightRAG / RAGFlow legacy fallback 在用户界面混乱。
- [ ] 扩展 `/api/knowledge-rag/status` 的 source summary 与 registry doctor。 - [ ] 扩展 `/api/knowledge-rag/status` 的 source summary 与 registry doctor。
- [ ] 新建 Knowledge RAG 面板基础 UI,显示 provider health、source summary、失败/stale 汇总。 - [ ] 新建 Knowledge RAG 面板基础 UI,显示 provider health、source summary、失败/stale 汇总。
- [ ] FileTree tooltip 展示当前 `indexStatus`,不改变索引行为。 - [ ] FileTree tooltip 展示当前 `indexStatus`,不改变索引行为。
@@ -0,0 +1,631 @@
# 7-76 [process] AI 主体账号 · Web PAT · 生产级鉴权硬化 · 用户级密码箱 v1
> 创建时间:2026-07-26
> 状态:`PROCESS`**P0P1b 已实现**;§12 验收清单已勾选;P2 / 部分跨用户 e2e 残留见 §12.5)
> 执行看板:`design/07-ai/process/7-76-execution-goal-v1.md`
> Owner`07-ai` / `mnote-web` control-plane auth / `12-vault`(密码箱多用户与代签面)
> 建议 repo 落点:`design/07-ai/process/7-76-ai-principal-web-pat-and-auth-hardening-v1.md`
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/AGENTS.md`(云首发 control-plane、`libsql-local`、Page AI = Pi Lab
> - `design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md`
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
> - `design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md`
> - `design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`
> - `/home/lix/.agent-infra/vault-policy.md` + `$mnote-vault`
>
> 触发会话:
> - Paseo `28e19bd9-f091-4fa8-807b-e80b88d3aba7`
> 主题:生产/开发分流、屏蔽测试快速登录、让外部 AI 以正式身份访问自身记录与用户笔记;密码箱按用户隔离并支持分享给 AI。
>
> 用户已拍板(相对初稿的硬约束,**不得回退**):
> 1. **开发态对标生产**:撤下「测试账号快速登录」;开发只是在生产形态上调试,不靠 fixture 快捷入口。
> 2. **Web PAT 可复显**:不做「创建后永远不可再看明文」;列表默认遮罩,需要时可 reveal;**不是**放弃安全,而是可审计的受控复显。
> 3. **`mnote.e2e` 定位改为系统 AI 主体账号**(不是普通冒充测试人):拥有 AI 自己的知识库、笔记与记录;可查看**别人分享给 AI** 的密码;**仅持有该账号密码的人(一般是 admin)**可登录检查;普通用户不能访问该主体工作区。
> 4. **外部 AI 主能力** = 用户笔记的增删读写(及必要元数据),**不**作为再介入 Pi 内部对话的主路径。
> 5. **密码箱跟随用户**:一个用户只有一个密码箱(用户真源,而非「共享 workspace 一库混装」)。
> 6. **Admin 可代用户签发 vault token**(须审计)。
---
## 1. 第一结论
本设计同时收口四件事,且它们共用同一套主体模型:
| 主题 | 结论 |
|------|------|
| 鉴权 UI | 移除 `/auth` 测试快速登录与 HTML 内嵌测试密码;`MNOTE_WEB_ALLOW_DEV_FIXTURES` 只服务 seed/smoke 机机接口,**不**再驱动登录页后门。 |
| AI 主体 | 保留并正式化系统账号 **`mnote-e2e` / `mnote.e2e@example.com`** 为 **AI Principal`principal_kind=ai_service`**,不是普通 e2e 人设;其工作区 / 知识库 / vault 为 AI 私有面。 |
| 机机访问 | 引入 control-plane **Web PAT`mnpat1.*`**:外部 AI / 脚本用 Bearer 调用 JSON API;浏览器人用继续 cookie session。 |
| 密码箱 | 从「workspace 级单库」演进为 **每用户一库**;支持 **share-to-AI**vault capability token`mnv1.*`)与 Web PAT **职责分离**admin 可代签 vault token。 |
**一句话:**
人用 cookie 登录自己的工作区;外部 AI 持 **用户或 AI 主体的 PAT** 做笔记 CRUD;读密另持 **vault token**AI 自己的家在 **AI 主体账号** 下,普通用户进不去。
---
## 2. 背景与现状
> §2 保留**设计前**痛点快照;**当前实现状态以 §10 / §12 勾选为准**2026-07-26 核验)。
### 2.1 测试快速登录(设计前)
| 项 | 设计前 | 当前(已核验) |
|----|--------|----------------|
| 开关 | `ALLOW_DEV_FIXTURES` 默认 false | 不变;seed 仍 fail-closed |
| `/auth` | 无开关渲染快速登录 + `data-test-password` | **已移除**按钮与测试密码 DOM |
| 测试 | 断言 HTML **含**快速登录 | 断言 **不含** 快速登录 / `data-test-password` / 明文密码 |
### 2.2 AI 管理面(设计前 → 当前)
- `7-71` `/admin/ai``/user/ai` 主线仍在。
- **已补**Web PAT API + Bearer 中间件 + 管理 UI「API 访问令牌 / 密码箱 AI 访问」。
- PAT 存储当前为部署侧 sealed JSON`~/.config/mnote/api-access-tokens.json`),非 control-plane 表;API 合同满足 7-76。
- Page AI = Pi Lab;外部 AI 默认走 PAT + notes/tree/tools**不含** Pi run scope。
### 2.3 密码箱(设计前 → 当前)
- 真源仍为 workspace 下 `.mnote/vault/**`;用户默认落在 `users/<id>/workspaces/my-space`(一人一默认箱路径)。
- share-to-AI / AI vault 路径:12-x 既有能力 + `POST /api/vault/ai/token` Web 签发(admin 可代签)。
- 12-2 vaultd / `mnv1` 读密路径保留。
---
## 3. Goals / Non-Goals
### 3.1 Goals
1. **开发 = 生产基线**:登录页无快速登录、无测试密码 DOM;需要 seed 时用受控 API / CLI,不走人机后门。
2. **正式 AI 主体**`mnote-e2e` 升级为系统 AI 账号(可配置显示名);自有笔记/知识库/vault;可接收他人 vault 分享;人登录该账号需密码(admin 持有),普通用户无入口。
3. **Web PAT**:用户与 admin 可签发/列表/撤销/复显(受控);scope 白名单;默认服务「外部 AI 笔记 CRUD」而非 Pi 会话劫持。
4. **密码箱用户真源**:每用户一个 vault 命名空间;share-to-AI 显式授权;admin 可代签 vault token 并全量审计。
5. **凭证分职**cookie / Web PAT / vault token / 扩展 token / 上游 provider key **互不冒充**
### 3.2 Non-Goals
- 不把外部 AI 做成第二套 Page AI 宿主;不替换 Pi Lab。
- 不在本阶段做 OAuth2 完整 AS / 第三方 App 商店。
- 不取消 12-2 本机 vaultd 路径;云端多用户在其之上叠加 owner 与 Web 签发。
- 不做「admin 默认可明文浏览任意用户密码」;break-glass 若需要另开 P2 且强审计。
- 不把 vault 挂进 graph/`tree.*` 为普通页面节点。
---
## 4. 主体与凭证模型
### 4.1 主体(Principal
| `principal_kind` | 示例 | 谁用 | 工作区 / 知识库 / vault |
|------------------|------|------|-------------------------|
| `human` | 普通注册用户 | 浏览器人 | 自己的用户空间 |
| `ai_service` | `mnote-e2e`(系统 AI 主体) | 外部 AI 以 PAT 操作;人仅 admin 用密码检查 | **AI 私有** 空间 |
| `service`(可选 P1 | `service:ops` | 运维脚本 | 极窄 health/overview,默认无用户笔记 |
**AI 主体账号规则:**
- 账号标识稳定:`username=mnote-e2e`(或 `system/ai`,迁移期保留 e2e 兼容 id)。
- control-plane 标记:`principal_kind=ai_service``is_system=true``login_policy=password_holders_only`
- **禁止** 对普通用户暴露「切换到 AI 账号」;禁止公开注册同名。
- **禁止** 把 AI 主体密码写进前端 HTML / 文档 / smoke 默认文案(smoke 用 env 或 vault,不进 SSR)。
- 普通用户访问 AI 主体私有资源 → `403 principal_forbidden`
- Admin 检查:用**密码登录**该账号(或未来 break-glass 代登,P2);UI 上可在 `/admin/ai` 显示「AI 主体状态」,**不**自动注入密码。
- **禁止** 将 `mnote-e2e` 列入 `MNOTE_ADMIN_USER_IDS``access-policy.json``admins`(历史 e2e 方便已废止)。
### 4.1.1 账户分离(方案 A · 2026-07-26 已落地)
| 账号 | principal / role | admin 能力 | 用途 |
|------|------------------|------------|------|
| **`mnote-admin`** | human / `admin` | **是** | ops`/admin/*`、access-policy、代签 PAT/vault、全局 AI policy |
| **`liaibo` 等真人** | human / `user` | **否** | 个人笔记 / vault / 分享给 AI |
| **`mnote-e2e`** | `ai_service` | **否** | AI 私有工作区与知识库;外部 AI 以 PAT 操作 |
Admin 判定真源(保持兼容):`users.role=admin` **或** `access-policy.admins` **或** `MNOTE_ADMIN_USER_IDS`
云首发基线:`admins=["mnote-admin"]``MNOTE_ADMIN_USER_IDS=mnote-admin`
本地 insecure 默认口令仅用于 dev(见 `TESTING_REFERENCE`);生产 init 必须显式 `--password`
### 4.2 五种凭证(禁止混用)
| 凭证 | 前缀 | 作用 | 持有方 |
|------|------|------|--------|
| Session cookie | httpOnly cookie | 人用浏览器全站 | 浏览器 |
| **Web PAT** | `mnpat1.` | 机机调 mnote-web JSON API | 外部 AI / 脚本 env 或 0600 文件 |
| **Vault token** | `mnv1.` | 仅 vault list/get/resolve/login | agent 本地 token 文件 / skill |
| Extension token | `mnext1.` | Chrome 扩展写 vault | 扩展;**禁止**给 agent |
| Provider API Key | 厂商格式 | 调 OpenAI 等 | admin provider 配置;**不是**登录 MNote |
**原则:一种凭证一种职责。**
Web PAT **不等于** 登录 vaultvault token **不等于** 登录 WebAI 主体密码 **不等于** 分发给所有用户的万能 key。
---
## 5. 生产级鉴权:撤快速登录
### 5.1 产品行为
| 环境 | 登录页 | 标准邮箱/用户名密码 | `/api/dev/seed` | AI 主体密码登录 |
|------|--------|---------------------|-----------------|----------------|
| 生产 | 无快速登录 | 开 | 关 | 仅知密者(admin |
| 开发(对标生产) | **同样无快速登录** | 开 | 仅当 `ALLOW_DEV_FIXTURES=1` 的机机 seed | 同生产;本地 admin 自持密码 |
开发调试方式:
- 使用真实注册/登录流程,或本地已知 admin/测试人账号密码(**存在密码箱或本地 secret,不进 HTML**)。
- 自动化:优先 PAT;次选 control-plane seed APIfixtures 开时);**禁止**依赖「快速登录」按钮选择器作为唯一 smoke。
### 5.2 实现落点(P0
1. **删除** `AuthPage` 中测试快速登录按钮、`TEST_ACCOUNT_*` 常量注入 DOM、`data-test-password` 等。
2. 清理 `AUTH_SCRIPT` 中 quick-login 分支;表单脚本不得依赖 `quickLogin` 节点(避免 `if (!quickLogin) return` 整页失效)。
3. 更新 `gateway.rs` / 相关 SSR 测试:断言 **不包含**「测试账号快速登录」与测试密码属性。
4. `scripts/TESTING_REFERENCE.md`、AGENTS smoke 文案:改为「开发用标准登录或 PAT;fixtures 仅 seed」。
5. **可选 harden**:对 `principal_kind=ai_service` 的密码登录增加审计日志 `auth.ai_principal.login`rate limit 加强。
### 5.3 明确不做
- 不把快速登录藏到「连点 logo 十次」之类隐藏手势。
- 不在生产 HTML 注释里保留 e2e 密码。
---
## 6. Web PAT(外部 AI 访问合同)
### 6.1 产品 IA
#### `/user/ai` → 导航 **「API 访问令牌」**`#ai-api-tokens`
用户只管理 **自己的** PAT
- 创建:名称、过期、scope 勾选(白名单)。
- 列表:前缀 `mnpat_****abcd`、创建/最后使用/过期、状态。
- **Reveal(可复显)**:默认遮罩;用户点击「显示」→ 二次确认(或短时 re-auth)→ 展示明文;操作写审计。
- 撤销 / 改名 / 旋转(rotate = 废旧发新,旧 jti 立即失效)。
- 文案:用于外部 AI、脚本访问 **你的** 笔记与授权 API**不是** vault 读密 token**不是** Pi 内部会话钥匙。
#### `/admin/ai` → **「API 访问令牌」**
1. 平台级 `service` PAT(可选,窄 scope)。
2. **代签**:选择主体(普通用户 **或 AI 主体**)→ 生成该 `sub` 的 PAT + 审计。
3. 强制撤销任意 PAT、查看元数据与审计。
4.**AI 主体** 预置/轮转「给外部 AI 用的主 PAT」的运营入口(仍走同一表结构)。
### 6.2 可复显的安全模型(相对「只显示一次」)
用户明确要求 **可再显示**,因此采用:
```text
创建时:
raw = 高熵随机 或 mnpat1.<payload>.<sig>
token_hash = SHA-256(raw) // 鉴权比对
token_sealed = Seal(server_key, raw) // 可恢复密封(AEAD
token_prefix = raw 前后缀展示位
明文不进日志
Reveal
校验 session 为 owner 或 admin 代操作权限
解封 token_sealed → 返回明文一次响应
写 audit: api_token.reveal
鉴权请求:
只比对 token_hash,不走 sealed 热路径
```
约束:
- `server_key` 来自部署密钥(env / 文件 0600),**不**进库、不进前端。
- Reveal **限流**(按 user / token_id);列表默认永不自动展开。
- 若部署选择更高安全档(`PAT_REVEAL_MODE=create_only`),可编译/配置为创建后清空 sealed —— 默认产品档为 **reveal_allowed**(符合本拍板)。
- 泄露响应:revoke + 轮转;审计含 `jti` 不含 raw。
### 6.3 Token 形态与存储
```text
mnpat1.<base64url(payload)>.<base64url(sig)>
# 或 mnpat1_ + 高熵 random(服务端只存 hash + sealed
```
Claims(逻辑字段,无论 JWT 形还是 opaque):
```json
{
"v": 1,
"iss": "mnote-web",
"aud": "mnote-api",
"sub": "user:<user_id>",
"principal_kind": "human | ai_service | service",
"scope": ["notes.read", "notes.write", "tree.read", "tree.write"],
"workspace_ids": ["*"],
"jti": "...",
"iat": 0,
"exp": 0
}
```
**control-plane 表 `api_access_tokens`**
```text
id, jti,
subject_user_id, -- 权限主体
created_by_user_id, -- 签发者(admin 代签可不同于 subject
name,
token_prefix,
token_hash, -- 鉴权
token_sealed, -- 可复显密封;create_only 模式可空
scopes_json,
workspace_ids_json,
expires_at, revoked_at,
last_used_at, last_reveal_at,
created_at, updated_at
```
### 6.4 Scope 白名单(对齐「外部 AI = 笔记 CRUD」)
**P0 默认推荐(创建向导默认勾选):**
| Scope | 含义 | 外部 AI 默认 |
|-------|------|----------------|
| `notes.read` | 读页面/文件正文与元数据(授权工作区内) | ✓ |
| `notes.write` | 创建/更新/删除笔记正文(授权范围内) | ✓ |
| `tree.read` | 读树/目录投影 | ✓ |
| `tree.write` | 新建/重命名/移动/归档(`tree.*` | ✓(可默认关,产品可选) |
| `ai.settings.read` | 读自己的 effective AI 设置 | 可选 |
| `ai.usage.read` | 读用量 | 可选 |
**默认不开放(Non-Goals / 需显式高危勾选):**
| Scope | 说明 |
|-------|------|
| `ai.sessions.write` / `pi.run` | **不**作为外部 AI 主路径;避免外部 agent 驱动内部 Pi |
| `ai.admin.*` | 仅 admin session 或 admin 级 service PAT |
| `vault.resolve` | **不**塞进 Web PAT 默认;读密用 `mnv1` |
| `provider.keys.read` | 禁止;上游 key 永不经 PAT 下发明文 |
映射实现:
- 优先复用现有 `/api/mnote/tools/*` 与 local-folder / page / tree 路由的 **统一鉴权中间件**
- 每个 handler 声明 required scopes;缺 scope → **403**,不静默放大。
- `directory_grants` / membership **仍然生效**PAT 不能绕过文件夹授权。
- 写入走既有版本/冲突/watcher 纪律(对齐 7-18);外部 AI 写文件 = 授权 roots 内 patch/write,不是第二套真相。
### 6.5 鉴权中间件规则
1. 请求带 `Authorization: Bearer mnpat1…` → 走 PAT**忽略** cookie 叠加(避免权限并集歧义)。
2. 仅 cookie → 人用 session(现有行为)。
3. 绑定 `RequestContext.user_id = subject_user_id``auth_method = pat`,附带 scopes。
4. AI 主体 PAT`subject` 为 AI 用户 id,只能访问 **AI 私有空间 + 显式 share 给 AI 的资源**
5. 人用户 PAT:只能访问该用户被授权的 workspace 资源。
6. HTML SSR 管理页:**不以长期 PAT 当 cookie** 渲染全站;机机走 JSON。P2 才考虑 exchange code → 短会话。
### 6.6 调用示例
```bash
# 外部 AI:以某用户 PAT 列树 / 读写笔记
curl -H "Authorization: Bearer mnpat1.…" \
https://<host>/api/… # 具体 path 以 tools/tree/page 现网为准
# 外部 AI:以 AI 主体 PAT 写 AI 自己的知识库笔记
curl -H "Authorization: Bearer mnpat1.…" \
https://<host>/api/…
```
Agent 持有方式:环境变量或 `0600` 文件(对齐 vault token 习惯);**禁止**默认写入前端 localStorage。
---
## 7. AI 主体:自己的知识库、记录与「被分享的密码」
### 7.1 AI 私有面
AI 主体拥有与普通用户同构的能力边界,但 **默认不对普通用户可见**
| 能力 | 说明 |
|------|------|
| 笔记 / 树 | AI 自己的 workspace(或 system workspace 绑定 AI 主体) |
| 知识库 | LightRAG 命名空间绑定 AI 主体;索引 AI 私有笔记与授权语料 |
| 设置 | `/user/ai`**AI 主体 session** 下的 effective 配置;admin 可在 `/admin/ai` 看全局 |
| Vault | **AI 自己的一箱** + **他人 share-to-AI 的只读投影** |
普通用户 **不能**
- 打开 AI 主体的 FileTree / 笔记(无 membership)。
- 列出 AI 主体 vault 私有条目。
- 使用 AI 主体密码(除非 admin 运营持有)。
Admin **可以**
- 密码登录 AI 主体做检查(审计)。
- 代签 / 撤销 AI 主体的 Web PAT 与 vault token。
- 在管理面查看 AI 主体健康、用量、token 元数据(默认无 vault 明文)。
### 7.2 分享密码给 AIshare-to-AI
```text
用户 U 的 vault 条目 C
→ 用户标记 share_to_ai = true
或 创建 share grant: (item_id, grantee=ai_principal, perms=resolve|list)
→ AI 主体的 vault token / AI 读密路径可见 C
→ 用户 U 的其他条目默认不可见
→ 其他用户 V 不可见 C(除非另有 grant)
```
隔离验收:
1. 用户 A token 不能 resolve 用户 B 私有条目。
2. AI token 只能 resolveAI 自有条目 有效 share-to-AI 条目。
3. 用户 A 的 Web PAT 默认 **不能** resolve 密码;除非错误地签发了 vault scope(产品向导应拆分两步,默认不勾)。
4. 撤销 share 或 revoke token 后立即失效。
### 7.3 与 Page AIPi)的边界
| 角色 | 职责 |
|------|------|
| **Pi Lab(内部)** | 浏览器内 Page AIcookie + directory_grants;不依赖外部 PAT |
| **外部 AIPaseo/Codex/…)** | 持用户或 AI 主体 PAT,做 **笔记/树 CRUD**;可选另持 vault token 读密 |
| **禁止默认** | 外部 AI 用 PAT 驱动 `/api/page-ai/pi/*` 代聊、劫持内部 session |
若未来需要「外部系统触发一次 Pi run」,单独立项高危 scope,不进 7-76 P0 默认。
---
## 8. 密码箱:一人一箱 + 代签
### 8.1 真源模型(升级 12-1
**产品口径(冻结):密码箱跟随用户,一个用户只有一个密码箱。**
推荐存储解析:
```text
# 逻辑
VaultRoot(user_id) → 用户私有系统目录
# 物理(云 / 多用户服务器,推荐)
{control_or_data_root}/users/<user_id>/vault/**
# 物理(单机 local-first 兼容)
若部署仅有单用户绑定默认 workspace:
可仍落在该用户默认 workspace 的 .mnote/vault/**
但元数据必须带 owner_user_id,且 API 按 user_id 解析,禁止「同事共享 workspace 就共享密码箱」
```
**禁止:**
- 多个 human 共用一个无 owner 的 workspace vault 当默认。
- Agent 用通用 file 工具扫任意 vault 路径。
- 默认 actor 写死所有人共用 `mnote-e2e` 而不校验 owner(开发本机单用户过渡期除外,须文档标明)。
条目 frontmatter / index 强制:
```text
ownerUserId: <user_id>
shareToAi: bool | grant refs
```
旧数据迁移:
-`ownerUserId` → 归属「当前 workspace 的 owner 用户」或部署配置的 `MNOTE_VAULT_LEGACY_OWNER`;迁移完成前,非 owner deny。
### 8.2 Vault token claims 升级
在 12-2 `VaultTokenClaims` 上强制:
```json
{
"v": 1,
"iss": "mnote-vault",
"aud": "mnote-vault",
"sub": "user:<user_id>",
"actor": "user:<user_id>",
"principal_kind": "human | ai_service",
"scope": ["vault.list", "vault.get", "vault.resolve"],
"item_ids": [],
"include_shared_to_ai": true,
"workspace": null,
"iat": 0,
"exp": 0,
"jti": "..."
}
```
- `item_ids` 非空 = 白名单;空 = 该主体命名空间内策略允许的全部(仍受 share 与总闸)。
- AI 主体 token`include_shared_to_ai=true` 时合并分享投影。
- **Admin 代签**`created_by` 记 admin`sub` 仍是目标用户或 AI 主体;**必须审计** `vault_token.issue.delegated`
### 8.3 签发入口(同一后端)
| 入口 | 谁 | 能力 |
|------|----|------|
| `/vault` →「AI 访问」 | 用户 | 管自己的条目可见性、签发/撤销自己的 vault token |
| `/user/ai` →「密码箱 AI 访问」 | 用户 | 总闸、复制 token、测 doctor;深链避免双真源 |
| `/admin/ai` →「密码箱治理」 | Admin | 代签、强制撤销、元数据与审计;**默认不 reveal 用户密码明文** |
**总闸:**
```text
user.settings.vault_ai_access = off | resolve_only | resolve_and_login
```
总闸 off → 既有 token verify 失败或签发拒绝。
### 8.4 与 Web PAT 的关系(再强调)
| 需求 | 钥匙 |
|------|------|
| 外部 AI 增删读写用户笔记 | **Web PAT**(用户 sub |
| 外部 AI 写 AI 自己的笔记/知识库 | **Web PAT**AI 主体 sub |
| 外部 AI / skill 读密 | **Vault token mnv1**(对应用户或 AI 主体) |
| 人打开 `/vault` 编辑 | **Session cookie** |
| 扩展保存密码 | **mnext1** |
创建向导默认 **两步拆分**:「笔记 API 令牌」与「密码箱令牌」,禁止一键「全能钥匙」无 scope 确认。
---
## 9. 信息架构汇总
### `/user/ai` 侧栏
1. Overview(已有)
2. Models / Tools / Skillseffective
3. 目录权限(directory_grants,唯一授权真源)
4. **API 访问令牌**Web PAT,可复显)
5. **密码箱 AI 访问**vault token + 总闸 + share 摘要)
6. Sessions / Usage(人用 Pi;外部 AI 不依赖此写路径)
### `/admin/ai` 侧栏
1. 全局 provider / model / policy7-71
2. **API 访问令牌(平台 + 代签 + AI 主体 PAT)**
3. **密码箱治理(代签 vault token / 强制撤销 / 审计)**
4. **AI 主体**(状态、是否可登录、最近登录审计、知识库/工作区绑定)
5. **环境健康只读**:快速登录已移除、fixtures 开关状态、PAT reveal 模式
### `/auth`
- 仅标准登录/注册。
- 无测试快速登录、无测试密码 DOM。
### `/vault`
-**当前登录用户** 的唯一密码箱工作。
- 条目:**允许 AI 使用 / 分享给 AI**。
- AI 访问令牌入口深链到 `/user/ai#vault-ai-access`
---
## 10. 分阶段落地
| 阶段 | 内容 | 验收要点 | 状态(2026-07-26 核验) |
|------|------|----------|-------------------------|
| **P0** | 删除快速登录 + 修脚本/测试/文档;AI 主体字段标记最小落库 | 任意环境 HTML 无测试密码;标准登录可用 | **done** |
| **P0.5** | PAT API + Bearer 中间件 + `/user/ai`·`/admin/ai` PAT UIsealed 复显) | 外部 AI 用 PAT 完成 notes/tree 读写 | **done**(代码+单测;端到端 smoke 建议补) |
| **P1** | 用户级 vault 路径 + share-to-AI + vault 区 | A 不能读 BAI 能读 share | **done(主路径)**:复用 `users/<id>/…` + 12-x share-to-AIWeb 签发 vault token |
| **P1b** | Admin 代签 PAT/vault token、撤销、审计;AI 主体运营 | 代签有 `created_by` 审计 | **done** |
| **P2** | PAT exchange 短会话、break-glass、service 细粒度、`PAT_REVEAL_MODE=create_only` | 高级场景 | **deferred** |
**实现顺序回顾:** P0 → P0.5 → P1/P1b 已合入主工作区;P2 不阻塞产品主路径。
---
## 11. 安全边界清单
1. 凭证分职,禁止用 vault token 调笔记 API 或用 PAT 当 master key。
2. PAT 与 cookie 不同时并权。
3. Scope 白名单 + handler 强制;默认不含 Pi 写入。
4. directory_grants / membership 不可绕过。
5. PAT reveal 可审计、限流;日志无 raw token。
6. AI 主体私有资源对普通用户 403。
7. share-to-AI 显式;撤销即时。
8. Admin 代签全审计;默认不 reveal 他人 vault 明文。
9. 扩展 token 禁止进 agent。
10. 开发对标生产:无登录页后门。
---
## 12. 测试与验收
> 勾选口径:**[x] = 代码已实现且本轮有单测/静态核验证据**;未勾选 = 未做独立 e2e 或仅结构依赖、需后续补测。
> 核验日:2026-07-26。证据:`auth.rs` / `api_access_token.rs` / `middleware/request_context.rs` / `ai_admin` UI / `cargo test -p mnote-web --lib` 定向套件。
### 12.1 P0 鉴权
- [x] `/auth` HTML 不含「测试账号快速登录」、`data-test-password`、硬编码 e2e 密码属性。
- [x] 标准注册/登录仍可用(`auth_api_*` 单测通过)。
- [x] `ALLOW_DEV_FIXTURES=0``/api/dev/seed` 403(既有 fail-closed 保留)。
- [x] 相关单元/SSR 测试已改断言(`auth_entry_uses_mnote_web_login_ui_when_compat_enabled`)。
### 12.2 Web PAT
- [x] 用户可创建/列表/撤销/复显自己的 PAT(`/api/ai-tokens*` + UI)。
- [x] Bearer 可 `notes.read/write`(及 tree scope):middleware + documents/tree/tools `ensure_scope`
- [x] 缺 scope 403`ensure_scope_session_passes_pat_requires` 单测)。
- [ ] 用户 A 的 PAT 不能写用户 B 的私有笔记(**结构上**仍受 directory_grants/membership**无独立 PAT 跨用户 e2e**)。
- [ ] AI 主体 PAT 不能读普通用户未授权笔记(同上,依赖 grants,**无独立 e2e**)。
- [x] 默认不能调用 Pi run APIPAT scope 白名单不含 Pi;无 `pi.run`)。
- [x] reveal 产生审计(`api_token.reveal`)+ 30s 限流生效(代码路径)。
### 12.3 Vault 多用户
- [x] 每用户一箱路径解析:`users/<id>/workspaces/my-space` + AI vault 路径(12-x / vault-core)。
- [ ] owner mismatch → denyshare 模型与路径隔离已有;**全量 owner 字段强制校验未单独立项闭环**)。
- [x] share-to-AI 后 AI resolve / 取消分享:12-x vault-core share/unshare 能力与单测保留。
- [x] Admin 代签 vault token 有审计(`POST /api/vault/ai/token` + `vault_token.issue(.delegated)`)。
- [x] 通用 file/RAG 仍排除 vault(12-1 既有门闩,本轮未回退)。
### 12.4 AI 主体
- [x] `mnote-e2e` 注册标记 `role=ai_service``/api/ai-admin/users` 返回 `principalKind`
- [ ] 普通用户 session 无法打开 AI 主体工作区(依赖 membership/默认私有 workspace**无专用 403 e2e**)。
- [ ] Admin 密码登录 AI 主体成功且有**专用**审计事件(通用登录可用;**未单独埋 `auth.ai_principal.login` e2e**)。
- [ ] AI 知识库命名空间隔离冒烟(**未做本轮专项 smoke**)。
### 12.5 残留 / 下一步(不回退已勾选)
1. 补 PAT 跨用户拒绝 e2eA token → B 笔记 403)。
2. 补 AI 主体工作区拒绝 / 知识库隔离浏览器 smoke。
3. 可选:PAT 迁 control-plane 表;AI 主体专用登录审计。
4. P2 整包延后。
5. 零散脚本:`scripts/test-handle-qa.js` 已改为标准登录(本轮核验时修正)。
---
## 13. 与现有设计的衔接 / 覆盖关系
| 文档 | 关系 |
|------|------|
| **7-71** | 在 effective AI 配置之上增加 Token / AI 主体运营区段;不另起第二套 provider 真相。 |
| **7-18** | 外部 AI 写笔记仍受 AiAccessScope / allowed roots / 冲突模型约束;本设计补 **谁持何种凭证进入**。 |
| **12-1** | 工作台 UI 保留;**存储归属**从 workspace 单库升级为 **用户一箱**(本文 §8 为增量合同)。 |
| **12-2** | vaultd + `mnv1` 路径保留;claims 强制 user/AI ownerWeb 代签与多用户 verify 为增量。 |
| **12-3** | `mnext1` 仍仅扩展;不与 PAT/mnv1 合并。 |
| **AGENTS / TESTING_REFERENCE** | 删除「优先测试账号快速登录」作为生产或默认真路径的表述。 |
后续若 vault 用户根路径实现细节膨胀,可再拆 `12-4-user-scoped-vault-and-share-to-ai-v1.md`,但 **产品口径以本文为 SSOT**,避免双源。
---
## 14. 开放实现细节(不阻塞 P0)
1. AI 主体 username 是否长期保留 `mnote-e2e` 字符串,或迁移为 `system-ai`(兼容 id 映射)。
2. `VaultRoot` 物理根:control-plane data dir vs 用户私有默认 workspace —— 实现选一种并写迁移脚本。
3. Web PAT 用 opaque+hash 还是可验证 signed payload —— 两者均可,表结构已支持。
4. 外部笔记 CRUD 优先挂现有 `mnote_agent_tools` 还是一组更窄的 REST 资源路由 —— 建议 **先中间件 + 现有 tools/tree/page**,少造表面。
---
## 15. 建议 PR 切片
| PR | 内容 | 风险 |
|----|------|------|
| PR-A | 删除快速登录 + 测试/文档 | 低;立刻降公网面 |
| PR-B | `api_access_tokens` schema + middleware + 单测 | 中 |
| PR-C | `/user/ai` PAT UI(创建/列表/撤销/reveal | 中 |
| PR-D | notes/tree 路由 scope 接线 + 外部 AI smoke | 中 |
| PR-E | 用户级 vault root + owner + share-to-AI | 高(数据迁移) |
| PR-F | admin 代签 + AI 主体运营 + 审计 | 中 |
---
## 16. 完成定义(Definition of Done
当下列全部成立时,本稿可迁 `done/`
| # | 条件 | 状态 |
|---|------|------|
| 1 | 登录页无快速登录后门,开发/生产行为一致 | **满足**P0 |
| 2 | 外部 AI 可仅凭 Web PAT 在授权范围内笔记 CRUD,无需 cookie/Pi | **代码满足**;建议补浏览器/API smoke |
| 3 | AI 主体隔离笔记/知识库,仅密码持有者可人登检查 | **部分满足**(角色标记+默认 workspace;缺专用 e2e |
| 4 | 密码箱用户隔离 + share-to-AIvault token 与 PAT 分职 | **主路径满足** |
| 5 | Admin 代签与 reveal/revoke 有审计;文档与 smoke 一致 | **满足**(残留见 §12.5 |
**结论:** 不迁 `done/` 直至 §12 未勾选项(跨用户 e2e / AI 主体工作区 / KB 隔离 smoke)补齐或明确降级为 follow-up。P0–P1b 产品主路径可按 `PROCESS` 继续使用。
---
## 17. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-07-26 | 初版:吸收 Paseo `28e19bd9-…` 讨论与用户六条拍板;落盘 `7-76`。 |
| 2026-07-26 | 执行:P0P1b 落地(见 `7-76-execution-goal-v1.md`);P2 延后。 |
| 2026-07-26 | 核验并勾选 §10/§12 已完成项;修正 `test-handle-qa.js` 残留快速登录;§12.5 记录残留。 |
@@ -0,0 +1,60 @@
# Goal:执行 7-76AI 主体 · Web PAT · 鉴权硬化 · 用户级密码箱)
> 创建:2026-07-26
> 状态:`P0P1b DONE`P2 deferred;§12 部分 e2e 残留见设计稿 12.5)
> 设计 SSOT`design/07-ai/process/7-76-ai-principal-web-pat-and-auth-hardening-v1.md`
> 触发:用户要求「设置 goal,开始执行 7-76」
## Goal 文案(可贴任务板)
**在 7-76 边界下,分阶段交付:开发对标生产鉴权、Web PAT 机机访问、AI 主体隔离面、一人一密码箱 + share-to-AI;本迭代先完成 P0(撤登录页快速登录后门并校准测试/文档/smoke 登录路径)。**
## 成功标准(整条 7-76
1. `/auth` 无测试快速登录、无测试密码 DOM;开发与生产登录形态一致。
2. 外部 AI 可凭 Web PAT 在授权范围内完成笔记/树 CRUD,默认不接 Pi。
3. `mnote-e2e` 为 AI 主体:私有笔记/知识库/vault;普通用户不可进。
4. 密码箱按用户隔离;share-to-AI 可验;admin 代签可审计。
5. cookie / PAT / mnv1 / mnext1 分职。
## 阶段看板
| 阶段 | 内容 | 状态 |
|------|------|------|
| **P0** | 删除快速登录;测试/文档/smoke 改标准登录或 API | `done` |
| **P0.5** | `api_access_tokens` + Bearer 中间件 + `/user/ai` PAT UI(可复显) | `done` |
| **P1** | 用户级 vault root + owner + share-to-AI | `done`(复用既有一人一箱路径 + share-to-AI;补 Web 签发 vault token |
| **P1b** | Admin 代签 PAT/vault token、AI 主体运营、审计 | `done` |
| **P2** | exchange 短会话、break-glass、reveal 模式开关等 | `deferred`(非本轮阻塞) |
## P0 Checklist
- [x] `AuthPage` 移除快速登录按钮、`TEST_ACCOUNT_*` DOM、quickLogin 脚本分支
- [x] `gateway` SSR 测试:断言 **不存在** 快速登录与 `data-test-password`
- [x] `scripts/tree-shell-smoke-helpers.js` UI 登录改为表单标准登录
- [x] 各 smoke 中「点击测试账号快速登录」改为表单/API(共享 helper)
- [x] `AGENTS.md` / `TESTING_REFERENCE.md` 去掉「优先快速登录」
- [x] `cargo test -p mnote-web` 定向 auth 相关通过
## 当前切片
- **已完成:** P0 → P1b 主交付(P2 延后)
- **实现要点:** 见下节「交付清单」
## 交付清单(本轮代码)
- [x] P0 撤 `/auth` 快速登录;smoke 标准登录 helper
- [x] Web PAT`/api/ai-tokens*` 签发/列表/复显/吊销;sealed 可复显
- [x] Bearer `mnpat1.*` 中间件;PAT 忽略 cookiescope 接线 notes/tree/tools
- [x] `/user/ai``/admin/ai` UI:API 访问令牌 + 密码箱 AI 访问
- [x] vault agent token`POST /api/vault/ai/token`(本人/admin 代签 + 审计)
- [x] AI 主体:`mnote-e2e` 注册 `role=ai_service`;用户列表 `principalKind`
- [x] 密码箱 share-to-AI / 用户 vault 路径沿用 12-x 既有实现
## 变更记录
| 日期 | 说明 |
|------|------|
| 2026-07-26 | 建 goal;完成 P0P1b 实现与定向单测。 |
| 2026-07-26 | **方案 A 账户分离**:新建 `mnote-admin`ops);`liaibo`/`mnote-e2e` 去 admin`mnote-e2e``ai_service``MNOTE_ADMIN_USER_IDS` + `access-policy.admins` 仅 adminPi Lab smoke admin seed 改 `withAdminBrowserSession`。 |
| 2026-07-26 | 对照代码核验;设计稿 §12 勾选已完成项;修 `test-handle-qa.js` 残留快速登录。 |
+1 -1
View File
@@ -32,4 +32,4 @@
- Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线以 `design/04-tree-domain/done/4-24-*``design/05-editor-mainline/done/5-12-*` 为准。 - Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线以 `design/04-tree-domain/done/4-24-*``design/05-editor-mainline/done/5-12-*` 为准。
- 历史 Convex File Tree、`index.md` 可见模型和 10-review 初轮执行清单均已归档。 - 历史 Convex File Tree、`index.md` 可见模型和 10-review 初轮执行清单均已归档。
- `done/07-vscode-explorer-filetree-trash-gap-review.md` 是 07-ai 开发前对 04-tree 文件树 / 页面树多选、默认删除进垃圾箱、垃圾箱恢复与永久删除、资源级 `tree.resource.*`、Convex purge 同步、VSCode Explorer 体验对标的增量审查归档。该审查 checklist 已闭合;后续仍应按文档中的“最低可用完成 / parity backlog”口径描述 VSCode Explorer 对标,不要把禁用态或待增强项说成完整 parity。 - `done/07-vscode-explorer-filetree-trash-gap-review.md` 是 07-ai 开发前对 04-tree 文件树 / 页面树多选、默认删除进垃圾箱、垃圾箱恢复与永久删除、资源级 `tree.resource.*`、Convex purge 同步、VSCode Explorer 体验对标的增量审查归档。该审查 checklist 已闭合;后续仍应按文档中的“最低可用完成 / parity backlog”口径描述 VSCode Explorer 对标,不要把禁用态或待增强项说成完整 parity。
- 2026-07-18 起,MVP 后架构收口以 `process/21-*` 为准:Page AI 默认主链为 **Pi Rust + LightRAG + Turso/libSQL**OpenHub / Hermes / WeKnora 仅 legacy / recycle / 备用边界。 - 2026-07-18 起,MVP 后架构收口以 `process/21-*` 为准:Page AI 默认主链为 **Pi Rust + LightRAG + Turso/libSQL**OpenHub / Hermes 仅 legacy / recycle / 备用边界。
@@ -42,7 +42,7 @@
- OpenHub / opencode 完整应用嵌入 - OpenHub / opencode 完整应用嵌入
- Hermes / Reasonix / ACP 默认 Page AI runtime - Hermes / Reasonix / ACP 默认 Page AI runtime
- WeKnora 默认知识库 provider - 非 LightRAG 默认知识库 provider
- Convex 默认控制面或正文/附件/AI 会话主存储 - Convex 默认控制面或正文/附件/AI 会话主存储
- BlockNote 默认编辑器 - BlockNote 默认编辑器
- Next App Router 3000 主入口 - Next App Router 3000 主入口
@@ -51,7 +51,7 @@
## 3. 硬边界(全程禁止) ## 3. 硬边界(全程禁止)
- 不把正文、附件、mindmap、Office 文件迁入 Turso/libSQL。 - 不把正文、附件、mindmap、Office 文件迁入 Turso/libSQL。
- 不恢复 OpenHub / Hermes / WeKnora 为默认入口默认 provider。 - 不恢复 OpenHub / Hermes 为默认入口;知识库默认 provider 保持 LightRAG
- 不为「更正规」把 control-plane 换成 Postgres / 新云数据库。 - 不为「更正规」把 control-plane 换成 Postgres / 新云数据库。
- 不把 tiptap / Page Aggregate / LightRAG storage 当成正文真相。 - 不把 tiptap / Page Aggregate / LightRAG storage 当成正文真相。
- 不新增基于轮询的主链数据刷新。 - 不新增基于轮询的主链数据刷新。
+5 -5
View File
@@ -37,7 +37,7 @@ export MNOTE_TURSO_SYNC_INTERVAL_MS=5000
- 本地 `.md`、附件、mindmap、OnlyOffice 文件仍是 local-first 文件真相,不迁入 Turso。 - 本地 `.md`、附件、mindmap、OnlyOffice 文件仍是 local-first 文件真相,不迁入 Turso。
- Turso 只承接 Rust control-plane 元数据:auth、session、workspace、grant、AI policy/runtime、audit/outbox 等。 - Turso 只承接 Rust control-plane 元数据:auth、session、workspace、grant、AI policy/runtime、audit/outbox 等。
- `dev-hot``desktop-hot`、prod runtime 默认使用 `libsql-local``sqlite` 不再是 mnote-web 运行时后端。 - `dev-hot``mnote-web-hot`、prod runtime 默认使用 `libsql-local``sqlite` 不再是 mnote-web 运行时后端。
- `turso-remote` / `turso-local-replica` / `turso-synced` 只作为 dev DB dry-run、迁移演练、回滚演练和后续受保护 smoke。 - `turso-remote` / `turso-local-replica` / `turso-synced` 只作为 dev DB dry-run、迁移演练、回滚演练和后续受保护 smoke。
- remote 成为长期候选默认前,必须先完成 Argon2id password hash migration、remote smoke、rollback export 和 token 轮换策略。 - remote 成为长期候选默认前,必须先完成 Argon2id password hash migration、remote smoke、rollback export 和 token 轮换策略。
@@ -123,7 +123,7 @@ cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-a
--backup-existing --backup-existing
``` ```
备份文件仅用于审计、对照和后续手工恢复,不作为 `npm run desktop:hot` / `npm run dev:hot` 启动后端。 备份文件仅用于审计、对照和后续手工恢复,不作为 `npm run dev:hot` / `npm run dev:hot` 启动后端。
## 验证命令 ## 验证命令
@@ -134,7 +134,7 @@ cargo test --manifest-path rust/Cargo.toml -p control-plane --features turso-uni
cargo test --manifest-path rust/Cargo.toml -p mnote-web app_state_initializes_control_plane_store_for_tests cargo test --manifest-path rust/Cargo.toml -p mnote-web app_state_initializes_control_plane_store_for_tests
cargo test --manifest-path rust/Cargo.toml -p mnote-web dev_seed -- --test-threads=1 cargo test --manifest-path rust/Cargo.toml -p mnote-web dev_seed -- --test-threads=1
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_watcher -- --test-threads=1 cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_watcher -- --test-threads=1
node --test scripts/desktop-hot.test.js node --test scripts/mnote-web-hot.test.js
node scripts/task-dev-hot-plan-test.js node scripts/task-dev-hot-plan-test.js
node scripts/task-control-plane-admin-libsql-roundtrip-smoke.js node scripts/task-control-plane-admin-libsql-roundtrip-smoke.js
codegraph sync . codegraph sync .
@@ -152,7 +152,7 @@ rg -n "sqlite3|better-sqlite3|node:sqlite|MNOTE_CONTROL_PLANE_DB_PATH|control-pl
- `control-plane-admin` CLI - `control-plane-admin` CLI
- `control-plane-admin` 的 SQLite 迁移 / 导出参数 - `control-plane-admin` 的 SQLite 迁移 / 导出参数
- `scripts/lib/control-plane-test-env.js` 的显式测试后端参数 - `scripts/lib/control-plane-test-env.js` 的显式测试后端参数
- `desktop-hot` / `dev-hot` / `prod-build-start``sqlite` runtime 的拒绝逻辑 - `mnote-web-hot` / `dev-hot` / `prod-build-start``sqlite` runtime 的拒绝逻辑
- legacy `evidence.sqlite` / local search 测试边界 - legacy `evidence.sqlite` / local search 测试边界
不允许命中范围: 不允许命中范围:
@@ -265,7 +265,7 @@ Tantivy FTS 当前不进入 control-plane
- Turso FTS 是 `CREATE INDEX ... USING fts` + `fts_match` / `fts_score` / `fts_highlight`,不是 SQLite FTS5 的无成本替换。 - Turso FTS 是 `CREATE INDEX ... USING fts` + `fts_match` / `fts_score` / `fts_highlight`,不是 SQLite FTS5 的无成本替换。
- 当前 control-plane 只存轻量元数据,全文搜索收益有限。 - 当前 control-plane 只存轻量元数据,全文搜索收益有限。
- 页面正文、知识库和 RAG 仍走 local-first 文件 / OpenHub / WeKnora,不迁入 Turso control-plane。 - 页面正文、知识库和 RAG 仍走 local-first 文件 / OpenHub / LightRAG,不迁入 Turso control-plane。
- 后续如需搜索页面标题、路径、AI 会话标题或轻量摘要,可单独做 P2/P3 spike。 - 后续如需搜索页面标题、路径、AI 会话标题或轻量摘要,可单独做 P2/P3 spike。
Browser WASM / OPFS 当前不进入主线: Browser WASM / OPFS 当前不进入主线:
-1
View File
@@ -4,7 +4,6 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node scripts/dev-hot.js", "dev": "node scripts/dev-hot.js",
"desktop:hot": "node scripts/desktop-hot.js",
"dev:hot": "node scripts/dev-hot.js", "dev:hot": "node scripts/dev-hot.js",
"prod:start": "node scripts/prod-build-start.js" "prod:start": "node scripts/prod-build-start.js"
}, },
+203 -2
View File
@@ -166,6 +166,46 @@ pub fn sign_config(config: &Value, secret: &str) -> Result<OnlyOfficeSignTokens,
}) })
} }
/// 校验 OnlyOffice Document Server 回调 JWTHS256)。
/// - `secret` 为空:返回 `Ok(None)`(与 `sign_config` 一致,本地未配置 JWT 时放行)。
/// - `secret` 非空:必须提供有效 token,成功时返回解码后的 payload。
pub fn verify_callback_jwt(
token: Option<&str>,
secret: &str,
) -> Result<Option<Value>, String> {
let normalized_secret = normalize_secret(secret);
if normalized_secret.is_empty() {
return Ok(None);
}
let raw = token
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "OnlyOffice 回调缺少 JWT token".to_string())?;
let payload = verify_hs256(raw, &normalized_secret)?;
Ok(Some(payload))
}
/// 从回调 body / Authorization 提取 JWT 字符串。
/// 优先 body.token;其次 Authorization: Bearer <jwt>。
pub fn extract_callback_jwt(body: &Value, authorization: Option<&str>) -> Option<String> {
if let Some(token) = body.get("token").and_then(Value::as_str) {
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let auth = authorization?.trim();
let bearer = auth
.strip_prefix("Bearer ")
.or_else(|| auth.strip_prefix("bearer "))?;
let trimmed = bearer.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn resolve_session(input: OnlyOfficeSessionResolveInput) -> OnlyOfficeSessionResolveResult { pub fn resolve_session(input: OnlyOfficeSessionResolveInput) -> OnlyOfficeSessionResolveResult {
let session_id = input let session_id = input
.session_id .session_id
@@ -281,7 +321,7 @@ pub fn prepare_proxy_request(
.host_str() .host_str()
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?; .ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
if is_private_ipv4(hostname) if is_private_or_local_ip_literal(hostname)
&& !is_local_hostname(hostname) && !is_local_hostname(hostname)
&& !allowed_hostnames.contains(hostname) && !allowed_hostnames.contains(hostname)
{ {
@@ -490,6 +530,53 @@ fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
Ok(format!("{signing_input}.{signature_part}")) Ok(format!("{signing_input}.{signature_part}"))
} }
fn verify_hs256(token: &str, secret: &str) -> Result<Value, String> {
let mut parts = token.split('.');
let header_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
let payload_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
let signature_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
if parts.next().is_some() {
return Err("JWT 格式无效".to_string());
}
let header_bytes = URL_SAFE_NO_PAD
.decode(header_part)
.map_err(|_| "JWT header 无法解码".to_string())?;
let header: Value = serde_json::from_slice(&header_bytes)
.map_err(|_| "JWT header 不是合法 JSON".to_string())?;
let alg = header
.get("alg")
.and_then(Value::as_str)
.unwrap_or_default();
if !alg.eq_ignore_ascii_case("HS256") {
return Err(format!("不支持的 JWT alg: {alg}"));
}
let signature = URL_SAFE_NO_PAD
.decode(signature_part)
.map_err(|_| "JWT signature 无法解码".to_string())?;
let signing_input = format!("{header_part}.{payload_part}");
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
mac.update(signing_input.as_bytes());
mac.verify_slice(&signature)
.map_err(|_| "JWT 签名校验失败".to_string())?;
let payload_bytes = URL_SAFE_NO_PAD
.decode(payload_part)
.map_err(|_| "JWT payload 无法解码".to_string())?;
serde_json::from_slice(&payload_bytes).map_err(|_| "JWT payload 不是合法 JSON".to_string())
}
fn decode_base64url_to_utf8(input: &str) -> Result<String, String> { fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
let bytes = URL_SAFE_NO_PAD let bytes = URL_SAFE_NO_PAD
.decode(input.trim()) .decode(input.trim())
@@ -498,7 +585,11 @@ fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
} }
fn is_local_hostname(hostname: &str) -> bool { fn is_local_hostname(hostname: &str) -> bool {
matches!(hostname, "127.0.0.1" | "localhost" | "host.docker.internal") let h = hostname.trim().trim_matches(|c| c == '[' || c == ']');
matches!(
h,
"127.0.0.1" | "localhost" | "host.docker.internal" | "::1" | "0:0:0:0:0:0:0:1"
)
} }
fn is_private_ipv4(hostname: &str) -> bool { fn is_private_ipv4(hostname: &str) -> bool {
@@ -522,6 +613,88 @@ fn is_private_ipv4(hostname: &str) -> bool {
|| a == 0 || a == 0
} }
/// Block IPv6 loopback / ULA / link-local / IPv4-mapped / IPv4-compatible private literals.
fn is_private_or_local_ip_literal(hostname: &str) -> bool {
let h = hostname.trim().trim_matches(|c| c == '[' || c == ']');
if is_private_ipv4(h) {
return true;
}
let lower = h.to_ascii_lowercase();
if lower == "::1" || lower == "0:0:0:0:0:0:0:1" {
return true;
}
// link-local fe80::/10 and ULA fc00::/7
if lower.starts_with("fe80:")
|| lower.starts_with("fc")
|| lower.starts_with("fd")
|| lower.starts_with("fe8")
|| lower.starts_with("fe9")
|| lower.starts_with("fea")
|| lower.starts_with("feb")
{
return true;
}
// IPv4-mapped ::ffff:a.b.c.d
if let Some(v4) = lower.strip_prefix("::ffff:") {
if is_private_ipv4(v4) || ipv4_compat_hextets_are_private(v4) {
return true;
}
}
// IPv4-compatible / compressed forms embedding private IPv4:
// ::127.0.0.1, ::7f00:1, 0:0:0:0:0:0:7f00:1, ::ffff:7f00:1
if let Some(embedded) = extract_embedded_ipv4_from_v6_literal(&lower) {
if is_private_ipv4(&embedded) {
return true;
}
}
false
}
fn ipv4_compat_hextets_are_private(tail: &str) -> bool {
// tail may already be dotted quad or two hextets like 7f00:1
if is_private_ipv4(tail) {
return true;
}
extract_ipv4_from_two_hextets(tail)
.map(|v4| is_private_ipv4(&v4))
.unwrap_or(false)
}
fn extract_embedded_ipv4_from_v6_literal(lower: &str) -> Option<String> {
if let Some(v4) = lower.strip_prefix("::") {
if is_private_ipv4(v4) {
return Some(v4.to_string());
}
if let Some(dotted) = extract_ipv4_from_two_hextets(v4) {
return Some(dotted);
}
}
// full form ...:0:0:a.b.c.d or ...:7f00:1
let parts: Vec<&str> = lower.split(':').filter(|p| !p.is_empty()).collect();
if parts.len() >= 2 {
let last2 = format!("{}:{}", parts[parts.len() - 2], parts[parts.len() - 1]);
if let Some(dotted) = extract_ipv4_from_two_hextets(&last2) {
return Some(dotted);
}
let last = parts[parts.len() - 1];
if is_private_ipv4(last) {
return Some(last.to_string());
}
}
None
}
fn extract_ipv4_from_two_hextets(tail: &str) -> Option<String> {
let (hi, lo) = tail.split_once(':')?;
let hi = u16::from_str_radix(hi, 16).ok()?;
let lo = u16::from_str_radix(lo, 16).ok()?;
let a = (hi >> 8) as u8;
let b = (hi & 0xff) as u8;
let c = (lo >> 8) as u8;
let d = (lo & 0xff) as u8;
Some(format!("{a}.{b}.{c}.{d}"))
}
fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> { fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
let value = raw?.trim(); let value = raw?.trim();
if value.is_empty() { if value.is_empty() {
@@ -579,6 +752,34 @@ mod tests {
assert!(tokens.editor_config_token.is_some()); assert!(tokens.editor_config_token.is_some());
} }
#[test]
fn verify_callback_jwt_roundtrip_and_rejects_tamper() {
let payload = json!({"status": 2, "url": "http://example/file.docx", "key": "k1"});
let token = sign_hs256(&payload, "secret").expect("sign");
let verified = verify_callback_jwt(Some(&token), "secret")
.expect("verify")
.expect("payload present");
assert_eq!(verified.get("status").and_then(Value::as_i64), Some(2));
assert!(verify_callback_jwt(Some(&token), "wrong").is_err());
assert!(verify_callback_jwt(None, "secret").is_err());
assert!(verify_callback_jwt(Some(&token), "").unwrap().is_none());
}
#[test]
fn extract_callback_jwt_prefers_body_then_bearer() {
let body = json!({"token": " body.jwt ", "status": 2});
assert_eq!(
extract_callback_jwt(&body, Some("Bearer header.jwt")),
Some("body.jwt".into())
);
let body_empty = json!({"status": 2});
assert_eq!(
extract_callback_jwt(&body_empty, Some("Bearer header.jwt")),
Some("header.jwt".into())
);
assert_eq!(extract_callback_jwt(&body_empty, None), None);
}
#[test] #[test]
fn proxy_request_rewrites_supabase_path_to_internal_origin() { fn proxy_request_rewrites_supabase_path_to_internal_origin() {
let encoded_url = URL_SAFE_NO_PAD.encode( let encoded_url = URL_SAFE_NO_PAD.encode(
+49 -2
View File
@@ -48,7 +48,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1); static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1);
static MINDMAP_UID_COUNTER: AtomicU64 = AtomicU64::new(1); static MINDMAP_UID_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub struct BridgeContext { pub struct BridgeContext {
pub deployment_id: Option<String>, pub deployment_id: Option<String>,
pub project_id: Option<String>, pub project_id: Option<String>,
@@ -67,6 +67,31 @@ pub struct BridgeContext {
pub dry_run: bool, pub dry_run: bool,
} }
impl std::fmt::Debug for BridgeContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BridgeContext")
.field("deployment_id", &self.deployment_id)
.field("project_id", &self.project_id)
.field("request_id", &self.request_id)
.field("trace_id", &self.trace_id)
.field("actor_type", &self.actor_type)
.field("actor_id", &self.actor_id)
.field("session_id", &self.session_id)
.field("workspace_id", &self.workspace_id)
.field("tenant_id", &self.tenant_id)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "<redacted>"),
)
.field("source_channel", &self.source_channel)
.field("source_client", &self.source_client)
.field("idempotency_key", &self.idempotency_key)
.field("validate_only", &self.validate_only)
.field("dry_run", &self.dry_run)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum BridgeErrorKind { pub enum BridgeErrorKind {
Validation, Validation,
@@ -271,7 +296,7 @@ pub enum RuntimeInput {
}, },
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RuntimeBridgeContextWire { pub struct RuntimeBridgeContextWire {
pub deployment_id: Option<String>, pub deployment_id: Option<String>,
@@ -288,6 +313,28 @@ pub struct RuntimeBridgeContextWire {
pub dry_run: bool, pub dry_run: bool,
} }
impl std::fmt::Debug for RuntimeBridgeContextWire {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeBridgeContextWire")
.field("deployment_id", &self.deployment_id)
.field("project_id", &self.project_id)
.field("workspace_id", &self.workspace_id)
.field("request_id", &self.request_id)
.field("trace_id", &self.trace_id)
.field("actor", &self.actor)
.field("source", &self.source)
.field("tenant_id", &self.tenant_id)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "<redacted>"),
)
.field("idempotency_key", &self.idempotency_key)
.field("validate_only", &self.validate_only)
.field("dry_run", &self.dry_run)
.finish()
}
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RuntimeActorWire { pub struct RuntimeActorWire {
@@ -46,7 +46,7 @@ enum Backend {
TursoSynced, TursoSynced,
} }
#[derive(Debug, Clone)] #[derive(Clone)]
struct BackendConfig { struct BackendConfig {
backend: Backend, backend: Backend,
sqlite_path: Option<PathBuf>, sqlite_path: Option<PathBuf>,
@@ -57,13 +57,60 @@ struct BackendConfig {
turso_token: Option<String>, turso_token: Option<String>,
} }
#[derive(Debug, Clone)] impl std::fmt::Debug for BackendConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BackendConfig")
.field("backend", &self.backend)
.field("sqlite_path", &self.sqlite_path)
.field("libsql_local_path", &self.libsql_local_path)
.field("replica_path", &self.replica_path)
.field("synced_path", &self.synced_path)
.field("turso_url", &self.turso_url)
.field(
"turso_token",
&self.turso_token.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
#[derive(Clone)]
struct Args { struct Args {
command: String, command: String,
flags: BTreeMap<String, String>, flags: BTreeMap<String, String>,
switches: BTreeSet<String>, switches: BTreeSet<String>,
} }
impl std::fmt::Debug for Args {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// flags 可能含 password / turso-token 等敏感值
const SENSITIVE_FLAGS: &[&str] = &[
"password",
"turso-token",
"token",
"auth-token",
"secret",
"api-key",
];
let redacted_flags: BTreeMap<&String, String> = self
.flags
.iter()
.map(|(k, v)| {
let key_l = k.to_ascii_lowercase();
let hide = SENSITIVE_FLAGS
.iter()
.any(|s| key_l == *s || key_l.contains(s));
(k, if hide { "<redacted>".into() } else { v.clone() })
})
.collect();
f.debug_struct("Args")
.field("command", &self.command)
.field("flags", &redacted_flags)
.field("switches", &self.switches)
.finish()
}
}
enum StoreHandle { enum StoreHandle {
Sqlite(SqliteControlPlaneStore), Sqlite(SqliteControlPlaneStore),
Turso(TursoControlPlaneStore), Turso(TursoControlPlaneStore),
@@ -887,7 +934,7 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
"backend": format!("{:?}", config.backend), "backend": format!("{:?}", config.backend),
"planned": [ "planned": [
"run schema migrations", "run schema migrations",
"upsert default e2e/admin user", "upsert default ops admin user (mnote-admin)",
"create password identity", "create password identity",
"upsert default local workspace", "upsert default local workspace",
"grant write access", "grant write access",
@@ -900,20 +947,46 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
} }
let store = open_store(&config)?; let store = open_store(&config)?;
// 7-76 方案 A:默认 init 主体是 ops adminmnote-admin),不是 AI 主体 mnote-e2e。
let user_id = flag_or_env(args, "user-id", "MNOTE_CONTROL_PLANE_INIT_USER_ID") let user_id = flag_or_env(args, "user-id", "MNOTE_CONTROL_PLANE_INIT_USER_ID")
.unwrap_or_else(|| "mnote-e2e".to_string()); .unwrap_or_else(|| "mnote-admin".to_string());
let email = flag_or_env(args, "email", "MNOTE_CONTROL_PLANE_INIT_EMAIL") let email = flag_or_env(args, "email", "MNOTE_CONTROL_PLANE_INIT_EMAIL")
.unwrap_or_else(|| "mnote.e2e@example.com".to_string()); .unwrap_or_else(|| "mnote.admin@example.com".to_string());
let username = flag_or_env(args, "username", "MNOTE_CONTROL_PLANE_INIT_USERNAME") let username = flag_or_env(args, "username", "MNOTE_CONTROL_PLANE_INIT_USERNAME")
.unwrap_or_else(|| "mnote-e2e".to_string()); .unwrap_or_else(|| "mnote-admin".to_string());
let display_name = flag_or_env( let display_name = flag_or_env(
args, args,
"display-name", "display-name",
"MNOTE_CONTROL_PLANE_INIT_DISPLAY_NAME", "MNOTE_CONTROL_PLANE_INIT_DISPLAY_NAME",
) )
.unwrap_or_else(|| username.clone()); .unwrap_or_else(|| username.clone());
let password = flag_or_env(args, "password", "MNOTE_CONTROL_PLANE_INIT_PASSWORD") // 禁止硬编码默认管理员密码。生产/本地 init 必须显式传入:
.unwrap_or_else(|| "MnoteE2E123!".to_string()); // --password <secret> 或 MNOTE_CONTROL_PLANE_INIT_PASSWORD
// 仅当显式打开不安全开关时,才允许本地已知默认口令(仅 smoke)。
let password = match flag_or_env(args, "password", "MNOTE_CONTROL_PLANE_INIT_PASSWORD") {
Some(value) => value,
None => {
let allow_insecure = std::env::var("MNOTE_CONTROL_PLANE_ALLOW_INSECURE_DEFAULT_PASSWORD")
.ok()
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
)
})
.unwrap_or(false);
if allow_insecure {
// 与 scripts/TESTING_REFERENCE 本地 admin 口令对齐(仅 insecure 开关)
"MnoteAdmin123!".to_string()
} else {
return Err(
"init 需要管理员密码:请传 --password 或设置 MNOTE_CONTROL_PLANE_INIT_PASSWORD\
使 MNOTE_CONTROL_PLANE_ALLOW_INSECURE_DEFAULT_PASSWORD=1"
.into(),
);
}
}
};
let workspace_id = flag_or_env( let workspace_id = flag_or_env(
args, args,
"workspace-id", "workspace-id",
@@ -925,26 +998,37 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
"workspace-name", "workspace-name",
"MNOTE_CONTROL_PLANE_INIT_WORKSPACE_NAME", "MNOTE_CONTROL_PLANE_INIT_WORKSPACE_NAME",
) )
.unwrap_or_else(|| "MNote E2E Workspace".to_string()); .unwrap_or_else(|| "MNote Admin Workspace".to_string());
let root_path = flag_or_env(args, "root-path", "MNOTE_CONTROL_PLANE_INIT_ROOT_PATH") let root_path = flag_or_env(args, "root-path", "MNOTE_CONTROL_PLANE_INIT_ROOT_PATH")
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/workspaces/default".to_string()); .unwrap_or_else(|| {
format!("/mnt/Data1T/Mnote_data/users/{user_id}/workspaces/my-space")
});
let root_uri = flag_or_env(args, "root-uri", "MNOTE_CONTROL_PLANE_INIT_ROOT_URI") let root_uri = flag_or_env(args, "root-uri", "MNOTE_CONTROL_PLANE_INIT_ROOT_URI")
.unwrap_or_else(|| format!("file://{root_path}")); .unwrap_or_else(|| format!("file://{root_path}"));
// rolemnote-e2e → ai_service;默认 admin 账号 → admin;其它可显式 --role
let role = flag_or_env(args, "role", "MNOTE_CONTROL_PLANE_INIT_ROLE").unwrap_or_else(|| {
if user_id == "mnote-e2e" || username == "mnote-e2e" {
"ai_service".to_string()
} else {
"admin".to_string()
}
});
let user = store.upsert_user(UpsertUserInput { let user = store.upsert_user(UpsertUserInput {
id: Some(user_id.clone()), id: Some(user_id.clone()),
email: Some(email.clone()), email: Some(email.clone()),
username: username.clone(), username: username.clone(),
display_name, display_name,
role: Some("admin".to_string()), role: Some(role),
password_hash: None, password_hash: None,
})?; })?;
let _ = store.create_password_identity(control_plane::CreatePasswordIdentityInput { store.create_password_identity(control_plane::CreatePasswordIdentityInput {
user_id: user.id.clone(), user_id: user.id.clone(),
email: Some(email), email: Some(email),
username, username,
password, password,
}); })?;
let workspace = store.upsert_workspace(UpsertWorkspaceInput { let workspace = store.upsert_workspace(UpsertWorkspaceInput {
id: Some(workspace_id), id: Some(workspace_id),
owner_user_id: user.id.clone(), owner_user_id: user.id.clone(),
@@ -997,7 +1081,8 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
"ok": true, "ok": true,
"backend": format!("{:?}", config.backend), "backend": format!("{:?}", config.backend),
"userId": user.id, "userId": user.id,
"workspaceId": workspace.id "workspaceId": workspace.id,
"role": user.role,
}))? }))?
); );
Ok(()) Ok(())
+90 -4
View File
@@ -19,12 +19,17 @@ pub struct UserRecord {
pub revision: i64, pub revision: i64,
} }
/// 会话令牌哈希(领域前缀,避免与 share/password 等 token 类型混淆)。
/// 注意:改前缀会使既有 session 行失效,需用户重新登录。
pub fn session_token_hash(raw_token: &str) -> String { pub fn session_token_hash(raw_token: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(b"mnote-session-token-v1:");
hasher.update(raw_token.as_bytes()); hasher.update(raw_token.as_bytes());
hex::encode(hasher.finalize()) format!("sha256-v1:{}", hex::encode(hasher.finalize()))
} }
/// 密码哈希 v1:SHA-256 + 固定领域前缀(兼容既有 `sha256-v1:` 存档)。
/// 技术债:生产应迁移 Argon2id + 每用户随机盐;在此保持算法兼容以免批量锁死账号。
pub fn password_hash_v1(password: &str) -> String { pub fn password_hash_v1(password: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(b"mnote-password-v1:"); hasher.update(b"mnote-password-v1:");
@@ -39,27 +44,76 @@ pub fn share_token_hash_v1(token: &str) -> String {
format!("sha256-v1:{}", hex::encode(hasher.finalize())) format!("sha256-v1:{}", hex::encode(hasher.finalize()))
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpsertUserInput { pub struct UpsertUserInput {
pub id: Option<EntityId>, pub id: Option<EntityId>,
pub email: Option<String>, pub email: Option<String>,
pub username: String, pub username: String,
pub display_name: String, pub display_name: String,
pub role: Option<String>, pub role: Option<String>,
/// 密码哈希;Debug 脱敏,避免日志泄露。
pub password_hash: Option<String>, pub password_hash: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] impl std::fmt::Debug for UpsertUserInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UpsertUserInput")
.field("id", &self.id)
.field("email", &self.email)
.field("username", &self.username)
.field("display_name", &self.display_name)
.field("role", &self.role)
.field(
"password_hash",
&self
.password_hash
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.finish()
}
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct CreatePasswordIdentityInput { pub struct CreatePasswordIdentityInput {
pub user_id: EntityId, pub user_id: EntityId,
pub email: Option<String>, pub email: Option<String>,
pub username: String, pub username: String,
/// 明文密码仅用于创建身份;Debug/Serialize 必须脱敏。
pub password: String, pub password: String,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] impl Serialize for CreatePasswordIdentityInput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("CreatePasswordIdentityInput", 4)?;
state.serialize_field("user_id", &self.user_id)?;
state.serialize_field("email", &self.email)?;
state.serialize_field("username", &self.username)?;
state.serialize_field("password", "<redacted>")?;
state.end()
}
}
impl std::fmt::Debug for CreatePasswordIdentityInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CreatePasswordIdentityInput")
.field("user_id", &self.user_id)
.field("email", &self.email)
.field("username", &self.username)
.field("password", &"<redacted>")
.finish()
}
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct AuthenticatePasswordInput { pub struct AuthenticatePasswordInput {
pub account: String, pub account: String,
/// 明文密码仅用于鉴权;Debug/Serialize 脱敏。
pub password: String, pub password: String,
pub session_id: Option<EntityId>, pub session_id: Option<EntityId>,
pub token_hash: String, pub token_hash: String,
@@ -68,6 +122,38 @@ pub struct AuthenticatePasswordInput {
pub expires_at: Option<Timestamp>, pub expires_at: Option<Timestamp>,
} }
impl Serialize for AuthenticatePasswordInput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("AuthenticatePasswordInput", 7)?;
state.serialize_field("account", &self.account)?;
state.serialize_field("password", "<redacted>")?;
state.serialize_field("session_id", &self.session_id)?;
state.serialize_field("token_hash", "<redacted>")?;
state.serialize_field("user_agent", &self.user_agent)?;
state.serialize_field("ip_hash", &self.ip_hash)?;
state.serialize_field("expires_at", &self.expires_at)?;
state.end()
}
}
impl std::fmt::Debug for AuthenticatePasswordInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthenticatePasswordInput")
.field("account", &self.account)
.field("password", &"<redacted>")
.field("session_id", &self.session_id)
.field("token_hash", &"<redacted>")
.field("user_agent", &self.user_agent)
.field("ip_hash", &self.ip_hash)
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthSessionRecord { pub struct AuthSessionRecord {
pub id: EntityId, pub id: EntityId,
+12 -6
View File
@@ -255,7 +255,8 @@ impl CommandContext {
editor_dirty: false, editor_dirty: false,
editor_dirty_state: Some("clean".to_string()), editor_dirty_state: Some("clean".to_string()),
editor_has_selection: false, editor_has_selection: false,
ai_can_write: true, // 最小权限:默认禁止 AI 写;调用方确认需要时再 with_ai_can_write(true)
ai_can_write: false,
} }
} }
@@ -351,11 +352,15 @@ impl CommandContext {
self self
} }
/// 用 builder 模式设置 editor dirty。 /// 用 builder 模式设置 editor dirty;与 `editor_dirty_state` 双向同步
pub fn with_editor_dirty(mut self, dirty: bool) -> Self { pub fn with_editor_dirty(mut self, dirty: bool) -> Self {
self.editor_dirty = dirty; self.editor_dirty = dirty;
if dirty && self.editor_dirty_state.as_deref() == Some("clean") { if dirty {
// dirty=true 时始终落到 dirty 状态(含从 clean/其它状态同步)
self.editor_dirty_state = Some("dirty".to_string()); self.editor_dirty_state = Some("dirty".to_string());
} else {
// dirty=false 时重置为 clean,避免两字段语义分裂
self.editor_dirty_state = Some("clean".to_string());
} }
self self
} }
@@ -700,10 +705,11 @@ pub fn check_when(ctx: &CommandContext, when_expr: &str) -> Result<bool, String>
/// 检查一个命令在指定上下文中是否应 enabled。 /// 检查一个命令在指定上下文中是否应 enabled。
/// 如果 when 表达式为 None 或空,默认返回 true。 /// 如果 when 表达式为 None 或空,默认返回 true。
/// when 解析失败时 fail-closed:返回 false(禁止因坏表达式默认放开命令)。
pub fn is_command_enabled(ctx: &CommandContext, when: Option<&str>) -> bool { pub fn is_command_enabled(ctx: &CommandContext, when: Option<&str>) -> bool {
match when { match when {
None | Some("") => true, None | Some("") => true,
Some(expr) => check_when(ctx, expr).unwrap_or(true), Some(expr) => check_when(ctx, expr).unwrap_or(false),
} }
} }
@@ -905,8 +911,8 @@ mod command_context_tests {
#[test] #[test]
fn command_context_parse_error_returns_default() { fn command_context_parse_error_returns_default() {
let ctx = CommandContext::new(); let ctx = CommandContext::new();
// 解析错误 → is_command_enabled 返回 true(默认允许 // 解析错误 → fail-closedis_command_enabled 返回 false(禁止默认放开
assert!(is_command_enabled(&ctx, Some("invalid syntax &&&"))); assert!(!is_command_enabled(&ctx, Some("invalid syntax &&&")));
} }
#[test] #[test]
+23 -1
View File
@@ -60,7 +60,14 @@ impl JobTicket {
} }
pub fn decide_access(context: &AccessContext) -> AccessDecision { pub fn decide_access(context: &AccessContext) -> AccessDecision {
if context.tenant_id.is_none() { // None 与空串/纯空白均视为缺 tenant,避免 `Some("")` 绕过鉴权。
let tenant_missing = context
.tenant_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none();
if tenant_missing {
return AccessDecision::Deny(AccessDenyReason::MissingTenant); return AccessDecision::Deny(AccessDenyReason::MissingTenant);
} }
if context.workspace_id.trim().is_empty() { if context.workspace_id.trim().is_empty() {
@@ -100,6 +107,21 @@ mod tests {
assert_eq!(decide_access(&context), AccessDecision::Allow); assert_eq!(decide_access(&context), AccessDecision::Allow);
} }
#[test]
fn access_denies_empty_tenant_string() {
let context = AccessContext {
tenant_id: Some(" ".to_string()),
workspace_id: "ws-1".to_string(),
actor_id: "user-1".to_string(),
actor_type: "human".to_string(),
source: "react-next".to_string(),
};
assert_eq!(
decide_access(&context),
AccessDecision::Deny(AccessDenyReason::MissingTenant)
);
}
#[test] #[test]
fn job_ticket_keeps_trace_fields() { fn job_ticket_keeps_trace_fields() {
let ticket = JobTicket::new( let ticket = JobTicket::new(
+24 -1
View File
@@ -246,7 +246,13 @@ impl DocumentBuffer {
pub fn mark_dirty(&mut self, content_hash: String) { pub fn mark_dirty(&mut self, content_hash: String) {
self.current_content_hash = Some(content_hash); self.current_content_hash = Some(content_hash);
self.dirty_state = DocBufferDirtyState::Dirty; // Stale / Deleted 不可被普通编辑覆盖:保留外部冲突与删除语义。
match self.dirty_state {
DocBufferDirtyState::Stale | DocBufferDirtyState::Deleted => {}
_ => {
self.dirty_state = DocBufferDirtyState::Dirty;
}
}
} }
pub fn mark_saved(&mut self, file_version: String, content_hash: String) { pub fn mark_saved(&mut self, file_version: String, content_hash: String) {
@@ -298,6 +304,14 @@ impl DocumentBuffer {
pub fn mark_deleted(&mut self) { pub fn mark_deleted(&mut self) {
self.dirty_state = DocBufferDirtyState::Deleted; self.dirty_state = DocBufferDirtyState::Deleted;
} }
/// 从回收站 restore 后清除 deleted 标记,使已打开 buffer 可再次编辑。
/// 恢复为 Clean:文件已回到磁盘,当前 buffer 内容仍视为与 base 一致直至下一次外部变更检测。
pub fn clear_deleted(&mut self) {
if self.dirty_state == DocBufferDirtyState::Deleted {
self.dirty_state = DocBufferDirtyState::Clean;
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1109,6 +1123,11 @@ mod tests {
assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale); assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale);
assert_eq!(buf.external_actor.as_deref(), Some("external-editor")); assert_eq!(buf.external_actor.as_deref(), Some("external-editor"));
// Stale 时继续编辑只更新 content_hash,不得降级回 Dirty。
buf.mark_dirty("sha256:stale-edit".into());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale);
assert_eq!(buf.current_content_hash.as_deref(), Some("sha256:stale-edit"));
buf.mark_saved("v2".into(), "sha256:saved".into()); buf.mark_saved("v2".into(), "sha256:saved".into());
assert!(!buf.is_dirty()); assert!(!buf.is_dirty());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
@@ -1119,5 +1138,9 @@ mod tests {
buf.mark_deleted(); buf.mark_deleted();
assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted); assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted);
buf.mark_dirty("sha256:after-delete".into());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted);
buf.clear_deleted();
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
} }
} }
+2 -1
View File
@@ -77,7 +77,8 @@ pub struct MindmapKernelProjection {
pub source: MindmapProjectionSource, pub source: MindmapProjectionSource,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] // style/refs 含 serde_json::Value(可能含 f64),不实现 Eq;仅保留 PartialEq
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MindmapKernelNode { pub struct MindmapKernelNode {
pub node_id: String, pub node_id: String,
+68 -22
View File
@@ -765,8 +765,9 @@ fn upsert_match(
} }
fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String { fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) { // 维护累计字节长度,避免每次递归 O(n²) join。
if output.join("\n").len() >= max_chars { fn walk(node: &Value, output: &mut Vec<String>, total_len: &mut usize, max_chars: usize) {
if *total_len >= max_chars {
return; return;
} }
match node { match node {
@@ -775,26 +776,30 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
if let Some(Value::String(text)) = data.get("text") { if let Some(Value::String(text)) = data.get("text") {
let normalized = normalize_text(text); let normalized = normalize_text(text);
if !normalized.is_empty() { if !normalized.is_empty() {
if !output.is_empty() {
*total_len = total_len.saturating_add(1); // '\n'
}
*total_len = total_len.saturating_add(normalized.len());
output.push(normalized); output.push(normalized);
} }
} }
} }
if let Some(Value::Array(children)) = map.get("children") { if let Some(Value::Array(children)) = map.get("children") {
for child in children { for child in children {
walk(child, output, max_chars); walk(child, output, total_len, max_chars);
if output.join("\n").len() >= max_chars { if *total_len >= max_chars {
break; break;
} }
} }
} }
if let Some(root) = map.get("root") { if let Some(root) = map.get("root") {
walk(root, output, max_chars); walk(root, output, total_len, max_chars);
} }
} }
Value::Array(items) => { Value::Array(items) => {
for item in items { for item in items {
walk(item, output, max_chars); walk(item, output, total_len, max_chars);
if output.join("\n").len() >= max_chars { if *total_len >= max_chars {
break; break;
} }
} }
@@ -804,10 +809,18 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
} }
let mut output = Vec::new(); let mut output = Vec::new();
walk(value, &mut output, max_chars); let mut total_len = 0usize;
walk(value, &mut output, &mut total_len, max_chars);
let joined = output.join("\n").trim().to_string(); let joined = output.join("\n").trim().to_string();
if joined.len() > max_chars { if joined.len() > max_chars {
format!("{}", &joined[..max_chars]) // 按 UTF-8 字符边界截断,避免 panic
let end = joined
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max_chars)
.last()
.unwrap_or(0);
format!("{}", &joined[..end])
} else { } else {
joined joined
} }
@@ -834,31 +847,45 @@ fn build_snippet(text: &str, keyword: &str) -> String {
if source.is_empty() { if source.is_empty() {
return "暂无正文内容".into(); return "暂无正文内容".into();
} }
let escaped = escape_html(source);
if keyword.trim().is_empty() { if keyword.trim().is_empty() {
return truncate_snippet(&escaped); return truncate_snippet(&escape_html(source));
} }
// 先在原文上匹配,再 escape 匹配片段,避免在 `&amp;` 等实体内部插入 <mark>。
let regex = match RegexBuilder::new(&regex::escape(keyword)) let regex = match RegexBuilder::new(&regex::escape(keyword))
.case_insensitive(true) .case_insensitive(true)
.build() .build()
{ {
Ok(regex) => regex, Ok(regex) => regex,
Err(_) => return truncate_snippet(&escaped), Err(_) => return truncate_snippet(&escape_html(source)),
}; };
let Some(found) = regex.find(&escaped) else { let Some(found) = regex.find(source) else {
return truncate_snippet(&escaped); return truncate_snippet(&escape_html(source));
}; };
let start = found.start().saturating_sub(20); // 按 UTF-8 字符边界取上下文,避免多字节字符中间切片 panic。
let end = (found.end() + 80).min(escaped.len()); let match_start = found.start();
let segment = escaped[start..end].to_string(); let match_end = found.end();
regex let target_start = match_start.saturating_sub(20);
.replace_all(&segment, |captures: &regex::Captures<'_>| { let start = source
format!("<mark>{}</mark>", &captures[0]) .char_indices()
}) .map(|(i, _)| i)
.to_string() .take_while(|&i| i <= target_start)
.last()
.unwrap_or(0);
let target_end = (match_end + 80).min(source.len());
let end = source
.char_indices()
.map(|(i, _)| i)
.find(|&i| i >= target_end)
.unwrap_or(source.len());
let end = if end < start { source.len() } else { end };
let prefix = escape_html(&source[start..match_start]);
let matched = escape_html(&source[match_start..match_end]);
let suffix = escape_html(&source[match_end..end]);
format!("{prefix}<mark>{matched}</mark>{suffix}")
} }
fn truncate_snippet(value: &str) -> String { fn truncate_snippet(value: &str) -> String {
@@ -1120,4 +1147,23 @@ mod tests {
assert_eq!(result.results[0].evidence.len(), 1); assert_eq!(result.results[0].evidence.len(), 1);
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]); assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
} }
#[test]
fn build_snippet_does_not_break_html_entities() {
// 关键词 "amp" 若在转义后匹配,会破坏 `&amp;` 实体。
let snippet = build_snippet("x & y amp-word", "amp");
assert!(
!snippet.contains("&<mark>"),
"不得在 HTML 实体内部插入 mark: {snippet}"
);
assert!(
snippet.contains("<mark>") && snippet.contains("</mark>"),
"应高亮匹配词: {snippet}"
);
// 原文 & 应被 escape 为 &amp;,且实体完整
assert!(
snippet.contains("&amp;") || !snippet.contains('&'),
"原文 & 应被正确转义: {snippet}"
);
}
} }
+12 -8
View File
@@ -210,22 +210,26 @@ fn move_block(
.iter() .iter()
.map(|block| block.id.clone()) .map(|block| block.id.clone())
.collect(); .collect();
if let Some(target_id) = after_block_id { // 先算好 drain 后的插入点,避免「先 drain 再发现目标不存在」导致块丢失。
if moved_ids.iter().any(|id| id == target_id) {
return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面"));
}
}
let moved_blocks: Vec<DocumentBlock> = document.blocks_mut().drain(start..=end).collect();
let insert_index = match after_block_id { let insert_index = match after_block_id {
Some(target_id) => { Some(target_id) => {
if moved_ids.iter().any(|id| id == target_id) {
return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面"));
}
let target_range = document let target_range = document
.subtree_range(target_id) .subtree_range(target_id)
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?; .ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
*target_range.end() + 1 let target_end = *target_range.end();
// drain 会把 [start..=end] 移除:目标在段后时索引左移 removed 个位置。
if target_end > end {
target_end + 1 - (end - start + 1)
} else {
target_end + 1
}
} }
None => 0, None => 0,
}; };
let moved_blocks: Vec<DocumentBlock> = document.blocks_mut().drain(start..=end).collect();
document document
.blocks_mut() .blocks_mut()
.splice(insert_index..insert_index, moved_blocks); .splice(insert_index..insert_index, moved_blocks);
+14 -1
View File
@@ -23,11 +23,13 @@ pub fn ai_vault_actor_id() -> String {
} }
/// Encode actor id for managed path segment (aligned with mnote-web). /// Encode actor id for managed path segment (aligned with mnote-web).
///
/// 不保留 `.` / `/` 等路径元字符,避免 actor=`..` 时穿越 `users/` 目录。
pub fn encode_actor_segment(actor_id: &str) -> String { pub fn encode_actor_segment(actor_id: &str) -> String {
actor_id actor_id
.chars() .chars()
.map(|c| match c { .map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c.to_string(), 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c.to_string(),
other => format!("~{:02x}", other as u32), other => format!("~{:02x}", other as u32),
}) })
.collect() .collect()
@@ -786,6 +788,17 @@ mod tests {
static AI_TEST_LOCK: Mutex<()> = Mutex::new(()); static AI_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn encode_actor_segment_rejects_path_traversal_dots() {
// `..` 不得原样保留,否则 join("users", "..") 可穿越目录。
let encoded = encode_actor_segment("..");
assert!(!encoded.contains('.'), "encoded={encoded}");
assert_eq!(encoded, "~2e~2e");
let dotted = encode_actor_segment("a.b");
assert!(!dotted.contains('.'), "encoded={dotted}");
assert_eq!(dotted, "a~2eb");
}
fn with_temp_workspace<F, R>(f: F) -> R fn with_temp_workspace<F, R>(f: F) -> R
where where
F: FnOnce(&Path) -> R, F: FnOnce(&Path) -> R,
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -108,10 +108,11 @@ pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, VaultError> {
) )
})?; })?;
let hex = raw.trim(); let hex = raw.trim();
if hex.len() < 32 { // 生成侧写 32 字节 → 64 hex;读取侧要求一致,拒绝弱 key。
if hex.len() < 64 {
return Err(VaultError::bad_request_code( return Err(VaultError::bad_request_code(
"vault_hmac_key_invalid", "vault_hmac_key_invalid",
"HMAC key 过短", "HMAC key 过短(需要至少 32 字节 / 64 hex)",
)); ));
} }
return hex::decode(hex).map_err(|e| { return hex::decode(hex).map_err(|e| {
+63 -42
View File
@@ -428,6 +428,36 @@ fn sock_reachable(path: &Path) -> bool {
} }
} }
/// Path-segment safe id for UDS HTTP paths (reject CRLF / query / slash inject).
fn sanitize_item_id_for_path(id: &str) -> Result<String, VaultError> {
let id = id.trim();
if id.is_empty() || id.len() > 128 {
return Err(VaultError::new(
VaultStatus::BadRequest,
"vault_item_id_invalid",
"vault item id 无效",
));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(VaultError::new(
VaultStatus::BadRequest,
"vault_item_id_invalid",
"vault item id 含非法字符",
));
}
if id.contains("..") {
return Err(VaultError::new(
VaultStatus::BadRequest,
"vault_item_id_invalid",
"vault item id 不得含 ..",
));
}
Ok(id.to_string())
}
/// HTTP/1.1 request over UDS; returns status + body. /// HTTP/1.1 request over UDS; returns status + body.
#[cfg(unix)] #[cfg(unix)]
fn uds_http( fn uds_http(
@@ -650,7 +680,7 @@ fn cmd_list(args: ListArgs) -> Result<Value, VaultError> {
} }
fn cmd_get(args: GetArgs) -> Result<Value, VaultError> { fn cmd_get(args: GetArgs) -> Result<Value, VaultError> {
let id = args.id.clone(); let id = sanitize_item_id_for_path(&args.id)?;
with_transport( with_transport(
args.local, args.local,
args.remote, args.remote,
@@ -671,7 +701,7 @@ fn cmd_get(args: GetArgs) -> Result<Value, VaultError> {
} }
fn cmd_resolve(args: ResolveArgs) -> Result<Value, VaultError> { fn cmd_resolve(args: ResolveArgs) -> Result<Value, VaultError> {
let id = args.id.clone(); let id = sanitize_item_id_for_path(&args.id)?;
let field = args.field.clone(); let field = args.field.clone();
let account_id = args.account_id.clone(); let account_id = args.account_id.clone();
let secret_id = args.secret_id.clone(); let secret_id = args.secret_id.clone();
@@ -712,7 +742,7 @@ fn cmd_resolve(args: ResolveArgs) -> Result<Value, VaultError> {
} }
fn cmd_login(args: LoginArgs) -> Result<Value, VaultError> { fn cmd_login(args: LoginArgs) -> Result<Value, VaultError> {
let id = args.id.clone(); let id = sanitize_item_id_for_path(&args.id)?;
let force = args.force_refresh; let force = args.force_refresh;
with_transport( with_transport(
args.local, args.local,
@@ -740,7 +770,7 @@ fn cmd_login(args: LoginArgs) -> Result<Value, VaultError> {
} }
fn cmd_session(args: SessionArgs) -> Result<Value, VaultError> { fn cmd_session(args: SessionArgs) -> Result<Value, VaultError> {
let id = args.id.clone(); let id = sanitize_item_id_for_path(&args.id)?;
let cookie = args.cookie_header.clone(); let cookie = args.cookie_header.clone();
let expires = args.expires_at.clone(); let expires = args.expires_at.clone();
let source = args.source.clone(); let source = args.source.clone();
@@ -962,14 +992,11 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "get") { if let Err(e) = authorize_bearer(auth.as_deref(), "get") {
return err_response(e); return err_response(e);
} }
let id = p.trim_start_matches("/v1/items/"); let id = match sanitize_item_id_for_path(p.trim_start_matches("/v1/items/")) {
if id.is_empty() || id.contains('/') { Ok(id) => id,
return err_response(VaultError::bad_request_code( Err(e) => return err_response(e),
"vault_item_id_invalid", };
"条目 id 无效", match get_ai_vault_item(&id) {
));
}
match get_ai_vault_item(id) {
Ok(mut v) => { Ok(mut v) => {
if let Some(obj) = v.as_object_mut() { if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds")); obj.insert("transport".into(), json!("uds"));
@@ -983,15 +1010,13 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "resolve") { if let Err(e) = authorize_bearer(auth.as_deref(), "resolve") {
return err_response(e); return err_response(e);
} }
let mid = p let mid = match sanitize_item_id_for_path(
.trim_start_matches("/v1/items/") p.trim_start_matches("/v1/items/")
.trim_end_matches("/resolve"); .trim_end_matches("/resolve"),
if mid.is_empty() { ) {
return err_response(VaultError::bad_request_code( Ok(id) => id,
"vault_item_id_invalid", Err(e) => return err_response(e),
"条目 id 无效", };
));
}
let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); let parsed: Value = serde_json::from_str(body).unwrap_or(json!({}));
let field = parsed let field = parsed
.get("field") .get("field")
@@ -1006,7 +1031,7 @@ fn dispatch_request(raw: &str) -> (u16, String) {
.or_else(|| parsed.get("secret_id")) .or_else(|| parsed.get("secret_id"))
.and_then(Value::as_str); .and_then(Value::as_str);
let actor = format!("vaultd:{}", ai_vault_actor_id()); let actor = format!("vaultd:{}", ai_vault_actor_id());
match resolve_ai_vault_secret(mid, field, &actor, None, account_id, secret_id) { match resolve_ai_vault_secret(&mid, field, &actor, None, account_id, secret_id) {
Ok(mut v) => { Ok(mut v) => {
if let Some(obj) = v.as_object_mut() { if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds")); obj.insert("transport".into(), json!("uds"));
@@ -1020,15 +1045,13 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "login") { if let Err(e) = authorize_bearer(auth.as_deref(), "login") {
return err_response(e); return err_response(e);
} }
let mid = p let mid = match sanitize_item_id_for_path(
.trim_start_matches("/v1/items/") p.trim_start_matches("/v1/items/")
.trim_end_matches("/login"); .trim_end_matches("/login"),
if mid.is_empty() { ) {
return err_response(VaultError::bad_request_code( Ok(id) => id,
"vault_item_id_invalid", Err(e) => return err_response(e),
"条目 id 无效", };
));
}
let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); let parsed: Value = serde_json::from_str(body).unwrap_or(json!({}));
let force = parsed let force = parsed
.get("forceRefresh") .get("forceRefresh")
@@ -1036,7 +1059,7 @@ fn dispatch_request(raw: &str) -> (u16, String) {
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(false); .unwrap_or(false);
let actor = format!("vaultd:{}", ai_vault_actor_id()); let actor = format!("vaultd:{}", ai_vault_actor_id());
match login_ai_vault_credential(mid, force, &actor, None) { match login_ai_vault_credential(&mid, force, &actor, None) {
Ok(mut v) => { Ok(mut v) => {
if let Some(obj) = v.as_object_mut() { if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds")); obj.insert("transport".into(), json!("uds"));
@@ -1050,15 +1073,13 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "session") { if let Err(e) = authorize_bearer(auth.as_deref(), "session") {
return err_response(e); return err_response(e);
} }
let mid = p let mid = match sanitize_item_id_for_path(
.trim_start_matches("/v1/items/") p.trim_start_matches("/v1/items/")
.trim_end_matches("/session"); .trim_end_matches("/session"),
if mid.is_empty() { ) {
return err_response(VaultError::bad_request_code( Ok(id) => id,
"vault_item_id_invalid", Err(e) => return err_response(e),
"条目 id 无效", };
));
}
let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); let parsed: Value = serde_json::from_str(body).unwrap_or(json!({}));
let cookie = parsed let cookie = parsed
.get("cookieHeader") .get("cookieHeader")
@@ -1074,7 +1095,7 @@ fn dispatch_request(raw: &str) -> (u16, String) {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("human_bridge"); .unwrap_or("human_bridge");
let actor = format!("vaultd:{}", ai_vault_actor_id()); let actor = format!("vaultd:{}", ai_vault_actor_id());
match put_ai_vault_session(mid, cookie, expires, source, &actor, None) { match put_ai_vault_session(&mid, cookie, expires, source, &actor, None) {
Ok(mut v) => { Ok(mut v) => {
if let Some(obj) = v.as_object_mut() { if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds")); obj.insert("transport".into(), json!("uds"));
@@ -293,6 +293,8 @@ import {
}; };
const paneViewRegistry = new Map(); const paneViewRegistry = new Map();
/** 每 pane 导航世代:快速连点时丢弃过期 replacePaneDocument 结果,保证 last-click-wins。 */
const paneReplaceGeneration = new Map();
const currentWebShellWorkspaceId = () => { const currentWebShellWorkspaceId = () => {
try { try {
return currentUrl().searchParams.get('workspaceId') || ''; return currentUrl().searchParams.get('workspaceId') || '';
@@ -709,6 +711,8 @@ import {
}; };
const replacePaneDocument = async (paneRole, descriptor, options = {}) => { const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
const generation = (paneReplaceGeneration.get(paneRole) || 0) + 1;
paneReplaceGeneration.set(paneRole, generation);
mindmapHost.unmountMindmapPane(paneRole); mindmapHost.unmountMindmapPane(paneRole);
const root = paneRole === 'primary' const root = paneRole === 'primary'
? ensureLazyPrimaryPaneRoot() ? ensureLazyPrimaryPaneRoot()
@@ -719,8 +723,12 @@ import {
const aggregatePromise = options.aggregate const aggregatePromise = options.aggregate
? Promise.resolve(options.aggregate) ? Promise.resolve(options.aggregate)
: fetchPageAggregateForPane(descriptor); : fetchPageAggregateForPane(descriptor);
const previousView = paneViewRegistry.get(paneRole);
const [runtime, aggregate] = await Promise.all([runtimePromise, aggregatePromise]); const [runtime, aggregate] = await Promise.all([runtimePromise, aggregatePromise]);
// 过期导航:丢弃结果,避免覆盖更新的 last-click View 并泄漏中间实例。
if (paneReplaceGeneration.get(paneRole) !== generation) {
return null;
}
const previousView = paneViewRegistry.get(paneRole);
if (previousView) { if (previousView) {
unmountEditorViewBinding(previousView); unmountEditorViewBinding(previousView);
paneViewRegistry.delete(paneRole); paneViewRegistry.delete(paneRole);
@@ -754,6 +762,13 @@ import {
clearEmbeddedLocalDraft(runtimeDescriptor); clearEmbeddedLocalDraft(runtimeDescriptor);
try { try {
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions); const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
if (paneReplaceGeneration.get(paneRole) !== generation) {
try {
if (typeof runtime.unmount === 'function') runtime.unmount(mountId);
} catch (_) { /* ignore stale unmount */ }
unmountEditorViewBinding(view);
return null;
}
view.mountId = mountId; view.mountId = mountId;
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
@@ -1181,7 +1196,9 @@ import {
if (!id) return false; if (!id) return false;
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
descriptor.pageBlockNavigation = pageBlockNavigation === true; descriptor.pageBlockNavigation = pageBlockNavigation === true;
await replacePaneDocument('primary', descriptor); const view = await replacePaneDocument('primary', descriptor);
// 过期导航(快速连点被 generation 丢弃)不得改 URL / 激活 tab。
if (!view) return false;
activateMainEditorTab('', 'primary'); activateMainEditorTab('', 'primary');
updatePrimaryUrl(descriptor, url instanceof URL ? url : null); updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
return true; return true;
@@ -1202,7 +1219,8 @@ import {
const id = typeof documentId === 'string' ? documentId.trim() : ''; const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false; if (!id) return false;
const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri }); const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('secondary', descriptor); const view = await replacePaneDocument('secondary', descriptor);
if (!view) return false;
activateMainEditorTab('', 'secondary'); activateMainEditorTab('', 'secondary');
if (url instanceof URL) replaceUrlState(url); if (url instanceof URL) replaceUrlState(url);
return true; return true;
@@ -1473,7 +1473,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}; };
const knowledgeRagProvider = () => { const knowledgeRagProvider = () => {
return 'weknora'; return 'lightrag_legacy';
}; };
const setKnowledgeRagStatus = (entry, status, message, job) => { const setKnowledgeRagStatus = (entry, status, message, job) => {
@@ -11,20 +11,65 @@ function uploadedAssetTitle(asset) {
return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件'; return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件';
} }
/** Reject path escape segments and null bytes after light decode. */
function hasPathEscape(value) {
var s = String(value || '').replace(/\\/g, '/');
try {
s = decodeURIComponent(s);
} catch (_) {
// keep raw
}
s = s.replace(/\\/g, '/');
if (s.indexOf('\0') >= 0) return true;
var parts = s.split('/');
for (var i = 0; i < parts.length; i += 1) {
if (parts[i] === '..') return true;
}
return false;
}
/** Allow only safe URL schemes for editor img/src or link href. */
function isSafeAssetUrl(url) {
var value = String(url || '').trim();
if (!value) return false;
if (value.charAt(0) === '#' || value.charAt(0) === '/' || value.indexOf('./') === 0) {
return !hasPathEscape(value);
}
// scheme-relative
if (value.indexOf('//') === 0) return false;
var colon = value.indexOf(':');
if (colon < 0) {
// relative path without scheme
return !hasPathEscape(value);
}
var scheme = value.slice(0, colon).toLowerCase();
if (scheme === 'http' || scheme === 'https' || scheme === 'blob' || scheme === 'data') {
// data: raster images only. Reject svg+xml (can embed script even when base64 hides "script").
if (scheme === 'data') {
if (!/^data:image\//i.test(value)) return false;
if (/^data:image\/svg\+xml/i.test(value)) return false;
// deny explicit script markers in non-svg image payloads
if (value.toLowerCase().indexOf('script') >= 0) return false;
return true;
}
return true;
}
return false;
}
function uploadedAssetUrl(asset) { function uploadedAssetUrl(asset) {
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); var raw = String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
return isSafeAssetUrl(raw) ? raw : '';
} }
function uploadedAssetMarkdownHref(asset) { function uploadedAssetMarkdownHref(asset) {
var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim(); var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim();
if (href) { if (href) {
var normalizedHref = href.replace(/\\/g, '/'); if (hasPathEscape(href) || !isSafeAssetUrl(href)) return '';
if (normalizedHref.indexOf('../') === 0 || normalizedHref.indexOf('/../') >= 0) return '';
return href; return href;
} }
var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/'); var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/');
if (!relativePath) return ''; if (!relativePath || hasPathEscape(relativePath)) return '';
if (relativePath.indexOf('../') === 0 || relativePath.indexOf('/../') >= 0) return '';
if (relativePath.indexOf('./') === 0) return relativePath; if (relativePath.indexOf('./') === 0) return relativePath;
return './' + relativePath; return './' + relativePath;
} }
@@ -621,9 +666,9 @@ async function uploadFilesWithResolvedTarget(files, detail, options, deps) {
if (errors.length) { if (errors.length) {
var message = '部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : ''); var message = '部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : '');
if (typeof deps.alert === 'function') { if (typeof deps.alert === 'function') {
deps.alert(message); await deps.alert(message);
} else { } else {
window.alert(message); await window.mnote.alert(message);
} }
} }
return uploaded; return uploaded;
@@ -633,7 +678,7 @@ function localAssetOpenUrl(asset, download, context) {
if (!isLocalUploadedAsset(asset)) return ''; if (!isLocalUploadedAsset(asset)) return '';
var rootUri = String(context && context.rootUri || asset && (asset.rootUri || asset.root_uri) || '').trim() || currentRootUri(); var rootUri = String(context && context.rootUri || asset && (asset.rootUri || asset.root_uri) || '').trim() || currentRootUri();
var rootRelativePath = String(asset && (asset.rootRelativePath || asset.root_relative_path) || '').trim(); var rootRelativePath = String(asset && (asset.rootRelativePath || asset.root_relative_path) || '').trim();
if (!rootUri || !rootRelativePath) return ''; if (!rootUri || !rootRelativePath || hasPathEscape(rootRelativePath)) return '';
var url = new URL('/api/local-folder/files/open', window.location.origin); var url = new URL('/api/local-folder/files/open', window.location.origin);
url.searchParams.set('rootUri', rootUri); url.searchParams.set('rootUri', rootUri);
url.searchParams.set('path', rootRelativePath); url.searchParams.set('path', rootRelativePath);
@@ -1,8 +1,12 @@
// MNote UI 基础运行时:全局 portaltoast API // MNote UI 基础运行时:全局 portaltoast、居中 confirm/alert
(function initMnoteUiRuntime() { (function initMnoteUiRuntime() {
if (window.__mnoteUiRuntimeStarted) return; if (window.__mnoteUiRuntimeStarted) return;
window.__mnoteUiRuntimeStarted = true; window.__mnoteUiRuntimeStarted = true;
var nativeConfirm = typeof window.confirm === 'function' ? window.confirm.bind(window) : null;
var nativeAlert = typeof window.alert === 'function' ? window.alert.bind(window) : null;
var activeDialog = null;
function ensurePortalRoot() { function ensurePortalRoot() {
var root = document.getElementById('mnote-portal-root'); var root = document.getElementById('mnote-portal-root');
if (root instanceof HTMLElement) return root; if (root instanceof HTMLElement) return root;
@@ -52,15 +56,192 @@
return item; return item;
} }
function closeActiveDialog(result) {
if (!activeDialog) return;
var state = activeDialog;
activeDialog = null;
document.removeEventListener('keydown', state.onKeyDown, true);
if (state.backdrop && state.backdrop.parentNode) {
state.backdrop.parentNode.removeChild(state.backdrop);
}
if (typeof state.resolve === 'function') state.resolve(result);
}
/**
* 居中对话框
* @param {object} options
* @param {string} [options.title]
* @param {string} options.message
* @param {'confirm'|'alert'} [options.mode]
* @param {string} [options.confirmLabel]
* @param {string} [options.cancelLabel]
* @param {'primary'|'danger'} [options.tone]
* @returns {Promise<boolean>} confirm 模式确定=truealert 模式始终 true
*/
function showDialog(options) {
var config = options || {};
var message = String(config.message || '').trim();
if (!message) return Promise.resolve(config.mode === 'alert');
return new Promise(function (resolve) {
if (activeDialog) closeActiveDialog(false);
var mode = config.mode === 'alert' ? 'alert' : 'confirm';
var tone = String(config.tone || 'primary');
var title =
String(config.title || (mode === 'alert' ? '提示' : '确认')).trim() ||
(mode === 'alert' ? '提示' : '确认');
var confirmLabel = String(config.confirmLabel || (mode === 'alert' ? '知道了' : '确定')).trim();
var cancelLabel = String(config.cancelLabel || '取消').trim();
var backdrop = document.createElement('div');
backdrop.className = 'mnote-dialog-backdrop';
backdrop.setAttribute('data-mnote-dialog-backdrop', 'true');
backdrop.setAttribute('data-mode', mode);
var dialog = document.createElement('div');
dialog.className =
'mnote-dialog' + (tone === 'danger' ? ' mnote-dialog--danger' : '');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('data-mnote-dialog', 'true');
var body = document.createElement('div');
body.className = 'mnote-dialog__body';
var titleEl = document.createElement('h2');
titleEl.className = 'mnote-dialog__title';
titleEl.textContent = title;
body.appendChild(titleEl);
var messageEl = document.createElement('p');
messageEl.className = 'mnote-dialog__message';
messageEl.textContent = message;
body.appendChild(messageEl);
dialog.appendChild(body);
var actions = document.createElement('div');
actions.className = 'mnote-dialog__actions';
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className =
'mnote-dialog__btn ' +
(tone === 'danger' ? 'mnote-dialog__btn--danger' : 'mnote-dialog__btn--primary');
confirmBtn.setAttribute('data-role', 'confirm');
confirmBtn.textContent = confirmLabel || '确定';
if (mode === 'confirm') {
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'mnote-dialog__btn';
cancelBtn.setAttribute('data-role', 'cancel');
cancelBtn.textContent = cancelLabel || '取消';
cancelBtn.addEventListener('click', function () {
closeActiveDialog(false);
});
actions.appendChild(cancelBtn);
}
confirmBtn.addEventListener('click', function () {
closeActiveDialog(true);
});
actions.appendChild(confirmBtn);
dialog.appendChild(actions);
backdrop.appendChild(dialog);
backdrop.addEventListener('click', function (event) {
if (event.target === backdrop) {
closeActiveDialog(mode === 'alert' ? true : false);
}
});
var onKeyDown = function (event) {
if (event.key === 'Escape') {
event.preventDefault();
closeActiveDialog(mode === 'alert' ? true : false);
} else if (event.key === 'Enter') {
var tag = (event.target && event.target.tagName) || '';
if (tag === 'TEXTAREA' || tag === 'BUTTON') return;
event.preventDefault();
closeActiveDialog(true);
}
};
activeDialog = {
backdrop: backdrop,
resolve: resolve,
onKeyDown: onKeyDown,
};
document.addEventListener('keydown', onKeyDown, true);
ensurePortalRoot().appendChild(backdrop);
(mode === 'confirm' ? actions.querySelector('[data-role="cancel"]') || confirmBtn : confirmBtn).focus({
preventScroll: true,
});
});
}
function confirmDialog(message, options) {
var config = options && typeof options === 'object' ? options : {};
return showDialog({
mode: 'confirm',
message: message,
title: config.title,
confirmLabel: config.confirmLabel || config.okText,
cancelLabel: config.cancelLabel || config.cancelText,
tone: config.tone || (config.danger ? 'danger' : 'primary'),
});
}
function alertDialog(message, options) {
var config = options && typeof options === 'object' ? options : {};
return showDialog({
mode: 'alert',
message: message,
title: config.title || '提示',
confirmLabel: config.confirmLabel || config.okText || '知道了',
tone: config.tone || (config.kind === 'error' || config.danger ? 'danger' : 'primary'),
}).then(function () {
return undefined;
});
}
window.mnote = window.mnote || {}; window.mnote = window.mnote || {};
window.mnote.ensurePortalRoot = ensurePortalRoot; window.mnote.ensurePortalRoot = ensurePortalRoot;
window.mnote.toast = toast; window.mnote.toast = toast;
window.mnote.confirm = confirmDialog;
window.mnote.alert = alertDialog;
window.mnote.dialog = showDialog;
// 全局替换:统一返回 Promise。调用点需 await(已在各 runtime 迁移)。
window.alert = function mnoteWindowAlert(message) {
if (!document.body) {
if (nativeAlert) nativeAlert(message);
return Promise.resolve();
}
return alertDialog(message);
};
window.confirm = function mnoteWindowConfirm(message) {
if (!document.body) {
return Promise.resolve(nativeConfirm ? !!nativeConfirm(message) : true);
}
return confirmDialog(message);
};
window.addEventListener('mnote:toast', function onMnoteToast(event) { window.addEventListener('mnote:toast', function onMnoteToast(event) {
var detail = event && event.detail ? event.detail : {}; var detail = event && event.detail ? event.detail : {};
toast(detail.message, detail); toast(detail.message, detail);
}); });
window.addEventListener('mnote:confirm', function onMnoteConfirm(event) {
var detail = event && event.detail ? event.detail : {};
var message = detail.message || '';
confirmDialog(message, detail).then(function (ok) {
if (typeof detail.onResult === 'function') detail.onResult(ok);
});
});
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensurePortalRoot, { once: true }); document.addEventListener('DOMContentLoaded', ensurePortalRoot, { once: true });
} else { } else {
@@ -783,7 +783,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
var resolved = await resolveEditorAttachmentUrl(detail); var resolved = await resolveEditorAttachmentUrl(detail);
var title = String(detail.fileName || resolved.asset.file_name || 'PDF').trim() || 'PDF'; var title = String(detail.fileName || resolved.asset.file_name || 'PDF').trim() || 'PDF';
var previewUrl = buildPdfPreviewOpenUrl(resolved.url, title); var previewUrl = buildPdfPreviewOpenUrl(resolved.url, title);
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'async function') {
var didOpenPdfTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ var didOpenPdfTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: 'resource:pdf:' + (detail.documentId || '') + ':' + (detail.assetId || previewUrl || resolved.url), objectIdentity: 'resource:pdf:' + (detail.documentId || '') + ':' + (detail.assetId || previewUrl || resolved.url),
assetId: detail.assetId || '', assetId: detail.assetId || '',
@@ -799,7 +799,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
} }
window.open(previewUrl || resolved.url, '_blank', 'noopener,noreferrer'); window.open(previewUrl || resolved.url, '_blank', 'noopener,noreferrer');
} catch (error) { } catch (error) {
window.alert(error && error.message ? error.message : '打开 PDF 失败'); await window.mnote.alert(error && error.message ? error.message : '打开 PDF 失败');
} }
} }
@@ -848,7 +848,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
window.open(resolved.url, '_blank', 'noopener,noreferrer'); window.open(resolved.url, '_blank', 'noopener,noreferrer');
return; return;
} }
window.alert(error && error.message ? error.message : '打开代码附件失败'); await window.mnote.alert(error && error.message ? error.message : '打开代码附件失败');
} }
} }
@@ -148,10 +148,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}).then(function(){ }).then(function(){
if (!localFolderSource && documentId) updateTitleEverywhere(documentId, commandTitle); if (!localFolderSource && documentId) updateTitleEverywhere(documentId, commandTitle);
}); });
void work.then(close).catch(function(error) { void work.then(close).catch(async function(error) {
committing = false; committing = false;
input.disabled = false; input.disabled = false;
window.alert(error && error.message ? error.message : '重命名失败'); await window.mnote.alert(error && error.message ? error.message : '重命名失败');
}); });
}; };
input.addEventListener('click', function(event) { event.stopPropagation(); }); input.addEventListener('click', function(event) { event.stopPropagation(); });
@@ -298,7 +298,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return url.toString(); return url.toString();
} }
function convertToPreviousSiblingChild(trigger, detail) { async function convertToPreviousSiblingChild(trigger, detail) {
var documentId = detail.documentId || ''; var documentId = detail.documentId || '';
var row = document.querySelector('#sidebar-tree-root .tree-row[data-node-id="' + cssEscape(documentId) + '"]'); var row = document.querySelector('#sidebar-tree-root .tree-row[data-node-id="' + cssEscape(documentId) + '"]');
if (!(row instanceof HTMLElement)) return; if (!(row instanceof HTMLElement)) return;
@@ -308,7 +308,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}); });
var index = siblings.indexOf(row); var index = siblings.indexOf(row);
if (index <= 0) { if (index <= 0) {
window.alert('当前页面前面没有同级页面。'); await window.mnote.alert('当前页面前面没有同级页面。');
return; return;
} }
var previous = siblings[index - 1]; var previous = siblings[index - 1];
@@ -369,9 +369,21 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
function joinLocalAbsolutePath(rootUri, relativePath) { function joinLocalAbsolutePath(rootUri, relativePath) {
var rootPath = localRootPathFromRootUri(rootUri); var rootPath = localRootPathFromRootUri(rootUri);
var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, ''); var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, '').replace(/\\/g, '/');
if (!rootPath || !normalized) return rootPath || normalized; if (!rootPath || !normalized) return rootPath || normalized;
return rootPath.replace(/\/+$/g, '') + '/' + normalized; // Collapse . / empty and resolve .. without climbing above workspace root.
var parts = [];
normalized.split('/').forEach(function (seg) {
if (!seg || seg === '.') return;
if (seg === '..') {
if (parts.length) parts.pop();
return;
}
parts.push(seg);
});
var safeRel = parts.join('/');
if (!safeRel) return rootPath.replace(/\/+$/g, '');
return rootPath.replace(/\/+$/g, '') + '/' + safeRel;
} }
function fileTreeCopyLocalAbsolutePath(detail, trigger) { function fileTreeCopyLocalAbsolutePath(detail, trigger) {
@@ -488,7 +500,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return payload; return payload;
} }
function handleTreeContextMenuAction(action, detail, trigger) { async function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu(); closeTreeContextMenu();
detail = detail || {}; detail = detail || {};
if (action === 'knowledge-rag-index') { if (action === 'knowledge-rag-index') {
@@ -499,13 +511,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
if (window.mnote && window.mnote.toast) { if (window.mnote && window.mnote.toast) {
window.mnote.toast('已加入知识库', { kind: 'success' }); window.mnote.toast('已加入知识库', { kind: 'success' });
} }
}).catch(function(error) { }).catch(async function(error) {
var msg = error && error.message ? error.message : '知识库索引失败'; var msg = error && error.message ? error.message : '知识库索引失败';
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
if (window.mnote && window.mnote.toast) { if (window.mnote && window.mnote.toast) {
window.mnote.toast(msg, { kind: 'error' }); window.mnote.toast(msg, { kind: 'error' });
} else { } else {
window.alert(msg); await window.mnote.alert(msg);
} }
}); });
return; return;
@@ -584,7 +596,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return; return;
} }
if (isAsset && action === 'open-edit-mode') { if (isAsset && action === 'open-edit-mode') {
withOfficeEditModeGuard(function() { withOfficeEditModeGuard(async function() {
recordFileTreeAction('open-edit-mode', detail); recordFileTreeAction('open-edit-mode', detail);
void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' }); void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' });
}); });
@@ -635,15 +647,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
} }
} }
if (action === 'delete-trash' && isAsset) { if (action === 'delete-trash' && isAsset) {
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return; if (!(await window.mnote.confirm('确定要将“' + title + '”删除到垃圾桶吗?'))) return;
recordFileTreeAction('delete-trash', detail); recordFileTreeAction('delete-trash', detail);
recordFileTreeActionStatus('pending', detail); recordFileTreeActionStatus('pending', detail);
void deleteSingleFileTreeAsset(detail, trigger).then(function() { void deleteSingleFileTreeAsset(detail, trigger).then(function() {
recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' })); recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' }));
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
}).catch(function(error) { }).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '资源删除失败'); await window.mnote.alert(error && error.message ? error.message : '资源删除失败');
}); });
return; return;
} }
@@ -655,9 +667,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
recordFileTreeAction('new-folder', detail); recordFileTreeAction('new-folder', detail);
void createFileTreeFolder(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null).then(function(ok) { void createFileTreeFolder(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null).then(function(ok) {
recordFileTreeActionStatus(ok ? 'created' : 'skipped', detail); recordFileTreeActionStatus(ok ? 'created' : 'skipped', detail);
}).catch(function(error) { }).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '新建文件夹失败'); await window.mnote.alert(error && error.message ? error.message : '新建文件夹失败');
}); });
return; return;
} }
@@ -690,9 +702,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
recordFileTreeAction('paste-into', detail); recordFileTreeAction('paste-into', detail);
void pasteSidebarFileTreeClipboard(pasteRow).then(function(ok) { void pasteSidebarFileTreeClipboard(pasteRow).then(function(ok) {
recordFileTreeActionStatus(ok ? 'applied' : 'skipped', detail); recordFileTreeActionStatus(ok ? 'applied' : 'skipped', detail);
}).catch(function(error) { }).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '粘贴失败'); await window.mnote.alert(error && error.message ? error.message : '粘贴失败');
}); });
return; return;
} }
@@ -738,7 +750,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
workspaceId: workspaceId, workspaceId: workspaceId,
documentId: documentId, documentId: documentId,
title: nextTitle.trim() title: nextTitle.trim()
}).then(function(){ updateTitleEverywhere(documentId, nextTitle.trim()); }); }).then(async function(){ updateTitleEverywhere(documentId, nextTitle.trim()); });
} }
return; return;
} }
@@ -747,12 +759,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return; return;
} }
if (action === 'convert-child') { if (action === 'convert-child') {
convertToPreviousSiblingChild(trigger, detail); void convertToPreviousSiblingChild(trigger, detail);
return; return;
} }
var deleteTargetId = documentId || String(detail.rowId || '').trim(); var deleteTargetId = documentId || String(detail.rowId || '').trim();
if (action === 'delete-trash' && deleteTargetId) { if (action === 'delete-trash' && deleteTargetId) {
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return; if (!(await window.mnote.confirm('确定要将“' + title + '”删除到垃圾桶吗?'))) return;
recordFileTreeAction('delete-trash', detail); recordFileTreeAction('delete-trash', detail);
recordFileTreeActionStatus('pending', detail); recordFileTreeActionStatus('pending', detail);
void dispatchTreeCommand(trigger || document.body, { void dispatchTreeCommand(trigger || document.body, {
@@ -762,9 +774,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}).then(function() { }).then(function() {
recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' })); recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' }));
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
}).catch(function(error) { }).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '删除失败'); await window.mnote.alert(error && error.message ? error.message : '删除失败');
}); });
} }
} }
@@ -967,7 +979,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
document.head.appendChild(style); document.head.appendChild(style);
} }
// ── 知识库列表获取:只读 MNote 后端 registry/status避免前端直连 WeKnora service key。 ── // ── 知识库列表获取:只读 MNote 后端 registry/status不直连外部知识库 service key。 ──
function fetchKnowledgeBaseList(detail) { function fetchKnowledgeBaseList(detail) {
var rootUri = knowledgeRagRootUri(detail, null); var rootUri = knowledgeRagRootUri(detail, null);
if (!rootUri) return Promise.resolve([]); if (!rootUri) return Promise.resolve([]);
@@ -2125,7 +2137,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'move', failed: failures.length, count: movableRows.length } })); window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'move', failed: failures.length, count: movableRows.length } }));
if (failures.length > 0) { if (failures.length > 0) {
recordFileTreeActionStatus('failed', { documentId: targetParentId, fallback: 'alert', batchId: batchId }); recordFileTreeActionStatus('failed', { documentId: targetParentId, fallback: 'alert', batchId: batchId });
window.alert('部分对象移动失败:' + failures.join('')); await window.mnote.alert('部分对象移动失败:' + failures.join(''));
return false; return false;
} }
recordFileTreeActionStatus('applied', { documentId: targetParentId, batchId: batchId }); recordFileTreeActionStatus('applied', { documentId: targetParentId, batchId: batchId });
@@ -2159,7 +2171,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var plan = buildSidebarFileTreeDeletePlan(rows); var plan = buildSidebarFileTreeDeletePlan(rows);
var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length; var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length;
if (total === 0) return false; if (total === 0) return false;
if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false; if (!(await window.mnote.confirm(sidebarFileTreeDeleteConfirmText(plan)))) return false;
var batchId = nextFileTreeOperationBatchId('bulk-delete'); var batchId = nextFileTreeOperationBatchId('bulk-delete');
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-batch-id', batchId); document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-batch-id', batchId);
recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total, batchId: batchId }); recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total, batchId: batchId });
@@ -2264,7 +2276,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
if (failures.length > 0) { if (failures.length > 0) {
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: failures.length, count: total } })); window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: failures.length, count: total } }));
recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert', batchId: batchId }); recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert', batchId: batchId });
window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : '')); await window.mnote.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : ''));
return false; return false;
} }
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true'); document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
@@ -209,13 +209,39 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|| (/^思维导图/i.test(fileName) && /\.json$/i.test(fileName)); || (/^思维导图/i.test(fileName) && /\.json$/i.test(fileName));
} }
function sanitizeWorkspaceRelativePath(rel) {
var value = String(rel || '').trim().replace(/\\/g, '/');
try {
value = decodeURIComponent(value);
} catch (_) {
// keep raw
}
value = value.replace(/\\/g, '/').replace(/^\/+/, '');
if (!value || value.indexOf('\0') >= 0) return '';
var parts = [];
value.split('/').forEach(function (seg) {
if (!seg || seg === '.') return;
if (seg === '..') {
if (parts.length) parts.pop();
return;
}
parts.push(seg);
});
return parts.join('/');
}
function localFilePathFromAssetId(assetId) { function localFilePathFromAssetId(assetId) {
var _rto_ = window.__mnoteResourceOpenRuntime; var _rto_ = window.__mnoteResourceOpenRuntime;
if (_rto_ && typeof _rto_.localFilePathFromAssetId === 'function') { if (_rto_ && typeof _rto_.localFilePathFromAssetId === 'function') {
return _rto_.localFilePathFromAssetId(assetId); return sanitizeWorkspaceRelativePath(_rto_.localFilePathFromAssetId(assetId));
} }
var value = String(assetId || '').trim(); var value = String(assetId || '').trim();
return value.indexOf('local-file:') === 0 ? value.slice('local-file:'.length) : value.indexOf('local:asset:') === 0 ? value.slice('local:asset:'.length) : ''; var raw = value.indexOf('local-file:') === 0
? value.slice('local-file:'.length)
: value.indexOf('local:asset:') === 0
? value.slice('local:asset:'.length)
: '';
return sanitizeWorkspaceRelativePath(raw);
} }
function workspacePathFromDetail(detail) { function workspacePathFromDetail(detail) {
@@ -249,15 +275,16 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
} }
function buildLocalFileOpenUrl(relativePath, download) { function buildLocalFileOpenUrl(relativePath, download) {
var safePath = sanitizeWorkspaceRelativePath(relativePath);
var _rto_ = window.__mnoteResourceOpenRuntime; var _rto_ = window.__mnoteResourceOpenRuntime;
if (_rto_ && typeof _rto_.buildLocalFileOpenUrl === 'function') { if (_rto_ && typeof _rto_.buildLocalFileOpenUrl === 'function') {
return _rto_.buildLocalFileOpenUrl(relativePath, download); return safePath ? _rto_.buildLocalFileOpenUrl(safePath, download) : '';
} }
var rootUri = currentRootUri(); var rootUri = currentRootUri();
if (!rootUri || !relativePath) return ''; if (!rootUri || !safePath) return '';
var url = new URL('/api/local-folder/files/open', window.location.origin); var url = new URL('/api/local-folder/files/open', window.location.origin);
url.searchParams.set('rootUri', rootUri); url.searchParams.set('rootUri', rootUri);
url.searchParams.set('path', relativePath); url.searchParams.set('path', safePath);
if (download) url.searchParams.set('download', 'true'); if (download) url.searchParams.set('download', 'true');
return url.toString(); return url.toString();
} }
@@ -466,7 +493,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
} }
var documentId = String(detail && detail.documentId || '').trim(); var documentId = String(detail && detail.documentId || '').trim();
if (isMindmapAssetDetail(detail) && documentId) { if (isMindmapAssetDetail(detail) && documentId) {
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'async function') {
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-resource-tab'); document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-resource-tab');
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
@@ -493,7 +520,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
return; return;
} }
document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true'); document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
window.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。'); await window.mnote.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。');
} }
window.addEventListener('tree.asset.open', function(event) { window.addEventListener('tree.asset.open', function(event) {
@@ -46,7 +46,7 @@ export const createSidebarFileTreeUploadRuntime = (dependencies = {}) => {
}); });
} }
function blockReadonlyFileTreeAction(action, detail, message) { async function blockReadonlyFileTreeAction(action, detail, message) {
var normalizedAction = String(action || 'drop').trim() || 'drop'; var normalizedAction = String(action || 'drop').trim() || 'drop';
var text = String(message || '目标位置是只读,不能拖放到这里').trim(); var text = String(message || '目标位置是只读,不能拖放到这里').trim();
var targetRowId = String(detail && (detail.targetRowId || detail.rowId) || '').trim(); var targetRowId = String(detail && (detail.targetRowId || detail.rowId) || '').trim();
@@ -75,7 +75,7 @@ export const createSidebarFileTreeUploadRuntime = (dependencies = {}) => {
row.setAttribute('data-readonly-message', text); row.setAttribute('data-readonly-message', text);
} }
} }
window.alert(text); await window.mnote.alert(text);
return false; return false;
} }
@@ -1186,15 +1186,15 @@
var documentId = String(urlDocumentId || activeWorkspacePath.documentId || active.documentId || '').trim(); var documentId = String(urlDocumentId || activeWorkspacePath.documentId || active.documentId || '').trim();
var fileTreeWorkspacePath = activeFileTreeWorkspacePath(documentId) || {}; var fileTreeWorkspacePath = activeFileTreeWorkspacePath(documentId) || {};
var workspacePath = Object.assign({}, activeWorkspacePath, fileTreeWorkspacePath); var workspacePath = Object.assign({}, activeWorkspacePath, fileTreeWorkspacePath);
var session = piLabState.session || {}; // Do not fall back to piLabState.session.* — that re-amplifies stale context after navigation.
var pagePath = normalizeSlashes(urlPagePath || workspacePath.relativePath || workspacePath.path || active.relativePath || active.path || localMarkdownRelativePathFromDocumentId(documentId) || session.pagePath || ''); var pagePath = normalizeSlashes(urlPagePath || workspacePath.relativePath || workspacePath.path || active.relativePath || active.path || localMarkdownRelativePathFromDocumentId(documentId) || '');
var resourceKind = String(workspacePath.resourceKind || workspacePath.kind || active.resourceKind || active.kind || '').trim().toLowerCase(); var resourceKind = String(workspacePath.resourceKind || workspacePath.kind || active.resourceKind || active.kind || '').trim().toLowerCase();
var isDirectory = resourceKind === 'directory' || resourceKind === 'folder' || resourceKind === 'workspace'; var isDirectory = resourceKind === 'directory' || resourceKind === 'folder' || resourceKind === 'workspace';
var isStandalonePiPage = currentUrl().pathname === '/page-ai/pi'; var isStandalonePiPage = currentUrl().pathname === '/page-ai/pi';
var isLocalMarkdownPage = documentId.indexOf('local-md:') === 0 || /\.md$/i.test(pagePath); var isLocalMarkdownPage = documentId.indexOf('local-md:') === 0 || /\.md$/i.test(pagePath);
if (isDirectory || (isStandalonePiPage && pagePath && !isLocalMarkdownPage)) pagePath = ''; if (isDirectory || (isStandalonePiPage && pagePath && !isLocalMarkdownPage)) pagePath = '';
var rootUri = String(currentRootUri() || workspacePath.rootUri || active.rootUri || session.rootUri || '').trim(); var rootUri = String(currentRootUri() || workspacePath.rootUri || active.rootUri || '').trim();
var workspaceId = String(currentWorkspaceId() || workspacePath.workspaceId || active.workspaceId || session.workspaceId || '').trim(); var workspaceId = String(currentWorkspaceId() || workspacePath.workspaceId || active.workspaceId || '').trim();
var titleNode = document.querySelector('[data-page-title-current="true"]') || document.querySelector('.wolai-breadcrumb-current'); var titleNode = document.querySelector('[data-page-title-current="true"]') || document.querySelector('.wolai-breadcrumb-current');
var title = String(active.title || workspacePath.title || (titleNode && titleNode.textContent) || document.title || '').trim(); var title = String(active.title || workspacePath.title || (titleNode && titleNode.textContent) || document.title || '').trim();
var selection = currentSelectionText(); var selection = currentSelectionText();
@@ -1278,12 +1278,13 @@
var context = currentPageContext(); var context = currentPageContext();
refreshSelectionSummary(); refreshSelectionSummary();
if (!piLabState.session) piLabState.session = {}; if (!piLabState.session) piLabState.session = {};
if (context.pagePath) piLabState.session.pagePath = context.pagePath; // Always sync (including empty) so navigation away from a page clears stale context.
if (context.pageTitle) piLabState.session.pageTitle = context.pageTitle; piLabState.session.pagePath = context.pagePath || '';
if (context.rootUri) piLabState.session.rootUri = context.rootUri; piLabState.session.pageTitle = context.pageTitle || '';
if (context.workspaceId) piLabState.session.workspaceId = context.workspaceId; piLabState.session.rootUri = context.rootUri || '';
piLabState.session.workspaceId = context.workspaceId || '';
var folderPath = currentFolderPathFromPagePath(context.pagePath); var folderPath = currentFolderPathFromPagePath(context.pagePath);
if (folderPath !== null) piLabState.session.folderPath = folderPath; piLabState.session.folderPath = folderPath === null ? '' : folderPath;
updateContextStrip(); updateContextStrip();
return context; return context;
} }
@@ -2294,11 +2295,11 @@
updateConfig(); updateConfig();
} }
function deleteHistorySession(sessionId) { async function deleteHistorySession(sessionId) {
if (!sessionId) return Promise.resolve(); if (!sessionId) return Promise.resolve();
var existing = currentHistorySession(sessionId); var existing = currentHistorySession(sessionId);
var title = existing ? historyTitle(existing) : sessionId; var title = existing ? historyTitle(existing) : sessionId;
if (!window.confirm('删除历史对话“' + title + '”?')) return Promise.resolve(); if (!(await window.mnote.confirm('删除历史对话“' + title + '”?'))) return Promise.resolve();
return fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId), { return fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId), {
method: 'DELETE', method: 'DELETE',
credentials: 'same-origin', credentials: 'same-origin',
@@ -2313,10 +2314,10 @@
}); });
} }
function clearHistorySessions() { async function clearHistorySessions() {
var count = Array.isArray(piLabState.history) ? piLabState.history.length : 0; var count = Array.isArray(piLabState.history) ? piLabState.history.length : 0;
if (!count) return Promise.resolve(); if (!count) return Promise.resolve();
if (!window.confirm('清空全部 ' + count + ' 条 Pi 历史对话?')) return Promise.resolve(); if (!(await window.mnote.confirm('清空全部 ' + count + ' 条 Pi 历史对话?'))) return Promise.resolve();
return fetch(API.SESSIONS + '?limit=1000', { return fetch(API.SESSIONS + '?limit=1000', {
method: 'DELETE', method: 'DELETE',
credentials: 'same-origin', credentials: 'same-origin',
@@ -2618,8 +2619,10 @@
if (lightrag) lightrag.textContent = '唯一默认'; if (lightrag) lightrag.textContent = '唯一默认';
if (changedFiles) { if (changedFiles) {
var files = {}; var files = {};
piLabState.changedFiles.forEach(function (file) { if (file) files[file] = true; }); (Array.isArray(piLabState.changedFiles) ? piLabState.changedFiles : []).forEach(function (file) {
piLabState.messages.forEach(function (msg) { if (file) files[file] = true;
});
(Array.isArray(piLabState.messages) ? piLabState.messages : []).forEach(function (msg) {
if (msg && msg.diffSummary && Array.isArray(msg.diffSummary.files)) { if (msg && msg.diffSummary && Array.isArray(msg.diffSummary.files)) {
msg.diffSummary.files.forEach(function (file) { if (file) files[file] = true; }); msg.diffSummary.files.forEach(function (file) { if (file) files[file] = true; });
} }
@@ -3264,7 +3267,7 @@
function collectPiArtifacts() { function collectPiArtifacts() {
var artifacts = []; var artifacts = [];
var seen = {}; var seen = {};
piLabState.messages.forEach(function (msg) { (Array.isArray(piLabState.messages) ? piLabState.messages : []).forEach(function (msg) {
if (!msg) return; if (!msg) return;
(Array.isArray(msg.citations) ? msg.citations : []).forEach(function (cit, index) { (Array.isArray(msg.citations) ? msg.citations : []).forEach(function (cit, index) {
var key = 'citation:' + (cit.url || cit.source || cit.title || index); var key = 'citation:' + (cit.url || cit.source || cit.title || index);
@@ -3307,7 +3310,7 @@
}); });
}); });
}); });
piLabState.changedFiles.forEach(function (file) { (Array.isArray(piLabState.changedFiles) ? piLabState.changedFiles : []).forEach(function (file) {
if (!file || seen['diff:' + file]) return; if (!file || seen['diff:' + file]) return;
seen['diff:' + file] = true; seen['diff:' + file] = true;
artifacts.push({ kind: 'diff', title: file, summary: '文件变更' }); artifacts.push({ kind: 'diff', title: file, summary: '文件变更' });
@@ -3722,8 +3725,10 @@
function handleToolCallEventPayload(payload) { function handleToolCallEventPayload(payload) {
payload = payload || {}; payload = payload || {};
// Fail-closed: only explicit allowed===true triggers side effects / changedFiles.
var allowed = payload.allowed === true;
applyToolCallSideEffects({ applyToolCallSideEffects({
ok: payload.allowed !== false, ok: allowed,
toolName: payload.toolName, toolName: payload.toolName,
result: { result: {
rootUri: payload.rootUri, rootUri: payload.rootUri,
@@ -3733,7 +3738,7 @@
}); });
piLabState.receipts.push({ piLabState.receipts.push({
toolName: payload.toolName, toolName: payload.toolName,
allowed: payload.allowed !== false, allowed: allowed,
denyReason: payload.denyReason || null, denyReason: payload.denyReason || null,
diffSummary: payload.diffSummary || null, diffSummary: payload.diffSummary || null,
citationCount: payload.citationCount || 0, citationCount: payload.citationCount || 0,
@@ -3746,9 +3751,9 @@
toolCallId: payload.toolCallId || payload.id || ('receipt:' + (payload.toolName || 'tool') + ':' + piLabState.receipts.length), toolCallId: payload.toolCallId || payload.id || ('receipt:' + (payload.toolName || 'tool') + ':' + piLabState.receipts.length),
toolName: payload.toolName || 'tool', toolName: payload.toolName || 'tool',
args: payload.params || payload.args || {}, args: payload.params || payload.args || {},
}, payload.allowed === false ? 'denied' : 'done', { }, allowed ? 'done' : 'denied', {
result: { result: {
allowed: payload.allowed !== false, allowed: allowed,
denyReason: payload.denyReason || null, denyReason: payload.denyReason || null,
diffSummary: payload.diffSummary || null, diffSummary: payload.diffSummary || null,
citationCount: payload.citationCount || 0, citationCount: payload.citationCount || 0,
@@ -3757,7 +3762,9 @@
approvalConfirmed: payload.approvalConfirmed === true, approvalConfirmed: payload.approvalConfirmed === true,
toolPolicy: payload.toolPolicy || '', toolPolicy: payload.toolPolicy || '',
}); });
if (payload.normalizedFilePath && payload.diffSummary) piLabState.changedFiles.push(payload.normalizedFilePath); if (allowed && payload.normalizedFilePath && payload.diffSummary) {
piLabState.changedFiles.push(payload.normalizedFilePath);
}
updateReceiptDisplay(); updateReceiptDisplay();
updateContextStrip(); updateContextStrip();
} }
@@ -4591,7 +4598,8 @@
function parseUnifiedPatch(patchText) { function parseUnifiedPatch(patchText) {
if (!patchText) return []; if (!patchText) return [];
var files = []; var files = [];
var fileLines = patchText.split('\\n'); // Real newlines (JSON-parsed patch text), not the two-char sequence \ + n.
var fileLines = String(patchText).replace(/\r\n/g, '\n').split('\n');
var currentFile = null; var currentFile = null;
var currentHunk = null; var currentHunk = null;
@@ -4982,8 +4990,8 @@
} }
var copyMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-copy-markdown]') : null; var copyMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-copy-markdown]') : null;
if (copyMarkdown) { if (copyMarkdown) {
copyTextToClipboard(copyMarkdown.getAttribute('data-page-ai-pi-lab-copy-markdown') || '').then(function () { copyTextToClipboard(copyMarkdown.getAttribute('data-page-ai-pi-lab-copy-markdown') || '').then(function (ok) {
showPiToast('Markdown copied', 'success'); showPiToast(ok ? 'Markdown copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning');
}); });
return; return;
} }
@@ -4999,7 +5007,11 @@
} }
var openArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-open]') : null; var openArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-open]') : null;
if (openArtifact) { if (openArtifact) {
var artUrl = openArtifact.getAttribute('data-page-ai-pi-lab-artifact-open') || openArtifact.getAttribute('href') || ''; var artUrl = String(openArtifact.getAttribute('data-page-ai-pi-lab-artifact-open') || openArtifact.getAttribute('href') || '').trim();
if (!artUrl || artUrl === '#') {
showPiToast('引用无效或无法访问', 'warning');
return;
}
window.dispatchEvent(new CustomEvent('mnote:open-reference', { window.dispatchEvent(new CustomEvent('mnote:open-reference', {
detail: { detail: {
source: 'page_ai_pi_lab_artifact', source: 'page_ai_pi_lab_artifact',
@@ -5007,9 +5019,7 @@
url: artUrl, url: artUrl,
}, },
})); }));
if (!artUrl || artUrl === '#') { return;
showPiToast('引用无效或无法访问', 'warning');
}
} }
var viewDiff = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-view-diff]') : null; var viewDiff = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-view-diff]') : null;
if (viewDiff) { if (viewDiff) {
@@ -5020,15 +5030,17 @@
} }
var copyArtifactMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy-markdown]') : null; var copyArtifactMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy-markdown]') : null;
if (copyArtifactMarkdown) { if (copyArtifactMarkdown) {
copyTextToClipboard(copyArtifactMarkdown.getAttribute('data-page-ai-pi-lab-artifact-copy-markdown') || '').then(function () { copyTextToClipboard(copyArtifactMarkdown.getAttribute('data-page-ai-pi-lab-artifact-copy-markdown') || '').then(function (ok) {
showPiToast('Artifact markdown copied', 'success'); showPiToast(ok ? 'Artifact markdown copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning');
}); });
return; return;
} }
var copyArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy]') : null; var copyArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy]') : null;
if (copyArtifact) { if (copyArtifact) {
var raw = copyArtifact.getAttribute('data-page-ai-pi-lab-artifact-copy') || ''; var raw = copyArtifact.getAttribute('data-page-ai-pi-lab-artifact-copy') || '';
copyTextToClipboard(raw).then(function () { showPiToast('Artifact raw copied', 'success'); }); copyTextToClipboard(raw).then(function (ok) {
showPiToast(ok ? 'Artifact raw copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning');
});
return; return;
} }
var deleteTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-delete]') : null; var deleteTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-delete]') : null;
@@ -14,8 +14,21 @@ export function createSidebarPageAiRuntime(context) {
function openPageAiDrawer() { function openPageAiDrawer() {
// 产品唯一入口:Pi Lab。旧 Hermes/OpenCode/Board drawer 不再打开。 // 产品唯一入口:Pi Lab。旧 Hermes/OpenCode/Board drawer 不再打开。
if (window.createSidebarPageAiPiLabRuntime) { if (typeof window.createSidebarPageAiPiLabRuntime !== 'function') {
console.warn('[page-ai] Pi Lab runtime not loaded');
if (window.mnote && typeof window.mnote.toast === 'function') {
window.mnote.toast('Page AI 运行时未加载', { kind: 'warning' });
}
return;
}
try {
window.createSidebarPageAiPiLabRuntime({}); window.createSidebarPageAiPiLabRuntime({});
} catch (err) {
console.error('[page-ai] Pi Lab runtime init failed', err);
if (window.mnote && typeof window.mnote.toast === 'function') {
window.mnote.toast('Page AI 启动失败', { kind: 'error' });
}
return;
} }
window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin); window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin);
if (pageUiState) pageUiState.pageAiOpen = true; if (pageUiState) pageUiState.pageAiOpen = true;
@@ -691,49 +691,49 @@ export function createSidebarPageSettingsRuntime(context) {
function createKnowledgeRagSettingsPanelHtmlV2() { function createKnowledgeRagSettingsPanelHtmlV2() {
return '' + return '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-weknora-kb-page" role="dialog" aria-modal="false" aria-label="LightRAG 知识库" data-testid="mnote-weknora-knowledge-settings-panel" data-knowledge-rag-default-provider="lightrag" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="lightrag-source-registry" data-mnote-weknora-page-replacement="false">' + '<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-krag-kb-page" role="dialog" aria-modal="false" aria-label="LightRAG 知识库" data-testid="mnote-krag-knowledge-settings-panel" data-knowledge-rag-default-provider="lightrag" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="lightrag-source-registry" data-mnote-krag-page-replacement="false">' +
'<div class="mnote-weknora-kb-page-header">' + '<div class="mnote-krag-kb-page-header">' +
'<div class="mnote-weknora-kb-page-title">' + '<div class="mnote-krag-kb-page-title">' +
'<span class="mnote-weknora-kb-page-eyebrow">LightRAG</span>' + '<span class="mnote-krag-kb-page-eyebrow">LightRAG</span>' +
'<strong>知识库</strong>' + '<strong>知识库</strong>' +
'<span>资料、索引状态和检索</span>' + '<span>资料、索引状态和检索</span>' +
'</div>' + '</div>' +
'<div class="mnote-weknora-kb-page-actions">' + '<div class="mnote-krag-kb-page-actions">' +
'<button type="button" data-knowledge-rag-action="refresh" title="刷新"><span class="material-symbols-outlined" data-icon="refresh" aria-hidden="true"></span></button>' + '<button type="button" data-knowledge-rag-action="refresh" title="刷新"><span class="material-symbols-outlined" data-icon="refresh" aria-hidden="true"></span></button>' +
'<button type="button" data-knowledge-rag-action="open-dashboard" title="打开知识库" aria-label="打开知识库"><span class="material-symbols-outlined" data-icon="open_in_new" aria-hidden="true"></span></button>' + '<button type="button" data-knowledge-rag-action="open-dashboard" title="打开知识库" aria-label="打开知识库"><span class="material-symbols-outlined" data-icon="open_in_new" aria-hidden="true"></span></button>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭知识库"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' + '<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭知识库"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mnote-weknora-kb-page-status" data-testid="mnote-knowledge-rag-status"></div>' + '<div class="mnote-krag-kb-page-status" data-testid="mnote-knowledge-rag-status"></div>' +
'<div class="mnote-weknora-kb-page-body" data-kb-list-hidden="true">' + '<div class="mnote-krag-kb-page-body" data-kb-list-hidden="true">' +
'<aside class="mnote-weknora-kb-list-pane" data-testid="mnote-weknora-kb-list-pane" hidden>' + '<aside class="mnote-krag-kb-list-pane" data-testid="mnote-krag-kb-list-pane" hidden>' +
'<div class="mnote-weknora-kb-list-head">' + '<div class="mnote-krag-kb-list-head">' +
'<div><strong>资料库</strong><span data-testid="mnote-weknora-kb-list-count">0</span></div>' + '<div><strong>资料库</strong><span data-testid="mnote-krag-kb-list-count">0</span></div>' +
'<button type="button" data-knowledge-rag-action="toggle-create-kb" title="新建资料库"><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' + '<button type="button" data-knowledge-rag-action="toggle-create-kb" title="新建资料库"><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
'</div>' + '</div>' +
'<div class="mnote-weknora-kb-card-wrap" data-testid="mnote-weknora-kb-card-list"></div>' + '<div class="mnote-krag-kb-card-wrap" data-testid="mnote-krag-kb-card-list"></div>' +
'</aside>' + '</aside>' +
'<section class="mnote-weknora-kb-detail-pane" data-testid="mnote-weknora-kb-detail-pane">' + '<section class="mnote-krag-kb-detail-pane" data-testid="mnote-krag-kb-detail-pane">' +
'<div class="mnote-weknora-kb-detail-hero" data-testid="mnote-weknora-kb-detail-hero">' + '<div class="mnote-krag-kb-detail-hero" data-testid="mnote-krag-kb-detail-hero">' +
'<div>' + '<div>' +
'<nav class="mnote-weknora-kb-breadcrumb" data-testid="mnote-weknora-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-weknora-kb-breadcrumb-current">LightRAG 资料库</strong></nav>' + '<nav class="mnote-krag-kb-breadcrumb" data-testid="mnote-krag-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-krag-kb-breadcrumb-current">LightRAG 资料库</strong></nav>' +
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>资料来源</span>' + '<span class="mnote-krag-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>资料来源</span>' +
'<h2 data-testid="mnote-weknora-kb-detail-title">LightRAG 资料库</h2>' + '<h2 data-testid="mnote-krag-kb-detail-title">LightRAG 资料库</h2>' +
'<p data-testid="mnote-weknora-kb-detail-description">MNote 管理本地来源、引用定位和权限边界,LightRAG 负责索引与召回。</p>' + '<p data-testid="mnote-krag-kb-detail-description">MNote 管理本地来源、引用定位和权限边界,LightRAG 负责索引与召回。</p>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<section class="mnote-knowledge-rag-kb-manager" data-testid="mnote-knowledge-rag-kb-manager">' + '<section class="mnote-knowledge-rag-kb-manager" data-testid="mnote-knowledge-rag-kb-manager">' +
'<div class="mnote-knowledge-rag-kb-manager-row">' + '<div class="mnote-knowledge-rag-kb-manager-row">' +
'<label class="mnote-knowledge-rag-kb-select-field"><span>当前资料库</span><select class="mnote-kb-params-select" data-testid="mnote-weknora-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select></label>' + '<label class="mnote-knowledge-rag-kb-select-field"><span>当前资料库</span><select class="mnote-kb-params-select" data-testid="mnote-krag-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select></label>' +
'<button type="button" data-knowledge-rag-action="toggle-create-kb"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span>新建</button>' + '<button type="button" data-knowledge-rag-action="toggle-create-kb"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span>新建</button>' +
'<button type="button" data-knowledge-rag-action="delete-kb" data-knowledge-rag-delete-selected-kb="true"><span class="material-symbols-outlined" data-icon="delete" aria-hidden="true"></span>删除</button>' + '<button type="button" data-knowledge-rag-action="delete-kb" data-knowledge-rag-delete-selected-kb="true"><span class="material-symbols-outlined" data-icon="delete" aria-hidden="true"></span>删除</button>' +
'</div>' + '</div>' +
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls" hidden>' + '<section class="mnote-krag-kb-create-card" data-testid="mnote-krag-kb-controls" hidden>' +
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建资料库</strong></div>' + '<div class="mnote-krag-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建资料库</strong></div>' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' + '<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-krag-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' + '<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-krag-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="create-kb">创建知识库</button>' + '<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="create-kb">创建知识库</button>' +
'<span class="mnote-knowledge-rag-input-status" data-testid="mnote-weknora-create-kb-status" data-knowledge-rag-create-kb-status hidden></span>' + '<span class="mnote-knowledge-rag-input-status" data-testid="mnote-krag-create-kb-status" data-knowledge-rag-create-kb-status hidden></span>' +
'</section>' + '</section>' +
'<div class="mnote-knowledge-rag-kb-manager-note" data-testid="mnote-knowledge-rag-kb-manager-note"></div>' + '<div class="mnote-knowledge-rag-kb-manager-note" data-testid="mnote-knowledge-rag-kb-manager-note"></div>' +
'</section>' + '</section>' +
@@ -742,27 +742,27 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' + '<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
'</details>' + '</details>' +
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="当前资料库内容">' + '<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="当前资料库内容">' +
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">资料</button>' + '<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-krag-tab-documents">资料</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-weknora-tab-wiki" hidden>Wiki</button>' + '<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-krag-tab-wiki" hidden>Wiki</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-weknora-tab-graph" hidden>Graph</button>' + '<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-krag-tab-graph" hidden>Graph</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">检索</button>' + '<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">检索</button>' +
'</div>' + '</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="sources">' + '<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="sources">' +
'<div class="mnote-weknora-doc-layout" data-testid="mnote-weknora-document-management">' + '<div class="mnote-krag-doc-layout" data-testid="mnote-krag-document-management">' +
'<aside class="mnote-weknora-doc-sidebar" data-testid="mnote-weknora-doc-source-sidebar" hidden>' + '<aside class="mnote-krag-doc-sidebar" data-testid="mnote-krag-doc-source-sidebar" hidden>' +
'<div class="mnote-weknora-doc-sidebar-head"><strong>来源</strong><span>MNote registry</span></div>' + '<div class="mnote-krag-doc-sidebar-head"><strong>来源</strong><span>MNote registry</span></div>' +
'<div class="mnote-weknora-doc-source-tags" data-testid="mnote-weknora-doc-source-tags">' + '<div class="mnote-krag-doc-source-tags" data-testid="mnote-krag-doc-source-tags">' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="all" data-active="true"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>全部资料</button>' + '<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="all" data-active="true"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>全部资料</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexed"><span class="material-symbols-outlined" data-icon="check_circle" aria-hidden="true"></span>已索引</button>' + '<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexed"><span class="material-symbols-outlined" data-icon="check_circle" aria-hidden="true"></span>已索引</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>处理中</button>' + '<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>处理中</button>' +
'</div>' + '</div>' +
'<div class="mnote-weknora-doc-sidebar-head"><strong>标签</strong><span>MNote views</span></div>' + '<div class="mnote-krag-doc-sidebar-head"><strong>标签</strong><span>MNote views</span></div>' +
'<div class="mnote-weknora-doc-tags" data-testid="mnote-weknora-doc-tags"><span>PDF</span><span>Markdown</span><span>Folder</span><span>Citation</span></div>' + '<div class="mnote-krag-doc-tags" data-testid="mnote-krag-doc-tags"><span>PDF</span><span>Markdown</span><span>Folder</span><span>Citation</span></div>' +
'</aside>' + '</aside>' +
'<section class="mnote-weknora-doc-main">' + '<section class="mnote-krag-doc-main">' +
'<div class="mnote-weknora-doc-toolbar">' + '<div class="mnote-krag-doc-toolbar">' +
'<div class="mnote-weknora-doc-search"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span><input type="text" data-testid="mnote-weknora-doc-filter-input" data-knowledge-rag-doc-filter-input="true" placeholder="搜索或筛选当前资料库内容" spellcheck="false" /></div>' + '<div class="mnote-krag-doc-search"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span><input type="text" data-testid="mnote-krag-doc-filter-input" data-knowledge-rag-doc-filter-input="true" placeholder="搜索或筛选当前资料库内容" spellcheck="false" /></div>' +
'<div class="mnote-weknora-view-switch" role="group" aria-label="文档视图切换" data-testid="mnote-weknora-view-switch">' + '<div class="mnote-krag-view-switch" role="group" aria-label="文档视图切换" data-testid="mnote-krag-view-switch">' +
'<button type="button" class="is-active" data-knowledge-rag-view="list" aria-pressed="true" title="列表视图"><span class="material-symbols-outlined" data-icon="view_list" aria-hidden="true"></span></button>' + '<button type="button" class="is-active" data-knowledge-rag-view="list" aria-pressed="true" title="列表视图"><span class="material-symbols-outlined" data-icon="view_list" aria-hidden="true"></span></button>' +
'<button type="button" data-knowledge-rag-view="grid" aria-pressed="false" title="网格视图"><span class="material-symbols-outlined" data-icon="grid_view" aria-hidden="true"></span></button>' + '<button type="button" data-knowledge-rag-view="grid" aria-pressed="false" title="网格视图"><span class="material-symbols-outlined" data-icon="grid_view" aria-hidden="true"></span></button>' +
'</div>' + '</div>' +
@@ -774,24 +774,24 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' + '<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
knowledgeRagSourceInputRow('') + knowledgeRagSourceInputRow('') +
'</div>' + '</div>' +
'<div class="mnote-weknora-doc-actions">' + '<div class="mnote-krag-doc-actions">' +
'<button type="button" class="mnote-knowledge-rag-primary-action" data-knowledge-rag-action="ingest">开始索引</button>' + '<button type="button" class="mnote-knowledge-rag-primary-action" data-knowledge-rag-action="ingest">开始索引</button>' +
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' + '<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' + '<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' +
'<div class="mnote-weknora-doc-table" data-testid="mnote-knowledge-rag-sources" data-weknora-document-list-view="true"></div>' + '<div class="mnote-krag-doc-table" data-testid="mnote-knowledge-rag-sources" data-krag-document-list-view="true"></div>' +
'</section>' + '</section>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="wiki" hidden>' + '<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="wiki" hidden>' +
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-wiki-panel">' + '<div class="mnote-krag-placeholder-panel" data-testid="mnote-krag-wiki-panel">' +
'<strong>Wiki</strong>' + '<strong>Wiki</strong>' +
'<span>后续可基于 LightRAG 召回结果生成知识页;当前以 MNote open-reference 边界为准。</span>' + '<span>后续可基于 LightRAG 召回结果生成知识页;当前以 MNote open-reference 边界为准。</span>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="graph" hidden>' + '<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="graph" hidden>' +
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-graph-panel">' + '<div class="mnote-krag-placeholder-panel" data-testid="mnote-krag-graph-panel">' +
'<strong>Graph</strong>' + '<strong>Graph</strong>' +
'<span>展示 LightRAG 图谱和文档关系入口,本地文件真相仍由 MNote source registry 管理。</span>' + '<span>展示 LightRAG 图谱和文档关系入口,本地文件真相仍由 MNote source registry 管理。</span>' +
'</div>' + '</div>' +
@@ -1255,9 +1255,7 @@ export function createSidebarPageSettingsRuntime(context) {
function knowledgeRagProviderLabel(status) { function knowledgeRagProviderLabel(status) {
var providerName = knowledgeRagProviderName(status); var providerName = knowledgeRagProviderName(status);
if (providerName === 'weknora') return 'WeKnora'; if (providerName === 'lightrag' || providerName === 'lightrag_legacy') return 'LightRAG';
if (providerName === 'lightrag') return 'LightRAG';
if (providerName === 'lightrag_legacy') return 'LightRAG';
return providerName; return providerName;
} }
@@ -1371,10 +1369,8 @@ export function createSidebarPageSettingsRuntime(context) {
} }
function knowledgeRagProviderKnowledgeBaseIds(status) { function knowledgeRagProviderKnowledgeBaseIds(status) {
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
var weknora = config.weknora && typeof config.weknora === 'object' ? config.weknora : {};
var ids = []; var ids = [];
var raw = weknora.knowledgeBaseIds || weknora.knowledge_base_ids || status && status.knowledgeBaseIds || []; var raw = status && status.knowledgeBaseIds || [];
if (!Array.isArray(raw) && raw) raw = [raw]; if (!Array.isArray(raw) && raw) raw = [raw];
(Array.isArray(raw) ? raw : []).forEach(function(id) { (Array.isArray(raw) ? raw : []).forEach(function(id) {
var text = String(id || '').trim(); var text = String(id || '').trim();
@@ -1542,10 +1538,10 @@ export function createSidebarPageSettingsRuntime(context) {
} }
function renderKnowledgeRagKnowledgeBaseCards(popover, status, entries, providerDocs) { function renderKnowledgeRagKnowledgeBaseCards(popover, status, entries, providerDocs) {
var listNode = popover.querySelector('[data-testid="mnote-weknora-kb-card-list"]'); var listNode = popover.querySelector('[data-testid="mnote-krag-kb-card-list"]');
var countNode = popover.querySelector('[data-testid="mnote-weknora-kb-list-count"]'); var countNode = popover.querySelector('[data-testid="mnote-krag-kb-list-count"]');
var listPane = popover.querySelector('[data-testid="mnote-weknora-kb-list-pane"]'); var listPane = popover.querySelector('[data-testid="mnote-krag-kb-list-pane"]');
var pageBody = popover.querySelector('.mnote-weknora-kb-page-body'); var pageBody = popover.querySelector('.mnote-krag-kb-page-body');
if (!(listNode instanceof HTMLElement)) return; if (!(listNode instanceof HTMLElement)) return;
var bases = knowledgeRagKnowledgeBases(status || {}); var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {}); var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
@@ -1563,9 +1559,9 @@ export function createSidebarPageSettingsRuntime(context) {
var baseStatus = String(base.status || 'active'); var baseStatus = String(base.status || 'active');
var baseStatusLabel = knowledgeRagBaseStatusLabel(baseStatus); var baseStatusLabel = knowledgeRagBaseStatusLabel(baseStatus);
return '' + return '' +
'<button type="button" class="mnote-weknora-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' + '<button type="button" class="mnote-krag-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
'<span class="mnote-weknora-kb-card-star"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span></span>' + '<span class="mnote-krag-kb-card-star"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span></span>' +
'<span class="mnote-weknora-kb-card-main">' + '<span class="mnote-krag-kb-card-main">' +
'<strong title="' + escapeHtml(base.name) + '">' + escapeHtml(base.name) + '</strong>' + '<strong title="' + escapeHtml(base.name) + '">' + escapeHtml(base.name) + '</strong>' +
'<em title="' + escapeHtml(base.description || '暂无描述') + '">' + escapeHtml(base.description || '暂无描述') + '</em>' + '<em title="' + escapeHtml(base.description || '暂无描述') + '">' + escapeHtml(base.description || '暂无描述') + '</em>' +
'<span>' + '<span>' +
@@ -1574,16 +1570,16 @@ export function createSidebarPageSettingsRuntime(context) {
(stats.processingCount ? '<i class="is-processing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>' + escapeHtml(String(stats.processingCount)) + '</i>' : '') + (stats.processingCount ? '<i class="is-processing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>' + escapeHtml(String(stats.processingCount)) + '</i>' : '') +
'</span>' + '</span>' +
'</span>' + '</span>' +
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatusLabel) + '</span>' + '<span class="mnote-krag-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatusLabel) + '</span>' +
'</button>'; '</button>';
}).join(''); }).join('');
} }
function renderKnowledgeRagKnowledgeBaseHero(popover, status, entries, providerDocs) { function renderKnowledgeRagKnowledgeBaseHero(popover, status, entries, providerDocs) {
var heroNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-hero"]'); var heroNode = popover.querySelector('[data-testid="mnote-krag-kb-detail-hero"]');
var titleNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-title"]'); var titleNode = popover.querySelector('[data-testid="mnote-krag-kb-detail-title"]');
var descNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-description"]'); var descNode = popover.querySelector('[data-testid="mnote-krag-kb-detail-description"]');
var breadcrumbNode = popover.querySelector('[data-testid="mnote-weknora-kb-breadcrumb-current"]'); var breadcrumbNode = popover.querySelector('[data-testid="mnote-krag-kb-breadcrumb-current"]');
if (!(titleNode instanceof HTMLElement) || !(descNode instanceof HTMLElement)) return; if (!(titleNode instanceof HTMLElement) || !(descNode instanceof HTMLElement)) return;
var bases = knowledgeRagKnowledgeBases(status || {}); var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {}); var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
@@ -1646,14 +1642,11 @@ export function createSidebarPageSettingsRuntime(context) {
deleteButton.title = canDelete ? '删除当前空资料库' : (selectedValue === 'lightrag-default' ? '默认资料库不能删除' : '请先移除资料后再删除资料库'); deleteButton.title = canDelete ? '删除当前空资料库' : (selectedValue === 'lightrag-default' ? '默认资料库不能删除' : '请先移除资料后再删除资料库');
} }
if (noteNode instanceof HTMLElement) { if (noteNode instanceof HTMLElement) {
var providerName = knowledgeRagProviderName(status || {});
var activeName = activeBase.name || '当前资料库'; var activeName = activeBase.name || '当前资料库';
if (selectedValue === 'lightrag-default') { if (selectedValue === 'lightrag-default') {
noteNode.textContent = '默认资料库显示当前工作区全部已索引资料;新建资料库可管理独立资料范围。'; noteNode.textContent = '默认资料库显示当前工作区全部已索引资料;新建资料库可管理独立资料范围。';
} else if (activeStats.sourceCount === 0 && activeStats.processingCount === 0) { } else if (activeStats.sourceCount === 0 && activeStats.processingCount === 0) {
noteNode.textContent = activeName + ':空资料库,可添加资料或直接删除。'; noteNode.textContent = activeName + ':空资料库,可添加资料或直接删除。';
} else if (providerName === 'weknora') {
noteNode.textContent = bases.length > 1 ? '已连接多个资料库,可用下拉框切换当前范围。' : '当前只有一个资料库,可新建后切换。';
} else { } else {
noteNode.textContent = bases.length > 1 ? '当前可切换资料库;删除前需要先移除其中资料。' : '当前使用默认资料库,也可以新建一个资料库管理独立范围。'; noteNode.textContent = bases.length > 1 ? '当前可切换资料库;删除前需要先移除其中资料。' : '当前使用默认资料库,也可以新建一个资料库管理独立范围。';
} }
@@ -1674,7 +1667,7 @@ export function createSidebarPageSettingsRuntime(context) {
var activeCount = Object.keys(activeKbIds).length || kbIds.length; var activeCount = Object.keys(activeKbIds).length || kbIds.length;
var processingCount = knowledgeRagInFlightCount(entries, providerDocs); var processingCount = knowledgeRagInFlightCount(entries, providerDocs);
return '' + return '' +
'<div class="mnote-weknora-kb-summary-card" data-testid="mnote-weknora-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="' + escapeHtml(knowledgeRagProviderName(status || {})) + '" data-knowledge-rag-kb-status="configured">' + '<div class="mnote-krag-kb-summary-card" data-testid="mnote-krag-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="' + escapeHtml(knowledgeRagProviderName(status || {})) + '" data-knowledge-rag-kb-status="configured">' +
'<div><span>知识库</span><strong>' + escapeHtml(activeCount ? String(activeCount) + ' 个' : '未配置') + '</strong></div>' + '<div><span>知识库</span><strong>' + escapeHtml(activeCount ? String(activeCount) + ' 个' : '未配置') + '</strong></div>' +
'<div><span>文档</span><strong>' + escapeHtml(String((entries || []).length)) + '</strong></div>' + '<div><span>文档</span><strong>' + escapeHtml(String((entries || []).length)) + '</strong></div>' +
'<div><span>索引文档</span><strong>' + escapeHtml(String((providerDocs || []).length)) + '</strong></div>' + '<div><span>索引文档</span><strong>' + escapeHtml(String((providerDocs || []).length)) + '</strong></div>' +
@@ -1928,7 +1921,7 @@ export function createSidebarPageSettingsRuntime(context) {
function renderKnowledgeRagSourceFilters(popover, rows) { function renderKnowledgeRagSourceFilters(popover, rows) {
var filtersNode = popover.querySelector('[data-testid="mnote-knowledge-rag-source-filters"]'); var filtersNode = popover.querySelector('[data-testid="mnote-knowledge-rag-source-filters"]');
var sourceTagsNode = popover.querySelector('[data-testid="mnote-weknora-doc-source-tags"]'); var sourceTagsNode = popover.querySelector('[data-testid="mnote-krag-doc-source-tags"]');
if (!(filtersNode instanceof HTMLElement)) return; if (!(filtersNode instanceof HTMLElement)) return;
var counts = rows.reduce(function(acc, row) { var counts = rows.reduce(function(acc, row) {
acc.all += 1; acc.all += 1;
@@ -1997,7 +1990,7 @@ export function createSidebarPageSettingsRuntime(context) {
if (filePath) providerDocsByPath.set(filePath, doc); if (filePath) providerDocsByPath.set(filePath, doc);
}); });
var listView = String(pageUiState.knowledgeRagDocumentView || 'list') === 'grid' ? 'grid' : 'list'; var listView = String(pageUiState.knowledgeRagDocumentView || 'list') === 'grid' ? 'grid' : 'list';
sourcesNode.setAttribute('data-weknora-document-view', listView); sourcesNode.setAttribute('data-krag-document-view', listView);
sourcesNode.innerHTML = items.map(function(row) { sourcesNode.innerHTML = items.map(function(row) {
if (row.kind === 'provider') { if (row.kind === 'provider') {
var doc = row.doc || {}; var doc = row.doc || {};
@@ -2022,7 +2015,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-source-main">' + '<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(providerTitle) + '">' + escapeHtml(providerTitle) + '</strong>' + '<strong title="' + escapeHtml(providerTitle) + '">' + escapeHtml(providerTitle) + '</strong>' +
'<span>' + escapeHtml(providerMeta) + '</span>' + '<span>' + escapeHtml(providerMeta) + '</span>' +
'<span class="mnote-weknora-source-aux"><i>' + escapeHtml(knowledgeId || '索引记录') + '</i><i>' + escapeHtml(providerPath ? '引用可回跳' : '无法直接定位') + '</i></span>' + '<span class="mnote-krag-source-aux"><i>' + escapeHtml(knowledgeId || '索引记录') + '</i><i>' + escapeHtml(providerPath ? '引用可回跳' : '无法直接定位') + '</i></span>' +
knowledgeRagSourceProgressHtml(providerProgress) + knowledgeRagSourceProgressHtml(providerProgress) +
'</div>' + '</div>' +
'<div class="mnote-knowledge-rag-source-actions"></div>' + '<div class="mnote-knowledge-rag-source-actions"></div>' +
@@ -2075,7 +2068,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-source-main">' + '<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名资料') + '</strong>' + '<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名资料') + '</strong>' +
'<span>' + escapeHtml(meta) + '</span>' + '<span>' + escapeHtml(meta) + '</span>' +
'<span class="mnote-weknora-source-aux"><i>' + escapeHtml(kbId || '未绑定 KB') + '</i><i>' + escapeHtml(knowledgeId || '待生成 citation') + '</i></span>' + '<span class="mnote-krag-source-aux"><i>' + escapeHtml(kbId || '未绑定 KB') + '</i><i>' + escapeHtml(knowledgeId || '待生成 citation') + '</i></span>' +
knowledgeRagSourceProgressHtml(entryProgress) + knowledgeRagSourceProgressHtml(entryProgress) +
'</div>' + '</div>' +
'<div class="mnote-knowledge-rag-source-actions">' + '<div class="mnote-knowledge-rag-source-actions">' +
@@ -2191,12 +2184,8 @@ export function createSidebarPageSettingsRuntime(context) {
function publicKnowledgeRagDashboardUrl(status) { function publicKnowledgeRagDashboardUrl(status) {
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {}; var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
var active = knowledgeRagProviderName(status || {});
var weknora = config.weknora && typeof config.weknora === 'object' ? config.weknora : {};
var lightrag = config.lightrag && typeof config.lightrag === 'object' ? config.lightrag : {}; var lightrag = config.lightrag && typeof config.lightrag === 'object' ? config.lightrag : {};
var url = active === 'weknora' var url = String((status && (status.dashboardUrl || status.endpoint)) || (lightrag && lightrag.dashboardUrl) || '').trim();
? String((weknora && weknora.dashboardUrl) || (status && (status.dashboardUrl || status.endpoint)) || '').trim()
: String((status && (status.dashboardUrl || status.endpoint)) || (lightrag && lightrag.dashboardUrl) || '').trim();
if (!url) return ''; if (!url) return '';
try { try {
var parsed = new URL(url, window.location.href); var parsed = new URL(url, window.location.href);
@@ -2360,7 +2349,7 @@ export function createSidebarPageSettingsRuntime(context) {
function setKnowledgeRagCreateCardVisible(visible) { function setKnowledgeRagCreateCardVisible(visible) {
var popover = ensureKnowledgeRagSettingsPopover(); var popover = ensureKnowledgeRagSettingsPopover();
var card = popover.querySelector('[data-testid="mnote-weknora-kb-controls"]'); var card = popover.querySelector('[data-testid="mnote-krag-kb-controls"]');
var statusNode = popover.querySelector('[data-knowledge-rag-create-kb-status]'); var statusNode = popover.querySelector('[data-knowledge-rag-create-kb-status]');
if (card instanceof HTMLElement) card.hidden = !visible; if (card instanceof HTMLElement) card.hidden = !visible;
if (statusNode instanceof HTMLElement && visible) statusNode.hidden = true; if (statusNode instanceof HTMLElement && visible) statusNode.hidden = true;
@@ -2372,7 +2361,7 @@ export function createSidebarPageSettingsRuntime(context) {
function toggleKnowledgeRagCreateCard() { function toggleKnowledgeRagCreateCard() {
var popover = ensureKnowledgeRagSettingsPopover(); var popover = ensureKnowledgeRagSettingsPopover();
var card = popover.querySelector('[data-testid="mnote-weknora-kb-controls"]'); var card = popover.querySelector('[data-testid="mnote-krag-kb-controls"]');
setKnowledgeRagCreateCardVisible(!(card instanceof HTMLElement) || card.hidden); setKnowledgeRagCreateCardVisible(!(card instanceof HTMLElement) || card.hidden);
} }
@@ -2724,7 +2713,10 @@ export function createSidebarPageSettingsRuntime(context) {
} }
// --- 检索测试 --- // --- 检索测试 ---
function knowledgeRagSearchResultSourcePath(result) { // depth 限制防止恶意/循环 source 对象导致栈溢出
function knowledgeRagSearchResultSourcePath(result, depth) {
if (depth == null) depth = 0;
if (depth > 4) return '';
var candidates = [ var candidates = [
result && result.sourceRootRelativePath, result && result.sourceRootRelativePath,
result && result.source_root_relative_path, result && result.source_root_relative_path,
@@ -2740,7 +2732,7 @@ export function createSidebarPageSettingsRuntime(context) {
} }
var source = result && result.source; var source = result && result.source;
if (source && typeof source === 'object') { if (source && typeof source === 'object') {
return knowledgeRagSearchResultSourcePath(source); return knowledgeRagSearchResultSourcePath(source, depth + 1);
} }
if (typeof source === 'string' && source.trim()) return source.trim(); if (typeof source === 'string' && source.trim()) return source.trim();
return ''; return '';
@@ -2100,7 +2100,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return await runtimeFn(files, detail || {}, options || {}, { return await runtimeFn(files, detail || {}, options || {}, {
resolveFileTreeUploadTarget: resolveFileTreeUploadTarget, resolveFileTreeUploadTarget: resolveFileTreeUploadTarget,
uploadFileToMediaAsset: uploadFileToMediaAsset, uploadFileToMediaAsset: uploadFileToMediaAsset,
alert: function(message) { window.alert(message); } alert: async function(message) { await window.mnote.alert(message); }
}); });
} }
var list = Array.from(files || []).filter(Boolean); var list = Array.from(files || []).filter(Boolean);
@@ -2116,7 +2116,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
} }
} }
if (errors.length) { if (errors.length) {
window.alert('部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : '')); await window.mnote.alert('部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : ''));
} }
return uploaded; return uploaded;
} }
@@ -1966,7 +1966,7 @@ function startTreeShellRuntime() {
const message = error instanceof Error ? error.message : "创建页面失败"; const message = error instanceof Error ? error.message : "创建页面失败";
setStatus(message, "error"); setStatus(message, "error");
setLastAction("创建页面失败", "error"); setLastAction("创建页面失败", "error");
window.alert(message); await window.mnote.alert(message);
} }
}; };
@@ -2040,7 +2040,7 @@ function startTreeShellRuntime() {
setLastAction("重命名失败", "error"); setLastAction("重命名失败", "error");
inlineRenameState = { mode: null, id: null, committing: false }; inlineRenameState = { mode: null, id: null, committing: false };
renderTree(); renderTree();
window.alert(message); await window.mnote.alert(message);
} }
}; };
@@ -2071,7 +2071,7 @@ function startTreeShellRuntime() {
const item = itemById.get(nodeId); const item = itemById.get(nodeId);
if (!item) return; if (!item) return;
const siblings = getSiblings(item.parentNodeId); const siblings = getSiblings(item.parentNodeId);
const currentIndex = siblings.findIndex((entry) => entry.nodeId === nodeId); const currentIndex = siblings.findIndex(entry) => entry.nodeId === nodeId);
if (currentIndex === -1) return; if (currentIndex === -1) return;
const nextIndex = currentIndex + delta; const nextIndex = currentIndex + delta;
if (nextIndex < 0 || nextIndex >= siblings.length) return; if (nextIndex < 0 || nextIndex >= siblings.length) return;
@@ -2099,7 +2099,7 @@ function startTreeShellRuntime() {
const message = error instanceof Error ? error.message : "移动页面失败"; const message = error instanceof Error ? error.message : "移动页面失败";
setStatus(message, "error"); setStatus(message, "error");
setLastAction("移动页面失败", "error"); setLastAction("移动页面失败", "error");
window.alert(message); await window.mnote.alert(message);
} }
}; };
@@ -2108,7 +2108,7 @@ function startTreeShellRuntime() {
const targetItem = itemById.get(targetNodeId); const targetItem = itemById.get(targetNodeId);
if (!sourceItem || !targetItem) return; if (!sourceItem || !targetItem) return;
const siblings = getSiblings(targetItem.parentNodeId); const siblings = getSiblings(targetItem.parentNodeId);
const targetIndex = siblings.findIndex((entry) => entry.nodeId === targetNodeId); const targetIndex = siblings.findIndex(entry) => entry.nodeId === targetNodeId);
if (targetIndex < 0) return; if (targetIndex < 0) return;
try { try {
const result = await sendCommand({ const result = await sendCommand({
@@ -2134,7 +2134,7 @@ function startTreeShellRuntime() {
const message = error instanceof Error ? error.message : "拖拽移动失败"; const message = error instanceof Error ? error.message : "拖拽移动失败";
setStatus(message, "error"); setStatus(message, "error");
setLastAction("拖拽移动失败", "error"); setLastAction("拖拽移动失败", "error");
window.alert(message); await window.mnote.alert(message);
} }
}; };
@@ -79,49 +79,77 @@
return data && data.result != null ? data.result : data; return data && data.result != null ? data.result : data;
} }
function maskLabel(state) {
if (!state || state.state === 'absent') return '(无)';
if (state.state === 'masked') return '••••••••';
if (state.state === 'revealed') return state.value || '';
return '••••••••';
}
function escapeHtml(text) { function escapeHtml(text) {
return String(text == null ? '' : text) return String(text == null ? '' : text)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
/** /**
* Reusable helper for rendering a single secret row (password/apikey/token). * Display label for a secret field state. Always HTML-escaped so callers can
* Used heavily by account secrets, nested secrets, and the second-secret bug fix. * safely inject into innerHTML (revealed values may contain user-controlled text).
* Reduces duplication in render blocks.
*/ */
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts = {}) { function maskLabel(secretState) {
var key = opts.secretId || ''; if (!secretState || secretState.state === 'absent') return '(无)';
if (secretState.state === 'masked') return '••••••••';
if (secretState.state === 'revealed') return escapeHtml(secretState.value || '');
return '••••••••';
}
/**
* Optional reusable secret-row helper. Prefer bindWorkbench's secretRow for
* production paths; this stays fail-safe if called with uiState in opts.
* @param {object} [opts.uiState] workbench state with revealed / _revealedValues
*/
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts) {
opts = opts || {};
var uiState = opts.uiState || { revealed: {}, _revealedValues: {} };
var revealed = uiState.revealed || {};
var revealedValues = uiState._revealedValues || {};
var key = opts.secretId || secretId || field || '';
var has = secretState && secretState.state === 'masked'; var has = secretState && secretState.state === 'masked';
var canReveal = has || (secretState && secretState.state === 'revealed'); var canReveal = has || (secretState && secretState.state === 'revealed');
if (!canReveal && !showEmpty) return ''; if (!canReveal && !showEmpty) return '';
var shown = state.revealed[key] var isOpen = !!revealed[key];
? state._revealedValues && state._revealedValues[key] var shown = isOpen
? escapeHtml(state._revealedValues[key]) ? (revealedValues[key] != null
: maskLabel(secretState) ? escapeHtml(revealedValues[key])
: maskLabel(secretState))
: maskLabel(secretState); : maskLabel(secretState);
var revealBtn = state.revealed[key] ? '隐藏' : '显示'; var revealBtn = isOpen ? '隐藏' : '显示';
var copyBtn = '<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' + escapeHtml(key) + '">复制</button>'; var copyBtn =
'<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' +
escapeHtml(key) +
'">复制</button>';
var actions =
'<button type="button" data-vault-reveal="" data-vault-secret-id="' +
escapeHtml(secretId || '') +
'" data-testid="vault-reveal-' +
escapeHtml(key) +
'">' +
revealBtn +
'</button>' +
copyBtn;
var actions = state.revealed[key] return (
? '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn '<div class="mnote-vault-field-row" data-vault-secret-row="' +
: '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn; escapeHtml(key) +
'">' +
return '<div class="mnote-vault-field-row" data-vault-secret-row="' + escapeHtml(key) + '">' '<div class="mnote-vault-secret-value" data-testid="vault-secret-' +
+ '<div class="mnote-vault-secret-value" data-testid="vault-secret-' + escapeHtml(key) + '">' + shown + '</div>' escapeHtml(key) +
+ '<div class="mnote-vault-secret-actions">' + actions + '</div>' '">' +
+ '</div>'; shown +
'</div>' +
'<div class="mnote-vault-secret-actions">' +
actions +
'</div>' +
'</div>'
);
} }
function bindWorkbench(root) { function bindWorkbench(root) {
@@ -531,6 +559,10 @@
badge.classList.remove('is-ai'); badge.classList.remove('is-ai');
} }
} }
var repairBtn = qs('[data-vault-repair-ai-folders]', root);
if (repairBtn) {
repairBtn.hidden = !state.isAiVault;
}
root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user'); root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user');
} }
@@ -540,9 +572,9 @@
statusEl.setAttribute('data-type', type || 'info'); statusEl.setAttribute('data-type', type || 'info');
} }
function confirmDiscard() { async function confirmDiscard() {
if (!state.dirty) return true; if (!state.dirty) return true;
return window.confirm('有未保存的修改,确定放弃?'); return await window.mnote.confirm('有未保存的修改,确定放弃?');
} }
function filteredItems() { function filteredItems() {
@@ -840,6 +872,53 @@
return out; return out;
} }
/**
* URLs for the edit form: keep empty draft rows (so "+ 网址" can add a blank
* line). normalizeUrlsFromItem strips empties and would make add-url look dead.
*/
function urlsForFormEditor(item) {
if (item && Array.isArray(item.urls) && item.urls.length) {
return item.urls.map(function (u) {
return u == null ? '' : String(u);
});
}
var urls = normalizeUrlsFromItem(item);
if (!urls.length) urls = [''];
return urls;
}
/** Make a safe absolute href for external open (http/https only). */
function externalUrlHref(raw) {
var t = String(raw || '').trim();
if (!t) return '';
if (/^https?:\/\//i.test(t)) return t;
if (/^\/\//.test(t)) return 'https:' + t;
// Bare host/path: assume https (common for vault entries).
if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}([/:].*)?$/i.test(t)) {
return 'https://' + t;
}
return '';
}
/** View-mode URL as hyperlink look; open only via Ctrl/Cmd+click (see click handler). */
function urlLinkHtml(raw) {
var text = String(raw || '').trim();
if (!text) return '<span class="mnote-vault-muted">—</span>';
var href = externalUrlHref(text);
if (!href) {
return '<span class="mnote-vault-field-value">' + escapeHtml(text) + '</span>';
}
return (
'<a class="mnote-vault-url-link" href="' +
escapeHtml(href) +
'" data-vault-external-url="' +
escapeHtml(href) +
'" title="Ctrl/Cmd + 点击打开">' +
escapeHtml(text) +
'</a>'
);
}
function emptyForm(defaults) { function emptyForm(defaults) {
defaults = defaults || {}; defaults = defaults || {};
var urls = normalizeUrlsFromItem(defaults); var urls = normalizeUrlsFromItem(defaults);
@@ -1381,20 +1460,14 @@
if (urls.length) { if (urls.length) {
urlsHtml = urls urlsHtml = urls
.map(function (u, idx) { .map(function (u, idx) {
var label = idx === 0 ? '主网址' : '备用 ' + idx; var label = idx === 0 ? (urls.length > 1 ? '主网址' : '网址') : '备用 ' + idx;
var link =
'<a href="' +
escapeHtml(u) +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(u) +
'</a>';
return ( return (
'<div class="mnote-vault-field-row">' + '<div class="mnote-vault-field-row">' +
'<label>' + '<label>' +
escapeHtml(label) + escapeHtml(label) +
'</label>' + '</label>' +
'<div class="mnote-vault-field-value">' + '<div class="mnote-vault-field-value">' +
link + urlLinkHtml(u) +
'</div></div>' '</div></div>'
); );
}) })
@@ -1412,9 +1485,7 @@
' 个</span></div>' + ' 个</span></div>' +
urlsHtml + urlsHtml +
'</div>' '</div>'
: urls.length === 1 : urlsHtml) +
? fieldRow('网址', urls[0], showEmpty)
: urlsHtml) +
'<div class="mnote-vault-section">' + '<div class="mnote-vault-section">' +
'<div class="mnote-vault-section-head"><span>登录账号</span>' + '<div class="mnote-vault-section-head"><span>登录账号</span>' +
(accounts.length > 1 (accounts.length > 1
@@ -1722,8 +1793,8 @@
var tagsStr = (item.tags || []).join(', '); var tagsStr = (item.tags || []).join(', ');
// 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。 // 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。
var accounts = normalizeAccountsFromItem(item, true); var accounts = normalizeAccountsFromItem(item, true);
var urls = normalizeUrlsFromItem(item); // Prefer draft rows (may include empty slots after "+ 网址"); do not strip blanks.
if (!urls.length) urls = ['']; var urls = urlsForFormEditor(item);
state._formUrls = urls.slice(); state._formUrls = urls.slice();
/** /**
* Rebuild draft slots from item projection. * Rebuild draft slots from item projection.
@@ -2325,10 +2396,43 @@
} else { } else {
renderDetail(); renderDetail();
} }
setStatus( var repair = result && result.folderRepair;
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条', var statusMsg =
'success' (state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条';
if (repair && Number(repair.updated) > 0) {
statusMsg += ' · 已修复分组 ' + repair.updated + ' 条';
}
setStatus(statusMsg, 'success');
} catch (err) {
setStatus(err.message || String(err), 'error');
toast(err.message || String(err), 'error');
}
}
async function repairAiFolders() {
if (!state.isAiVault) return;
// 你已清掉其它人的分享时,可指定默认分享者;留空则自动从 from-* / sharedFrom 推断
var hint = window.prompt(
'修复 AI 分组为「用户名/原分组」。\n' +
'若库内只剩一人分享,可留空自动识别;否则填写分享者用户名(如 liaibo):',
'liaibo'
);
if (hint === null) return;
setStatus('正在修复 AI 分组…', 'info');
try {
var body = {};
var actor = String(hint || '').trim();
if (actor) body.defaultSourceActor = actor;
var payload = await apiJson('POST', '/api/vault/ai/repair-folders', state.rootUri, body);
var result = (payload && payload.result) || payload || {};
var n = Number(result.updated || 0);
toast(
n > 0
? '已修复 ' + n + ' 条 → 用户名/原分组(如 liaibo/系统)'
: '无需修复(分组已是 用户名/原分组)',
n > 0 ? 'success' : 'info'
); );
await loadList();
} catch (err) { } catch (err) {
setStatus(err.message || String(err), 'error'); setStatus(err.message || String(err), 'error');
toast(err.message || String(err), 'error'); toast(err.message || String(err), 'error');
@@ -2893,10 +2997,32 @@
} }
} }
root.addEventListener('click', function (ev) { root.addEventListener('click', async function (ev) {
var t = ev.target; var t = ev.target;
if (!(t instanceof Element)) return; if (!(t instanceof Element)) return;
// View-mode URL: look like a link, open only with Ctrl/Cmd+click.
var urlLink = t.closest('a.mnote-vault-url-link, a[data-vault-external-url]');
if (urlLink && root.contains(urlLink)) {
var href =
urlLink.getAttribute('data-vault-external-url') ||
urlLink.getAttribute('href') ||
'';
if (!(ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
return;
}
ev.preventDefault();
if (href) {
try {
window.open(href, '_blank', 'noopener,noreferrer');
} catch (_e) {
/* ignore popup block */
}
}
return;
}
var toggle = t.closest('[data-vault-toggle-folder]'); var toggle = t.closest('[data-vault-toggle-folder]');
if (toggle && root.contains(toggle)) { if (toggle && root.contains(toggle)) {
var fpath = toggle.getAttribute('data-vault-toggle-folder') || ''; var fpath = toggle.getAttribute('data-vault-toggle-folder') || '';
@@ -3298,8 +3424,7 @@
var shareTitle = var shareTitle =
(state.item && state.item.title) || state.selectedId; (state.item && state.item.title) || state.selectedId;
var alreadyShared = itemIsSharedToAi(state.item); var alreadyShared = itemIsSharedToAi(state.item);
if ( if (!(await window.mnote.confirm(
!window.confirm(
alreadyShared alreadyShared
? '将「' + ? '将「' +
shareTitle + shareTitle +
@@ -3310,7 +3435,7 @@
'」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' + '」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' +
'会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' + '会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' +
'密文 [Key] 模板原样复制,并自动同步缺失密文 key。' '密文 [Key] 模板原样复制,并自动同步缺失密文 key。'
) ))
) { ) {
return; return;
} }
@@ -3343,7 +3468,7 @@
return loadList(); return loadList();
}); });
}) })
.catch(function (err) { .catch(async function (err) {
toast(err.message || String(err), 'error'); toast(err.message || String(err), 'error');
setStatus(err.message || String(err), 'error'); setStatus(err.message || String(err), 'error');
}); });
@@ -3367,8 +3492,7 @@
state.item.sharedToAi && state.item.sharedToAi &&
state.item.sharedToAi.targetActorId) || state.item.sharedToAi.targetActorId) ||
'mnote-e2e'; 'mnote-e2e';
if ( if (!(await window.mnote.confirm(
!window.confirm(
'撤销「' + '撤销「' +
unshareTitle + unshareTitle +
'」对 AI 密码本(' + '」对 AI 密码本(' +
@@ -3376,7 +3500,7 @@
')的共享?\n' + ')的共享?\n' +
'将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' + '将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' +
'原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。' '原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。'
) ))
) { ) {
return; return;
} }
@@ -3414,6 +3538,10 @@
return; return;
} }
if (t.closest('[data-vault-repair-ai-folders]')) {
repairAiFolders();
return;
}
if (t.closest('[data-vault-cipher-book]')) { if (t.closest('[data-vault-cipher-book]')) {
if (!confirmDiscard()) return; if (!confirmDiscard()) return;
state.mode = 'cipher'; state.mode = 'cipher';
@@ -3472,7 +3600,7 @@
delete state.cipherRevealed[ek]; delete state.cipherRevealed[ek];
return loadCipherBook(); return loadCipherBook();
}) })
.catch(function (err) { .catch(async function (err) {
toast(err.message || String(err), 'error'); toast(err.message || String(err), 'error');
}); });
return; return;
@@ -3480,7 +3608,7 @@
var cipherDel = t.closest('[data-vault-cipher-delete]'); var cipherDel = t.closest('[data-vault-cipher-delete]');
if (cipherDel) { if (cipherDel) {
var dk = cipherDel.getAttribute('data-vault-cipher-delete'); var dk = cipherDel.getAttribute('data-vault-cipher-delete');
if (!window.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。')) return; if (!(await window.mnote.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。'))) return;
apiJson( apiJson(
'DELETE', 'DELETE',
'/api/vault/cipher-book/' + encodeURIComponent(dk), '/api/vault/cipher-book/' + encodeURIComponent(dk),
@@ -3570,27 +3698,6 @@
return; return;
} }
var clearNestedSec = t.closest('[data-vault-clear-sec-value]');
if (clearNestedSec) {
var csid = clearNestedSec.getAttribute('data-vault-clear-sec-value');
var cAcc = clearNestedSec.getAttribute('data-acc-id');
var formClearSec = qs('[data-vault-form]', detailEl);
if (formClearSec) snapshotFormSlots(formClearSec);
(state._formAccounts || []).forEach(function (acc) {
if (cAcc && acc.id !== cAcc) return;
(acc.secrets || []).forEach(function (sec) {
if (sec.id !== csid) return;
sec.valueClear = true;
sec.valueText = '';
sec.valueState = { state: 'absent' };
});
});
state.dirty = true;
reRenderFormKeepingSlots(state.mode === 'create');
toast('密钥将在保存时清空', 'info');
return;
}
var clearBtn = t.closest('[data-vault-clear-secret]'); var clearBtn = t.closest('[data-vault-clear-secret]');
if (clearBtn) { if (clearBtn) {
var cf = clearBtn.getAttribute('data-vault-clear-secret'); var cf = clearBtn.getAttribute('data-vault-clear-secret');
@@ -3606,7 +3713,7 @@
} }
if (t.closest('[data-vault-delete]')) { if (t.closest('[data-vault-delete]')) {
if (!window.confirm('确定将条目移入密码箱回收站?')) return; if (!(await window.mnote.confirm('确定将条目移入密码箱回收站?'))) return;
apiJson( apiJson(
'DELETE', 'DELETE',
'/api/vault/items/' + encodeURIComponent(state.selectedId), '/api/vault/items/' + encodeURIComponent(state.selectedId),
@@ -3637,13 +3744,13 @@
if (tabDeleted) tabDeleted.classList.remove('is-active'); if (tabDeleted) tabDeleted.classList.remove('is-active');
return loadList(); return loadList();
}) })
.catch(function (err) { .catch(async function (err) {
toast(err.message || String(err), 'error'); toast(err.message || String(err), 'error');
}); });
return; return;
} }
if (t.closest('[data-vault-purge]')) { if (t.closest('[data-vault-purge]')) {
if (!window.confirm('永久删除后无法恢复,确定?')) return; if (!(await window.mnote.confirm('永久删除后无法恢复,确定?'))) return;
apiJson( apiJson(
'POST', 'POST',
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge',
+22
View File
@@ -41,6 +41,8 @@ pub struct AppConfig {
pub dev_user_id: String, pub dev_user_id: String,
pub dev_user_name: String, pub dev_user_name: String,
pub dev_user_email: String, pub dev_user_email: String,
/// "dev" | "prod" — 影响前端显示与 env 片段中的 base URL。
pub environment: String,
} }
impl AppConfig { impl AppConfig {
@@ -93,10 +95,29 @@ impl AppConfig {
.ok() .ok()
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL")) .or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
.unwrap_or_else(|| "dev@mnote.local".into()), .unwrap_or_else(|| "dev@mnote.local".into()),
environment: detect_environment(),
} }
} }
} }
/// 推断运行环境:显式 MNOTE_WEB_ENV > 端口匹配(3000=dev, 3003=prod> 默认 "dev"。
fn detect_environment() -> String {
if let Ok(v) = env::var("MNOTE_WEB_ENV") {
let t = v.trim().to_lowercase();
if !t.is_empty() {
return t;
}
}
let port = env::var("MNOTE_WEB_BIND")
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
.ok()
.and_then(|addr| addr.rsplit(':').next()?.parse::<u16>().ok());
match port {
Some(3003) => "prod".into(),
_ => "dev".into(),
}
}
fn env_bool(key: &str, default: bool) -> bool { fn env_bool(key: &str, default: bool) -> bool {
env::var(key) env::var(key)
.ok() .ok()
@@ -392,6 +413,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
} }
} }
+70 -2
View File
@@ -29,14 +29,79 @@ pub struct TraceContext {
pub path: String, pub path: String,
} }
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthContext { pub struct AuthContext {
/// 原始 Authorization;禁止序列化/Debug 明文以免 token 进日志或错误响应。
pub authorization: Option<String>, pub authorization: Option<String>,
pub cookie_header: Option<String>, pub cookie_header: Option<String>,
pub actor_id: String, pub actor_id: String,
pub actor_type: String, pub actor_type: String,
pub session_id: Option<String>, pub session_id: Option<String>,
/// `anonymous` | `session` | `pat` | `extension`7-76
pub auth_method: String,
/// PAT scopessession 为空表示不受 PAT 白名单限制。
pub scopes: Vec<String>,
/// PAT jti(若适用)
pub pat_jti: Option<String>,
}
impl std::fmt::Debug for AuthContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthContext")
.field(
"authorization",
&self
.authorization
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field(
"cookie_header",
&self
.cookie_header
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field("actor_id", &self.actor_id)
.field("actor_type", &self.actor_type)
.field(
"session_id",
&self
.session_id
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field("auth_method", &self.auth_method)
.field("scopes", &self.scopes)
.field(
"pat_jti",
&self
.pat_jti
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.finish()
}
}
impl Serialize for AuthContext {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
// 不序列化 sessionId / token:避免进入错误响应 / 日志 / 诊断载荷。
let mut state = serializer.serialize_struct("AuthContext", 4)?;
state.serialize_field("actorId", &self.actor_id)?;
state.serialize_field("actorType", &self.actor_type)?;
state.serialize_field("authMethod", &self.auth_method)?;
state.serialize_field("scopes", &self.scopes)?;
state.end()
}
} }
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@@ -88,6 +153,9 @@ impl RequestContext {
.or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE)) .or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE))
.unwrap_or_else(|| "anonymous".into()), .unwrap_or_else(|| "anonymous".into()),
session_id: header_value(headers, HEADER_SESSION_ID), session_id: header_value(headers, HEADER_SESSION_ID),
auth_method: "anonymous".into(),
scopes: Vec::new(),
pat_jti: None,
}, },
workspace: WorkspaceContext { workspace: WorkspaceContext {
workspace_id: header_value(headers, HEADER_WORKSPACE_ID), workspace_id: header_value(headers, HEADER_WORKSPACE_ID),
@@ -297,6 +297,26 @@ impl BufferStore {
} }
} }
/// 本地文件 restore 后清除已打开 Markdown buffer 的 deleted 标记。
pub fn clear_local_folder_markdown_deleted(
&self,
workspace_id: &str,
root_uri: &str,
relative_path: &str,
document_id: &str,
) -> Option<DocumentBuffer> {
let path =
build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id);
let key = BufferKey::from_workspace_path(&path);
let mut inner = self.inner.write().expect("BufferStore lock");
if let Some(buf) = inner.buffers.get_mut(&key) {
buf.clear_deleted();
Some(buf.clone())
} else {
None
}
}
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。 /// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
pub fn init_buffer( pub fn init_buffer(
&self, &self,
@@ -738,5 +758,15 @@ mod tests {
.expect("buffer should be marked deleted"); .expect("buffer should be marked deleted");
assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted); assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted);
let restored = store
.clear_local_folder_markdown_deleted(
"local:test",
root_uri,
"docs/Delete.md",
"local-md:docs~2FDelete.md",
)
.expect("buffer should clear deleted after restore");
assert_eq!(restored.dirty_state, DocBufferDirtyState::Clean);
} }
} }
+145 -72
View File
@@ -137,54 +137,7 @@ impl EditorRuntimeActor {
format!("文档 {document_id} 尚未加载"), format!("文档 {document_id} 尚未加载"),
) )
})?; })?;
build_block_delta_from_state(state, document_id, command)
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
let block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block_id);
vec![DeltaOperation::ReplaceBlock {
block_id: cmd.block_id.clone(),
text: block_text_from_block(block),
block_type: block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::InsertBlockAfter(cmd) => {
let new_block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block.block_id);
vec![DeltaOperation::InsertBlockAfter {
anchor_block_id: cmd.after_block_id.clone(),
block_id: cmd.block.block_id.clone(),
text: block_text_from_block(new_block),
block_type: new_block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::DeleteBlock(cmd) => {
vec![DeltaOperation::DeleteBlock {
block_id: cmd.block_id.clone(),
}]
}
EditorCommand::MoveBlock(cmd) => {
let anchor = cmd.after_block_id.clone().unwrap_or_default();
vec![DeltaOperation::MoveBlock {
block_id: cmd.block_id.clone(),
anchor_block_id: anchor,
}]
}
_ => vec![],
};
Ok(BlockDelta {
document_id: document_id.to_string(),
revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
operations,
})
} }
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -257,33 +210,64 @@ impl EditorRuntimeActor {
) )
})?; })?;
let changed_blocks = extract_changed_blocks(&state.document, &command); apply_command_locked(state, document_id, command, command_name)
}
apply_editor_command_to_document(&mut state.document, command.clone()).map_err( /// 单次写锁内完成 load_or_init + apply + legacy export + BlockDelta。
|error| WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}")), /// 避免 is_loaded / apply / legacy_content_for_save 分锁导致的并发交错。
)?; pub fn apply_command_and_export(
&self,
document_id: &str,
workspace_id: Option<&str>,
page_aggregate: &Value,
command: EditorCommand,
command_name: &str,
) -> Result<(Value, Option<Value>), WebError> {
let mut documents = self
.documents
.write()
.map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?;
state.revision += 1; if !documents.contains_key(document_id) {
state.conflict_detection_key = format!( let content = page_aggregate
"{}:{}:{}", .pointer("/body/content")
document_id, .cloned()
state.revision, .unwrap_or_else(|| json!([]));
state.last_applied_at.elapsed().as_micros() let revision = page_aggregate
); .pointer("/body/revision")
state.last_applied_at = Instant::now(); .and_then(|value| value.as_u64())
.unwrap_or(1);
let conflict_detection_key = page_aggregate
.pointer("/body/conflictDetectionKey")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let document = editor_document_from_legacy_content(document_id, &content);
documents.insert(
document_id.to_string(),
EditorDocumentState {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(ToString::to_string),
document,
revision,
conflict_detection_key,
last_applied_at: Instant::now(),
},
);
}
Ok(ApplyResult { let state = documents.get_mut(document_id).ok_or_else(|| {
ok: true, WebError::internal(format!(
command: command_name.to_string(), "EditorRuntimeActor 文档 {document_id} 初始化后仍不可用"
document_id: Some(document_id.to_string()), ))
workspace_id: state.workspace_id.clone(), })?;
new_revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(), let _apply = apply_command_locked(state, document_id, command.clone(), command_name)?;
changed_blocks, let content = legacy_content_from_editor_document(&state.document);
warnings: vec![], let delta = build_block_delta_from_state(state, document_id, &command)
blocked: false, .ok()
risk: "low".to_string(), .and_then(|bd| serde_json::to_value(bd).ok());
}) Ok((content, delta))
} }
/// 从内存态生成 legacy content(用于构建 Convex save payload)。 /// 从内存态生成 legacy content(用于构建 Convex save payload)。
@@ -325,6 +309,95 @@ impl EditorRuntimeActor {
} }
} }
fn apply_command_locked(
state: &mut EditorDocumentState,
document_id: &str,
command: EditorCommand,
command_name: &str,
) -> Result<ApplyResult, WebError> {
let changed_blocks = extract_changed_blocks(&state.document, &command);
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(|error| {
WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}"))
})?;
state.revision += 1;
state.conflict_detection_key = format!(
"{}:{}:{}",
document_id,
state.revision,
state.last_applied_at.elapsed().as_micros()
);
state.last_applied_at = Instant::now();
Ok(ApplyResult {
ok: true,
command: command_name.to_string(),
document_id: Some(document_id.to_string()),
workspace_id: state.workspace_id.clone(),
new_revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
changed_blocks,
warnings: vec![],
blocked: false,
risk: "low".to_string(),
})
}
fn build_block_delta_from_state(
state: &EditorDocumentState,
document_id: &str,
command: &EditorCommand,
) -> Result<BlockDelta, WebError> {
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
let block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block_id);
vec![DeltaOperation::ReplaceBlock {
block_id: cmd.block_id.clone(),
text: block_text_from_block(block),
block_type: block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::InsertBlockAfter(cmd) => {
let new_block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block.block_id);
vec![DeltaOperation::InsertBlockAfter {
anchor_block_id: cmd.after_block_id.clone(),
block_id: cmd.block.block_id.clone(),
text: block_text_from_block(new_block),
block_type: new_block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::DeleteBlock(cmd) => {
vec![DeltaOperation::DeleteBlock {
block_id: cmd.block_id.clone(),
}]
}
EditorCommand::MoveBlock(cmd) => {
let anchor = cmd.after_block_id.clone().unwrap_or_default();
vec![DeltaOperation::MoveBlock {
block_id: cmd.block_id.clone(),
anchor_block_id: anchor,
}]
}
_ => vec![],
};
Ok(BlockDelta {
document_id: document_id.to_string(),
revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
operations,
})
}
fn extract_changed_blocks( fn extract_changed_blocks(
document: &EditorBlockDocument, document: &EditorBlockDocument,
command: &EditorCommand, command: &EditorCommand,
@@ -1,4 +1,5 @@
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::routes::api_access_token::{bearer_mnpat1, verify_pat_token};
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token}; use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
use axum::extract::Request; use axum::extract::Request;
use axum::middleware::Next; use axum::middleware::Next;
@@ -8,23 +9,56 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons
let mut context = let mut context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers()); RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
// 12-3 E2: Authorization Bearer mnext1.* → actor (when cookie/header actor is anonymous) // 7-76Bearer mnpat1.* 优先于 cookiePAT 请求忽略 cookie 并权)。
if context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty() { if let Some(token) = bearer_mnpat1(context.auth.authorization.as_deref()) {
if let Ok(verified) = verify_pat_token(token) {
if let Some(actor) = crate::context::stable_actor_id(&verified.subject_user_id) {
context.auth.actor_id = actor;
context.auth.actor_type = if verified.claims.principal_kind == "ai_service" {
"ai_service".into()
} else {
"user".into()
};
context.auth.auth_method = "pat".into();
context.auth.scopes = verified.claims.scope.clone();
context.auth.pat_jti = Some(verified.claims.jti.clone());
context.auth.session_id = Some(format!("pat:{}", verified.claims.jti));
// 清除 cookie 头语义:后续 current_actor_id 见 auth_method=pat 短路
context.auth.cookie_header = None;
}
}
}
// 12-3 E2: Authorization Bearer mnext1.* → actor(仅当尚未 PAT / 仍 anonymous
if context.auth.auth_method != "pat"
&& (context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty())
{
if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) { if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) {
if let Ok(claims) = verify_extension_token(token) { if let Ok(claims) = verify_extension_token(token) {
context.auth.actor_id = claims.actor; if let Some(actor) = crate::context::stable_actor_id(&claims.actor) {
if context.auth.actor_type.trim().is_empty() context.auth.actor_id = actor;
|| context.auth.actor_type.trim() == "anonymous" if context.auth.actor_type.trim().is_empty()
{ || context.auth.actor_type.trim() == "anonymous"
context.auth.actor_type = "user".into(); {
} context.auth.actor_type = "user".into();
if context.auth.session_id.is_none() { }
context.auth.session_id = Some(format!("ext:{}", claims.jti)); context.auth.auth_method = "extension".into();
if context.auth.session_id.is_none() {
context.auth.session_id = Some(format!("ext:{}", claims.jti));
}
} }
} }
} }
} }
// cookie actor 已存在时标记 session 方法(真正 session 校验仍在 current_actor_id
if context.auth.auth_method == "anonymous"
&& context.auth.actor_id.trim() != "anonymous"
&& !context.auth.actor_id.trim().is_empty()
{
context.auth.auth_method = "session".into();
}
request.extensions_mut().insert(context.clone()); request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await; let mut response = next.run(request).await;
@@ -9,7 +9,10 @@ use bridge_runtime::{
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::fs; use std::fs;
use std::path::PathBuf; use std::io::Write;
use std::path::Path;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
pub async fn create_summary( pub async fn create_summary(
state: &AppState, state: &AppState,
@@ -79,7 +82,8 @@ async fn create_artifact_node(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(context) .with_context(context)
})?; })?;
ensure_local_workspace_access(context, &root_uri) // 使用 ensure 返回的规范化路径,避免权限检查与 I/O 路径不一致。
let root_path = ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?; .map_err(|error| error.with_context(context))?;
if input.dry_run.unwrap_or(false) { if input.dry_run.unwrap_or(false) {
return Ok(json!({ return Ok(json!({
@@ -93,7 +97,6 @@ async fn create_artifact_node(
"diff": [{"op": "create_artifact", "artifactType": node_type}] "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"); let artifact_dir = root_path.join(".mnote").join("artifacts");
fs::create_dir_all(&artifact_dir).map_err(|error| { fs::create_dir_all(&artifact_dir).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
@@ -105,10 +108,12 @@ async fn create_artifact_node(
) )
.with_context(context) .with_context(context)
})?; })?;
let artifact_path = artifact_dir.join(format!( let artifact_file_name =
"{}.json", format!("{}.json", sanitize_local_artifact_file_name(&artifact_document_id));
sanitize_local_artifact_file_name(&artifact_document_id) let artifact_path = artifact_dir.join(&artifact_file_name);
)); let created_at = OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
let artifact_value = json!({ let artifact_value = json!({
"schema": "mnote.local_artifact.v1", "schema": "mnote.local_artifact.v1",
"artifactType": node_type, "artifactType": node_type,
@@ -116,16 +121,14 @@ async fn create_artifact_node(
"documentId": document_id, "documentId": document_id,
"workspaceId": workspace_id, "workspaceId": workspace_id,
"content": content, "content": content,
"createdAt": context.trace.trace_id, "createdAt": created_at,
"traceId": context.trace.trace_id,
}); });
fs::write( let artifact_body = serde_json::to_string_pretty(&artifact_value).map_err(|error| {
&artifact_path, WebError::internal(format!("本地 artifact 序列化失败: {error}")).with_context(context)
serde_json::to_string_pretty(&artifact_value).map_err(|error| { })?;
WebError::internal(format!("本地 artifact 序列化失败: {error}")) // tmp + fsync + rename,降低并发写半截文件 / 丢失更新风险(对齐 vault atomic_write)。
.with_context(context) atomic_write_string(&artifact_path, &artifact_body).map_err(|error| {
})?,
)
.map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
"local_artifact_write_failed", "local_artifact_write_failed",
format!( format!(
@@ -135,6 +138,8 @@ async fn create_artifact_node(
) )
.with_context(context) .with_context(context)
})?; })?;
// 响应只返回相对路径,避免泄露服务器绝对路径。
let relative_artifact_path = format!(".mnote/artifacts/{artifact_file_name}");
return Ok(json!({ return Ok(json!({
"dryRun": false, "dryRun": false,
"commandName": "tree.node.create", "commandName": "tree.node.create",
@@ -147,7 +152,7 @@ async fn create_artifact_node(
"result": { "result": {
"ok": true, "ok": true,
"source": "local_folder", "source": "local_folder",
"artifactPath": artifact_path, "artifactPath": relative_artifact_path,
"artifactDocumentId": artifact_document_id, "artifactDocumentId": artifact_document_id,
} }
})); }));
@@ -248,21 +253,6 @@ async fn create_artifact_node(
})) }))
} }
fn parse_local_root_path(root_uri: &str) -> Result<PathBuf, WebError> {
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 { fn sanitize_local_artifact_file_name(value: &str) -> String {
value value
.chars() .chars()
@@ -275,6 +265,21 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
.to_string() .to_string()
} }
/// 本地 artifact 原子写:先写同目录临时文件,sync 后 rename 覆盖目标。
fn atomic_write_string(path: &Path, content: &str) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
{
let mut file = fs::File::create(&tmp)?;
file.write_all(content.as_bytes())?;
let _ = file.sync_all();
}
fs::rename(&tmp, path)?;
Ok(())
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
crate::mnote_agent_tools::ensure_write_authorized(context, input) crate::mnote_agent_tools::ensure_write_authorized(context, input)
} }
@@ -323,6 +323,8 @@ pub async fn block_move_after(
&anchor_block_id, &anchor_block_id,
"move_after_anchor", "move_after_anchor",
)?; )?;
// move_after 对复杂/不可编辑块走 soft-block200 + blocked),便于 agent 规划;
// 与 replace/delete 的硬失败不同,避免把「暂不支持」误报成工具调用错误。
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId"); let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
let leaf = block let leaf = block
.get("children") .get("children")
@@ -612,12 +614,21 @@ pub async fn doc_apply_block_ops(
&anchor_block_id, &anchor_block_id,
"move_after_anchor", "move_after_anchor",
)?; )?;
if block.get("parentBlockId") != anchor.get("parentBlockId") // 与单个 block_move_after 一致:仅 paragraph/heading/todo/task 可移动。
let movable_type = block
.get("type")
.and_then(Value::as_str)
.map(|block_type| {
matches!(block_type, "paragraph" | "heading" | "todo" | "task")
})
.unwrap_or(false);
if !movable_type
|| block.get("parentBlockId") != anchor.get("parentBlockId")
|| block_id == anchor_block_id || block_id == anchor_block_id
{ {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"mnote_block_unsupported", "mnote_block_unsupported",
"move_after 批量快路径第一阶段仅支持同父级普通叶子块", "move_after 批量快路径第一阶段仅支持同父级普通叶子块paragraph/heading/todo/task",
) )
.with_context(context)); .with_context(context));
} }
@@ -988,15 +999,27 @@ fn value_label(value: &Value) -> Option<String> {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
/// 将 revision 规范化为 u64 JSON;无法解析时保留原值,避免静默变成 null 绕过冲突检测。
fn revision_number_value(value: Value) -> Option<Value> { fn revision_number_value(value: Value) -> Option<Value> {
if value.is_null() {
return None;
}
if let Some(number) = value.as_u64() { if let Some(number) = value.as_u64() {
return Some(json!(number)); return Some(json!(number));
} }
value if let Some(number) = value.as_i64() {
.as_str() if number >= 0 {
.map(str::trim) return Some(json!(number as u64));
.and_then(|value| value.parse::<u64>().ok()) }
.map(|number| json!(number)) }
if let Some(raw) = value.as_str().map(str::trim).filter(|v| !v.is_empty()) {
if let Ok(number) = raw.parse::<u64>() {
return Some(json!(number));
}
// 非数值字符串:原样保留,由下游冲突检测处理,不丢 revision。
return Some(json!(raw));
}
Some(value)
} }
fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec<Value> { fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec<Value> {
@@ -1063,30 +1086,15 @@ fn compute_next_content_via_actor(
})?; })?;
let workspace_id = input.effective_workspace_id(); let workspace_id = input.effective_workspace_id();
// 确保 actor 已加载此文档 // 单次写锁内完成 load + apply + export,避免并发交错损坏内存态。
if !state.editor_actor.is_loaded(&document_id) { let (content, delta) = state.editor_actor.apply_command_and_export(
state &document_id,
.editor_actor workspace_id.as_deref(),
.load_or_init(&document_id, workspace_id.as_deref(), aggregate)?; aggregate,
} command.clone(),
command_name,
)?;
// 在内存中 apply
let _apply_result =
state
.editor_actor
.apply_command(&document_id, command.clone(), command_name)?;
// 从 actor 获取 legacy content(用于 Convex save 的 payload
let content = state.editor_actor.legacy_content_for_save(&document_id)?;
// 构建 BlockDeltaPhase B)并序列化为 JSON
let delta = state
.editor_actor
.build_block_delta(&document_id, command)
.ok()
.and_then(|bd| serde_json::to_value(bd).ok());
// Phase C:将 block.delta 推送到 broadcast 广播(SSE 事件 stream
if let Some(ref delta_json) = delta { if let Some(ref delta_json) = delta {
state.editor_actor.try_push_block_delta(delta_json); state.editor_actor.try_push_block_delta(delta_json);
} }
@@ -1238,7 +1246,17 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
})) }))
} }
/// 递归抽取 content 文本的最大深度,防止恶意/异常嵌套导致栈溢出。
const CONTENT_TO_TEXT_MAX_DEPTH: usize = 32;
fn content_to_text(value: &Value) -> String { fn content_to_text(value: &Value) -> String {
content_to_text_limited(value, CONTENT_TO_TEXT_MAX_DEPTH)
}
fn content_to_text_limited(value: &Value, depth: usize) -> String {
if depth == 0 {
return String::new();
}
if let Some(text) = value.as_str() { if let Some(text) = value.as_str() {
return text.to_string(); return text.to_string();
} }
@@ -1246,18 +1264,18 @@ fn content_to_text(value: &Value) -> String {
return text.to_string(); return text.to_string();
} }
if let Some(payload) = value.get("payload") { if let Some(payload) = value.get("payload") {
return content_to_text(payload); return content_to_text_limited(payload, depth - 1);
} }
if let Some(content_nodes) = value.get("contentNodes") { if let Some(content_nodes) = value.get("contentNodes") {
return content_to_text(content_nodes); return content_to_text_limited(content_nodes, depth - 1);
} }
if let Some(content) = value.get("content") { if let Some(content) = value.get("content") {
return content_to_text(content); return content_to_text_limited(content, depth - 1);
} }
if let Some(items) = value.as_array() { if let Some(items) = value.as_array() {
return items return items
.iter() .iter()
.map(content_to_text) .map(|item| content_to_text_limited(item, depth - 1))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(""); .join("");
} }
@@ -1271,8 +1289,11 @@ fn ensure_allowed_target(
block_id: &str, block_id: &str,
op: &str, op: &str,
) -> Result<(), WebError> { ) -> Result<(), WebError> {
let allowed = allowed_target_block_ids(input, operation); // None = 未声明 selection 限制(整页写权限路径);Some = 显式限制(含空数组 fail-closed)。
if allowed.is_empty() || allowed.contains(block_id) { let Some(allowed) = allowed_target_block_scope(input, operation) else {
return Ok(());
};
if allowed.contains(block_id) {
return Ok(()); return Ok(());
} }
Err(WebError::bad_request_code( Err(WebError::bad_request_code(
@@ -1282,9 +1303,17 @@ fn ensure_allowed_target(
.with_context(context)) .with_context(context))
} }
fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet<String> { /// 解析 `allowedTargetBlockIds` 作用域。
/// - `None`input/operation 均未提供该字段 → 不施加 selection 限制
/// - `Some(set)`:至少一处显式提供(含空数组)→ fail-closed,仅集合内 block 可写
fn allowed_target_block_scope(
input: &ToolCallInput,
operation: &Value,
) -> Option<HashSet<String>> {
let mut scope_declared = false;
let mut allowed = HashSet::new(); let mut allowed = HashSet::new();
if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") { if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") {
scope_declared = true;
for value in values { for value in values {
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) { if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
allowed.insert(id.to_string()); allowed.insert(id.to_string());
@@ -1295,13 +1324,18 @@ fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet
.get("allowedTargetBlockIds") .get("allowedTargetBlockIds")
.and_then(Value::as_array) .and_then(Value::as_array)
{ {
scope_declared = true;
for value in values { for value in values {
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) { if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
allowed.insert(id.to_string()); allowed.insert(id.to_string());
} }
} }
} }
allowed if scope_declared {
Some(allowed)
} else {
None
}
} }
fn block_to_ai_content( fn block_to_ai_content(
@@ -1550,4 +1584,21 @@ mod tests {
assert_eq!(content_to_text(&value), "agent 插件插入段"); assert_eq!(content_to_text(&value), "agent 插件插入段");
} }
#[test]
fn content_to_text_stops_at_max_depth() {
// 构造远超 CONTENT_TO_TEXT_MAX_DEPTH 的 payload 链,验证不 panic 且深度耗尽后返回空。
let mut nested = json!({"text": "deep-leaf"});
for _ in 0..(CONTENT_TO_TEXT_MAX_DEPTH + 8) {
nested = json!({ "payload": nested });
}
assert_eq!(content_to_text(&nested), "");
// 深度内仍可读到文本。
let mut shallow = json!({"text": "ok"});
for _ in 0..3 {
shallow = json!({ "payload": shallow });
}
assert_eq!(content_to_text(&shallow), "ok");
}
} }
@@ -524,20 +524,39 @@ pub(crate) fn ensure_ai_scope_resource_allowed(
let Some(scope) = input.arg_value("aiAccessScope") else { let Some(scope) = input.arg_value("aiAccessScope") else {
return Ok(()); return Ok(());
}; };
let allowed = scope // 与 resource ensure_resource_scope_allowed 对齐:
// - 未声明 allowedResourceIds → 不按资源 id 收紧
// - 声明了但非数组 / 空数组 → fail-closed
// - 声明了非空数组 → 仅白名单放行
let raw = scope
.get("allowedResourceIds") .get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids")) .or_else(|| scope.get("allowed_resource_ids"));
.and_then(Value::as_array) let Some(raw) = raw else {
.map(|values| { return Ok(());
values };
.iter() let Some(values) = raw.as_array() else {
.filter_map(Value::as_str) return Err(WebError::new(
.map(str::trim) StatusCode::FORBIDDEN,
.filter(|value| !value.is_empty()) "mnote_tool_ai_scope_invalid",
.collect::<HashSet<_>>() "aiAccessScope.allowedResourceIds 必须是字符串数组",
}) )
.unwrap_or_default(); .with_context(context));
if allowed.is_empty() || allowed.contains(document_id) { };
let allowed = values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<HashSet<_>>();
if allowed.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_empty",
"aiAccessScope.allowedResourceIds 为空,拒绝读取任何资源",
)
.with_context(context));
}
if allowed.contains(document_id) {
return Ok(()); return Ok(());
} }
Err(WebError::new( Err(WebError::new(
@@ -575,6 +594,19 @@ fn local_root_uri_for_tool(input: &ToolCallInput) -> Option<String> {
}) })
} }
/// full_content 创建场景:仅文档缺失可当空文档;鉴权/越权/网络等错误必须传播。
fn is_document_missing_for_full_content(error: &WebError) -> bool {
matches!(
error.code(),
"local_markdown_not_found"
| "page_not_found"
| "document_not_found"
| "mnote_resource_page_not_found"
| "local_file_unavailable"
| "local_file_not_file"
) || error.status() == StatusCode::NOT_FOUND
}
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> { pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
aggregate aggregate
.pointer("/body/blockDocument/blocks") .pointer("/body/blockDocument/blocks")
@@ -804,15 +836,18 @@ fn extract_block_comment(line: &str) -> (Option<String>, Option<(String, String,
(None, None) (None, None)
} }
/// 提取 `[mnote-raw-block:ID]` 标记 /// 提取 `[mnote-raw-block:ID]` 标记
/// 前缀长度为 17`[mnote-raw-block:`);`]` 必须在 marker 之后,避免 panic / 截断首字符。
fn extract_raw_block_marker(line: &str) -> Option<String> { fn extract_raw_block_marker(line: &str) -> Option<String> {
let start = line.find("[mnote-raw-block:"); const MARKER: &str = "[mnote-raw-block:";
let end = line.find(']'); let start = line.find(MARKER)?;
if let (Some(start), Some(end)) = (start, end) { let id_start = start + MARKER.len();
let id = &line[start + 18..end]; let end = line[id_start..].find(']')? + id_start;
return Some(id.to_string()); let id = line[id_start..end].trim();
if id.is_empty() {
return None;
} }
None Some(id.to_string())
} }
/// 从行前缀推断块类型 /// 从行前缀推断块类型
@@ -1140,6 +1175,9 @@ fn blocks_to_content(
"page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate), "page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate),
"text" | "plain" => blocks_to_text(blocks, include_ids), "text" | "plain" => blocks_to_text(blocks, include_ids),
"markdown" | "md" => blocks_to_markdown(blocks, include_ids), "markdown" | "md" => blocks_to_markdown(blocks, include_ids),
// 默认 format=jsoncontent 返回 blocks 的 JSON 文本,与 format 字段一致;
// 结构化数组仍在响应的 blocks 字段中。
"json" => serde_json::to_string(blocks).unwrap_or_else(|_| "[]".into()),
_ => blocks_to_markdown(blocks, include_ids), _ => blocks_to_markdown(blocks, include_ids),
} }
} }
@@ -1291,6 +1329,45 @@ mod tests {
assert!(result.unwrap_err().contains("无法匹配")); assert!(result.unwrap_err().contains("无法匹配"));
} }
#[test]
fn test_search_replace_not_found_utf8_long_needle_no_panic() {
// 多字节中文 needle 超过 60 字符时,错误消息截断不得 panic。
// 使用与原文差异极大的 needle,避免 Level3 fuzzy30% 容限)误匹配。
let needle: String = format!("UNIQUE_NEEDLE_{}", "".repeat(80));
let result = search_replace("完全不同的短文内容ABC", &needle, "x");
let err = result.expect_err("应无法匹配");
assert!(err.contains("无法匹配"), "err={err}");
assert!(err.contains("..."), "err={err}");
// 截断后总长合理(前缀 60 字 + "..." + 引号包装)
assert!(err.chars().count() < 120, "err len={}", err.chars().count());
}
#[test]
fn test_truncate_for_error_char_safe() {
assert_eq!(truncate_for_error("abc", 10), "abc");
assert_eq!(truncate_for_error("abcdefghij", 5), "abcde...");
let s: String = "".repeat(70);
let t = truncate_for_error(&s, 60);
assert!(t.ends_with("..."));
assert_eq!(t.chars().count(), 63); // 60 + "..."
}
#[test]
fn test_fuzzy_match_rejects_pattern_longer_than_text() {
let long: String = "".repeat(80);
assert!(!fuzzy_match("短文", &long, 0.3));
assert!(fuzzy_match("第一段内容差不多", "第一段内容差不", 0.3));
}
#[test]
fn test_search_replace_fuzzy_paragraph_no_unwrap_panic() {
// Level3:段落 fuzzy 命中后必须安全替换,不依赖 find().unwrap()。
let text = "引言。\n\n第一段内容差不多。\n\n结尾。";
let result = search_replace(text, "第一段内容差不", "替换段").expect("fuzzy 应命中");
assert!(result.contains("替换段"), "result={result}");
assert!(!result.contains("第一段内容差不多"), "result={result}");
}
#[test] #[test]
fn test_search_replace_full_content() { fn test_search_replace_full_content() {
// 全文替换:search 等于全文 // 全文替换:search 等于全文
@@ -1335,6 +1412,30 @@ mod tests {
assert!(md.contains("<!-- block:rsc_1:resource -->")); assert!(md.contains("<!-- block:rsc_1:resource -->"));
} }
#[test]
fn test_extract_raw_block_marker_keeps_full_id() {
assert_eq!(
extract_raw_block_marker("[mnote-raw-block:rsc_1] <!-- block:rsc_1:resource -->")
.as_deref(),
Some("rsc_1")
);
// 前缀 `]` 不得影响 marker 后的 id 截取
assert_eq!(
extract_raw_block_marker("prefix] [mnote-raw-block:abc_9] tail").as_deref(),
Some("abc_9")
);
assert_eq!(extract_raw_block_marker("no marker here"), None);
}
#[test]
fn test_blocks_to_content_json_format() {
let blocks = json!([{"blockId": "b1", "text": "x", "type": "paragraph"}]);
let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
let content = blocks_to_content("json", &blocks, true, "doc_1", &json!({}));
let parsed: Value = serde_json::from_str(&content).expect("json content");
assert_eq!(parsed[0]["blockId"], "b1");
}
#[test] #[test]
fn test_build_page_content_full_content_replaces_old_text_blocks_but_keeps_complex_blocks() { fn test_build_page_content_full_content_replaces_old_text_blocks_but_keeps_complex_blocks() {
let original_content = json!([ let original_content = json!([
@@ -1489,23 +1590,43 @@ pub async fn doc_markdown_edit(
let workspace_id = input.effective_workspace_id(); let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind(); let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri(); let root_uri = input.effective_root_uri();
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./"); let is_local_file = document_id.starts_with('/')
|| document_id.starts_with("./")
|| document_id.starts_with("file://");
let is_local_workspace = let is_local_workspace =
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some(); source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
crate::mnote_agent_tools::block::ensure_write_contract(context, input)?; crate::mnote_agent_tools::block::ensure_write_contract(context, input)?;
// 本地绝对/相对路径写:强制 rootUri + 路径守卫(防穿越)
let local_file_path = if is_local_file {
let tool_root = local_root_uri_for_tool(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"ai_scope_root_uri_required",
"本地文件写入需要授权 rootUri",
)
.with_context(context)
})?;
Some(
crate::routes::ensure_local_path_write_access(context, &tool_root, &document_id)
.map_err(|error| error.with_context(context))?,
)
} else {
None
};
// 1. 读取当前文档内容(markdown 形式) // 1. 读取当前文档内容(markdown 形式)
let (current_md, source) = if is_local_file { let (current_md, source) = if let Some(ref path) = local_file_path {
use std::fs; use std::fs;
// full_content 模式时允许文件不存在(创建新文件) // full_content 模式时允许文件不存在(创建新文件)
let has_full = input.arg_value("full_content").is_some(); let has_full = input.arg_value("full_content").is_some();
let content = match fs::read_to_string(&document_id) { let content = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
Err(_) if has_full => String::new(), // 创建模式:空内容 Err(_) if has_full => String::new(), // 创建模式:空内容
Err(error) => { Err(error) => {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"mnote_tool_bad_request", "mnote_tool_bad_request",
format!("无法读取本地文件 {document_id}: {error}"), format!("无法读取本地文件 {}: {error}", path.display()),
) )
.with_context(context)); .with_context(context));
} }
@@ -1629,23 +1750,24 @@ pub async fn doc_markdown_edit(
}; };
// 5. 写回(本地文件直接 fs::writeConvex 文档通过 block ops apply // 5. 写回(本地文件直接 fs::writeConvex 文档通过 block ops apply
let apply_result = if is_local_file { let apply_result = if let Some(ref path) = local_file_path {
if input.dry_run.unwrap_or(false) { if input.dry_run.unwrap_or(false) {
json!({"written": false, "dryRun": true, "path": document_id.clone()}) json!({"written": false, "dryRun": true, "path": path.display().to_string()})
} else { } else {
use std::fs; use std::fs;
fs::write(&document_id, &md).map_err(|error| { fs::write(path, &md).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
"mnote_tool_bad_request", "mnote_tool_bad_request",
format!("无法写入本地文件 {document_id}: {error}"), format!("无法写入本地文件 {}: {error}", path.display()),
) )
.with_context(context) .with_context(context)
})?; })?;
json!({"written": true, "path": document_id.clone()}) json!({"written": true, "path": path.display().to_string()})
} }
} else { } else {
// 7-27: 在线写回以最终 markdown 为真源,直接生成 block content // 7-27: 在线写回以最终 markdown 为真源,直接生成 block content
// 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径 // 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径
// full_content 仅对「文档不存在」类错误放行为空文档;鉴权/网络等错误 fail-closed
let (aggregate, blocks, original_content) = let (aggregate, blocks, original_content) =
match aggregate_value(state, context, input).await { match aggregate_value(state, context, input).await {
Ok(agg) => { Ok(agg) => {
@@ -1653,8 +1775,10 @@ pub async fn doc_markdown_edit(
let original_content = crate::mnote_agent_tools::block::current_body_content(&agg); let original_content = crate::mnote_agent_tools::block::current_body_content(&agg);
(agg, blocks, original_content) (agg, blocks, original_content)
} }
Err(_) if use_full_content.is_some() => { Err(error)
// 空文档 + full_content:跳过读取 if use_full_content.is_some()
&& is_document_missing_for_full_content(&error) =>
{
(Value::Null, vec![], json!([])) (Value::Null, vec![], json!([]))
} }
Err(e) => return Err(e), Err(e) => return Err(e),
@@ -1817,20 +1941,27 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
// Level 3: 按段落 fuzzy30% 字符差异容限) // Level 3: 按段落 fuzzy30% 字符差异容限)
for para in text.split("\n\n") { for para in text.split("\n\n") {
if fuzzy_match(para, search, 0.3) { if fuzzy_match(para, search, 0.3) {
let idx = text.find(para).unwrap(); // para 来自 split,正常必是 text 子串;不用 unwrap,避免极端 Unicode 规范化下 panic。
let Some(idx) = text.find(para) else {
continue;
};
let replaced = format!("{}{}{}", &text[..idx], replace, &text[idx + para.len()..]); let replaced = format!("{}{}{}", &text[..idx], replace, &text[idx + para.len()..]);
return Ok(replaced); return Ok(replaced);
} }
} }
// Level 4: 失败 // Level 4: 失败(按字符截断,避免 UTF-8 多字节边界 panic
Err(format!( Err(format!("无法匹配 \"{}\"", truncate_for_error(search, 60)))
"无法匹配 \"{}\"", }
if search.len() > 60 {
format!("{}...", &search[..60]) /// 按 Unicode 标量截断用于错误消息;不在 UTF-8 字节中间切开。
} else { fn truncate_for_error(s: &str, max_chars: usize) -> String {
search.to_string() let mut it = s.chars();
} let head: String = it.by_ref().take(max_chars).collect();
)) if it.next().is_some() {
format!("{head}...")
} else {
head
}
} }
fn search_replace_exact_or_normalized(text: &str, search: &str, replace: &str) -> Option<String> { fn search_replace_exact_or_normalized(text: &str, search: &str, replace: &str) -> Option<String> {
@@ -1896,9 +2027,17 @@ fn normalize_search_char(ch: char) -> Option<char> {
fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool { fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool {
let text_chars: Vec<char> = text.chars().collect(); let text_chars: Vec<char> = text.chars().collect();
let pat_chars: Vec<char> = pattern.chars().collect(); let pat_chars: Vec<char> = pattern.chars().collect();
if pat_chars.is_empty() {
return false;
}
// pattern 比 text 长时无法在 text 上形成等长窗口;旧逻辑用 min 缩短窗口,
// 会把 max_dist 相对长 needle 放大到「几乎任意短段落都命中」。
if pat_chars.len() > text_chars.len() {
return false;
}
let max_dist = (pat_chars.len() as f64 * max_diff_ratio).ceil() as usize; let max_dist = (pat_chars.len() as f64 * max_diff_ratio).ceil() as usize;
// 简单的滑动窗口匹配 // 等长滑动窗口:只比较完整 pattern 窗口
for window in text_chars.windows(pat_chars.len().min(text_chars.len())) { for window in text_chars.windows(pat_chars.len()) {
let dist = window let dist = window
.iter() .iter()
.zip(pat_chars.iter()) .zip(pat_chars.iter())
@@ -62,6 +62,8 @@ pub async fn index_refresh(
context: &RequestContext, context: &RequestContext,
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
// 索引刷新会写本地索引文件,只读 scope / ai_can_write=false 必须拒绝。
ensure_write_authorized(context, input)?;
let workspace_id = effective_workspace_id(context, input); let workspace_id = effective_workspace_id(context, input);
let root_uri = required_root_uri( let root_uri = required_root_uri(
context, context,
@@ -70,7 +72,7 @@ pub async fn index_refresh(
"本地索引刷新缺少 rootUri", "本地索引刷新缺少 rootUri",
)?; )?;
let root_path = let root_path =
routes::ensure_local_workspace_read_access_with_state(state, context, &root_uri) routes::ensure_local_workspace_write_access_with_state(state, context, &root_uri)
.map_err(|error| error.with_context(context))?; .map_err(|error| error.with_context(context))?;
let effective_settings = routes::effective_local_index_settings_for_root( let effective_settings = routes::effective_local_index_settings_for_root(
state.control_plane(), state.control_plane(),
@@ -14,18 +14,29 @@ pub async fn status(
context: &RequestContext, context: &RequestContext,
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({})); // status 与 inject_identity 一致:context / 顶层绑定优先,不采用 args 自报身份。
let body = KnowledgeRagStatusQuery { let body = KnowledgeRagStatusQuery {
workspace_id: input.effective_workspace_id().or_else(|| { workspace_id: context
args.get("workspaceId") .workspace
.and_then(Value::as_str) .workspace_id
.map(ToOwned::to_owned) .as_deref()
}), .map(str::trim)
root_uri: input.effective_root_uri().or_else(|| { .filter(|value| !value.is_empty())
args.get("rootUri") .map(ToOwned::to_owned)
.and_then(Value::as_str) .or_else(|| {
.map(ToOwned::to_owned) input
}), .workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}),
root_uri: input
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
}; };
let Json(payload) = crate::routes::knowledge_rag::status( let Json(payload) = crate::routes::knowledge_rag::status(
State(state.clone()), State(state.clone()),
@@ -42,12 +53,12 @@ pub async fn search(
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({})); let mut args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?; ensure_knowledge_tool_scope(&args, input, context)?;
inject_identity_args(&mut args, input); inject_identity_args(&mut args, input, context);
let body = serde_json::from_value::<KnowledgeRagSearchRequest>(args).map_err(|error| { let body = serde_json::from_value::<KnowledgeRagSearchRequest>(args).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
"mnote_weknora_search_payload_invalid", "mnote_knowledge_search_payload_invalid",
format!("WeKnora 检索参数无效: {error}"), format!("知识库检索参数无效: {error}"),
) )
.with_context(context) .with_context(context)
})?; })?;
@@ -66,7 +77,7 @@ pub async fn query(
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({})); let mut args = input.args.clone().unwrap_or_else(|| json!({}));
inject_identity_args(&mut args, input); inject_identity_args(&mut args, input, context);
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| { let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid", "mnote_knowledge_rag_query_payload_invalid",
@@ -89,8 +100,8 @@ pub async fn open_reference(
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({})); let mut args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?; ensure_knowledge_tool_scope(&args, input, context)?;
inject_identity_args(&mut args, input); inject_identity_args(&mut args, input, context);
let body = let body =
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| { serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
@@ -114,7 +125,7 @@ pub async fn section_context(
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({})); let mut args = input.args.clone().unwrap_or_else(|| json!({}));
inject_identity_args(&mut args, input); inject_identity_args(&mut args, input, context);
let body = let body =
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| { serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
@@ -138,16 +149,16 @@ pub async fn list_sources(
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({})); let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?; ensure_knowledge_tool_scope(&args, input, context)?;
let payload = status(state, context, input).await?; let payload = status(state, context, input).await?;
Ok(json!({ Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)), "ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.weknora.sources_result.v1", "schema": "mnote.knowledge.sources_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")), "provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"providerConfig": payload.get("providerConfig").cloned().unwrap_or(Value::Null), "providerConfig": payload.get("providerConfig").cloned().unwrap_or(Value::Null),
"registry": payload.get("registry").cloned().unwrap_or(Value::Null), "registry": payload.get("registry").cloned().unwrap_or(Value::Null),
"documents": payload.get("documents").cloned().unwrap_or(Value::Null), "documents": payload.get("documents").cloned().unwrap_or(Value::Null),
"locatorPolicy": "WeKnora provider ids are returned separately; filenames and chunk ids are not local paths.", "locatorPolicy": "Provider ids are returned separately; filenames and chunk ids are not local paths.",
})) }))
} }
@@ -157,7 +168,7 @@ pub async fn get_source_status(
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({})); let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?; ensure_knowledge_tool_scope(&args, input, context)?;
let payload = list_sources(state, context, input).await?; let payload = list_sources(state, context, input).await?;
let requested_source = args let requested_source = args
.get("sourcePath") .get("sourcePath")
@@ -186,28 +197,50 @@ pub async fn get_source_status(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(json!({ Ok(json!({
"ok": true, "ok": true,
"schema": "mnote.weknora.source_status_result.v1", "schema": "mnote.knowledge.source_status_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")), "provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"source": if matched.len() == 1 { matched[0].clone() } else { Value::Null }, "source": if matched.len() == 1 { matched[0].clone() } else { Value::Null },
"sources": matched, "sources": matched,
"requestedSource": requested_source, "requestedSource": requested_source,
})) }))
} }
fn inject_identity_args(args: &mut Value, input: &ToolCallInput) { /// 身份字段 fail-closed 注入:
if args.get("workspaceId").is_none() { /// 1) `RequestContext` 头/会话 workspace
if let Some(workspace_id) = input.effective_workspace_id() { /// 2) `ToolCallInput` **顶层** workspaceId/rootUri(宿主绑定)
args["workspaceId"] = json!(workspace_id); /// 绝不采用 `args` 里的用户自报身份(防跨 workspace 劫持)。
} fn inject_identity_args(args: &mut Value, input: &ToolCallInput, context: &RequestContext) {
let workspace_id = context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
input
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
});
if let Some(workspace_id) = workspace_id {
args["workspaceId"] = json!(workspace_id);
} }
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() { // rootUri 不在 RequestContext;仅信任 tool 顶层绑定,不读 args。
args["rootUri"] = json!(root_uri); if let Some(root_uri) = input
} .root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args["rootUri"] = json!(root_uri);
} }
} }
fn ensure_weknora_scope( fn ensure_knowledge_tool_scope(
args: &Value, args: &Value,
input: &ToolCallInput, input: &ToolCallInput,
context: &RequestContext, context: &RequestContext,
@@ -229,8 +262,8 @@ fn ensure_weknora_scope(
return Ok(()); return Ok(());
} }
Err(WebError::bad_request_code( Err(WebError::bad_request_code(
"mnote_weknora_scope_required", "mnote_knowledge_scope_required",
"WeKnora tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一", "knowledge tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
) )
.with_context(context)) .with_context(context))
} }
@@ -297,7 +330,7 @@ pub(crate) fn compact_query_result_for_agent(payload: Value) -> Value {
json!({ json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)), "ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1", "schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")), "provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.", "answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"references": references, "references": references,
"referenceCount": payload_references.len(), "referenceCount": payload_references.len(),
@@ -643,7 +676,7 @@ pub(crate) fn compact_section_context_for_agent(payload: Value) -> Value {
json!({ json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)), "ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")), "schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")), "provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null), "sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null), "sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null), "lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
@@ -669,7 +702,7 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
.unwrap_or_else(|| quote_diagnostics(&quote)); .unwrap_or_else(|| quote_diagnostics(&quote));
json!({ json!({
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")), "schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("weknora")), "provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null), "citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null), "citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null), "filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
@@ -693,7 +726,7 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
.unwrap_or_default(); .unwrap_or_default();
json!({ json!({
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")), "schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("weknora")), "provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null), "citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null), "citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null), "sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
@@ -1010,4 +1043,79 @@ mod tests {
< 60_000 < 60_000
); );
} }
#[test]
fn inject_identity_args_prefers_request_context_over_user_args() {
use crate::context::{
AuthContext, RequestContext, SourceContext, TraceContext, WorkspaceContext,
};
use crate::mnote_agent_tools::ToolCallInput;
let context = RequestContext {
trace: TraceContext {
request_id: "req".into(),
trace_id: "tr".into(),
method: "POST".into(),
path: "/api".into(),
},
auth: AuthContext {
authorization: None,
cookie_header: None,
actor_id: "user_1".into(),
actor_type: "user".into(),
session_id: None,
auth_method: "session".into(),
scopes: Vec::new(),
pat_jti: None,
},
workspace: WorkspaceContext {
workspace_id: Some("ws-from-header".into()),
tenant_id: None,
deployment_id: None,
project_id: None,
},
source: SourceContext {
channel: "http".into(),
client: "test".into(),
idempotency_key: None,
},
};
let input = ToolCallInput {
tool_name: "mnote.knowledge_rag.query".into(),
workspace_id: Some("ws-from-top".into()),
document_id: None,
source_kind: None,
root_uri: Some("file:///bound-root".into()),
actor_id: None,
profile: None,
session_id: None,
run_id: None,
tool_call_id: None,
trace_id: None,
idempotency_key: None,
dry_run: None,
capability_scope: None,
args: Some(json!({
"workspaceId": "ws-attacker",
"rootUri": "file:///etc",
"query": "x",
})),
};
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
super::inject_identity_args(&mut args, &input, &context);
assert_eq!(args["workspaceId"].as_str(), Some("ws-from-header"));
assert_eq!(args["rootUri"].as_str(), Some("file:///bound-root"));
// 无 header 时用顶层,仍忽略 args
let context_no_ws = RequestContext {
workspace: WorkspaceContext {
workspace_id: None,
..context.workspace.clone()
},
..context.clone()
};
let mut args2 = input.args.clone().unwrap_or_else(|| json!({}));
super::inject_identity_args(&mut args2, &input, &context_no_ws);
assert_eq!(args2["workspaceId"].as_str(), Some("ws-from-top"));
assert_eq!(args2["rootUri"].as_str(), Some("file:///bound-root"));
}
} }
@@ -355,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
} }
json!({ json!({
"name": "mnote.knowledge_rag.status", "name": "mnote.knowledge_rag.status",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGWeKnora/RAGFlow 仅作为 env 显式切换的备用 / 调试路径。", "description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGKnowledge RAG/RAGFlow 仅作为 env 显式切换的备用 / 调试路径。",
"schemaVersion": TOOL_SCHEMA_VERSION, "schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"], "capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available", "status": "available",
@@ -405,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
} }
json!({ json!({
"name": "mnote.knowledge_rag.query", "name": "mnote.knowledge_rag.query",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGWeKnora/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。", "description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGKnowledge RAG/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION, "schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"], "capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available", "status": "available",
@@ -2096,13 +2096,16 @@ fn onlyoffice_tool(
{ "required": ["bridgeSessionId"] } { "required": ["bridgeSessionId"] }
]); ]);
} }
// 清空幻灯片 / 删除页等会不可逆丢内容,必须标 destructive,避免 AI 当普通写操作。
let destructive =
!readonly && (name.contains("delete") || name.contains("clear_slide"));
json!({ json!({
"name": name, "name": name,
"description": description, "description": description,
"schemaVersion": TOOL_SCHEMA_VERSION, "schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": capability_scope.into_iter().collect::<Vec<_>>(), "capabilityScope": capability_scope.into_iter().collect::<Vec<_>>(),
"status": "available", "status": "available",
"annotations": tool_annotations(readonly, false, readonly, false), "annotations": tool_annotations(readonly, destructive, readonly, destructive),
"inputSchema": input_schema "inputSchema": input_schema
}) })
} }
@@ -141,15 +141,17 @@ impl ToolCallInput {
} }
pub fn ai_access_scope_is_read_only(&self) -> bool { pub fn ai_access_scope_is_read_only(&self) -> bool {
match self.ai_access_permission_level() {
Some(level) => is_read_only_permission_level(&level),
// 缺 scope / permissionLevel 时不再默认“可写”;由 ensure_write_authorized 统一 fail-closed。
None => false,
}
}
/// 是否显式声明了可写 permissionLevel(缺省不算可写)。
pub fn ai_access_scope_explicitly_allows_write(&self) -> bool {
self.ai_access_permission_level() self.ai_access_permission_level()
.map(|level| { .map(|level| !is_read_only_permission_level(&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) .unwrap_or(false)
} }
@@ -164,12 +166,13 @@ impl ToolCallInput {
.or_else(|| ctx.get("workspace_readonly")) .or_else(|| ctx.get("workspace_readonly"))
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(false); .unwrap_or(false);
// 与 core-protocol CommandContext 一致:未显式声明时默认不可写(fail-closed
let ai_can_write = ctx let ai_can_write = ctx
.get("ai.canWrite") .get("ai.canWrite")
.or_else(|| ctx.get("aiCanWrite")) .or_else(|| ctx.get("aiCanWrite"))
.or_else(|| ctx.get("ai_can_write")) .or_else(|| ctx.get("ai_can_write"))
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(true); .unwrap_or(false);
Some(CommandContextBridge { Some(CommandContextBridge {
workspace_readonly, workspace_readonly,
ai_can_write, ai_can_write,
@@ -191,13 +194,23 @@ pub struct CommandContextBridge {
pub ai_can_write: bool, pub ai_can_write: bool,
} }
fn is_read_only_permission_level(level: &str) -> bool {
let normalized = level.trim().to_ascii_lowercase();
normalized == "read"
|| normalized == "readonly"
|| normalized == "read_only"
|| normalized == "shared_read"
|| (normalized.contains("read") && !normalized.contains("write"))
}
/// 统一的 agent tools 写入守卫。检查: /// 统一的 agent tools 写入守卫。检查:
/// ///
/// - `idempotencyKey` 必须存在 /// - `idempotencyKey` 必须存在
/// - `dryRun` 必须显式携带 /// - `dryRun` 必须显式携带
/// - `aiAccessScope.permissionLevel` 不是只读(来自 ToolCallInput /// - 若声明了只读 `aiAccessScope.permissionLevel`,拒绝
/// - 如果提供了 `bridge` `ai_can_write == false`拒绝 /// - 提供了 `commandContext``workspace.readonly` / `ai.canWrite` 任一拒绝则拒绝
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝 /// - **fail-closed**:必须至少有一方显式授予写权限
/// `aiAccessScope` 可写 permissionLevel,或 `commandContext.ai.canWrite=true`
/// ///
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。 /// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
pub fn ensure_write_authorized( pub fn ensure_write_authorized(
@@ -218,7 +231,12 @@ pub fn ensure_write_authorized(
) )
.with_context(context)); .with_context(context));
} }
if input.ai_access_scope_is_read_only() { // 显式只读 scope 优先拒绝(含 shared_read
if input
.ai_access_permission_level()
.as_deref()
.is_some_and(is_read_only_permission_level)
{
return Err(WebError::new( return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN, axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden", "mnote_tool_ai_scope_write_forbidden",
@@ -226,7 +244,9 @@ pub fn ensure_write_authorized(
) )
.with_context(context)); .with_context(context));
} }
if let Some(bridge) = input.command_context_bridge() {
let bridge = input.command_context_bridge();
if let Some(bridge) = bridge {
if bridge.workspace_readonly { if bridge.workspace_readonly {
return Err(WebError::new( return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN, axum::http::StatusCode::FORBIDDEN,
@@ -244,6 +264,20 @@ pub fn ensure_write_authorized(
.with_context(context)); .with_context(context));
} }
} }
let scope_allows_write = input.ai_access_scope_explicitly_allows_write();
let bridge_allows_write = bridge
.map(|b| b.ai_can_write && !b.workspace_readonly)
.unwrap_or(false);
// 缺 aiAccessScope 且缺 commandContext(或两者都未授予写)→ fail-closed
if !scope_allows_write && !bridge_allows_write {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_write_authorization_required",
"写入型 mnote agent tool 必须携带可写 aiAccessScope.permissionLevel 或 commandContext.ai.canWrite=true",
)
.with_context(context));
}
Ok(()) Ok(())
} }
@@ -298,6 +332,23 @@ mod tests {
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden"); assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
} }
#[test]
fn ensure_write_authorized_rejects_missing_ai_can_write_default_false() {
// commandContext 存在但未声明 ai.canWrite → fail-closed 拒绝写
let error = ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"workspace.readonly": false
}
})),
)
.expect_err("missing ai.canWrite should default false and reject");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
}
#[test] #[test]
fn ensure_write_authorized_rejects_command_context_readonly_workspace() { fn ensure_write_authorized_rejects_command_context_readonly_workspace() {
let error = ensure_write_authorized( let error = ensure_write_authorized(
@@ -314,4 +365,39 @@ mod tests {
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN); assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_workspace_readonly"); assert_eq!(error.code(), "mnote_tool_workspace_readonly");
} }
#[test]
fn ensure_write_authorized_rejects_missing_command_context_and_scope() {
// 无 aiAccessScope、无 commandContext → fail-closed,禁止写
let error = ensure_write_authorized(&context(), &write_input(json!({})))
.expect_err("missing write authorization should reject");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_write_authorization_required");
}
#[test]
fn ensure_write_authorized_accepts_explicit_write_scope_without_command_context() {
ensure_write_authorized(
&context(),
&write_input(json!({
"aiAccessScope": { "permissionLevel": "read_write" }
})),
)
.expect("explicit write scope should authorize without commandContext");
}
#[test]
fn ensure_write_authorized_accepts_command_context_write_without_scope() {
ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": false
}
})),
)
.expect("commandContext ai.canWrite=true should authorize without aiAccessScope");
}
} }
@@ -311,6 +311,9 @@ pub async fn document_insert_html(
input: &ToolCallInput, input: &ToolCallInput,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let html = required_string(context, input, "html")?; let html = required_string(context, input, "html")?;
// AI / bridge 传入的 HTML 可能含 script / on* / javascript: 等载荷;
// OnlyOffice 虽不完全执行脚本,仍会落盘或外链,必须先净化。
let html = sanitize_insert_html(&html);
run_write_action( run_write_action(
context, context,
input, input,
@@ -320,6 +323,234 @@ pub async fn document_insert_html(
.await .await
} }
/// 轻量 HTML 净化:剥危险标签与事件处理器,保留基础排版标签。
/// 不引入 ammonia / regex 依赖;面向 OnlyOffice insert_html 的最小防御。
fn sanitize_insert_html(html: &str) -> String {
let without_blocks = strip_dangerous_html_blocks(html);
strip_dangerous_html_attrs(&without_blocks)
}
const DANGEROUS_BLOCK_TAGS: &[&str] = &[
"script", "style", "iframe", "object", "embed", "link", "meta", "base", "form",
];
fn strip_dangerous_html_blocks(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let bytes = html.as_bytes();
let lower_bytes = lower.as_bytes();
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'<' {
// 拷贝到下一个 '<' 或末尾
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'<' {
j += 1;
}
out.push_str(&html[i..j]);
i = j;
continue;
}
// 解析标签名
let after_lt = i + 1;
let is_close = after_lt < bytes.len() && bytes[after_lt] == b'/';
let name_start = if is_close { after_lt + 1 } else { after_lt };
// 跳过空白
let mut name_pos = name_start;
while name_pos < lower_bytes.len() && lower_bytes[name_pos].is_ascii_whitespace() {
name_pos += 1;
}
let mut name_end = name_pos;
while name_end < lower_bytes.len()
&& (lower_bytes[name_end].is_ascii_alphanumeric() || lower_bytes[name_end] == b'-' || lower_bytes[name_end] == b':')
{
name_end += 1;
}
let tag = std::str::from_utf8(&lower_bytes[name_pos..name_end]).unwrap_or("");
let is_dangerous = DANGEROUS_BLOCK_TAGS.iter().any(|t| *t == tag);
// 找到本标签结束 '>'(粗处理引号内 > 较少见,足够防御)
let mut tag_end = name_end;
while tag_end < bytes.len() && bytes[tag_end] != b'>' {
tag_end += 1;
}
if tag_end >= bytes.len() {
// 残缺标签:丢弃剩余
break;
}
let self_close = tag_end > i && bytes[tag_end - 1] == b'/';
if !is_dangerous {
out.push_str(&html[i..=tag_end]);
i = tag_end + 1;
continue;
}
// 危险开标签:跳过到匹配闭合(或自闭合)
i = tag_end + 1;
if is_close || self_close {
continue;
}
// 找 </tag ...>
let close_pat = format!("</{tag}");
let close_bytes = close_pat.as_bytes();
while i < lower_bytes.len() {
if lower_bytes[i..].starts_with(close_bytes) {
// 前进到 '>'
let mut k = i + close_bytes.len();
while k < bytes.len() && bytes[k] != b'>' {
k += 1;
}
i = if k < bytes.len() { k + 1 } else { bytes.len() };
break;
}
i += 1;
}
}
out
}
fn strip_dangerous_html_attrs(html: &str) -> String {
let bytes = html.as_bytes();
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'<' {
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'<' {
j += 1;
}
out.push_str(&html[i..j]);
i = j;
continue;
}
// 标签起点
out.push('<');
i += 1;
// 保留 / 与 tag name + 空白分隔
while i < bytes.len() && bytes[i] != b'>' {
// 尝试读一个属性
// 跳过空白
if bytes[i].is_ascii_whitespace() {
// 窥探下一个属性名
let mut k = i;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
k += 1;
}
if k >= bytes.len() || bytes[k] == b'>' || bytes[k] == b'/' {
// 空白直接写入到 > 或 /
out.push(bytes[i] as char);
i += 1;
continue;
}
// 读属性名
let name_start = k;
let mut name_end = k;
while name_end < bytes.len()
&& (bytes[name_end].is_ascii_alphanumeric()
|| bytes[name_end] == b'-'
|| bytes[name_end] == b':'
|| bytes[name_end] == b'_')
{
name_end += 1;
}
if name_end == name_start {
out.push(bytes[i] as char);
i += 1;
continue;
}
let attr_name = html[name_start..name_end].to_ascii_lowercase();
// 找 = 与值
let mut p = name_end;
while p < bytes.len() && bytes[p].is_ascii_whitespace() {
p += 1;
}
let mut value = String::new();
let value_end;
if p < bytes.len() && bytes[p] == b'=' {
p += 1;
while p < bytes.len() && bytes[p].is_ascii_whitespace() {
p += 1;
}
if p < bytes.len() && (bytes[p] == b'"' || bytes[p] == b'\'') {
let quote = bytes[p];
p += 1;
let vstart = p;
while p < bytes.len() && bytes[p] != quote {
p += 1;
}
value = html[vstart..p].to_string();
if p < bytes.len() {
p += 1; // closing quote
}
value_end = p;
} else {
let vstart = p;
while p < bytes.len()
&& !bytes[p].is_ascii_whitespace()
&& bytes[p] != b'>'
&& bytes[p] != b'/'
{
p += 1;
}
value = html[vstart..p].to_string();
value_end = p;
}
} else {
value_end = name_end;
}
let drop_attr = should_drop_html_attr(&attr_name, &value);
if drop_attr {
i = value_end;
continue;
}
// 保留:从原空白起点到 value_end
out.push_str(&html[i..value_end]);
i = value_end;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
if i < bytes.len() && bytes[i] == b'>' {
out.push('>');
i += 1;
}
}
out
}
fn should_drop_html_attr(name: &str, value: &str) -> bool {
if name.starts_with("on") && name.len() > 2 {
return true;
}
let url_attrs = [
"href",
"src",
"xlink:href",
"action",
"formaction",
"poster",
"data",
"background",
"dynsrc",
"lowsrc",
];
if !url_attrs.iter().any(|a| *a == name) {
return false;
}
// 折叠空白后判断危险 scheme,防 `java script:` 类绕过的弱变体
let compact: String = value
.chars()
.filter(|c| !c.is_whitespace() && *c != '\0')
.collect::<String>()
.to_ascii_lowercase();
compact.starts_with("javascript:")
|| compact.starts_with("vbscript:")
|| compact.starts_with("data:text/html")
}
pub async fn sheet_set_value( pub async fn sheet_set_value(
context: &RequestContext, context: &RequestContext,
input: &ToolCallInput, input: &ToolCallInput,
@@ -1019,7 +1250,16 @@ fn arg_f64(input: &ToolCallInput, key: &'static str) -> Option<f64> {
_ => None, _ => None,
}) })
.and_then(|value| value.as_f64()) .and_then(|value| value.as_f64())
.filter(|value| value.is_finite() && *value >= 0.0) .filter(|value| {
if !value.is_finite() {
return false;
}
// 位置/偏移可负(向左/上);尺寸/字号/线宽必须非负。
match key {
"xOffsetMm" | "yOffsetMm" | "xMm" | "yMm" => true,
_ => *value >= 0.0,
}
})
} }
fn arg_bool(input: &ToolCallInput, key: &'static str) -> Option<bool> { fn arg_bool(input: &ToolCallInput, key: &'static str) -> Option<bool> {
@@ -1128,3 +1368,81 @@ fn bridge_run_error(context: &RequestContext, error: BridgeRunError) -> WebError
} }
.with_context(context) .with_context(context)
} }
#[cfg(test)]
mod tests {
use super::{arg_f64, sanitize_insert_html, should_drop_html_attr};
use crate::mnote_agent_tools::ToolCallInput;
use serde_json::json;
fn input_with_args(args: serde_json::Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.onlyoffice.test".into(),
workspace_id: None,
document_id: None,
source_kind: None,
root_uri: None,
actor_id: None,
profile: None,
session_id: None,
run_id: None,
tool_call_id: None,
trace_id: None,
idempotency_key: None,
dry_run: None,
capability_scope: None,
args: Some(args),
}
}
#[test]
fn arg_f64_allows_negative_offsets_but_rejects_negative_sizes() {
let offsets = input_with_args(json!({
"xOffsetMm": -12.5,
"yMm": -3.0,
"widthMm": -1.0,
"fontSize": -8.0,
"strokeWidthMm": 1.5
}));
assert_eq!(arg_f64(&offsets, "xOffsetMm"), Some(-12.5));
assert_eq!(arg_f64(&offsets, "yMm"), Some(-3.0));
assert_eq!(arg_f64(&offsets, "widthMm"), None);
assert_eq!(arg_f64(&offsets, "fontSize"), None);
assert_eq!(arg_f64(&offsets, "strokeWidthMm"), Some(1.5));
}
#[test]
fn sanitize_strips_script_and_event_handlers() {
let dirty = r#"<p onclick="alert(1)">hi</p><script>evil()</script><b>ok</b>"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("<script"));
assert!(!clean.to_ascii_lowercase().contains("onclick"));
assert!(clean.contains("<b>ok</b>") || clean.contains("ok"));
assert!(clean.contains("hi"));
}
#[test]
fn sanitize_strips_javascript_href() {
let dirty = r#"<a href="javascript:alert(1)">x</a><a href="https://ok.example">y</a>"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("javascript:"));
assert!(clean.contains("https://ok.example"));
}
#[test]
fn sanitize_strips_iframe_and_data_html() {
let dirty = r#"<iframe src="https://evil"></iframe><img src="data:text/html;base64,xx">"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("<iframe"));
assert!(!clean.to_ascii_lowercase().contains("data:text/html"));
}
#[test]
fn should_drop_on_and_js_schemes() {
assert!(should_drop_html_attr("onclick", "x"));
assert!(should_drop_html_attr("href", "javascript:alert(1)"));
assert!(should_drop_html_attr("href", " java\tscript:alert(1)"));
assert!(!should_drop_html_attr("href", "https://example.com"));
assert!(!should_drop_html_attr("class", "foo"));
}
}
@@ -360,13 +360,17 @@ async fn resolve_page_save_content(
.with_context(context) .with_context(context)
})?; })?;
let workspace_id = input.effective_workspace_id(); let workspace_id = input.effective_workspace_id();
// 与 page_get 一致:local_folder 等非默认源必须带上 source_kind/root_uri
// 否则 append/prepend 会读到错误 workspace 的当前正文。
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let aggregate = build_page_aggregate_snapshot( let aggregate = build_page_aggregate_snapshot(
state, state,
context, context,
&document_id, &document_id,
workspace_id.as_deref(), workspace_id.as_deref(),
None, source_kind.as_deref(),
None, root_uri.as_deref(),
) )
.await?; .await?;
let aggregate_value = serde_json::to_value(&aggregate) let aggregate_value = serde_json::to_value(&aggregate)
@@ -394,6 +394,15 @@ impl ResourceToolTarget {
})?; })?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri) let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?; .map_err(|error| error.with_context(context))?;
// 与 resolve_write_path 一致:canonicalize 根,并逐段解析已存在路径上的符号链接,
// 防止 `subdir -> /etc` 后再 create `subdir/x` 逃出授权目录。
let root = root.canonicalize().map_err(|error| {
WebError::bad_request_code(
"mnote_resource_unavailable",
format!("无法访问授权根目录: {error}"),
)
.with_context(context)
})?;
let relative = self.relative_path(); let relative = self.relative_path();
let relative_path = Path::new(&relative); let relative_path = Path::new(&relative);
if relative_path.is_absolute() if relative_path.is_absolute()
@@ -407,15 +416,41 @@ impl ResourceToolTarget {
) )
.with_context(context)); .with_context(context));
} }
let path = root.join(relative_path); let mut cursor = root.clone();
if !path.starts_with(&root) { let components: Vec<_> = relative_path.components().collect();
for (index, component) in components.iter().enumerate() {
cursor = cursor.join(component);
if !cursor.exists() {
// 从本段起路径尚未落盘:直接拼完剩余段(无 symlink 可解析),仍须 starts_with(root)。
for rest in components.iter().skip(index + 1) {
cursor = cursor.join(rest);
}
break;
}
let canonical = cursor.canonicalize().map_err(|error| {
WebError::bad_request_code(
"mnote_resource_unavailable",
format!("无法解析资源路径: {error}"),
)
.with_context(context)
})?;
if !canonical.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
cursor = canonical;
}
if !cursor.starts_with(&root) {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"mnote_resource_root_escape", "mnote_resource_root_escape",
"资源工具不能越过授权目录", "资源工具不能越过授权目录",
) )
.with_context(context)); .with_context(context));
} }
Ok(path) Ok(cursor)
} }
fn relative_path(&self) -> String { fn relative_path(&self) -> String {
@@ -436,24 +471,40 @@ fn ensure_resource_scope_allowed(
let Some(scope) = input.arg_value("aiAccessScope") else { let Some(scope) = input.arg_value("aiAccessScope") else {
return Ok(()); return Ok(());
}; };
let allowed = scope // 与 block allowedTargetBlockIds 一致:
// - 未声明 allowedResourceIds → 不按资源 id 收紧(仍受 rootUri / 写授权约束)
// - 声明了但非数组 / 空数组 → fail-closed
// - 声明了非空数组 → 仅白名单放行
let raw = scope
.get("allowedResourceIds") .get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids")) .or_else(|| scope.get("allowed_resource_ids"));
.and_then(Value::as_array) let Some(raw) = raw else {
.map(|values| { return Ok(());
values };
.iter() let Some(values) = raw.as_array() else {
.filter_map(Value::as_str) return Err(WebError::new(
.map(str::trim) StatusCode::FORBIDDEN,
.filter(|value| !value.is_empty()) "mnote_resource_ai_scope_invalid",
.map(ToOwned::to_owned) "aiAccessScope.allowedResourceIds 必须是字符串数组",
.collect::<HashSet<_>>() )
}) .with_context(context));
.unwrap_or_default(); };
if allowed.is_empty() let allowed = values
|| allowed.contains(&target.resource_id) .iter()
|| allowed.contains(&target.object_identity) .filter_map(Value::as_str)
{ .map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<HashSet<_>>();
if allowed.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_ai_scope_empty",
"aiAccessScope.allowedResourceIds 为空,拒绝访问任何资源",
)
.with_context(context));
}
if allowed.contains(&target.resource_id) || allowed.contains(&target.object_identity) {
return Ok(()); return Ok(());
} }
Err(WebError::new( Err(WebError::new(
@@ -979,14 +1030,22 @@ fn ensure_mindmap_revision_precondition(
return Ok(()); return Ok(());
}; };
let current = file_revision(path); let current = file_revision(path);
if revision_label(&expected).as_deref() != revision_label(&current).as_deref() { let expected_label = revision_label(&expected);
return Err(WebError::bad_request_code( let current_label = revision_label(&current);
// 双方均无法解析时不得 None==None 静默放行(fail-closed)。
match (expected_label.as_deref(), current_label.as_deref()) {
(Some(exp), Some(cur)) if exp == cur => Ok(()),
(Some(_), Some(_)) => Err(WebError::bad_request_code(
"mnote_resource_revision_conflict", "mnote_resource_revision_conflict",
"mindmap resource revision 已变化,请重新读取后再写入", "mindmap resource revision 已变化,请重新读取后再写入",
) )
.with_context(context)); .with_context(context)),
_ => Err(WebError::bad_request_code(
"mnote_resource_revision_unparseable",
"mindmap resource revision 无法解析,拒绝写入",
)
.with_context(context)),
} }
Ok(())
} }
fn revision_label(value: &Value) -> Option<String> { fn revision_label(value: &Value) -> Option<String> {
@@ -1063,8 +1122,33 @@ fn office_mime_type(path: &Path) -> &'static str {
} }
fn office_text_preview(path: &Path) -> Value { fn office_text_preview(path: &Path) -> Value {
match fs::read_to_string(path) { // Office Open XML 是 ZIP 二进制,read_to_string 必失败;不要整文件读入内存。
Ok(text) => json!(text.chars().take(4000).collect::<String>()), // 文本摘要请走 onlyoffice document.export;此处仅对可能的纯文本做有限预览。
Err(_) => Value::Null, let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if matches!(
ext.as_str(),
"docx" | "xlsx" | "pptx" | "doc" | "xls" | "ppt" | "odt" | "ods" | "odp"
) {
return Value::Null;
} }
const MAX_PREVIEW_BYTES: usize = 16 * 1024;
let Ok(mut file) = fs::File::open(path) else {
return Value::Null;
};
use std::io::Read;
let mut buf = vec![0_u8; MAX_PREVIEW_BYTES];
let Ok(n) = file.read(&mut buf) else {
return Value::Null;
};
buf.truncate(n);
// 含明显 NUL 则当二进制,避免把 ZIP/乱码当摘要
if buf.iter().any(|b| *b == 0) {
return Value::Null;
}
let text = String::from_utf8_lossy(&buf);
json!(text.chars().take(4000).collect::<String>())
} }
@@ -1,12 +1,8 @@
use crate::error::WebError; //! Provider identity sync (legacy).
use control_plane::UserRecord; //!
use reqwest::header::{HeaderMap, HeaderValue}; //! 外部知识库身份同步已移除。本模块保留 API 兼容外壳,始终返回 skipped。
use serde_json::{json, Value};
use std::env;
use std::time::Duration;
const DEFAULT_WEKNORA_ENDPOINT: &str = "http://127.0.0.1:8080/api/v1"; use control_plane::UserRecord;
const PROVIDER_IDENTITY_SYNC_TIMEOUT_MS: u64 = 1_500;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProviderIdentitySyncResult { pub struct ProviderIdentitySyncResult {
@@ -16,143 +12,16 @@ pub struct ProviderIdentitySyncResult {
pub provider_user_id: Option<String>, pub provider_user_id: Option<String>,
} }
fn env_flag(key: &str, default: bool) -> bool { /// 历史入口:注册/改密时同步外部知识库身份。已退役,固定 no-op。
env::var(key)
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(default)
}
fn clean_url(value: String) -> Option<String> {
let trimmed = value.trim().trim_end_matches('/').to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
fn weknora_endpoint() -> String {
env::var("MNOTE_WEKNORA_ENDPOINT")
.or_else(|_| env::var("WEKNORA_ENDPOINT"))
.ok()
.and_then(clean_url)
.unwrap_or_else(|| DEFAULT_WEKNORA_ENDPOINT.to_string())
}
fn join_url(base_url: &str, path: &str) -> String {
format!(
"{}/{}",
base_url.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
fn internal_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(secret) = env::var("MNOTE_PROVIDER_IDENTITY_SYNC_SECRET")
.or_else(|_| env::var("MNOTE_INTERNAL_API_SECRET"))
.or_else(|_| env::var("INTERNAL_API_SECRET"))
{
if let Ok(value) = HeaderValue::from_str(secret.trim()) {
headers.insert("x-internal-token", value);
}
}
headers
}
fn fallback_email(user: &UserRecord) -> String {
user.email
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{}@mnote.local", user.username))
}
fn sync_disabled() -> bool {
!env_flag("MNOTE_PROVIDER_IDENTITY_SYNC", true)
}
pub async fn sync_provider_identities( pub async fn sync_provider_identities(
user: &UserRecord, user: &UserRecord,
password: &str, _password: &str,
) -> Vec<ProviderIdentitySyncResult> { ) -> Vec<ProviderIdentitySyncResult> {
if sync_disabled() { let _ = user;
return vec![ProviderIdentitySyncResult { vec![ProviderIdentitySyncResult {
provider: "all", provider: "none",
ok: true, ok: true,
message: "provider identity sync disabled".to_string(), message: "External knowledge provider identity sync removed; no sync configured".into(),
provider_user_id: None, provider_user_id: None,
}]; }]
}
let sync_weknora = env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_WEKNORA", true);
if sync_weknora {
vec![sync_weknora_identity(user, password).await]
} else {
vec![ProviderIdentitySyncResult {
provider: "all",
ok: true,
message: "provider identity sync disabled".to_string(),
provider_user_id: None,
}]
}
}
async fn post_json(url: String, body: Value) -> Result<Value, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(PROVIDER_IDENTITY_SYNC_TIMEOUT_MS))
.build()
.map_err(|error| WebError::internal(format!("provider identity sync client: {error}")))?;
let response = client
.post(url.clone())
.headers(internal_headers())
.json(&body)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"provider_identity_sync_unreachable",
format!("{url}: {error}"),
)
})?;
let status = response.status();
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"provider_identity_sync_failed",
format!("{url} returned {status}: {payload}"),
));
}
Ok(payload)
}
async fn sync_weknora_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult {
let payload = json!({
"mnote_user_id": user.id,
"username": user.username,
"email": fallback_email(user),
"password": password,
"role": "contributor",
"is_active": user.status == "active",
});
match post_json(
join_url(&weknora_endpoint(), "/internal/mnote/users/provision"),
payload,
)
.await
{
Ok(payload) => ProviderIdentitySyncResult {
provider: "weknora",
ok: true,
message: "synced".to_string(),
provider_user_id: payload
.pointer("/user/id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
Err(error) => ProviderIdentitySyncResult {
provider: "weknora",
ok: false,
message: error.message().to_string(),
provider_user_id: None,
},
}
} }
+181 -30
View File
@@ -593,8 +593,14 @@ pub async fn create_directory_access_request(
Json(body): Json<DirectoryAccessRequestBody>, Json(body): Json<DirectoryAccessRequestBody>,
) -> Result<Json<Value>, WebError> { ) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?; let actor_id = ensure_authenticated(&context)?;
let root_path = body.root_path.trim(); let root_path = sanitize_directory_access_root(body.root_path.trim()).map_err(|message| {
let root_uri = body.root_uri.trim(); WebError::bad_request_code("directory_access_request_invalid_path", message)
.with_context(&context)
})?;
let root_uri = sanitize_directory_access_root(body.root_uri.trim()).map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_uri", message)
.with_context(&context)
})?;
if root_path.is_empty() && root_uri.is_empty() { if root_path.is_empty() && root_uri.is_empty() {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"directory_access_request_root_required", "directory_access_request_root_required",
@@ -659,23 +665,34 @@ pub async fn approve_directory_access_request(
.unwrap_or_default() .unwrap_or_default()
.trim() .trim()
.to_string(); .to_string();
let root_path = request let root_path = sanitize_directory_access_root(
.get("rootPath") request
.and_then(Value::as_str) .get("rootPath")
.unwrap_or_default() .and_then(Value::as_str)
.trim() .unwrap_or_default(),
.to_string(); )
let root_uri = request .map_err(|message| {
.get("rootUri") WebError::bad_request_code("directory_access_request_invalid_path", message)
.and_then(Value::as_str) })?;
.unwrap_or_default() let root_uri = sanitize_directory_access_root(
.trim() request
.to_string(); .get("rootUri")
.and_then(Value::as_str)
.unwrap_or_default(),
)
.map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_uri", message)
})?;
let permission = request let permission = request
.get("permission") .get("permission")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(normalize_directory_request_permission) .map(normalize_directory_request_permission)
.unwrap_or_else(|| "read".into()); .unwrap_or_else(|| "read".into());
// 默认递归;若申请明确写了 recursive=false 则尊重(兼容旧审计无该字段)
let recursive = request
.get("recursive")
.and_then(Value::as_bool)
.unwrap_or(true);
let (_, Json(created)) = local_folder_source::create_local_access_grant( let (_, Json(created)) = local_folder_source::create_local_access_grant(
State(state.clone()), State(state.clone()),
Extension(context.clone()), Extension(context.clone()),
@@ -685,7 +702,7 @@ pub async fn approve_directory_access_request(
root_uri, root_uri,
root_path, root_path,
permission, permission,
recursive: true, recursive,
capabilities: Vec::new(), capabilities: Vec::new(),
}), }),
) )
@@ -786,18 +803,53 @@ fn normalize_directory_request_permission(value: &str) -> String {
} }
} }
/// 拒绝路径穿越与空段;允许绝对/相对路径与 file:// URI(仅做字面安全检查)。
fn sanitize_directory_access_root(raw: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
if trimmed.contains('\0') {
return Err("目录路径包含非法字符".into());
}
// file:// 或普通路径统一按 path component 检查 `..`
let path_part = trimmed
.strip_prefix("file://")
.or_else(|| trimmed.strip_prefix("file:"))
.unwrap_or(trimmed);
let path = std::path::Path::new(path_part);
for component in path.components() {
match component {
std::path::Component::ParentDir => {
return Err("目录路径不能包含 ..".into());
}
std::path::Component::CurDir => continue,
_ => {}
}
}
// 拒绝 Windows 风格盘符以外的可疑 `..` 编码残留
if path_part.split(['/', '\\']).any(|seg| seg == "..") {
return Err("目录路径不能包含 ..".into());
}
Ok(trimmed.to_string())
}
fn list_directory_access_requests( fn list_directory_access_requests(
state: &AppState, state: &AppState,
user_filter: Option<&str>, user_filter: Option<&str>,
) -> Result<Vec<Value>, WebError> { ) -> Result<Vec<Value>, WebError> {
// list_audit_log 按 created_at DESC;用两遍处理保证与返回顺序无关:
// 先收 requested,再叠 approved/rejected,避免决策事件找不到条目而永久 pending。
const AUDIT_LOG_FETCH_LIMIT: usize = 5_000;
let rows = state let rows = state
.control_plane() .control_plane()
.list_audit_log(1000) .list_audit_log(AUDIT_LOG_FETCH_LIMIT)
.map_err(|error| WebError::internal(format!("目录权限申请读取失败: {error}")))?; .map_err(|error| WebError::internal(format!("目录权限申请读取失败: {error}")))?;
let mut requests: HashMap<String, Value> = HashMap::new(); let mut requests: HashMap<String, Value> = HashMap::new();
for row in rows.iter().rev() {
let mut apply_row = |row: &control_plane::AuditLogRecord, phase: u8| {
if row.target_kind.as_str() != "directory_access_request" { if row.target_kind.as_str() != "directory_access_request" {
continue; return;
} }
let Some(request_id) = row let Some(request_id) = row
.target_id .target_id
@@ -805,19 +857,35 @@ fn list_directory_access_requests(
.map(str::trim) .map(str::trim)
.filter(|v| !v.is_empty()) .filter(|v| !v.is_empty())
else { else {
continue; return;
}; };
let metadata = let metadata =
serde_json::from_str::<Value>(&row.metadata_json).unwrap_or_else(|_| json!({})); serde_json::from_str::<Value>(&row.metadata_json).unwrap_or_else(|_| json!({}));
match row.action.as_str() { match (phase, row.action.as_str()) {
"directory_access.requested" => { (0, "directory_access.requested") => {
let mut value = metadata; let mut value = metadata;
value["requestId"] = json!(request_id); value["requestId"] = json!(request_id);
value["status"] = json!("pending"); value["status"] = json!("pending");
value["createdAt"] = json!(row.created_at.clone()); value["createdAt"] = json!(row.created_at.clone());
requests.insert(request_id.to_string(), value); // 同 id 多次申请:保留最早 createdAt(若已有条目则不覆盖 createdAt)
requests
.entry(request_id.to_string())
.and_modify(|existing| {
if existing.get("createdAt").and_then(Value::as_str).is_none() {
existing["createdAt"] = json!(row.created_at.clone());
}
// 若尚无决策,刷新申请元数据
if existing.get("status").and_then(Value::as_str) == Some("pending") {
for key in ["rootPath", "rootUri", "permission", "note", "userId"] {
if let Some(v) = value.get(key) {
existing[key] = v.clone();
}
}
}
})
.or_insert(value);
} }
"directory_access.approved" | "directory_access.rejected" => { (1, "directory_access.approved") | (1, "directory_access.rejected") => {
if let Some(value) = requests.get_mut(request_id) { if let Some(value) = requests.get_mut(request_id) {
let status = metadata.get("status").and_then(Value::as_str).unwrap_or( let status = metadata.get("status").and_then(Value::as_str).unwrap_or(
if row.action.ends_with("approved") { if row.action.ends_with("approved") {
@@ -837,11 +905,32 @@ fn list_directory_access_requests(
if let Some(grant_id) = metadata.get("grantId").and_then(Value::as_str) { if let Some(grant_id) = metadata.get("grantId").and_then(Value::as_str) {
value["grantId"] = json!(grant_id); value["grantId"] = json!(grant_id);
} }
} else {
// 决策日志早于申请窗口之外时,仍以决策元数据建档,避免幽灵 pending
let mut value = metadata;
value["requestId"] = json!(request_id);
if value.get("status").and_then(Value::as_str).is_none() {
value["status"] = json!(if row.action.ends_with("approved") {
"approved"
} else {
"rejected"
});
}
value["decidedAt"] = json!(row.created_at.clone());
requests.insert(request_id.to_string(), value);
} }
} }
_ => {} _ => {}
} }
};
for row in &rows {
apply_row(row, 0);
} }
for row in &rows {
apply_row(row, 1);
}
let mut values = requests let mut values = requests
.into_values() .into_values()
.filter(|request| { .filter(|request| {
@@ -1050,12 +1139,29 @@ pub async fn admin_list_users(
let users = users let users = users
.into_iter() .into_iter()
.map(|user| { .map(|user| {
// 7-76mnote-e2e / role=ai_service 视为 AI 主体;普通用户不可冒充。
let principal_kind = if user.role == "ai_service"
|| user.id == "mnote-e2e"
|| user.username == "mnote-e2e"
{
"ai_service"
} else {
"human"
};
let display_role = if principal_kind == "ai_service" {
"ai_service"
} else if is_admin_user_for_display(&user.id, &user.role) {
"admin"
} else {
user.role.as_str()
};
json!({ json!({
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
"username": user.username, "username": user.username,
"displayName": user.display_name, "displayName": user.display_name,
"role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() }, "role": display_role,
"principalKind": principal_kind,
"status": user.status, "status": user.status,
"createdAt": user.created_at, "createdAt": user.created_at,
"updatedAt": user.updated_at, "updatedAt": user.updated_at,
@@ -2294,9 +2400,11 @@ fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
"用户 ID 不能为空", "用户 ID 不能为空",
)); ));
} }
// 控制面尚无 get_user;提高 limit 降低误报,仍是 O(n) 列表扫描。
const USER_LOOKUP_LIMIT: usize = 5_000;
let exists = state let exists = state
.control_plane() .control_plane()
.list_users(500) .list_users(USER_LOOKUP_LIMIT)
.map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))? .map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))?
.into_iter() .into_iter()
.any(|user| user.id == user_id); .any(|user| user.id == user_id);
@@ -2311,26 +2419,39 @@ fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
} }
fn validate_user_policy_body(body: &UserAiPolicyBody, global_policy: &Value) -> Result<(), String> { fn validate_user_policy_body(body: &UserAiPolicyBody, global_policy: &Value) -> Result<(), String> {
let global_models = global_policy // 与 runtime 一致:两边都 canonical,避免 freefirst-fast ↔ freefirst 误拒。
let global_models: std::collections::HashSet<String> = global_policy
.get("allowedModels") .get("allowedModels")
.and_then(Value::as_array) .and_then(Value::as_array)
.into_iter() .into_iter()
.flatten() .flatten()
.filter_map(Value::as_str) .filter_map(Value::as_str)
.collect::<std::collections::HashSet<_>>(); .filter_map(|value| canonical_ai_model_ref(None, value))
.collect();
if let Some(models) = &body.allowed_models { if let Some(models) = &body.allowed_models {
for model in models { for model in models {
if !global_models.contains(model.trim()) { let Some(canonical) = canonical_ai_model_ref(None, model.trim()) else {
return Err(format!("模型 {model} 非法"));
};
if !global_models.contains(&canonical) {
return Err(format!("模型 {model} 不在管理员允许范围内")); return Err(format!("模型 {model} 不在管理员允许范围内"));
} }
} }
} }
if let Some(default_model) = &body.default_model { if let Some(default_model) = &body.default_model {
let Some(default_canonical) = canonical_ai_model_ref(None, default_model.trim()) else {
return Err("默认模型非法".into());
};
let allowed = body let allowed = body
.allowed_models .allowed_models
.as_ref() .as_ref()
.map(|models| models.iter().any(|model| model == default_model)) .map(|models| {
.unwrap_or_else(|| global_models.contains(default_model.as_str())); models.iter().any(|model| {
canonical_ai_model_ref(None, model.trim()).as_deref()
== Some(default_canonical.as_str())
})
})
.unwrap_or_else(|| global_models.contains(&default_canonical));
if !allowed { if !allowed {
return Err("默认模型必须属于该用户允许的模型".into()); return Err("默认模型必须属于该用户允许的模型".into());
} }
@@ -3609,6 +3730,36 @@ mod tests {
assert!(validate_user_policy_body(&body, &global).is_ok()); assert!(validate_user_policy_body(&body, &global).is_ok());
} }
#[test]
fn user_policy_accepts_canonical_model_aliases() {
// 全局存 freefirst-fast,用户侧用 canonical freefirst 应通过
let global = json!({
"allowedModels": ["omniroute/freefirst-fast"],
"tools": {}
});
let body = UserAiPolicyBody {
default_model: Some("omniroute/freefirst".into()),
allowed_models: Some(vec!["omniroute/freefirst".into()]),
tools: None,
skills: None,
mcp_servers: None,
pi_extensions: None,
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn sanitize_directory_access_root_rejects_traversal() {
assert!(sanitize_directory_access_root("../../etc/passwd").is_err());
assert!(sanitize_directory_access_root("/tmp/../etc").is_err());
assert!(sanitize_directory_access_root("file:///tmp/../secret").is_err());
assert_eq!(
sanitize_directory_access_root("/home/user/notes").unwrap(),
"/home/user/notes"
);
assert_eq!(sanitize_directory_access_root("").unwrap(), "");
}
#[test] #[test]
fn admin_user_display_role_includes_access_policy_admins() { fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::agent_env_lock() let _guard = crate::test_support::agent_env_lock()
File diff suppressed because it is too large Load Diff
+29 -7
View File
@@ -53,6 +53,16 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Val
) )
} }
/// Cap bridge overview page size to avoid unbounded fan-out / memory pressure.
const BRIDGE_WORKSPACE_LIMIT_MAX: u32 = 200;
const BRIDGE_WORKSPACE_LIMIT_DEFAULT: u32 = 50;
fn clamped_bridge_limit(limit: Option<u32>) -> u32 {
limit
.unwrap_or(BRIDGE_WORKSPACE_LIMIT_DEFAULT)
.clamp(1, BRIDGE_WORKSPACE_LIMIT_MAX)
}
fn workspace_query_payload( fn workspace_query_payload(
workspace_id: &str, workspace_id: &str,
query: &BridgeWorkspaceQuery, query: &BridgeWorkspaceQuery,
@@ -61,7 +71,7 @@ fn workspace_query_payload(
name: "bridge.workspace.overview".into(), name: "bridge.workspace.overview".into(),
payload: json!({ payload: json!({
"workspaceId": workspace_id, "workspaceId": workspace_id,
"limit": query.limit.unwrap_or(50), "limit": clamped_bridge_limit(query.limit),
"cursor": query.cursor, "cursor": query.cursor,
"commandStatus": query.command_status, "commandStatus": query.command_status,
"eventStatus": query.event_status, "eventStatus": query.event_status,
@@ -82,14 +92,27 @@ async fn execute_bridge_query(
execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await
} }
fn require_resolved_workspace_id(
context: &RequestContext,
query_workspace_id: Option<&str>,
) -> Result<String, WebError> {
resolve_effective_workspace_id(context, query_workspace_id, true)?.ok_or_else(|| {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
.with_header("x-error-phase", "workspace_resolve")
})
}
pub async fn workspace( pub async fn workspace(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<BridgeWorkspaceQuery>, Query(query): Query<BridgeWorkspaceQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
.expect("workspace_required 已确保存在");
let result = execute_bridge_query( let result = execute_bridge_query(
state.config(), state.config(),
&context, &context,
@@ -106,8 +129,7 @@ pub async fn request(
Query(query): Query<BridgeRequestQuery>, Query(query): Query<BridgeRequestQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
.expect("workspace_required 已确保存在");
let result = execute_bridge_query( let result = execute_bridge_query(
state.config(), state.config(),
&context, &context,
@@ -131,8 +153,7 @@ pub async fn trace(
Query(query): Query<BridgeTraceQuery>, Query(query): Query<BridgeTraceQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
.expect("workspace_required 已确保存在");
let result = execute_bridge_query( let result = execute_bridge_query(
state.config(), state.config(),
&context, &context,
@@ -177,6 +198,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -83,6 +83,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -122,6 +123,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -178,6 +180,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -246,6 +249,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -532,6 +532,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -605,6 +605,10 @@ pub async fn meta(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>, Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_READ,
)?;
let result = load_document_meta_result(&state, &context, query).await?; let result = load_document_meta_result(&state, &context, query).await?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
@@ -614,6 +618,10 @@ pub async fn content(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>, Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_READ,
)?;
let result = load_document_content_result(&state, &context, query).await?; let result = load_document_content_result(&state, &context, query).await?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
@@ -691,6 +699,10 @@ pub async fn page_body_write(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<core_protocol::PageBodyWriteRequest>, Json(body): Json<core_protocol::PageBodyWriteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim(); let document_id = body.document_id.trim();
if document_id.is_empty() { if document_id.is_empty() {
return Err( return Err(
@@ -724,6 +736,10 @@ pub async fn save(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>, Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim(); let document_id = body.document_id.trim();
if document_id.is_empty() { if document_id.is_empty() {
return Err( return Err(
@@ -977,6 +993,10 @@ pub async fn title(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentTitleRequest>, Json(body): Json<DocumentTitleRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim(); let document_id = body.document_id.trim();
if document_id.is_empty() { if document_id.is_empty() {
return Err( return Err(
@@ -1280,6 +1300,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -1913,6 +1913,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -398,6 +398,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
} }
+95 -22
View File
@@ -128,7 +128,8 @@ pub async fn auth_api(
if action != "auth:signIn" && action != "auth:signOut" { if action != "auth:signIn" && action != "auth:signOut" {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"auth_action_unsupported", "auth_action_unsupported",
"Rust gateway 当前仅支持账号登录、注册与登出动作。", // 注册走 auth:signIn 的 create-if-missing 路径;独立 auth:signUp 未开放。
"Rust gateway 当前仅支持账号登录(auth:signIn)与登出(auth:signOut)。",
) )
.with_context(&context) .with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
@@ -306,6 +307,14 @@ fn ai_management_response(
} else { } else {
"user-ai-admin" "user-ai-admin"
}; };
let ui_runtime_src = {
let base = "/api/mnote-browser-runtime/mnote-ui-runtime.js";
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
format!("{base}?devHot={cache_buster}")
} else {
base.to_string()
}
};
let mut response = Html(format!( let mut response = Html(format!(
r#"<!doctype html> r#"<!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
@@ -314,6 +323,7 @@ fn ai_management_response(
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title> <title>{}</title>
<style>{}</style> <style>{}</style>
<script src="{}"></script>
</head> </head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="{}" data-mnote-actor-id="{}"> <body data-mnote-web-owner="mnote-web" data-mnote-shell="{}" data-mnote-actor-id="{}">
{} {}
@@ -321,6 +331,7 @@ fn ai_management_response(
</html>"#, </html>"#,
title, title,
crate::ssr::MNOTE_CSS, crate::ssr::MNOTE_CSS,
ui_runtime_src,
shell, shell,
escape_html(context.auth.actor_id.as_str()), escape_html(context.auth.actor_id.as_str()),
content content
@@ -1105,14 +1116,32 @@ fn parent_scope_from_relative_path(relative_path: &str) -> &str {
.unwrap_or("") .unwrap_or("")
} }
/// 导航 scope 路径:词法拒 `..` / 绝对路径,避免把穿越串写进 query(下游仍会再校验)。
fn sanitize_navigation_relative_path(relative_path: &str) -> &str {
let trimmed = relative_path.trim();
if trimmed.is_empty() || trimmed == "." {
return "";
}
let path = std::path::Path::new(trimmed);
if path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return "";
}
trimmed
}
fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String { fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
let mut href = format!( let mut href = format!(
"/?sourceKind=local_folder&rootUri={}&treeView=filetree", "/?sourceKind=local_folder&rootUri={}&treeView=filetree",
query_escape(root_uri) query_escape(root_uri)
); );
if !relative_path.trim().is_empty() { let scope = sanitize_navigation_relative_path(relative_path);
if !scope.is_empty() {
href.push_str("&fileTreeScope="); href.push_str("&fileTreeScope=");
href.push_str(&query_escape(relative_path)); href.push_str(&query_escape(scope));
} }
href href
} }
@@ -1123,9 +1152,10 @@ fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id:
query_escape(document_id), query_escape(document_id),
query_escape(root_uri) query_escape(root_uri)
); );
if !file_tree_scope.trim().is_empty() { let scope = sanitize_navigation_relative_path(file_tree_scope);
if !scope.is_empty() {
href.push_str("&fileTreeScope="); href.push_str("&fileTreeScope=");
href.push_str(&query_escape(file_tree_scope)); href.push_str(&query_escape(scope));
} }
href href
} }
@@ -1246,8 +1276,7 @@ pub async fn vault_entry(
let root_uri = root_uri.ok_or_else(|| { let root_uri = root_uri.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?; })?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri) // 单次调用同时完成权限校验与路径解析,避免重复 FS 操作。
.map_err(|error| error.with_context(&context))?;
let workspace_root = let workspace_root =
ensure_local_workspace_read_access_with_state(&state, &context, root_uri) ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?; .map_err(|error| error.with_context(&context))?;
@@ -1419,6 +1448,7 @@ fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &V
<div class="mnote-vault-header-actions"> <div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create"></button> <button type="button" data-vault-create data-testid="vault-create"></button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">簿</button> <button type="button" data-vault-cipher-book data-testid="vault-cipher-book">簿</button>
<button type="button" data-vault-repair-ai-folders data-testid="vault-repair-ai-folders" hidden title="将分享副本分组重写为 用户名/原分组"> AI </button>
<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true"> <label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">
<span class="mnote-vault-sr-only"></span> <span class="mnote-vault-sr-only"></span>
<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文"> <select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">
@@ -1750,13 +1780,13 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
}} }}
return Promise.reject(new Error('resource_kind_unsupported')); return Promise.reject(new Error('resource_kind_unsupported'));
}} }}
root.addEventListener('click', function(event) {{ root.addEventListener('click', async function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null; var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return; if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action'); var action = button.getAttribute('data-trash-action');
var documentId = button.getAttribute('data-document-id') || ''; var documentId = button.getAttribute('data-document-id') || '';
if (action === 'empty-documents') {{ if (action === 'empty-documents') {{
if (!window.confirm('')) return; if (!(await window.mnote.confirm(''))) return;
button.disabled = true; button.disabled = true;
fetch('/api/documents/empty-trash', {{ fetch('/api/documents/empty-trash', {{
method: 'POST', method: 'POST',
@@ -1770,14 +1800,14 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
if (count) count.textContent = '0'; if (count) count.textContent = '0';
setStatus('', false); setStatus('', false);
}}); }});
}}).catch(function(error) {{ }}).catch(async function(error) {{
button.disabled = false; button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true); setStatus(error && error.message ? error.message : String(error), true);
}}); }});
return; return;
}} }}
if (action === 'empty-resources') {{ if (action === 'empty-resources') {{
if (!window.confirm('')) return; if (!(await window.mnote.confirm(''))) return;
button.disabled = true; button.disabled = true;
Promise.all([ Promise.all([
postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}), postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
@@ -1788,7 +1818,7 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
var count = root.querySelector('[data-trash-resource-count]'); var count = root.querySelector('[data-trash-resource-count]');
if (count) count.textContent = '0'; if (count) count.textContent = '0';
setStatus('', false); setStatus('', false);
}}).catch(function(error) {{ }}).catch(async function(error) {{
button.disabled = false; button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true); setStatus(error && error.message ? error.message : String(error), true);
}}); }});
@@ -1799,21 +1829,21 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
var kind = button.getAttribute('data-resource-kind') || ''; var kind = button.getAttribute('data-resource-kind') || '';
var resourceDocumentId = button.getAttribute('data-document-id') || ''; var resourceDocumentId = button.getAttribute('data-document-id') || '';
if (!resourceId) return; if (!resourceId) return;
if (action === 'resource-purge' && !window.confirm('')) return; if (action === 'resource-purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true; button.disabled = true;
runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{ runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
var row = button.closest('[data-trash-row]'); var row = button.closest('[data-trash-row]');
if (row) row.remove(); if (row) row.remove();
decrement('[data-trash-resource-count]'); decrement('[data-trash-resource-count]');
setStatus(action === 'resource-restore' ? '' : '', false); setStatus(action === 'resource-restore' ? '' : '', false);
}}).catch(function(error) {{ }}).catch(async function(error) {{
button.disabled = false; button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true); setStatus(error && error.message ? error.message : String(error), true);
}}); }});
return; return;
}} }}
if (!documentId) return; if (!documentId) return;
if (action === 'purge' && !window.confirm('')) return; if (action === 'purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true; button.disabled = true;
fetch('/api/tree/commands', {{ fetch('/api/tree/commands', {{
method: 'POST', method: 'POST',
@@ -1973,7 +2003,7 @@ fn render_local_trash_workbench_html(
return false; return false;
}}); }});
}} }}
root.addEventListener('click', function(event) {{ root.addEventListener('click', async function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null; var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return; if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action'); var action = button.getAttribute('data-trash-action');
@@ -1981,7 +2011,7 @@ fn render_local_trash_workbench_html(
var documentId = button.getAttribute('data-document-id') || entryId; var documentId = button.getAttribute('data-document-id') || entryId;
var kind = button.getAttribute('data-resource-kind') || ''; var kind = button.getAttribute('data-resource-kind') || '';
if (action === 'local-restore' || action === 'local-purge') {{ if (action === 'local-restore' || action === 'local-purge') {{
if (action === 'local-purge' && !window.confirm('')) return; if (action === 'local-purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true; button.disabled = true;
postJson('/api/tree/commands', {{ postJson('/api/tree/commands', {{
action: action === 'local-restore' ? 'restore' : 'purge', action: action === 'local-restore' ? 'restore' : 'purge',
@@ -2003,7 +2033,7 @@ fn render_local_trash_workbench_html(
return refresh(); return refresh();
}}).then(function() {{ }}).then(function() {{
setStatus(action === 'local-restore' ? '' : '', false); setStatus(action === 'local-restore' ? '' : '', false);
}}).catch(function(error) {{ }}).catch(async function(error) {{
button.disabled = false; button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true); setStatus(error && error.message ? error.message : String(error), true);
}}); }});
@@ -2014,7 +2044,7 @@ fn render_local_trash_workbench_html(
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])'; : '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
var rows = Array.prototype.slice.call(root.querySelectorAll(selector)); var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
if (rows.length === 0) return; if (rows.length === 0) return;
if (!window.confirm('')) return; if (!(await window.mnote.confirm(''))) return;
button.disabled = true; button.disabled = true;
rows.reduce(function(chain, row) {{ rows.reduce(function(chain, row) {{
return chain.then(function() {{ return chain.then(function() {{
@@ -2461,6 +2491,14 @@ pub(crate) fn current_actor_display_name(
} }
pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option<String> { pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option<String> {
// 7-76PAT 鉴权优先,忽略 cookie 叠加。
if context.auth.auth_method == "pat" {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return None;
}
return Some(actor_id.to_string());
}
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token); let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
@@ -2476,6 +2514,14 @@ pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Op
} }
pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String { pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String {
if context.auth.auth_method == "pat" {
let t = context.auth.actor_type.trim();
return if t.is_empty() {
"user".to_string()
} else {
t.to_string()
};
}
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token); let token_hash = session_token_hash(&raw_token);
if matches!( if matches!(
@@ -2489,6 +2535,10 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
} }
pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool { pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool {
// PAT 永不隐式 admin(即使 subject 是 admin 用户,也只按 scopes 授权)。
if context.auth.auth_method == "pat" {
return false;
}
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token); let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
@@ -2573,14 +2623,27 @@ async fn handle_control_plane_auth_action(
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or_else(|| email.split('@').next().unwrap_or("user")); .unwrap_or_else(|| email.split('@').next().unwrap_or("user"));
// 7-76mnote-e2e 注册为 AI 主体(ai_service),非普通测试人设。
let role = if username == "mnote-e2e"
|| email.eq_ignore_ascii_case("mnote.e2e@example.com")
{
Some("ai_service".to_string())
} else {
None
};
let display_name = if role.as_deref() == Some("ai_service") {
"MNote AI".to_string()
} else {
username.to_string()
};
let user = state let user = state
.control_plane() .control_plane()
.upsert_user(UpsertUserInput { .upsert_user(UpsertUserInput {
id: Some(username.to_string()), id: Some(username.to_string()),
email: Some(email.to_string()), email: Some(email.to_string()),
username: username.to_string(), username: username.to_string(),
display_name: username.to_string(), display_name,
role: None, role,
password_hash: None, password_hash: None,
}) })
.map_err(|error| control_plane_auth_error(context, error))?; .map_err(|error| control_plane_auth_error(context, error))?;
@@ -3007,6 +3070,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -3529,6 +3593,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
app_state app_state
.control_plane() .control_plane()
@@ -3596,6 +3661,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
app_state app_state
.control_plane() .control_plane()
@@ -3903,6 +3969,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
state state
.control_plane() .control_plane()
@@ -4543,7 +4610,13 @@ mod tests {
assert!(html.contains("账号登录")); assert!(html.contains("账号登录"));
assert!(html.contains("邮箱或用户名")); assert!(html.contains("邮箱或用户名"));
assert!(!html.contains(r#"<span>"用户名"</span>"#)); assert!(!html.contains(r#"<span>"用户名"</span>"#));
assert!(html.contains("测试账号快速登录")); // 7-76 P0:开发对标生产,登录页不得再渲染测试快速登录或测试密码 DOM。
assert!(!html.contains("测试账号快速登录"));
assert!(!html.contains("data-auth-test-login"));
assert!(!html.contains("data-test-password"));
assert!(!html.contains("MnoteE2E123!"));
assert!(html.contains("data-auth-submit"));
assert!(html.contains(r#"data-testid="mnote-auth-page""#));
} }
#[tokio::test] #[tokio::test]
@@ -308,6 +308,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -514,6 +514,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -46,8 +46,19 @@ pub(crate) struct OcrFrontmatter {
pub(crate) status: String, pub(crate) status: String,
} }
fn reject_path_escape(value: &str) -> bool {
value
.replace('\\', "/")
.split('/')
.any(|seg| seg == ".." || seg == ".")
|| value.contains('\0')
}
pub(crate) fn is_local_ocr_sidecar_relative_path(relative_path: &str) -> bool { pub(crate) fn is_local_ocr_sidecar_relative_path(relative_path: &str) -> bool {
let normalized = relative_path.trim().replace('\\', "/"); let normalized = relative_path.trim().replace('\\', "/");
if normalized.is_empty() || reject_path_escape(&normalized) {
return false;
}
let path = Path::new(&normalized); let path = Path::new(&normalized);
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false; return false;
@@ -79,11 +90,24 @@ pub(crate) fn parse_ocr_frontmatter(markdown: &str) -> Option<OcrFrontmatter> {
if fields.get("mnote_ocr_version").map(String::as_str) != Some("1") { if fields.get("mnote_ocr_version").map(String::as_str) != Some("1") {
return None; return None;
} }
let owner_document = fields.get("owner_document")?.to_string();
let source_path = fields.get("source_path")?.to_string();
let source_root_relative_path = fields.get("source_root_relative_path")?.to_string();
// Frontmatter 路径字段不得含 `..` / `.` 段,避免后续 join 逃逸工作区。
for candidate in [
owner_document.as_str(),
source_path.as_str(),
source_root_relative_path.as_str(),
] {
if reject_path_escape(candidate) {
return None;
}
}
Some(OcrFrontmatter { Some(OcrFrontmatter {
provider: fields.get("provider")?.to_string(), provider: fields.get("provider")?.to_string(),
owner_document: fields.get("owner_document")?.to_string(), owner_document,
source_path: fields.get("source_path")?.to_string(), source_path,
source_root_relative_path: fields.get("source_root_relative_path")?.to_string(), source_root_relative_path,
source_size: fields.get("source_size")?.parse().ok()?, source_size: fields.get("source_size")?.parse().ok()?,
source_mtime_ms: fields.get("source_mtime_ms")?.parse().ok()?, source_mtime_ms: fields.get("source_mtime_ms")?.parse().ok()?,
status: fields.get("status")?.to_string(), status: fields.get("status")?.to_string(),
@@ -153,8 +177,15 @@ mod tests {
assert!(!is_local_ocr_sidecar_relative_path( assert!(!is_local_ocr_sidecar_relative_path(
"docs/Page.assets/photo.png" "docs/Page.assets/photo.png"
)); ));
assert!(!is_local_ocr_sidecar_relative_path(
"docs/../Page.ocr/photo.png.ocr.md"
));
let markdown = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n"; // 含路径逃逸段的 frontmatter 必须拒绝解析。
let bad = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: ../Page.md\nsource_path: Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
assert!(parse_ocr_frontmatter(bad).is_none());
let markdown = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: Page.md\nsource_path: Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
let parsed = parse_ocr_frontmatter(markdown).expect("frontmatter"); let parsed = parse_ocr_frontmatter(markdown).expect("frontmatter");
assert_eq!(parsed.provider, "mock"); assert_eq!(parsed.provider, "mock");
assert_eq!( assert_eq!(
@@ -17,7 +17,6 @@ use core_protocol::{
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
#[cfg(test)]
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::fs; use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
@@ -1457,6 +1456,7 @@ fn rebuild_local_search_index_with_settings(
) -> Result<LocalSearchIndex, WebError> { ) -> Result<LocalSearchIndex, WebError> {
let mut documents = Vec::new(); let mut documents = Vec::new();
let mut resources = Vec::new(); let mut resources = Vec::new();
let mut visited = BTreeSet::new();
for include_path in &settings.include_paths { for include_path in &settings.include_paths {
let base_path = if include_path == "." { let base_path = if include_path == "." {
root_path.to_path_buf() root_path.to_path_buf()
@@ -1464,7 +1464,13 @@ fn rebuild_local_search_index_with_settings(
root_path.join(include_path) root_path.join(include_path)
}; };
if base_path.exists() { if base_path.exists() {
collect_markdown_documents(root_path, &base_path, &mut documents, &mut resources)?; collect_markdown_documents(
root_path,
&base_path,
&mut documents,
&mut resources,
&mut visited,
)?;
} }
} }
documents.sort_by(|left, right| left.path.cmp(&right.path)); documents.sort_by(|left, right| left.path.cmp(&right.path));
@@ -1964,7 +1970,28 @@ fn collect_markdown_documents(
current: &Path, current: &Path,
documents: &mut Vec<LocalSearchDocument>, documents: &mut Vec<LocalSearchDocument>,
resources: &mut Vec<LocalSearchResource>, resources: &mut Vec<LocalSearchResource>,
visited: &mut BTreeSet<PathBuf>,
) -> Result<(), WebError> { ) -> Result<(), WebError> {
// 规范化后必须仍在 root 内;失败则跳过(含断链 / 权限)。
let root_canonical = match root_path.canonicalize() {
Ok(path) => path,
Err(_) => root_path.to_path_buf(),
};
let current_key = match current.canonicalize() {
Ok(path) => path,
Err(_) => current.to_path_buf(),
};
if !current_key.starts_with(&root_canonical) && current != root_path {
// 词法路径可能尚未 canonicalize 到 root;仅当 current 已规范且越界时拒绝。
if current.canonicalize().is_ok() {
return Ok(());
}
}
if !visited.insert(current_key) {
// 目录环(含硬链接/重复 include)直接跳过,避免无限递归。
return Ok(());
}
let entries = match fs::read_dir(current) { let entries = match fs::read_dir(current) {
Ok(entries) => entries, Ok(entries) => entries,
Err(error) => { Err(error) => {
@@ -1995,8 +2022,12 @@ fn collect_markdown_documents(
format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()), format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()),
) )
})?; })?;
// 不跟随符号链接,防止索引逃逸到 root 之外或形成符号链接环。
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() { if file_type.is_dir() {
collect_markdown_documents(root_path, &path, documents, resources)?; collect_markdown_documents(root_path, &path, documents, resources, visited)?;
continue; continue;
} }
if !file_type.is_file() { if !file_type.is_file() {
@@ -502,6 +502,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::web_shell::{ use crate::routes::web_shell::{
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html, build_page_aggregate_snapshot, escape_script_json, load_file_tree_html, load_sidebar_tree_html,
load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html, load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html,
}; };
use crate::ssr::pages::mindmap::MindmapPage; use crate::ssr::pages::mindmap::MindmapPage;
@@ -32,14 +32,26 @@ pub async fn mindmap_object_shell(
Query(query): Query<MindmapShellQuery>, Query(query): Query<MindmapShellQuery>,
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let default_workspace_name = default_workspace_name_for_context(&state, &context); let default_workspace_name = default_workspace_name_for_context(&state, &context);
let source_kind = query.source_kind.as_deref(); // 与路由分支一致:未显式提供时不伪称 local_folder(避免 is_local_folder=false 但 JSON 写 local_folder
let resolved_source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("")
.to_string();
let source_kind = resolved_source_kind.as_str();
let root_uri = query.root_uri.as_deref(); let root_uri = query.root_uri.as_deref();
let aggregate = build_page_aggregate_snapshot( let aggregate = build_page_aggregate_snapshot(
&state, &state,
&context, &context,
&doc_id, &doc_id,
query.workspace_id.as_deref(), query.workspace_id.as_deref(),
source_kind, if source_kind.is_empty() {
None
} else {
Some(source_kind)
},
root_uri, root_uri,
) )
.await .await
@@ -53,7 +65,7 @@ pub async fn mindmap_object_shell(
.map(|value| value.head.title.trim().to_string()) .map(|value| value.head.title.trim().to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or_else(|| "思维导图".to_string()); .unwrap_or_else(|| "思维导图".to_string());
let is_local_folder = source_kind.map(str::trim) == Some("local_folder"); let is_local_folder = source_kind == "local_folder";
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) = if is_local_folder { let (workspace_name, sidebar_tree_html, workspace_sidebar_html) = if is_local_folder {
let root_uri = root_uri.unwrap_or_default(); let root_uri = root_uri.unwrap_or_default();
let sidebar_tree_html = let sidebar_tree_html =
@@ -150,7 +162,7 @@ pub async fn mindmap_object_shell(
"documentId": doc_id, "documentId": doc_id,
"mindmapId": mindmap_id "mindmapId": mindmap_id
}, },
"sourceKind": source_kind.unwrap_or("local_folder"), "sourceKind": source_kind,
"rootUri": root_uri.unwrap_or(""), "rootUri": root_uri.unwrap_or(""),
"revision": serde_json::Value::Null, "revision": serde_json::Value::Null,
"conflictDetectionKey": serde_json::Value::Null, "conflictDetectionKey": serde_json::Value::Null,
@@ -167,7 +179,7 @@ pub async fn mindmap_object_shell(
"shell": "mindmap", "shell": "mindmap",
"documentId": doc_id, "documentId": doc_id,
"mindmapId": mindmap_id, "mindmapId": mindmap_id,
"sourceKind": source_kind.unwrap_or("local_folder"), "sourceKind": source_kind,
"rootUri": root_uri.unwrap_or(""), "rootUri": root_uri.unwrap_or(""),
"projection": { "projection": {
"schema": "mnote.mindmap.simple_mind_map_scene.v1", "schema": "mnote.mindmap.simple_mind_map_scene.v1",
@@ -217,7 +229,7 @@ pub async fn mindmap_object_shell(
crate::ssr::MNOTE_CSS, crate::ssr::MNOTE_CSS,
escape_html(&doc_id), escape_html(&doc_id),
escape_html(&mindmap_id), escape_html(&mindmap_id),
escape_html(source_kind.unwrap_or("local_folder")), escape_html(source_kind),
escape_html(root_uri.unwrap_or("")), escape_html(root_uri.unwrap_or("")),
body_content, body_content,
escape_script_json(&editor_bootstrap_json), escape_script_json(&editor_bootstrap_json),
@@ -246,10 +258,6 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;") .replace('"', "&quot;")
} }
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn render_mindmap_standalone_bootstrap_script() -> String { fn render_mindmap_standalone_bootstrap_script() -> String {
r#"<script type="module"> r#"<script type="module">
(() => { (() => {
@@ -407,6 +415,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -449,6 +458,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
+237 -27
View File
@@ -171,13 +171,39 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent")) .unwrap_or_else(|| PathBuf::from(".mnote-agent"))
} }
fn agent_profile_config_path(profile: &str) -> PathBuf { /// profile 段只允许单层安全名,拒绝 `/`、`\`、`..`,防止拼到 profiles/ 外。
let home = agent_profile_home(); fn sanitize_agent_profile_segment(profile: &str) -> Option<&str> {
let profile = profile.trim(); let profile = profile.trim();
if profile.is_empty() || profile == "default" { if profile.is_empty() || profile == "default" {
return home.join("config.yaml"); return None;
} }
let candidate = home.join("profiles").join(profile); if profile.contains('/')
|| profile.contains('\\')
|| profile.contains('\0')
|| profile == "."
|| profile == ".."
|| profile
.split(['/', '\\'])
.any(|seg| seg.is_empty() || seg == "." || seg == "..")
{
return None;
}
// 仅允许常见 profile 标识字符,避免奇怪路径段。
if !profile
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return None;
}
Some(profile)
}
fn agent_profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
let Some(safe) = sanitize_agent_profile_segment(profile) else {
return home.join("config.yaml");
};
let candidate = home.join("profiles").join(safe);
if candidate.exists() { if candidate.exists() {
candidate.join("config.yaml") candidate.join("config.yaml")
} else { } else {
@@ -388,6 +414,13 @@ pub async fn mnote_call(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>, Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
// 7-76:外部 AI 经 tools 调用时按读写工具要求 notes scope。
let required = if is_read_tool(&input.tool_name) {
crate::routes::api_access_token::SCOPE_NOTES_READ
} else {
crate::routes::api_access_token::SCOPE_NOTES_WRITE
};
crate::routes::api_access_token::ensure_scope(&context, required)?;
let response_body = execute_mnote_tool_call(&state, &context, input).await?; let response_body = execute_mnote_tool_call(&state, &context, input).await?;
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body))) Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
} }
@@ -588,15 +621,6 @@ pub(crate) async fn execute_mnote_tool_call(
.with_context(&context)) .with_context(&context))
} }
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await, "mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference" => Err(WebError::new(
StatusCode::GONE,
"mnote_weknora_tools_retired",
"WeKnora 专用工具已从默认 agent manifest 移除;请改用 provider-neutral mnote.knowledge_rag.*,或显式启动 legacy WeKnora provider 调试。",
)
.with_context(&context)),
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await, "mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.section_context" => { "mnote.knowledge_rag.section_context" => {
knowledge_rag::section_context(&state, &context, &input).await knowledge_rag::section_context(&state, &context, &input).await
@@ -905,8 +929,11 @@ fn required_capability_scope(tool_name: &str) -> Vec<String> {
} }
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool { fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
// required 为空时由 ensure_tool_capability_scope 短路。
let Some(declared) = declared else { let Some(declared) = declared else {
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。 // 兼容旧调用方:完全未声明 capabilityScope 不改变既有执行路径。
// 写工具仍由 shared_read / aiAccessScope / commandContext 等合同 fail-closed。
// 注意:显式声明 `[]` 与“未声明”语义不同——空数组表示调用方主动声明无能力,必须拒绝。
return true; return true;
}; };
let declared = declared let declared = declared
@@ -914,6 +941,10 @@ fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[
.map(|value| normalize_capability_scope(value)) .map(|value| normalize_capability_scope(value))
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// 显式空 capabilityScope → fail-closed(禁止 None 与 [] 混同为“放行”)。
if declared.is_empty() {
return false;
}
required.iter().all(|scope| { required.iter().all(|scope| {
declared declared
.iter() .iter()
@@ -1311,6 +1342,7 @@ fn stamp_tool_headers() -> HeaderMap {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{agent_profile_config_path, sanitize_agent_profile_segment};
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body}; use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
@@ -1325,6 +1357,37 @@ mod tests {
crate::test_support::agent_env_lock() crate::test_support::agent_env_lock()
} }
#[test]
fn agent_profile_segment_rejects_path_traversal() {
assert!(sanitize_agent_profile_segment("ok-profile").is_some());
assert!(sanitize_agent_profile_segment("../etc").is_none());
assert!(sanitize_agent_profile_segment("a/b").is_none());
assert!(sanitize_agent_profile_segment("..").is_none());
assert!(sanitize_agent_profile_segment("default").is_none());
// 危险段回落到 default config.yaml,路径中不得含攻击串
let path = agent_profile_config_path("../../../etc/passwd");
let s = path.to_string_lossy();
assert!(!s.contains("etc/passwd"), "{s}");
assert!(s.ends_with("config.yaml"), "{s}");
}
#[test]
fn declared_capability_scope_empty_vec_is_fail_closed() {
use super::declared_capability_scope_covers;
let required = vec!["page.write".to_string()];
// 未声明:兼容旧路径
assert!(declared_capability_scope_covers(None, &required));
// 显式空:拒绝
let empty: Vec<String> = vec![];
assert!(!declared_capability_scope_covers(Some(&empty), &required));
// 显式覆盖:通过
let ok = vec!["page.write".to_string()];
assert!(declared_capability_scope_covers(Some(&ok), &required));
// 仅 read 不覆盖 write
let read_only = vec!["page.read".to_string()];
assert!(!declared_capability_scope_covers(Some(&read_only), &required));
}
fn app() -> axum::Router { fn app() -> axum::Router {
build_app(AppState::new(AppConfig { build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
@@ -1389,6 +1452,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -1439,6 +1503,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -2333,6 +2398,10 @@ mod tests {
"dryRun": true, "dryRun": true,
"capabilityScope": ["office.write"], "capabilityScope": ["office.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"address": "A1", "address": "A1",
"value": "after" "value": "after"
} }
@@ -2476,6 +2545,10 @@ mod tests {
"dryRun": true, "dryRun": true,
"capabilityScope": ["office.write"], "capabilityScope": ["office.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"onlyofficeSessionId": bridge_session_id, "onlyofficeSessionId": bridge_session_id,
"address": "A1", "address": "A1",
"value": "after" "value": "after"
@@ -4951,6 +5024,10 @@ mod tests {
"dryRun": true, "dryRun": true,
"capabilityScope": ["page.write", "block.write"], "capabilityScope": ["page.write", "block.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"command": "block_replace", "command": "block_replace",
"blockId": "heading_1", "blockId": "heading_1",
"content": "替换标题" "content": "替换标题"
@@ -4990,6 +5067,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1", "blockId": "heading_1",
"anchorBlockId": "heading_1", "anchorBlockId": "heading_1",
"revision": 7, "revision": 7,
@@ -5041,6 +5122,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1", "blockId": "heading_1",
"content": "替换后的章节", "content": "替换后的章节",
"revision": 7, "revision": 7,
@@ -5089,6 +5174,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1", "anchorBlockId": "heading_1",
"content": "新增段落", "content": "新增段落",
"revision": 7, "revision": 7,
@@ -5137,6 +5226,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1", "blockId": "p_1",
"revision": 7, "revision": 7,
"conflictDetectionKey": "doc_1:7", "conflictDetectionKey": "doc_1:7",
@@ -5184,6 +5277,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1", "blockId": "heading_1",
"anchorBlockId": "p_2", "anchorBlockId": "p_2",
"revision": 7, "revision": 7,
@@ -5228,6 +5325,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1", "blockId": "heading_1",
"content": "越界替换", "content": "越界替换",
"revision": 7, "revision": 7,
@@ -5248,6 +5349,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1", "anchorBlockId": "heading_1",
"content": "越界插入", "content": "越界插入",
"revision": 7, "revision": 7,
@@ -5268,6 +5373,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1", "blockId": "p_1",
"revision": 7, "revision": 7,
"conflictDetectionKey": "doc_1:7", "conflictDetectionKey": "doc_1:7",
@@ -5287,6 +5396,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1", "blockId": "heading_1",
"anchorBlockId": "p_2", "anchorBlockId": "p_2",
"revision": 7, "revision": 7,
@@ -5394,6 +5507,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": block_id, "blockId": block_id,
"anchorBlockId": "p_anchor", "anchorBlockId": "p_anchor",
"revision": 7, "revision": 7,
@@ -5445,6 +5562,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1", "anchorBlockId": "heading_1",
"blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}], "blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}],
"revision": 7, "revision": 7,
@@ -5507,6 +5628,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1", "anchorBlockId": "heading_1",
"blocks": blocks, "blocks": blocks,
"revision": 7, "revision": 7,
@@ -5552,6 +5677,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1", "blockId": "heading_1",
"content": "不应写入" "content": "不应写入"
} }
@@ -5591,6 +5720,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1", "blockId": "heading_1",
"content": "不应写入", "content": "不应写入",
"revision": 7, "revision": 7,
@@ -5636,6 +5769,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{ "operations": [{
"op": "replace", "op": "replace",
"blockId": "p_2", "blockId": "p_2",
@@ -5678,6 +5815,10 @@ mod tests {
"dryRun": false, "dryRun": false,
"capabilityScope": ["block.write", "page.write"], "capabilityScope": ["block.write", "page.write"],
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"revision": 7, "revision": 7,
"conflictDetectionKey": "doc_1:7", "conflictDetectionKey": "doc_1:7",
"operations": [{ "operations": [{
@@ -6016,7 +6157,11 @@ mod tests {
"args": { "args": {
"content": [ "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]} {"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
] ],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
} }
}) })
.to_string(), .to_string(),
@@ -6097,6 +6242,10 @@ mod tests {
"wideLayout": true, "wideLayout": true,
"showHeadingNumbers": true, "showHeadingNumbers": true,
"hideTitleHeader": false "hideTitleHeader": false
},
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
} }
} }
}) })
@@ -6158,7 +6307,11 @@ mod tests {
"traceId": "trace_1", "traceId": "trace_1",
"idempotencyKey": "idem_save_1", "idempotencyKey": "idem_save_1",
"dryRun": true, "dryRun": true,
"args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]} "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
}) })
.to_string(), .to_string(),
)) ))
@@ -6238,8 +6391,12 @@ mod tests {
)); ));
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create temp root"); fs::create_dir_all(&root).expect("create temp root");
let path = root.join("page.md"); fs::write(&root.join("page.md"), "第一段\n\n第二段\n").expect("write markdown");
fs::write(&path, "第一段\n\n第二段\n").expect("write markdown"); 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() let response = app()
.oneshot( .oneshot(
@@ -6248,11 +6405,15 @@ mod tests {
.uri("/api/mnote/tools/call") .uri("/api/mnote/tools/call")
.header("content-type", "application/json") .header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from( .body(Body::from(
json!({ json!({
"toolName": "mnote.doc.markdown_edit", "toolName": "mnote.doc.markdown_edit",
"workspaceId": "ws_demo", "workspaceId": "local-ws-dry-run",
"documentId": path.to_string_lossy(), "documentId": "local-md:page.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_1", "sessionId": "sess_1",
"runId": "run_1", "runId": "run_1",
"toolCallId": "call_1", "toolCallId": "call_1",
@@ -6260,6 +6421,10 @@ mod tests {
"idempotencyKey": "idem_markdown_local_1", "idempotencyKey": "idem_markdown_local_1",
"dryRun": true, "dryRun": true,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:page.md"]
},
"operations": [{"search": "第二段", "replace": "测试123"}] "operations": [{"search": "第二段", "replace": "测试123"}]
} }
}) })
@@ -6277,10 +6442,9 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json"); let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["audit"]["effect"], "dry_run"); assert_eq!(payload["audit"]["effect"], "dry_run");
assert_eq!(payload["result"]["operationsApplied"], 1); assert_eq!(payload["result"]["operationsApplied"], 1);
assert_eq!(payload["result"]["applyResult"]["written"], false); // dry-run 不得改盘
assert_eq!(payload["result"]["applyResult"]["dryRun"], true);
assert_eq!( assert_eq!(
fs::read_to_string(&path).expect("read markdown"), fs::read_to_string(root.join("page.md")).expect("read markdown"),
"第一段\n\n第二段\n" "第一段\n\n第二段\n"
); );
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
@@ -6475,6 +6639,10 @@ mod tests {
"idempotencyKey": "idem_markdown_mapping_empty_2", "idempotencyKey": "idem_markdown_mapping_empty_2",
"dryRun": false, "dryRun": false,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段 <!-- block:p_2 -->", "replace": "测试123"}] "operations": [{"search": "第二段 <!-- block:p_2 -->", "replace": "测试123"}]
} }
}) })
@@ -6519,6 +6687,10 @@ mod tests {
"idempotencyKey": "idem_markdown_full_content_online_2", "idempotencyKey": "idem_markdown_full_content_online_2",
"dryRun": false, "dryRun": false,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"full_content": "章节一\n\n第一段已改\n\n第二段已改" "full_content": "章节一\n\n第一段已改\n\n第二段已改"
} }
}) })
@@ -6562,6 +6734,10 @@ mod tests {
"idempotencyKey": "idem_markdown_normalized_1", "idempotencyKey": "idem_markdown_normalized_1",
"dryRun": true, "dryRun": true,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二 段", "replace": "测试123"}] "operations": [{"search": "第二 段", "replace": "测试123"}]
} }
}) })
@@ -6612,6 +6788,10 @@ mod tests {
"idempotencyKey": "idem_markdown_precondition", "idempotencyKey": "idem_markdown_precondition",
"dryRun": false, "dryRun": false,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段", "replace": "测试123"}] "operations": [{"search": "第二段", "replace": "测试123"}]
} }
}) })
@@ -6676,7 +6856,11 @@ mod tests {
"idempotencyKey": "idem_local_folder_md", "idempotencyKey": "idem_local_folder_md",
"dryRun": false, "dryRun": false,
"args": { "args": {
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}] "operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
} }
}) })
.to_string(), .to_string(),
@@ -6730,6 +6914,10 @@ mod tests {
"idempotencyKey": "idem_markdown_noop", "idempotencyKey": "idem_markdown_noop",
"dryRun": false, "dryRun": false,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "不存在的段落", "replace": "测试123"}] "operations": [{"search": "不存在的段落", "replace": "测试123"}]
} }
}) })
@@ -6771,6 +6959,10 @@ mod tests {
"idempotencyKey": "idem_markdown_scope", "idempotencyKey": "idem_markdown_scope",
"dryRun": true, "dryRun": true,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"allowedTargetBlockIds": ["p_2"], "allowedTargetBlockIds": ["p_2"],
"operations": [{"search": "第一段", "replace": "不应越权修改"}] "operations": [{"search": "第一段", "replace": "不应越权修改"}]
} }
@@ -6813,6 +7005,10 @@ mod tests {
"idempotencyKey": "idem_markdown_same_block_1", "idempotencyKey": "idem_markdown_same_block_1",
"dryRun": true, "dryRun": true,
"args": { "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [ "operations": [
{"search": "", "replace": "2"}, {"search": "", "replace": "2"},
{"search": "2段", "replace": "2段落"} {"search": "2段", "replace": "2段落"}
@@ -6861,7 +7057,11 @@ mod tests {
"traceId": "trace_1", "traceId": "trace_1",
"idempotencyKey": "idem_options_1", "idempotencyKey": "idem_options_1",
"dryRun": true, "dryRun": true,
"args": {"options": {"wideLayout": true, "pageFont": "serif"}} "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"options": {"wideLayout": true, "pageFont": "serif"}}
}) })
.to_string(), .to_string(),
)) ))
@@ -6904,7 +7104,11 @@ mod tests {
"traceId": "trace_1", "traceId": "trace_1",
"idempotencyKey": "idem_summary_1", "idempotencyKey": "idem_summary_1",
"dryRun": true, "dryRun": true,
"args": {"summary": "摘要内容"} "args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"summary": "摘要内容"}
}) })
.to_string(), .to_string(),
)) ))
@@ -6958,7 +7162,13 @@ mod tests {
"traceId": "trace_artifact_local", "traceId": "trace_artifact_local",
"idempotencyKey": "idem_artifact_local", "idempotencyKey": "idem_artifact_local",
"dryRun": false, "dryRun": false,
"args": {"summary": "本地摘要"} "args": {
"summary": "本地摘要",
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
}) })
.to_string(), .to_string(),
)) ))
+57 -3
View File
@@ -1,5 +1,7 @@
mod ai_settings; mod ai_settings;
pub(crate) mod api_access_token;
mod bridge; mod bridge;
pub(crate) mod local_agent_install;
pub(crate) mod command_support; pub(crate) mod command_support;
mod compat; mod compat;
pub(crate) mod dev_hot; pub(crate) mod dev_hot;
@@ -49,9 +51,10 @@ mod ws;
pub(crate) use gateway::current_actor_id; pub(crate) use gateway::current_actor_id;
pub(crate) use local_folder_source::{ pub(crate) use local_folder_source::{
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access, control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state, ensure_local_path_write_access, ensure_local_workspace_access,
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key, ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
local_workspace_id_from_root_uri, update_local_markdown_title, write_local_markdown_page_body, local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
}; };
#[cfg(test)] #[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings; pub(crate) use local_search_index::write_local_index_settings;
@@ -379,6 +382,26 @@ pub fn build_router(state: AppState) -> Router {
"/api/vault/items/{id}/session", "/api/vault/items/{id}/session",
put(vault::put_item_session), put(vault::put_item_session),
) )
.route(
"/api/vault/ai/token",
post(vault::issue_agent_vault_token),
)
.route(
"/api/vault/ai/token/install",
post(vault::install_agent_vault_token),
)
.route(
"/api/vault/ai/token/uninstall",
post(vault::uninstall_agent_vault_token),
)
.route(
"/api/vault/ai/token/local",
get(vault::list_local_agent_vault_tokens),
)
.route(
"/api/vault/ai/token/local/{subject}",
get(vault::detail_local_agent_vault_token),
)
.route( .route(
"/api/vault/extension/token", "/api/vault/extension/token",
post(vault::issue_extension_token), post(vault::issue_extension_token),
@@ -388,6 +411,10 @@ pub fn build_router(state: AppState) -> Router {
post(vault::revoke_extension_token), post(vault::revoke_extension_token),
) )
.route("/api/vault/ai/list", get(vault::list_ai)) .route("/api/vault/ai/list", get(vault::list_ai))
.route(
"/api/vault/ai/repair-folders",
post(vault::repair_ai_folders),
)
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item)) .route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route( .route(
"/api/vault/ai/items/{id}/resolve", "/api/vault/ai/items/{id}/resolve",
@@ -546,6 +573,32 @@ pub fn build_router(state: AppState) -> Router {
) )
.route("/api/ai-settings/receipts", get(ai_settings::user_receipts)) .route("/api/ai-settings/receipts", get(ai_settings::user_receipts))
.route("/api/ai-admin/receipts", get(ai_settings::admin_receipts)) .route("/api/ai-admin/receipts", get(ai_settings::admin_receipts))
// 7-76 Web PAT 管理
.route(
"/api/ai-tokens",
get(api_access_token::list_tokens).post(api_access_token::create_token),
)
.route(
"/api/ai-tokens/{id}/reveal",
post(api_access_token::reveal_token),
)
.route(
"/api/ai-tokens/{id}/revoke",
post(api_access_token::revoke_token),
)
.route(
"/api/ai-tokens/{id}",
axum::routing::patch(api_access_token::rename_token)
.delete(api_access_token::delete_token),
)
.route(
"/api/ai-tokens/{id}/install",
post(api_access_token::install_token),
)
.route(
"/api/ai-tokens/{id}/uninstall",
post(api_access_token::uninstall_token),
)
.route( .route(
"/api/ai-admin/settings", "/api/ai-admin/settings",
get(ai_settings::admin_get_settings).put(ai_settings::admin_put_settings), get(ai_settings::admin_get_settings).put(ai_settings::admin_put_settings),
@@ -854,6 +907,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -227,18 +227,20 @@ fn ensure_navigation_recent_target_exists(
} }
fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> { fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
let relative_path = normalize_navigation_relative_path(Some(relative_path))? // root 与 target 都 canonicalize,避免 symlink / 未规范化 root 导致 starts_with 误判。
let canonical_root = root_path.canonicalize().unwrap_or_else(|_| root_path.to_path_buf());
let joined = normalize_navigation_relative_path(Some(relative_path))?
.as_deref() .as_deref()
.map(Path::new) .map(Path::new)
.map(|path| root_path.join(path)) .map(|path| canonical_root.join(path))
.unwrap_or_else(|| root_path.to_path_buf()); .unwrap_or_else(|| canonical_root.clone());
let canonical_target = relative_path.canonicalize().map_err(|error| { let canonical_target = joined.canonicalize().map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
"navigation_recent_target_unavailable", "navigation_recent_target_unavailable",
format!("最近访问目标不可用: {error}"), format!("最近访问目标不可用: {error}"),
) )
})?; })?;
if !canonical_target.starts_with(root_path) { if !canonical_target.starts_with(&canonical_root) {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"navigation_recent_root_escape", "navigation_recent_root_escape",
"最近访问路径不能越过授权目录", "最近访问路径不能越过授权目录",
@@ -379,6 +381,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}) })
} }
+167 -21
View File
@@ -3,8 +3,8 @@ use crate::app::AppConfig;
use crate::app::AppState; use crate::app::AppState;
use crate::error::WebError; use crate::error::WebError;
use adapter_onlyoffice::{ use adapter_onlyoffice::{
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput, extract_callback_jwt, prepare_callback, prepare_proxy_request, sign_config,
OnlyOfficeProxyPreparationInput, verify_callback_jwt, OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput,
}; };
use axum::body::{Body, Bytes}; use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
@@ -1025,24 +1025,106 @@ fn resolve_onlyoffice_local_file_path(
"本地文件路径不能越过 root", "本地文件路径不能越过 root",
)); ));
} }
let target = canonical_root let joined = canonical_root.join(requested);
.join(requested) // 拒绝目标本身为 symlink,降低 canonicalize 与 open 之间的 TOCTOU 替换窗口。
.canonicalize() match fs::symlink_metadata(&joined) {
.map_err(|error| { Ok(meta) if meta.file_type().is_symlink() => {
WebError::bad_request_code( return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
Ok(meta) if !meta.is_file() => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_not_file",
"本地文件路径必须指向普通文件",
));
}
Ok(_) => {}
Err(error) => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_not_found", "onlyoffice_local_file_not_found",
format!("找不到本地文件: {error}"), format!("找不到本地文件: {error}"),
) ));
})?; }
}
let target = joined.canonicalize().map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_not_found",
format!("找不到本地文件: {error}"),
)
})?;
if !target.starts_with(&canonical_root) || !target.is_file() { if !target.starts_with(&canonical_root) || !target.is_file() {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"onlyoffice_local_file_root_escape", "onlyoffice_local_file_root_escape",
"本地文件路径不能越过 root", "本地文件路径不能越过 root",
)); ));
} }
// 二次确认 canonicalize 后路径仍不是 symlink(竞态窗口内被替换)。
if let Ok(meta) = fs::symlink_metadata(&target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
Ok(target) Ok(target)
} }
/// 读取本地 OnlyOffice 文件:resolve 后再以 symlink_metadata 校验并 open,缩小 TOCTOU 窗口。
fn read_onlyoffice_local_file_bytes(target: &FsPath) -> Result<Vec<u8>, WebError> {
if let Ok(meta) = fs::symlink_metadata(target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
fs::read(target).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_read_failed",
format!("无法读取本地文件: {error}"),
)
})
}
/// 写回本地 OnlyOffice 文件:写前拒绝 symlink,避免 TOCTOU 导向 root 外。
fn write_onlyoffice_local_file_bytes(target: &FsPath, bytes: &[u8]) -> Result<(), WebError> {
if let Ok(meta) = fs::symlink_metadata(target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
fs::write(target, bytes).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})
}
/// 当配置了 ONLYOFFICE_JWT_SECRET 时,强制校验回调 JWT;未配置时与 sign 一致放行。
fn enforce_onlyoffice_callback_jwt(
body: &Value,
authorization: Option<&str>,
) -> Result<(), WebError> {
let secret = env_or_dotenv("ONLYOFFICE_JWT_SECRET").unwrap_or_default();
let token = extract_callback_jwt(body, authorization);
verify_callback_jwt(token.as_deref(), &secret).map_err(|error| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_callback_jwt_invalid",
error,
)
})?;
Ok(())
}
fn onlyoffice_content_type_for_path(path: &FsPath) -> HeaderValue { fn onlyoffice_content_type_for_path(path: &FsPath) -> HeaderValue {
let extension = path let extension = path
.extension() .extension()
@@ -1111,12 +1193,7 @@ fn proxy_local_folder_file_open(
let mut response = if *method == Method::HEAD { let mut response = if *method == Method::HEAD {
Response::new(Body::empty()) Response::new(Body::empty())
} else { } else {
let bytes = fs::read(&target).map_err(|error| { let bytes = read_onlyoffice_local_file_bytes(&target)?;
WebError::bad_request_code(
"onlyoffice_local_file_read_failed",
format!("无法读取本地文件: {error}"),
)
})?;
Response::new(Body::from(bytes)) Response::new(Body::from(bytes))
}; };
response.headers_mut().insert( response.headers_mut().insert(
@@ -1297,12 +1374,7 @@ async fn local_folder_onlyoffice_callback(
) )
})?; })?;
let bytes = download_onlyoffice_callback_body(download_url).await?; let bytes = download_onlyoffice_callback_body(download_url).await?;
fs::write(&target, &bytes).map_err(|error| { write_onlyoffice_local_file_bytes(&target, &bytes)?;
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})?;
Ok(onlyoffice_callback_success(json!({ Ok(onlyoffice_callback_success(json!({
"localWrite": true, "localWrite": true,
"bytes": bytes.len(), "bytes": bytes.len(),
@@ -1312,9 +1384,16 @@ async fn local_folder_onlyoffice_callback(
pub async fn callback( pub async fn callback(
State(state): State<AppState>, State(state): State<AppState>,
uri: Uri, uri: Uri,
headers: HeaderMap,
Query(query): Query<OnlyOfficeCallbackQuery>, Query(query): Query<OnlyOfficeCallbackQuery>,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Response { ) -> Response {
let authorization = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
if let Err(error) = enforce_onlyoffice_callback_jwt(&body, authorization) {
return onlyoffice_callback_failure(error);
}
let status = body let status = body
.get("status") .get("status")
.and_then(Value::as_i64) .and_then(Value::as_i64)
@@ -2008,6 +2087,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}) })
} }
@@ -2040,6 +2120,7 @@ mod tests {
"/api/onlyoffice/callback?assetId=asset_1" "/api/onlyoffice/callback?assetId=asset_1"
.parse::<Uri>() .parse::<Uri>()
.expect("uri"), .expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery { Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()), asset_id: Some("asset_1".into()),
user_id: None, user_id: None,
@@ -2083,6 +2164,7 @@ mod tests {
) )
.parse::<Uri>() .parse::<Uri>()
.expect("uri"), .expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery { Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()), asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None, user_id: None,
@@ -2149,6 +2231,7 @@ mod tests {
) )
.parse::<Uri>() .parse::<Uri>()
.expect("uri"), .expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery { Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()), asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None, user_id: None,
@@ -2210,6 +2293,7 @@ mod tests {
) )
.parse::<Uri>() .parse::<Uri>()
.expect("uri"), .expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery { Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()), asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None, user_id: None,
@@ -2277,6 +2361,7 @@ mod tests {
) )
.parse::<Uri>() .parse::<Uri>()
.expect("uri"), .expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery { Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()), asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None, user_id: None,
@@ -2350,6 +2435,7 @@ mod tests {
) )
.parse::<Uri>() .parse::<Uri>()
.expect("uri"), .expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery { Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()), asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None, user_id: None,
@@ -2379,6 +2465,7 @@ mod tests {
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1" "/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
.parse::<Uri>() .parse::<Uri>()
.expect("uri"), .expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery { Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()), asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()), user_id: Some("user_1".into()),
@@ -2534,4 +2621,63 @@ mod tests {
assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__")); assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__"));
assert!(html.contains("window.location.replace(editHref);")); assert!(html.contains("window.location.replace(editHref);"));
} }
#[tokio::test]
async fn onlyoffice_callback_rejects_invalid_jwt_when_secret_configured() {
std::env::set_var("ONLYOFFICE_JWT_SECRET", "test-onlyoffice-jwt-secret");
let response = callback(
State(test_state(None)),
"/api/onlyoffice/callback?assetId=asset_1"
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
session_id: None,
token: None,
root_uri: None,
path: None,
}),
Json(json!({
"status": 2,
"url": "http://127.0.0.1:8082/cache/files/out.docx",
"token": "not.a.valid.jwt"
})),
)
.await;
std::env::remove_var("ONLYOFFICE_JWT_SECRET");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 1);
assert_eq!(payload["code"], "onlyoffice_callback_jwt_invalid");
}
#[test]
fn resolve_onlyoffice_local_file_path_rejects_symlink_target() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-symlink-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let real = root.join("Page").join("real.docx");
fs::write(&real, b"real").expect("write real");
let link = root.join("Page").join("link.docx");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let err = resolve_onlyoffice_local_file_path(
&format!("file://{}", root.display()),
"Page/link.docx",
)
.expect_err("symlink should fail");
assert_eq!(err.code(), "onlyoffice_local_file_symlink_forbidden");
}
let _ = fs::remove_dir_all(&root);
}
} }
@@ -1345,16 +1345,18 @@ fn render_plugin_index(
async function loop() {{ async function loop() {{
while (!stopped) {{ while (!stopped) {{
// 每次迭代用 let 限定 command,避免 var 提升导致 catch 误报上一轮成功命令
let command = null;
try {{ try {{
var query = new URLSearchParams({{ const query = new URLSearchParams({{
sessionId: sessionId, sessionId: sessionId,
token: bridgeToken, token: bridgeToken,
timeoutMs: "25000" timeoutMs: "25000"
}}); }});
var response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString()); const response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString());
if (response.status === 204) continue; if (response.status === 204) continue;
var command = await response.json(); command = await response.json();
var result = await executeCommand(command); const result = await executeCommand(command);
await postJson("/api/onlyoffice/bridge/results", {{ await postJson("/api/onlyoffice/bridge/results", {{
sessionId: sessionId, sessionId: sessionId,
token: bridgeToken, token: bridgeToken,
@@ -1902,8 +1904,32 @@ fn ensure_session_token(session_id: &str, token: Option<&str>) -> Result<(), Res
} }
} }
/// 将字符串序列化为可安全嵌入 `<script>` 的 JSON 字面量。
/// `serde_json` 只做 JS 字符串转义,不会打断 HTML 解析器对 `</script>` 的识别,
/// 因此必须额外把 `</script`(大小写不敏感)写成 `<\\/script`。
fn json_string(input: &str) -> String { fn json_string(input: &str) -> String {
serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into()) let encoded = serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into());
escape_script_json_literal(&encoded)
}
fn escape_script_json_literal(value: &str) -> String {
let lower = value.to_ascii_lowercase();
let needle = b"</script";
let mut out = String::with_capacity(value.len());
let bytes = value.as_bytes();
let lower_bytes = lower.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + needle.len() <= lower_bytes.len() && &lower_bytes[i..i + needle.len()] == needle {
out.push_str("<\\/script");
i += needle.len();
continue;
}
let ch = value[i..].chars().next().expect("valid utf-8 offset");
out.push(ch);
i += ch.len_utf8();
}
out
} }
fn now_millis() -> u128 { fn now_millis() -> u128 {
@@ -1993,6 +2019,56 @@ mod tests {
assert!(html.contains(r#"pageOrigin: "http://127.0.0.1:3001""#)); assert!(html.contains(r#"pageOrigin: "http://127.0.0.1:3001""#));
} }
#[test]
fn json_string_escapes_script_close_tag_case_insensitive() {
// 与 web_shell::escape_script_json 一致:匹配大小写不敏感,输出统一为 <\/script
let escaped = r#"<\/script>"#;
assert_eq!(
json_string(r#"</script><img onerror=alert(1)>"#),
format!(r#""{escaped}<img onerror=alert(1)>""#)
);
assert_eq!(
json_string(r#"</SCRIPT>alert(1)"#),
format!(r#""{escaped}alert(1)""#)
);
assert_eq!(
json_string(r#"</ScRiPt>x"#),
format!(r#""{escaped}x""#)
);
assert_eq!(json_string(r#"ok-value"#), r#""ok-value""#);
}
#[tokio::test]
async fn bridge_plugin_index_escapes_script_breaking_payloads() {
let response = plugin_index(Query(BridgePluginIndexQuery {
session_id: Some(r#"x</script><script>alert(1)</script>"#.into()),
api_base: Some("http://127.0.0.1:3000".into()),
token: Some("token-safe".into()),
document_id: Some(r#"doc</ScRiPt>x"#.into()),
asset_id: None,
file_type: None,
doc_key: None,
page_origin: None,
}))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
// 原始 `</script>` 不得出现在注入字面量位置(允许外层 HTML 的正常闭合标签)
assert!(!html.contains(r#"var sessionId = "x</script>"#));
assert!(!html.contains(r#"documentId: "doc</ScRiPt>"#));
assert!(!html.contains(r#"documentId: "doc</script>"#));
// 大小写变体统一转义为 <\/script(小写),打断 HTML 解析器
assert!(html.contains(r#"var sessionId = "x<\/script><script>alert(1)<\/script>";"#));
assert!(html.contains(r#"documentId: "doc<\/script>x""#));
// 每次迭代用 let command,避免 var 提升残留
assert!(html.contains("let command = null;"));
assert!(!html.contains("var command = await response.json()"));
}
#[tokio::test] #[tokio::test]
async fn bridge_plugin_index_exposes_second_batch_recipe_actions() { async fn bridge_plugin_index_exposes_second_batch_recipe_actions() {
let response = plugin_index(Query(BridgePluginIndexQuery { let response = plugin_index(Query(BridgePluginIndexQuery {
@@ -27,7 +27,8 @@ use std::fs::{self, OpenOptions};
use std::io::{Read as _, Write as _}; use std::io::{Read as _, Write as _};
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use sha2::{Digest, Sha256};
use std::process::Stdio; use std::process::Stdio;
use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -379,18 +380,28 @@ fn generate_id(prefix: &str) -> String {
format!("{prefix}_{:x}_{:x}", now_ms(), random_suffix()) format!("{prefix}_{:x}_{:x}", now_ms(), random_suffix())
} }
fn generate_bridge_token() -> String { fn fill_secure_random(buf: &mut [u8]) -> bool {
let mut bytes = [0_u8; 24]; // bridge token / 安全凭证:只接受 CSPRNG。Linux/Unix 用 /dev/urandom。
if let Ok(mut random) = fs::File::open("/dev/urandom") { #[cfg(unix)]
if random.read_exact(&mut bytes).is_ok() { {
let mut encoded = String::with_capacity(bytes.len() * 2); if let Ok(mut random) = fs::File::open("/dev/urandom") {
for byte in bytes { if random.read_exact(buf).is_ok() {
encoded.push_str(&format!("{byte:02x}")); return true;
} }
return format!("pi_bridge_{encoded}");
} }
} }
generate_id("pi_bridge_fallback") false
}
fn generate_bridge_token() -> String {
let mut bytes = [0_u8; 32];
// 两次尝试 urandom;仍失败则 panic,禁止发出可预测 bridge token。
if !fill_secure_random(&mut bytes) && !fill_secure_random(&mut bytes) {
panic!(
"generate_bridge_token: 无法从 CSPRNG 读取随机字节,拒绝发出可预测 bridge token"
);
}
format!("pi_bridge_{}", hex::encode(bytes))
} }
fn random_suffix() -> u64 { fn random_suffix() -> u64 {
@@ -438,15 +449,17 @@ fn pi_lab_tool_params_hash(params: &Value) -> String {
map.remove("mnoteApproval"); map.remove("mnoteApproval");
map.remove("mnote_approval"); map.remove("mnote_approval");
} }
format!("{:x}", stable_hash(&canonical_json_string(&normalized))) // approval 参数匹配必须抗碰撞;DJB2/32-bit 不够。使用 SHA-256 hex。
let digest = Sha256::digest(canonical_json_string(&normalized).as_bytes());
hex::encode(digest)
} }
fn stable_hash(value: &str) -> u64 { fn stable_hash(value: &str) -> u64 {
let mut hash = 5381_u32; // 仅用于 MCP cache 变更检测等非安全场景;取 SHA-256 前 8 字节作 64-bit 指纹。
for unit in value.encode_utf16() { let digest = Sha256::digest(value.as_bytes());
hash = ((hash << 5).wrapping_add(hash)).wrapping_add(u32::from(unit)); let mut bytes = [0_u8; 8];
} bytes.copy_from_slice(&digest[..8]);
u64::from(hash) u64::from_be_bytes(bytes)
} }
fn approval_key(session_id: &str, approval_id: &str) -> String { fn approval_key(session_id: &str, approval_id: &str) -> String {
@@ -634,22 +647,63 @@ fn root_can_write(root: &AllowedRoot) -> bool {
permission.contains("write") || permission == "owner" permission.contains("write") || permission == "owner"
} }
fn canonical_or_parent(path: &Path) -> PathBuf { /// 词法归一化路径分量:解析 `.` / `..`,避免未 canonicalize 时 `starts_with` 被 `..` 绕过。
if let Ok(canonical) = path.canonicalize() { fn lexically_normalize(path: &Path) -> PathBuf {
return canonical; let mut out = PathBuf::new();
} for component in path.components() {
if let Some(parent) = path.parent() { match component {
if let Ok(canonical_parent) = parent.canonicalize() { Component::Prefix(prefix) => out.push(prefix.as_os_str()),
return canonical_parent.join(path.file_name().unwrap_or_default()); Component::RootDir => out.push(Component::RootDir.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir) | Some(Component::Prefix(_)) => {
// 已在文件系统根,忽略继续上溯
}
Some(Component::ParentDir) | Some(Component::CurDir) | None => {
// 相对路径上溢:保留 `..`,后续 path_is_inside 会拒绝
out.push("..");
}
}
}
Component::Normal(part) => out.push(part),
} }
} }
path.to_path_buf() out
}
fn path_contains_parent_dir(path: &Path) -> bool {
path.components()
.any(|component| matches!(component, Component::ParentDir))
}
fn canonical_or_parent(path: &Path) -> PathBuf {
// 先词法归一化,确保即便 canonicalize 失败也不会把含 `..` 的原始路径交给 starts_with。
let normalized = lexically_normalize(path);
if let Ok(canonical) = normalized.canonicalize() {
return canonical;
}
if let Some(parent) = normalized.parent() {
if !path_contains_parent_dir(parent) {
if let Ok(canonical_parent) = parent.canonicalize() {
return canonical_parent.join(normalized.file_name().unwrap_or_default());
}
}
}
normalized
} }
fn path_is_inside(path: &Path, root: &Path) -> bool { fn path_is_inside(path: &Path, root: &Path) -> bool {
let canonical_path = canonical_or_parent(path); let canonical_path = lexically_normalize(&canonical_or_parent(path));
let canonical_root = canonical_or_parent(root); let canonical_root = lexically_normalize(&canonical_or_parent(root));
canonical_path.starts_with(canonical_root) // 任一端仍含未解析的 `..` → 拒绝(fail-closed
if path_contains_parent_dir(&canonical_path) || path_contains_parent_dir(&canonical_root) {
return false;
}
canonical_path.starts_with(&canonical_root)
} }
fn resolve_root_relative_path( fn resolve_root_relative_path(
@@ -813,28 +867,71 @@ fn managed_session_dir(
session_id: &str, session_id: &str,
) -> Result<PathBuf, WebError> { ) -> Result<PathBuf, WebError> {
let actor = pi_lab_actor_segment(&context.auth.actor_id); let actor = pi_lab_actor_segment(&context.auth.actor_id);
// session_id 来自客户端;必须消毒,禁止 `..` / 路径分隔符跳出 pi-sessions 目录。
let session_seg = pi_lab_path_segment(session_id, "session");
if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) { if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) {
return Ok(root_path let dir = root_path
.join(".mnote") .join(".mnote")
.join("ai") .join("ai")
.join("pi-sessions") .join("pi-sessions")
.join(actor) .join(&actor)
.join(session_id)); .join(&session_seg);
// 防御:归一化后必须仍落在 pi-sessions/<actor>/ 下
let base = root_path
.join(".mnote")
.join("ai")
.join("pi-sessions")
.join(&actor);
let normalized = lexically_normalize(&dir);
if path_contains_parent_dir(&normalized) || !normalized.starts_with(&base) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_session_id",
"sessionId 非法,禁止路径穿越",
));
}
return Ok(dir);
} }
Ok(std::env::temp_dir() let dir = std::env::temp_dir()
.join("mnote-web") .join("mnote-web")
.join("pi-lab") .join("pi-lab")
.join(actor) .join(&actor)
.join(session_id)) .join(&session_seg);
let base = std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join(&actor);
let normalized = lexically_normalize(&dir);
if path_contains_parent_dir(&normalized) || !normalized.starts_with(&base) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_session_id",
"sessionId 非法,禁止路径穿越",
));
}
Ok(dir)
}
fn pi_lab_path_segment(raw: &str, fallback: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return fallback.to_string();
}
// 只保留安全文件名字符;拒绝 `..` 与路径分隔。
let mut out = String::with_capacity(trimmed.len().min(128));
for ch in trimmed.chars().take(128) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return fallback.to_string();
}
out
} }
fn pi_lab_actor_segment(actor_id: &str) -> String { fn pi_lab_actor_segment(actor_id: &str) -> String {
let actor = actor_id.trim().replace(['/', '\\', ':'], "_"); pi_lab_path_segment(actor_id, "anonymous")
if actor.is_empty() {
"anonymous".into()
} else {
actor
}
} }
fn is_pi_lab_warmup_session_id(session_id: &str) -> bool { fn is_pi_lab_warmup_session_id(session_id: &str) -> bool {
@@ -1569,12 +1666,20 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
} }
} }
fn is_vault_tool_name(tool_name: &str) -> bool {
matches!(
tool_name,
"mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session"
| "mnote.vault.list"
| "mnote.vault.get"
) || tool_name.starts_with("mnote.vault.")
}
/// Strip secret values before control-plane receipt persistence. /// Strip secret values before control-plane receipt persistence.
fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value { fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
if tool_name != "mnote.vault.resolve" if !is_vault_tool_name(tool_name) {
&& tool_name != "mnote.vault.login"
&& tool_name != "mnote.vault.session"
{
return payload.clone(); return payload.clone();
} }
let mut safe = payload.clone(); let mut safe = payload.clone();
@@ -1597,6 +1702,24 @@ fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
safe safe
} }
/// vault 工具的 receipt 元字段(diff/version 摘要)可能间接含密文路径或值片段。
fn redact_vault_receipt_meta(
tool_name: &str,
diff_summary: Option<String>,
before_file_version: Option<String>,
after_file_version: Option<String>,
) -> (Option<String>, Option<String>, Option<String>) {
if !is_vault_tool_name(tool_name) {
return (diff_summary, before_file_version, after_file_version);
}
(
diff_summary.map(|_| "[redacted-vault-meta]".into()),
// file_version 通常是内容哈希;vault 工具侧仍 fail-closed 不落盘自由文本摘要
before_file_version.map(|_| "[redacted]".into()),
after_file_version.map(|_| "[redacted]".into()),
)
}
fn session_permission_mode(session: &PiLabSession) -> Option<&str> { fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
session session
.runtime_policy_snapshot .runtime_policy_snapshot
@@ -2045,7 +2168,11 @@ fn schedule_mcp_cache_sync(session_id: String, config_dir: PathBuf, shared_cache
} }
fn shell_glob_escape_path(path: &str) -> String { fn shell_glob_escape_path(path: &str) -> String {
// 路径写入 permission JSON 的 path 规则时,必须把 glob 元字符转义,
// 否则目录名含 `*`/`?` 会被当成通配,扩大到 sibling 路径。
path.replace('\\', "\\\\") path.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('?', "\\?")
.replace('[', "\\[") .replace('[', "\\[")
.replace(']', "\\]") .replace(']', "\\]")
.replace('{', "\\{") .replace('{', "\\{")
@@ -2463,7 +2590,8 @@ fn pi_lab_session_tool_policy(session: &PiLabSession, tool_name: &str) -> String
.and_then(|policies| policies.get(tool_name)) .and_then(|policies| policies.get(tool_name))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::to_string) .map(str::to_string)
.unwrap_or_else(|| "allow".into()); // 策略缺失时 fail-closed:禁止默认 allow 绕过权限。
.unwrap_or_else(|| "deny".into());
permission_mode_tool_policy(session_permission_mode(session), tool_name, &base_policy) permission_mode_tool_policy(session_permission_mode(session), tool_name, &base_policy)
} }
@@ -2616,12 +2744,13 @@ fn pi_lab_tool_approval_confirmed(
if !confirmed { if !confirmed {
return false; return false;
} }
// fail-closedapproval 未带 toolName 时不得放行(避免跨工具复用 approval)
if !approval if !approval
.get("toolName") .get("toolName")
.or_else(|| approval.get("tool_name")) .or_else(|| approval.get("tool_name"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(|name| name == tool_name) .map(|name| name == tool_name)
.unwrap_or(true) .unwrap_or(false)
{ {
return false; return false;
} }
@@ -3324,16 +3453,48 @@ fn cleanup_expired_sessions() {
} }
for (_, session_dir) in expired { for (_, session_dir) in expired {
let path = PathBuf::from(session_dir); let path = PathBuf::from(session_dir);
if path if is_safe_pi_session_dir_to_delete(&path) {
.components() let _ = fs::remove_dir_all(&path);
.any(|component| component.as_os_str() == "pi-sessions")
{
let _ = fs::remove_dir_all(path);
} }
} }
} }
} }
/// 过期会话目录删除守卫:词法归一化后不得含 `..`,且路径段必须匹配
/// `…/.mnote/ai/pi-sessions/…` 或 `…/mnote-web/pi-lab/…`;若路径已存在则再对
/// canonicalize 结果复检,防止 `pi-sessions/../../etc` 类穿越。
fn is_safe_pi_session_dir_to_delete(path: &Path) -> bool {
fn looks_like_managed_session_dir(p: &Path) -> bool {
let comps: Vec<_> = p
.components()
.filter_map(|c| c.as_os_str().to_str().map(str::to_string))
.collect();
// workspace 托管:`.mnote/ai/pi-sessions`
if comps.windows(3).any(|w| {
w[0] == ".mnote" && w[1] == "ai" && w[2] == "pi-sessions"
}) {
return true;
}
// 无 root_uri 时的临时目录:`mnote-web/pi-lab`
comps
.windows(2)
.any(|w| w[0] == "mnote-web" && w[1] == "pi-lab")
}
let normalized = lexically_normalize(path);
if path_contains_parent_dir(&normalized) || !looks_like_managed_session_dir(&normalized) {
return false;
}
match fs::canonicalize(path) {
Ok(canon) => {
let cnorm = lexically_normalize(&canon);
!path_contains_parent_dir(&cnorm) && looks_like_managed_session_dir(&cnorm)
}
// 目录不存在:无需删除;canonicalize 失败(权限等)→ fail-closed 不删
Err(_) => false,
}
}
fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), WebError> { fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), WebError> {
let now = now_ms(); let now = now_ms();
let key = format!("{actor_id}:{action}"); let key = format!("{actor_id}:{action}");
@@ -4012,8 +4173,12 @@ async fn send_rpc_command_wait(
pending.insert(key.clone(), tx); pending.insert(key.clone(), tx);
} }
// Send command // Send command;失败时必须清理 pending,避免 map 泄漏
send_rpc_command(session_id, command).await?; if let Err(error) = send_rpc_command(session_id, command).await {
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.remove(&key);
return Err(error);
}
// Wait for response with timeout // Wait for response with timeout
let result = tokio::time::timeout(timeout, rx).await; let result = tokio::time::timeout(timeout, rx).await;
@@ -4429,11 +4594,10 @@ fn apply_single_operation(current: &str, op: &Value) -> Result<String, WebError>
} }
fn file_version(path: &Path) -> Option<String> { fn file_version(path: &Path) -> Option<String> {
// 跨进程稳定:DefaultHasher 每进程随机种子,会破坏持久化 fileVersion 可比性。
let bytes = fs::read(path).ok()?; let bytes = fs::read(path).ok()?;
use std::hash::{Hash, Hasher}; let digest = Sha256::digest(&bytes);
let mut hasher = std::collections::hash_map::DefaultHasher::new(); Some(hex::encode(digest))
bytes.hash(&mut hasher);
Some(format!("{:016x}", hasher.finish()))
} }
fn write_receipt( fn write_receipt(
@@ -4668,14 +4832,17 @@ fn mnote_repo_root() -> PathBuf {
} }
fn pi_codex_extra_writable_dirs(repo_root: &Path) -> Vec<PathBuf> { fn pi_codex_extra_writable_dirs(repo_root: &Path) -> Vec<PathBuf> {
[ // 禁止硬编码开发者家目录;仅允许:仓库内 skills、当前用户 HOME/XDG 下的 skills。
repo_root.join(".codex").join("skills"), let mut dirs = vec![repo_root.join(".codex").join("skills")];
PathBuf::from("/home/lix/.codex/skills"), if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
PathBuf::from("/home/lix/.agents/skills"), dirs.push(home.join(".codex").join("skills"));
] dirs.push(home.join(".agents").join("skills"));
.into_iter() dirs.push(home.join(".grok").join("skills"));
.filter(|path| path.exists()) }
.collect() if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) {
dirs.push(xdg.join("mnote").join("skills"));
}
dirs.into_iter().filter(|path| path.exists()).collect()
} }
impl PiLabToolFacade { impl PiLabToolFacade {
@@ -5297,7 +5464,8 @@ impl PiLabToolFacade {
let desired = string_param(&params, "desiredOutcome") let desired = string_param(&params, "desiredOutcome")
.or_else(|| string_param(&params, "goal")) .or_else(|| string_param(&params, "goal"))
.unwrap_or_else(|| "修复根因;如果不能安全修复,说明阻塞原因和下一步".into()); .unwrap_or_else(|| "修复根因;如果不能安全修复,说明阻塞原因和下一步".into());
let allow_writes = bool_param(&params, "allowWrites").unwrap_or(true); // 最小权限:未显式 allowWrites=true 时只读 sandbox,避免自救默认可写工作区
let allow_writes = bool_param(&params, "allowWrites").unwrap_or(false);
let timeout_secs = u64_param(&params, "timeoutSeconds") let timeout_secs = u64_param(&params, "timeoutSeconds")
.unwrap_or(300) .unwrap_or(300)
.clamp(60, 900); .clamp(60, 900);
@@ -5474,10 +5642,11 @@ async fn execute_tool(
session: session.clone(), session: session.clone(),
}; };
let started = now_ms(); let started = now_ms();
// 无会话上下文时 fail-closed:禁止默认 allow。
let tool_policy = session let tool_policy = session
.as_ref() .as_ref()
.map(|session| pi_lab_session_tool_policy(session, &tool_name)) .map(|session| pi_lab_session_tool_policy(session, &tool_name))
.unwrap_or_else(|| "allow".into()); .unwrap_or_else(|| "deny".into());
if tool_policy == "deny" { if tool_policy == "deny" {
return Err(WebError::new( return Err(WebError::new(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@@ -5589,6 +5758,13 @@ async fn execute_tool(
.map(|items| items.len()) .map(|items| items.len())
}) })
.unwrap_or(0); .unwrap_or(0);
// vault 工具:receipt / artifact / tool_call 元数据一并脱敏,避免 move 后仍用明文。
let (diff_summary, before_file_version, after_file_version) = redact_vault_receipt_meta(
&tool_name,
diff_summary,
before_file_version,
after_file_version,
);
let receipt = receipt_for( let receipt = receipt_for(
&context, &context,
session.as_ref(), session.as_ref(),
@@ -5714,75 +5890,60 @@ pub async fn status(
} }
cleanup_expired_sessions(); cleanup_expired_sessions();
let actor_id = ensure_authenticated(&state, &context)?; let actor_id = ensure_authenticated(&state, &context)?;
let sessions = PI_LAB_SESSIONS.lock().ok(); // 锁中毒 = 共享状态可能已损坏;禁止 .ok() 静默空态(fail-open)。
let current_session = sessions.as_ref().and_then(|sessions| { let sessions = PI_LAB_SESSIONS.lock().map_err(|_| {
sessions WebError::internal("PI_LAB_SESSIONS 锁中毒,共享状态可能已损坏")
.values() .with_context(&context)
.filter(|session| session.mnote_user_id == actor_id) })?;
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) let current_session = sessions
.filter(|session| session_can_auto_resume(session)) .values()
.max_by_key(|session| session.updated_at_ms) .filter(|session| session.mnote_user_id == actor_id)
.cloned() .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
}); .filter(|session| session_can_auto_resume(session))
let warmup_session = sessions.as_ref().and_then(|sessions| { .max_by_key(|session| session.updated_at_ms)
sessions .cloned();
.values() let warmup_session = sessions
.filter(|session| session.mnote_user_id == actor_id) .values()
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id)) .filter(|session| session.mnote_user_id == actor_id)
.filter(|session| session_can_auto_resume(session)) .filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.max_by_key(|session| session.updated_at_ms) .filter(|session| session_can_auto_resume(session))
.cloned() .max_by_key(|session| session.updated_at_ms)
}); .cloned();
let active_session_count = sessions let active_session_count = sessions
.as_ref() .values()
.map(|sessions| { .filter(|session| session.mnote_user_id == actor_id)
sessions .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.values() .count();
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.count()
})
.unwrap_or(0);
let owned_session_ids = sessions let owned_session_ids = sessions
.as_ref() .values()
.map(|sessions| { .filter(|session| session.mnote_user_id == actor_id)
sessions .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.values() .map(|session| session.session_id.clone())
.filter(|session| session.mnote_user_id == actor_id) .collect::<Vec<_>>();
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let owned_warmup_session_ids = sessions let owned_warmup_session_ids = sessions
.as_ref() .values()
.map(|sessions| { .filter(|session| session.mnote_user_id == actor_id)
sessions .filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.values() .map(|session| session.session_id.clone())
.filter(|session| session.mnote_user_id == actor_id) .collect::<Vec<_>>();
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id)) // 在同一把进程锁下统计,避免两次独立加锁导致状态窗口不一致。
.map(|session| session.session_id.clone()) let (process_count, warmup_process_count) = PI_LAB_PROCESSES
.collect::<Vec<_>>()
})
.unwrap_or_default();
let process_count = PI_LAB_PROCESSES
.lock() .lock()
.map(|processes| { .map(|processes| {
owned_session_ids let process_count = owned_session_ids
.iter() .iter()
.filter(|session_id| processes.contains_key(*session_id)) .filter(|session_id| processes.contains_key(*session_id))
.count() .count();
}) let warmup_process_count = owned_warmup_session_ids
.unwrap_or(0);
let warmup_process_count = PI_LAB_PROCESSES
.lock()
.map(|processes| {
owned_warmup_session_ids
.iter() .iter()
.filter(|session_id| processes.contains_key(*session_id)) .filter(|session_id| processes.contains_key(*session_id))
.count() .count();
(process_count, warmup_process_count)
}) })
.unwrap_or(0); .map_err(|_| {
WebError::internal("PI_LAB_PROCESSES 锁中毒,共享状态可能已损坏")
.with_context(&context)
})?;
let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) = let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) =
pi_runtime_status_snapshot(); pi_runtime_status_snapshot();
let runtime_error = current_session let runtime_error = current_session
@@ -5962,12 +6123,13 @@ pub async fn start(
} }
} }
} }
if kill_session_process(&requested_session.session_id).await { // 无论 kill 是否找到进程,都先把会话标为 Aborted,避免旧 runtime 状态残留后
update_session(&requested_session.session_id, |session| { // 与 start_runtime_for_session 并发。
session.status = PiLabSessionStatus::Aborted; let _ = kill_session_process(&requested_session.session_id).await;
session.runtime_pid = None; update_session(&requested_session.session_id, |session| {
}); session.status = PiLabSessionStatus::Aborted;
} session.runtime_pid = None;
});
let session = start_runtime_for_session(&state, requested_session).await?; let session = start_runtime_for_session(&state, requested_session).await?;
Ok(Json(pi_lab_start_response(&session, false))) Ok(Json(pi_lab_start_response(&session, false)))
} }
@@ -7471,7 +7633,8 @@ fn build_run_runtime_json(session: &PiLabSession) -> String {
"allowedRootsSnapshot": session.allowed_roots_snapshot, "allowedRootsSnapshot": session.allowed_roots_snapshot,
"runtimePolicy": session.runtime_policy_snapshot, "runtimePolicy": session.runtime_policy_snapshot,
})) }))
.unwrap_or_default() // 必须是合法 JSON:空串会让下游 `from_str` 失败;失败时回退 `{}`。
.unwrap_or_else(|_| "{}".to_string())
} }
fn build_upsert_run_input(session: &PiLabSession) -> UpsertAiRuntimeRunInput { fn build_upsert_run_input(session: &PiLabSession) -> UpsertAiRuntimeRunInput {
@@ -7550,11 +7713,9 @@ fn read_pi_session_jsonl(session: &PiLabSession) -> Result<Vec<Value>, WebError>
let path = Path::new(pi_session_file); let path = Path::new(pi_session_file);
// 安全验证:路径必须在 session pi_session_dir 内 // 安全验证:路径必须在 session pi_session_dir 内path_is_inside 含词法归一化 + `..` fail-closed
let session_dir = Path::new(&session.pi_session_dir); let session_dir = Path::new(&session.pi_session_dir);
let canonical_path = canonical_or_parent(path); if !path_is_inside(path, session_dir) {
let canonical_dir = canonical_or_parent(session_dir);
if !canonical_path.starts_with(&canonical_dir) {
return Err(WebError::new( return Err(WebError::new(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"page_ai_pi_lab_jsonl_path_escape", "page_ai_pi_lab_jsonl_path_escape",
@@ -8908,7 +9069,8 @@ pub async fn rename_session(
update_session(&path.session_id, |session| { update_session(&path.session_id, |session| {
session.page_title = Some(title.to_string()); session.page_title = Some(title.to_string());
}); });
let mut pi_rpc_response = Value::Null; // 不向客户端回传原始 Pi RPC 响应(可能含 runtime 内部状态);仅暴露是否成功。
let mut pi_rpc_ok = false;
if let Some(session) = get_session(&path.session_id) { if let Some(session) = get_session(&path.session_id) {
if session.runtime_mode != "mock" && session_runtime_is_usable(&session) { if session.runtime_mode != "mock" && session_runtime_is_usable(&session) {
if let Some(response) = send_rpc_command_wait( if let Some(response) = send_rpc_command_wait(
@@ -8922,7 +9084,10 @@ pub async fn rename_session(
) )
.await? .await?
{ {
pi_rpc_response = response; pi_rpc_ok = response
.get("ok")
.and_then(Value::as_bool)
.unwrap_or(true);
} }
} }
} }
@@ -8931,7 +9096,7 @@ pub async fn rename_session(
"schema": "mnote.page_ai_pi.rename_session.v1", "schema": "mnote.page_ai_pi.rename_session.v1",
"sessionId": path.session_id, "sessionId": path.session_id,
"title": title, "title": title,
"piRpcResponse": pi_rpc_response, "piRpcOk": pi_rpc_ok,
"updatedRuns": renamed.len(), "updatedRuns": renamed.len(),
}))) })))
} }
@@ -8945,6 +9110,108 @@ mod tests {
use control_plane::{DirectoryGrantInput, UpsertAiPolicyInput, UpsertUserInput}; use control_plane::{DirectoryGrantInput, UpsertAiPolicyInput, UpsertUserInput};
use tower::util::ServiceExt; use tower::util::ServiceExt;
#[test]
fn lexically_normalize_collapses_parent_dirs() {
let path = PathBuf::from("/root/workspace/../../etc/passwd");
let normalized = lexically_normalize(&path);
assert_eq!(normalized, PathBuf::from("/etc/passwd"));
assert!(!path_contains_parent_dir(&normalized));
}
#[test]
fn path_is_inside_rejects_parent_dir_escape_without_existing_target() {
let root = temp_root("pi-path-inside-root");
// 目标目录不存在时 canonicalize 会失败;旧实现仅靠 starts_with 词法匹配会放行。
let escape = root.join("../../etc/passwd_should_not_escape");
assert!(
!path_is_inside(&escape, &root),
"含 .. 且未 canonicalize 的路径不得判定为 inside root"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn path_is_inside_allows_nested_file_under_root() {
let root = temp_root("pi-path-inside-ok");
let nested = root.join("docs").join("note.md");
fs::create_dir_all(nested.parent().unwrap()).expect("mkdir");
fs::write(&nested, "ok").expect("write");
assert!(path_is_inside(&nested, &root));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn build_run_runtime_json_is_valid_object_json() {
let session = PiLabSession {
session_id: "sess-1".into(),
mnote_user_id: "u1".into(),
bridge_token: "pi_bridge_test".into(),
status: PiLabSessionStatus::Idle,
provider_session_id: "prov-1".into(),
pi_session_dir: "/tmp/pi".into(),
pi_session_file: Some("/tmp/pi/session.jsonl".into()),
root_uri: Some("file:///tmp".into()),
workspace_id: Some("ws".into()),
page_path: Some("a.md".into()),
page_title: Some("A".into()),
model_provider: Some("openai".into()),
model_id: Some("gpt".into()),
thinking_level: None,
allowed_roots_snapshot: None,
runtime_policy_snapshot: None,
runtime_pid: None,
runtime_mode: "lab".into(),
runtime_error: None,
created_at_ms: 1,
updated_at_ms: 1,
message_count: 0,
};
let raw = build_run_runtime_json(&session);
assert!(!raw.is_empty(), "不得回退空串");
let parsed: Value = serde_json::from_str(&raw).expect("必须是合法 JSON");
assert!(parsed.is_object());
assert_eq!(parsed["providerSessionId"], "prov-1");
assert_eq!(parsed["workspaceId"], "ws");
// session_id 不进 runtime_json 字段集合(仅 run 元数据)
assert!(parsed.get("sessionId").is_none());
}
#[test]
fn generate_bridge_token_is_unpredictable_hex() {
let a = generate_bridge_token();
let b = generate_bridge_token();
assert!(a.starts_with("pi_bridge_"));
assert_ne!(a, b);
// 32 字节 → 64 hex
assert_eq!(a.len(), "pi_bridge_".len() + 64);
}
#[test]
fn shell_glob_escape_path_escapes_wildcards() {
let escaped = shell_glob_escape_path("/home/user/project*/docs?");
assert_eq!(escaped, r"/home/user/project\*/docs\?");
// 已有的 bracket/brace 转义仍保留
assert_eq!(
shell_glob_escape_path(r"/tmp/a[b]{c}"),
r"/tmp/a\[b\]\{c\}"
);
// 反斜杠先加倍,避免二次解释
assert_eq!(shell_glob_escape_path(r"a\b*"), r"a\\b\*");
}
#[test]
fn pi_lab_tool_params_hash_is_sha256_hex_and_stable() {
let params = json!({"path": "a.md", "mnoteApproval": {"id": "x"}});
let hash = pi_lab_tool_params_hash(&params);
assert_eq!(hash.len(), 64);
// mnoteApproval 被剥离后应与无 approval 的 params 一致
let without = json!({"path": "a.md"});
assert_eq!(hash, pi_lab_tool_params_hash(&without));
// 不同参数不得碰撞(强哈希)
let other = json!({"path": "b.md"});
assert_ne!(hash, pi_lab_tool_params_hash(&other));
}
fn test_state() -> AppState { fn test_state() -> AppState {
AppState::new(AppConfig { AppState::new(AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
@@ -8965,6 +9232,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}) })
} }
@@ -83,12 +83,25 @@ pub async fn block_edit_workflow(
) )
.with_context(&context)); .with_context(&context));
} }
let actor_id = // Fail-closed: unauthenticated actors cannot write via the page-ai workflow.
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" { // Dev fixtures may use dev_user_id only when explicitly enabled.
state.config().dev_user_id.clone() let actor_id = {
let raw = context.auth.actor_id.trim();
if raw.is_empty() || raw == "anonymous" {
if state.config().allow_dev_fixtures {
state.config().dev_user_id.clone()
} else {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"page_ai_workflow_auth_required",
"页面 AI 工作流需要先登录",
)
.with_context(&context));
}
} else { } else {
context.auth.actor_id.clone() context.auth.actor_id.clone()
}; }
};
let allowed_target_block_ids = ai_context let allowed_target_block_ids = ai_context
.get("allowedTargetBlockIds") .get("allowedTargetBlockIds")
.and_then(Value::as_array) .and_then(Value::as_array)
@@ -103,7 +116,17 @@ pub async fn block_edit_workflow(
}) })
.unwrap_or_default(); .unwrap_or_default();
let mut markdown_args = json!({ let mut markdown_args = json!({
"operations": markdown_operations.clone() "operations": markdown_operations.clone(),
// 写入守卫 fail-closed:快路径也必须显式授予写权限。
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": [document_id.clone()],
"allowedTargetBlockIds": allowed_target_block_ids.clone(),
},
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": false
}
}); });
if !allowed_target_block_ids.is_empty() { if !allowed_target_block_ids.is_empty() {
if let Value::Object(map) = &mut markdown_args { if let Value::Object(map) = &mut markdown_args {
@@ -172,13 +195,29 @@ struct MarkdownEditPlan {
summary: Option<String>, summary: Option<String>,
} }
/// 模型输出可能把 JSON 再包一层 `choices[0].message.content` 字符串;限制解包深度防栈溢出。
const MODEL_JSON_UNWRAP_MAX_DEPTH: u8 = 4;
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> { fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
extract_markdown_plan_from_model_text_depth(text, 0)
}
fn extract_markdown_plan_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<MarkdownEditPlan, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?; let parsed = parse_model_json(text)?;
if let Some(content) = parsed if let Some(content) = parsed
.pointer("/choices/0/message/content") .pointer("/choices/0/message/content")
.and_then(Value::as_str) .and_then(Value::as_str)
{ {
return extract_markdown_plan_from_model_text(content); return extract_markdown_plan_from_model_text_depth(content, depth + 1);
} }
let summary = parsed let summary = parsed
.get("summary") .get("summary")
@@ -237,12 +276,26 @@ fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan,
#[allow(dead_code)] #[allow(dead_code)]
fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> { fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
extract_operations_from_model_text_depth(text, 0)
}
#[allow(dead_code)]
fn extract_operations_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<Vec<Value>, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?; let parsed = parse_model_json(text)?;
if let Some(content) = parsed if let Some(content) = parsed
.pointer("/choices/0/message/content") .pointer("/choices/0/message/content")
.and_then(Value::as_str) .and_then(Value::as_str)
{ {
return extract_operations_from_model_text(content); return extract_operations_from_model_text_depth(content, depth + 1);
} }
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) { if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
return Ok(operations.clone()); return Ok(operations.clone());
@@ -385,7 +438,13 @@ async fn call_block_edit_model(
.with_context(context) .with_context(context)
})?; })?;
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_workflow_model_body_read_failed",
format!("页面 AI workflow 模型响应体读取失败: {error}"),
)
.with_context(context)
})?;
if !status.is_success() { if !status.is_success() {
return Err(WebError::bad_gateway_code( return Err(WebError::bad_gateway_code(
"page_ai_workflow_model_failed", "page_ai_workflow_model_failed",
@@ -545,14 +604,50 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent")) .unwrap_or_else(|| PathBuf::from(".mnote-agent"))
} }
/// profile 名仅允许安全文件名字符,拒绝 `..` / 路径分隔,防止 profiles join 穿越。
fn sanitize_workflow_profile_name(profile: &str) -> String {
let trimmed = profile.trim();
if trimmed.is_empty() {
return "default".to_string();
}
let mut out = String::with_capacity(trimmed.len().min(64));
for ch in trimmed.chars().take(64) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return "default".to_string();
}
out
}
fn profile_config_path(profile: &str) -> PathBuf { fn profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home(); let home = agent_profile_home();
let profile = profile.trim(); let profile = profile.trim();
if profile.is_empty() || profile == "default" { if profile.is_empty() || profile == "default" {
return home.join("config.yaml"); return home.join("config.yaml");
} }
let candidate = home.join("profiles").join(profile); let safe = sanitize_workflow_profile_name(profile);
if candidate.exists() { if safe == "default" {
return home.join("config.yaml");
}
let profiles_root = home.join("profiles");
let candidate = profiles_root.join(&safe);
// 防御:消毒后仍须落在 profiles 目录内(不解析 symlink,仅词法检查)。
if candidate
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return home.join("config.yaml");
}
// 禁止跟随 profiles 下指向外部的 symlinkexists/is_dir 会跟随)。
if candidate.is_symlink() {
return home.join("config.yaml");
}
if candidate.is_dir() {
candidate.join("config.yaml") candidate.join("config.yaml")
} else { } else {
home.join("config.yaml") home.join("config.yaml")
@@ -673,6 +768,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -978,4 +1074,56 @@ mod tests {
std::env::remove_var("MNOTE_AGENT_HOME"); std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home); let _ = fs::remove_dir_all(&agent_home);
} }
#[test]
fn profile_config_path_rejects_traversal() {
use super::{profile_config_path, sanitize_workflow_profile_name};
use std::path::Component;
assert_eq!(sanitize_workflow_profile_name("../../etc"), "default");
assert_eq!(sanitize_workflow_profile_name("mnoteai"), "mnoteai");
assert_eq!(sanitize_workflow_profile_name("a/b"), "a_b");
let path = profile_config_path("../../etc/passwd");
assert!(
path.components().all(|c| !matches!(c, Component::ParentDir)),
"profile path must not contain ParentDir: {path:?}"
);
// 危险 profile 回落到 home/config.yaml,不得 join 原始 ../../
assert!(
path.ends_with("config.yaml"),
"expected config.yaml fallback, got {path:?}"
);
let path2 = profile_config_path("mnoteai");
// may or may not exist; must be under profiles/mnoteai or home config
assert!(
path2.components().all(|c| !matches!(c, Component::ParentDir))
);
}
#[test]
fn extract_markdown_plan_rejects_deeply_nested_content() {
use super::extract_markdown_plan_from_model_text;
// 5 层 choices.content 嵌套 → 超过 MODEL_JSON_UNWRAP_MAX_DEPTH(4)
let mut nested = r#"{"operations":[{"search":"a","replace":"b"}]}"#.to_string();
for _ in 0..5 {
nested = format!(
r#"{{"choices":[{{"message":{{"content":{}}}}}]}}"#,
serde_json::to_string(&nested).expect("escape")
);
}
let err = match extract_markdown_plan_from_model_text(&nested) {
Ok(_) => panic!("must reject deeply nested model JSON"),
Err(e) => e,
};
let msg = err.message();
assert!(
msg.contains("嵌套")
|| msg.contains("too_nested")
|| format!("{err:?}").contains("too_nested")
|| format!("{err:?}").contains("嵌套"),
"unexpected err: {err:?}"
);
}
} }
@@ -103,10 +103,16 @@ fn stamp_headers(headers: &mut HeaderMap) {
} }
} }
async fn current_user_id(state: &AppState, context: &RequestContext) -> String { /// Resolve the acting user for trash mutations.
/// Fail-closed: unauthenticated callers only fall back to `dev_user_id` when
/// `allow_dev_fixtures` is enabled (local/dev). Production must present a real actor.
async fn current_user_id(
state: &AppState,
context: &RequestContext,
) -> Result<String, WebError> {
let user_id = context.auth.actor_id.trim(); let user_id = context.auth.actor_id.trim();
if !user_id.is_empty() && user_id != "anonymous" { if !user_id.is_empty() && user_id != "anonymous" {
return user_id.to_string(); return Ok(user_id.to_string());
} }
let has_auth_cookie = context let has_auth_cookie = context
@@ -135,13 +141,23 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
{ {
return user_id.to_string(); return Ok(user_id.to_string());
} }
} }
} }
} }
state.config().dev_user_id.clone() if state.config().allow_dev_fixtures {
return Ok(state.config().dev_user_id.clone());
}
Err(WebError::new(
StatusCode::UNAUTHORIZED,
"resource_trash_auth_required",
"资源回收站操作需要先登录",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"))
} }
fn require_id<'a>( fn require_id<'a>(
@@ -521,7 +537,7 @@ pub async fn media_batch(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<MediaBatchRequest>, Json(body): Json<MediaBatchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let action = body.action.trim(); let action = body.action.trim();
if action != "restore" && action != "delete" && action != "rename" && action != "move" { if action != "restore" && action != "delete" && action != "rename" && action != "move" {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
@@ -600,6 +616,15 @@ pub async fn media_batch(
document_id.as_deref(), document_id.as_deref(),
payload, payload,
); );
// 先执行 tree runtime command;成功后再做 legacy media store patch
// 避免 patch 已生效而 tree 失败时调用方仍看到成功(与 restore/delete 一致)。
execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
workspace_id.as_deref(),
command,
)
.await?;
if action == "rename" { if action == "rename" {
let new_name = require_id( let new_name = require_id(
&context, &context,
@@ -646,25 +671,6 @@ pub async fn media_batch(
) )
.await?; .await?;
} }
let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
workspace_id.as_deref(),
command,
)
.await;
if action == "rename" || action == "move" {
if let Err(error) = command_result {
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
action = %action,
"tree.resource artifact command 失败,已保留兼容 patch 结果"
);
}
} else {
command_result?;
}
updated += 1; updated += 1;
} }
@@ -695,7 +701,7 @@ pub async fn media_purge(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<MediaPurgeRequest>, Json(body): Json<MediaPurgeRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let asset_id = require_id(&context, &body.asset_id, "assetId")?; let asset_id = require_id(&context, &body.asset_id, "assetId")?;
let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?; let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?;
let (workspace_id, document_id) = read_asset_workspace_and_document(&asset); let (workspace_id, document_id) = read_asset_workspace_and_document(&asset);
@@ -727,7 +733,7 @@ pub async fn media_empty_trash(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>, Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
let result = execute_retired_mutation_by_name( let result = execute_retired_mutation_by_name(
state.config(), state.config(),
@@ -775,7 +781,7 @@ pub async fn mindmap_delete(
Path((doc_id, mindmap_id)): Path<(String, String)>, Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapLocalQuery>, Query(query): Query<MindmapLocalQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let doc_id = require_id(&context, &doc_id, "docId")?; let doc_id = require_id(&context, &doc_id, "docId")?;
let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
if query.source_kind.as_deref() == Some("local_folder") { if query.source_kind.as_deref() == Some("local_folder") {
@@ -845,7 +851,7 @@ pub async fn mindmap_trash_action(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
} }
}; };
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let doc_id = require_id(&context, &doc_id, "docId")?; let doc_id = require_id(&context, &doc_id, "docId")?;
let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
let local_source_kind = body let local_source_kind = body
@@ -971,7 +977,7 @@ pub async fn table_create(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<TableCreateRequest>, Json(body): Json<TableCreateRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let document_id = require_id(&context, &body.document_id, "documentId")?; let document_id = require_id(&context, &body.document_id, "documentId")?;
let workspace_id = fetch_document_workspace_id(&state, &context, document_id) let workspace_id = fetch_document_workspace_id(&state, &context, document_id)
.await .await
@@ -1048,7 +1054,7 @@ async fn table_action(
function_name: &'static str, function_name: &'static str,
error_phase: &'static str, error_phase: &'static str,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let table_id = require_id(&context, &table_id, "tableId")?; let table_id = require_id(&context, &table_id, "tableId")?;
let table_meta = fetch_table_meta(&state, &context, &user_id, table_id).await; let table_meta = fetch_table_meta(&state, &context, &user_id, table_id).await;
let workspace_id = table_meta let workspace_id = table_meta
@@ -1106,7 +1112,7 @@ pub async fn table_empty_trash(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>, Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await?;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
let result = execute_retired_mutation_by_name( let result = execute_retired_mutation_by_name(
state.config(), state.config(),
@@ -1198,6 +1204,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
+4 -5
View File
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud, execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
resolve_effective_workspace_id, resolve_effective_workspace_id,
}; };
use crate::routes::web_shell::load_sidebar_tree_html; use crate::routes::web_shell::{escape_script_json, load_sidebar_tree_html};
use crate::routes::{local_folder_source, local_search_index}; use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage; use crate::ssr::pages::search::SearchPage;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
@@ -734,10 +734,6 @@ fn render_initial_results_html(results: Option<&Vec<Value>>) -> String {
format!(r#"<div class="search-result-list">{items}</div>"#) format!(r#"<div class="search-result-list">{items}</div>"#)
} }
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn escape_html(value: &str) -> String { fn escape_html(value: &str) -> String {
value value
.replace('&', "&amp;") .replace('&', "&amp;")
@@ -786,6 +782,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -916,6 +913,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})); }));
let response = app let response = app
.oneshot( .oneshot(
@@ -1527,6 +1525,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
state state
.control_plane() .control_plane()
+41 -27
View File
@@ -103,37 +103,44 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
.as_deref() .as_deref()
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_NAME)) .and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_NAME))
.or_else(|| jwt_cookie_claim(&context, &["name", "username"])); .or_else(|| jwt_cookie_claim(&context, &["name", "username"]));
let user_id = if has_forwarded_actor {
actor_id.to_string() // Fail-closed: only fall back to dev identity when allow_dev_fixtures is on.
} else { // Production unauthenticated callers get anonymous (not a privileged dev user).
state.config().dev_user_id.clone() if !has_forwarded_actor {
}; if state.config().allow_dev_fixtures {
let actor_type = if has_forwarded_actor { return SessionResponse {
effective_actor_type_for_user(actor_id, &context.auth.actor_type) ok: true,
} else { owner: "mnote-web",
"devFallback".to_string() user_id: state.config().dev_user_id.clone(),
}; email: state.config().dev_user_email.clone(),
name: state.config().dev_user_name.clone(),
actor_type: "devFallback".to_string(),
auth_mode: "devFallback",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
}
return SessionResponse {
ok: true,
owner: "mnote-web",
user_id: "anonymous".to_string(),
email: String::new(),
name: "anonymous".to_string(),
actor_type: "anonymous".to_string(),
auth_mode: "anonymous",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
}
SessionResponse { SessionResponse {
ok: true, ok: true,
owner: "mnote-web", owner: "mnote-web",
user_id, user_id: actor_id.to_string(),
email: if has_forwarded_actor { email: actor_email.unwrap_or_default(),
actor_email.unwrap_or_default() name: actor_name.unwrap_or_else(|| actor_id.to_string()),
} else { actor_type: effective_actor_type_for_user(actor_id, &context.auth.actor_type),
state.config().dev_user_email.clone() auth_mode: "forwardedActor",
},
name: if has_forwarded_actor {
actor_name.unwrap_or_else(|| actor_id.to_string())
} else {
state.config().dev_user_name.clone()
},
actor_type,
auth_mode: if has_forwarded_actor {
"forwardedActor"
} else {
"devFallback"
},
request_id: context.trace.request_id, request_id: context.trace.request_id,
trace_id: context.trace.trace_id, trace_id: context.trace.trace_id,
} }
@@ -152,6 +159,9 @@ fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String {
} }
} }
/// 仅用于 **展示** email/name 补全;**绝不**作为 user_id / 鉴权真源。
/// 无签名校验:任意客户端可伪造 JWT payload 中的展示字段。
/// 身份仍以 control-plane session / header actor 为准(见上方 get_session 分支)。
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> { fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
let token = context let token = context
.auth .auth
@@ -239,6 +249,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -341,6 +352,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
state state
.control_plane() .control_plane()
@@ -410,6 +422,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
state state
.control_plane() .control_plane()
@@ -490,6 +503,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
state state
.control_plane() .control_plane()
@@ -157,13 +157,21 @@ pub async fn delete_shortcut(
) )
.with_context(&context) .with_context(&context)
})?; })?;
let _ = state.control_plane().append_audit(AppendAuditInput { if let Err(error) = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id), actor_user_id: Some(actor_id),
action: "sidebar.shortcut.removed".to_string(), action: "sidebar.shortcut.removed".to_string(),
target_kind: "sidebar_shortcut".to_string(), target_kind: "sidebar_shortcut".to_string(),
target_id: Some(shortcut_id.clone()), target_id: Some(shortcut_id.clone()),
metadata_json: "{}".to_string(), metadata_json: "{}".to_string(),
}); }) {
// 审计失败不回滚业务删除,但必须可观测(避免静默 fail-open)。
tracing::warn!(
target: "mnote_web::sidebar_shortcuts",
error = %error,
shortcut_id = %shortcut_id,
"sidebar shortcut delete audit append failed"
);
}
Ok(Json(json!({ Ok(Json(json!({
"ok": true, "ok": true,
"owner": "mnote-web", "owner": "mnote-web",
@@ -256,7 +264,7 @@ fn append_shortcut_audit(
action: &str, action: &str,
shortcut: &SidebarShortcutRecord, shortcut: &SidebarShortcutRecord,
) { ) {
let _ = state.control_plane().append_audit(AppendAuditInput { if let Err(error) = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id.to_string()), actor_user_id: Some(actor_id.to_string()),
action: action.to_string(), action: action.to_string(),
target_kind: "sidebar_shortcut".to_string(), target_kind: "sidebar_shortcut".to_string(),
@@ -267,7 +275,15 @@ fn append_shortcut_audit(
"targetId": shortcut.target_id, "targetId": shortcut.target_id,
}) })
.to_string(), .to_string(),
}); }) {
tracing::warn!(
target: "mnote_web::sidebar_shortcuts",
error = %error,
action = %action,
shortcut_id = %shortcut.id,
"sidebar shortcut audit append failed"
);
}
} }
trait EmptyStringExt { trait EmptyStringExt {
+70 -9
View File
@@ -53,6 +53,21 @@ async fn events_with_stream_delta(
let state_for_stream = state.clone(); let state_for_stream = state.clone();
let context_for_stream = context.clone(); let context_for_stream = context.clone();
let query_for_stream = query.clone(); let query_for_stream = query.clone();
let subscription_workspace = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
});
let stream = stream::unfold( let stream = stream::unfold(
Some(StreamPollState { Some(StreamPollState {
app_state: state_for_stream, app_state: state_for_stream,
@@ -64,6 +79,7 @@ async fn events_with_stream_delta(
initial_emitted: false, initial_emitted: false,
block_delta_rx, block_delta_rx,
stream_delta_rx, stream_delta_rx,
subscription_workspace,
}), }),
move |state| async move { move |state| async move {
let mut state = state?; let mut state = state?;
@@ -76,11 +92,17 @@ async fn events_with_stream_delta(
)); ));
} }
// Check block.delta broadcast first // Check block.delta broadcast first(按订阅 workspace 过滤,防跨工作区泄露)
if let Some(ref mut rx) = state.block_delta_rx { if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() { match rx.try_recv() {
Ok(payload) => { Ok(payload) => {
return Some((Ok(stream_event("block.delta", &payload)), Some(state))); if delta_matches_workspace(
&payload,
state.subscription_workspace.as_deref(),
) {
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
}
// 非本工作区:丢弃并继续同一 tick 的后续检查
} }
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {} Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => { Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -94,12 +116,17 @@ async fn events_with_stream_delta(
if let Some(ref mut rx) = state.stream_delta_rx { if let Some(ref mut rx) = state.stream_delta_rx {
match rx.try_recv() { match rx.try_recv() {
Ok(payload) => { Ok(payload) => {
let hint = build_stream_push_delta_hint( if delta_matches_workspace(
&payload, &payload,
&state.context.trace.request_id, state.subscription_workspace.as_deref(),
&state.context.trace.trace_id, ) {
); let hint = build_stream_push_delta_hint(
return Some((Ok(stream_event("delta", &hint)), Some(state))); &payload,
&state.context.trace.request_id,
&state.context.trace.trace_id,
);
return Some((Ok(stream_event("delta", &hint)), Some(state)));
}
} }
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {} Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => { Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -184,11 +211,19 @@ async fn events_with_stream_delta(
state.polls += 1; state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await; sleep(Duration::from_millis(poll_ms)).await;
// Check block.delta after poll sleep // Check block.delta after poll sleep(同样按 workspace 过滤)
if let Some(ref mut rx) = state.block_delta_rx { if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() { match rx.try_recv() {
Ok(payload) => { Ok(payload) => {
return Some((Ok(stream_event("block.delta", &payload)), Some(state))); if delta_matches_workspace(
&payload,
state.subscription_workspace.as_deref(),
) {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
}
} }
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {} Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => { Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -305,6 +340,31 @@ struct StreamPollState {
#[allow(dead_code)] #[allow(dead_code)]
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>, block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
stream_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>, stream_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
/// 订阅工作区;broadcast 推送仅转发匹配项,避免跨工作区泄露。
subscription_workspace: Option<String>,
}
/// 仅转发与当前订阅 workspace 一致的 delta(与 ws.rs 同策略)。
fn delta_matches_workspace(delta: &Value, subscription_workspace: Option<&str>) -> bool {
let Some(expected) = subscription_workspace else {
// 无订阅 workspace 时不推送带 workspace 的全局 delta(保守)
return delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none();
};
match delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(delta_ws) => delta_ws == expected,
// 无 workspace 标记的 delta 不转发(避免跨租户噪声)
None => false,
}
} }
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery { fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
@@ -368,6 +428,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
} }
@@ -624,9 +624,17 @@ pub async fn load_stream_overview(
context: &RequestContext, context: &RequestContext,
query: &StreamSnapshotQuery, query: &StreamSnapshotQuery,
) -> Result<(String, Value), WebError> { ) -> Result<(String, Value), WebError> {
// resolve_effective_workspace_id(require=true) 在缺失时已返回 Err,不再用 expect panic。
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)? resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else(
.expect("workspace_required 已确保存在"); || {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
},
)?;
let overview = execute_runtime_query_via_legacy_cloud( let overview = execute_runtime_query_via_legacy_cloud(
config, config,
context, context,
@@ -643,8 +651,15 @@ pub async fn load_stream_snapshot(
query: &StreamSnapshotQuery, query: &StreamSnapshotQuery,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)? resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else(
.expect("workspace_required 已确保存在"); || {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
},
)?;
let scope = resolve_stream_scope(query); let scope = resolve_stream_scope(query);
let snapshot = match scope { let snapshot = match scope {
@@ -653,8 +668,13 @@ pub async fn load_stream_snapshot(
.await? .await?
} }
StreamSnapshotScope::Subtree => { StreamSnapshotScope::Subtree => {
let root_node_id = let root_node_id = normalize_root_node_id(query).ok_or_else(|| {
normalize_root_node_id(query).expect("subtree scope 已确保 rootNodeId 存在"); WebError::bad_request_code(
"stream_root_node_required",
"subtree 流需要 rootNodeId",
)
.with_context(context)
})?;
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?; let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
let tree = execute_kernel_query( let tree = execute_kernel_query(
context, context,
+109 -29
View File
@@ -263,10 +263,23 @@ fn escape_html(input: &str) -> String {
} }
fn escape_inline_json(input: &str) -> String { fn escape_inline_json(input: &str) -> String {
input // 嵌入 HTML/script 时除 <>& 外,还需处理 U+2028/U+2029 与控制字符,
.replace('&', "\\u0026") // 避免破坏 JSON 解析或形成 XSS 面。
.replace('<', "\\u003c") let mut out = String::with_capacity(input.len());
.replace('>', "\\u003e") for ch in input.chars() {
match ch {
'&' => out.push_str("\\u0026"),
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c if c.is_control() && c != '\n' && c != '\r' && c != '\t' => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
} }
fn normalize_title(value: Option<String>) -> String { fn normalize_title(value: Option<String>) -> String {
@@ -308,22 +321,42 @@ fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
} }
} }
fn normalize_tree_source_kind(raw: Option<&str>) -> &'static str {
match raw.map(str::trim).unwrap_or("") {
"local_folder" | "local-folder" | "local" => "local_folder",
"convex_workspace" | "convex" | "" => "convex_workspace",
// 未知 kind 一律回落到 convex_workspace,避免客户端自报 kind 与 root_uri 脱钩
_ => "convex_workspace",
}
}
fn build_workspace_source_wire( fn build_workspace_source_wire(
context: &RequestContext, context: &RequestContext,
workspace_id: &str, workspace_id: &str,
envelope_context: &TreeCommandEnvelopeContext, envelope_context: &TreeCommandEnvelopeContext,
) -> bridge_runtime::RuntimeSourceWire { ) -> bridge_runtime::RuntimeSourceWire {
let source_kind = envelope_context // 不信任请求体自报的任意 source_kind:白名单归一化后再与 root_uri 对齐。
.source_kind let source_kind = normalize_tree_source_kind(envelope_context.source_kind.as_deref()).to_string();
.clone() let root_uri = match source_kind.as_str() {
.unwrap_or_else(|| "convex_workspace".into()); "local_folder" => {
let root_uri = envelope_context.root_uri.clone().or_else(|| { // local_folder 必须带已由上游鉴权的 root_uri;无则不回落到 convex 假 URI
if source_kind == "convex_workspace" { envelope_context
Some(format!("convex://workspace/{workspace_id}")) .root_uri
} else { .as_ref()
None .map(|u| u.trim().to_string())
.filter(|u| !u.is_empty() && !u.contains(".."))
} }
}); _ => Some(
envelope_context
.root_uri
.clone()
.filter(|u| {
let t = u.trim();
!t.is_empty() && (t.starts_with("convex://") || t.starts_with("workspace:"))
})
.unwrap_or_else(|| format!("convex://workspace/{workspace_id}")),
),
};
let capabilities = let capabilities =
if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" { if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" {
vec![ vec![
@@ -895,19 +928,6 @@ fn build_tree_shell_renderer_input(
} }
} }
fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext {
let mut next = context.clone();
let actor_id = actor_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(actor_id) = actor_id {
next.auth.actor_id = actor_id;
next.auth.actor_type = "user".into();
}
next
}
fn generate_tree_document_id() -> String { fn generate_tree_document_id() -> String {
let millis = SystemTime::now() let millis = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -1836,7 +1856,10 @@ pub async fn tree_shell(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<TreeShellQuery>, Query(query): Query<TreeShellQuery>,
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let effective_context = override_actor_context(&context, query.actor_id.as_deref()); // 安全:忽略 query.actor_id,禁止用查询参数冒充任意用户。
// actor 仅来自 RequestContextheader/cookie/session/extension token)。
let _ignored_query_actor_id = query.actor_id.as_deref();
let effective_context = context.clone();
let mode = normalize_tree_mode(query.mode.as_deref()); let mode = normalize_tree_mode(query.mode.as_deref());
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false); let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false);
let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref()); let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
@@ -2120,8 +2143,9 @@ fn create_command_wire(
items, items,
title: _, title: _,
} => { } => {
let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?; // 仅当 items 为空时要求顶层 documentId;已有 items 时用 items.documentId。
let copy_items = if items.is_empty() { let copy_items = if items.is_empty() {
let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?;
vec![json!({ vec![json!({
"documentId": fallback_document_id, "documentId": fallback_document_id,
"recursive": true, "recursive": true,
@@ -2366,6 +2390,25 @@ fn apply_local_file_operation_participants(
); );
} }
} }
"restore" => {
// restore 后资源回到 resource 路径;若仅有 previousResource 也尝试清除。
let restore_relative = next_relative_path
.as_deref()
.or(previous_relative_path.as_deref());
let restore_document_id = next_document_id
.as_deref()
.or(previous_document_id.as_deref());
if let (Some(relative_path), Some(document_id)) =
(restore_relative, restore_document_id)
{
let _ = buffer_store.clear_local_folder_markdown_deleted(
workspace_id,
root_uri,
relative_path,
document_id,
);
}
}
_ => {} _ => {}
} }
} }
@@ -2375,6 +2418,11 @@ pub async fn tree_command(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
body: String, body: String,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, Json<Value>), WebError> {
// 7-76PAT 写树需要 tree.write(读投影走其它 GET,不经此入口)。
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_TREE_WRITE,
)?;
let raw_request: TreeCommandEnvelope = serde_json::from_str(&body).map_err(|error| { let raw_request: TreeCommandEnvelope = serde_json::from_str(&body).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
"tree_command_invalid_json", "tree_command_invalid_json",
@@ -2735,6 +2783,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})) }))
.layer(axum::middleware::from_fn(inject_test_actor)) .layer(axum::middleware::from_fn(inject_test_actor))
} }
@@ -2817,6 +2866,35 @@ mod tests {
assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中"); assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中");
} }
#[tokio::test]
async fn tree_shell_ignores_query_actor_id_impersonation() {
// 攻击面:?actorId= 不得覆盖 middleware 注入的 actorx-mnote-actor-id=user_test)。
let response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&actorId=attacker_admin&channel=test-shell")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
// 嵌入的 actor 必须是测试中间件身份,而非 query 冒充值
assert!(
html.contains("\"actorId\":\"user_test\"") || html.contains("user_test"),
"tree shell 应使用认证 actor,而非 query.actorId"
);
assert!(
!html.contains("attacker_admin"),
"query.actorId 不得写入 shell 身份上下文"
);
}
#[tokio::test] #[tokio::test]
async fn tree_shell_returns_interactive_html_document() { async fn tree_shell_returns_interactive_html_document() {
let response = app() let response = app()
@@ -3080,6 +3158,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] { for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state state
@@ -3825,6 +3904,7 @@ mod tests {
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}); });
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] { for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state state
+532 -18
View File
@@ -18,9 +18,10 @@ use crate::routes::vault_store::{
use axum::extract::{Extension, Path, Query, State}; use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json; use axum::Json;
use control_plane::AppendAuditInput;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf; use std::path::PathBuf;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
@@ -531,6 +532,25 @@ pub async fn list(
let root_uri = require_root_uri(query.root_uri.as_deref())?; let root_uri = require_root_uri(query.root_uri.as_deref())?;
let root = resolve_read_root(&state, &context, root_uri).await?; let root = resolve_read_root(&state, &context, root_uri).await?;
let status = parse_status(query.status.as_deref())?; let status = parse_status(query.status.as_deref())?;
let (is_ai_vault, ai_actor) = vault_role_meta(&root);
// AI 密码本:打开列表时自动把旧分享副本分组修成 `{分享者}/{源分组}`
// (含 from-liaibo、无前缀的 个人/系统;仅识别到一个分享者时会统一归到其名下)
let folder_repair = if is_ai_vault {
vault_store::repair_ai_shared_folder_paths(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
)
.ok()
} else {
None
};
let (index, items) = vault_store::list_credentials(&root, status)?; let (index, items) = vault_store::list_credentials(&root, status)?;
let book = vault_store::load_cipher_book(&root).ok(); let book = vault_store::load_cipher_book(&root).ok();
let book_ref = book.as_ref(); let book_ref = book.as_ref();
@@ -538,20 +558,31 @@ pub async fn list(
.iter() .iter()
.map(|e| vault_store::project_list_entry_with_cipher(e, book_ref)) .map(|e| vault_store::project_list_entry_with_cipher(e, book_ref))
.collect(); .collect();
let (is_ai_vault, ai_actor) = vault_role_meta(&root); let mut body = json!({
Ok(ok_response( "schema": "mnote.vault.list.v1",
&context, "status": status.as_str(),
json!({ "revision": index.revision,
"schema": "mnote.vault.list.v1", "updatedAt": index.updated_at,
"status": status.as_str(), "items": projected,
"revision": index.revision, "isAiVault": is_ai_vault,
"updatedAt": index.updated_at, "aiVaultActorId": ai_actor,
"items": projected, "vaultRole": if is_ai_vault { "ai" } else { "user" },
"isAiVault": is_ai_vault, });
"aiVaultActorId": ai_actor, if let Some(outcome) = folder_repair {
"vaultRole": if is_ai_vault { "ai" } else { "user" }, if let Some(obj) = body.as_object_mut() {
}), obj.insert(
)) "folderRepair".into(),
json!({
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
);
}
}
Ok(ok_response(&context, body))
} }
/// GET /api/vault/items/{id} /// GET /api/vault/items/{id}
@@ -1216,6 +1247,7 @@ pub async fn resolve_item(
} }
/// GET /api/vault/ai/list — multi-agent list of AI password book (no rootUri). /// GET /api/vault/ai/list — multi-agent list of AI password book (no rootUri).
/// 打开列表时自动修复旧分享副本分组:`{分享者}/{源分组}`。
pub async fn list_ai( pub async fn list_ai(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<VaultRootQuery>, Query(query): Query<VaultRootQuery>,
@@ -1223,10 +1255,91 @@ pub async fn list_ai(
require_vault_enabled()?; require_vault_enabled()?;
require_authenticated(&context)?; require_authenticated(&context)?;
let status = parse_status(query.status.as_deref())?; let status = parse_status(query.status.as_deref())?;
let result = list_ai_vault_items(status)?; // 尽力修复旧数据分组(失败不阻断列表)
let repair = repair_ai_folder_paths_best_effort();
let mut result = list_ai_vault_items(status)?;
if let Some(outcome) = repair {
if let Some(obj) = result.as_object_mut() {
obj.insert(
"folderRepair".into(),
json!({
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
);
}
}
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
/// POST /api/vault/ai/repair-folders — 显式批量修复 AI 密码本分享副本分组。
/// body 可选:`{ "defaultSourceActor": "liaibo" }`(全库仅剩一人分享时也可自动推断)。
pub async fn repair_ai_folders(
Extension(context): Extension<RequestContext>,
body: Option<Json<Value>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = require_authenticated(&context)?;
let root = ensure_ai_vault_workspace()?;
let default_actor = body
.as_ref()
.and_then(|Json(v)| v.get("defaultSourceActor").or_else(|| v.get("default_source_actor")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let outcome = vault_store::repair_ai_shared_folder_paths_with_default(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
default_actor,
)?;
let _ = vault_store::append_vault_audit(
&root,
"repair_ai_folders",
&actor,
"",
Some(&format!("updated={}", outcome.updated)),
Some(context.trace.request_id.as_str()),
true,
);
Ok(ok_response(
&context,
json!({
"schema": "mnote.vault.repairAiFolders.v1",
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
))
}
fn repair_ai_folder_paths_best_effort() -> Option<vault_store::RepairAiFolderPathsOutcome> {
let root = ensure_ai_vault_workspace().ok()?;
vault_store::repair_ai_shared_folder_paths(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
)
.ok()
}
/// GET /api/vault/ai/items/{id} /// GET /api/vault/ai/items/{id}
pub async fn get_ai_item( pub async fn get_ai_item(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -1493,6 +1606,388 @@ fn parse_session_cookies(value: Option<&Value>) -> Vec<VaultSessionCookie> {
.collect() .collect()
} }
/// 归一化 agent vault token scopes 为 CLI 短名(`list/get/resolve/...`)。
///
/// 接受 `vault.list` 等前缀写法;空输入 → 默认读密三件套。
pub(crate) fn normalize_agent_vault_scopes(input: Vec<String>) -> Vec<String> {
let mapped: BTreeSet<String> = input
.into_iter()
.map(|s| {
let t = s.trim();
match t {
"vault.list" => "list".into(),
"vault.get" => "get".into(),
"vault.resolve" => "resolve".into(),
"vault.login" => "login".into(),
"vault.session" => "session".into(),
other => other.to_string(),
}
})
.filter(|s| !s.is_empty())
.collect();
if mapped.is_empty() {
vec!["list".into(), "get".into(), "resolve".into()]
} else {
mapped.into_iter().collect()
}
}
/// POST /api/vault/ai/token — 签发 agent vault token`mnv1.*`)。
///
/// 7-76:用户本人或 admin 代签;与 Web PAT / 扩展 token 分职。
pub async fn issue_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_issue_via_pat_forbidden",
"不能用 Web PAT 签发 vault token;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"签发 vault token 需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
let map = body.as_object();
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject_user_id")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_delegate_forbidden",
"只有管理员可以代用户签发 vault token",
)
.with_context(&context));
}
let ttl_secs = map
.and_then(|m| m.get("ttlSecs").or_else(|| m.get("ttl_secs")))
.and_then(Value::as_u64)
.or_else(|| {
map.and_then(|m| m.get("ttlHours").or_else(|| m.get("ttl_hours")))
.and_then(Value::as_u64)
.map(|h| h.saturating_mul(3600))
});
let scopes_raw: Vec<String> = map
.and_then(|m| m.get("scopes").or_else(|| m.get("scope")))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.filter(|v: &Vec<String>| !v.is_empty())
.unwrap_or_default();
let scopes = normalize_agent_vault_scopes(scopes_raw);
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let key = mnote_vault_core::load_or_create_hmac_key(&mnote_vault_core::default_hmac_key_path())
.map_err(WebError::from)?;
let issued = mnote_vault_core::issue_token(
&key,
&format!("user:{subject}"),
&subject,
&scope_refs,
ttl_secs,
None,
)
.map_err(WebError::from)?;
let auto_install = map
.and_then(|m| m.get("install").or_else(|| m.get("autoInstall")))
.and_then(Value::as_bool)
.unwrap_or(true);
let mut install_result = None;
if auto_install {
match crate::routes::local_agent_install::install_vault_token_with_meta(
state.config(),
&subject,
&issued.token,
&issued.claims.jti,
&scopes,
Some(&subject),
) {
Ok(v) => install_result = Some(v),
Err(e) => {
// 签发成功但安装失败:仍返回 token,附 error
install_result = Some(json!({
"ok": false,
"installed": false,
"error": e.message(),
"code": e.code(),
}));
}
}
}
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor.clone()),
action: if subject == actor {
"vault_token.issue".into()
} else {
"vault_token.issue.delegated".into()
},
target_kind: "vault_token".into(),
target_id: Some(issued.claims.jti.clone()),
metadata_json: json!({
"subjectUserId": subject,
"scopes": scopes,
"delegated": subject != actor,
"autoInstall": auto_install,
"installed": install_result
.as_ref()
.and_then(|v| v.get("installed"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
})
.to_string(),
});
Ok(ok_response(
&context,
json!({
"token": issued.token,
"jti": issued.claims.jti,
"actor": issued.claims.actor,
"sub": issued.claims.sub,
"scope": issued.claims.scope,
"exp": issued.claims.exp,
"aud": issued.claims.aud,
"install": install_result,
"warning": if auto_install {
"明文仅此一次完整返回;已尝试一键写入本机 vault-tokens 与 agent-env。"
} else {
"明文仅此一次完整返回;请点击「配置」写入本机,或勿提交仓库。"
},
}),
))
}
/// POST /api/vault/ai/token/install — 一键把 vault token 写到本机
pub async fn install_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_install_via_pat_forbidden",
"不能用 Web PAT 安装 vault token;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"安装 vault token 需要登录会话",
)
.with_context(&context)
})?;
let map = body.as_object();
let token = map
.and_then(|m| m.get("token"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("vault_token_required", "需要 token 明文(签发后仅一次)")
})?;
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
// 本机配置按 subject 隔离;非 admin 只能写自己的 vault-tokens/<self>.token
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_install_forbidden",
"只能配置自己的 vault 本机 token",
)
.with_context(&context));
}
let jti = map
.and_then(|m| m.get("jti"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let result = crate::routes::local_agent_install::install_vault_token(
state.config(),
&subject,
token,
&jti,
)?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor),
action: "vault_token.install".into(),
target_kind: "vault_token".into(),
target_id: Some(jti),
metadata_json: json!({
"subjectUserId": subject,
"environment": result.get("environment"),
"path": result.get("path"),
})
.to_string(),
});
Ok(ok_response(&context, result))
}
/// POST /api/vault/ai/token/uninstall — 删除本机 vault token 文件并清理 agent-env 指针
pub async fn uninstall_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_uninstall_via_pat_forbidden",
"不能用 Web PAT 取消 vault 配置;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"取消 vault 配置需要登录会话",
)
.with_context(&context)
})?;
let map = body.as_object();
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
// 非 admin 只能取消自己的本机 vault 配置,避免交叉删他人 token
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_uninstall_forbidden",
"只能取消自己的 vault 本机配置",
)
.with_context(&context));
}
let result =
crate::routes::local_agent_install::uninstall_vault_token(state.config(), &subject)?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor),
action: "vault_token.uninstall".into(),
target_kind: "vault_token".into(),
target_id: Some(subject.clone()),
metadata_json: json!({
"subjectUserId": subject,
"environment": result.get("environment"),
})
.to_string(),
});
Ok(ok_response(&context, result))
}
/// GET /api/vault/ai/token/local — 本机已配置的 vault token 列表
///
/// 默认只返回**当前登录用户**的本机配置,避免 liaibo / mnote-e2e 交叉。
/// admin 可传 `?all=1` 查看本机全部 subject。
pub async fn list_local_agent_vault_tokens(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
let want_all = query
.get("all")
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
let list_all = is_admin && want_all;
Ok(ok_response(
&context,
crate::routes::local_agent_install::list_local_vault_installs(
state.config(),
&actor,
list_all,
),
))
}
/// GET /api/vault/ai/token/local/{subject} — 详情:本机 token 明文 + 配置方法
pub async fn detail_local_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(subject): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_detail_via_pat_forbidden",
"不能用 Web PAT 查看 vault 本机详情;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_detail_forbidden",
"只能查看自己的 vault 本机配置",
)
.with_context(&context));
}
let detail =
crate::routes::local_agent_install::vault_token_detail(state.config(), &subject)?;
Ok(ok_response(&context, detail))
}
/// POST /api/vault/extension/token — issue E2 human token (mnext1.*) after session login. /// POST /api/vault/extension/token — issue E2 human token (mnext1.*) after session login.
pub async fn issue_extension_token( pub async fn issue_extension_token(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -1671,14 +2166,16 @@ pub async fn share_to_ai(
)); ));
} }
// 相对分组覆盖(可选)。省略时使用源条目 folderPath。
// AI 侧最终路径由 compose_ai_shared_folder_path 生成:`{分享者}/{原分组…}`。
// 不再默认写成扁平的 `from-{actor}`(会丢掉用户原有「系统/个人」层级)。
let folder_override = map let folder_override = map
.get("folderPath") .get("folderPath")
.or_else(|| map.get("folder_path")) .or_else(|| map.get("folder_path"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| s.to_string()) .map(|s| s.to_string());
.or_else(|| Some(format!("from-{}", actor)));
// Default true: copy missing [Key] fragments into AI cipher-book so templates resolve. // Default true: copy missing [Key] fragments into AI cipher-book so templates resolve.
// Existing non-empty target keys are never overwritten. // Existing non-empty target keys are never overwritten.
@@ -2016,6 +2513,23 @@ mod resolve_strategy_tests {
/// Serialize tests that touch the shared AI vault path. /// Serialize tests that touch the shared AI vault path.
static AI_VAULT_TEST_LOCK: Mutex<()> = Mutex::new(()); static AI_VAULT_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn normalize_agent_vault_scopes_defaults_and_strips_vault_prefix() {
assert_eq!(
normalize_agent_vault_scopes(vec![]),
vec!["list", "get", "resolve"]
);
assert_eq!(
normalize_agent_vault_scopes(vec![
"vault.list".into(),
"vault.resolve".into(),
"get".into(),
"vault.login".into(),
]),
vec!["get", "list", "login", "resolve"]
);
}
#[test] #[test]
fn resolve_rejects_invalid_field_before_io() { fn resolve_rejects_invalid_field_before_io() {
let err = let err =
@@ -112,10 +112,11 @@ pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, WebError> {
) )
})?; })?;
let hex = raw.trim(); let hex = raw.trim();
if hex.len() < 32 { // 与生成侧 32 字节/64 hex 一致,拒绝弱 key。
if hex.len() < 64 {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"vault_ext_hmac_key_invalid", "vault_ext_hmac_key_invalid",
"extension HMAC key 过短", "extension HMAC key 过短(需要至少 32 字节 / 64 hex)",
)); ));
} }
return hex::decode(hex).map_err(|e| { return hex::decode(hex).map_err(|e| {
@@ -308,6 +309,8 @@ pub fn verify_extension_token(token: &str) -> Result<ExtensionTokenClaims, WebEr
Ok(claims) Ok(claims)
} }
/// 目前仅 unit test 使用;extension 路由后续接 scope 校验时可去掉 cfg。
#[cfg(test)]
pub fn require_scope(claims: &ExtensionTokenClaims, need: &str) -> Result<(), WebError> { pub fn require_scope(claims: &ExtensionTokenClaims, need: &str) -> Result<(), WebError> {
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect(); let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
if set.contains(need) || set.contains("*") { if set.contains(need) || set.contains("*") {
+57 -20
View File
@@ -9,21 +9,26 @@ use axum::http::StatusCode;
/// Workspace-root-relative path that points at the password vault system space. /// Workspace-root-relative path that points at the password vault system space.
/// ///
/// `rel` should use `/` separators. Leading `./` is stripped. Parent segments /// `rel` should use `/` separators. Collapses `.` / empty segments and resolves
/// (`..`) are rejected before classification (callers usually already do this). /// `..` with a stack (cannot climb above workspace root). This closes
/// `.mnote/./vault/...` and `.mnote/vault/../vault/...` classification gaps.
pub fn normalize_workspace_relative_path(rel: &str) -> String { pub fn normalize_workspace_relative_path(rel: &str) -> String {
let mut value = rel.trim().replace('\\', "/"); let value = rel.trim().replace('\\', "/");
while value.starts_with("./") { let mut parts: Vec<&str> = Vec::new();
value = value[2..].to_string(); for segment in value.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
if !parts.is_empty() {
parts.pop();
}
// Climbing above workspace root is discarded (fail closed at root).
continue;
}
parts.push(segment);
} }
value = value.trim_start_matches('/').to_string(); parts.join("/")
while value.contains("//") {
value = value.replace("//", "/");
}
if value.ends_with('/') && value != "/" {
value.pop();
}
value
} }
/// Returns true when `rel` is `.mnote/vault` or any path under it. /// Returns true when `rel` is `.mnote/vault` or any path under it.
@@ -32,13 +37,6 @@ pub fn is_vault_sensitive_relative_path(rel: &str) -> bool {
if n.is_empty() { if n.is_empty() {
return false; return false;
} }
if n
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
// Escape / empty segments are not vault matches; callers reject escape.
return false;
}
n == ".mnote/vault" || n.starts_with(".mnote/vault/") n == ".mnote/vault" || n.starts_with(".mnote/vault/")
} }
@@ -101,6 +99,23 @@ mod tests {
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok()); assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
} }
#[test]
fn detects_dot_segment_and_parent_resolved_vault_paths() {
assert!(is_vault_sensitive_relative_path(
".mnote/./vault/entries/x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/../vault/cipher-book.json"
));
assert!(is_vault_sensitive_relative_path(
"foo/../.mnote/vault/secret.json"
));
// After resolving out of vault, no longer sensitive.
assert!(!is_vault_sensitive_relative_path(
".mnote/vault/../trash/a.md"
));
}
#[test] #[test]
fn denies_cipher_book_and_index_under_vault() { fn denies_cipher_book_and_index_under_vault() {
assert!(is_vault_sensitive_relative_path( assert!(is_vault_sensitive_relative_path(
@@ -116,4 +131,26 @@ mod tests {
.expect_err("must deny cipher-book via general file surface"); .expect_err("must deny cipher-book via general file surface");
assert_eq!(err.code(), "vault_path_denied"); assert_eq!(err.code(), "vault_path_denied");
} }
#[test]
fn normalize_collapses_dot_and_parent_segments() {
assert_eq!(
normalize_workspace_relative_path(".mnote/./vault/secret.json"),
".mnote/vault/secret.json"
);
assert_eq!(
normalize_workspace_relative_path(".mnote/vault/../vault/a"),
".mnote/vault/a"
);
assert_eq!(
normalize_workspace_relative_path("foo/../../.mnote/vault/x"),
".mnote/vault/x"
);
assert!(is_vault_sensitive_relative_path(
".mnote/./vault/secret.json"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/../vault/a"
));
}
} }
+307 -29
View File
@@ -493,31 +493,57 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
Ok(root) Ok(root)
} }
/// 将 session 路径段消毒为安全文件名,拒绝 `..` / 分隔符 / 控制字符。
fn sanitize_session_path_segment(raw: &str, fallback: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return fallback.to_string();
}
let mut out = String::with_capacity(trimmed.len().min(128));
for ch in trimmed.chars().take(128) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return fallback.to_string();
}
out
}
/// Normalize account id for session file name (`primary` when empty). /// Normalize account id for session file name (`primary` when empty).
pub fn normalize_session_account_id(account_id: Option<&str>) -> String { pub fn normalize_session_account_id(account_id: Option<&str>) -> String {
let raw = account_id.map(str::trim).unwrap_or(""); let raw = account_id.map(str::trim).unwrap_or("");
if raw.is_empty() || raw.eq_ignore_ascii_case("primary") { if raw.is_empty() || raw.eq_ignore_ascii_case("primary") {
SESSION_ACCOUNT_PRIMARY.to_string() return SESSION_ACCOUNT_PRIMARY.to_string();
} else if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
SESSION_ACCOUNT_PRIMARY.to_string()
} else {
raw.to_string()
} }
if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
return SESSION_ACCOUNT_PRIMARY.to_string();
}
sanitize_session_path_segment(raw, SESSION_ACCOUNT_PRIMARY)
} }
/// `sessions/{credId}/` under vault root. /// `sessions/{credId}/` under vault root.
/// 公共相对路径 API;内部读写走 `*_abs`,保留供外部/脚本使用。
#[allow(dead_code)]
pub fn session_dir_rel(credential_id: &str) -> String { pub fn session_dir_rel(credential_id: &str) -> String {
format!("sessions/{credential_id}") let cred = sanitize_session_path_segment(credential_id, "invalid");
format!("sessions/{cred}")
} }
/// `sessions/{credId}/{accountId}.json` /// `sessions/{credId}/{accountId}.json`
#[allow(dead_code)]
pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String { pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String {
let cred = sanitize_session_path_segment(credential_id, "invalid");
let acc = normalize_session_account_id(account_id); let acc = normalize_session_account_id(account_id);
format!("sessions/{credential_id}/{acc}.json") format!("sessions/{cred}/{acc}.json")
} }
fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf { fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf {
vault.join("sessions").join(credential_id) let cred = sanitize_session_path_segment(credential_id, "invalid");
vault.join("sessions").join(cred)
} }
fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf { fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf {
@@ -577,6 +603,19 @@ fn atomic_write_session_string(path: &Path, content: &str) -> Result<(), WebErro
format!("无法写入登录态: {error}"), format!("无法写入登录态: {error}"),
) )
})?; })?;
// 会话含 cookie/tokenrename 前 fsync,降低崩溃半写风险。
f.sync_all().map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法同步登录态到磁盘: {error}"),
)
})?;
// 尽量限制为属主读写(Unix);Windows 上 set_permissions 语义不同,忽略失败。
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
}
} }
fs::rename(&tmp, path).map_err(|error| { fs::rename(&tmp, path).map_err(|error| {
WebError::bad_request_code( WebError::bad_request_code(
@@ -584,6 +623,11 @@ fn atomic_write_session_string(path: &Path, content: &str) -> Result<(), WebErro
format!("无法提交登录态: {error}"), format!("无法提交登录态: {error}"),
) )
})?; })?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(()) Ok(())
} }
@@ -607,6 +651,8 @@ fn read_session_file_at(path: &Path) -> Result<Option<VaultSessionFile>, WebErro
} }
/// Read one session file (no frontmatter fallback). /// Read one session file (no frontmatter fallback).
/// 公共包装;内部路径用 `read_session_file_at`。
#[allow(dead_code)]
pub fn read_session_file( pub fn read_session_file(
workspace_root: &Path, workspace_root: &Path,
credential_id: &str, credential_id: &str,
@@ -1670,6 +1716,210 @@ pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
} }
} }
/// Sanitize one folder segment (username / actor id) for use in `folderPath`.
pub fn sanitize_folder_segment(raw: &str) -> Option<String> {
let s = raw.trim();
if s.is_empty() || s == "." || s == ".." {
return None;
}
let cleaned: String = s
.chars()
.map(|c| match c {
'/' | '\\' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let cleaned = cleaned.trim_matches('_').trim();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
None
} else {
Some(cleaned.to_string())
}
}
fn strip_ai_share_namespace(path: &str, actor_ns: &str) -> Option<String> {
let path = path.trim().trim_matches('/');
if path.is_empty() {
return None;
}
let from_ns = format!("from-{actor_ns}");
if path == actor_ns || path == from_ns {
return None;
}
if let Some(rest) = path
.strip_prefix(&format!("{actor_ns}/"))
.or_else(|| path.strip_prefix(&format!("{from_ns}/")))
{
return normalize_folder_path(Some(rest));
}
Some(path.to_string())
}
/// Compose AI 密码本分组路径:`{sourceUser}/{原用户分组…}`。
pub fn compose_ai_shared_folder_path(
source_actor_id: &str,
relative: Option<&str>,
) -> Option<String> {
let Some(ns) = sanitize_folder_segment(source_actor_id) else {
return normalize_folder_path(relative);
};
let relative = normalize_folder_path(relative)
.and_then(|p| strip_ai_share_namespace(&p, &ns));
match relative {
Some(r) if !r.is_empty() => Some(format!("{ns}/{r}")),
_ => Some(ns),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RepairAiFolderPathChange {
pub id: String,
pub title: String,
pub source_actor_id: String,
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RepairAiFolderPathsOutcome {
pub scanned: usize,
pub updated: usize,
pub skipped: usize,
pub failed: usize,
pub changes: Vec<RepairAiFolderPathChange>,
}
pub fn normalize_share_actor_id(raw: &str) -> Option<String> {
let s = raw.trim();
let s = s
.strip_prefix("user:")
.or_else(|| s.strip_prefix("user/"))
.unwrap_or(s)
.trim();
sanitize_folder_segment(s)
}
pub fn parse_from_actor_folder_prefix(folder_path: Option<&str>) -> Option<String> {
let p = normalize_folder_path(folder_path)?;
let rest = p.strip_prefix("from-")?;
let actor = rest.split('/').next().unwrap_or("").trim();
sanitize_folder_segment(actor)
}
/// 批量修复 AI 密码本分享副本分组为 `{分享者}/{源分组}`。
pub fn repair_ai_shared_folder_paths(
ai_workspace: &Path,
resolve_source_workspace: impl FnMut(&str) -> Option<PathBuf>,
) -> Result<RepairAiFolderPathsOutcome, WebError> {
repair_ai_shared_folder_paths_with_default(ai_workspace, resolve_source_workspace, None)
}
pub fn repair_ai_shared_folder_paths_with_default(
ai_workspace: &Path,
mut resolve_source_workspace: impl FnMut(&str) -> Option<PathBuf>,
default_source_actor: Option<&str>,
) -> Result<RepairAiFolderPathsOutcome, WebError> {
let (_index, entries) = list_credentials(ai_workspace, VaultItemStatus::Active)?;
let mut scanned = 0usize;
let mut updated = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
let mut changes = Vec::new();
let mut known_actors: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
if let Some(d) = default_source_actor.and_then(normalize_share_actor_id) {
known_actors.insert(d);
}
let mut records: Vec<VaultCredentialRecord> = Vec::with_capacity(entries.len());
for entry in &entries {
match get_credential(ai_workspace, &entry.id) {
Ok(r) => {
if let Some(origin) = r.shared_from.as_ref() {
if let Some(a) = normalize_share_actor_id(&origin.source_actor_id) {
known_actors.insert(a);
}
}
if let Some(a) = parse_from_actor_folder_prefix(r.folder_path.as_deref()) {
known_actors.insert(a);
}
records.push(r);
}
Err(_) => {
scanned += 1;
failed += 1;
}
}
}
let sole_actor = if known_actors.len() == 1 {
known_actors.iter().next().cloned()
} else {
default_source_actor.and_then(normalize_share_actor_id)
};
for mut record in records {
scanned += 1;
let source_id_hint = record
.shared_from
.as_ref()
.map(|o| o.source_id.clone())
.unwrap_or_default();
let source_actor = record
.shared_from
.as_ref()
.and_then(|o| normalize_share_actor_id(&o.source_actor_id))
.or_else(|| parse_from_actor_folder_prefix(record.folder_path.as_deref()))
.or_else(|| sole_actor.clone());
let Some(source_actor) = source_actor else {
skipped += 1;
continue;
};
let relative_from_source = if !source_id_hint.is_empty() {
resolve_source_workspace(&source_actor)
.and_then(|src_root| get_credential(&src_root, &source_id_hint).ok())
.and_then(|src| src.folder_path)
} else {
None
};
let relative = relative_from_source.or_else(|| {
let p = record.folder_path.as_deref()?;
strip_ai_share_namespace(p, &source_actor)
});
let new_path = compose_ai_shared_folder_path(&source_actor, relative.as_deref());
if record.folder_path == new_path {
skipped += 1;
continue;
}
let from = record.folder_path.clone();
record.folder_path = new_path.clone();
record.updated_at = now_rfc3339();
record.revision = record.revision.saturating_add(1);
if write_record_with_index(ai_workspace, &record).is_err() {
failed += 1;
continue;
}
updated += 1;
changes.push(RepairAiFolderPathChange {
id: record.id.clone(),
title: record.title.clone(),
source_actor_id: source_actor,
from,
to: new_path,
});
}
Ok(RepairAiFolderPathsOutcome {
scanned,
updated,
skipped,
failed,
changes,
})
}
/// Validate cipher-book key: `[A-Za-z0-9_]+`, 1..=32 chars (matches `[Key]` in passwords). /// Validate cipher-book key: `[A-Za-z0-9_]+`, 1..=32 chars (matches `[Key]` in passwords).
pub fn validate_cipher_key(key: &str) -> Result<(), WebError> { pub fn validate_cipher_key(key: &str) -> Result<(), WebError> {
let key = key.trim(); let key = key.trim();
@@ -1954,11 +2204,32 @@ fn yaml_escape(value: &str) -> String {
if value.is_empty() { if value.is_empty() {
return "\"\"".to_string(); return "\"\"".to_string();
} }
if value.chars().any(|c| { // 双引号标量必须转义控制字符,否则按行解析 frontmatter 时多行值会被截断。
let needs_quote = value.chars().any(|c| {
c.is_whitespace() c.is_whitespace()
|| matches!(c, ':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>' | '%' | '@' | '`') || c.is_control()
}) { || matches!(
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) c,
':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>'
| '%' | '@' | '`'
)
});
if needs_quote {
let mut escaped = String::with_capacity(value.len() + 8);
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
c if c.is_control() => {
escaped.push_str(&format!("\\u{:04x}", c as u32));
}
c => escaped.push(c),
}
}
format!("\"{escaped}\"")
} else { } else {
value.to_string() value.to_string()
} }
@@ -3035,11 +3306,11 @@ fn apply_source_fields_to_ai_copy(
target.secrets = source.secrets.clone(); target.secrets = source.secrets.clone();
target.fields = source.fields.clone(); target.fields = source.fields.clone();
target.notes_markdown = source.notes_markdown.clone(); target.notes_markdown = source.notes_markdown.clone();
// AI 副本分组必须带分享者命名空间;禁止把用户原 folder_path 原样写回。
target.folder_path = folder_path_override target.folder_path = folder_path_override
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| s.to_string()) .map(|s| s.to_string())
.or_else(|| source.folder_path.clone())
.or_else(|| target.folder_path.clone()); .or_else(|| target.folder_path.clone());
target.tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); target.tags = merge_ai_shared_tags(source.tags.clone(), extra_tags);
// Keep AI-side login session always; seed playbook only if missing. // Keep AI-side login session always; seed playbook only if missing.
@@ -3150,7 +3421,8 @@ pub fn sync_shared_ai_copy(
/// When `sync_cipher_keys` is true, missing target cipher fragments are copied /// When `sync_cipher_keys` is true, missing target cipher fragments are copied
/// from the source book (existing non-empty target keys are never overwritten). /// from the source book (existing non-empty target keys are never overwritten).
/// If source already has `shared_to_ai` and the target still exists, **updates** that copy. /// If source already has `shared_to_ai` and the target still exists, **updates** that copy.
/// Tags get `ai-shared` if missing. Optional `folder_path_override` (e.g. `from-user`). /// Tags get `ai-shared` if missing.
/// `folder_path_override`:可选相对分组;AI 侧路径 = `{sourceUser}/{relative}`。
pub fn share_credential_to_workspace( pub fn share_credential_to_workspace(
source_workspace: &Path, source_workspace: &Path,
target_workspace: &Path, target_workspace: &Path,
@@ -3169,6 +3441,12 @@ pub fn share_credential_to_workspace(
)); ));
} }
let relative = folder_path_override
.map(str::trim)
.filter(|s| !s.is_empty())
.or_else(|| source.folder_path.as_deref());
let ai_folder = compose_ai_shared_folder_path(source_actor_id, relative);
// Re-share / sync existing linked copy when still present. // Re-share / sync existing linked copy when still present.
if let Some(link) = source.shared_to_ai.clone() { if let Some(link) = source.shared_to_ai.clone() {
if !link.target_id.is_empty() if !link.target_id.is_empty()
@@ -3181,7 +3459,7 @@ pub fn share_credential_to_workspace(
&link.target_id, &link.target_id,
source_actor_id, source_actor_id,
target_actor_id, target_actor_id,
folder_path_override, ai_folder.as_deref(),
sync_cipher_keys, sync_cipher_keys,
); );
} }
@@ -3201,11 +3479,7 @@ pub fn share_credential_to_workspace(
} }
let tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); let tags = merge_ai_shared_tags(source.tags.clone(), extra_tags);
let folder = folder_path_override let folder = ai_folder;
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| source.folder_path.clone());
let mut item = create_credential( let mut item = create_credential(
target_workspace, target_workspace,
VaultCreateInput { VaultCreateInput {
@@ -3293,6 +3567,8 @@ pub fn maybe_sync_after_source_update(
if link.target_id.is_empty() { if link.target_id.is_empty() {
return Ok(None); return Ok(None);
} }
let ai_folder =
compose_ai_shared_folder_path(source_actor_id, source.folder_path.as_deref());
match sync_shared_ai_copy( match sync_shared_ai_copy(
source_workspace, source_workspace,
source, source,
@@ -3300,7 +3576,7 @@ pub fn maybe_sync_after_source_update(
&link.target_id, &link.target_id,
source_actor_id, source_actor_id,
&link.target_actor_id, &link.target_actor_id,
None, ai_folder.as_deref(),
sync_cipher_keys, sync_cipher_keys,
) { ) {
Ok(outcome) => Ok(Some(outcome)), Ok(outcome) => Ok(Some(outcome)),
@@ -4228,6 +4504,8 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value {
/// ///
/// Does **not** rewrite credential markdown with cookie secrets. /// Does **not** rewrite credential markdown with cookie secrets.
/// AI multi-agent session write remains in `mnote-vault-core` (12-2). /// AI multi-agent session write remains in `mnote-vault-core` (12-2).
/// 兼容封装 → `put_login_session_for_account`;生产路由走后者,本函数主要给测试。
#[cfg(test)]
pub fn put_login_session( pub fn put_login_session(
workspace_root: &Path, workspace_root: &Path,
id: &str, id: &str,
@@ -4750,7 +5028,7 @@ mod tests {
&src, &src,
&dst, &dst,
&created.id, &created.id,
Some("from-user"), None,
&[], &[],
true, true,
"user-a", "user-a",
@@ -4761,7 +5039,7 @@ mod tests {
assert_ne!(shared.id, created.id); assert_ne!(shared.id, created.id);
assert_eq!(shared.password.as_deref(), Some("Li@[A]s3cret")); assert_eq!(shared.password.as_deref(), Some("Li@[A]s3cret"));
assert_eq!(shared.apikey.as_deref(), Some("sk-test")); assert_eq!(shared.apikey.as_deref(), Some("sk-test"));
assert_eq!(shared.folder_path.as_deref(), Some("from-user")); assert_eq!(shared.folder_path.as_deref(), Some("user-a/ai/keys"));
assert!(shared.tags.iter().any(|t| t == "ai-shared")); assert!(shared.tags.iter().any(|t| t == "ai-shared"));
assert!(shared.tags.iter().any(|t| t == "prod")); assert!(shared.tags.iter().any(|t| t == "prod"));
assert!(shared.shared_from.is_some()); assert!(shared.shared_from.is_some());
@@ -4788,7 +5066,7 @@ mod tests {
&src, &src,
&dst, &dst,
&created.id, &created.id,
Some("from-user"), None,
&[], &[],
true, true,
"user-a", "user-a",
@@ -4824,7 +5102,7 @@ mod tests {
&src, &src,
&dst, &dst,
&created.id, &created.id,
Some("from-user"), None,
&[], &[],
true, true,
"user-a", "user-a",
@@ -4892,7 +5170,7 @@ mod tests {
&src, &src,
&dst, &dst,
&created.id, &created.id,
Some("from-user"), None,
&[], &[],
true, true,
"user-a", "user-a",
@@ -4944,7 +5222,7 @@ mod tests {
&src, &src,
&dst, &dst,
&created.id, &created.id,
Some("from-user"), None,
&[], &[],
true, true,
"user-a", "user-a",
@@ -4984,7 +5262,7 @@ mod tests {
&src, &src,
&dst, &dst,
&created.id, &created.id,
Some("from-user"), None,
&[], &[],
true, true,
"user-a", "user-a",
@@ -5437,7 +5715,7 @@ mod tests {
&src, &src,
&dst, &dst,
&created.id, &created.id,
Some("from-user"), None,
&[], &[],
false, false,
"user-a", "user-a",
@@ -186,10 +186,46 @@ fn map_http_error(status: u16, body: &str) -> WebError {
fn client_token() -> Result<Option<String>, WebError> { fn client_token() -> Result<Option<String>, WebError> {
match read_token_from_env_or_file() { match read_token_from_env_or_file() {
Ok(t) => Ok(Some(t)), Ok(t) => Ok(Some(t)),
Err(_) => Ok(None), Err(e) => {
// UDS-only 必须有 tokenauto 模式允许后续失败后回落 local core。
if transport_mode() == TransportMode::UdsOnly {
return Err(WebError::service_unavailable_code(
"vaultd_token_unavailable",
format!("无法读取 vaultd client token: {e}"),
));
}
Ok(None)
}
} }
} }
/// vaultd 路径段 id:拒 CRLF / 路径穿越 / 查询注入。
fn sanitize_vaultd_item_id(id: &str) -> Result<String, WebError> {
let id = id.trim();
if id.is_empty() || id.len() > 128 {
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 无效",
));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 含非法字符",
));
}
if id.contains("..") {
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 不得含 ..",
));
}
Ok(id.to_string())
}
fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError> fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError>
where where
F: FnOnce(Option<&str>) -> Result<Value, WebError>, F: FnOnce(Option<&str>) -> Result<Value, WebError>,
@@ -241,6 +277,7 @@ pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, WebError> {
} }
pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> { pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
let id = sanitize_vaultd_item_id(id)?;
with_transport( with_transport(
|token| { |token| {
let path = format!("/v1/items/{id}"); let path = format!("/v1/items/{id}");
@@ -252,7 +289,7 @@ pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
WebError::internal(format!("vaultd get JSON 无效: {e}")) WebError::internal(format!("vaultd get JSON 无效: {e}"))
}) })
}, },
|| vault::get_ai_vault_item(id), || vault::get_ai_vault_item(&id),
) )
} }
@@ -264,7 +301,7 @@ pub fn resolve_ai_vault_secret(
account_id: Option<&str>, account_id: Option<&str>,
secret_id: Option<&str>, secret_id: Option<&str>,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let id_owned = id.to_string(); let id_owned = sanitize_vaultd_item_id(id)?;
let field_owned = field.to_string(); let field_owned = field.to_string();
let account = account_id.map(str::to_string); let account = account_id.map(str::to_string);
let secret = secret_id.map(str::to_string); let secret = secret_id.map(str::to_string);
@@ -305,7 +342,7 @@ pub fn login_ai_vault_credential(
actor: &str, actor: &str,
request_id: Option<&str>, request_id: Option<&str>,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let id_owned = id.to_string(); let id_owned = sanitize_vaultd_item_id(id)?;
with_transport( with_transport(
|token| { |token| {
let path = format!("/v1/items/{id_owned}/login"); let path = format!("/v1/items/{id_owned}/login");
@@ -332,7 +369,7 @@ pub fn put_ai_vault_session(
actor: &str, actor: &str,
request_id: Option<&str>, request_id: Option<&str>,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let id_owned = id.to_string(); let id_owned = sanitize_vaultd_item_id(id)?;
let cookie = cookie_header.to_string(); let cookie = cookie_header.to_string();
let expires = expires_at.map(str::to_string); let expires = expires_at.map(str::to_string);
let source_owned = source.to_string(); let source_owned = source.to_string();
@@ -388,4 +425,16 @@ mod tests {
let _ = list_ai_vault_items(VaultItemStatus::Active); let _ = list_ai_vault_items(VaultItemStatus::Active);
std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT"); std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT");
} }
#[test]
fn sanitize_vaultd_item_id_rejects_path_injection() {
assert!(sanitize_vaultd_item_id("../x").is_err());
assert!(sanitize_vaultd_item_id("a/b").is_err());
assert!(sanitize_vaultd_item_id("a?b").is_err());
assert!(sanitize_vaultd_item_id("a\nb").is_err());
assert_eq!(
sanitize_vaultd_item_id("item-1_ok.x").unwrap(),
"item-1_ok.x"
);
}
} }

Some files were not shown because too many files have changed in this diff Show More