From 26ff1a9c9a8c96c99cd73f99b5e78ba4fbca92bc Mon Sep 17 00:00:00 2001 From: Agent Board Date: Tue, 28 Jul 2026 17:04:27 +0800 Subject: [PATCH] Harden auth/vault path sanitization and clean WeKnora docs --- .gitignore | 3 + AGENTS.md | 11 +- ...first-workspace-convex-control-plane-v1.md | 8 +- .../2-9-turso-control-plane-cutover-v1.md | 10 +- ...7-50-lightrag-knowledge-rag-provider-v1.md | 2 +- .../7-51-lightrag-post-commit-hardening-v1.md | 2 +- ...rag-image-ocr-search-chain-hardening-v1.md | 2 +- ...htrag-docx-citation-rerank-alignment-v1.md | 2 +- ...tive-docx-sidecar-addressable-blocks-v1.md | 2 +- ...rence-ai-run-config-knowledge-facade-v1.md | 2 +- ...control-plane-and-pi-lab-integration-v1.md | 2 +- ...-knowledge-rag-ui-panel-optimization-v1.md | 6 +- ...principal-web-pat-and-auth-hardening-v1.md | 631 ++++ .../07-ai/process/7-76-execution-goal-v1.md | 60 + design/10-review/README.md | 2 +- ...-post-architecture-closure-checklist-v1.md | 4 +- docs/operations/control-plane-turso.md | 10 +- package.json | 1 - .../task79x-weknora-kb-page-browser-smoke.js | 0 rust/crates/adapter-onlyoffice/src/lib.rs | 205 +- rust/crates/bridge-runtime/src/lib.rs | 51 +- .../src/bin/control-plane-admin.rs | 113 +- rust/crates/control-plane/src/model.rs | 94 +- rust/crates/core-protocol/src/command.rs | 18 +- rust/crates/core-protocol/src/governance.rs | 24 +- rust/crates/core-protocol/src/kernel.rs | 25 +- rust/crates/core-protocol/src/mindmap.rs | 3 +- rust/crates/index-fts/src/lib.rs | 90 +- rust/crates/mnote-editor-core/src/command.rs | 20 +- rust/crates/mnote-vault-core/src/ai.rs | 15 +- rust/crates/mnote-vault-core/src/store.rs | 820 ++++- rust/crates/mnote-vault-core/src/token.rs | 5 +- rust/crates/mnote-vault/src/main.rs | 105 +- .../document-editor-adapter-runtime.js | 24 +- .../browser/document-resource-tab-runtime.js | 2 +- .../mnote-web/browser/local-upload-runtime.js | 61 +- .../mnote-web/browser/mnote-ui-runtime.js | 183 +- .../sidebar-attachment-open-runtime.js | 6 +- .../sidebar-filetree-command-runtime.js | 64 +- .../browser/sidebar-filetree-open-runtime.js | 41 +- .../sidebar-filetree-upload-runtime.js | 4 +- .../browser/sidebar-page-ai-pi-lab-runtime.js | 76 +- .../browser/sidebar-page-ai-runtime.js | 15 +- .../browser/sidebar-page-settings-runtime.js | 142 +- .../mnote-web/browser/sidebar-tree-runtime.js | 4 +- .../mnote-web/browser/tree-shell-runtime.js | 12 +- .../browser/vault-workbench-runtime.js | 265 +- rust/crates/mnote-web/src/app.rs | 22 + rust/crates/mnote-web/src/context.rs | 72 +- .../mnote-web/src/document_buffer_store.rs | 30 + rust/crates/mnote-web/src/editor_actor.rs | 217 +- .../src/middleware/request_context.rs | 54 +- .../src/mnote_agent_tools/artifact.rs | 69 +- .../mnote-web/src/mnote_agent_tools/block.rs | 127 +- .../mnote-web/src/mnote_agent_tools/doc.rs | 225 +- .../mnote-web/src/mnote_agent_tools/index.rs | 4 +- .../src/mnote_agent_tools/knowledge_rag.rs | 192 +- .../src/mnote_agent_tools/manifest.rs | 9 +- .../mnote-web/src/mnote_agent_tools/mod.rs | 114 +- .../src/mnote_agent_tools/onlyoffice_live.rs | 320 +- .../mnote-web/src/mnote_agent_tools/page.rs | 8 +- .../src/mnote_agent_tools/resource.rs | 138 +- .../mnote-web/src/provider_identity_sync.rs | 157 +- .../mnote-web/src/routes/ai_settings.rs | 211 +- .../mnote-web/src/routes/api_access_token.rs | 1408 ++++++++ rust/crates/mnote-web/src/routes/bridge.rs | 36 +- rust/crates/mnote-web/src/routes/compat.rs | 4 + rust/crates/mnote-web/src/routes/dev_seed.rs | 1 + rust/crates/mnote-web/src/routes/documents.rs | 21 + rust/crates/mnote-web/src/routes/editor.rs | 1 + rust/crates/mnote-web/src/routes/evidence.rs | 1 + rust/crates/mnote-web/src/routes/gateway.rs | 117 +- rust/crates/mnote-web/src/routes/kernel.rs | 1 + .../mnote-web/src/routes/knowledge_rag.rs | 2843 +---------------- .../src/routes/local_agent_install.rs | 1238 +++++++ .../src/routes/local_folder_events.rs | 1 + .../src/routes/local_folder_source.rs | 794 ++++- rust/crates/mnote-web/src/routes/local_ocr.rs | 39 +- .../src/routes/local_search_index.rs | 37 +- .../mnote-web/src/routes/mindmap_api.rs | 1 + .../mnote-web/src/routes/mindmap_shell.rs | 32 +- .../mnote-web/src/routes/mnote_tools.rs | 264 +- rust/crates/mnote-web/src/routes/mod.rs | 60 +- .../mnote-web/src/routes/navigation_recent.rs | 13 +- .../crates/mnote-web/src/routes/onlyoffice.rs | 188 +- .../mnote-web/src/routes/onlyoffice_bridge.rs | 86 +- .../src/routes/page_ai_pi/runtime.rs | 546 +++- .../mnote-web/src/routes/page_ai_workflow.rs | 168 +- .../mnote-web/src/routes/resource_trash.rs | 69 +- rust/crates/mnote-web/src/routes/search.rs | 9 +- rust/crates/mnote-web/src/routes/session.rs | 68 +- .../mnote-web/src/routes/sidebar_shortcuts.rs | 24 +- rust/crates/mnote-web/src/routes/sse.rs | 79 +- .../mnote-web/src/routes/stream_support.rs | 32 +- rust/crates/mnote-web/src/routes/tree.rs | 138 +- rust/crates/mnote-web/src/routes/vault.rs | 550 +++- .../src/routes/vault_extension_token.rs | 7 +- .../crates/mnote-web/src/routes/vault_path.rs | 77 +- .../mnote-web/src/routes/vault_store.rs | 336 +- .../mnote-web/src/routes/vault_transport.rs | 59 +- rust/crates/mnote-web/src/routes/web_shell.rs | 74 +- rust/crates/mnote-web/src/routes/ws.rs | 67 +- rust/crates/mnote-web/src/ssr/pages/admin.rs | 3 +- .../mnote-web/src/ssr/pages/ai_admin.rs | 847 ++++- rust/crates/mnote-web/src/ssr/pages/auth.rs | 56 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 3 + rust/crates/mnote-web/src/ssr/styles.rs | 5 + .../src/ssr/styles/components/dialog.css | 113 + .../src/ssr/styles/components/main.css | 198 +- .../src/ssr/styles/components/vault.css | 11 +- .../src/tree_shell/filetree_renderer.rs | 5 +- .../src/tree_shell/filetree_runtime.rs | 22 +- .../mnote-web/src/tree_shell/page_runtime.rs | 3 +- .../src/tree_shell/picker_renderer.rs | 14 +- scripts/TESTING_REFERENCE.md | 41 +- scripts/dev-hot.js | 13 +- scripts/lib/browser-auth-login.js | 168 + scripts/lib/control-plane-dev-seed.js | 2 +- scripts/{desktop-hot.js => mnote-web-hot.js} | 2 +- ...ktop-hot.test.js => mnote-web-hot.test.js} | 6 +- scripts/qa-block-handle-menu-smoke.js | 13 +- scripts/qa-block-handle-menu.js | 22 +- scripts/task-ai-management-browser-smoke.js | 32 +- scripts/task-dev-hot-plan-test.js | 4 +- scripts/task-pi-lab-api-endpoint-smoke.js | 2 +- scripts/task-pi-lab-browser-smoke.js | 17 +- scripts/task-pi-lab-folder-file-tool-smoke.js | 32 +- .../task-pi-lab-full-access-ask-user-smoke.js | 105 +- ...pi-lab-full-access-builtin-delete-smoke.js | 32 +- scripts/task-pi-lab-history-tail-smoke.js | 32 +- scripts/task-pi-lab-input-controls-smoke.js | 32 +- scripts/task-pi-lab-model-capability-smoke.js | 32 +- ...sk-pi-lab-model-thinking-controls-smoke.js | 32 +- .../task-pi-lab-plan-mode-browser-smoke.js | 105 +- ...-pi-lab-real-extension-mcp-matrix-smoke.js | 109 +- scripts/task-pi-lab-real-lightrag-smoke.js | 111 +- scripts/task-pi-lab-real-skill-mcp-smoke.js | 111 +- scripts/task-pi-lab-rpc-browser-smoke.js | 17 +- scripts/task-pi-lab-ui-completion-smoke.js | 32 +- scripts/task-pi-lab-user-exact-web-smoke.js | 32 +- scripts/task-pi-lab-warmup-binding-smoke.js | 32 +- .../task114-rust-web-gateway-entry-smoke.js | 6 +- scripts/task159-auth-entry-smoke.js | 6 +- .../task426-mnote-web-main-no-reload-smoke.js | 29 +- ...k435-local-folder-watch-no-reload-smoke.js | 29 +- ...own-open-document-external-change-smoke.js | 29 +- ...8-filetree-title-md-active-reveal-smoke.js | 29 +- .../task439-filetree-title-md-rename-smoke.js | 29 +- ...sk440-page-title-filetree-md-sync-smoke.js | 29 +- ...task441-local-folder-cloud-switch-smoke.js | 29 +- .../task442-trash-modal-workbench-smoke.js | 29 +- ...sk443-local-markdown-asset-upload-smoke.js | 29 +- .../task457-main-editor-resource-tab-smoke.js | 29 +- ...458-local-create-page-no-conflict-smoke.js | 29 +- ...459-local-markdown-attachment-tab-smoke.js | 29 +- .../task460-resource-tab-close-dirty-smoke.js | 29 +- scripts/task461-resource-kind-smoke.js | 29 +- ...62-resource-tab-error-placeholder-smoke.js | 29 +- scripts/task463-onlyoffice-resolver-smoke.js | 29 +- ...64-local-folder-resource-trash-ui-smoke.js | 29 +- ...-local-folder-bulk-resource-trash-smoke.js | 29 +- ...ask472-side-target-secondary-pane-smoke.js | 29 +- ...rash-restore-no-refresh-focus-gap-smoke.js | 29 +- ...sk474-local-folder-empty-trash-ui-smoke.js | 29 +- ...tree-editor-context-menu-download-smoke.js | 29 +- ...older-markdown-resource-lifecycle-smoke.js | 29 +- ...7-local-folder-tree-live-consumer-smoke.js | 14 +- ...local-attachment-link-delete-undo-smoke.js | 29 +- .../task489-block-menu-delete-undo-smoke.js | 29 +- ...-local-md-attachment-icon-refresh-smoke.js | 29 +- ...3-local-pptx-upload-filetree-open-smoke.js | 29 +- ...cal-md-external-conflict-recovery-smoke.js | 29 +- ...sk505-resource-tab-watch-contract-smoke.js | 29 +- ...al-markdown-attachment-ref-matrix-smoke.js | 32 +- ...06-local-md-delete-move-lifecycle-smoke.js | 29 +- ...sk511-slash-menu-position-dismiss-smoke.js | 29 +- ...yoffice-real-iframe-session-scope-smoke.js | 29 +- ...-workspace-object-identity-matrix-smoke.js | 29 +- ...-local-search-result-open-locator-smoke.js | 5 +- ...38-knowledge-rag-source-scope-api-smoke.js | 10 +- ...-aggregate-local-first-hard-guard-smoke.js | 12 +- ...g-search-grouping-and-short-query-smoke.js | 8 +- scripts/test-handle-qa.js | 8 +- scripts/tree-shell-smoke-helpers.js | 60 +- scripts/vault-extension-api-smoke.js | 2 +- skills/mnote-knowledge-rag/SKILL.md | 4 +- skills/mnote-vault/SKILL.md | 71 +- skills/mnote-vault/agent-env.example.env | 20 + skills/mnote-vault/env.schema.json | 71 + wolai-backend/README.md | 2 +- 190 files changed, 13454 insertions(+), 4987 deletions(-) create mode 100644 design/07-ai/process/7-76-ai-principal-web-pat-and-auth-hardening-v1.md create mode 100644 design/07-ai/process/7-76-execution-goal-v1.md rename {scripts => recycle/scripts/retired-weknora-smokes-20260726}/task79x-weknora-kb-page-browser-smoke.js (100%) create mode 100644 rust/crates/mnote-web/src/routes/api_access_token.rs create mode 100644 rust/crates/mnote-web/src/routes/local_agent_install.rs create mode 100644 rust/crates/mnote-web/src/ssr/styles/components/dialog.css create mode 100644 scripts/lib/browser-auth-login.js rename scripts/{desktop-hot.js => mnote-web-hot.js} (99%) rename scripts/{desktop-hot.test.js => mnote-web-hot.test.js} (97%) create mode 100644 skills/mnote-vault/agent-env.example.env create mode 100644 skills/mnote-vault/env.schema.json diff --git a/.gitignore b/.gitignore index a301c88b..c6f3a542 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ env-archive/ /.serena/ /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. /REASONIX.md /reasonix.toml diff --git a/AGENTS.md b/AGENTS.md index 9fe4c511..35c8037c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ ## 常用命令 -- 热启动:`npm run desktop:hot` → `http://localhost:3000` +- 热启动:`npm run dev:hot` → `http://localhost:3000` - 测试:`cargo test -p mnote-web` - Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` - 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote` @@ -111,7 +111,14 @@ - 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth`。 - 禁止 smoke 用 `sqlite3` CLI 直写 control-plane;seed 走 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`)。 ## 前端测试 diff --git a/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md index eb79980d..bb04c5ee 100644 --- a/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md +++ b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md @@ -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-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 隔离且可共享。 @@ -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 简化版工作区内核**:本地文件夹、目录树、页面树、资源树、拖拽、重命名、移动、回收站、导入导出。 - **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 文本块。 - **Wolai 风格 web 壳**:提供统一导航、主题、页面壳、AI 面板、侧边栏和分享入口。 - **鉴权控制面**:负责登录、用户隔离、目录授权、分享授权、同步状态和审计,而不是默认正文存储。 @@ -900,7 +900,7 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编 #### 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 节。 - 验收:普通 local-first Markdown 编辑不再被描述为必须走 `mnote.block.*`、`mnote.doc.markdown_edit` 或 Convex documents。 - [x] A2. 建立 Convex 主存储新增检查。 diff --git a/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md index 0b1add53..5a35986b 100644 --- a/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md +++ b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md @@ -67,7 +67,7 @@ Turso 的收益不在于“SQLite 语法换个驱动”本身,而在于把控 - `.mnote/index/evidence.sqlite`。该库已从当前主线退役,本轮不迁移。 - LightRAG 自身存储。知识库/RAG provider 存储不在 control-plane 切换范围内。 - OpenHub 自身 SQLite 会话库。它可以作为第二阶段单独迁移,不应和 Rust control-plane 首次切换绑死。 -- WeKnora / RAGFlow。当前不作为主线迁移目标。 +- RAGFlow。当前不作为主线迁移目标。 - 浏览器直连 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` 占位哈希带入云端。 | | P1 | 读写并发性能设计 | 仅完成安全基线,未实现性能优化 | 当前 `TursoControlPlaneStore` 是 `Mutex`,读写全部串行;这能保证简单安全,但不能发挥 remote / replica 的读并发优势。 | | 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 权限真源。 | ### 11.4 读写并发设计原则 @@ -486,9 +486,9 @@ Turso 文档显示 FTS 通过 `CREATE INDEX ... USING fts` 和 `fts_match` / `ft 对 MNote 的判断: - 当前 control-plane 不需要:users、workspaces、grants、recent、shortcuts、policy 都是轻量元数据,`LIKE` 或精确索引足够。 -- 不迁正文:页面正文 `.md` 仍在本地文件系统,知识库问答主线是 OpenHub/WeKnora,不把正文全文塞进 control-plane。 +- 不迁正文:页面正文 `.md` 仍在本地文件系统,知识库问答主线是 OpenHub/LightRAG,不把正文全文塞进 control-plane。 - 可作为 P2/P3:如果以后要做轻量“标题 / 文件路径 / 页面摘要 / AI 会话标题”的本地快速搜索,可以单独设计 Turso FTS 索引。 -- 不替代 WeKnora/OpenHub RAG:Tantivy FTS 是关键词检索,不等于知识库引用、OCR、语义检索和 agent citation 链。 +- 不替代 LightRAG/OpenHub RAG:Tantivy FTS 是关键词检索,不等于知识库引用、OCR、语义检索和 agent citation 链。 结论:**对当前 control-plane 价值低;对未来轻量元数据搜索有价值;不进入本轮。** @@ -662,7 +662,7 @@ cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-a - `.mnote/index/evidence.sqlite`(已退役)→ 未迁移 - LightRAG 自身存储 → 未迁移 - OpenHub 自身 SQLite 会话库 → 未迁移(Phase 7 推迟) -- WeKnora / RAGFlow → 未迁移 +- RAGFlow → 未迁移 - 浏览器直连 Turso → 未实现(所有读写经过 Rust API) ### 2026-07-03 最终执行记录 diff --git a/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md b/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md index 829f9112..07f01ceb 100644 --- a/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md +++ b/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`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,仅保留为历史设计或参考边界。 > > Owner:07-ai / knowledge-rag / plugin-ui / external-provider > diff --git a/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md b/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md index d5c9ede5..15227c8a 100644 --- a/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md +++ b/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md @@ -4,7 +4,7 @@ > > 状态:`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。 > > Owner:07-ai / knowledge-rag / 03-rust-web / plugin-ui > diff --git a/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md b/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md index 8ce6509f..6895cbc7 100644 --- a/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md +++ b/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`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。 > > Owner:07-ai / knowledge-rag / local-search > diff --git a/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md b/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md index bb9f2c13..93758f2c 100644 --- a/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md +++ b/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`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 仅为归档边界。 > > Owner:07-ai / knowledge-rag / 03-rust-web / office-preview > diff --git a/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md b/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md index 58901cf4..ff4accae 100644 --- a/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md +++ b/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`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。 > > Owner:07-ai / knowledge-rag / LightRAG native parser / office-preview > diff --git a/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md b/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md index d6ce5aa0..f6c84d8a 100644 --- a/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md +++ b/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`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。 > > Owner:07-ai / Page AI runtime / Hermes client runs / Reasonix ACP / Knowledge RAG facade > diff --git a/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md b/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md index 6211e43f..281038c0 100644 --- a/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md +++ b/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md @@ -33,7 +33,7 @@ OpenHub 的 admin 面板(历史)证明了正确产品结构:provider/model 不应直接搬的部分: -- opencode 服务管理、飞书渠道、WeKnora 配置导出。 +- opencode 服务管理、飞书渠道、知识库配置导出。 - React/AntD 组件主链。 - OpenHub SQLite 表作为 MNote 真相层。 diff --git a/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md b/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md index c40b0a17..1329b9e3 100644 --- a/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md +++ b/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md @@ -91,7 +91,7 @@ Knowledge RAG 面板应回答用户四个问题: 显示: -- provider 名称:`LightRAG / WeKnora / RAGFlow / legacy fallback`。 +- provider 名称:`LightRAG / RAGFlow / legacy fallback`。 - health:在线、不可达、API key 错误、pipeline busy。 - dashboard 链接。 - VLM/OCR 能力:图片解析是否启用、MinerU/Docling/native 路由摘要。 @@ -129,7 +129,7 @@ Knowledge RAG 面板应回答用户四个问题: |----|------| | Source | root-relative path | | 类型 | markdown / image / pdf / office | -| Provider | LightRAG / WeKnora / RAGFlow | +| Provider | LightRAG / RAGFlow | | 状态 | registry + provider status 合并结果 | | 更新时间 | registry updatedAt / provider updatedAt | | 引用 | open_reference 可否定位 | @@ -334,7 +334,7 @@ AI 行为: ### 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。 - [ ] 新建 Knowledge RAG 面板基础 UI,显示 provider health、source summary、失败/stale 汇总。 - [ ] FileTree tooltip 展示当前 `indexStatus`,不改变索引行为。 diff --git a/design/07-ai/process/7-76-ai-principal-web-pat-and-auth-hardening-v1.md b/design/07-ai/process/7-76-ai-principal-web-pat-and-auth-hardening-v1.md new file mode 100644 index 00000000..ba9ca5f2 --- /dev/null +++ b/design/07-ai/process/7-76-ai-principal-web-pat-and-auth-hardening-v1.md @@ -0,0 +1,631 @@ +# 7-76 [process] AI 主体账号 · Web PAT · 生产级鉴权硬化 · 用户级密码箱 v1 + +> 创建时间:2026-07-26 +> 状态:`PROCESS`(**P0–P1b 已实现**;§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//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 **不等于** 登录 vault;vault token **不等于** 登录 Web;AI 主体密码 **不等于** 分发给所有用户的万能 key。 + +--- + +## 5. 生产级鉴权:撤快速登录 + +### 5.1 产品行为 + +| 环境 | 登录页 | 标准邮箱/用户名密码 | `/api/dev/seed` | AI 主体密码登录 | +|------|--------|---------------------|-----------------|----------------| +| 生产 | 无快速登录 | 开 | 关 | 仅知密者(admin) | +| 开发(对标生产) | **同样无快速登录** | 开 | 仅当 `ALLOW_DEV_FIXTURES=1` 的机机 seed | 同生产;本地 admin 自持密码 | + +开发调试方式: + +- 使用真实注册/登录流程,或本地已知 admin/测试人账号密码(**存在密码箱或本地 secret,不进 HTML**)。 +- 自动化:优先 PAT;次选 control-plane seed API(fixtures 开时);**禁止**依赖「快速登录」按钮选择器作为唯一 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.. + 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.. +# 或 mnpat1_ + 高熵 random(服务端只存 hash + sealed) +``` + +Claims(逻辑字段,无论 JWT 形还是 opaque): + +```json +{ + "v": 1, + "iss": "mnote-web", + "aud": "mnote-api", + "sub": "user:", + "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:///api/… # 具体 path 以 tools/tree/page 现网为准 + +# 外部 AI:以 AI 主体 PAT 写 AI 自己的知识库笔记 +curl -H "Authorization: Bearer mnpat1.…" \ + https:///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 分享密码给 AI(share-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 只能 resolve:AI 自有条目 ∪ 有效 share-to-AI 条目。 +3. 用户 A 的 Web PAT 默认 **不能** resolve 密码;除非错误地签发了 vault scope(产品向导应拆分两步,默认不勾)。 +4. 撤销 share 或 revoke token 后立即失效。 + +### 7.3 与 Page AI(Pi)的边界 + +| 角色 | 职责 | +|------|------| +| **Pi Lab(内部)** | 浏览器内 Page AI;cookie + directory_grants;不依赖外部 PAT | +| **外部 AI(Paseo/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//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: +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:", + "actor": "user:", + "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 / Skills(effective) +3. 目录权限(directory_grants,唯一授权真源) +4. **API 访问令牌**(Web PAT,可复显) +5. **密码箱 AI 访问**(vault token + 总闸 + share 摘要) +6. Sessions / Usage(人用 Pi;外部 AI 不依赖此写路径) + +### `/admin/ai` 侧栏 + +1. 全局 provider / model / policy(7-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 UI(sealed 复显) | 外部 AI 用 PAT 完成 notes/tree 读写 | **done**(代码+单测;端到端 smoke 建议补) | +| **P1** | 用户级 vault 路径 + share-to-AI + vault 区 | A 不能读 B;AI 能读 share | **done(主路径)**:复用 `users//…` + 12-x share-to-AI;Web 签发 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 API(PAT scope 白名单不含 Pi;无 `pi.run`)。 +- [x] reveal 产生审计(`api_token.reveal`)+ 30s 限流生效(代码路径)。 + +### 12.3 Vault 多用户 + +- [x] 每用户一箱路径解析:`users//workspaces/my-space` + AI vault 路径(12-x / vault-core)。 +- [ ] owner mismatch → deny(share 模型与路径隔离已有;**全量 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 跨用户拒绝 e2e(A 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 owner;Web 代签与多用户 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-AI;vault 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 | 执行:P0–P1b 落地(见 `7-76-execution-goal-v1.md`);P2 延后。 | +| 2026-07-26 | 核验并勾选 §10/§12 已完成项;修正 `test-handle-qa.js` 残留快速登录;§12.5 记录残留。 | diff --git a/design/07-ai/process/7-76-execution-goal-v1.md b/design/07-ai/process/7-76-execution-goal-v1.md new file mode 100644 index 00000000..e3b05926 --- /dev/null +++ b/design/07-ai/process/7-76-execution-goal-v1.md @@ -0,0 +1,60 @@ +# Goal:执行 7-76(AI 主体 · Web PAT · 鉴权硬化 · 用户级密码箱) + +> 创建:2026-07-26 +> 状态:`P0–P1b 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 忽略 cookie;scope 接线 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;完成 P0–P1b 实现与定向单测。 | +| 2026-07-26 | **方案 A 账户分离**:新建 `mnote-admin`(ops);`liaibo`/`mnote-e2e` 去 admin;`mnote-e2e` → `ai_service`;`MNOTE_ADMIN_USER_IDS` + `access-policy.admins` 仅 admin;Pi Lab smoke admin seed 改 `withAdminBrowserSession`。 | +| 2026-07-26 | 对照代码核验;设计稿 §12 勾选已完成项;修 `test-handle-qa.js` 残留快速登录。 | diff --git a/design/10-review/README.md b/design/10-review/README.md index 9f51fe9a..c874d640 100644 --- a/design/10-review/README.md +++ b/design/10-review/README.md @@ -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-*` 为准。 - 历史 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。 -- 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 / 备用边界。 diff --git a/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md b/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md index fa6743dd..04345662 100644 --- a/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md +++ b/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md @@ -42,7 +42,7 @@ - OpenHub / opencode 完整应用嵌入 - Hermes / Reasonix / ACP 默认 Page AI runtime -- WeKnora 默认知识库 provider +- 非 LightRAG 默认知识库 provider - Convex 默认控制面或正文/附件/AI 会话主存储 - BlockNote 默认编辑器 - Next App Router 3000 主入口 @@ -51,7 +51,7 @@ ## 3. 硬边界(全程禁止) - 不把正文、附件、mindmap、Office 文件迁入 Turso/libSQL。 -- 不恢复 OpenHub / Hermes / WeKnora 为默认入口或默认 provider。 +- 不恢复 OpenHub / Hermes 为默认入口;知识库默认 provider 保持 LightRAG。 - 不为「更正规」把 control-plane 换成 Postgres / 新云数据库。 - 不把 tiptap / Page Aggregate / LightRAG storage 当成正文真相。 - 不新增基于轮询的主链数据刷新。 diff --git a/docs/operations/control-plane-turso.md b/docs/operations/control-plane-turso.md index 78a37303..b893c1e1 100644 --- a/docs/operations/control-plane-turso.md +++ b/docs/operations/control-plane-turso.md @@ -37,7 +37,7 @@ export MNOTE_TURSO_SYNC_INTERVAL_MS=5000 - 本地 `.md`、附件、mindmap、OnlyOffice 文件仍是 local-first 文件真相,不迁入 Turso。 - 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。 - 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 ``` -备份文件仅用于审计、对照和后续手工恢复,不作为 `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 dev_seed -- --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-control-plane-admin-libsql-roundtrip-smoke.js 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` 的 SQLite 迁移 / 导出参数 - `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 测试边界 不允许命中范围: @@ -265,7 +265,7 @@ Tantivy FTS 当前不进入 control-plane: - Turso FTS 是 `CREATE INDEX ... USING fts` + `fts_match` / `fts_score` / `fts_highlight`,不是 SQLite FTS5 的无成本替换。 - 当前 control-plane 只存轻量元数据,全文搜索收益有限。 -- 页面正文、知识库和 RAG 仍走 local-first 文件 / OpenHub / WeKnora,不迁入 Turso control-plane。 +- 页面正文、知识库和 RAG 仍走 local-first 文件 / OpenHub / LightRAG,不迁入 Turso control-plane。 - 后续如需搜索页面标题、路径、AI 会话标题或轻量摘要,可单独做 P2/P3 spike。 Browser WASM / OPFS 当前不进入主线: diff --git a/package.json b/package.json index a60053dd..50915fef 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "private": true, "scripts": { "dev": "node scripts/dev-hot.js", - "desktop:hot": "node scripts/desktop-hot.js", "dev:hot": "node scripts/dev-hot.js", "prod:start": "node scripts/prod-build-start.js" }, diff --git a/scripts/task79x-weknora-kb-page-browser-smoke.js b/recycle/scripts/retired-weknora-smokes-20260726/task79x-weknora-kb-page-browser-smoke.js similarity index 100% rename from scripts/task79x-weknora-kb-page-browser-smoke.js rename to recycle/scripts/retired-weknora-smokes-20260726/task79x-weknora-kb-page-browser-smoke.js diff --git a/rust/crates/adapter-onlyoffice/src/lib.rs b/rust/crates/adapter-onlyoffice/src/lib.rs index b379a7a4..19cc22f6 100644 --- a/rust/crates/adapter-onlyoffice/src/lib.rs +++ b/rust/crates/adapter-onlyoffice/src/lib.rs @@ -166,6 +166,46 @@ pub fn sign_config(config: &Value, secret: &str) -> Result, + secret: &str, +) -> Result, 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 。 +pub fn extract_callback_jwt(body: &Value, authorization: Option<&str>) -> Option { + 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 { let session_id = input .session_id @@ -281,7 +321,7 @@ pub fn prepare_proxy_request( .host_str() .ok_or_else(|| "目标 URL 缺少 hostname".to_string())?; - if is_private_ipv4(hostname) + if is_private_or_local_ip_literal(hostname) && !is_local_hostname(hostname) && !allowed_hostnames.contains(hostname) { @@ -490,6 +530,53 @@ fn sign_hs256(payload: &Value, secret: &str) -> Result { Ok(format!("{signing_input}.{signature_part}")) } +fn verify_hs256(token: &str, secret: &str) -> Result { + 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 { let bytes = URL_SAFE_NO_PAD .decode(input.trim()) @@ -498,7 +585,11 @@ fn decode_base64url_to_utf8(input: &str) -> Result { } 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 { @@ -522,6 +613,88 @@ fn is_private_ipv4(hostname: &str) -> bool { || 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 { + 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 { + 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)> { let value = raw?.trim(); if value.is_empty() { @@ -579,6 +752,34 @@ mod tests { 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] fn proxy_request_rewrites_supabase_path_to_internal_origin() { let encoded_url = URL_SAFE_NO_PAD.encode( diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index e5d809cd..a50dad85 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -48,7 +48,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; static TOOL_BLOCK_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 deployment_id: Option, pub project_id: Option, @@ -67,6 +67,31 @@ pub struct BridgeContext { 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(|_| ""), + ) + .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)] pub enum BridgeErrorKind { Validation, @@ -271,7 +296,7 @@ pub enum RuntimeInput { }, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeBridgeContextWire { pub deployment_id: Option, @@ -288,6 +313,28 @@ pub struct RuntimeBridgeContextWire { 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(|_| ""), + ) + .field("idempotency_key", &self.idempotency_key) + .field("validate_only", &self.validate_only) + .field("dry_run", &self.dry_run) + .finish() + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeActorWire { diff --git a/rust/crates/control-plane/src/bin/control-plane-admin.rs b/rust/crates/control-plane/src/bin/control-plane-admin.rs index cd95c80f..00058418 100644 --- a/rust/crates/control-plane/src/bin/control-plane-admin.rs +++ b/rust/crates/control-plane/src/bin/control-plane-admin.rs @@ -46,7 +46,7 @@ enum Backend { TursoSynced, } -#[derive(Debug, Clone)] +#[derive(Clone)] struct BackendConfig { backend: Backend, sqlite_path: Option, @@ -57,13 +57,60 @@ struct BackendConfig { turso_token: Option, } -#[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(|_| ""), + ) + .finish() + } +} + +#[derive(Clone)] struct Args { command: String, flags: BTreeMap, switches: BTreeSet, } +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 { "".into() } else { v.clone() }) + }) + .collect(); + f.debug_struct("Args") + .field("command", &self.command) + .field("flags", &redacted_flags) + .field("switches", &self.switches) + .finish() + } +} + enum StoreHandle { Sqlite(SqliteControlPlaneStore), Turso(TursoControlPlaneStore), @@ -887,7 +934,7 @@ fn cmd_init(args: &Args) -> Result<(), Box> { "backend": format!("{:?}", config.backend), "planned": [ "run schema migrations", - "upsert default e2e/admin user", + "upsert default ops admin user (mnote-admin)", "create password identity", "upsert default local workspace", "grant write access", @@ -900,20 +947,46 @@ fn cmd_init(args: &Args) -> Result<(), Box> { } let store = open_store(&config)?; + // 7-76 方案 A:默认 init 主体是 ops admin(mnote-admin),不是 AI 主体 mnote-e2e。 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") - .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") - .unwrap_or_else(|| "mnote-e2e".to_string()); + .unwrap_or_else(|| "mnote-admin".to_string()); let display_name = flag_or_env( args, "display-name", "MNOTE_CONTROL_PLANE_INIT_DISPLAY_NAME", ) .unwrap_or_else(|| username.clone()); - let password = flag_or_env(args, "password", "MNOTE_CONTROL_PLANE_INIT_PASSWORD") - .unwrap_or_else(|| "MnoteE2E123!".to_string()); + // 禁止硬编码默认管理员密码。生产/本地 init 必须显式传入: + // --password 或 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( args, "workspace-id", @@ -925,26 +998,37 @@ fn cmd_init(args: &Args) -> Result<(), Box> { "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") - .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") .unwrap_or_else(|| format!("file://{root_path}")); + // role:mnote-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 { id: Some(user_id.clone()), email: Some(email.clone()), username: username.clone(), display_name, - role: Some("admin".to_string()), + role: Some(role), password_hash: None, })?; - let _ = store.create_password_identity(control_plane::CreatePasswordIdentityInput { + store.create_password_identity(control_plane::CreatePasswordIdentityInput { user_id: user.id.clone(), email: Some(email), username, password, - }); + })?; let workspace = store.upsert_workspace(UpsertWorkspaceInput { id: Some(workspace_id), owner_user_id: user.id.clone(), @@ -997,7 +1081,8 @@ fn cmd_init(args: &Args) -> Result<(), Box> { "ok": true, "backend": format!("{:?}", config.backend), "userId": user.id, - "workspaceId": workspace.id + "workspaceId": workspace.id, + "role": user.role, }))? ); Ok(()) diff --git a/rust/crates/control-plane/src/model.rs b/rust/crates/control-plane/src/model.rs index 104c3f4c..1ccafa68 100644 --- a/rust/crates/control-plane/src/model.rs +++ b/rust/crates/control-plane/src/model.rs @@ -19,12 +19,17 @@ pub struct UserRecord { pub revision: i64, } +/// 会话令牌哈希(领域前缀,避免与 share/password 等 token 类型混淆)。 +/// 注意:改前缀会使既有 session 行失效,需用户重新登录。 pub fn session_token_hash(raw_token: &str) -> String { let mut hasher = Sha256::new(); + hasher.update(b"mnote-session-token-v1:"); 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 { let mut hasher = Sha256::new(); 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())) } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UpsertUserInput { pub id: Option, pub email: Option, pub username: String, pub display_name: String, pub role: Option, + /// 密码哈希;Debug 脱敏,避免日志泄露。 pub password_hash: Option, } -#[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(|_| "") + .unwrap_or("None"), + ) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] pub struct CreatePasswordIdentityInput { pub user_id: EntityId, pub email: Option, pub username: String, + /// 明文密码仅用于创建身份;Debug/Serialize 必须脱敏。 pub password: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +impl Serialize for CreatePasswordIdentityInput { + fn serialize(&self, serializer: S) -> Result + 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", "")?; + 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", &"") + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] pub struct AuthenticatePasswordInput { pub account: String, + /// 明文密码仅用于鉴权;Debug/Serialize 脱敏。 pub password: String, pub session_id: Option, pub token_hash: String, @@ -68,6 +122,38 @@ pub struct AuthenticatePasswordInput { pub expires_at: Option, } +impl Serialize for AuthenticatePasswordInput { + fn serialize(&self, serializer: S) -> Result + 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", "")?; + state.serialize_field("session_id", &self.session_id)?; + state.serialize_field("token_hash", "")?; + 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", &"") + .field("session_id", &self.session_id) + .field("token_hash", &"") + .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)] pub struct AuthSessionRecord { pub id: EntityId, diff --git a/rust/crates/core-protocol/src/command.rs b/rust/crates/core-protocol/src/command.rs index f2bf4698..28de90e6 100644 --- a/rust/crates/core-protocol/src/command.rs +++ b/rust/crates/core-protocol/src/command.rs @@ -255,7 +255,8 @@ impl CommandContext { editor_dirty: false, editor_dirty_state: Some("clean".to_string()), 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 } - /// 用 builder 模式设置 editor dirty。 + /// 用 builder 模式设置 editor dirty;与 `editor_dirty_state` 双向同步。 pub fn with_editor_dirty(mut self, dirty: bool) -> Self { 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()); + } else { + // dirty=false 时重置为 clean,避免两字段语义分裂 + self.editor_dirty_state = Some("clean".to_string()); } self } @@ -700,10 +705,11 @@ pub fn check_when(ctx: &CommandContext, when_expr: &str) -> Result /// 检查一个命令在指定上下文中是否应 enabled。 /// 如果 when 表达式为 None 或空,默认返回 true。 +/// when 解析失败时 fail-closed:返回 false(禁止因坏表达式默认放开命令)。 pub fn is_command_enabled(ctx: &CommandContext, when: Option<&str>) -> bool { match when { 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] fn command_context_parse_error_returns_default() { let ctx = CommandContext::new(); - // 解析错误 → is_command_enabled 返回 true(默认允许) - assert!(is_command_enabled(&ctx, Some("invalid syntax &&&"))); + // 解析错误 → fail-closed:is_command_enabled 返回 false(禁止默认放开) + assert!(!is_command_enabled(&ctx, Some("invalid syntax &&&"))); } #[test] diff --git a/rust/crates/core-protocol/src/governance.rs b/rust/crates/core-protocol/src/governance.rs index 03fd2303..8c474c6c 100644 --- a/rust/crates/core-protocol/src/governance.rs +++ b/rust/crates/core-protocol/src/governance.rs @@ -60,7 +60,14 @@ impl JobTicket { } 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); } if context.workspace_id.trim().is_empty() { @@ -100,6 +107,21 @@ mod tests { 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] fn job_ticket_keeps_trace_fields() { let ticket = JobTicket::new( diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index 3b1487c6..6abd4468 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -246,7 +246,13 @@ impl DocumentBuffer { pub fn mark_dirty(&mut self, content_hash: String) { 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) { @@ -298,6 +304,14 @@ impl DocumentBuffer { pub fn mark_deleted(&mut self) { 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)] @@ -1109,6 +1123,11 @@ mod tests { assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale); 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()); assert!(!buf.is_dirty()); assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); @@ -1119,5 +1138,9 @@ mod tests { buf.mark_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); } } diff --git a/rust/crates/core-protocol/src/mindmap.rs b/rust/crates/core-protocol/src/mindmap.rs index 83086105..a715e094 100644 --- a/rust/crates/core-protocol/src/mindmap.rs +++ b/rust/crates/core-protocol/src/mindmap.rs @@ -77,7 +77,8 @@ pub struct MindmapKernelProjection { 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")] pub struct MindmapKernelNode { pub node_id: String, diff --git a/rust/crates/index-fts/src/lib.rs b/rust/crates/index-fts/src/lib.rs index 38e740af..b86be34f 100644 --- a/rust/crates/index-fts/src/lib.rs +++ b/rust/crates/index-fts/src/lib.rs @@ -765,8 +765,9 @@ fn upsert_match( } fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String { - fn walk(node: &Value, output: &mut Vec, max_chars: usize) { - if output.join("\n").len() >= max_chars { + // 维护累计字节长度,避免每次递归 O(n²) join。 + fn walk(node: &Value, output: &mut Vec, total_len: &mut usize, max_chars: usize) { + if *total_len >= max_chars { return; } 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") { let normalized = normalize_text(text); 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); } } } if let Some(Value::Array(children)) = map.get("children") { for child in children { - walk(child, output, max_chars); - if output.join("\n").len() >= max_chars { + walk(child, output, total_len, max_chars); + if *total_len >= max_chars { break; } } } if let Some(root) = map.get("root") { - walk(root, output, max_chars); + walk(root, output, total_len, max_chars); } } Value::Array(items) => { for item in items { - walk(item, output, max_chars); - if output.join("\n").len() >= max_chars { + walk(item, output, total_len, max_chars); + if *total_len >= max_chars { break; } } @@ -804,10 +809,18 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String { } 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(); 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 { joined } @@ -834,31 +847,45 @@ fn build_snippet(text: &str, keyword: &str) -> String { if source.is_empty() { return "暂无正文内容".into(); } - let escaped = escape_html(source); if keyword.trim().is_empty() { - return truncate_snippet(&escaped); + return truncate_snippet(&escape_html(source)); } + // 先在原文上匹配,再 escape 匹配片段,避免在 `&` 等实体内部插入 。 let regex = match RegexBuilder::new(®ex::escape(keyword)) .case_insensitive(true) .build() { Ok(regex) => regex, - Err(_) => return truncate_snippet(&escaped), + Err(_) => return truncate_snippet(&escape_html(source)), }; - let Some(found) = regex.find(&escaped) else { - return truncate_snippet(&escaped); + let Some(found) = regex.find(source) else { + return truncate_snippet(&escape_html(source)); }; - let start = found.start().saturating_sub(20); - let end = (found.end() + 80).min(escaped.len()); - let segment = escaped[start..end].to_string(); - regex - .replace_all(&segment, |captures: ®ex::Captures<'_>| { - format!("{}", &captures[0]) - }) - .to_string() + // 按 UTF-8 字符边界取上下文,避免多字节字符中间切片 panic。 + let match_start = found.start(); + let match_end = found.end(); + let target_start = match_start.saturating_sub(20); + let start = source + .char_indices() + .map(|(i, _)| i) + .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}{matched}{suffix}") } fn truncate_snippet(value: &str) -> String { @@ -1120,4 +1147,23 @@ mod tests { assert_eq!(result.results[0].evidence.len(), 1); assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]); } + + #[test] + fn build_snippet_does_not_break_html_entities() { + // 关键词 "amp" 若在转义后匹配,会破坏 `&` 实体。 + let snippet = build_snippet("x & y amp-word", "amp"); + assert!( + !snippet.contains("&"), + "不得在 HTML 实体内部插入 mark: {snippet}" + ); + assert!( + snippet.contains("") && snippet.contains(""), + "应高亮匹配词: {snippet}" + ); + // 原文 & 应被 escape 为 &,且实体完整 + assert!( + snippet.contains("&") || !snippet.contains('&'), + "原文 & 应被正确转义: {snippet}" + ); + } } diff --git a/rust/crates/mnote-editor-core/src/command.rs b/rust/crates/mnote-editor-core/src/command.rs index 1755d60a..8c6de305 100644 --- a/rust/crates/mnote-editor-core/src/command.rs +++ b/rust/crates/mnote-editor-core/src/command.rs @@ -210,22 +210,26 @@ fn move_block( .iter() .map(|block| block.id.clone()) .collect(); - if let Some(target_id) = after_block_id { - if moved_ids.iter().any(|id| id == target_id) { - return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面")); - } - } - - let moved_blocks: Vec = document.blocks_mut().drain(start..=end).collect(); + // 先算好 drain 后的插入点,避免「先 drain 再发现目标不存在」导致块丢失。 let insert_index = match after_block_id { Some(target_id) => { + if moved_ids.iter().any(|id| id == target_id) { + return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面")); + } let target_range = document .subtree_range(target_id) .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, }; + let moved_blocks: Vec = document.blocks_mut().drain(start..=end).collect(); document .blocks_mut() .splice(insert_index..insert_index, moved_blocks); diff --git a/rust/crates/mnote-vault-core/src/ai.rs b/rust/crates/mnote-vault-core/src/ai.rs index 3c3dc4e4..31706411 100644 --- a/rust/crates/mnote-vault-core/src/ai.rs +++ b/rust/crates/mnote-vault-core/src/ai.rs @@ -23,11 +23,13 @@ pub fn ai_vault_actor_id() -> String { } /// Encode actor id for managed path segment (aligned with mnote-web). +/// +/// 不保留 `.` / `/` 等路径元字符,避免 actor=`..` 时穿越 `users/` 目录。 pub fn encode_actor_segment(actor_id: &str) -> String { actor_id .chars() .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), }) .collect() @@ -786,6 +788,17 @@ mod tests { 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: F) -> R where F: FnOnce(&Path) -> R, diff --git a/rust/crates/mnote-vault-core/src/store.rs b/rust/crates/mnote-vault-core/src/store.rs index 4351f96d..5d4b5b1c 100644 --- a/rust/crates/mnote-vault-core/src/store.rs +++ b/rust/crates/mnote-vault-core/src/store.rs @@ -19,7 +19,7 @@ use crate::error::{VaultError as WebError, VaultStatus as StatusCode}; use crate::frontmatter::split_frontmatter; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -490,33 +490,55 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result 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 slot for session file name: empty / "primary" → `primary`. pub fn normalize_session_account_id(account_id: Option<&str>) -> String { let raw = account_id.map(str::trim).unwrap_or(""); if raw.is_empty() || raw.eq_ignore_ascii_case(SESSION_ACCOUNT_PRIMARY) { - SESSION_ACCOUNT_PRIMARY.to_string() - } else { - // reject path traversal - if raw.contains('/') || raw.contains('\\') || raw.contains("..") { - return SESSION_ACCOUNT_PRIMARY.to_string(); - } - raw.to_string() + return SESSION_ACCOUNT_PRIMARY.to_string(); } + // reject path traversal + 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. 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` 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); - format!("sessions/{credential_id}/{acc}.json") + format!("sessions/{cred}/{acc}.json") } 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 { @@ -766,21 +788,24 @@ pub fn copy_session_files( } let name = entry.file_name(); let dst_path = dst_dir.join(&name); - // rewrite credentialId inside file - if let Ok(Some(mut file)) = read_session_file_at(&path) { - file.credential_id = target_id.to_string(); - file.updated_at = now_rfc3339(); - let json = serde_json::to_string_pretty(&file).map_err(|e| { - WebError::bad_request_code( - "vault_session_copy_failed", - format!("序列化登录态失败: {e}"), - ) - })?; - atomic_write_string(&dst_path, &json)?; - count += 1; - } else { - let _ = fs::copy(&path, &dst_path); - count += 1; + // rewrite credentialId inside file;解析失败则跳过,禁止原样 copy 以免带出错误 credential 或损坏内容 + match read_session_file_at(&path) { + Ok(Some(mut file)) => { + file.credential_id = target_id.to_string(); + file.updated_at = now_rfc3339(); + let json = serde_json::to_string_pretty(&file).map_err(|e| { + WebError::bad_request_code( + "vault_session_copy_failed", + format!("序列化登录态失败: {e}"), + ) + })?; + atomic_write_string(&dst_path, &json)?; + count += 1; + } + Ok(None) | Err(_) => { + // 跳过无法解析的 session 文件,不静默复制 + continue; + } } } Ok(count) @@ -1631,6 +1656,233 @@ pub fn normalize_folder_path(raw: Option<&str>) -> Option { } } +/// Sanitize one folder segment (username / actor id) for use in `folderPath`. +/// Disallows path separators and `.` / `..`; empty after sanitize → None. +pub fn sanitize_folder_segment(raw: &str) -> Option { + let s = raw.trim(); + if s.is_empty() || s == "." || s == ".." { + return None; + } + // Reject separators; collapse unsafe chars to `_` for stable grouping. + 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()) + } +} + +/// Strip leading actor namespace variants from a folder path: +/// `{actor}/…`, `from-{actor}/…`, exact `{actor}` / `from-{actor}`. +fn strip_ai_share_namespace(path: &str, actor_ns: &str) -> Option { + 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}/{原用户分组…}`。 +/// +/// - 无原分组 → 仅 `liaibo` +/// - 原分组 `系统/个人` → `liaibo/系统/个人` +/// - 已带命名空间或历史 `from-liaibo/…` → 规范化后不叠层 +/// +/// `relative` 为源条目 `folderPath`,或请求体里覆盖的**相对**分组(不是整库绝对路径)。 +pub fn compose_ai_shared_folder_path( + source_actor_id: &str, + relative: Option<&str>, +) -> Option { + 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), + } +} + +/// One rewritten AI-copy folder path during repair. +#[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, + pub to: Option, +} + +/// Outcome of repairing AI 密码本分组命名空间。 +#[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, +} + +/// Normalize share actor id: trim, strip optional `user:` prefix. +pub fn normalize_share_actor_id(raw: &str) -> Option { + let s = raw.trim(); + let s = s + .strip_prefix("user:") + .or_else(|| s.strip_prefix("user/")) + .unwrap_or(s) + .trim(); + sanitize_folder_segment(s) +} + +/// Parse legacy `from-{actor}` or `from-{actor}/rest` first segment actor. +pub fn parse_from_actor_folder_prefix(folder_path: Option<&str>) -> Option { + 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 密码本中「分享副本」的 `folderPath`: +/// `{分享者}/{源分组}`。 +/// +/// 分享者解析顺序:`sharedFrom.sourceActorId` → 路径前缀 `from-{user}` → +/// 若全库仅识别到一个分享者则作为默认(处理「个人/系统」无前缀的旧数据)。 +/// 相对分组:优先源库条目 folderPath;否则从 AI 现路径剥命名空间。 +pub fn repair_ai_shared_folder_paths( + ai_workspace: &Path, + resolve_source_workspace: impl FnMut(&str) -> Option, +) -> Result { + repair_ai_shared_folder_paths_with_default(ai_workspace, resolve_source_workspace, None) +} + +/// Same as [`repair_ai_shared_folder_paths`], with optional forced default sharer +/// (e.g. user confirms leftovers are all from `liaibo`). +pub fn repair_ai_shared_folder_paths_with_default( + ai_workspace: &Path, + mut resolve_source_workspace: impl FnMut(&str) -> Option, + default_source_actor: Option<&str>, +) -> Result { + 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(); + + // Pre-scan: infer sole sharer from metadata / from-* prefixes + let mut known_actors: BTreeSet = BTreeSet::new(); + if let Some(d) = default_source_actor.and_then(normalize_share_actor_id) { + known_actors.insert(d); + } + let mut records: Vec = 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; + }; + + // 1) 优先源库真分组 2) 否则从 AI 现路径剥前缀(含 from-liaibo) + 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). pub fn validate_cipher_key(key: &str) -> Result<(), WebError> { let key = key.trim(); @@ -1703,6 +1955,11 @@ fn write_cipher_book_atomic(vault_root: &Path, book: &CipherBook) -> Result<(), format!("无法提交密文簿: {error}"), ) })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + } Ok(()) } @@ -1915,11 +2172,32 @@ fn yaml_escape(value: &str) -> String { if value.is_empty() { return "\"\"".to_string(); } - if value.chars().any(|c| { + // 双引号标量必须转义控制字符,否则按行解析 frontmatter 时多行值会被截断。 + let needs_quote = value.chars().any(|c| { c.is_whitespace() - || matches!(c, ':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>' | '%' | '@' | '`') - }) { - format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) + || c.is_control() + || matches!( + 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 { value.to_string() } @@ -2140,7 +2418,44 @@ fn parse_scalar(raw: &str) -> String { || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2) { let inner = &trimmed[1..trimmed.len() - 1]; - return inner.replace("\\\"", "\"").replace("\\\\", "\\"); + // 与 yaml_escape 对称:还原 \\ \" \n \r \t 与 \uXXXX + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some('\\') => out.push('\\'), + Some('"') => out.push('"'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('t') => out.push('\t'), + Some('u') => { + let mut hex = String::with_capacity(4); + for _ in 0..4 { + if let Some(h) = chars.next() { + hex.push(h); + } + } + if let Ok(code) = u32::from_str_radix(&hex, 16) { + if let Some(c) = char::from_u32(code) { + out.push(c); + continue; + } + } + out.push('u'); + out.push_str(&hex); + } + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } else { + out.push(ch); + } + } + return out; } // strip inline comment if let Some((value, _)) = trimmed.split_once(" #") { @@ -2958,11 +3273,11 @@ fn apply_source_fields_to_ai_copy( target.secrets = source.secrets.clone(); target.fields = source.fields.clone(); target.notes_markdown = source.notes_markdown.clone(); + // AI 副本分组必须带分享者命名空间;禁止把用户原 folder_path 原样写回(会把多人「系统/个人」揉成一坨)。 target.folder_path = folder_path_override .map(str::trim) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) - .or_else(|| source.folder_path.clone()) .or_else(|| target.folder_path.clone()); target.tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); // Keep AI-side login session always; seed playbook only if missing. @@ -3056,7 +3371,10 @@ pub fn sync_shared_ai_copy( /// When `sync_cipher_keys` is true, missing target cipher fragments are copied /// 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. -/// Tags get `ai-shared` if missing. Optional `folder_path_override` (e.g. `from-user`). +/// Tags get `ai-shared` if missing. +/// +/// `folder_path_override`:可选**相对**分组(覆盖源条目 folderPath);AI 侧最终路径恒为 +/// [`compose_ai_shared_folder_path`] → `{sourceUser}/{relative}`。 pub fn share_credential_to_workspace( source_workspace: &Path, target_workspace: &Path, @@ -3075,6 +3393,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. if let Some(link) = source.shared_to_ai.clone() { if !link.target_id.is_empty() @@ -3087,7 +3411,7 @@ pub fn share_credential_to_workspace( &link.target_id, source_actor_id, target_actor_id, - folder_path_override, + ai_folder.as_deref(), sync_cipher_keys, ); } @@ -3107,11 +3431,7 @@ pub fn share_credential_to_workspace( } let tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); - let folder = folder_path_override - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .or_else(|| source.folder_path.clone()); + let folder = ai_folder; let mut item = create_credential( target_workspace, VaultCreateInput { @@ -3201,6 +3521,8 @@ pub fn maybe_sync_after_source_update( if link.target_id.is_empty() { return Ok(None); } + let ai_folder = + compose_ai_shared_folder_path(source_actor_id, source.folder_path.as_deref()); match sync_shared_ai_copy( source_workspace, source, @@ -3208,7 +3530,7 @@ pub fn maybe_sync_after_source_update( &link.target_id, source_actor_id, &link.target_actor_id, - None, + ai_folder.as_deref(), sync_cipher_keys, ) { Ok(outcome) => Ok(Some(outcome)), @@ -3508,25 +3830,42 @@ pub fn update_credential( } validate_hint_not_secret(record.password_hint.as_deref(), record.password.as_deref())?; + let accounts_replaced = input.accounts.is_some(); if let Some(accounts) = input.accounts { record.accounts = sanitize_accounts(accounts)?; // Nested secrets under accounts are source of truth; top-level rebuilt in ensure. if record.accounts.is_empty() { // Explicit empty multi-list: clear primary identity mirrors too. - record.username = None; - record.email = None; - record.password = None; - record.password_hint = None; + // 同请求若仍 patch 了 top-level secret,保留 patch 值,由 ensure 重建 slots。 + if !username_patched { + record.username = None; + } + if !email_patched { + record.email = None; + } + if input.password == SecretPatch::Omit { + record.password = None; + } + if !hint_patched { + record.password_hint = None; + } record.secrets.clear(); + if input.apikey == SecretPatch::Omit { + record.apikey = None; + } + if input.token == SecretPatch::Omit { + record.token = None; + } } else { - // Prefer nested; if client still sent empty nested but top-level secrets - // in the same request, merge happens below via input.secrets. + // Prefer nested; same-request top-level secret patches merge below. record.secrets = flatten_secrets_from_accounts(&record.accounts); } - } else if primary_touched { - // Mirror legacy primary identity into first account slot when multi-list not sent. + } + // 无论是否同请求替换 accounts,top-level identity/secret patch 都要镜像进 first account, + // 避免 `if accounts {..} else if primary_touched` 互斥导致 apikey/token 被 ensure 回滚。 + if primary_touched { if record.accounts.is_empty() { - // leave empty; ensure_slots will synthesize + // leave empty; ensure_slots will synthesize from primary fields } else if let Some(acc) = record.accounts.first_mut() { if username_patched { acc.username = record.username.clone(); @@ -3541,8 +3880,9 @@ pub fn update_credential( acc.password = record.password.clone(); } } - // Mirror primary apikey/token into first account nested secret when multi-lists not sent. - if input.secrets.is_none() { + // Mirror primary apikey/token into first account nested secret when top-level secrets + // list is not also being fully replaced (or when accounts just replaced with nested). + if input.secrets.is_none() || accounts_replaced { if input.apikey != SecretPatch::Omit { let mut found = false; if let Some(acc) = record.accounts.first_mut() { @@ -3608,6 +3948,10 @@ pub fn update_credential( } } } + // 同请求 accounts+primary 后刷新 flatten 镜像,供 ensure 使用 + if accounts_replaced && !record.accounts.is_empty() { + record.secrets = flatten_secrets_from_accounts(&record.accounts); + } } if let Some(secrets) = input.secrets { // Legacy top-level secrets replace: only apply when accounts were not also replaced @@ -3655,12 +3999,83 @@ fn move_path(from: &Path, to: &Path) -> Result<(), WebError> { ) })?; } - fs::rename(from, to).map_err(|error| { + match fs::rename(from, to) { + Ok(()) => Ok(()), + Err(error) => { + // 跨文件系统(EXDEV)时 rename 失败:回退 copy + remove + if from.is_file() { + fs::copy(from, to).map_err(|copy_err| { + WebError::bad_request_code( + "vault_move_failed", + format!("跨设备复制失败: {copy_err} (rename: {error})"), + ) + })?; + fs::remove_file(from).map_err(|rm_err| { + WebError::bad_request_code( + "vault_move_failed", + format!("跨设备移动后删除源失败: {rm_err}"), + ) + })?; + return Ok(()); + } + if from.is_dir() { + copy_dir_all(from, to)?; + fs::remove_dir_all(from).map_err(|rm_err| { + WebError::bad_request_code( + "vault_move_failed", + format!("跨设备移动后删除源目录失败: {rm_err}"), + ) + })?; + return Ok(()); + } + Err(WebError::bad_request_code( + "vault_move_failed", + format!("无法移动密码箱文件: {error}"), + )) + } + } +} + +fn copy_dir_all(from: &Path, to: &Path) -> Result<(), WebError> { + fs::create_dir_all(to).map_err(|error| { WebError::bad_request_code( "vault_move_failed", - format!("无法移动密码箱文件: {error}"), + format!("无法创建目标目录 {}: {error}", to.display()), ) - }) + })?; + for entry in fs::read_dir(from).map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法读取源目录 {}: {error}", from.display()), + ) + })? { + let entry = entry.map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法读取目录项: {error}"), + ) + })?; + let src = entry.path(); + let dst = to.join(entry.file_name()); + let ft = entry.file_type().map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法识别文件类型: {error}"), + ) + })?; + if ft.is_dir() { + copy_dir_all(&src, &dst)?; + } else if ft.is_file() { + fs::copy(&src, &dst).map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法复制 {}: {error}", src.display()), + ) + })?; + } + // 跳过 symlink 等特殊节点,避免把外部路径拷进 vault + } + Ok(()) } pub fn soft_delete_credential( @@ -4498,6 +4913,187 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn repair_ai_shared_folder_paths_rewrites_legacy_groups() { + let src = temp_root(); + let ai = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "旧分组".into(), + password: Some("x".into()), + folder_path: Some("个人/文献".into()), + ..Default::default() + }, + ) + .unwrap(); + // 故意制造旧坏数据:先正常 share,再把 AI 副本改回无命名空间 + let shared = share_credential_to_workspace( + &src, + &ai, + &created.id, + None, + &[], + true, + "liaibo", + "mnote-e2e", + ) + .unwrap() + .item; + assert_eq!(shared.folder_path.as_deref(), Some("liaibo/个人/文献")); + let mut broken = get_credential(&ai, &shared.id).unwrap(); + broken.folder_path = Some("个人/文献".into()); + write_record_with_index(&ai, &broken).unwrap(); + + let outcome = repair_ai_shared_folder_paths(&ai, |_| Some(src.clone())).unwrap(); + assert_eq!(outcome.updated, 1); + assert_eq!(outcome.changes[0].from.as_deref(), Some("个人/文献")); + assert_eq!(outcome.changes[0].to.as_deref(), Some("liaibo/个人/文献")); + let fixed = get_credential(&ai, &shared.id).unwrap(); + assert_eq!(fixed.folder_path.as_deref(), Some("liaibo/个人/文献")); + + // 再修一次应 no-op + let again = repair_ai_shared_folder_paths(&ai, |_| Some(src.clone())).unwrap(); + assert_eq!(again.updated, 0); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&ai); + } + + #[test] + fn repair_rewrites_from_liaibo_and_orphan_personal_when_sole_sharer() { + let src = temp_root(); + let ai = temp_root(); + let a = create_credential( + &src, + VaultCreateInput { + title: "A".into(), + password: Some("1".into()), + folder_path: Some("系统/核云".into()), + ..Default::default() + }, + ) + .unwrap(); + let shared = share_credential_to_workspace( + &src, &ai, &a.id, None, &[], true, "liaibo", "mnote-e2e", + ) + .unwrap() + .item; + // 旧扁平 from-liaibo + let mut flat = get_credential(&ai, &shared.id).unwrap(); + flat.folder_path = Some("from-liaibo".into()); + write_record_with_index(&ai, &flat).unwrap(); + + // 无 sharedFrom、仅 个人 分组的孤儿(sole sharer = liaibo via from- 扫描) + // 另造一条:先 share 再清 shared_from 并改路径 + let b = create_credential( + &src, + VaultCreateInput { + title: "B".into(), + password: Some("2".into()), + folder_path: Some("个人/文献".into()), + ..Default::default() + }, + ) + .unwrap(); + let shared_b = share_credential_to_workspace( + &src, &ai, &b.id, None, &[], true, "liaibo", "mnote-e2e", + ) + .unwrap() + .item; + let mut orphan = get_credential(&ai, &shared_b.id).unwrap(); + orphan.folder_path = Some("个人/文献".into()); + // 保留 shared_from 以能回源;sole 场景下 from- 已锁定 liaibo + write_record_with_index(&ai, &orphan).unwrap(); + + let outcome = repair_ai_shared_folder_paths(&ai, |_| Some(src.clone())).unwrap(); + assert!(outcome.updated >= 2); + let fa = get_credential(&ai, &shared.id).unwrap(); + // from-liaibo + 源 系统/核云 → liaibo/系统/核云 + assert_eq!(fa.folder_path.as_deref(), Some("liaibo/系统/核云")); + let fb = get_credential(&ai, &shared_b.id).unwrap(); + assert_eq!(fb.folder_path.as_deref(), Some("liaibo/个人/文献")); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&ai); + } + + #[test] + fn compose_ai_shared_folder_path_namespaces_source_groups() { + assert_eq!( + compose_ai_shared_folder_path("liaibo", Some("系统/个人")), + Some("liaibo/系统/个人".into()) + ); + assert_eq!( + compose_ai_shared_folder_path("liaibo", Some("个人/文献")), + Some("liaibo/个人/文献".into()) + ); + assert_eq!( + compose_ai_shared_folder_path("liaibo", None), + Some("liaibo".into()) + ); + // 不叠层 + assert_eq!( + compose_ai_shared_folder_path("liaibo", Some("liaibo/系统")), + Some("liaibo/系统".into()) + ); + assert_eq!( + compose_ai_shared_folder_path("liaibo", Some("from-liaibo/系统/个人")), + Some("liaibo/系统/个人".into()) + ); + assert_eq!( + compose_ai_shared_folder_path("liaibo", Some("from-liaibo")), + Some("liaibo".into()) + ); + } + + #[test] + fn share_and_sync_keep_actor_namespaced_folder_tree() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "图书馆".into(), + username: Some("liaibo".into()), + password: Some("secret".into()), + folder_path: Some("个人/文献".into()), + ..Default::default() + }, + ) + .unwrap(); + let shared = share_credential_to_workspace( + &src, + &dst, + &created.id, + None, + &[], + true, + "liaibo", + "mnote-e2e", + ) + .unwrap() + .item; + assert_eq!(shared.folder_path.as_deref(), Some("liaibo/个人/文献")); + + // 用户改分组后同步,AI 侧仍带用户名前缀 + let updated = update_credential( + &src, + &created.id, + VaultUpdateInput { + folder_path: Some(Some("系统/服务".into())), + ..Default::default() + }, + ) + .unwrap(); + let sync = maybe_sync_after_source_update(&src, &updated, &dst, "liaibo", true) + .unwrap() + .expect("synced"); + assert_eq!(sync.item.folder_path.as_deref(), Some("liaibo/系统/服务")); + // 不应回写成无命名空间的用户原路径 + assert_ne!(sync.item.folder_path.as_deref(), Some("系统/服务")); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } + #[test] fn same_url_multiple_accounts_allowed() { let root = temp_root(); @@ -4560,7 +5156,7 @@ mod tests { &src, &dst, &created.id, - Some("from-user"), + None, &[], true, "user-a", @@ -4571,7 +5167,8 @@ mod tests { assert_ne!(shared.id, created.id); assert_eq!(shared.password.as_deref(), Some("Li@[A]s3cret")); assert_eq!(shared.apikey.as_deref(), Some("sk-test")); - assert_eq!(shared.folder_path.as_deref(), Some("from-user")); + // 7-76:AI 侧分组 = {分享者}/{源分组} + 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 == "prod")); assert!(shared.shared_from.is_some()); @@ -4593,12 +5190,12 @@ mod tests { .any(|k| k == "A")); let (_i, items) = list_credentials(&dst, VaultItemStatus::Active).unwrap(); assert_eq!(items.len(), 1); - // re-share updates existing, no second copy + // re-share updates existing, no second copy; keeps namespaced folder let outcome2 = share_credential_to_workspace( &src, &dst, &created.id, - Some("from-user"), + None, &[], true, "user-a", @@ -4607,6 +5204,7 @@ mod tests { .unwrap(); assert!(outcome2.updated_existing); assert_eq!(outcome2.item.id, shared.id); + assert_eq!(outcome2.item.folder_path.as_deref(), Some("user-a/ai/keys")); let (_i, items2) = list_credentials(&dst, VaultItemStatus::Active).unwrap(); assert_eq!(items2.len(), 1); let _ = fs::remove_dir_all(&src); @@ -4634,7 +5232,7 @@ mod tests { &src, &dst, &created.id, - Some("from-user"), + None, &[], true, "user-a", @@ -4702,7 +5300,7 @@ mod tests { &src, &dst, &created.id, - Some("from-user"), + None, &[], true, "user-a", @@ -4754,7 +5352,7 @@ mod tests { &src, &dst, &created.id, - Some("from-user"), + None, &[], true, "user-a", @@ -4794,7 +5392,7 @@ mod tests { &src, &dst, &created.id, - Some("from-user"), + None, &[], true, "user-a", @@ -4980,6 +5578,64 @@ mod tests { let _ = fs::remove_dir_all(&root); } + /// 同请求同时带 accounts(已有 nested secrets)+ top-level apikey/token 时, + /// 不得因 `else if primary_touched` 被跳过而静默丢失新密钥。 + #[test] + fn update_credential_same_request_accounts_and_apikey_token_merge() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Dual path".into(), + username: Some("u0".into()), + password: Some("p0".into()), + apikey: Some("old-key".into()), + token: Some("old-tok".into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(record_has_apikey(&created)); + assert!(record_has_token(&created)); + + let acc = created.accounts[0].clone(); + // 客户端重发 accounts(nested 仍是旧 secret)+ 同请求 top-level 新 apikey/token + let updated = update_credential( + &root, + &created.id, + VaultUpdateInput { + accounts: Some(vec![acc]), + apikey: SecretPatch::Set("new-key".into()), + token: SecretPatch::Set("new-tok".into()), + username: Some(Some("u1".into())), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(updated.username.as_deref(), Some("u1")); + assert_eq!(updated.apikey.as_deref(), Some("new-key")); + assert_eq!(updated.token.as_deref(), Some("new-tok")); + let nested_apikey = updated + .accounts + .first() + .and_then(|a| a.secrets.iter().find(|s| s.kind == "apikey")) + .and_then(|s| s.value.as_deref()); + let nested_token = updated + .accounts + .first() + .and_then(|a| a.secrets.iter().find(|s| s.kind == "token")) + .and_then(|s| s.value.as_deref()); + assert_eq!(nested_apikey, Some("new-key")); + assert_eq!(nested_token, Some("new-tok")); + assert_eq!(updated.accounts[0].username.as_deref(), Some("u1")); + + let reloaded = get_credential(&root, &created.id).unwrap(); + assert_eq!(reloaded.apikey.as_deref(), Some("new-key")); + assert_eq!(reloaded.token.as_deref(), Some("new-tok")); + let _ = fs::remove_dir_all(&root); + } + #[test] fn session_file_put_resolve_and_share_copy() { let src = temp_root(); @@ -5087,7 +5743,7 @@ mod tests { &src, &dst, &created.id, - Some("from-user"), + None, &[], false, "user-a", @@ -5134,4 +5790,42 @@ mod tests { let _ = fs::remove_dir_all(&src); let _ = fs::remove_dir_all(&dst); } + + #[test] + fn session_path_segments_reject_traversal() { + use super::{normalize_session_account_id, session_dir_rel, session_file_rel}; + use std::path::{Component, PathBuf}; + + assert_eq!(session_dir_rel("../../etc"), "sessions/invalid"); + assert_eq!(session_dir_rel("cred/../x"), "sessions/invalid"); + assert_eq!(session_dir_rel("safe-cred_01"), "sessions/safe-cred_01"); + + assert_eq!( + session_file_rel("../../etc", Some("acc")), + "sessions/invalid/acc.json" + ); + assert_eq!( + normalize_session_account_id(Some("../admin")), + "primary" + ); + assert_eq!( + normalize_session_account_id(Some("acc/evil")), + "primary" + ); + assert_eq!( + normalize_session_account_id(Some("acc_ok-1")), + "acc_ok-1" + ); + + // abs join must not escape vault root via ParentDir + let vault = PathBuf::from("/tmp/vault-root"); + let dir = super::session_dir_abs(&vault, "../../etc/passwd"); + assert!( + dir.components().all(|c| !matches!(c, Component::ParentDir)), + "session_dir_abs must not contain ParentDir: {dir:?}" + ); + assert!(dir.starts_with(&vault.join("sessions"))); + assert_eq!(dir, vault.join("sessions").join("invalid")); + } + } diff --git a/rust/crates/mnote-vault-core/src/token.rs b/rust/crates/mnote-vault-core/src/token.rs index bf74afcd..0a22193d 100644 --- a/rust/crates/mnote-vault-core/src/token.rs +++ b/rust/crates/mnote-vault-core/src/token.rs @@ -108,10 +108,11 @@ pub fn load_or_create_hmac_key(path: &Path) -> Result, VaultError> { ) })?; let hex = raw.trim(); - if hex.len() < 32 { + // 生成侧写 32 字节 → 64 hex;读取侧要求一致,拒绝弱 key。 + if hex.len() < 64 { return Err(VaultError::bad_request_code( "vault_hmac_key_invalid", - "HMAC key 过短", + "HMAC key 过短(需要至少 32 字节 / 64 hex)", )); } return hex::decode(hex).map_err(|e| { diff --git a/rust/crates/mnote-vault/src/main.rs b/rust/crates/mnote-vault/src/main.rs index f23ab7f3..08f9deb6 100644 --- a/rust/crates/mnote-vault/src/main.rs +++ b/rust/crates/mnote-vault/src/main.rs @@ -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 { + 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. #[cfg(unix)] fn uds_http( @@ -650,7 +680,7 @@ fn cmd_list(args: ListArgs) -> Result { } fn cmd_get(args: GetArgs) -> Result { - let id = args.id.clone(); + let id = sanitize_item_id_for_path(&args.id)?; with_transport( args.local, args.remote, @@ -671,7 +701,7 @@ fn cmd_get(args: GetArgs) -> Result { } fn cmd_resolve(args: ResolveArgs) -> Result { - let id = args.id.clone(); + let id = sanitize_item_id_for_path(&args.id)?; let field = args.field.clone(); let account_id = args.account_id.clone(); let secret_id = args.secret_id.clone(); @@ -712,7 +742,7 @@ fn cmd_resolve(args: ResolveArgs) -> Result { } fn cmd_login(args: LoginArgs) -> Result { - let id = args.id.clone(); + let id = sanitize_item_id_for_path(&args.id)?; let force = args.force_refresh; with_transport( args.local, @@ -740,7 +770,7 @@ fn cmd_login(args: LoginArgs) -> Result { } fn cmd_session(args: SessionArgs) -> Result { - let id = args.id.clone(); + let id = sanitize_item_id_for_path(&args.id)?; let cookie = args.cookie_header.clone(); let expires = args.expires_at.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") { return err_response(e); } - let id = p.trim_start_matches("/v1/items/"); - if id.is_empty() || id.contains('/') { - return err_response(VaultError::bad_request_code( - "vault_item_id_invalid", - "条目 id 无效", - )); - } - match get_ai_vault_item(id) { + let id = match sanitize_item_id_for_path(p.trim_start_matches("/v1/items/")) { + Ok(id) => id, + Err(e) => return err_response(e), + }; + match get_ai_vault_item(&id) { Ok(mut v) => { if let Some(obj) = v.as_object_mut() { 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") { return err_response(e); } - let mid = p - .trim_start_matches("/v1/items/") - .trim_end_matches("/resolve"); - if mid.is_empty() { - return err_response(VaultError::bad_request_code( - "vault_item_id_invalid", - "条目 id 无效", - )); - } + let mid = match sanitize_item_id_for_path( + p.trim_start_matches("/v1/items/") + .trim_end_matches("/resolve"), + ) { + Ok(id) => id, + Err(e) => return err_response(e), + }; let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); let field = parsed .get("field") @@ -1006,7 +1031,7 @@ fn dispatch_request(raw: &str) -> (u16, String) { .or_else(|| parsed.get("secret_id")) .and_then(Value::as_str); 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) => { if let Some(obj) = v.as_object_mut() { 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") { return err_response(e); } - let mid = p - .trim_start_matches("/v1/items/") - .trim_end_matches("/login"); - if mid.is_empty() { - return err_response(VaultError::bad_request_code( - "vault_item_id_invalid", - "条目 id 无效", - )); - } + let mid = match sanitize_item_id_for_path( + p.trim_start_matches("/v1/items/") + .trim_end_matches("/login"), + ) { + Ok(id) => id, + Err(e) => return err_response(e), + }; let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); let force = parsed .get("forceRefresh") @@ -1036,7 +1059,7 @@ fn dispatch_request(raw: &str) -> (u16, String) { .and_then(Value::as_bool) .unwrap_or(false); 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) => { if let Some(obj) = v.as_object_mut() { 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") { return err_response(e); } - let mid = p - .trim_start_matches("/v1/items/") - .trim_end_matches("/session"); - if mid.is_empty() { - return err_response(VaultError::bad_request_code( - "vault_item_id_invalid", - "条目 id 无效", - )); - } + let mid = match sanitize_item_id_for_path( + p.trim_start_matches("/v1/items/") + .trim_end_matches("/session"), + ) { + Ok(id) => id, + Err(e) => return err_response(e), + }; let parsed: Value = serde_json::from_str(body).unwrap_or(json!({})); let cookie = parsed .get("cookieHeader") @@ -1074,7 +1095,7 @@ fn dispatch_request(raw: &str) -> (u16, String) { .and_then(Value::as_str) .unwrap_or("human_bridge"); 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) => { if let Some(obj) = v.as_object_mut() { obj.insert("transport".into(), json!("uds")); diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index d09276ee..b4cdfab3 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -293,6 +293,8 @@ import { }; const paneViewRegistry = new Map(); + /** 每 pane 导航世代:快速连点时丢弃过期 replacePaneDocument 结果,保证 last-click-wins。 */ + const paneReplaceGeneration = new Map(); const currentWebShellWorkspaceId = () => { try { return currentUrl().searchParams.get('workspaceId') || ''; @@ -709,6 +711,8 @@ import { }; const replacePaneDocument = async (paneRole, descriptor, options = {}) => { + const generation = (paneReplaceGeneration.get(paneRole) || 0) + 1; + paneReplaceGeneration.set(paneRole, generation); mindmapHost.unmountMindmapPane(paneRole); const root = paneRole === 'primary' ? ensureLazyPrimaryPaneRoot() @@ -719,8 +723,12 @@ import { const aggregatePromise = options.aggregate ? Promise.resolve(options.aggregate) : fetchPageAggregateForPane(descriptor); - const previousView = paneViewRegistry.get(paneRole); 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) { unmountEditorViewBinding(previousView); paneViewRegistry.delete(paneRole); @@ -754,6 +762,13 @@ import { clearEmbeddedLocalDraft(runtimeDescriptor); try { 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; runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); @@ -1181,7 +1196,9 @@ import { if (!id) return false; const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); descriptor.pageBlockNavigation = pageBlockNavigation === true; - await replacePaneDocument('primary', descriptor); + const view = await replacePaneDocument('primary', descriptor); + // 过期导航(快速连点被 generation 丢弃)不得改 URL / 激活 tab。 + if (!view) return false; activateMainEditorTab('', 'primary'); updatePrimaryUrl(descriptor, url instanceof URL ? url : null); return true; @@ -1202,7 +1219,8 @@ import { const id = typeof documentId === 'string' ? documentId.trim() : ''; if (!id) return false; const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri }); - await replacePaneDocument('secondary', descriptor); + const view = await replacePaneDocument('secondary', descriptor); + if (!view) return false; activateMainEditorTab('', 'secondary'); if (url instanceof URL) replaceUrlState(url); return true; diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js index b1fdf51b..3008f243 100644 --- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -1473,7 +1473,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { }; const knowledgeRagProvider = () => { - return 'weknora'; + return 'lightrag_legacy'; }; const setKnowledgeRagStatus = (entry, status, message, job) => { diff --git a/rust/crates/mnote-web/browser/local-upload-runtime.js b/rust/crates/mnote-web/browser/local-upload-runtime.js index 5ef4d450..aea68a19 100644 --- a/rust/crates/mnote-web/browser/local-upload-runtime.js +++ b/rust/crates/mnote-web/browser/local-upload-runtime.js @@ -11,20 +11,65 @@ function uploadedAssetTitle(asset) { 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) { - 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) { var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim(); if (href) { - var normalizedHref = href.replace(/\\/g, '/'); - if (normalizedHref.indexOf('../') === 0 || normalizedHref.indexOf('/../') >= 0) return ''; + if (hasPathEscape(href) || !isSafeAssetUrl(href)) return ''; return href; } var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/'); - if (!relativePath) return ''; - if (relativePath.indexOf('../') === 0 || relativePath.indexOf('/../') >= 0) return ''; + if (!relativePath || hasPathEscape(relativePath)) return ''; if (relativePath.indexOf('./') === 0) return relativePath; return './' + relativePath; } @@ -621,9 +666,9 @@ async function uploadFilesWithResolvedTarget(files, detail, options, deps) { if (errors.length) { var message = '部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : ''); if (typeof deps.alert === 'function') { - deps.alert(message); + await deps.alert(message); } else { - window.alert(message); + await window.mnote.alert(message); } } return uploaded; @@ -633,7 +678,7 @@ function localAssetOpenUrl(asset, download, context) { if (!isLocalUploadedAsset(asset)) return ''; 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(); - if (!rootUri || !rootRelativePath) return ''; + if (!rootUri || !rootRelativePath || hasPathEscape(rootRelativePath)) return ''; var url = new URL('/api/local-folder/files/open', window.location.origin); url.searchParams.set('rootUri', rootUri); url.searchParams.set('path', rootRelativePath); diff --git a/rust/crates/mnote-web/browser/mnote-ui-runtime.js b/rust/crates/mnote-web/browser/mnote-ui-runtime.js index f1373301..13e70a6e 100644 --- a/rust/crates/mnote-web/browser/mnote-ui-runtime.js +++ b/rust/crates/mnote-web/browser/mnote-ui-runtime.js @@ -1,8 +1,12 @@ -// MNote UI 基础运行时:全局 portal 与 toast API。 +// MNote UI 基础运行时:全局 portal、toast、居中 confirm/alert。 (function initMnoteUiRuntime() { if (window.__mnoteUiRuntimeStarted) return; 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() { var root = document.getElementById('mnote-portal-root'); if (root instanceof HTMLElement) return root; @@ -52,15 +56,192 @@ 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} confirm 模式:确定=true;alert 模式始终 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.ensurePortalRoot = ensurePortalRoot; 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) { var detail = event && event.detail ? event.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') { document.addEventListener('DOMContentLoaded', ensurePortalRoot, { once: true }); } else { diff --git a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js index 3c1c2bdb..ceff3d76 100644 --- a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js @@ -783,7 +783,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { var resolved = await resolveEditorAttachmentUrl(detail); var title = String(detail.fileName || resolved.asset.file_name || 'PDF').trim() || 'PDF'; 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({ objectIdentity: 'resource:pdf:' + (detail.documentId || '') + ':' + (detail.assetId || previewUrl || resolved.url), assetId: detail.assetId || '', @@ -799,7 +799,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { } window.open(previewUrl || resolved.url, '_blank', 'noopener,noreferrer'); } 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'); return; } - window.alert(error && error.message ? error.message : '打开代码附件失败'); + await window.mnote.alert(error && error.message ? error.message : '打开代码附件失败'); } } diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js index 659c9c5d..88dde13f 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -148,10 +148,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { }).then(function(){ if (!localFolderSource && documentId) updateTitleEverywhere(documentId, commandTitle); }); - void work.then(close).catch(function(error) { + void work.then(close).catch(async function(error) { committing = 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(); }); @@ -298,7 +298,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return url.toString(); } - function convertToPreviousSiblingChild(trigger, detail) { + async function convertToPreviousSiblingChild(trigger, detail) { var documentId = detail.documentId || ''; var row = document.querySelector('#sidebar-tree-root .tree-row[data-node-id="' + cssEscape(documentId) + '"]'); if (!(row instanceof HTMLElement)) return; @@ -308,7 +308,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { }); var index = siblings.indexOf(row); if (index <= 0) { - window.alert('当前页面前面没有同级页面。'); + await window.mnote.alert('当前页面前面没有同级页面。'); return; } var previous = siblings[index - 1]; @@ -369,9 +369,21 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { function joinLocalAbsolutePath(rootUri, relativePath) { var rootPath = localRootPathFromRootUri(rootUri); - var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, ''); + var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, '').replace(/\\/g, '/'); 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) { @@ -488,7 +500,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return payload; } - function handleTreeContextMenuAction(action, detail, trigger) { + async function handleTreeContextMenuAction(action, detail, trigger) { closeTreeContextMenu(); detail = detail || {}; if (action === 'knowledge-rag-index') { @@ -499,13 +511,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { if (window.mnote && window.mnote.toast) { window.mnote.toast('已加入知识库', { kind: 'success' }); } - }).catch(function(error) { + }).catch(async function(error) { var msg = error && error.message ? error.message : '知识库索引失败'; recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); if (window.mnote && window.mnote.toast) { window.mnote.toast(msg, { kind: 'error' }); } else { - window.alert(msg); + await window.mnote.alert(msg); } }); return; @@ -584,7 +596,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return; } if (isAsset && action === 'open-edit-mode') { - withOfficeEditModeGuard(function() { + withOfficeEditModeGuard(async function() { recordFileTreeAction('open-edit-mode', detail); void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' }); }); @@ -635,15 +647,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { } } if (action === 'delete-trash' && isAsset) { - if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return; + if (!(await window.mnote.confirm('确定要将“' + title + '”删除到垃圾桶吗?'))) return; recordFileTreeAction('delete-trash', detail); recordFileTreeActionStatus('pending', detail); void deleteSingleFileTreeAsset(detail, trigger).then(function() { recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' })); if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); - }).catch(function(error) { + }).catch(async function(error) { recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); - window.alert(error && error.message ? error.message : '资源删除失败'); + await window.mnote.alert(error && error.message ? error.message : '资源删除失败'); }); return; } @@ -655,9 +667,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { recordFileTreeAction('new-folder', detail); void createFileTreeFolder(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null).then(function(ok) { recordFileTreeActionStatus(ok ? 'created' : 'skipped', detail); - }).catch(function(error) { + }).catch(async function(error) { recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); - window.alert(error && error.message ? error.message : '新建文件夹失败'); + await window.mnote.alert(error && error.message ? error.message : '新建文件夹失败'); }); return; } @@ -690,9 +702,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { recordFileTreeAction('paste-into', detail); void pasteSidebarFileTreeClipboard(pasteRow).then(function(ok) { recordFileTreeActionStatus(ok ? 'applied' : 'skipped', detail); - }).catch(function(error) { + }).catch(async function(error) { recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); - window.alert(error && error.message ? error.message : '粘贴失败'); + await window.mnote.alert(error && error.message ? error.message : '粘贴失败'); }); return; } @@ -738,7 +750,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { workspaceId: workspaceId, documentId: documentId, title: nextTitle.trim() - }).then(function(){ updateTitleEverywhere(documentId, nextTitle.trim()); }); + }).then(async function(){ updateTitleEverywhere(documentId, nextTitle.trim()); }); } return; } @@ -747,12 +759,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return; } if (action === 'convert-child') { - convertToPreviousSiblingChild(trigger, detail); + void convertToPreviousSiblingChild(trigger, detail); return; } var deleteTargetId = documentId || String(detail.rowId || '').trim(); if (action === 'delete-trash' && deleteTargetId) { - if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return; + if (!(await window.mnote.confirm('确定要将“' + title + '”删除到垃圾桶吗?'))) return; recordFileTreeAction('delete-trash', detail); recordFileTreeActionStatus('pending', detail); void dispatchTreeCommand(trigger || document.body, { @@ -762,9 +774,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { }).then(function() { recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' })); if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); - }).catch(function(error) { + }).catch(async function(error) { 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); } - // ── 知识库列表获取:只读 MNote 后端 registry/status,避免前端直连 WeKnora service key。 ── + // ── 知识库列表获取:只读 MNote 后端 registry/status,不直连外部知识库 service key。 ── function fetchKnowledgeBaseList(detail) { var rootUri = knowledgeRagRootUri(detail, null); 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 } })); if (failures.length > 0) { recordFileTreeActionStatus('failed', { documentId: targetParentId, fallback: 'alert', batchId: batchId }); - window.alert('部分对象移动失败:' + failures.join(';')); + await window.mnote.alert('部分对象移动失败:' + failures.join(';')); return false; } recordFileTreeActionStatus('applied', { documentId: targetParentId, batchId: batchId }); @@ -2159,7 +2171,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var plan = buildSidebarFileTreeDeletePlan(rows); var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length; 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'); 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 }); @@ -2264,7 +2276,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { if (failures.length > 0) { 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 }); - window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : '')); + await window.mnote.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : '')); return false; } document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true'); diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js index ef5ef45d..d164668a 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js @@ -209,13 +209,39 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { || (/^思维导图/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) { var _rto_ = window.__mnoteResourceOpenRuntime; if (_rto_ && typeof _rto_.localFilePathFromAssetId === 'function') { - return _rto_.localFilePathFromAssetId(assetId); + return sanitizeWorkspaceRelativePath(_rto_.localFilePathFromAssetId(assetId)); } 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) { @@ -249,15 +275,16 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { } function buildLocalFileOpenUrl(relativePath, download) { + var safePath = sanitizeWorkspaceRelativePath(relativePath); var _rto_ = window.__mnoteResourceOpenRuntime; if (_rto_ && typeof _rto_.buildLocalFileOpenUrl === 'function') { - return _rto_.buildLocalFileOpenUrl(relativePath, download); + return safePath ? _rto_.buildLocalFileOpenUrl(safePath, download) : ''; } var rootUri = currentRootUri(); - if (!rootUri || !relativePath) return ''; + if (!rootUri || !safePath) return ''; var url = new URL('/api/local-folder/files/open', window.location.origin); url.searchParams.set('rootUri', rootUri); - url.searchParams.set('path', relativePath); + url.searchParams.set('path', safePath); if (download) url.searchParams.set('download', 'true'); return url.toString(); } @@ -466,7 +493,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { } var documentId = String(detail && detail.documentId || '').trim(); 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-id', assetId); void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ @@ -493,7 +520,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { return; } 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) { diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-upload-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-upload-runtime.js index 2a7cd583..fdb64739 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-upload-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-upload-runtime.js @@ -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 text = String(message || '目标位置是只读,不能拖放到这里').trim(); var targetRowId = String(detail && (detail.targetRowId || detail.rowId) || '').trim(); @@ -75,7 +75,7 @@ export const createSidebarFileTreeUploadRuntime = (dependencies = {}) => { row.setAttribute('data-readonly-message', text); } } - window.alert(text); + await window.mnote.alert(text); return false; } diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js index 04f39ced..aae07634 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js @@ -1186,15 +1186,15 @@ var documentId = String(urlDocumentId || activeWorkspacePath.documentId || active.documentId || '').trim(); var fileTreeWorkspacePath = activeFileTreeWorkspacePath(documentId) || {}; var workspacePath = Object.assign({}, activeWorkspacePath, fileTreeWorkspacePath); - var session = piLabState.session || {}; - var pagePath = normalizeSlashes(urlPagePath || workspacePath.relativePath || workspacePath.path || active.relativePath || active.path || localMarkdownRelativePathFromDocumentId(documentId) || session.pagePath || ''); + // 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) || ''); var resourceKind = String(workspacePath.resourceKind || workspacePath.kind || active.resourceKind || active.kind || '').trim().toLowerCase(); var isDirectory = resourceKind === 'directory' || resourceKind === 'folder' || resourceKind === 'workspace'; var isStandalonePiPage = currentUrl().pathname === '/page-ai/pi'; var isLocalMarkdownPage = documentId.indexOf('local-md:') === 0 || /\.md$/i.test(pagePath); if (isDirectory || (isStandalonePiPage && pagePath && !isLocalMarkdownPage)) pagePath = ''; - var rootUri = String(currentRootUri() || workspacePath.rootUri || active.rootUri || session.rootUri || '').trim(); - var workspaceId = String(currentWorkspaceId() || workspacePath.workspaceId || active.workspaceId || session.workspaceId || '').trim(); + var rootUri = String(currentRootUri() || workspacePath.rootUri || active.rootUri || '').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 title = String(active.title || workspacePath.title || (titleNode && titleNode.textContent) || document.title || '').trim(); var selection = currentSelectionText(); @@ -1278,12 +1278,13 @@ var context = currentPageContext(); refreshSelectionSummary(); if (!piLabState.session) piLabState.session = {}; - if (context.pagePath) piLabState.session.pagePath = context.pagePath; - if (context.pageTitle) piLabState.session.pageTitle = context.pageTitle; - if (context.rootUri) piLabState.session.rootUri = context.rootUri; - if (context.workspaceId) piLabState.session.workspaceId = context.workspaceId; + // Always sync (including empty) so navigation away from a page clears stale context. + piLabState.session.pagePath = context.pagePath || ''; + piLabState.session.pageTitle = context.pageTitle || ''; + piLabState.session.rootUri = context.rootUri || ''; + piLabState.session.workspaceId = context.workspaceId || ''; var folderPath = currentFolderPathFromPagePath(context.pagePath); - if (folderPath !== null) piLabState.session.folderPath = folderPath; + piLabState.session.folderPath = folderPath === null ? '' : folderPath; updateContextStrip(); return context; } @@ -2294,11 +2295,11 @@ updateConfig(); } - function deleteHistorySession(sessionId) { + async function deleteHistorySession(sessionId) { if (!sessionId) return Promise.resolve(); var existing = currentHistorySession(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), { method: 'DELETE', credentials: 'same-origin', @@ -2313,10 +2314,10 @@ }); } - function clearHistorySessions() { + async function clearHistorySessions() { var count = Array.isArray(piLabState.history) ? piLabState.history.length : 0; 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', { method: 'DELETE', credentials: 'same-origin', @@ -2618,8 +2619,10 @@ if (lightrag) lightrag.textContent = '唯一默认'; if (changedFiles) { var files = {}; - piLabState.changedFiles.forEach(function (file) { if (file) files[file] = true; }); - piLabState.messages.forEach(function (msg) { + (Array.isArray(piLabState.changedFiles) ? piLabState.changedFiles : []).forEach(function (file) { + if (file) files[file] = true; + }); + (Array.isArray(piLabState.messages) ? piLabState.messages : []).forEach(function (msg) { if (msg && msg.diffSummary && Array.isArray(msg.diffSummary.files)) { msg.diffSummary.files.forEach(function (file) { if (file) files[file] = true; }); } @@ -3264,7 +3267,7 @@ function collectPiArtifacts() { var artifacts = []; var seen = {}; - piLabState.messages.forEach(function (msg) { + (Array.isArray(piLabState.messages) ? piLabState.messages : []).forEach(function (msg) { if (!msg) return; (Array.isArray(msg.citations) ? msg.citations : []).forEach(function (cit, 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; seen['diff:' + file] = true; artifacts.push({ kind: 'diff', title: file, summary: '文件变更' }); @@ -3722,8 +3725,10 @@ function handleToolCallEventPayload(payload) { payload = payload || {}; + // Fail-closed: only explicit allowed===true triggers side effects / changedFiles. + var allowed = payload.allowed === true; applyToolCallSideEffects({ - ok: payload.allowed !== false, + ok: allowed, toolName: payload.toolName, result: { rootUri: payload.rootUri, @@ -3733,7 +3738,7 @@ }); piLabState.receipts.push({ toolName: payload.toolName, - allowed: payload.allowed !== false, + allowed: allowed, denyReason: payload.denyReason || null, diffSummary: payload.diffSummary || null, citationCount: payload.citationCount || 0, @@ -3746,9 +3751,9 @@ toolCallId: payload.toolCallId || payload.id || ('receipt:' + (payload.toolName || 'tool') + ':' + piLabState.receipts.length), toolName: payload.toolName || 'tool', args: payload.params || payload.args || {}, - }, payload.allowed === false ? 'denied' : 'done', { + }, allowed ? 'done' : 'denied', { result: { - allowed: payload.allowed !== false, + allowed: allowed, denyReason: payload.denyReason || null, diffSummary: payload.diffSummary || null, citationCount: payload.citationCount || 0, @@ -3757,7 +3762,9 @@ approvalConfirmed: payload.approvalConfirmed === true, 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(); updateContextStrip(); } @@ -4591,7 +4598,8 @@ function parseUnifiedPatch(patchText) { if (!patchText) return []; 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 currentHunk = null; @@ -4982,8 +4990,8 @@ } var copyMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-copy-markdown]') : null; if (copyMarkdown) { - copyTextToClipboard(copyMarkdown.getAttribute('data-page-ai-pi-lab-copy-markdown') || '').then(function () { - showPiToast('Markdown copied', 'success'); + copyTextToClipboard(copyMarkdown.getAttribute('data-page-ai-pi-lab-copy-markdown') || '').then(function (ok) { + showPiToast(ok ? 'Markdown copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning'); }); return; } @@ -4999,7 +5007,11 @@ } var openArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-open]') : null; 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', { detail: { source: 'page_ai_pi_lab_artifact', @@ -5007,9 +5019,7 @@ url: artUrl, }, })); - if (!artUrl || artUrl === '#') { - showPiToast('引用无效或无法访问', 'warning'); - } + return; } var viewDiff = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-view-diff]') : null; 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; if (copyArtifactMarkdown) { - copyTextToClipboard(copyArtifactMarkdown.getAttribute('data-page-ai-pi-lab-artifact-copy-markdown') || '').then(function () { - showPiToast('Artifact markdown copied', 'success'); + copyTextToClipboard(copyArtifactMarkdown.getAttribute('data-page-ai-pi-lab-artifact-copy-markdown') || '').then(function (ok) { + showPiToast(ok ? 'Artifact markdown copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning'); }); return; } var copyArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy]') : null; if (copyArtifact) { 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; } var deleteTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-delete]') : null; diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js index 45ee9eb4..389916af 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -14,8 +14,21 @@ export function createSidebarPageAiRuntime(context) { function openPageAiDrawer() { // 产品唯一入口: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({}); + } 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); if (pageUiState) pageUiState.pageAiOpen = true; diff --git a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js index 29b3aa23..991d66c4 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -691,49 +691,49 @@ export function createSidebarPageSettingsRuntime(context) { function createKnowledgeRagSettingsPanelHtmlV2() { return '' + - '