feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup
Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token read path design, vault transport split, and retire obsolete filetree smokes. Ignore local vault reimport scripts that trip secret scanners.
This commit is contained in:
@@ -28,6 +28,7 @@ env-archive/
|
|||||||
/.reasonix/
|
/.reasonix/
|
||||||
/.codegraph/
|
/.codegraph/
|
||||||
/.pi/
|
/.pi/
|
||||||
|
/.serena/
|
||||||
/tmp-block-handle-qa.js
|
/tmp-block-handle-qa.js
|
||||||
|
|
||||||
# Retired root-level agent/harness/browser evidence artifacts.
|
# Retired root-level agent/harness/browser evidence artifacts.
|
||||||
@@ -112,3 +113,6 @@ design/05-editor-mainline/reference-code
|
|||||||
# 下载的 Wolai 静态页面参考包,不作为可维护设计稿入库
|
# 下载的 Wolai 静态页面参考包,不作为可维护设计稿入库
|
||||||
design/design/html/
|
design/design/html/
|
||||||
recycle
|
recycle
|
||||||
|
|
||||||
|
# 本地一次性 vault 源数据重导入脚本(含 password frontmatter 探测字面量,ECC 误报;不入库)
|
||||||
|
/scripts/vault-reimport-*.py
|
||||||
|
|||||||
@@ -215,6 +215,20 @@ passwordHint: "Li@[A]***"
|
|||||||
email: alice@example.com
|
email: alice@example.com
|
||||||
apikey: ""
|
apikey: ""
|
||||||
token: ""
|
token: ""
|
||||||
|
# 站内多账号(折叠组);顶层 username/password 为 accounts[0] 镜像
|
||||||
|
accounts:
|
||||||
|
- id: acc_...
|
||||||
|
label: work
|
||||||
|
username: alice
|
||||||
|
email: alice@example.com
|
||||||
|
password: <example-template>
|
||||||
|
passwordHint: "Li@[A]***"
|
||||||
|
# 附录密钥(API Key / Token);顶层 apikey/token 为首个同类镜像
|
||||||
|
secrets:
|
||||||
|
- id: sec_...
|
||||||
|
kind: apikey
|
||||||
|
label: personal-token
|
||||||
|
value: ""
|
||||||
tags: [work, github]
|
tags: [work, github]
|
||||||
fields:
|
fields:
|
||||||
otp_issuer: GitHub
|
otp_issuer: GitHub
|
||||||
@@ -403,9 +417,14 @@ P0 UI 必须有回收站 tab(非「以后再说」)。
|
|||||||
- **存储**:条目里保留模板原文;**reveal** 时展开为明文 `value`,并可选返回 `template` / `usedCipherKeys` / `missingCipherKeys`。
|
- **存储**:条目里保留模板原文;**reveal** 时展开为明文 `value`,并可选返回 `template` / `usedCipherKeys` / `missingCipherKeys`。
|
||||||
- L0 list 仅返回 keys + `hasValue`,**永不**投影片段明文。
|
- L0 list 仅返回 keys + `hasValue`,**永不**投影片段明文。
|
||||||
|
|
||||||
2. **同站多账号**
|
2. **站内多账号 / 多密钥(推荐,取代「同站新账号」拆条)**
|
||||||
- **不**对 `url` 做唯一约束;同一 URL 可对应多条 credential(不同 username)。
|
- 一条 credential = 一个站点/服务(左栏一条)。
|
||||||
- UI:详情提供「同站新账号」预填 url/folderPath;列表 meta 展示 username 区分。
|
- **`accounts[]`**:每组可折叠包含 `username` / `email` / `password` / `passwordHint`(+ 可选 `label`);UI 用 **+ 账号** 新增组。
|
||||||
|
- **`secrets[]`**:附录 API Key / Token / other(`kind` + `label` + `value`);UI 用 **+ 密钥** 新增。
|
||||||
|
- 顶层 `username`/`password`/`apikey`/`token` 仍保留为 **primary 镜像**(首账号密码、首个 apikey/token),供 AI `resolve`/`login` 兼容。
|
||||||
|
- `reveal` 支持 `accountId`(密码)与 `secretId`(附录密钥)。
|
||||||
|
- **不再提供**详情「同站新账号」按钮(多账号应写在同一条目内)。
|
||||||
|
- 兼容:同 URL 仍允许多条 credential(历史数据);列表 meta 展示 `N 账号` / `N 密钥`。
|
||||||
|
|
||||||
3. **分组折叠(folderPath)**
|
3. **分组折叠(folderPath)**
|
||||||
- 逻辑字段 `folderPath`(如 `个人/银行/招商`),**不是** vault 下真实子目录。
|
- 逻辑字段 `folderPath`(如 `个人/银行/招商`),**不是** vault 下真实子目录。
|
||||||
@@ -413,9 +432,9 @@ P0 UI 必须有回收站 tab(非「以后再说」)。
|
|||||||
- 条目文件仍平铺在 `entries/{id}.md`。
|
- 条目文件仍平铺在 `entries/{id}.md`。
|
||||||
- **表单 UX(已落地)**:下拉选择已有 `folderPath` + 文本直接输入新路径(无弹窗、无空组、无「在此新建」);组随条目出现。
|
- **表单 UX(已落地)**:下拉选择已有 `folderPath` + 文本直接输入新路径(无弹窗、无空组、无「在此新建」);组随条目出现。
|
||||||
|
|
||||||
4. **同站 URL 折叠**
|
4. **同站 URL 折叠(历史兼容)**
|
||||||
- 同一 `folderPath` 节点下,**相同非空 url 且 ≥2 条** 时,再包一层可折叠「站点组」。
|
- 同一 `folderPath` 节点下,**相同非空 url 且 ≥2 条** 时,再包一层可折叠「站点组」。
|
||||||
- 单条 URL 不额外包组;无 url 条目平铺。
|
- 新录入应优先合并到 `accounts[]`/`secrets[]`,避免同站拆多条。
|
||||||
|
|
||||||
5. **共享到 AI 密码本**
|
5. **共享到 AI 密码本**
|
||||||
- `POST /api/vault/items/{id}/share-to-ai`:把条目(含 secret 模板)**复制/更新**到目标 actor 的 managed 默认工作区 vault。
|
- `POST /api/vault/items/{id}/share-to-ai`:把条目(含 secret 模板)**复制/更新**到目标 actor 的 managed 默认工作区 vault。
|
||||||
@@ -689,6 +708,7 @@ fn is_vault_sensitive_relative_path(rel: &str) -> bool {
|
|||||||
- **已落地**:`list_ai` / `get_ai` / `resolve` API;Pi `mnote.vault.list|get|resolve`(默认 allow,plan 模式 resolve deny);receipt 脱敏 value;`scripts/mnote-vault-cli.js`。
|
- **已落地**:`list_ai` / `get_ai` / `resolve` API;Pi `mnote.vault.list|get|resolve`(默认 allow,plan 模式 resolve deny);receipt 脱敏 value;`scripts/mnote-vault-cli.js`。
|
||||||
- transcript:agent 应使用 `transcriptHint`(已解析 field),不把 value 贴进聊天。
|
- transcript:agent 应使用 `transcriptHint`(已解析 field),不把 value 贴进聊天。
|
||||||
- skills:`skills/mnote-vault` + 全局 symlink + capability pack。
|
- skills:`skills/mnote-vault` + 全局 symlink + capability pack。
|
||||||
|
- **演进(读密 transport,未实现)**:策略语义不变;agent 读密改走本地 **vaultd + capability token**、**不依赖 mnote-web**。见 `design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`。
|
||||||
|
|
||||||
#### P2
|
#### P2
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
# 12-2 [process] 本地 vaultd + Agent Token 读密路径(不依赖 mnote-web)v1
|
||||||
|
|
||||||
|
> 创建时间:2026-07-23
|
||||||
|
> 状态:`PROCESS`(**P0 L-Access 已落地**;**P1a–P1d 已落地**:web AI list/get/resolve/login/session → core;`mnote-vault serve` UDS + CLI sock→内嵌 fallback;**login/session 不依赖 mnote-web**)
|
||||||
|
> Owner:`12-vault`
|
||||||
|
> 建议 repo 落点:`design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md`(vault 系统空间、AI 密码本策略、文件真源)
|
||||||
|
> - `/home/lix/.agent-infra/vault-policy.md`(多 agent 唯一策略 SSOT)
|
||||||
|
> - `skills/mnote-vault` / `$mnote-vault`
|
||||||
|
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`(禁裸读敏感路径)
|
||||||
|
> - `ARCHITECTURE.md` local-first 原则
|
||||||
|
>
|
||||||
|
> 触发证据:
|
||||||
|
> - Paseo session `21242fc0-db03-454c-a1e5-723688351ee7`:取密依赖 mnote-web 存活 + `auth-e2e` cookie,步骤膨胀、失败面大。
|
||||||
|
> - 用户目标:**读库本质是本地文件**;解密/受控读取只需轻量常驻服务,**不**应绑庞大 mnote-web;agent 内部保存 **token** 做授权取密。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
### 1.1 一句话
|
||||||
|
|
||||||
|
> **文件是库;`mnote-vaultd` 是受控读密网关(及未来 master key 匣);agent 只持 capability token,不持 master key,也不依赖 mnote-web 进程。**
|
||||||
|
|
||||||
|
### 1.2 与 12-1 的关系
|
||||||
|
|
||||||
|
| 维度 | 12-1(已落地主线) | 12-2(本文) |
|
||||||
|
|------|-------------------|--------------|
|
||||||
|
| 真源 | `{workspaceRoot}/.mnote/vault/**` 结构化文件 | **不变** |
|
||||||
|
| 人用 UI | `/vault` + mnote-web `/api/vault/*` | **保留**;不作为 agent 读密必经 |
|
||||||
|
| AI 策略 | 单 AI 本 + 单 resolve 通道 + 禁扫 vault 文件 | **策略不变**;**transport 换** |
|
||||||
|
| Agent 通道 | HTTP → mnote-web(需 cookie / auth-e2e) | UDS/loopback → **vaultd**(Bearer token) |
|
||||||
|
| 常驻进程 | 整站 mnote-web | 可选 **极小 vaultd** |
|
||||||
|
|
||||||
|
12-1 P1「多 agent 唯一策略」中的语义(只读 AI 本、`resolve` 唯一取密、禁通用 file)**冻结保留**。
|
||||||
|
本文只改 **谁持进程、用什么凭证、是否依赖 3000**。
|
||||||
|
|
||||||
|
### 1.3 现状诚实声明(实现前必读)
|
||||||
|
|
||||||
|
当前实现(2026-07):
|
||||||
|
|
||||||
|
- 条目为 workspace 下 **结构化 Markdown + frontmatter**(`mnote.kind: credential`)。
|
||||||
|
- `cipher-book.json` 是 **片段占位符展开**(如 `[A]` → 共享串),**不是** 磁盘 AES 整库加密。
|
||||||
|
- 尚无「用户 master password → 内存 master key → 密文 at rest」完整链路。
|
||||||
|
- 「解密」在产品语言上 = **load 文件 + cipher 展开 + nested secret 解析 + 策略门闩**。
|
||||||
|
|
||||||
|
因此 12-2 分两层能力,**禁止混称**:
|
||||||
|
|
||||||
|
| 层 | 名称 | P0 是否必须 | 说明 |
|
||||||
|
|----|------|-------------|------|
|
||||||
|
| **L-Access** | 受控读密网关 | **是** | token 鉴权、只暴露 list/get/resolve、审计、不依赖 mnote-web |
|
||||||
|
| **L-Crypto** | 真·at-rest 加密 | **P2 可选** | master key 仅驻 vaultd 内存;token **永不**等于 master key |
|
||||||
|
|
||||||
|
P0 交付 **L-Access** 即可消除「3000 挂了就不能取密」。
|
||||||
|
L-Crypto 在文件格式与迁移就绪后再做,不阻塞 agent 读路径瘦身。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals / Non-Goals
|
||||||
|
|
||||||
|
### 2.1 Goals
|
||||||
|
|
||||||
|
1. Agent 读密(list / get / resolve / login 所需密钥字段)**不依赖 mnote-web 运行**。
|
||||||
|
2. 常驻面缩小为 **`mnote-vaultd`**(或等价 one-shot CLI 内嵌同一 core,见 §5.4),不跑 SSR/Sidebar/文档编辑。
|
||||||
|
3. Agent **内部保存 vault_token**;每次调用带 token;**禁止**把 master password / master key 写入 agent 配置。
|
||||||
|
4. 保持 12-1 策略:仅 AI 密码本 actor;禁通用工具扫 `.mnote/vault/**`;resolve 审计不落明文到日志。
|
||||||
|
5. CLI / Pi 工具 / skill 语义对齐:稳态 **1~2 次调用**完成取密(已知 id 时仅 `resolve`)。
|
||||||
|
6. mnote-web UI 与 human CRUD **可继续用现网路径**;演进期可与 vaultd 并行,终态共享 `mnote-vault-core`。
|
||||||
|
|
||||||
|
### 2.2 Non-Goals(本设计不承诺)
|
||||||
|
|
||||||
|
- 不重做 `/vault` 工作台 UI。
|
||||||
|
- 不把 vault 变成 graph / `tree.*` 节点。
|
||||||
|
- 不要求远程多机共享 vaultd(本机 local-first;跨机另案)。
|
||||||
|
- 不在 P0 强制引入 at-rest 加密迁移(L-Crypto = P2)。
|
||||||
|
- 不取消「禁扫 vault 文件」策略(即便未来密文 at rest,仍禁止 agent 把 vault 目录当通用笔记读)。
|
||||||
|
- 不把 Cloudflare 过人机验证自动化为无人值守(仍 human → `session` 回写)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 问题根因(为何要拆)
|
||||||
|
|
||||||
|
```text
|
||||||
|
今天 Agent 取密关键路径:
|
||||||
|
|
||||||
|
skill/policy
|
||||||
|
→ mnote-vault-cli auth-e2e # 需要 mnote-web + 用户 auth API
|
||||||
|
→ list / resolve HTTP # 需要 3000 存活 + cookie
|
||||||
|
→ (可选)login / session
|
||||||
|
|
||||||
|
失败面:
|
||||||
|
- web 未启动 / 崩溃 / 端口占用
|
||||||
|
- cookie eval / FORCE_COLOR / 环境变量污染
|
||||||
|
- agent 探索路径、读 policy、猜 URL → 工具次数爆炸
|
||||||
|
```
|
||||||
|
|
||||||
|
根因归类:
|
||||||
|
|
||||||
|
| 误绑定 | 正确归属 |
|
||||||
|
|--------|----------|
|
||||||
|
| 读本地文件 → 需要 Web 服务器 | 读文件 → 本地 IO + 可选小网关 |
|
||||||
|
| Agent 身份 → mnote-web session cookie | Agent 身份 → vault capability token |
|
||||||
|
| 解密权 → 与 SSR 同进程 | 解密/受控 resolve → vaultd(或 CLI 内嵌 core) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 目标架构
|
||||||
|
|
||||||
|
### 4.1 分层图
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Agent runtime(Grok / Codex / Hermes / Pi / Paseo) │
|
||||||
|
│ - 持有 vault_token(env / 0600 文件 / 可选 keyring) │
|
||||||
|
│ - 薄客户端:CLI 或 Pi tool → 只打 vaultd │
|
||||||
|
│ - 禁止:通用 file 扫 .mnote/vault/** │
|
||||||
|
└────────────────────────────┬─────────────────────────────────┘
|
||||||
|
│ Authorization: Bearer <token>
|
||||||
|
│ 优先 Unix Domain Socket
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ mnote-vaultd(常驻 · 小 · 本机) │
|
||||||
|
│ - 校验 token(iss/aud/exp/scope/actor) │
|
||||||
|
│ - 解析 vault root(workspace / AI actor root) │
|
||||||
|
│ - load index / credential md + cipher expand + nested secret │
|
||||||
|
│ - L-Crypto 时:内存持 master key;lock 清空 │
|
||||||
|
│ - 审计 jsonl(无 plaintext value) │
|
||||||
|
│ - 不托管:SSR、树、Page AI、control-plane 全站逻辑 │
|
||||||
|
└────────────────────────────┬─────────────────────────────────┘
|
||||||
|
│ 受控读写 FS
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 本地文件真源(SSOT,与 12-1 相同) │
|
||||||
|
│ {workspaceRoot}/.mnote/vault/ │
|
||||||
|
│ vault-index.json │
|
||||||
|
│ credentials|*.md(条目) │
|
||||||
|
│ cipher-book.json │
|
||||||
|
│ audit.jsonl │
|
||||||
|
│ AI 本:MNOTE_AI_VAULT_ACTOR 对应 root(实现沿用 12-1) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
旁路(可选,非读密阻塞路径):
|
||||||
|
mnote-web /vault UI、human CRUD、共享到 AI 本
|
||||||
|
vault-login-helper resolve 后外站登录 / session 回写
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 两类密钥(禁止混用)
|
||||||
|
|
||||||
|
| 名称 | 持有者 | 用途 | 落盘? |
|
||||||
|
|------|--------|------|--------|
|
||||||
|
| **Master key**(L-Crypto) | 仅 vaultd 内存 | 解密 at-rest 密文包 | 否(仅 unlock 后内存) |
|
||||||
|
| **Vault token**(capability) | Agent 配置 / runtime | 向 vaultd 证明「谁、何 scope、何 actor」 | 可:`~/.config/mnote/vault-tokens/*.token`,`0600` |
|
||||||
|
|
||||||
|
**硬规则:**
|
||||||
|
|
||||||
|
1. Token **不能**派生或编码 master key。
|
||||||
|
2. 泄露 token ⇒ 在 vaultd 存活且已 unlock 时可 resolve;**不能**离线解密磁盘(L-Crypto 后更强)。
|
||||||
|
3. 泄露 master password ⇒ 可 unlock 整库;**不得**写入任何 agent skill / env 默认模板。
|
||||||
|
|
||||||
|
### 4.3 Token 声明(逻辑 schema)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"iss": "mnote-vaultd",
|
||||||
|
"aud": "local-vaultd",
|
||||||
|
"sub": "agent:paseo",
|
||||||
|
"actor": "mnote-e2e",
|
||||||
|
"scope": ["list", "get", "resolve", "login", "session"],
|
||||||
|
"workspace": "optional-stable-id-or-path-hash",
|
||||||
|
"iat": 0,
|
||||||
|
"exp": 0,
|
||||||
|
"jti": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 序列化:紧凑 JSON + **HMAC-SHA256**(本机 secret)或 ed25519(若未来多签发方)。
|
||||||
|
- 传输:`Authorization: Bearer <base64url(payload).base64url(sig)>`(类 JWT 即可,不必上完整 OIDC)。
|
||||||
|
- P0 默认 scope:`list` + `get` + `resolve`;`login`/`session` 可同 token 或分 token。
|
||||||
|
- `actor` **必须**等于 AI 密码本 actor(默认 `mnote-e2e`,env `MNOTE_AI_VAULT_ACTOR` 可覆盖签发时固化进 token)。
|
||||||
|
|
||||||
|
### 4.4 Bootstrap(token 从哪来)
|
||||||
|
|
||||||
|
```text
|
||||||
|
一次(人或安装脚本):
|
||||||
|
1. 启动 vaultd(或 CLI doctor)
|
||||||
|
2. (L-Crypto)用户 unlock → master key 入内存
|
||||||
|
3. vaultd issue-token \
|
||||||
|
--agent paseo \
|
||||||
|
--actor mnote-e2e \
|
||||||
|
--scope list,get,resolve,login,session \
|
||||||
|
--ttl 90d
|
||||||
|
4. 写入 ~/.config/mnote/vault-tokens/default.token(0600)
|
||||||
|
5. 各 agent skill / env:MNOTE_VAULT_TOKEN_FILE 或 MNOTE_VAULT_TOKEN
|
||||||
|
|
||||||
|
日常 agent:
|
||||||
|
读 token → 调 resolve → 用完 value(不写聊天、不写无关日志)
|
||||||
|
```
|
||||||
|
|
||||||
|
轮换:`revoke jti` + 重新 issue;vaultd 维护 deny jti 集合(内存 + 可选小文件)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `mnote-vaultd` 规格
|
||||||
|
|
||||||
|
### 5.1 进程与监听
|
||||||
|
|
||||||
|
| 项 | P0 决定 |
|
||||||
|
|----|---------|
|
||||||
|
| 二进制名 | `mnote-vaultd`(或 `mnote vault-daemon` 子命令) |
|
||||||
|
| 默认传输 | **Unix Domain Socket**:`$XDG_RUNTIME_DIR/mnote-vaultd.sock`(回退 `/tmp/mnote-vaultd-$UID.sock`) |
|
||||||
|
| TCP 备选 | `127.0.0.1:17300`,**默认关**;仅显式 `--listen-tcp` |
|
||||||
|
| 权限 | sock `0600`、属主当前用户;拒绝非本 uid(若 OS 支持 peer cred) |
|
||||||
|
| 启动 | user systemd / 登录脚本 / 首次 CLI 自动拉起(见 §5.4) |
|
||||||
|
| 资源 | 目标:常驻 RSS 远小于 mnote-web;无浏览器、无 leptos SSR |
|
||||||
|
|
||||||
|
### 5.2 API 表面(JSON over HTTP/1.1 on UDS,或 length-prefixed JSON)
|
||||||
|
|
||||||
|
与现 Pi / CLI 语义对齐,路径前缀建议 `/v1/`:
|
||||||
|
|
||||||
|
| 方法 | 路径 | scope | 解密/展开 | 说明 |
|
||||||
|
|------|------|-------|-----------|------|
|
||||||
|
| GET | `/v1/health` | 无 token 也可 | 否 | `ok`, `unlocked`, `version`, `actorDefault` |
|
||||||
|
| GET | `/v1/items` | `list` | 否* | L0 列表;同现 `list_ai` |
|
||||||
|
| GET | `/v1/items/{id}` | `get` | 否* | 元数据 + 掩码;无 password 明文 |
|
||||||
|
| POST | `/v1/items/{id}/resolve` | `resolve` | **是** | body: `field`, `accountId?`, `secretId?` |
|
||||||
|
| POST | `/v1/items/{id}/login` | `login` | 内部 resolve | 可 P1 仍代理或旁路 helper |
|
||||||
|
| POST | `/v1/items/{id}/session` | `session` | 写 session | 人机回写 |
|
||||||
|
| POST | `/v1/lock` | admin | — | 清 master key(L-Crypto) |
|
||||||
|
| POST | `/v1/unlock` | admin | — | 本机交互/钥匙环;**不**给 agent 默认 scope |
|
||||||
|
| POST | `/v1/tokens/issue` | admin | — | 签发 agent token |
|
||||||
|
| POST | `/v1/tokens/revoke` | admin | — | 吊销 jti |
|
||||||
|
|
||||||
|
\* list/get:若未来元数据也加密,仅解密标题等非 secret 字段;password 永不出现在 list/get。
|
||||||
|
|
||||||
|
**错误码(稳定字符串,CLI/skill 可分支):**
|
||||||
|
|
||||||
|
| code | HTTP | 含义 | Agent 动作 |
|
||||||
|
|------|------|------|------------|
|
||||||
|
| `vaultd_unavailable` | — | sock 连不上 | 尝试 autostart 或提示用户起服务 |
|
||||||
|
| `vault_token_missing` | 401 | 无 Bearer | 检查 token 文件 / env |
|
||||||
|
| `vault_token_invalid` | 401 | 签名/格式错 | 重新 issue |
|
||||||
|
| `vault_token_expired` | 401 | exp | 轮换 token |
|
||||||
|
| `vault_scope_denied` | 403 | scope 不足 | 重新签发含所需 scope |
|
||||||
|
| `vault_actor_mismatch` | 403 | token.actor ≠ AI 本 | 用正确 actor token |
|
||||||
|
| `vault_locked` | 423 | L-Crypto 未 unlock | 提示用户 `unlock`(**不是**启动 mnote-web) |
|
||||||
|
| `vault_item_not_found` | 404 | id 不存在 / 非 AI 本 | list 再选 |
|
||||||
|
| `vault_resolve_field_invalid` | 400 | 字段不合法 | 对齐 schema |
|
||||||
|
| `vault_resolve_ai_only` | 403 | 试图 resolve 用户本 | 策略拒绝(与 12-1 一致) |
|
||||||
|
|
||||||
|
### 5.3 与文件布局(概念,兼容 12-1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
{workspaceRoot}/.mnote/vault/ # SSOT,vaultd 与 mnote-web 共享读
|
||||||
|
vault-index.json
|
||||||
|
… credential markdown …
|
||||||
|
cipher-book.json
|
||||||
|
audit.jsonl # 可继续追加;vaultd 写 agent 通道审计
|
||||||
|
|
||||||
|
~/.config/mnote/
|
||||||
|
vaultd.toml # 可选:root 覆盖、ttl 默认、tcp 开关
|
||||||
|
vault-tokens/
|
||||||
|
default.token # agent 用,0600
|
||||||
|
vaultd-hmac.key # 签发 secret,0600;仅 vaultd/admin CLI 读
|
||||||
|
|
||||||
|
$XDG_RUNTIME_DIR/mnote-vaultd.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workspace root 解析顺序(P0):**
|
||||||
|
|
||||||
|
1. 请求头 / CLI 显式 `--workspace`
|
||||||
|
2. env `MNOTE_VAULT_WORKSPACE`
|
||||||
|
3. `vaultd.toml` 默认
|
||||||
|
4. 与现 mnote-web 一致的「当前 dev workspace」约定(实现时对齐 `vault.rs` AI root 解析,避免双根)
|
||||||
|
|
||||||
|
AI actor root:继续 `MNOTE_AI_VAULT_ACTOR`;token 内 `actor` 必须匹配。
|
||||||
|
|
||||||
|
### 5.4 无常驻时的退化(仍不依赖 mnote-web)
|
||||||
|
|
||||||
|
若用户不愿常驻 daemon:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mnote-vault-cli resolve --id X
|
||||||
|
→ 若 sock 可达:走 vaultd
|
||||||
|
→ 若不可达:CLI 内嵌 mnote-vault-core(同代码)+ 本机 token 校验
|
||||||
|
校验密钥来自 ~/.config/mnote/vaultd-hmac.key(用户可读则等同本机信任)
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:** 内嵌模式仍 **禁止** 无 token 裸读;仍 **禁止** agent 用通用 Read 打开 vault 文件。
|
||||||
|
Autostart:CLI 可 `spawn` 一次 vaultd(user 级),避免每个 agent 拉起全站 3000。
|
||||||
|
|
||||||
|
### 5.5 login / session 边界
|
||||||
|
|
||||||
|
| 能力 | 建议 owner | 依赖 |
|
||||||
|
|------|------------|------|
|
||||||
|
| resolve 密码字段 | **vaultd 必含** | 仅本地文件 |
|
||||||
|
| list/get | **vaultd 必含** | 仅本地文件 |
|
||||||
|
| 外站 login playbook | vaultd 插件 **或** `vault-login-helper` | 网络 + 可选浏览器 |
|
||||||
|
| Cloudflare session 回写 | 同 login helper | 人 + chrome-bridge |
|
||||||
|
|
||||||
|
P0 最小闭环:**health + list + get + resolve**。
|
||||||
|
login/session 可 P0.5 从现 mnote-web 逻辑迁出或短期仍调 web(但 **resolve 不得回退依赖 web**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Agent 侧合同
|
||||||
|
|
||||||
|
### 6.1 环境变量
|
||||||
|
|
||||||
|
| 变量 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `MNOTE_VAULT_TOKEN` | 直接 token 字符串(会话级优先) |
|
||||||
|
| `MNOTE_VAULT_TOKEN_FILE` | 默认 `~/.config/mnote/vault-tokens/default.token` |
|
||||||
|
| `MNOTE_VAULT_SOCK` | 覆盖 UDS 路径 |
|
||||||
|
| `MNOTE_VAULT_WORKSPACE` | workspace root |
|
||||||
|
| `MNOTE_AI_VAULT_ACTOR` | 与签发 token 一致(文档提示;以 token.actor 为准) |
|
||||||
|
| ~~`MNOTE_BASE_URL` + `MNOTE_COOKIE`~~ | **读密路径废弃**;仅 human/web 调试保留 |
|
||||||
|
|
||||||
|
### 6.2 CLI 稳态(目标)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一次安装(人)
|
||||||
|
mnote-vault doctor # sock / token / unlock 状态
|
||||||
|
mnote-vault unlock # 仅 L-Crypto
|
||||||
|
mnote-vault issue-token … # 写入 token 文件
|
||||||
|
|
||||||
|
# Agent 每次取密(已知 id)
|
||||||
|
mnote-vault resolve --id <id> --field password [--account-id …] [--raw]
|
||||||
|
|
||||||
|
# 未知 id
|
||||||
|
mnote-vault list
|
||||||
|
mnote-vault resolve --id <id> --field password
|
||||||
|
```
|
||||||
|
|
||||||
|
**删除读密对 `auth-e2e` 的依赖。**
|
||||||
|
`auth-e2e` 可留作「测 mnote-web UI」工具,**不得**再出现在 `$mnote-vault` skill 稳态步骤。
|
||||||
|
|
||||||
|
### 6.3 Skill / policy 修订点(实现阶段同步,本文先钉文案)
|
||||||
|
|
||||||
|
`/home/lix/.agent-infra/vault-policy.md` 与 `$mnote-vault` 将改为:
|
||||||
|
|
||||||
|
1. 读密:**token + vaultd/CLI**,不经 mnote-web。
|
||||||
|
2. 仍禁扫 `.mnote/vault/**`。
|
||||||
|
3. 登录仍优先 `login`;CF 仍 human + `session`。
|
||||||
|
4. 故障树:`vault_locked` → 用户 unlock;`vaultd_unavailable` → 起 vaultd / CLI 内嵌;**不要**默认 `desktop:hot`。
|
||||||
|
|
||||||
|
### 6.4 Pi tools
|
||||||
|
|
||||||
|
| 现名 | 后端切换 |
|
||||||
|
|------|----------|
|
||||||
|
| `mnote.vault.list\|get\|resolve\|login\|session` | 默认连 vaultd;失败策略见 §5.4 |
|
||||||
|
| 鉴权 | Bearer token,不再注入 web cookie |
|
||||||
|
|
||||||
|
### 6.5 安全行为(agent)
|
||||||
|
|
||||||
|
- 聊天 / transcript:只写 `transcriptHint`,不写 password / cookieHeader。
|
||||||
|
- resolve value 仅用于当前工具链下一步(登录、填表),不回显用户除非用户明确要求。
|
||||||
|
- 禁止把 token 贴进公开 issue / 截图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. mnote-web 关系与迁移阶段
|
||||||
|
|
||||||
|
### 7.1 阶段
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 退出标准 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **A 现状** | Agent → mnote-web cookie → 文件 | — |
|
||||||
|
| **B 并行(P0)** | 抽出 `mnote-vault-core`;`vaultd` + CLI token 路径;web 仍可直读文件 | **不启 3000 可 resolve** smoke 绿 |
|
||||||
|
| **C 收口(P1)** | web `/api/vault/ai/*` 改为调 core 或代理 vaultd;skill 去 auth-e2e | 双路径一致;旧 cookie AI 路径标 deprecated |
|
||||||
|
| **D 可选(P2)** | L-Crypto at-rest;web unlock UI 只负责把 key 交给 vaultd | 磁盘密文;无 key 不可读 |
|
||||||
|
|
||||||
|
### 7.2 代码落点(建议)
|
||||||
|
|
||||||
|
| crate / 路径 | 职责 |
|
||||||
|
|--------------|------|
|
||||||
|
| `rust/crates/mnote-vault-core`(新) | index/load/parse、cipher expand、nested secret resolve、audit append、(P2)encrypt |
|
||||||
|
| `rust/crates/mnote-vaultd`(新)或 `mnote-vault` bin | UDS 服务、token 校验、issue/revoke |
|
||||||
|
| `mnote-web` `vault_store` | 逐步 thin wrapper → core;UI 路由保留 |
|
||||||
|
| `scripts/mnote-vault-cli.js` | 默认 sock+token;保留 `--via-web` 仅调试 |
|
||||||
|
|
||||||
|
**禁止:** 在 vaultd 内重新实现第二套 frontmatter schema。
|
||||||
|
|
||||||
|
### 7.3 兼容
|
||||||
|
|
||||||
|
- 文件 schema(`mnote.vault.credential.v1` 等)**不**为 12-2 破坏性变更。
|
||||||
|
- 现有 AI 本条目无需迁移即可被 vaultd list/resolve(L-Access)。
|
||||||
|
- L-Crypto 若上:单独 migration 设计 + 双读窗口,不在本文展开实现细节,只保留扩展点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 威胁模型(摘要)
|
||||||
|
|
||||||
|
| 威胁 | 缓解 |
|
||||||
|
|------|------|
|
||||||
|
| Agent prompt 注入要求 dump 全库 | 按条 resolve;list 无密;rate limit;审计 |
|
||||||
|
| Token 文件被同机其他用户读 | `0600` + UDS peer cred |
|
||||||
|
| Token 被复制到另一台机器 | `aud=local-vaultd` + 可选绑定 machine-id;无 hmac key 则签失败 |
|
||||||
|
| 磁盘被盗 | P0:OS 磁盘加密依赖;P2:vault at-rest |
|
||||||
|
| 恶意进程连 sock | 无有效 token 拒绝;sock 权限 |
|
||||||
|
| 日志泄漏 | audit 不写 value;CLI 默认 JSON 管道、skill 禁回显 |
|
||||||
|
| mnote-web RCE | 读密不依赖 web 后,攻击面与 key 分离(P2 更明显) |
|
||||||
|
| 用户把 vault 目录加进 agent 工作区 | 策略 + Pi allowlist deny;文档披露(12-1 已有) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验收标准(设计完成 → 实现门闩)
|
||||||
|
|
||||||
|
### 9.1 P0 必须
|
||||||
|
|
||||||
|
- [x] **mnote-web 未运行**时,持有效 token 可 `list` + `resolve` 成功(AI 本已有条目)。
|
||||||
|
- [x] 无 token / 坏 token → 明确错误码,不回退匿名读文件。
|
||||||
|
- [x] resolve 响应含 value;audit 无 value(沿用 store audit)。
|
||||||
|
- [x] CLI 稳态文档与 skill:**无** `auth-e2e` 作为读密前置。
|
||||||
|
- [ ] 通用 file 工具读 vault 路径仍 deny(web 与 Pi 侧回归,既有策略)。
|
||||||
|
- [x] 单元测试:token 校验、scope;store 原有 20 测。
|
||||||
|
- [x] 集成 smoke:`cargo run -p mnote-vault -- …` 不启 3000(2026-07-23 本机绿)。
|
||||||
|
|
||||||
|
### 9.2 P1
|
||||||
|
|
||||||
|
- [x] login/session 不依赖 web(core + CLI + UDS;外站 HTTP 仅 api_first 出站,resolve 仍本地)。
|
||||||
|
- [x] mnote-web AI list/get/resolve/login/session thin-wrap 同一 core(2026-07-23)。
|
||||||
|
- [ ] policy / skill / Paseo appendSystemPrompt 全文切换(skill 已更新 login/session 本地路径;Paseo 段可再对齐)。
|
||||||
|
|
||||||
|
### 9.3 P2(L-Crypto)
|
||||||
|
|
||||||
|
- [ ] unlock 前 resolve → `vault_locked`。
|
||||||
|
- [ ] master key 仅内存;lock 后不可 resolve。
|
||||||
|
- [ ] token 无法离线解密 credential 文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 实现 Checklist(批准设计后执行;本文件阶段只列不写代码)
|
||||||
|
|
||||||
|
### PR-1 — core 抽出
|
||||||
|
|
||||||
|
- [x] 新建 `mnote-vault-core`,从 `vault_store` 搬迁:load index、load credential、cipher expand、`resolve_record_secret_field`、AI actor root 解析。
|
||||||
|
- [x] mnote-web AI 路径 thin-wrap core(list/get/resolve/login/session);CRUD/workbench 仍可双路径。
|
||||||
|
|
||||||
|
### PR-2 — vaultd + token
|
||||||
|
|
||||||
|
- [x] token issue/verify(HMAC + 文件格式 `mnv1.*`)。
|
||||||
|
- [x] UDS server:health/list/get/resolve/**login/session**(`mnote-vault serve`)。
|
||||||
|
- [x] audit 写入(resolve/login/session 经 store)。
|
||||||
|
- [x] CLI 默认 local-core + token(`mnote-vault` bin;sock 优先 fallback 内嵌)。
|
||||||
|
|
||||||
|
### PR-3 — agent 面
|
||||||
|
|
||||||
|
- [x] 更新 `vault-policy.md`、`$mnote-vault`(读密去 auth-e2e;login/session 本地 CLI)。
|
||||||
|
- [ ] Pi tool 仍进程内 thin-wrap core(不强制走 sock;可后置切 UDS)。
|
||||||
|
- [x] smoke:不启 3000 → resolve / session+login reuse。
|
||||||
|
- [x] TESTING_REFERENCE 增补 vault-local 段落(含 session/login)。
|
||||||
|
|
||||||
|
### PR-4 — login 迁出
|
||||||
|
|
||||||
|
- [x] login/session 进 core + CLI + UDS(api_first 出站 HTTP 在 core;human_required 仍人 + session 回写)。
|
||||||
|
- [x] skill 表述:login/session 可本地、不依赖 mnote-web。
|
||||||
|
|
||||||
|
### PR-5 — L-Crypto(另设计修订)
|
||||||
|
|
||||||
|
- [ ] 密文格式、unlock UI、迁移工具。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 待决问题(实现前钉死;默认推荐已标)
|
||||||
|
|
||||||
|
| # | 问题 | 推荐默认 |
|
||||||
|
|---|------|----------|
|
||||||
|
| D1 | UDS vs TCP | **UDS 默认**;TCP 显式开 |
|
||||||
|
| D2 | Token 算法 | **HMAC-SHA256** 本机 secret |
|
||||||
|
| D3 | 无 daemon 时 CLI 内嵌 | **允许**,同 token 校验 |
|
||||||
|
| D4 | login 是否进 P0 vaultd | **P0 不含**;P0 只 resolve 闭环 |
|
||||||
|
| D5 | web 是否立刻代理 vaultd | **P1**;P0 双路径并行 |
|
||||||
|
| D6 | AI root 与多 workspace | P0 单默认 workspace + env 覆盖 |
|
||||||
|
| D7 | L-Crypto 时间表 | **不进 P0**;扩展点保留 |
|
||||||
|
| D8 | token TTL | 默认 90d;可 issue 无 exp 仅 dev |
|
||||||
|
|
||||||
|
若用户否定推荐,在本文修订表追加一行后再开 PR-1。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 修订记录
|
||||||
|
|
||||||
|
| 日期 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-07-23 | v1 初稿:本地文件 SSOT + vaultd + agent token;与 12-1 AI 策略对齐;L-Access P0 / L-Crypto P2;验收与 PR 切片 |
|
||||||
|
| 2026-07-23 | P0 落地:`mnote-vault-core` + `mnote-vault` CLI(issue-token/list/get/resolve);无 web smoke 绿;UDS vaultd / web 共 core 留 P1 |
|
||||||
|
| 2026-07-23 | P1d:login/session → core + CLI/UDS;web thin-wrap;session 回写 + login reuse 不依赖 mnote-web |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 批准栏(实现启动条件)
|
||||||
|
|
||||||
|
- [x] 用户确认 §2 Goals / Non-Goals(2026-07-23「先按12-2实现」)
|
||||||
|
- [x] 用户确认 §11 默认决策(含 D3 内嵌 CLI)
|
||||||
|
- [x] P0:**core + CLI 内嵌 resolve**;login / UDS / web thin-wrap 后置
|
||||||
|
|
||||||
|
P0 交付物:`rust/crates/mnote-vault-core`、`rust/crates/mnote-vault`(bin `mnote-vault`)。
|
||||||
Generated
+25
@@ -2388,6 +2388,30 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mnote-vault"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"mnote-vault-core",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mnote-vault-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"time",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mnote-web"
|
name = "mnote-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2404,6 +2428,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"leptos",
|
"leptos",
|
||||||
"mnote-editor-core",
|
"mnote-editor-core",
|
||||||
|
"mnote-vault-core",
|
||||||
"notify",
|
"notify",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ members = [
|
|||||||
"crates/event-log",
|
"crates/event-log",
|
||||||
"crates/mnote-editor-core",
|
"crates/mnote-editor-core",
|
||||||
"crates/mnote-cli",
|
"crates/mnote-cli",
|
||||||
|
"crates/mnote-vault-core",
|
||||||
|
"crates/mnote-vault",
|
||||||
"crates/mnote-web",
|
"crates/mnote-web",
|
||||||
"crates/index-fts",
|
"crates/index-fts",
|
||||||
"crates/tree-shell-runtime-wasm",
|
"crates/tree-shell-runtime-wasm",
|
||||||
|
|||||||
@@ -813,6 +813,17 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
|||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?;
|
.ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?;
|
||||||
|
// 登录成功后对齐同用户全部 password_* identity 的哈希。
|
||||||
|
// 避免仅更新邮箱 identity 后,用户名登录仍用旧密码(liaibo 线上漂移过)。
|
||||||
|
let now = now_text();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE auth_identities
|
||||||
|
SET password_hash = ?1, updated_at = ?2
|
||||||
|
WHERE user_id = ?3
|
||||||
|
AND provider IN ('password_username', 'password_email')
|
||||||
|
AND password_hash != ?1",
|
||||||
|
params![expected_hash, now, user.id],
|
||||||
|
)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let session = self.create_session(CreateSessionInput {
|
let session = self.create_session(CreateSessionInput {
|
||||||
@@ -4869,6 +4880,50 @@ mod tests {
|
|||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticate_password_syncs_sibling_password_identity_hashes() {
|
||||||
|
let store = store();
|
||||||
|
let current_password = ["current", "secret"].join("-");
|
||||||
|
let stale_password = ["stale", "secret"].join("-");
|
||||||
|
create_password_identity(&store, "dana", "dana@example.com", ¤t_password);
|
||||||
|
|
||||||
|
// 模拟仅邮箱 identity 被改密、用户名 identity 仍是旧哈希的漂移。
|
||||||
|
{
|
||||||
|
let conn = store.lock_conn().expect("lock");
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE auth_identities
|
||||||
|
SET password_hash = ?1
|
||||||
|
WHERE provider = 'password_username' AND provider_subject = 'dana'",
|
||||||
|
params![password_hash_v1(&stale_password)],
|
||||||
|
)
|
||||||
|
.expect("stale username hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
|
.authenticate_password(AuthenticatePasswordInput {
|
||||||
|
account: "dana@example.com".to_string(),
|
||||||
|
password: current_password.clone(),
|
||||||
|
session_id: None,
|
||||||
|
token_hash: session_token_hash("dana-email-repair"),
|
||||||
|
user_agent: None,
|
||||||
|
ip_hash: None,
|
||||||
|
expires_at: None,
|
||||||
|
})
|
||||||
|
.expect("email login should still work");
|
||||||
|
|
||||||
|
store
|
||||||
|
.authenticate_password(AuthenticatePasswordInput {
|
||||||
|
account: "dana".to_string(),
|
||||||
|
password: current_password,
|
||||||
|
session_id: None,
|
||||||
|
token_hash: session_token_hash("dana-username-after-sync"),
|
||||||
|
user_agent: None,
|
||||||
|
ip_hash: None,
|
||||||
|
expires_at: None,
|
||||||
|
})
|
||||||
|
.expect("username login should work after sibling hash sync");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ai_tool_events_append_and_list() {
|
fn ai_tool_events_append_and_list() {
|
||||||
let store = store();
|
let store = store();
|
||||||
|
|||||||
@@ -1337,6 +1337,17 @@ impl ControlPlaneStore for TursoControlPlaneStore {
|
|||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?;
|
.ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?;
|
||||||
|
// 登录成功后对齐同用户全部 password_* identity 的哈希。
|
||||||
|
// 避免仅更新邮箱 identity 后,用户名登录仍用旧密码(liaibo 线上漂移过)。
|
||||||
|
let now = now_text();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE auth_identities
|
||||||
|
SET password_hash = ?1, updated_at = ?2
|
||||||
|
WHERE user_id = ?3
|
||||||
|
AND provider IN ('password_username', 'password_email')
|
||||||
|
AND password_hash != ?1",
|
||||||
|
params![expected_hash, now, user.id],
|
||||||
|
)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let session = self.create_session(CreateSessionInput {
|
let session = self.create_session(CreateSessionInput {
|
||||||
@@ -5235,6 +5246,50 @@ mod tests {
|
|||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticate_password_syncs_sibling_password_identity_hashes() {
|
||||||
|
let store = store();
|
||||||
|
let current_password = ["current", "secret"].join("-");
|
||||||
|
let stale_password = ["stale", "secret"].join("-");
|
||||||
|
create_password_identity(&store, "dana", "dana@example.com", ¤t_password);
|
||||||
|
|
||||||
|
// 模拟仅邮箱 identity 被改密、用户名 identity 仍是旧哈希的漂移。
|
||||||
|
{
|
||||||
|
let conn = store.lock_conn().expect("lock");
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE auth_identities
|
||||||
|
SET password_hash = ?1
|
||||||
|
WHERE provider = 'password_username' AND provider_subject = 'dana'",
|
||||||
|
params![password_hash_v1(&stale_password)],
|
||||||
|
)
|
||||||
|
.expect("stale username hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
|
.authenticate_password(AuthenticatePasswordInput {
|
||||||
|
account: "dana@example.com".to_string(),
|
||||||
|
password: current_password.clone(),
|
||||||
|
session_id: None,
|
||||||
|
token_hash: session_token_hash("dana-email-repair"),
|
||||||
|
user_agent: None,
|
||||||
|
ip_hash: None,
|
||||||
|
expires_at: None,
|
||||||
|
})
|
||||||
|
.expect("email login should still work");
|
||||||
|
|
||||||
|
store
|
||||||
|
.authenticate_password(AuthenticatePasswordInput {
|
||||||
|
account: "dana".to_string(),
|
||||||
|
password: current_password,
|
||||||
|
session_id: None,
|
||||||
|
token_hash: session_token_hash("dana-username-after-sync"),
|
||||||
|
user_agent: None,
|
||||||
|
ip_hash: None,
|
||||||
|
expires_at: None,
|
||||||
|
})
|
||||||
|
.expect("username login should work after sibling hash sync");
|
||||||
|
}
|
||||||
|
|
||||||
// --- Fault injection tests ---
|
// --- Fault injection tests ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "mnote-vault-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Local-first password vault store + agent token (no mnote-web)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
//! AI password-book path (list / get / resolve / login / session) without mnote-web.
|
||||||
|
|
||||||
|
use crate::error::VaultError;
|
||||||
|
use crate::store::{
|
||||||
|
self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials,
|
||||||
|
load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher,
|
||||||
|
project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session,
|
||||||
|
resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord,
|
||||||
|
VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
|
||||||
|
};
|
||||||
|
use crate::token::DEFAULT_ACTOR;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const DEFAULT_DATA_BASE: &str = "/mnt/Data1T/Mnote_data";
|
||||||
|
|
||||||
|
pub fn ai_vault_actor_id() -> String {
|
||||||
|
std::env::var("MNOTE_AI_VAULT_ACTOR")
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_ACTOR.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode actor id for managed path segment (aligned with mnote-web).
|
||||||
|
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(),
|
||||||
|
other => format!("~{:02x}", other as u32),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn managed_data_base() -> PathBuf {
|
||||||
|
std::env::var("MNOTE_DATA_DIR")
|
||||||
|
.or_else(|_| std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR"))
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_BASE))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ai_vault_workspace_root() -> PathBuf {
|
||||||
|
if let Ok(p) = std::env::var("MNOTE_VAULT_WORKSPACE") {
|
||||||
|
let p = p.trim();
|
||||||
|
if !p.is_empty() {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let actor = encode_actor_segment(&ai_vault_actor_id());
|
||||||
|
managed_data_base()
|
||||||
|
.join("users")
|
||||||
|
.join(actor)
|
||||||
|
.join("workspaces")
|
||||||
|
.join("my-space")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_ai_vault_workspace() -> Result<PathBuf, VaultError> {
|
||||||
|
let root = ai_vault_workspace_root();
|
||||||
|
if !root.exists() {
|
||||||
|
std::fs::create_dir_all(root.join(".mnote")).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_ai_root_unavailable",
|
||||||
|
format!("无法创建 AI 密码本工作区 {}: {e}", root.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let root = root.canonicalize().map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_ai_root_unavailable",
|
||||||
|
format!("无法访问 AI 密码本工作区: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let _ = ensure_vault_directories(&root)?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_secret_field(field: &str) -> Result<&'static str, VaultError> {
|
||||||
|
match field.trim() {
|
||||||
|
"password" => Ok("password"),
|
||||||
|
"apikey" | "apiKey" | "api_key" => Ok("apikey"),
|
||||||
|
"token" => Ok("token"),
|
||||||
|
"username" => Ok("username"),
|
||||||
|
"email" => Ok("email"),
|
||||||
|
other => Err(VaultError::bad_request_code(
|
||||||
|
"vault_resolve_field_invalid",
|
||||||
|
format!("不支持字段: {other};允许 password|apikey|token|username|email"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, VaultError> {
|
||||||
|
let root = ensure_ai_vault_workspace()?;
|
||||||
|
let (index, items) = list_credentials(&root, status)?;
|
||||||
|
let book = load_cipher_book(&root).ok();
|
||||||
|
let book_ref = book.as_ref();
|
||||||
|
let projected: Vec<Value> = items
|
||||||
|
.iter()
|
||||||
|
.map(|e| project_list_entry_with_cipher(e, book_ref))
|
||||||
|
.collect();
|
||||||
|
Ok(json!({
|
||||||
|
"schema": "mnote.vault.list.v1",
|
||||||
|
"status": status.as_str(),
|
||||||
|
"revision": index.revision,
|
||||||
|
"updatedAt": index.updated_at,
|
||||||
|
"items": projected,
|
||||||
|
"isAiVault": true,
|
||||||
|
"aiVaultActorId": ai_vault_actor_id(),
|
||||||
|
"vaultRole": "ai",
|
||||||
|
"transport": "local-core",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_ai_vault_item(credential_id: &str) -> Result<Value, VaultError> {
|
||||||
|
let root = ensure_ai_vault_workspace()?;
|
||||||
|
let record = get_credential(&root, credential_id)?;
|
||||||
|
Ok(json!({
|
||||||
|
"item": project_item_l0_with_cipher(&record, Some(&root)),
|
||||||
|
"isAiVault": true,
|
||||||
|
"aiVaultActorId": ai_vault_actor_id(),
|
||||||
|
"transport": "local-core",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_ai_vault_secret(
|
||||||
|
credential_id: &str,
|
||||||
|
field: &str,
|
||||||
|
actor: &str,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
account_id: Option<&str>,
|
||||||
|
secret_id: Option<&str>,
|
||||||
|
) -> Result<Value, VaultError> {
|
||||||
|
let field_norm = normalize_secret_field(field)?;
|
||||||
|
let account_id = account_id.map(str::trim).filter(|v| !v.is_empty());
|
||||||
|
let secret_id = secret_id.map(str::trim).filter(|v| !v.is_empty());
|
||||||
|
let root = ensure_ai_vault_workspace()?;
|
||||||
|
let record = match get_credential(&root, credential_id) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(err) => {
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"resolve",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some(field_norm),
|
||||||
|
request_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if record.status != VaultItemStatus::Active {
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"resolve",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some(field_norm),
|
||||||
|
request_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_resolve_inactive",
|
||||||
|
"只能 resolve 在用条目",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let secret = resolve_record_secret_field(&record, field_norm, account_id, secret_id);
|
||||||
|
let secret_proj = match project_secret_revealed(&root, secret) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"resolve",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some(field_norm),
|
||||||
|
request_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"resolve",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some(field_norm),
|
||||||
|
request_id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let value = secret_proj
|
||||||
|
.get("value")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let state = secret_proj
|
||||||
|
.get("state")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("absent");
|
||||||
|
let mut out = json!({
|
||||||
|
"schema": "mnote.vault.resolve.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"field": field_norm,
|
||||||
|
"resolved": state == "revealed",
|
||||||
|
"state": state,
|
||||||
|
"value": if state == "revealed" { Value::String(value.to_string()) } else { Value::Null },
|
||||||
|
"usedCipherKeys": secret_proj.get("usedCipherKeys").cloned().unwrap_or(json!([])),
|
||||||
|
"missingCipherKeys": secret_proj.get("missingCipherKeys").cloned().unwrap_or(json!([])),
|
||||||
|
"template": secret_proj.get("template").cloned().unwrap_or(Value::Null),
|
||||||
|
"aiVaultActorId": ai_vault_actor_id(),
|
||||||
|
"transcriptHint": format!("已解析 {field_norm}"),
|
||||||
|
"note": "多 agent 唯一读密通道;勿把 value 写入聊天/commit/RAG",
|
||||||
|
"transport": "local-core",
|
||||||
|
});
|
||||||
|
if let Some(aid) = account_id {
|
||||||
|
out["accountId"] = json!(aid);
|
||||||
|
}
|
||||||
|
if let Some(sid) = secret_id {
|
||||||
|
out["secretId"] = json!(sid);
|
||||||
|
}
|
||||||
|
let _ = store::vault_root(Path::new(&root));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_session_expires_hours() -> i64 {
|
||||||
|
std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
.filter(|h| *h > 0)
|
||||||
|
.unwrap_or(168) // 7 days
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_expires_rfc3339_from_now() -> String {
|
||||||
|
let hours = default_session_expires_hours();
|
||||||
|
let t = time::OffsetDateTime::now_utc() + time::Duration::hours(hours);
|
||||||
|
t.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap_or_else(|_| now_rfc3339())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn origin_from_credential_url(url: Option<&str>) -> Result<String, VaultError> {
|
||||||
|
let raw = url
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_login_url_required",
|
||||||
|
"条目缺少 url,无法推导登录 origin",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let with_scheme = if raw.contains("://") {
|
||||||
|
raw.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{raw}")
|
||||||
|
};
|
||||||
|
let parsed = reqwest::Url::parse(&with_scheme).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_login_url_invalid",
|
||||||
|
format!("无法解析 url: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(port) = parsed.port() {
|
||||||
|
return Ok(format!(
|
||||||
|
"{}://{}:{}",
|
||||||
|
parsed.scheme(),
|
||||||
|
parsed.host_str().unwrap_or(""),
|
||||||
|
port
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let origin = format!(
|
||||||
|
"{}://{}",
|
||||||
|
parsed.scheme(),
|
||||||
|
parsed.host_str().unwrap_or("")
|
||||||
|
);
|
||||||
|
if origin.ends_with("://") {
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_login_url_invalid",
|
||||||
|
"url 缺少 host",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_login_account(
|
||||||
|
workspace_root: &Path,
|
||||||
|
record: &VaultCredentialRecord,
|
||||||
|
preferred: &str,
|
||||||
|
) -> String {
|
||||||
|
let pref = preferred.trim().to_ascii_lowercase();
|
||||||
|
let expand = |raw: Option<&str>| -> Option<String> {
|
||||||
|
let t = raw.map(str::trim).filter(|s| !s.is_empty())?;
|
||||||
|
match resolve_secret_with_cipher_book(workspace_root, Some(t)) {
|
||||||
|
Ok(Some(resolved)) => {
|
||||||
|
let v = resolved.value.trim().to_string();
|
||||||
|
if v.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Some(t.to_string()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let email = expand(record.email.as_deref()).or_else(|| {
|
||||||
|
record
|
||||||
|
.accounts
|
||||||
|
.first()
|
||||||
|
.and_then(|a| expand(a.email.as_deref()))
|
||||||
|
});
|
||||||
|
let user = expand(record.username.as_deref()).or_else(|| {
|
||||||
|
record
|
||||||
|
.accounts
|
||||||
|
.first()
|
||||||
|
.and_then(|a| expand(a.username.as_deref()))
|
||||||
|
});
|
||||||
|
match pref.as_str() {
|
||||||
|
"email" => email.or(user).unwrap_or_default(),
|
||||||
|
"username" => user.or(email).unwrap_or_default(),
|
||||||
|
_ => email.or(user).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_set_cookie_into_header(existing: &str, set_cookies: &[String]) -> String {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
for part in existing.split(';') {
|
||||||
|
let p = part.trim();
|
||||||
|
if p.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((k, v)) = p.split_once('=') {
|
||||||
|
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sc in set_cookies {
|
||||||
|
let first = sc.split(';').next().unwrap_or("").trim();
|
||||||
|
if let Some((k, v)) = first.split_once('=') {
|
||||||
|
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.iter()
|
||||||
|
.map(|(k, v)| format!("{k}={v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write browser-captured cookies into AI password book (no mnote-web).
|
||||||
|
pub fn put_ai_vault_session(
|
||||||
|
credential_id: &str,
|
||||||
|
cookie_header: &str,
|
||||||
|
expires_at: Option<&str>,
|
||||||
|
source: &str,
|
||||||
|
actor: &str,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
) -> Result<Value, VaultError> {
|
||||||
|
let root = ensure_ai_vault_workspace()?;
|
||||||
|
let cookie = cookie_header.trim();
|
||||||
|
if cookie.is_empty() {
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_session_cookie_required",
|
||||||
|
"cookieHeader 不能为空",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let now = time::OffsetDateTime::now_utc()
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap_or_else(|_| now_rfc3339());
|
||||||
|
let expires = expires_at
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(session_expires_rfc3339_from_now);
|
||||||
|
let session = VaultLoginSession {
|
||||||
|
cookie_header: Some(cookie.to_string()),
|
||||||
|
expires_at: Some(expires.clone()),
|
||||||
|
last_login_at: Some(now),
|
||||||
|
source: Some(source.trim().to_string()),
|
||||||
|
};
|
||||||
|
let record = put_login_session(&root, credential_id, session)?;
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"session_put",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some(source),
|
||||||
|
request_id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
Ok(json!({
|
||||||
|
"schema": "mnote.vault.session.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"ok": true,
|
||||||
|
"hasLoginSession": true,
|
||||||
|
"expiresAt": expires,
|
||||||
|
"source": source,
|
||||||
|
"item": project_item_l0_with_cipher(&record, Some(&root)),
|
||||||
|
"transcriptHint": "已回写登录态",
|
||||||
|
"transport": "local-core",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local login against AI password book (no mnote-web):
|
||||||
|
/// reuse fresh session → else api_first password login → save session.
|
||||||
|
/// Cloudflare / captcha → human_required (agent uses browser tools then `session`).
|
||||||
|
pub fn login_ai_vault_credential(
|
||||||
|
credential_id: &str,
|
||||||
|
force_refresh: bool,
|
||||||
|
actor: &str,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
) -> Result<Value, VaultError> {
|
||||||
|
let root = ensure_ai_vault_workspace()?;
|
||||||
|
let mut record = get_credential(&root, credential_id)?;
|
||||||
|
if record.status != VaultItemStatus::Active {
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_login_inactive",
|
||||||
|
"只能登录在用条目",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.login_playbook.is_none() {
|
||||||
|
let pb = VaultLoginPlaybook::default_for_credential(
|
||||||
|
record.email.as_deref(),
|
||||||
|
record.username.as_deref(),
|
||||||
|
);
|
||||||
|
record = put_login_playbook(&root, credential_id, pb)?;
|
||||||
|
}
|
||||||
|
let playbook = record.login_playbook.clone().unwrap_or_else(|| {
|
||||||
|
VaultLoginPlaybook::default_for_credential(
|
||||||
|
record.email.as_deref(),
|
||||||
|
record.username.as_deref(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if playbook.is_human_required() && !force_refresh {
|
||||||
|
if let Some(sess) = record.login_session.as_ref() {
|
||||||
|
if login_session_is_fresh(sess) {
|
||||||
|
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"login_reuse",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some("session"),
|
||||||
|
request_id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": "mnote.vault.login.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"reused": true,
|
||||||
|
"mode": "session",
|
||||||
|
"cookieHeader": cookie,
|
||||||
|
"expiresAt": sess.expires_at,
|
||||||
|
"loginPlaybook": playbook,
|
||||||
|
"transcriptHint": "已复用登录态",
|
||||||
|
"note": "playbook=human_required;有可用 session 直接复用",
|
||||||
|
"transport": "local-core",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": "mnote.vault.login.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"reused": false,
|
||||||
|
"mode": "human_required",
|
||||||
|
"ok": false,
|
||||||
|
"code": "vault_login_human_required",
|
||||||
|
"message": "需要人完成验证码/Cloudflare 后回写登录态",
|
||||||
|
"loginPlaybook": playbook,
|
||||||
|
"humanInstructions": {
|
||||||
|
"local": "用 chrome-bridge 打开 loginUrl 完成验证并登录,然后 mnote-vault session --id <id> --cookie-header '…'",
|
||||||
|
"remote": "用 Paseo 浏览器工具完成验证后同样调用 session 回写",
|
||||||
|
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
||||||
|
},
|
||||||
|
"transcriptHint": "需要人工验证后回写 session",
|
||||||
|
"transport": "local-core",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !force_refresh {
|
||||||
|
if let Some(sess) = record.login_session.as_ref() {
|
||||||
|
if login_session_is_fresh(sess) {
|
||||||
|
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"login_reuse",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some("session"),
|
||||||
|
request_id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": "mnote.vault.login.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"reused": true,
|
||||||
|
"mode": "session",
|
||||||
|
"cookieHeader": cookie,
|
||||||
|
"expiresAt": sess.expires_at,
|
||||||
|
"source": sess.source,
|
||||||
|
"loginPlaybook": {
|
||||||
|
"mode": playbook.mode,
|
||||||
|
"preferredAccount": playbook.preferred_account,
|
||||||
|
},
|
||||||
|
"accountHint": pick_login_account(&root, &record, &playbook.preferred_account),
|
||||||
|
"url": record.url,
|
||||||
|
"transcriptHint": "已复用登录态",
|
||||||
|
"transport": "local-core",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_resolved = resolve_ai_vault_secret(
|
||||||
|
credential_id,
|
||||||
|
"password",
|
||||||
|
actor,
|
||||||
|
request_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
if password_resolved
|
||||||
|
.get("resolved")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
!= Some(true)
|
||||||
|
{
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_login_no_password",
|
||||||
|
"条目无可用 password,无法自动登录",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let password = password_resolved
|
||||||
|
.get("value")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let account = pick_login_account(&root, &record, &playbook.preferred_account);
|
||||||
|
if account.is_empty() {
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_login_no_account",
|
||||||
|
"条目缺少 email/username",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = playbook.mode.trim().to_ascii_lowercase();
|
||||||
|
if mode == "browser" {
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": "mnote.vault.login.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"reused": false,
|
||||||
|
"mode": "browser",
|
||||||
|
"ok": false,
|
||||||
|
"code": "vault_login_browser_required",
|
||||||
|
"message": "playbook 要求浏览器登录;agent 用 chrome-bridge/Paseo 浏览器按 selectors 填表后 session 回写",
|
||||||
|
"loginPlaybook": playbook,
|
||||||
|
"account": account,
|
||||||
|
"passwordResolved": true,
|
||||||
|
"url": record.url,
|
||||||
|
"transcriptHint": "需要浏览器按 playbook 登录后回写 session",
|
||||||
|
"humanInstructions": {
|
||||||
|
"local": "chrome-bridge 登录后 mnote-vault session",
|
||||||
|
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
||||||
|
},
|
||||||
|
"transport": "local-core",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// api_first (default) — outbound HTTP only; does not need mnote-web.
|
||||||
|
let origin = origin_from_credential_url(record.url.as_deref())?;
|
||||||
|
let api_path = playbook
|
||||||
|
.api_path
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("/api/auth");
|
||||||
|
let login_url = if api_path.starts_with("http") {
|
||||||
|
api_path.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
origin.trim_end_matches('/'),
|
||||||
|
if api_path.starts_with('/') {
|
||||||
|
api_path.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/{api_path}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"action": "auth:signIn",
|
||||||
|
"args": {
|
||||||
|
"provider": "password",
|
||||||
|
"params": {
|
||||||
|
"password": password,
|
||||||
|
"flow": "signIn",
|
||||||
|
"account": account,
|
||||||
|
"email": if account.contains('@') { Value::String(account.clone()) } else { Value::Null },
|
||||||
|
"name": if account.contains('@') {
|
||||||
|
account.split('@').next().unwrap_or(&account)
|
||||||
|
} else {
|
||||||
|
account.as_str()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
VaultError::bad_request_code("vault_login_client_failed", format!("HTTP 客户端: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&login_url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.to_ascii_lowercase().contains("cloudflare")
|
||||||
|
|| msg.to_ascii_lowercase().contains("403")
|
||||||
|
{
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_login_human_required",
|
||||||
|
format!("登录请求失败,可能需人机验证: {e}"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_login_http_failed",
|
||||||
|
format!("登录请求失败: {e}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let set_cookies: Vec<String> = response
|
||||||
|
.headers()
|
||||||
|
.get_all(reqwest::header::SET_COOKIE)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok().map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
let resp_text = response.text().unwrap_or_default();
|
||||||
|
|
||||||
|
let lower = resp_text.to_ascii_lowercase();
|
||||||
|
if status.as_u16() == 403
|
||||||
|
|| lower.contains("cloudflare")
|
||||||
|
|| lower.contains("cf-challenge")
|
||||||
|
|| lower.contains("captcha")
|
||||||
|
|| lower.contains("just a moment")
|
||||||
|
{
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"login_challenge",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some("human"),
|
||||||
|
request_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": "mnote.vault.login.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"reused": false,
|
||||||
|
"mode": "human_required",
|
||||||
|
"ok": false,
|
||||||
|
"code": "vault_login_human_required",
|
||||||
|
"message": "检测到验证码/Cloudflare/拦截,需人完成验证后回写 session",
|
||||||
|
"httpStatus": status.as_u16(),
|
||||||
|
"loginPlaybook": playbook,
|
||||||
|
"url": record.url,
|
||||||
|
"transcriptHint": "需要人工验证后回写 session",
|
||||||
|
"humanInstructions": {
|
||||||
|
"local": "chrome-bridge 完成验证登录 → mnote-vault session",
|
||||||
|
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
||||||
|
},
|
||||||
|
"transport": "local-core",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut json_ok = false;
|
||||||
|
if let Ok(v) = serde_json::from_str::<Value>(&resp_text) {
|
||||||
|
if v.get("error").is_none() && (status.is_success() || status.as_u16() == 200) {
|
||||||
|
json_ok = true;
|
||||||
|
}
|
||||||
|
if let Some(err) = v.get("error").and_then(Value::as_str) {
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"login_failed",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some("api"),
|
||||||
|
request_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_login_rejected",
|
||||||
|
format!("远端登录拒绝: {err}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !status.is_success() && !json_ok && set_cookies.is_empty() {
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"login_failed",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some("api"),
|
||||||
|
request_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_login_rejected",
|
||||||
|
format!("远端登录失败 HTTP {}", status.as_u16()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie_header = merge_set_cookie_into_header("", &set_cookies);
|
||||||
|
if cookie_header.is_empty() {
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_login_no_cookie",
|
||||||
|
"登录响应未包含 Set-Cookie;请改用浏览器登录并 session 回写",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = time::OffsetDateTime::now_utc()
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap_or_else(|_| "now".into());
|
||||||
|
let expires = session_expires_rfc3339_from_now();
|
||||||
|
let session = VaultLoginSession {
|
||||||
|
cookie_header: Some(cookie_header.clone()),
|
||||||
|
expires_at: Some(expires.clone()),
|
||||||
|
last_login_at: Some(now),
|
||||||
|
source: Some("api".into()),
|
||||||
|
};
|
||||||
|
let _ = put_login_session(&root, credential_id, session)?;
|
||||||
|
let _ = append_vault_audit(
|
||||||
|
&root,
|
||||||
|
"login_ok",
|
||||||
|
actor,
|
||||||
|
credential_id,
|
||||||
|
Some("api"),
|
||||||
|
request_id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"schema": "mnote.vault.login.v1",
|
||||||
|
"id": credential_id,
|
||||||
|
"reused": false,
|
||||||
|
"mode": "api",
|
||||||
|
"ok": true,
|
||||||
|
"cookieHeader": cookie_header,
|
||||||
|
"expiresAt": expires,
|
||||||
|
"account": account,
|
||||||
|
"url": record.url,
|
||||||
|
"loginPlaybook": {
|
||||||
|
"mode": playbook.mode,
|
||||||
|
"preferredAccount": playbook.preferred_account,
|
||||||
|
},
|
||||||
|
"transcriptHint": "已登录并保存登录态",
|
||||||
|
"note": "下次同 id 调用 login 将优先复用 session,直到过期或 forceRefresh",
|
||||||
|
"transport": "local-core",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::store::{
|
||||||
|
create_credential, purge_credential, soft_delete_credential, VaultCreateInput,
|
||||||
|
};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static AI_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn with_temp_workspace<F, R>(f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(&Path) -> R,
|
||||||
|
{
|
||||||
|
let _guard = AI_TEST_LOCK.lock().unwrap();
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let root = std::env::temp_dir().join(format!("mnote-vault-ai-session-{nanos}"));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
std::env::set_var("MNOTE_VAULT_WORKSPACE", &root);
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(root.as_path())));
|
||||||
|
std::env::remove_var("MNOTE_VAULT_WORKSPACE");
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
match result {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(payload) => std::panic::resume_unwind(payload),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn put_session_then_login_reuses_without_http() {
|
||||||
|
with_temp_workspace(|root| {
|
||||||
|
let created = create_credential(
|
||||||
|
root,
|
||||||
|
VaultCreateInput {
|
||||||
|
title: "session-reuse-core".into(),
|
||||||
|
url: Some("https://example.com/auth".into()),
|
||||||
|
email: Some("a@example.com".into()),
|
||||||
|
password: Some("secret-pass".into()),
|
||||||
|
tags: vec!["ai-shared".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("create");
|
||||||
|
let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(2))
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap();
|
||||||
|
let put = put_ai_vault_session(
|
||||||
|
&created.id,
|
||||||
|
"mnote_session=abc; other=1",
|
||||||
|
Some(&expires),
|
||||||
|
"human_bridge",
|
||||||
|
"tester",
|
||||||
|
Some("req-session"),
|
||||||
|
)
|
||||||
|
.expect("session put");
|
||||||
|
assert_eq!(put["ok"], true);
|
||||||
|
assert_eq!(put["hasLoginSession"], true);
|
||||||
|
assert_eq!(put["transport"], "local-core");
|
||||||
|
|
||||||
|
let login = login_ai_vault_credential(&created.id, false, "tester", None)
|
||||||
|
.expect("login reuse");
|
||||||
|
assert_eq!(login["reused"], true);
|
||||||
|
assert_eq!(login["mode"], "session");
|
||||||
|
assert_eq!(login["cookieHeader"], "mnote_session=abc; other=1");
|
||||||
|
assert_eq!(login["transport"], "local-core");
|
||||||
|
|
||||||
|
let _ = soft_delete_credential(root, &created.id);
|
||||||
|
let _ = purge_credential(root, &created.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_required_without_session_returns_instructions() {
|
||||||
|
with_temp_workspace(|root| {
|
||||||
|
let created = create_credential(
|
||||||
|
root,
|
||||||
|
VaultCreateInput {
|
||||||
|
title: "human-required-core".into(),
|
||||||
|
url: Some("https://example.com/login".into()),
|
||||||
|
email: Some("h@example.com".into()),
|
||||||
|
password: Some("pw".into()),
|
||||||
|
tags: vec!["ai-shared".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("create");
|
||||||
|
let mut pb = VaultLoginPlaybook::default_for_credential(Some("h@example.com"), None);
|
||||||
|
pb.mode = "human_required".into();
|
||||||
|
let _ = put_login_playbook(root, &created.id, pb).expect("playbook");
|
||||||
|
|
||||||
|
let login = login_ai_vault_credential(&created.id, false, "tester", None)
|
||||||
|
.expect("login human");
|
||||||
|
assert_eq!(login["reused"], false);
|
||||||
|
assert_eq!(login["mode"], "human_required");
|
||||||
|
assert_eq!(login["ok"], false);
|
||||||
|
assert_eq!(login["code"], "vault_login_human_required");
|
||||||
|
assert!(login["humanInstructions"]["cli"].as_str().unwrap().contains(&created.id));
|
||||||
|
|
||||||
|
let _ = soft_delete_credential(root, &created.id);
|
||||||
|
let _ = purge_credential(root, &created.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn put_session_rejects_empty_cookie() {
|
||||||
|
with_temp_workspace(|root| {
|
||||||
|
let created = create_credential(
|
||||||
|
root,
|
||||||
|
VaultCreateInput {
|
||||||
|
title: "empty-cookie".into(),
|
||||||
|
email: Some("e@example.com".into()),
|
||||||
|
password: Some("pw".into()),
|
||||||
|
tags: vec!["ai-shared".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("create");
|
||||||
|
let err = put_ai_vault_session(&created.id, " ", None, "api", "tester", None)
|
||||||
|
.expect_err("empty cookie");
|
||||||
|
assert_eq!(err.code, "vault_session_cookie_required");
|
||||||
|
let _ = soft_delete_credential(root, &created.id);
|
||||||
|
let _ = purge_credential(root, &created.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//! Vault errors without axum — map to HTTP-like codes for CLI / vaultd.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Logical status (subset used by vault_store).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VaultStatus {
|
||||||
|
BadRequest,
|
||||||
|
NotFound,
|
||||||
|
Conflict,
|
||||||
|
Unauthorized,
|
||||||
|
Forbidden,
|
||||||
|
Locked,
|
||||||
|
Unavailable,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultStatus {
|
||||||
|
pub fn as_u16(self) -> u16 {
|
||||||
|
match self {
|
||||||
|
Self::BadRequest => 400,
|
||||||
|
Self::Unauthorized => 401,
|
||||||
|
Self::Forbidden => 403,
|
||||||
|
Self::NotFound => 404,
|
||||||
|
Self::Conflict => 409,
|
||||||
|
Self::Locked => 423,
|
||||||
|
Self::Unavailable => 503,
|
||||||
|
Self::Internal => 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VaultError {
|
||||||
|
pub status: VaultStatus,
|
||||||
|
pub code: &'static str,
|
||||||
|
pub message: String,
|
||||||
|
pub details: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultError {
|
||||||
|
pub fn new(status: VaultStatus, code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(VaultStatus::BadRequest, "bad_request", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(VaultStatus::BadRequest, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(VaultStatus::NotFound, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unauthorized_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(VaultStatus::Unauthorized, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forbidden_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(VaultStatus::Forbidden, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(VaultStatus::Internal, "internal_error", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_details(mut self, details: Value) -> Self {
|
||||||
|
self.details = Some(details);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatible with mnote-web `WebError::code()` (store was copied as-is).
|
||||||
|
pub fn code(&self) -> &str {
|
||||||
|
self.code
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> VaultStatus {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for VaultError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}: {}", self.code, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for VaultError {}
|
||||||
|
|
||||||
|
// Compatibility aliases used by store.rs (former WebError + StatusCode patterns).
|
||||||
|
impl VaultError {
|
||||||
|
/// Former `WebError::new(StatusCode::NOT_FOUND, code, msg)`.
|
||||||
|
pub fn from_status(
|
||||||
|
status: VaultStatus,
|
||||||
|
code: &'static str,
|
||||||
|
message: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(status, code, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! Minimal frontmatter splitter (copied from mnote-web local_markdown_parser).
|
||||||
|
|
||||||
|
/// Split YAML frontmatter between leading `---\\n` and next `\\n---\\n`.
|
||||||
|
pub fn split_frontmatter(markdown: &str) -> (Option<String>, &str) {
|
||||||
|
let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown);
|
||||||
|
if !normalized.starts_with("---\n") {
|
||||||
|
return (None, normalized);
|
||||||
|
}
|
||||||
|
let rest = &normalized[4..];
|
||||||
|
if let Some(end) = rest.find("\n---\n") {
|
||||||
|
let frontmatter = rest[..end].to_string();
|
||||||
|
let body = &rest[end + 5..];
|
||||||
|
return (Some(frontmatter), body);
|
||||||
|
}
|
||||||
|
(None, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splits_basic() {
|
||||||
|
let (fm, body) = split_frontmatter("---\nid: x\n---\n\nhello\n");
|
||||||
|
assert_eq!(fm.as_deref(), Some("id: x"));
|
||||||
|
assert!(body.contains("hello"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//! Local-first password vault core (12-2).
|
||||||
|
//!
|
||||||
|
//! File SSOT under `{workspace}/.mnote/vault/`. Read path for agents does **not**
|
||||||
|
//! require mnote-web — use token + list/get/resolve APIs in this crate / `mnote-vault` CLI.
|
||||||
|
|
||||||
|
pub mod ai;
|
||||||
|
pub mod error;
|
||||||
|
pub mod frontmatter;
|
||||||
|
pub mod store;
|
||||||
|
pub mod token;
|
||||||
|
|
||||||
|
pub use ai::{
|
||||||
|
ai_vault_actor_id, ai_vault_workspace_root, ensure_ai_vault_workspace, get_ai_vault_item,
|
||||||
|
list_ai_vault_items, login_ai_vault_credential, put_ai_vault_session, resolve_ai_vault_secret,
|
||||||
|
};
|
||||||
|
pub use error::{VaultError, VaultStatus};
|
||||||
|
pub use store::{VaultCredentialRecord, VaultItemStatus};
|
||||||
|
pub use token::{
|
||||||
|
default_hmac_key_path, default_sock_path, default_token_path, issue_token, load_or_create_hmac_key,
|
||||||
|
read_token_from_env_or_file, require_actor, require_scope, verify_token, write_token_file,
|
||||||
|
VaultTokenClaims,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
|||||||
|
//! Agent capability tokens for local vault access (12-2).
|
||||||
|
//!
|
||||||
|
//! Format: `mnv1.<base64url(payload_json)>.<base64url(hmac_sha256)>`
|
||||||
|
//! Token is **not** a master key — only authorizes list/get/resolve against vaultd/CLI.
|
||||||
|
|
||||||
|
use crate::error::{VaultError, VaultStatus};
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
pub const TOKEN_VERSION: u32 = 1;
|
||||||
|
pub const DEFAULT_TOKEN_DIR: &str = ".config/mnote/vault-tokens";
|
||||||
|
pub const DEFAULT_HMAC_KEY_REL: &str = ".config/mnote/vaultd-hmac.key";
|
||||||
|
pub const DEFAULT_ACTOR: &str = "mnote-e2e";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct VaultTokenClaims {
|
||||||
|
pub v: u32,
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub sub: String,
|
||||||
|
pub actor: String,
|
||||||
|
pub scope: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace: Option<String>,
|
||||||
|
pub iat: u64,
|
||||||
|
/// 0 = no expiry
|
||||||
|
pub exp: u64,
|
||||||
|
pub jti: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IssuedToken {
|
||||||
|
pub token: String,
|
||||||
|
pub claims: VaultTokenClaims,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_hmac_key_path() -> PathBuf {
|
||||||
|
dirs_path_home().join(DEFAULT_HMAC_KEY_REL)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_token_path() -> PathBuf {
|
||||||
|
dirs_path_home()
|
||||||
|
.join(DEFAULT_TOKEN_DIR)
|
||||||
|
.join("default.token")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default UDS path for mnote-vaultd (12-2 §5.1).
|
||||||
|
/// Order: `MNOTE_VAULT_SOCK` → `$XDG_RUNTIME_DIR/mnote-vaultd.sock` → `/tmp/mnote-vaultd-$UID.sock`.
|
||||||
|
pub fn default_sock_path() -> PathBuf {
|
||||||
|
if let Ok(p) = std::env::var("MNOTE_VAULT_SOCK") {
|
||||||
|
let p = p.trim();
|
||||||
|
if !p.is_empty() {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||||
|
let runtime = runtime.trim();
|
||||||
|
if !runtime.is_empty() {
|
||||||
|
return PathBuf::from(runtime).join("mnote-vaultd.sock");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let uid = std::env::var("UID")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.unwrap_or_else(current_uid);
|
||||||
|
PathBuf::from(format!("/tmp/mnote-vaultd-{uid}.sock"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_uid() -> u32 {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
extern "C" {
|
||||||
|
fn getuid() -> u32;
|
||||||
|
}
|
||||||
|
// SAFETY: getuid has no preconditions.
|
||||||
|
unsafe { getuid() }
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_path_home() -> PathBuf {
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load or create a random 32-byte HMAC secret (hex file, 0600 when possible).
|
||||||
|
pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, VaultError> {
|
||||||
|
if path.exists() {
|
||||||
|
let raw = fs::read_to_string(path).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_hmac_key_read_failed",
|
||||||
|
format!("无法读取 HMAC key {}: {e}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let hex = raw.trim();
|
||||||
|
if hex.len() < 32 {
|
||||||
|
return Err(VaultError::bad_request_code(
|
||||||
|
"vault_hmac_key_invalid",
|
||||||
|
"HMAC key 过短",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return hex::decode(hex).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_hmac_key_invalid",
|
||||||
|
format!("HMAC key 非 hex: {e}"),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_hmac_key_write_failed",
|
||||||
|
format!("无法创建目录: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
getrandom_fill(&mut bytes)?;
|
||||||
|
let hex = hex::encode(bytes);
|
||||||
|
fs::write(path, format!("{hex}\n")).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_hmac_key_write_failed",
|
||||||
|
format!("无法写入 HMAC key: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getrandom_fill(buf: &mut [u8]) -> Result<(), VaultError> {
|
||||||
|
use std::io::Read;
|
||||||
|
// Prefer /dev/urandom for zero extra deps if getrandom crate not used.
|
||||||
|
let mut f = fs::File::open("/dev/urandom").map_err(|e| {
|
||||||
|
VaultError::internal(format!("open /dev/urandom: {e}"))
|
||||||
|
})?;
|
||||||
|
f.read_exact(buf)
|
||||||
|
.map_err(|e| VaultError::internal(format!("read urandom: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_token(
|
||||||
|
hmac_key: &[u8],
|
||||||
|
sub: &str,
|
||||||
|
actor: &str,
|
||||||
|
scopes: &[&str],
|
||||||
|
ttl_secs: Option<u64>,
|
||||||
|
workspace: Option<String>,
|
||||||
|
) -> Result<IssuedToken, VaultError> {
|
||||||
|
let iat = now_unix();
|
||||||
|
let exp = match ttl_secs {
|
||||||
|
Some(0) | None => 0,
|
||||||
|
Some(ttl) => iat.saturating_add(ttl),
|
||||||
|
};
|
||||||
|
let claims = VaultTokenClaims {
|
||||||
|
v: TOKEN_VERSION,
|
||||||
|
iss: "mnote-vaultd".into(),
|
||||||
|
aud: "local-vaultd".into(),
|
||||||
|
sub: sub.trim().to_string(),
|
||||||
|
actor: actor.trim().to_string(),
|
||||||
|
scope: scopes.iter().map(|s| (*s).to_string()).collect(),
|
||||||
|
workspace,
|
||||||
|
iat,
|
||||||
|
exp,
|
||||||
|
jti: Uuid::new_v4().to_string(),
|
||||||
|
};
|
||||||
|
let token = encode_token(hmac_key, &claims)?;
|
||||||
|
Ok(IssuedToken { token, claims })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_token(hmac_key: &[u8], claims: &VaultTokenClaims) -> Result<String, VaultError> {
|
||||||
|
let payload = serde_json::to_vec(claims).map_err(|e| {
|
||||||
|
VaultError::internal(format!("token serialize: {e}"))
|
||||||
|
})?;
|
||||||
|
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
|
||||||
|
let mut mac = HmacSha256::new_from_slice(hmac_key)
|
||||||
|
.map_err(|e| VaultError::internal(format!("hmac init: {e}")))?;
|
||||||
|
mac.update(payload_b64.as_bytes());
|
||||||
|
let sig = mac.finalize().into_bytes();
|
||||||
|
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
|
||||||
|
Ok(format!("mnv1.{payload_b64}.{sig_b64}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_token(hmac_key: &[u8], token: &str) -> Result<VaultTokenClaims, VaultError> {
|
||||||
|
let token = token.trim();
|
||||||
|
let parts: Vec<&str> = token.split('.').collect();
|
||||||
|
if parts.len() != 3 || parts[0] != "mnv1" {
|
||||||
|
return Err(VaultError::unauthorized_code(
|
||||||
|
"vault_token_invalid",
|
||||||
|
"token 格式无效(期望 mnv1.<payload>.<sig>)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload_b64 = parts[1];
|
||||||
|
let sig_b64 = parts[2];
|
||||||
|
let mut mac = HmacSha256::new_from_slice(hmac_key)
|
||||||
|
.map_err(|e| VaultError::internal(format!("hmac init: {e}")))?;
|
||||||
|
mac.update(payload_b64.as_bytes());
|
||||||
|
let expected = mac.finalize().into_bytes();
|
||||||
|
let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
|
||||||
|
VaultError::unauthorized_code("vault_token_invalid", "token 签名解码失败")
|
||||||
|
})?;
|
||||||
|
if sig.len() != expected.len()
|
||||||
|
|| sig
|
||||||
|
.iter()
|
||||||
|
.zip(expected.iter())
|
||||||
|
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||||
|
!= 0
|
||||||
|
{
|
||||||
|
return Err(VaultError::unauthorized_code(
|
||||||
|
"vault_token_invalid",
|
||||||
|
"token 签名校验失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
|
||||||
|
VaultError::unauthorized_code("vault_token_invalid", "token payload 解码失败")
|
||||||
|
})?;
|
||||||
|
let claims: VaultTokenClaims = serde_json::from_slice(&payload).map_err(|_| {
|
||||||
|
VaultError::unauthorized_code("vault_token_invalid", "token payload JSON 无效")
|
||||||
|
})?;
|
||||||
|
if claims.v != TOKEN_VERSION {
|
||||||
|
return Err(VaultError::unauthorized_code(
|
||||||
|
"vault_token_invalid",
|
||||||
|
format!("不支持的 token 版本 {}", claims.v),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if claims.aud != "local-vaultd" {
|
||||||
|
return Err(VaultError::unauthorized_code(
|
||||||
|
"vault_token_invalid",
|
||||||
|
"token aud 不匹配",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if claims.exp != 0 && now_unix() > claims.exp {
|
||||||
|
return Err(VaultError::unauthorized_code(
|
||||||
|
"vault_token_expired",
|
||||||
|
"token 已过期",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_scope(claims: &VaultTokenClaims, need: &str) -> Result<(), VaultError> {
|
||||||
|
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
|
||||||
|
if set.contains(need) || set.contains("*") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(VaultError::forbidden_code(
|
||||||
|
"vault_scope_denied",
|
||||||
|
format!("token 缺少 scope: {need}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_actor(claims: &VaultTokenClaims, expected: &str) -> Result<(), VaultError> {
|
||||||
|
if claims.actor.trim() == expected.trim() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(VaultError::forbidden_code(
|
||||||
|
"vault_actor_mismatch",
|
||||||
|
format!(
|
||||||
|
"token.actor={} 与 AI 本 actor={} 不一致",
|
||||||
|
claims.actor, expected
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_token_from_env_or_file() -> Result<String, VaultError> {
|
||||||
|
if let Ok(t) = std::env::var("MNOTE_VAULT_TOKEN") {
|
||||||
|
let t = t.trim().to_string();
|
||||||
|
if !t.is_empty() {
|
||||||
|
return Ok(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = std::env::var("MNOTE_VAULT_TOKEN_FILE")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| default_token_path());
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(VaultError::new(
|
||||||
|
VaultStatus::Unauthorized,
|
||||||
|
"vault_token_missing",
|
||||||
|
format!(
|
||||||
|
"未找到 token(设 MNOTE_VAULT_TOKEN 或运行 issue-token)。期望文件: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&path).map_err(|e| {
|
||||||
|
VaultError::unauthorized_code(
|
||||||
|
"vault_token_missing",
|
||||||
|
format!("无法读取 {}: {e}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let t = raw.trim().to_string();
|
||||||
|
if t.is_empty() {
|
||||||
|
return Err(VaultError::unauthorized_code(
|
||||||
|
"vault_token_missing",
|
||||||
|
"token 文件为空",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_token_file(path: &Path, token: &str) -> Result<(), VaultError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_token_write_failed",
|
||||||
|
format!("无法创建目录: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::write(path, format!("{}\n", token.trim())).map_err(|e| {
|
||||||
|
VaultError::bad_request_code(
|
||||||
|
"vault_token_write_failed",
|
||||||
|
format!("无法写入 token: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_verify_roundtrip() {
|
||||||
|
let key = b"0123456789abcdef0123456789abcdef";
|
||||||
|
let issued = issue_token(
|
||||||
|
key,
|
||||||
|
"agent:test",
|
||||||
|
"mnote-e2e",
|
||||||
|
&["list", "get", "resolve"],
|
||||||
|
Some(3600),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let claims = verify_token(key, &issued.token).unwrap();
|
||||||
|
assert_eq!(claims.actor, "mnote-e2e");
|
||||||
|
require_scope(&claims, "resolve").unwrap();
|
||||||
|
assert!(require_scope(&claims, "admin").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_sig_fails() {
|
||||||
|
let key = b"0123456789abcdef0123456789abcdef";
|
||||||
|
let issued = issue_token(key, "a", "mnote-e2e", &["list"], None, None).unwrap();
|
||||||
|
let mut t = issued.token;
|
||||||
|
t.push('x');
|
||||||
|
assert!(verify_token(key, &t).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "mnote-vault"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Local vault CLI: issue-token / list / get / resolve / login / session / serve (no mnote-web)"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mnote-vault"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5.38", features = ["derive"] }
|
||||||
|
mnote-vault-core = { path = "../mnote-vault-core" }
|
||||||
|
serde_json = "1"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ hyper = "1"
|
|||||||
hyper-util = { version = "0.1", features = ["tokio"] }
|
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||||
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
||||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||||
|
mnote-vault-core = { path = "../mnote-vault-core" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "stream"] }
|
||||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ var FILETREE_DRAG_MIME = 'application/x-mnote-file-tree';
|
|||||||
|
|
||||||
var draggingFileTreeRowIds = [];
|
var draggingFileTreeRowIds = [];
|
||||||
var activeFileTreeDropRow = null;
|
var activeFileTreeDropRow = null;
|
||||||
|
var activeFileTreeDropPosition = null;
|
||||||
|
|
||||||
// ─── 纯 helper ─────────────────────────────────────────
|
// ─── 纯 helper ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,10 +23,34 @@ function filetreeDragPayload(rowIds) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wolai-style drop zones on a row:
|
||||||
|
* - top 25% → before (sibling reorder line)
|
||||||
|
* - bottom 25% → after
|
||||||
|
* - middle → inside folders/dirs; leaf rows treat middle as after
|
||||||
|
*/
|
||||||
|
function fileTreeDropPosition(event, row) {
|
||||||
|
if (!(row instanceof HTMLElement) || !event) return 'inside';
|
||||||
|
var rect = row.getBoundingClientRect();
|
||||||
|
var ratio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||||
|
if (ratio < 0.25) return 'before';
|
||||||
|
if (ratio > 0.75) return 'after';
|
||||||
|
var kind = String(row.getAttribute('data-row-kind') || '').trim();
|
||||||
|
if (kind === 'folder' || kind === 'directory' || kind === 'doc' || kind === 'document') {
|
||||||
|
// documents can host children in page/file tree hybrid; folders always accept inside
|
||||||
|
if (kind === 'folder' || kind === 'directory') return 'inside';
|
||||||
|
// expandable document rows also accept nest-into
|
||||||
|
if (row.getAttribute('aria-expanded') != null && row.querySelector('.tree-toggle')) return 'inside';
|
||||||
|
}
|
||||||
|
// non-container leaf: middle zone still sorts after the row
|
||||||
|
return 'after';
|
||||||
|
}
|
||||||
|
|
||||||
function filetreeDropDetail(targetRow, fileTree, deps) {
|
function filetreeDropDetail(targetRow, fileTree, deps) {
|
||||||
deps = deps || {};
|
deps = deps || {};
|
||||||
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
|
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
|
||||||
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath || function() { return ''; };
|
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath || function() { return ''; };
|
||||||
|
var dropPosition = deps.dropPosition || null;
|
||||||
return {
|
return {
|
||||||
workspaceId: resolveWorkspaceId(targetRow || fileTree),
|
workspaceId: resolveWorkspaceId(targetRow || fileTree),
|
||||||
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
|
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
|
||||||
@@ -36,6 +61,7 @@ function filetreeDropDetail(targetRow, fileTree, deps) {
|
|||||||
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
|
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
|
||||||
targetRelativePath: targetRow ? fileTreeRowLocalUploadTargetRelativePath(targetRow) : '',
|
targetRelativePath: targetRow ? fileTreeRowLocalUploadTargetRelativePath(targetRow) : '',
|
||||||
uploadIntent: 'filetree.folder.drop',
|
uploadIntent: 'filetree.folder.drop',
|
||||||
|
dropPosition: dropPosition || (targetRow ? 'inside' : 'inside'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +85,21 @@ function parseFileTreeDragPayload(raw) {
|
|||||||
function clearFileTreeDropFeedback() {
|
function clearFileTreeDropFeedback() {
|
||||||
if (activeFileTreeDropRow instanceof HTMLElement) {
|
if (activeFileTreeDropRow instanceof HTMLElement) {
|
||||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||||
|
activeFileTreeDropRow.removeAttribute('data-drop-position');
|
||||||
|
activeFileTreeDropRow.setAttribute('data-drop-feedback', 'false');
|
||||||
}
|
}
|
||||||
activeFileTreeDropRow = null;
|
activeFileTreeDropRow = null;
|
||||||
|
activeFileTreeDropPosition = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFileTreeDropFeedback(row, position) {
|
||||||
|
clearFileTreeDropFeedback();
|
||||||
|
if (!(row instanceof HTMLElement)) return;
|
||||||
|
activeFileTreeDropRow = row;
|
||||||
|
activeFileTreeDropPosition = position || 'inside';
|
||||||
|
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||||
|
activeFileTreeDropRow.setAttribute('data-drop-feedback', 'true');
|
||||||
|
activeFileTreeDropRow.setAttribute('data-drop-position', activeFileTreeDropPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFileTreeDragState() {
|
function resetFileTreeDragState() {
|
||||||
@@ -95,10 +134,27 @@ function handleFileTreeDragOver(event, deps) {
|
|||||||
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return false;
|
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return false;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
clearFileTreeDropFeedback();
|
var row = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
|
||||||
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
|
if (row instanceof HTMLElement) {
|
||||||
if (activeFileTreeDropRow instanceof HTMLElement) {
|
var position = hasFiles ? 'inside' : fileTreeDropPosition(event, row);
|
||||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
// external file drops only nest into containers
|
||||||
|
if (hasFiles) {
|
||||||
|
var kind = String(row.getAttribute('data-row-kind') || '').trim();
|
||||||
|
if (kind !== 'folder' && kind !== 'directory' && kind !== 'doc' && kind !== 'document') {
|
||||||
|
// drop onto leaf file → treat as after (parent folder context resolved at drop)
|
||||||
|
position = 'after';
|
||||||
|
} else if (kind === 'folder' || kind === 'directory') {
|
||||||
|
position = 'inside';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFileTreeDropFeedback(row, position);
|
||||||
|
} else {
|
||||||
|
clearFileTreeDropFeedback();
|
||||||
|
activeFileTreeDropRow = fileTree;
|
||||||
|
if (activeFileTreeDropRow instanceof HTMLElement) {
|
||||||
|
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||||
|
}
|
||||||
|
activeFileTreeDropPosition = 'inside';
|
||||||
}
|
}
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
var copyModifier = event.altKey || event.ctrlKey || event.metaKey;
|
var copyModifier = event.altKey || event.ctrlKey || event.metaKey;
|
||||||
@@ -134,9 +190,16 @@ function handleFileTreeDrop(event, deps) {
|
|||||||
if (!files.length && !rowIds.length) return;
|
if (!files.length && !rowIds.length) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
var dropPosition = activeFileTreeDropPosition
|
||||||
|
|| (targetRow ? fileTreeDropPosition(event, targetRow) : 'inside');
|
||||||
|
if (files.length) {
|
||||||
|
var fileKind = targetRow ? String(targetRow.getAttribute('data-row-kind') || '').trim() : '';
|
||||||
|
if (fileKind === 'folder' || fileKind === 'directory') dropPosition = 'inside';
|
||||||
|
}
|
||||||
var detail = filetreeDropDetail(targetRow, fileTree, {
|
var detail = filetreeDropDetail(targetRow, fileTree, {
|
||||||
resolveWorkspaceId: resolveWorkspaceId,
|
resolveWorkspaceId: resolveWorkspaceId,
|
||||||
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
|
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
|
||||||
|
dropPosition: dropPosition,
|
||||||
});
|
});
|
||||||
clearFileTreeDropFeedback();
|
clearFileTreeDropFeedback();
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
@@ -149,6 +212,7 @@ function handleFileTreeDrop(event, deps) {
|
|||||||
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, {
|
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, {
|
||||||
rowIds: rowIds,
|
rowIds: rowIds,
|
||||||
copy: copyModifier,
|
copy: copyModifier,
|
||||||
|
dropPosition: dropPosition,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,14 +223,18 @@ function handleFileTreeDrop(event, deps) {
|
|||||||
|
|
||||||
window.__mnoteFileTreeDndRuntime = {
|
window.__mnoteFileTreeDndRuntime = {
|
||||||
FILETREE_DRAG_MIME: FILETREE_DRAG_MIME,
|
FILETREE_DRAG_MIME: FILETREE_DRAG_MIME,
|
||||||
draggingFileTreeRowIds: draggingFileTreeRowIds,
|
get draggingFileTreeRowIds() { return draggingFileTreeRowIds; },
|
||||||
activeFileTreeDropRow: activeFileTreeDropRow,
|
set draggingFileTreeRowIds(value) { draggingFileTreeRowIds = value; },
|
||||||
|
get activeFileTreeDropRow() { return activeFileTreeDropRow; },
|
||||||
|
get activeFileTreeDropPosition() { return activeFileTreeDropPosition; },
|
||||||
filetreeDragPayload: filetreeDragPayload,
|
filetreeDragPayload: filetreeDragPayload,
|
||||||
filetreeDropDetail: filetreeDropDetail,
|
filetreeDropDetail: filetreeDropDetail,
|
||||||
|
fileTreeDropPosition: fileTreeDropPosition,
|
||||||
filetreeHasFiles: filetreeHasFiles,
|
filetreeHasFiles: filetreeHasFiles,
|
||||||
filetreeHasInternalDrag: filetreeHasInternalDrag,
|
filetreeHasInternalDrag: filetreeHasInternalDrag,
|
||||||
parseFileTreeDragPayload: parseFileTreeDragPayload,
|
parseFileTreeDragPayload: parseFileTreeDragPayload,
|
||||||
clearFileTreeDropFeedback: clearFileTreeDropFeedback,
|
clearFileTreeDropFeedback: clearFileTreeDropFeedback,
|
||||||
|
setFileTreeDropFeedback: setFileTreeDropFeedback,
|
||||||
resetFileTreeDragState: resetFileTreeDragState,
|
resetFileTreeDragState: resetFileTreeDragState,
|
||||||
startFileTreeDrag: startFileTreeDrag,
|
startFileTreeDrag: startFileTreeDrag,
|
||||||
handleFileTreeDragOver: handleFileTreeDragOver,
|
handleFileTreeDragOver: handleFileTreeDragOver,
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ function getSidebarFileTreeClipboard() {
|
|||||||
return sidebarFileTreeClipboard;
|
return sidebarFileTreeClipboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebarFileTreeClipboard(action, rowIds) {
|
function setSidebarFileTreeClipboard(action, rowIds, options) {
|
||||||
sidebarFileTreeClipboard = { action: action, rowIds: rowIds };
|
sidebarFileTreeClipboard = { action: action, rowIds: rowIds };
|
||||||
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', action);
|
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', action);
|
||||||
|
// Keep product runtimeState / host clipboard in sync (menu paste reads runtimeState).
|
||||||
|
if (options && typeof options.onClipboardChange === 'function') {
|
||||||
|
options.onClipboardChange(sidebarFileTreeClipboard);
|
||||||
|
}
|
||||||
return sidebarFileTreeClipboard;
|
return sidebarFileTreeClipboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSidebarFileTreeClipboard() {
|
function clearSidebarFileTreeClipboard(options) {
|
||||||
sidebarFileTreeClipboard = null;
|
sidebarFileTreeClipboard = null;
|
||||||
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
|
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
|
||||||
|
if (options && typeof options.onClipboardChange === 'function') {
|
||||||
|
options.onClipboardChange(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Keyboard handler ───────────────────────────────────
|
// ─── Keyboard handler ───────────────────────────────────
|
||||||
@@ -31,6 +38,7 @@ function handleFileTreeKeyDown(event, deps) {
|
|||||||
var buildSidebarFileTreeContext = deps.buildSidebarFileTreeContext || function() { return {}; };
|
var buildSidebarFileTreeContext = deps.buildSidebarFileTreeContext || function() { return {}; };
|
||||||
var evaluateSidebarFileTreeWhen = deps.evaluateSidebarFileTreeWhen || function() { return false; };
|
var evaluateSidebarFileTreeWhen = deps.evaluateSidebarFileTreeWhen || function() { return false; };
|
||||||
var deleteSelectedSidebarFileTreeRows = deps.deleteSelectedSidebarFileTreeRows || function() { return Promise.resolve(); };
|
var deleteSelectedSidebarFileTreeRows = deps.deleteSelectedSidebarFileTreeRows || function() { return Promise.resolve(); };
|
||||||
|
var onClipboardChange = deps.onClipboardChange || null;
|
||||||
|
|
||||||
var keyTarget = event.target;
|
var keyTarget = event.target;
|
||||||
var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root');
|
var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root');
|
||||||
@@ -59,7 +67,9 @@ function handleFileTreeKeyDown(event, deps) {
|
|||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
if (selectedRowIds.length > 0) {
|
if (selectedRowIds.length > 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds);
|
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds, {
|
||||||
|
onClipboardChange: onClipboardChange,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -92,7 +102,7 @@ function handleFileTreeKeyDown(event, deps) {
|
|||||||
// ─── 导出 ───────────────────────────────────────────────
|
// ─── 导出 ───────────────────────────────────────────────
|
||||||
|
|
||||||
window.__mnoteFileTreeKeyboardRuntime = {
|
window.__mnoteFileTreeKeyboardRuntime = {
|
||||||
sidebarFileTreeClipboard: sidebarFileTreeClipboard,
|
get sidebarFileTreeClipboard() { return sidebarFileTreeClipboard; },
|
||||||
getSidebarFileTreeClipboard: getSidebarFileTreeClipboard,
|
getSidebarFileTreeClipboard: getSidebarFileTreeClipboard,
|
||||||
setSidebarFileTreeClipboard: setSidebarFileTreeClipboard,
|
setSidebarFileTreeClipboard: setSidebarFileTreeClipboard,
|
||||||
clearSidebarFileTreeClipboard: clearSidebarFileTreeClipboard,
|
clearSidebarFileTreeClipboard: clearSidebarFileTreeClipboard,
|
||||||
|
|||||||
@@ -183,6 +183,49 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
runtimeState.activeTreeContextMenu = null;
|
runtimeState.activeTreeContextMenu = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product clipboard used by menu paste + keyboard paste.
|
||||||
|
* Keep keyboard-module clipboard and runtimeState in lockstep so
|
||||||
|
* 「粘贴到此处」enables after either Ctrl+C/X or menu cut/copy.
|
||||||
|
*/
|
||||||
|
function currentSidebarFileTreeClipboard() {
|
||||||
|
if (runtimeState.sidebarFileTreeClipboard
|
||||||
|
&& Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds)
|
||||||
|
&& runtimeState.sidebarFileTreeClipboard.rowIds.length) {
|
||||||
|
return runtimeState.sidebarFileTreeClipboard;
|
||||||
|
}
|
||||||
|
var kb = window.__mnoteFileTreeKeyboardRuntime;
|
||||||
|
var fromKb = kb && typeof kb.getSidebarFileTreeClipboard === 'function'
|
||||||
|
? kb.getSidebarFileTreeClipboard()
|
||||||
|
: (kb && kb.sidebarFileTreeClipboard) || null;
|
||||||
|
if (fromKb && Array.isArray(fromKb.rowIds) && fromKb.rowIds.length) {
|
||||||
|
// Heal dual-clipboard drift (keyboard module wrote first).
|
||||||
|
runtimeState.sidebarFileTreeClipboard = fromKb;
|
||||||
|
return fromKb;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarFileTreeClipboard(action, rowIds) {
|
||||||
|
var next = action && Array.isArray(rowIds) && rowIds.length
|
||||||
|
? { action: action, rowIds: rowIds.slice() }
|
||||||
|
: null;
|
||||||
|
runtimeState.sidebarFileTreeClipboard = next;
|
||||||
|
var kb = window.__mnoteFileTreeKeyboardRuntime;
|
||||||
|
if (kb && typeof kb.setSidebarFileTreeClipboard === 'function') {
|
||||||
|
if (next) {
|
||||||
|
kb.setSidebarFileTreeClipboard(next.action, next.rowIds);
|
||||||
|
} else if (typeof kb.clearSidebarFileTreeClipboard === 'function') {
|
||||||
|
kb.clearSidebarFileTreeClipboard();
|
||||||
|
}
|
||||||
|
} else if (next) {
|
||||||
|
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', next.action);
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function copyTreeContextValue(value, actionName) {
|
function copyTreeContextValue(value, actionName) {
|
||||||
var text = String(value || '');
|
var text = String(value || '');
|
||||||
var done = function() {
|
var done = function() {
|
||||||
@@ -618,6 +661,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'cut' || action === 'copy') {
|
||||||
|
var clipboardRows = selectedSidebarFileTreeRows();
|
||||||
|
if (!clipboardRows.length && trigger && trigger.closest) {
|
||||||
|
var single = trigger.closest('.tree-row[data-shell-mode="filetree"]');
|
||||||
|
if (single instanceof HTMLElement) clipboardRows = [single];
|
||||||
|
}
|
||||||
|
var clipboardRowIds = clipboardRows.map(function(row) {
|
||||||
|
return String(row.getAttribute('data-row-id') || '').trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
if (!clipboardRowIds.length) {
|
||||||
|
recordFileTreeActionStatus('skipped', Object.assign({}, detail, { reason: 'empty-selection' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSidebarFileTreeClipboard(action, clipboardRowIds);
|
||||||
|
recordFileTreeAction(action, Object.assign({}, detail, {
|
||||||
|
sourceRowIds: clipboardRowIds,
|
||||||
|
clipboardAction: action,
|
||||||
|
}));
|
||||||
|
recordFileTreeActionStatus('applied', Object.assign({}, detail, {
|
||||||
|
sourceRowIds: clipboardRowIds,
|
||||||
|
clipboardAction: action,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === 'paste-into') {
|
if (action === 'paste-into') {
|
||||||
var pasteRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
|
var pasteRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
|
||||||
recordFileTreeAction('paste-into', detail);
|
recordFileTreeAction('paste-into', detail);
|
||||||
@@ -1228,7 +1295,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
|
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
|
||||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
|
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
|
||||||
{ action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' },
|
{ action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' },
|
||||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !runtimeState.sidebarFileTreeClipboard, title: runtimeState.sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
|
{ separator: true },
|
||||||
|
{ action: 'cut', icon: 'content_cut', label: '剪切', shortcut: 'Ctrl+X', when: '!workspace.readonly' },
|
||||||
|
{ action: 'copy', icon: 'content_copy', label: '复制', shortcut: 'Ctrl+C' },
|
||||||
|
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', shortcut: 'Ctrl+V', disabled: !currentSidebarFileTreeClipboard(), title: currentSidebarFileTreeClipboard() ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
|
||||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
||||||
@@ -1929,9 +1999,16 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
var runtimeFn = fileTreeRuntimeFunction('fileTreeChildCount');
|
var runtimeFn = fileTreeRuntimeFunction('fileTreeChildCount');
|
||||||
if (runtimeFn) return runtimeFn(documentId, fileTreeRuntimeDeps());
|
if (runtimeFn) return runtimeFn(documentId, fileTreeRuntimeDeps());
|
||||||
if (!documentId) return 0;
|
if (!documentId) return 0;
|
||||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]');
|
var row = document.querySelector(
|
||||||
|
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"],'
|
||||||
|
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(documentId) + '"],'
|
||||||
|
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(documentId) + '"],'
|
||||||
|
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-node-id="' + cssEscape(documentId) + '"]'
|
||||||
|
);
|
||||||
var node = row ? row.closest('.tree-node') : null;
|
var node = row ? row.closest('.tree-node') : null;
|
||||||
var children = node ? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="document"], :scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="doc"]') : [];
|
var children = node
|
||||||
|
? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"]')
|
||||||
|
: [];
|
||||||
return children.length;
|
return children.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1944,6 +2021,45 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
}, targetRow) || null;
|
}, targetRow) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileTreeParentIdFromAttr(parentAttr) {
|
||||||
|
var raw = String(parentAttr || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.indexOf('doc:') === 0) return raw.slice(4);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wolai-style move target:
|
||||||
|
* - inside → nest under target (folder/dir/doc)
|
||||||
|
* - before/after → same parent as target, sortOrder = sibling index (+1 for after)
|
||||||
|
*/
|
||||||
|
function resolveFileTreeMoveTarget(targetRow, position) {
|
||||||
|
if (!(targetRow instanceof HTMLElement)) {
|
||||||
|
return { parentId: null, sortOrder: 0 };
|
||||||
|
}
|
||||||
|
var pos = position || 'inside';
|
||||||
|
if (pos === 'inside') {
|
||||||
|
var nestParentId = fileTreeMoveTargetParentId(targetRow);
|
||||||
|
return {
|
||||||
|
parentId: nestParentId,
|
||||||
|
sortOrder: nestParentId ? fileTreeChildCount(nestParentId) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var parentAttr = targetRow.getAttribute('data-parent-id') || '';
|
||||||
|
var parentId = fileTreeParentIdFromAttr(parentAttr);
|
||||||
|
var siblings = Array.from(
|
||||||
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')
|
||||||
|
).filter(function(row) {
|
||||||
|
return (row.getAttribute('data-parent-id') || '') === parentAttr;
|
||||||
|
});
|
||||||
|
var index = siblings.indexOf(targetRow);
|
||||||
|
if (index < 0) index = 0;
|
||||||
|
return {
|
||||||
|
parentId: parentId,
|
||||||
|
sortOrder: Math.max(0, index + (pos === 'after' ? 1 : 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function fileTreeMoveSourceId(row) {
|
function fileTreeMoveSourceId(row) {
|
||||||
if (!(row instanceof HTMLElement)) return '';
|
if (!(row instanceof HTMLElement)) return '';
|
||||||
return fileTreeRowDocumentId(row)
|
return fileTreeRowDocumentId(row)
|
||||||
@@ -1956,7 +2072,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
var rows = fileTreeRowsByRowIds(rowIds || []);
|
var rows = fileTreeRowsByRowIds(rowIds || []);
|
||||||
if (rows.length === 0) return false;
|
if (rows.length === 0) return false;
|
||||||
var copy = Boolean(options && options.copy);
|
var copy = Boolean(options && options.copy);
|
||||||
var targetParentId = fileTreeMoveTargetParentId(targetRow);
|
var dropPosition = (options && options.dropPosition) || 'inside';
|
||||||
|
var resolved = resolveFileTreeMoveTarget(targetRow, dropPosition);
|
||||||
|
var targetParentId = resolved.parentId;
|
||||||
|
var baseSortOrder = typeof resolved.sortOrder === 'number' ? resolved.sortOrder : 0;
|
||||||
var workspaceId = resolveWorkspaceId(targetRow || document.body);
|
var workspaceId = resolveWorkspaceId(targetRow || document.body);
|
||||||
var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy);
|
var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy);
|
||||||
if (!writable) return false;
|
if (!writable) return false;
|
||||||
@@ -1983,7 +2102,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
documentId: sourceId,
|
documentId: sourceId,
|
||||||
parentId: targetParentId,
|
parentId: targetParentId,
|
||||||
sortOrder: targetParentId ? fileTreeChildCount(targetParentId) + i : i
|
sortOrder: baseSortOrder + i
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败'));
|
failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败'));
|
||||||
@@ -2014,21 +2133,24 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pasteSidebarFileTreeClipboard(trigger) {
|
async function pasteSidebarFileTreeClipboard(trigger) {
|
||||||
if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false;
|
var clipboard = currentSidebarFileTreeClipboard();
|
||||||
|
if (!clipboard) return false;
|
||||||
var targetRow = trigger instanceof HTMLElement ? trigger : null;
|
var targetRow = trigger instanceof HTMLElement ? trigger : null;
|
||||||
if (!targetRow && sidebarFileTreeSelection.focusedRowId) {
|
if (!targetRow && sidebarFileTreeSelection.focusedRowId) {
|
||||||
targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]');
|
targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]');
|
||||||
}
|
}
|
||||||
var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId();
|
var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId();
|
||||||
var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy';
|
var action = clipboard.action === 'cut' ? 'move' : 'copy';
|
||||||
recordFileTreeAction('paste', {
|
recordFileTreeAction('paste', {
|
||||||
rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '',
|
rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '',
|
||||||
documentId: targetDocumentId,
|
documentId: targetDocumentId,
|
||||||
sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds,
|
sourceRowIds: clipboard.rowIds,
|
||||||
clipboardAction: runtimeState.sidebarFileTreeClipboard.action
|
clipboardAction: clipboard.action
|
||||||
});
|
});
|
||||||
var ok = await moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds, targetRow, { copy: action === 'copy' });
|
var ok = await moveSidebarFileTreeRows(clipboard.rowIds, targetRow, { copy: action === 'copy' });
|
||||||
if (ok && action === 'move') runtimeState.sidebarFileTreeClipboard = null;
|
if (ok && action === 'move') {
|
||||||
|
setSidebarFileTreeClipboard(null, []);
|
||||||
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2206,6 +2328,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
postSidebarFileTreeJson,
|
postSidebarFileTreeJson,
|
||||||
fileTreeRowsByRowIds,
|
fileTreeRowsByRowIds,
|
||||||
fileTreeChildCount,
|
fileTreeChildCount,
|
||||||
|
resolveFileTreeMoveTarget,
|
||||||
moveSidebarFileTreeRows,
|
moveSidebarFileTreeRows,
|
||||||
pasteSidebarFileTreeClipboard,
|
pasteSidebarFileTreeClipboard,
|
||||||
deleteSelectedSidebarFileTreeRows,
|
deleteSelectedSidebarFileTreeRows,
|
||||||
|
|||||||
@@ -186,12 +186,29 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
|||||||
function clearPageDropFeedback() {
|
function clearPageDropFeedback() {
|
||||||
if (activePageDropRow instanceof HTMLElement) {
|
if (activePageDropRow instanceof HTMLElement) {
|
||||||
activePageDropRow.setAttribute('data-drop-feedback', 'false');
|
activePageDropRow.setAttribute('data-drop-feedback', 'false');
|
||||||
|
activePageDropRow.setAttribute('data-drop-target', 'false');
|
||||||
|
activePageDropRow.removeAttribute('data-drop-position');
|
||||||
}
|
}
|
||||||
activePageDropRow = null;
|
activePageDropRow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActivePageDropRow(row) {
|
function setActivePageDropRow(row, position) {
|
||||||
activePageDropRow = row instanceof HTMLElement ? row : null;
|
if (!(row instanceof HTMLElement)) {
|
||||||
|
clearPageDropFeedback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activePageDropRow && activePageDropRow !== row) {
|
||||||
|
clearPageDropFeedback();
|
||||||
|
}
|
||||||
|
activePageDropRow = row;
|
||||||
|
activePageDropRow.setAttribute('data-drop-feedback', 'true');
|
||||||
|
activePageDropRow.setAttribute('data-drop-target', 'true');
|
||||||
|
// Always refresh position so before↔after on the same row updates the edge line.
|
||||||
|
if (position) {
|
||||||
|
activePageDropRow.setAttribute('data-drop-position', position);
|
||||||
|
} else {
|
||||||
|
activePageDropRow.removeAttribute('data-drop-position');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPageDragState() {
|
function clearPageDragState() {
|
||||||
|
|||||||
@@ -522,6 +522,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
'<div class="mnote-vault-header-actions">' +
|
'<div class="mnote-vault-header-actions">' +
|
||||||
'<button type="button" data-vault-create data-testid="vault-create">新建</button>' +
|
'<button type="button" data-vault-create data-testid="vault-create">新建</button>' +
|
||||||
'<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>' +
|
'<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>' +
|
||||||
|
'<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">' +
|
||||||
|
'<span class="mnote-vault-sr-only">插入密文</span>' +
|
||||||
|
'<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">' +
|
||||||
|
'<option value="">插入密文…</option>' +
|
||||||
|
'</select></label>' +
|
||||||
'<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />' +
|
'<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />' +
|
||||||
'<div class="mnote-vault-tabs" role="tablist">' +
|
'<div class="mnote-vault-tabs" role="tablist">' +
|
||||||
'<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>' +
|
'<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>' +
|
||||||
@@ -3698,12 +3703,17 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
var targetRow = detail.targetRowId
|
var targetRow = detail.targetRowId
|
||||||
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]')
|
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]')
|
||||||
: null;
|
: null;
|
||||||
|
var dropPosition = detail.dropPosition || 'inside';
|
||||||
recordFileTreeAction('internal-drop', {
|
recordFileTreeAction('internal-drop', {
|
||||||
rowId: detail.targetRowId || '',
|
rowId: detail.targetRowId || '',
|
||||||
sourceRowIds: rowIds,
|
sourceRowIds: rowIds,
|
||||||
copy: Boolean(detail.copy)
|
copy: Boolean(detail.copy),
|
||||||
|
dropPosition: dropPosition
|
||||||
|
});
|
||||||
|
void moveSidebarFileTreeRows(rowIds, targetRow, {
|
||||||
|
copy: Boolean(detail.copy),
|
||||||
|
dropPosition: dropPosition,
|
||||||
});
|
});
|
||||||
void moveSidebarFileTreeRows(rowIds, targetRow, { copy: Boolean(detail.copy) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('contextmenu', function(event) {
|
document.addEventListener('contextmenu', function(event) {
|
||||||
@@ -3746,6 +3756,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen,
|
evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen,
|
||||||
deleteSelectedSidebarFileTreeRows: deleteSelectedSidebarFileTreeRows,
|
deleteSelectedSidebarFileTreeRows: deleteSelectedSidebarFileTreeRows,
|
||||||
pasteSidebarFileTreeClipboard: pasteSidebarFileTreeClipboard,
|
pasteSidebarFileTreeClipboard: pasteSidebarFileTreeClipboard,
|
||||||
|
onClipboardChange: function(nextClipboard) {
|
||||||
|
sidebarFileTreeClipboard = nextClipboard;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (handled) return;
|
if (handled) return;
|
||||||
} else {
|
} else {
|
||||||
@@ -3953,10 +3966,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
var sourceNodeId = readPageDragNodeId(event);
|
var sourceNodeId = readPageDragNodeId(event);
|
||||||
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
|
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
clearPageDropFeedback();
|
var pagePosition = pageDropPosition(event, pageRow);
|
||||||
pageRow.setAttribute('data-drop-feedback', 'true');
|
setActivePageDropRow(pageRow, pagePosition);
|
||||||
pageRow.setAttribute('data-drop-position', pageDropPosition(event, pageRow));
|
|
||||||
setActivePageDropRow(pageRow);
|
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,28 @@ impl WebError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map independent vault-core errors into HTTP WebError (12-2 P1a shared read path).
|
||||||
|
impl From<mnote_vault_core::VaultError> for WebError {
|
||||||
|
fn from(err: mnote_vault_core::VaultError) -> Self {
|
||||||
|
use mnote_vault_core::VaultStatus;
|
||||||
|
let status = match err.status {
|
||||||
|
VaultStatus::BadRequest => StatusCode::BAD_REQUEST,
|
||||||
|
VaultStatus::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
VaultStatus::Forbidden => StatusCode::FORBIDDEN,
|
||||||
|
VaultStatus::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
VaultStatus::Conflict => StatusCode::CONFLICT,
|
||||||
|
VaultStatus::Locked => StatusCode::from_u16(423).unwrap_or(StatusCode::FORBIDDEN),
|
||||||
|
VaultStatus::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
VaultStatus::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
let mut web = WebError::new(status, err.code, err.message);
|
||||||
|
if let Some(details) = err.details {
|
||||||
|
web = web.with_details(details);
|
||||||
|
}
|
||||||
|
web
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for WebError {
|
impl IntoResponse for WebError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let body = ErrorBody {
|
let body = ErrorBody {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
MnoteCapabilityPack {
|
MnoteCapabilityPack {
|
||||||
id: "mnote-vault",
|
id: "mnote-vault",
|
||||||
title: "密码箱 / AI 密码本",
|
title: "密码箱 / AI 密码本",
|
||||||
description: "密码箱与 AI 密码本使用约定:禁止通用文件工具读取 .mnote/vault;凭证经 vault API / 共享到 AI 密码本。",
|
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*(token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
|
||||||
category: "security",
|
category: "security",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["hermes", "reasonix"],
|
||||||
read_only: true,
|
read_only: true,
|
||||||
|
|||||||
@@ -1419,6 +1419,12 @@ fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &V
|
|||||||
<div class="mnote-vault-header-actions">
|
<div class="mnote-vault-header-actions">
|
||||||
<button type="button" data-vault-create data-testid="vault-create">新建</button>
|
<button type="button" data-vault-create data-testid="vault-create">新建</button>
|
||||||
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>
|
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>
|
||||||
|
<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">
|
||||||
|
<span class="mnote-vault-sr-only">插入密文</span>
|
||||||
|
<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">
|
||||||
|
<option value="">插入密文…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
|
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
|
||||||
<div class="mnote-vault-tabs" role="tablist">
|
<div class="mnote-vault-tabs" role="tablist">
|
||||||
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
|
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
|
||||||
|
|||||||
@@ -189,7 +189,10 @@ fn local_page_tree_snapshot_scan_test_loads_for_key(
|
|||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
parent_relative_path: &str,
|
parent_relative_path: &str,
|
||||||
) -> u64 {
|
) -> u64 {
|
||||||
let key = format!("{root_uri}\n{parent_relative_path}");
|
// Match load_local_folder_page_tree_snapshot_for_scope cache_key shape:
|
||||||
|
// "{root_source_uri}\n{parent_relative_path}\n{reveal_relative_path}".
|
||||||
|
// Callers pass the same root_uri used for load; reveal counters are empty here.
|
||||||
|
let key = format!("{root_uri}\n{parent_relative_path}\n");
|
||||||
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY
|
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY
|
||||||
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||||
.lock()
|
.lock()
|
||||||
@@ -8554,16 +8557,17 @@ fn append_page_tree_reveal_rows(
|
|||||||
}
|
}
|
||||||
let depth = local_folder_relative_depth(ancestor);
|
let depth = local_folder_relative_depth(ancestor);
|
||||||
// Parent id for children of this ancestor directory.
|
// Parent id for children of this ancestor directory.
|
||||||
let parent_node_id = if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, ancestor)
|
// Must match how shallow PageTree projects the directory itself
|
||||||
{
|
// (nested bundle Dir/Dir.md, sibling Name.md, or local-dir page-group).
|
||||||
Some(sibling_md)
|
// Previously only sibling .md was checked, so nested bundles got
|
||||||
} else {
|
// parentNodeId=local-dir:… while the parent row was local-md:…/Dir.md;
|
||||||
Some(local_directory_group_id(ancestor))
|
// groupRowsByParent then promoted children to roots (duplicate roots after delete/reveal).
|
||||||
};
|
let parent_node_id = Some(page_tree_node_id_for_directory(root, ancestor));
|
||||||
// Ensure the ancestor group/page row itself is expanded.
|
// Ensure the ancestor group/page row itself is expanded.
|
||||||
for row in rows.iter_mut() {
|
for row in rows.iter_mut() {
|
||||||
if row.relative_path == *ancestor
|
if row.relative_path == *ancestor
|
||||||
|| row.node_id == local_directory_group_id(ancestor)
|
|| row.node_id == local_directory_group_id(ancestor)
|
||||||
|
|| row.node_id == parent_node_id.as_deref().unwrap_or_default()
|
||||||
|| row.document_id.as_deref()
|
|| row.document_id.as_deref()
|
||||||
== parent_node_id
|
== parent_node_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -8628,6 +8632,31 @@ fn append_page_tree_reveal_rows(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PageTree node id for a directory, aligned with `scan_markdown_page_tree_shallow`:
|
||||||
|
/// 1. nested page bundle `Dir/Dir.md` → `local-md:…/Dir/Dir.md`
|
||||||
|
/// 2. sibling markdown `parent/Name.md` with directory `parent/Name/` → that page id
|
||||||
|
/// 3. otherwise page-group → `local-dir:…`
|
||||||
|
fn page_tree_node_id_for_directory(root: &Path, directory_relative: &str) -> String {
|
||||||
|
let normalized = directory_relative
|
||||||
|
.trim()
|
||||||
|
.trim_matches('/')
|
||||||
|
.replace('\\', "/");
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if let Ok(directory) = resolve_metadata_relative_path(root, &normalized) {
|
||||||
|
if let Some(nested_main) = nested_bundle_main_markdown(&directory) {
|
||||||
|
if let Ok(relative) = normalize_relative_path(root, &nested_main) {
|
||||||
|
return local_markdown_path_page_id(&relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, &normalized) {
|
||||||
|
return sibling_md;
|
||||||
|
}
|
||||||
|
local_directory_group_id(&normalized)
|
||||||
|
}
|
||||||
|
|
||||||
fn ancestor_sibling_markdown_page_id(root: &Path, ancestor_relative: &str) -> Option<String> {
|
fn ancestor_sibling_markdown_page_id(root: &Path, ancestor_relative: &str) -> Option<String> {
|
||||||
let parent = Path::new(ancestor_relative).parent()?;
|
let parent = Path::new(ancestor_relative).parent()?;
|
||||||
let name = Path::new(ancestor_relative).file_name()?.to_str()?;
|
let name = Path::new(ancestor_relative).file_name()?.to_str()?;
|
||||||
@@ -13953,6 +13982,38 @@ fn main() {}
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 12-2 / 12-1:通用 local file open 不得读 `.mnote/vault/**`(须走 vault API / mnote-vault)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_file_open_rejects_vault_system_path() {
|
||||||
|
let root = temp_root("mnote-local-file-open-vault-deny");
|
||||||
|
let vault_entry = root.join(".mnote/vault/entries");
|
||||||
|
std::fs::create_dir_all(&vault_entry).expect("vault dir");
|
||||||
|
std::fs::write(vault_entry.join("secret.md"), "password: leak").expect("write vault");
|
||||||
|
init_workspace(&root);
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
||||||
|
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
||||||
|
let context = RequestContext::from_http_parts(
|
||||||
|
&Method::GET,
|
||||||
|
&"/api/local-folder/files/open".parse().expect("uri"),
|
||||||
|
&headers,
|
||||||
|
);
|
||||||
|
let query = LocalFileOpenQuery {
|
||||||
|
root_uri,
|
||||||
|
path: ".mnote/vault/entries/secret.md".into(),
|
||||||
|
download: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = open_local_file(State(test_state()), Extension(context), Query(query))
|
||||||
|
.await
|
||||||
|
.expect_err("must deny vault path on general file open");
|
||||||
|
assert_eq!(error.status(), StatusCode::FORBIDDEN);
|
||||||
|
assert_eq!(error.code(), "vault_path_denied");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_file_open_allows_read_grant() {
|
async fn local_file_open_allows_read_grant() {
|
||||||
let _guard = env_lock().lock().expect("env lock");
|
let _guard = env_lock().lock().expect("env lock");
|
||||||
@@ -14804,6 +14865,102 @@ fn main() {}
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_page_tree_reveal_nested_bundle_keeps_single_root_parent() {
|
||||||
|
// Regression: delete/watch full sidebar refresh uses reveal. Nested
|
||||||
|
// Root/Root.md must own Root/Child/Child.md via parentNodeId, not
|
||||||
|
// local-dir:Root (which groupRowsByParent promotes to duplicate roots).
|
||||||
|
let root = temp_root("mnote-page-tree-reveal-nested-bundle-parent");
|
||||||
|
init_workspace(&root);
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
|
std::fs::create_dir_all(root.join("Root").join("Child")).expect("create nested dirs");
|
||||||
|
std::fs::write(root.join("Root").join("Root.md"), "# Root\n").expect("write Root.md");
|
||||||
|
std::fs::write(
|
||||||
|
root.join("Root").join("Child").join("Child.md"),
|
||||||
|
"# Child\n",
|
||||||
|
)
|
||||||
|
.expect("write Child.md");
|
||||||
|
// Second sibling under Root so multi-child root promotion would be obvious.
|
||||||
|
std::fs::create_dir_all(root.join("Root").join("Sibling")).expect("create Sibling");
|
||||||
|
std::fs::write(
|
||||||
|
root.join("Root").join("Sibling").join("Sibling.md"),
|
||||||
|
"# Sibling\n",
|
||||||
|
)
|
||||||
|
.expect("write Sibling.md");
|
||||||
|
|
||||||
|
let reveal_doc = local_markdown_path_page_id("Root/Child/Child.md");
|
||||||
|
assert_eq!(reveal_doc, "local-md:Root~2FChild~2FChild.md");
|
||||||
|
let revealed = load_local_folder_page_tree_snapshot_with_reveal(
|
||||||
|
&root_uri,
|
||||||
|
Some(reveal_doc.as_str()),
|
||||||
|
)
|
||||||
|
.expect("reveal snapshot");
|
||||||
|
let items = revealed.projection["items"].as_array().expect("items");
|
||||||
|
|
||||||
|
let root_node = items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FRoot.md"))
|
||||||
|
.expect("Root nested-bundle page in reveal snapshot");
|
||||||
|
assert!(
|
||||||
|
root_node["parentNodeId"].is_null()
|
||||||
|
|| root_node["parentNodeId"].as_str().map(str::is_empty).unwrap_or(false),
|
||||||
|
"Root page must remain a tree root: {root_node}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let child_node = items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FChild~2FChild.md"))
|
||||||
|
.expect("Child must be present after reveal");
|
||||||
|
assert_eq!(
|
||||||
|
child_node["parentNodeId"].as_str(),
|
||||||
|
Some("local-md:Root~2FRoot.md"),
|
||||||
|
"Child parent must match nested-bundle Root page id, not local-dir:Root: {child_node}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let sibling_node = items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FSibling~2FSibling.md"))
|
||||||
|
.expect("Sibling of revealed child must also nest under Root");
|
||||||
|
assert_eq!(
|
||||||
|
sibling_node["parentNodeId"].as_str(),
|
||||||
|
Some("local-md:Root~2FRoot.md"),
|
||||||
|
"Sibling parent must match Root page id: {sibling_node}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// No orphan local-dir:Root page-group row that would fight the local-md parent.
|
||||||
|
assert!(
|
||||||
|
items.iter().all(|item| {
|
||||||
|
item["nodeId"].as_str() != Some("local-dir:Root")
|
||||||
|
&& !item["rowId"]
|
||||||
|
.as_str()
|
||||||
|
.map(|id| id.contains("page-group:Root"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}),
|
||||||
|
"reveal must not invent a local-dir/page-group Root: {items:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// groupRowsByParent contract: only Root is a root; Child/Sibling hang under it.
|
||||||
|
let ids: std::collections::BTreeSet<String> = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item["nodeId"].as_str().map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
for item in items {
|
||||||
|
let parent = item["parentNodeId"].as_str().unwrap_or("");
|
||||||
|
if parent.is_empty() || !ids.contains(parent) {
|
||||||
|
roots.push(item["nodeId"].as_str().unwrap_or("").to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
roots,
|
||||||
|
vec!["local-md:Root~2FRoot.md".to_string()],
|
||||||
|
"groupRowsByParent-equivalent must keep a single root after nested-bundle reveal: {roots:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_file_tree_keeps_nested_bundle_filesystem_details() {
|
fn local_file_tree_keeps_nested_bundle_filesystem_details() {
|
||||||
let root = temp_root("mnote-local-file-tree-nested-bundle");
|
let root = temp_root("mnote-local-file-tree-nested-bundle");
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub(crate) mod ui_preferences;
|
|||||||
mod vault;
|
mod vault;
|
||||||
mod vault_path;
|
mod vault_path;
|
||||||
mod vault_store;
|
mod vault_store;
|
||||||
|
mod vault_transport;
|
||||||
pub(crate) mod web_shell;
|
pub(crate) mod web_shell;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
|
|||||||
@@ -4846,7 +4846,8 @@ impl PiLabToolFacade {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
|
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
|
||||||
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
|
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
|
||||||
crate::routes::vault::list_ai_vault_items(status)
|
// 12-2: UDS vaultd first, then in-process core (not HTTP :3000).
|
||||||
|
crate::routes::vault_transport::list_ai_vault_items(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
|
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
|
||||||
@@ -4855,7 +4856,7 @@ impl PiLabToolFacade {
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
|
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
|
||||||
})?;
|
})?;
|
||||||
crate::routes::vault::get_ai_vault_item(&id)
|
crate::routes::vault_transport::get_ai_vault_item(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
|
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
|
||||||
@@ -4870,9 +4871,13 @@ impl PiLabToolFacade {
|
|||||||
let field = string_param(¶ms, "field").ok_or_else(|| {
|
let field = string_param(¶ms, "field").ok_or_else(|| {
|
||||||
WebError::bad_request_code(
|
WebError::bad_request_code(
|
||||||
"page_ai_vault_field_required",
|
"page_ai_vault_field_required",
|
||||||
"mnote.vault.resolve 需要 field=password|apikey|token",
|
"mnote.vault.resolve 需要 field=password|apikey|token|username|email",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let account_id = string_param(¶ms, "accountId")
|
||||||
|
.or_else(|| string_param(¶ms, "account_id"));
|
||||||
|
let secret_id =
|
||||||
|
string_param(¶ms, "secretId").or_else(|| string_param(¶ms, "secret_id"));
|
||||||
let actor = {
|
let actor = {
|
||||||
let id = self.context.auth.actor_id.trim();
|
let id = self.context.auth.actor_id.trim();
|
||||||
if id.is_empty() {
|
if id.is_empty() {
|
||||||
@@ -4888,11 +4893,13 @@ impl PiLabToolFacade {
|
|||||||
"密码箱 resolve 需要登录会话",
|
"密码箱 resolve 需要登录会话",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
crate::routes::vault::resolve_ai_vault_secret(
|
crate::routes::vault_transport::resolve_ai_vault_secret(
|
||||||
&id,
|
&id,
|
||||||
&field,
|
&field,
|
||||||
&actor,
|
&actor,
|
||||||
Some(self.context.trace.request_id.as_str()),
|
Some(self.context.trace.request_id.as_str()),
|
||||||
|
account_id.as_deref(),
|
||||||
|
secret_id.as_deref(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4923,7 +4930,7 @@ impl PiLabToolFacade {
|
|||||||
"密码箱 login 需要登录会话",
|
"密码箱 login 需要登录会话",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
crate::routes::vault::login_ai_vault_credential(
|
crate::routes::vault_transport::login_ai_vault_credential(
|
||||||
&id,
|
&id,
|
||||||
force,
|
force,
|
||||||
&actor,
|
&actor,
|
||||||
@@ -4966,7 +4973,7 @@ impl PiLabToolFacade {
|
|||||||
"密码箱 session 需要登录会话",
|
"密码箱 session 需要登录会话",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
crate::routes::vault::put_ai_vault_session(
|
crate::routes::vault_transport::put_ai_vault_session(
|
||||||
&id,
|
&id,
|
||||||
&cookie,
|
&cookie,
|
||||||
expires.as_deref(),
|
expires.as_deref(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -100,4 +100,20 @@ mod tests {
|
|||||||
assert_eq!(err.status(), StatusCode::FORBIDDEN);
|
assert_eq!(err.status(), StatusCode::FORBIDDEN);
|
||||||
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
|
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denies_cipher_book_and_index_under_vault() {
|
||||||
|
assert!(is_vault_sensitive_relative_path(
|
||||||
|
".mnote/vault/cipher-book.json"
|
||||||
|
));
|
||||||
|
assert!(is_vault_sensitive_relative_path(
|
||||||
|
".mnote/vault/vault-index.json"
|
||||||
|
));
|
||||||
|
assert!(is_vault_sensitive_relative_path(
|
||||||
|
".mnote/vault/audit.jsonl"
|
||||||
|
));
|
||||||
|
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/cipher-book.json")
|
||||||
|
.expect_err("must deny cipher-book via general file surface");
|
||||||
|
assert_eq!(err.code(), "vault_path_denied");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
|||||||
|
//! Pi / agent vault transport: prefer vaultd UDS, fall back to in-process core.
|
||||||
|
//!
|
||||||
|
//! Aligns with 12-2 §5.4 / §6.4 — same sock→core policy as `mnote-vault` CLI.
|
||||||
|
//! Does **not** depend on HTTP to :3000 for list/get/resolve/login/session data plane.
|
||||||
|
//!
|
||||||
|
//! Env:
|
||||||
|
//! - `MNOTE_VAULT_PI_TRANSPORT=auto|uds|local` (default `auto`)
|
||||||
|
//! - `MNOTE_VAULT_SOCK` / token env handled by `mnote-vault-core::token`
|
||||||
|
|
||||||
|
use crate::error::WebError;
|
||||||
|
use crate::routes::vault;
|
||||||
|
use crate::routes::vault_store::VaultItemStatus;
|
||||||
|
use mnote_vault_core::default_sock_path;
|
||||||
|
use mnote_vault_core::read_token_from_env_or_file;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum TransportMode {
|
||||||
|
/// UDS if reachable, else local core.
|
||||||
|
Auto,
|
||||||
|
/// UDS only (fail if sock down).
|
||||||
|
UdsOnly,
|
||||||
|
/// In-process core only (skip sock).
|
||||||
|
LocalOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_mode() -> TransportMode {
|
||||||
|
match std::env::var("MNOTE_VAULT_PI_TRANSPORT")
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.map(|s| s.to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("uds") | Some("remote") | Some("sock") => TransportMode::UdsOnly,
|
||||||
|
Some("local") | Some("core") | Some("embedded") => TransportMode::LocalOnly,
|
||||||
|
_ => TransportMode::Auto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sock_reachable(path: &Path) -> bool {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if !path.exists() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::os::unix::net::UnixStream::connect(path).is_ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn uds_http(
|
||||||
|
sock: &Path,
|
||||||
|
method: &str,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
) -> Result<(u16, String), WebError> {
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
let mut stream = UnixStream::connect(sock).map_err(|e| {
|
||||||
|
WebError::service_unavailable_code(
|
||||||
|
"vaultd_unavailable",
|
||||||
|
format!("无法连接 vaultd sock {}: {e}", sock.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let body_bytes = body.unwrap_or("").as_bytes();
|
||||||
|
let mut req = format!(
|
||||||
|
"{method} {path_and_query} HTTP/1.1\r\nHost: mnote-vaultd\r\nConnection: close\r\n"
|
||||||
|
);
|
||||||
|
if let Some(token) = bearer {
|
||||||
|
req.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||||||
|
}
|
||||||
|
if body.is_some() {
|
||||||
|
req.push_str("Content-Type: application/json\r\n");
|
||||||
|
req.push_str(&format!("Content-Length: {}\r\n", body_bytes.len()));
|
||||||
|
} else {
|
||||||
|
req.push_str("Content-Length: 0\r\n");
|
||||||
|
}
|
||||||
|
req.push_str("\r\n");
|
||||||
|
stream
|
||||||
|
.write_all(req.as_bytes())
|
||||||
|
.and_then(|_| {
|
||||||
|
if !body_bytes.is_empty() {
|
||||||
|
stream.write_all(body_bytes)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| {
|
||||||
|
WebError::service_unavailable_code(
|
||||||
|
"vaultd_unavailable",
|
||||||
|
format!("写 sock 失败: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
stream.read_to_end(&mut raw).map_err(|e| {
|
||||||
|
WebError::service_unavailable_code(
|
||||||
|
"vaultd_unavailable",
|
||||||
|
format!("读 sock 失败: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let text = String::from_utf8_lossy(&raw);
|
||||||
|
parse_http_response(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn uds_http(
|
||||||
|
_sock: &Path,
|
||||||
|
_method: &str,
|
||||||
|
_path_and_query: &str,
|
||||||
|
_body: Option<&str>,
|
||||||
|
_bearer: Option<&str>,
|
||||||
|
) -> Result<(u16, String), WebError> {
|
||||||
|
Err(WebError::service_unavailable_code(
|
||||||
|
"vaultd_unavailable",
|
||||||
|
"UDS 仅支持 Unix",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_http_response(text: &str) -> Result<(u16, String), WebError> {
|
||||||
|
let (head, body) = text
|
||||||
|
.split_once("\r\n\r\n")
|
||||||
|
.or_else(|| text.split_once("\n\n"))
|
||||||
|
.unwrap_or((text, ""));
|
||||||
|
let status_line = head.lines().next().unwrap_or("");
|
||||||
|
let status: u16 = status_line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(500);
|
||||||
|
Ok((status, body.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_http_error(status: u16, body: &str) -> WebError {
|
||||||
|
if let Ok(v) = serde_json::from_str::<Value>(body) {
|
||||||
|
let code = v
|
||||||
|
.get("code")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("vaultd_error");
|
||||||
|
let message = v
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(body)
|
||||||
|
.to_string();
|
||||||
|
let http_status = axum::http::StatusCode::from_u16(status)
|
||||||
|
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
// Prefer stable vault_* codes when present.
|
||||||
|
let code_static: &'static str = match code {
|
||||||
|
"vault_token_missing" => "vault_token_missing",
|
||||||
|
"vault_token_invalid" => "vault_token_invalid",
|
||||||
|
"vault_token_expired" => "vault_token_expired",
|
||||||
|
"vault_scope_denied" => "vault_scope_denied",
|
||||||
|
"vault_actor_mismatch" => "vault_actor_mismatch",
|
||||||
|
"vault_item_not_found" => "vault_item_not_found",
|
||||||
|
"vault_resolve_field_invalid" => "vault_resolve_field_invalid",
|
||||||
|
"vault_resolve_inactive" => "vault_resolve_inactive",
|
||||||
|
"vaultd_unavailable" => "vaultd_unavailable",
|
||||||
|
"bad_request" => "bad_request",
|
||||||
|
"vault_session_inactive" => "vault_session_inactive",
|
||||||
|
"vault_login_no_url" => "vault_login_no_url",
|
||||||
|
"vault_login_human_required" => "vault_login_human_required",
|
||||||
|
_ if code.starts_with("vault_") => "vault_error",
|
||||||
|
_ => "vaultd_error",
|
||||||
|
};
|
||||||
|
let mut err = WebError::new(http_status, code_static, message);
|
||||||
|
if code_static == "vault_error" {
|
||||||
|
err = err.with_details(json!({ "upstreamCode": code }));
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
WebError::new(
|
||||||
|
axum::http::StatusCode::from_u16(status)
|
||||||
|
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
"vaultd_error",
|
||||||
|
format!("HTTP {status}: {body}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_token() -> Result<Option<String>, WebError> {
|
||||||
|
match read_token_from_env_or_file() {
|
||||||
|
Ok(t) => Ok(Some(t)),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError>
|
||||||
|
where
|
||||||
|
F: FnOnce(Option<&str>) -> Result<Value, WebError>,
|
||||||
|
G: FnOnce() -> Result<Value, WebError>,
|
||||||
|
{
|
||||||
|
let mode = transport_mode();
|
||||||
|
if mode == TransportMode::LocalOnly {
|
||||||
|
return via_local();
|
||||||
|
}
|
||||||
|
let sock = default_sock_path();
|
||||||
|
if sock_reachable(&sock) {
|
||||||
|
let token = client_token()?;
|
||||||
|
match via_uds(token.as_deref()) {
|
||||||
|
Ok(v) => return Ok(v),
|
||||||
|
Err(e) if mode == TransportMode::UdsOnly => return Err(e),
|
||||||
|
Err(_) => {
|
||||||
|
// Soft fallback to in-process core (same as CLI uds_fallback_local).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if mode == TransportMode::UdsOnly {
|
||||||
|
return Err(WebError::service_unavailable_code(
|
||||||
|
"vaultd_unavailable",
|
||||||
|
format!("vaultd sock 不可达: {}", sock.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
via_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn core_status(status: VaultItemStatus) -> VaultItemStatus {
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List AI vault (Pi tool). UDS → core.
|
||||||
|
pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, WebError> {
|
||||||
|
let status_q = status.as_str();
|
||||||
|
with_transport(
|
||||||
|
|token| {
|
||||||
|
let path = format!("/v1/items?status={status_q}");
|
||||||
|
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
|
||||||
|
if st >= 400 {
|
||||||
|
return Err(map_http_error(st, &body));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&body).map_err(|e| {
|
||||||
|
WebError::internal(format!("vaultd list JSON 无效: {e}"))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|| vault::list_ai_vault_items(core_status(status)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
|
||||||
|
with_transport(
|
||||||
|
|token| {
|
||||||
|
let path = format!("/v1/items/{id}");
|
||||||
|
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
|
||||||
|
if st >= 400 {
|
||||||
|
return Err(map_http_error(st, &body));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&body).map_err(|e| {
|
||||||
|
WebError::internal(format!("vaultd get JSON 无效: {e}"))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|| vault::get_ai_vault_item(id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_ai_vault_secret(
|
||||||
|
id: &str,
|
||||||
|
field: &str,
|
||||||
|
actor: &str,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
account_id: Option<&str>,
|
||||||
|
secret_id: Option<&str>,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let id_owned = id.to_string();
|
||||||
|
let field_owned = field.to_string();
|
||||||
|
let account = account_id.map(str::to_string);
|
||||||
|
let secret = secret_id.map(str::to_string);
|
||||||
|
with_transport(
|
||||||
|
|token| {
|
||||||
|
let path = format!("/v1/items/{id_owned}/resolve");
|
||||||
|
let body = json!({
|
||||||
|
"field": field_owned,
|
||||||
|
"accountId": account,
|
||||||
|
"secretId": secret,
|
||||||
|
});
|
||||||
|
let body_s = body.to_string();
|
||||||
|
let (st, resp) =
|
||||||
|
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||||
|
if st >= 400 {
|
||||||
|
return Err(map_http_error(st, &resp));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&resp).map_err(|e| {
|
||||||
|
WebError::internal(format!("vaultd resolve JSON 无效: {e}"))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
vault::resolve_ai_vault_secret(
|
||||||
|
&id_owned,
|
||||||
|
&field_owned,
|
||||||
|
actor,
|
||||||
|
request_id,
|
||||||
|
account.as_deref(),
|
||||||
|
secret.as_deref(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_ai_vault_credential(
|
||||||
|
id: &str,
|
||||||
|
force_refresh: bool,
|
||||||
|
actor: &str,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let id_owned = id.to_string();
|
||||||
|
with_transport(
|
||||||
|
|token| {
|
||||||
|
let path = format!("/v1/items/{id_owned}/login");
|
||||||
|
let body = json!({ "forceRefresh": force_refresh });
|
||||||
|
let body_s = body.to_string();
|
||||||
|
let (st, resp) =
|
||||||
|
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||||
|
if st >= 400 {
|
||||||
|
return Err(map_http_error(st, &resp));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&resp).map_err(|e| {
|
||||||
|
WebError::internal(format!("vaultd login JSON 无效: {e}"))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|| vault::login_ai_vault_credential(&id_owned, force_refresh, actor, request_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_ai_vault_session(
|
||||||
|
id: &str,
|
||||||
|
cookie_header: &str,
|
||||||
|
expires_at: Option<&str>,
|
||||||
|
source: &str,
|
||||||
|
actor: &str,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let id_owned = id.to_string();
|
||||||
|
let cookie = cookie_header.to_string();
|
||||||
|
let expires = expires_at.map(str::to_string);
|
||||||
|
let source_owned = source.to_string();
|
||||||
|
with_transport(
|
||||||
|
|token| {
|
||||||
|
let path = format!("/v1/items/{id_owned}/session");
|
||||||
|
let body = json!({
|
||||||
|
"cookieHeader": cookie,
|
||||||
|
"expiresAt": expires,
|
||||||
|
"source": source_owned,
|
||||||
|
});
|
||||||
|
let body_s = body.to_string();
|
||||||
|
let (st, resp) =
|
||||||
|
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||||
|
if st >= 400 {
|
||||||
|
return Err(map_http_error(st, &resp));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&resp).map_err(|e| {
|
||||||
|
WebError::internal(format!("vaultd session JSON 无效: {e}"))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
vault::put_ai_vault_session(
|
||||||
|
&id_owned,
|
||||||
|
&cookie,
|
||||||
|
expires.as_deref(),
|
||||||
|
&source_owned,
|
||||||
|
actor,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_mode_defaults_to_auto() {
|
||||||
|
// Do not assert env-free default in parallel tests; just ensure parser is stable.
|
||||||
|
let _ = transport_mode();
|
||||||
|
assert!(matches!(
|
||||||
|
TransportMode::Auto,
|
||||||
|
TransportMode::Auto
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_local_only_works_without_sock() {
|
||||||
|
std::env::set_var("MNOTE_VAULT_PI_TRANSPORT", "local");
|
||||||
|
// May fail if AI vault workspace missing in CI sandbox — only check no panic on mode.
|
||||||
|
let _ = list_ai_vault_items(VaultItemStatus::Active);
|
||||||
|
std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -320,6 +320,46 @@
|
|||||||
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wolai-style sibling reorder lines (before / after). Nest-into uses full-row fill above. */
|
||||||
|
.sidebar-tree .tree-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-feedback="true"],
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-target="true"],
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-feedback="true"],
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-target="true"] {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-feedback="true"],
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-target="true"] {
|
||||||
|
background: rgba(0, 110, 40, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="before"]::before,
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="after"]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
right: 8px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 110, 40, 0.85);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="before"]::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-drop-position="after"]::after {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-tree .tree-toggle,
|
.sidebar-tree .tree-toggle,
|
||||||
.sidebar-tree .tree-spacer {
|
.sidebar-tree .tree-spacer {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|||||||
@@ -90,6 +90,64 @@
|
|||||||
background: #f7f7f6;
|
background: #f7f7f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 顶栏(密文簿旁):插入已有密文 [Key];编辑态显示,滚动详情时仍可见 */
|
||||||
|
.mnote-vault-header-actions .mnote-vault-insert-cipher,
|
||||||
|
.mnote-vault-insert-cipher {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-insert-cipher[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-insert-cipher select {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 118px;
|
||||||
|
max-width: 180px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #37352f;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-insert-cipher select:hover {
|
||||||
|
background: #f7f7f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-form-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: #8b8782;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-form-hint code {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(27, 28, 28, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mnote-vault-detail-actions button.is-danger {
|
.mnote-vault-detail-actions button.is-danger {
|
||||||
color: #c93a32;
|
color: #c93a32;
|
||||||
border-color: rgba(201, 58, 50, 0.28);
|
border-color: rgba(201, 58, 50, 0.28);
|
||||||
@@ -732,3 +790,296 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Multi-account / multi-secret collapsible groups */
|
||||||
|
.mnote-vault-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(247, 247, 246, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #37352f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-section-head > button {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-section-head > button:hover {
|
||||||
|
background: #f7f7f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-URL (equivalent site endpoints / fallbacks) */
|
||||||
|
.mnote-vault-url-hint {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(55, 53, 47, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-url-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-url-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(55, 53, 47, 0.65);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-url-row input[type="url"] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-url-row input[type="url"]:focus {
|
||||||
|
outline: 2px solid rgba(35, 131, 226, 0.35);
|
||||||
|
border-color: rgba(35, 131, 226, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-url-row > button {
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-url-row > button:hover {
|
||||||
|
background: #fdf2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-url-spacer {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-urls-view .mnote-vault-field-value a {
|
||||||
|
color: #2383e2;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-group {
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #37352f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-actions button {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-actions button.is-danger {
|
||||||
|
color: #c93a32;
|
||||||
|
border-color: rgba(201, 58, 50, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
border-top: 1px solid rgba(27, 28, 28, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-slot-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Nested appendix secrets under each account (default collapsed) */
|
||||||
|
.mnote-vault-account-secrets {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 8px 6px;
|
||||||
|
border: 1px dashed rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafaf9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-account-secrets.is-collapsed {
|
||||||
|
gap: 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-account-secrets-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-account-secrets-body[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-account-secrets-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6d6a65;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-secrets-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6d6a65;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-secrets-toggle:hover {
|
||||||
|
color: #37352f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-secrets-chevron {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #9b9a97;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-secrets-count {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9b9a97;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-account-secrets-head button:not(.mnote-vault-secrets-toggle) {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-account-secrets-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-nested-secret {
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-nested-secret-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-nested-secret-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #37352f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-vault-nested-secret-head button.is-danger {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid rgba(201, 58, 50, 0.28);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #c93a32;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cipher book plain input (visible while typing) */
|
||||||
|
.mnote-vault-cipher-add input.mnote-vault-secret-input-plain,
|
||||||
|
.mnote-vault-cipher-add input[type="text"] {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,35 @@ paseo_browser_screenshot
|
|||||||
| Paseo 终端无输出 | 前台进程阻塞 | 用 `nohup ... &` 后台启动 |
|
| Paseo 终端无输出 | 前台进程阻塞 | 用 `nohup ... &` 后台启动 |
|
||||||
| `desktop:hot` vs `dev:hot` | 启动命令不同 | 浏览器测试用 `dev:hot` |
|
| `desktop:hot` vs `dev:hot` | 启动命令不同 | 浏览器测试用 `dev:hot` |
|
||||||
|
|
||||||
|
### 6. AI 密码本读密 / 登录态(12-2 · **不依赖 3000**)
|
||||||
|
|
||||||
|
读密与 login/session 走本地 core + capability token,**不要**为 resolve/login/session 启 mnote-web。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rust
|
||||||
|
cargo build -q -p mnote-vault
|
||||||
|
# 一次签发(写入 ~/.config/mnote/vault-tokens/default.token;默认 scope 含 login,session)
|
||||||
|
./target/debug/mnote-vault issue-token --agent smoke --ttl 1d
|
||||||
|
./target/debug/mnote-vault doctor
|
||||||
|
# 可选常驻 UDS($XDG_RUNTIME_DIR/mnote-vaultd.sock 或 MNOTE_VAULT_SOCK)
|
||||||
|
./target/debug/mnote-vault serve &
|
||||||
|
./target/debug/mnote-vault list # sock 优先,不可达则内嵌 core
|
||||||
|
./target/debug/mnote-vault resolve --id <cred_id> --field password
|
||||||
|
./target/debug/mnote-vault resolve --id <id> --field password --local # 强制内嵌
|
||||||
|
./target/debug/mnote-vault resolve --id <id> --field password --remote # 强制 UDS
|
||||||
|
# session 回写 + login 复用(无外站 HTTP)
|
||||||
|
./target/debug/mnote-vault session --id <id> --cookie-header 'mnote_session=smoke; other=1' --local
|
||||||
|
./target/debug/mnote-vault login --id <id> --local # 期望 reused=true / mode=session
|
||||||
|
cargo test -p mnote-vault-core --lib
|
||||||
|
cargo test -p mnote-web --lib routes::vault::resolve_strategy_tests
|
||||||
|
cargo test -p mnote-web --lib routes::vault -- --nocapture 2>&1 | rg -n "login_reuses|ok"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Crate:`mnote-vault-core`、`mnote-vault`(bin,含 `serve` + login/session)、`mnote-web` AI list/get/resolve/login/session thin-wrap core
|
||||||
|
- 设计:`design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`
|
||||||
|
- Skill / 策略:`$mnote-vault`、`/home/lix/.agent-infra/vault-policy.md`
|
||||||
|
- Web UI / CRUD workbench 仍可走 3000;**list/get/resolve/login/session 稳态禁止以 auth-e2e 为前置**
|
||||||
|
|
||||||
这份文档面向 `/mnt/Data1T/mnote/scripts` 目录下的现有测试脚本,目标不是重新设计测试体系,而是把当前已经在用的 smoke、回归脚本、截图取证和人工复核方式整理成一套可执行参考。
|
这份文档面向 `/mnt/Data1T/mnote/scripts` 目录下的现有测试脚本,目标不是重新设计测试体系,而是把当前已经在用的 smoke、回归脚本、截图取证和人工复核方式整理成一套可执行参考。
|
||||||
|
|
||||||
当前结论先说在前面:
|
当前结论先说在前面:
|
||||||
@@ -113,6 +142,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
|||||||
- WeKnora 默认 provider 脚本(`task544`、`task769`、`task772`、`task777`、`task781`、`task783`、`task784`、`task785`、`task787`、`task788`、`task789`、`task790`、`task79x`)已退役;它们与 LightRAG 默认 provider 主线相反。
|
- WeKnora 默认 provider 脚本(`task544`、`task769`、`task772`、`task777`、`task781`、`task783`、`task784`、`task785`、`task787`、`task788`、`task789`、`task790`、`task79x`)已退役;它们与 LightRAG 默认 provider 主线相反。
|
||||||
- Hermes/ACP/Reasonix 页面 AI 主流程 smoke 已退役;只保留 `task-hermes-page-ai-retirement-guard.js` 防止旧 runtime 复活。`task762-page-ai-board-first-smoke.js` 也已退役。
|
- Hermes/ACP/Reasonix 页面 AI 主流程 smoke 已退役;只保留 `task-hermes-page-ai-retirement-guard.js` 防止旧 runtime 复活。`task762-page-ai-board-first-smoke.js` 也已退役。
|
||||||
- 旧 `task019`、`task021`、`task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
|
- 旧 `task019`、`task021`、`task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
|
||||||
|
- `task494-filetree-lazy-loading-dedup-smoke.js`、`task498-starred-page-tree-scope-and-local-edit-smoke.js`、`task499-sidebar-tree-view-state-smoke.js`:2026-07-21 诊断为 fixture/契约过时(非 Playwright 安装问题),已迁到 `recycle/scripts/obsolete-tree-smokes-20260721/`。全局 Playwright 栈:`~/.agent-infra/playwright-stack`(见该目录 `use-in-project.md`)。树移动排序优先 `task447-tree-move-order-dual-browser-live-smoke.js` 或 Hermes tree QA。
|
||||||
|
|
||||||
### 0.3 其他 current 候选脚本
|
### 0.3 其他 current 候选脚本
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* node scripts/mnote-vault-cli.js list
|
* node scripts/mnote-vault-cli.js list
|
||||||
* node scripts/mnote-vault-cli.js get --id cred_xxx
|
* node scripts/mnote-vault-cli.js get --id cred_xxx
|
||||||
* node scripts/mnote-vault-cli.js resolve --id cred_xxx --field password
|
* node scripts/mnote-vault-cli.js resolve --id cred_xxx --field password
|
||||||
|
* node scripts/mnote-vault-cli.js resolve --id cred_xxx --field password --account-id acc_xxx
|
||||||
*
|
*
|
||||||
* Never print secrets to logs other than resolve stdout value line when --raw.
|
* Never print secrets to logs other than resolve stdout value line when --raw.
|
||||||
* Default resolve prints JSON including value for tool piping; use --raw for plain value only.
|
* Default resolve prints JSON including value for tool piping; use --raw for plain value only.
|
||||||
@@ -29,7 +30,8 @@ function usage() {
|
|||||||
mnote-vault-cli.js auth-e2e # print export MNOTE_COOKIE=... for local mnote-web
|
mnote-vault-cli.js auth-e2e # print export MNOTE_COOKIE=... for local mnote-web
|
||||||
mnote-vault-cli.js list [--status active|deleted]
|
mnote-vault-cli.js list [--status active|deleted]
|
||||||
mnote-vault-cli.js get --id <credentialId>
|
mnote-vault-cli.js get --id <credentialId>
|
||||||
mnote-vault-cli.js resolve --id <credentialId> --field password|apikey|token [--raw]
|
mnote-vault-cli.js resolve --id <credentialId> --field password|apikey|token|username|email \\
|
||||||
|
[--account-id <accountId>] [--secret-id <secretId>] [--raw]
|
||||||
mnote-vault-cli.js login --id <credentialId> [--force]
|
mnote-vault-cli.js login --id <credentialId> [--force]
|
||||||
mnote-vault-cli.js session --id <credentialId> --cookie <CookieHeader> [--source human_bridge]
|
mnote-vault-cli.js session --id <credentialId> --cookie <CookieHeader> [--source human_bridge]
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ Env: MNOTE_BASE_URL, MNOTE_COOKIE
|
|||||||
auth-e2e uses MNOTE_E2E_EMAIL / MNOTE_E2E_PASSWORD (defaults: mnote.e2e@example.com / MnoteE2E123!)
|
auth-e2e uses MNOTE_E2E_EMAIL / MNOTE_E2E_PASSWORD (defaults: mnote.e2e@example.com / MnoteE2E123!)
|
||||||
|
|
||||||
Steady-state: auth-e2e once → list (optional) → login (reuse session).
|
Steady-state: auth-e2e once → list (optional) → login (reuse session).
|
||||||
|
Multi-account: get item → pick accounts[].id → resolve --account-id …
|
||||||
Human Cloudflare: browser then "session" write-back.`);
|
Human Cloudflare: browser then "session" write-back.`);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,9 @@ function parseArgs(argv) {
|
|||||||
a === '--status' ||
|
a === '--status' ||
|
||||||
a === '--cookie' ||
|
a === '--cookie' ||
|
||||||
a === '--source' ||
|
a === '--source' ||
|
||||||
a === '--expires'
|
a === '--expires' ||
|
||||||
|
a === '--account-id' ||
|
||||||
|
a === '--secret-id'
|
||||||
) {
|
) {
|
||||||
out[a.slice(2)] = argv[++i];
|
out[a.slice(2)] = argv[++i];
|
||||||
} else if (a === '--raw' || a === '--force') {
|
} else if (a === '--raw' || a === '--force') {
|
||||||
@@ -173,10 +178,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (cmd === 'resolve') {
|
if (cmd === 'resolve') {
|
||||||
if (!args.id || !args.field) usage();
|
if (!args.id || !args.field) usage();
|
||||||
|
const body = { field: args.field };
|
||||||
|
if (args['account-id']) body.accountId = args['account-id'];
|
||||||
|
if (args['secret-id']) body.secretId = args['secret-id'];
|
||||||
const result = await api(
|
const result = await api(
|
||||||
'POST',
|
'POST',
|
||||||
`/api/vault/ai/items/${encodeURIComponent(args.id)}/resolve`,
|
`/api/vault/ai/items/${encodeURIComponent(args.id)}/resolve`,
|
||||||
{ field: args.field }
|
body
|
||||||
);
|
);
|
||||||
if (args.raw) {
|
if (args.raw) {
|
||||||
if (result && result.resolved && result.value != null) {
|
if (result && result.resolved && result.value != null) {
|
||||||
|
|||||||
@@ -1,658 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const http = require("node:http");
|
|
||||||
const net = require("node:net");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
const { spawn } = require("node:child_process");
|
|
||||||
const { chromium } = require("playwright");
|
|
||||||
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
|
|
||||||
|
|
||||||
const TASK = "task494-filetree-lazy-loading-dedup-smoke";
|
|
||||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
|
||||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
|
||||||
|
|
||||||
function resolveChromiumExecutablePath() {
|
|
||||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
|
||||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
||||||
return [
|
|
||||||
"/usr/bin/google-chrome-stable",
|
|
||||||
"/usr/bin/google-chrome",
|
|
||||||
"/snap/bin/chromium",
|
|
||||||
"/usr/bin/chromium",
|
|
||||||
"/usr/bin/chromium-browser",
|
|
||||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPort() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address();
|
|
||||||
const port = address && typeof address === "object" ? address.port : 0;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForHttpOk(url, timeoutMs) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tick = () => {
|
|
||||||
const request = http.get(url, (response) => {
|
|
||||||
response.resume();
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
retry();
|
|
||||||
});
|
|
||||||
request.on("error", retry);
|
|
||||||
request.setTimeout(1000, () => {
|
|
||||||
request.destroy();
|
|
||||||
retry();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const retry = () => {
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
reject(new Error(`server_not_ready: ${url}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(tick, 250);
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileUrlToPath(value) {
|
|
||||||
const url = new URL(value);
|
|
||||||
return decodeURIComponent(url.pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
function localMarkdownDocumentPath(documentId) {
|
|
||||||
const encoded = String(documentId || "").replace(/^local-md:/, "");
|
|
||||||
return decodeURIComponent(encoded.replace(/~/g, "%"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssEscape(value) {
|
|
||||||
return String(value).replace(/["\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const port = await pickPort();
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
|
|
||||||
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
|
|
||||||
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
|
|
||||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
|
||||||
cwd: path.join(__dirname, "..", "rust"),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
|
||||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
|
||||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
|
||||||
...controlPlaneEnv,
|
|
||||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
let stderr = "";
|
|
||||||
server.stderr.on("data", (chunk) => {
|
|
||||||
stderr += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
const executablePath = resolveChromiumExecutablePath();
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
...(executablePath ? { executablePath } : {}),
|
|
||||||
});
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
const browserDiagnostics = [];
|
|
||||||
let targetChildrenRequests = 0;
|
|
||||||
let staleScopeChildrenRequests = 0;
|
|
||||||
let scopedRootProjectionRequests = 0;
|
|
||||||
let workspaceRootProjectionRequests = 0;
|
|
||||||
let localWatchRevision = 0;
|
|
||||||
|
|
||||||
page.on("console", (message) => {
|
|
||||||
browserDiagnostics.push(`console:${message.type()}:${message.text()}`);
|
|
||||||
});
|
|
||||||
page.on("pageerror", (error) => {
|
|
||||||
browserDiagnostics.push(`pageerror:${error.message}`);
|
|
||||||
});
|
|
||||||
await page.route("**/api/tree/projections/file**", async (route) => {
|
|
||||||
const url = new URL(route.request().url());
|
|
||||||
const parentRelativePath = url.searchParams.get("parentRelativePath") || "";
|
|
||||||
if (url.pathname.endsWith("/api/tree/projections/file") && parentRelativePath === "design") {
|
|
||||||
scopedRootProjectionRequests += 1;
|
|
||||||
}
|
|
||||||
if (url.pathname.endsWith("/api/tree/projections/file") && !parentRelativePath) {
|
|
||||||
workspaceRootProjectionRequests += 1;
|
|
||||||
}
|
|
||||||
if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/03-rust-web") {
|
|
||||||
targetChildrenRequests += 1;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
}
|
|
||||||
if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/slow-scope") {
|
|
||||||
staleScopeChildrenRequests += 1;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
||||||
}
|
|
||||||
await route.continue();
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
|
||||||
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
action: "auth:signIn",
|
|
||||||
args: {
|
|
||||||
provider: "password",
|
|
||||||
params: {
|
|
||||||
email: `${actorId}@example.com`,
|
|
||||||
username: actorId,
|
|
||||||
name: actorId,
|
|
||||||
password: TEST_PASSWORD,
|
|
||||||
flow: "signUp",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`);
|
|
||||||
|
|
||||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
|
||||||
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
||||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialUrl = new URL(page.url());
|
|
||||||
const workspaceId = initialUrl.searchParams.get("workspaceId") || "";
|
|
||||||
const rootUri = initialUrl.searchParams.get("rootUri")
|
|
||||||
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
|
|
||||||
assert(rootUri, "应进入 local_folder workspace");
|
|
||||||
const rootPath = fileUrlToPath(rootUri);
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "done"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "process"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "reference"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design", "reveal-parent", "child"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design", "slow-scope", "child"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(rootPath, "docs", "target"), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "Overview.md"), "Plain paragraph without office attachment.\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "plan.md"), "# Plan\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "reveal-parent", "child", "note.md"), "# Reveal\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "slow-scope", "child", "note.md"), "# Slow child\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "docs", "target", "note.md"), "# Docs child\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "docs", "opened-rename.md"), "# Opened rename\n", "utf8");
|
|
||||||
|
|
||||||
const fileTreeUrl = new URL(baseUrl);
|
|
||||||
if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId);
|
|
||||||
fileTreeUrl.searchParams.set("sourceKind", "local_folder");
|
|
||||||
fileTreeUrl.searchParams.set("rootUri", rootUri);
|
|
||||||
fileTreeUrl.searchParams.set("treeView", "filetree");
|
|
||||||
await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const firstCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || "");
|
|
||||||
const firstCreatedRelativePath = localMarkdownDocumentPath(firstCreatedDocumentId);
|
|
||||||
const firstCreatedParentPath = firstCreatedRelativePath.split("/").slice(0, -1).join("/");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForURL((url) => {
|
|
||||||
const documentId = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() || "");
|
|
||||||
return url.pathname.startsWith("/documents/") && documentId !== firstCreatedDocumentId;
|
|
||||||
}, { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const secondCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || "");
|
|
||||||
const secondCreatedRelativePath = localMarkdownDocumentPath(secondCreatedDocumentId);
|
|
||||||
const secondCreatedParentPath = secondCreatedRelativePath.split("/").slice(0, -1).join("/");
|
|
||||||
const createExpansionState = await page.evaluate(({ firstParent, secondParent }) => {
|
|
||||||
const readRow = (relativePath) => {
|
|
||||||
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(relativePath)}"]`);
|
|
||||||
return row
|
|
||||||
? {
|
|
||||||
relativePath,
|
|
||||||
expanded: row.getAttribute("aria-expanded"),
|
|
||||||
selected: row.getAttribute("data-selected"),
|
|
||||||
focused: row.getAttribute("data-focused"),
|
|
||||||
active: row.getAttribute("data-active"),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
const firstMarkdownPath = firstParent + "/" + firstParent.split("/").pop() + ".md";
|
|
||||||
const secondMarkdownPath = secondParent + "/" + secondParent.split("/").pop() + ".md";
|
|
||||||
return {
|
|
||||||
first: readRow(firstParent),
|
|
||||||
second: readRow(secondParent),
|
|
||||||
firstMarkdown: readRow(firstMarkdownPath),
|
|
||||||
secondMarkdown: readRow(secondMarkdownPath),
|
|
||||||
};
|
|
||||||
}, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath });
|
|
||||||
assert.equal(
|
|
||||||
createExpansionState.second?.expanded,
|
|
||||||
"true",
|
|
||||||
`新建页面后当前页面包目录应展开,避免焦点落到父文件夹: ${JSON.stringify(createExpansionState)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
createExpansionState.secondMarkdown?.selected,
|
|
||||||
"true",
|
|
||||||
`新建页面后应选中内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
createExpansionState.secondMarkdown?.focused,
|
|
||||||
"true",
|
|
||||||
`新建页面后文件树焦点应落在内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
createExpansionState.secondMarkdown?.active,
|
|
||||||
"true",
|
|
||||||
`新建页面后 active 应落在内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const scopedUrl = new URL(baseUrl);
|
|
||||||
if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId);
|
|
||||||
scopedUrl.searchParams.set("sourceKind", "local_folder");
|
|
||||||
scopedUrl.searchParams.set("rootUri", rootUri);
|
|
||||||
scopedUrl.searchParams.set("treeView", "filetree");
|
|
||||||
scopedUrl.searchParams.set("fileTreeScope", "design");
|
|
||||||
await page.goto(scopedUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scopedRootRequestsAfterInitialLoad = scopedRootProjectionRequests;
|
|
||||||
const workspaceRootRequestsAfterInitialLoad = workspaceRootProjectionRequests;
|
|
||||||
await page.evaluate(() => {
|
|
||||||
window.__mnoteScopedFileOpenNoReloadMarker = "kept";
|
|
||||||
document.documentElement.setAttribute("data-mnote-scoped-file-open-no-reload-marker", "kept");
|
|
||||||
});
|
|
||||||
await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/Overview.md"] .tree-link').first().click({
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
await page.waitForURL((nextUrl) => nextUrl.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
await page.evaluate(() => {
|
|
||||||
if (typeof window.__mnoteEnhanceEditorAttachmentLinks === "function") {
|
|
||||||
window.__mnoteEnhanceEditorAttachmentLinks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
const scopedOpenMarker = await page.evaluate(() => window.__mnoteScopedFileOpenNoReloadMarker || "");
|
|
||||||
assert.equal(scopedOpenMarker, "kept", "scoped 文件树打开 md 应走 pane 内导航,不应整页 reload 重建侧栏");
|
|
||||||
assert.equal(
|
|
||||||
scopedRootProjectionRequests,
|
|
||||||
scopedRootRequestsAfterInitialLoad,
|
|
||||||
"scoped 文件树打开已可见 md 不应重新请求 scope 根 projection",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
workspaceRootProjectionRequests,
|
|
||||||
workspaceRootRequestsAfterInitialLoad,
|
|
||||||
"普通 Markdown 打开不应为了 legacy office 附件兼容重拉 workspace root projection",
|
|
||||||
);
|
|
||||||
const scopedRootRequestsBeforeRootSnapshot = scopedRootProjectionRequests;
|
|
||||||
await page.evaluate(() => {
|
|
||||||
window.dispatchEvent(new CustomEvent("tree:snapshot", {
|
|
||||||
detail: {
|
|
||||||
payload: {
|
|
||||||
dataset: {
|
|
||||||
kernel_file_tree_projection: {
|
|
||||||
parentRelativePath: "",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
rowId: "local:folder:root-probe",
|
|
||||||
nodeId: "local:folder:root-probe",
|
|
||||||
title: "root-probe",
|
|
||||||
rowKind: "folder",
|
|
||||||
resourceMeta: {
|
|
||||||
workspacePath: { relativePath: "root-probe" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(350);
|
|
||||||
assert.equal(
|
|
||||||
scopedRootProjectionRequests,
|
|
||||||
scopedRootRequestsBeforeRootSnapshot,
|
|
||||||
"scoped 文件树收到非 scope root snapshot 不应兜底重拉 scope 根 projection",
|
|
||||||
);
|
|
||||||
const scopedRootRequestsBeforeCoarseWatch = scopedRootProjectionRequests;
|
|
||||||
localWatchRevision += 1;
|
|
||||||
await page.evaluate((revision) => {
|
|
||||||
window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", {
|
|
||||||
detail: {
|
|
||||||
payload: {
|
|
||||||
schema: "mnote.local_folder_watch_batch.v1",
|
|
||||||
sourceKind: "local_folder",
|
|
||||||
revision: `forced-watch-${revision}`,
|
|
||||||
affectedParents: [{ relativePath: "", reason: "coarse-watch" }],
|
|
||||||
changedPaths: [{ relativePath: "design", kind: "Modify(Name(Both))" }],
|
|
||||||
eventKinds: ["Modify(Name(Both))"],
|
|
||||||
fallbackResync: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, localWatchRevision);
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
assert.equal(
|
|
||||||
scopedRootProjectionRequests,
|
|
||||||
scopedRootRequestsBeforeCoarseWatch,
|
|
||||||
"scoped 文件树收到 coarse local-folder 事件不应重拉 scope 根 projection",
|
|
||||||
);
|
|
||||||
|
|
||||||
const rowSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/03-rust-web"]';
|
|
||||||
const childSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path^="design/03-rust-web/"]';
|
|
||||||
await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
assert.equal(targetChildrenRequests, 0, "scoped 首屏不应预加载 design/03-rust-web children");
|
|
||||||
|
|
||||||
await page.locator(rowSelector).evaluate((row) => {
|
|
||||||
const toggle = row.querySelector('[data-rust-action="toggle"]');
|
|
||||||
for (let index = 0; index < 5; index += 1) toggle.click();
|
|
||||||
});
|
|
||||||
await page.waitForFunction((selector) => {
|
|
||||||
const row = document.querySelector(selector);
|
|
||||||
return row && row.getAttribute("aria-expanded") === "true" && row.getAttribute("data-filetree-children-loaded") === "true";
|
|
||||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
assert.equal(targetChildrenRequests, 1, "同一路径快速重复展开最多只能产生一个 children 请求");
|
|
||||||
|
|
||||||
const firstExpandRequests = targetChildrenRequests;
|
|
||||||
await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForFunction((selector) => {
|
|
||||||
const row = document.querySelector(selector);
|
|
||||||
return row && row.getAttribute("aria-expanded") === "false";
|
|
||||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
|
||||||
const expandStartedAt = Date.now();
|
|
||||||
await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForFunction((selector) => {
|
|
||||||
const row = document.querySelector(selector);
|
|
||||||
const child = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]');
|
|
||||||
return row && child && row.getAttribute("aria-expanded") === "true";
|
|
||||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
|
||||||
const cachedExpandMs = Date.now() - expandStartedAt;
|
|
||||||
assert.equal(targetChildrenRequests, firstExpandRequests, "收起后再次展开应命中 cache,不应再次请求 children");
|
|
||||||
|
|
||||||
await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => {
|
|
||||||
row.setAttribute("data-selected", "true");
|
|
||||||
row.setAttribute("data-focused", "true");
|
|
||||||
row.setAttribute("data-active", "true");
|
|
||||||
});
|
|
||||||
await page.evaluate(() => {
|
|
||||||
window.dispatchEvent(new CustomEvent("tree:local-command", {
|
|
||||||
detail: {
|
|
||||||
body: {
|
|
||||||
action: "rename",
|
|
||||||
documentId: "local-md:design~2F03-rust-web~2Fplan.md",
|
|
||||||
title: "plan",
|
|
||||||
parentRelativePath: "design/03-rust-web",
|
|
||||||
},
|
|
||||||
result: {
|
|
||||||
parentRelativePath: "design/03-rust-web",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
await page.waitForFunction((selector) => {
|
|
||||||
const row = document.querySelector(selector);
|
|
||||||
const done = document.querySelector('[data-local-relative-path="design/03-rust-web/done"]');
|
|
||||||
const process = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]');
|
|
||||||
return row && done && process && row.getAttribute("aria-expanded") === "true";
|
|
||||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
|
||||||
assert.equal(targetChildrenRequests, firstExpandRequests, "局部 parent refresh 不应绕过 cache 触发 children 重复请求");
|
|
||||||
const selectedAfterRefresh = await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => ({
|
|
||||||
selected: row.getAttribute("data-selected"),
|
|
||||||
focused: row.getAttribute("data-focused"),
|
|
||||||
active: row.getAttribute("data-active"),
|
|
||||||
}));
|
|
||||||
assert.deepEqual(selectedAfterRefresh, {
|
|
||||||
selected: "true",
|
|
||||||
focused: "true",
|
|
||||||
active: "true",
|
|
||||||
}, "局部 parent refresh 后应按 logical state 复投影 selection/focus/active");
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "watch-created.md"), "# Watch\n", "utf8");
|
|
||||||
await page.evaluate(() => {
|
|
||||||
window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", {
|
|
||||||
detail: {
|
|
||||||
payload: {
|
|
||||||
schema: "mnote.local_folder_watch_batch.v1",
|
|
||||||
revision: "smoke-watch-1",
|
|
||||||
affectedParents: [{ relativePath: "design/03-rust-web", reason: "child-watch" }],
|
|
||||||
changedPaths: [{ relativePath: "design/03-rust-web/watch-created.md", kind: "Create(File)" }],
|
|
||||||
eventKinds: ["Create(File)"],
|
|
||||||
fallbackResync: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
await page.waitForFunction((selector) => {
|
|
||||||
const row = document.querySelector(selector);
|
|
||||||
const created = document.querySelector('[data-local-relative-path="design/03-rust-web/watch-created.md"]');
|
|
||||||
return row && created && row.getAttribute("aria-expanded") === "true";
|
|
||||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
|
||||||
assert.equal(
|
|
||||||
await page.locator(rowSelector).getAttribute("aria-expanded"),
|
|
||||||
"true",
|
|
||||||
"watch batch 局部刷新后已展开 parent 不应折叠",
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const root = document.getElementById("sidebar-file-tree-root");
|
|
||||||
const original = Element.prototype.replaceChildren;
|
|
||||||
window.__mnoteRevealReplaceChildrenProbe = {
|
|
||||||
root,
|
|
||||||
original,
|
|
||||||
rootCalls: 0,
|
|
||||||
descendantCalls: [],
|
|
||||||
};
|
|
||||||
Element.prototype.replaceChildren = function(...nodes) {
|
|
||||||
const probe = window.__mnoteRevealReplaceChildrenProbe;
|
|
||||||
if (this === probe.root) {
|
|
||||||
probe.rootCalls += 1;
|
|
||||||
} else if (probe.root?.contains(this)) {
|
|
||||||
probe.descendantCalls.push({
|
|
||||||
className: this instanceof HTMLElement ? this.className : "",
|
|
||||||
relativePath: this.closest(".tree-node")?.querySelector(":scope > .tree-row")?.getAttribute("data-local-relative-path") || "",
|
|
||||||
incomingChildren: nodes.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return probe.original.apply(this, nodes);
|
|
||||||
};
|
|
||||||
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
|
|
||||||
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
|
|
||||||
}));
|
|
||||||
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
|
|
||||||
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
const revealTargetSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/reveal-parent/child/note.md"]';
|
|
||||||
await page.waitForFunction((selector) => {
|
|
||||||
const row = document.querySelector(selector);
|
|
||||||
return row
|
|
||||||
&& row.getAttribute("data-selected") === "true"
|
|
||||||
&& row.getAttribute("data-focused") === "true"
|
|
||||||
&& row.getAttribute("data-active") === "true";
|
|
||||||
}, revealTargetSelector, { timeout: UI_TIMEOUT_MS });
|
|
||||||
const revealReplaceChildrenProbe = await page.evaluate(() => {
|
|
||||||
const probe = window.__mnoteRevealReplaceChildrenProbe;
|
|
||||||
if (!probe) return null;
|
|
||||||
Element.prototype.replaceChildren = probe.original;
|
|
||||||
return {
|
|
||||||
rootCalls: probe.rootCalls,
|
|
||||||
descendantCalls: probe.descendantCalls,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
revealReplaceChildrenProbe?.rootCalls,
|
|
||||||
0,
|
|
||||||
`打开深层页面只能定位 FileTree,不能替换 FileTree 根节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
(revealReplaceChildrenProbe?.descendantCalls || []).every((call) => String(call.className).includes("tree-children")),
|
|
||||||
`打开深层页面不能替换 FileTree 的非局部节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
(revealReplaceChildrenProbe?.descendantCalls || []).length <= 2,
|
|
||||||
`已存在的祖先节点不应在页面打开时被重复重绘: ${JSON.stringify(revealReplaceChildrenProbe)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
const pageTreeTargetDocumentId = "local-md:design~2Freveal-parent~2Fchild~2Fnote.md";
|
|
||||||
const pageTreeTargetSelector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(pageTreeTargetDocumentId)}"]`;
|
|
||||||
await page.locator(pageTreeTargetSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
const pageTreeFileSelectionBeforeOpen = await page.evaluate(() => {
|
|
||||||
const root = document.getElementById("sidebar-file-tree-root");
|
|
||||||
const selected = root?.querySelector('.tree-row[data-selected="true"]');
|
|
||||||
return selected?.getAttribute("data-row-id") || "";
|
|
||||||
});
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
|
||||||
const pageRoot = document.getElementById("sidebar-tree-root");
|
|
||||||
const original = Element.prototype.replaceChildren;
|
|
||||||
window.__mnotePageTreeOpenProbe = {
|
|
||||||
fileRoot,
|
|
||||||
pageRoot,
|
|
||||||
original,
|
|
||||||
fileRootCalls: 0,
|
|
||||||
pageRootCalls: 0,
|
|
||||||
};
|
|
||||||
Element.prototype.replaceChildren = function(...nodes) {
|
|
||||||
const probe = window.__mnotePageTreeOpenProbe;
|
|
||||||
if (this === probe.fileRoot) probe.fileRootCalls += 1;
|
|
||||||
if (this === probe.pageRoot) probe.pageRootCalls += 1;
|
|
||||||
return probe.original.apply(this, nodes);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await page.locator(`${pageTreeTargetSelector} .tree-link`).click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForFunction((documentId) => {
|
|
||||||
const activeTab = document.querySelector('[data-mnote-sidebar-tree-tab="page"][aria-selected="true"]');
|
|
||||||
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"]`);
|
|
||||||
return activeTab && row && row.getAttribute("data-active") === "true";
|
|
||||||
}, pageTreeTargetDocumentId, { timeout: UI_TIMEOUT_MS });
|
|
||||||
const pageTreeOpenProbe = await page.evaluate(() => {
|
|
||||||
const probe = window.__mnotePageTreeOpenProbe;
|
|
||||||
if (!probe) return null;
|
|
||||||
Element.prototype.replaceChildren = probe.original;
|
|
||||||
const selected = probe.fileRoot?.querySelector('.tree-row[data-selected="true"]');
|
|
||||||
return {
|
|
||||||
fileRootCalls: probe.fileRootCalls,
|
|
||||||
pageRootCalls: probe.pageRootCalls,
|
|
||||||
fileTreeSelection: selected?.getAttribute("data-row-id") || "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
pageTreeOpenProbe?.fileRootCalls,
|
|
||||||
0,
|
|
||||||
`页面树打开页面时不得全量重建 FileTree: ${JSON.stringify(pageTreeOpenProbe)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
pageTreeOpenProbe?.pageRootCalls,
|
|
||||||
0,
|
|
||||||
`页面树打开已加载页面时不得全量重建 PageTree: ${JSON.stringify(pageTreeOpenProbe)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
pageTreeOpenProbe?.fileTreeSelection,
|
|
||||||
pageTreeFileSelectionBeforeOpen,
|
|
||||||
`页面树激活时打开页面不得改写 FileTree selection: ${JSON.stringify(pageTreeOpenProbe)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
const slowScopeSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/slow-scope"]';
|
|
||||||
await page.locator(slowScopeSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator(`${slowScopeSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
const docsUrl = new URL(scopedUrl.toString());
|
|
||||||
docsUrl.searchParams.set("fileTreeScope", "docs");
|
|
||||||
await page.goto(docsUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="docs"]').waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
assert.equal(staleScopeChildrenRequests, 1, "慢目录切 scope 前应触发一次 children 请求");
|
|
||||||
const staleRowsInDocsScope = await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path^="design/slow-scope/"]').count();
|
|
||||||
assert.equal(staleRowsInDocsScope, 0, "慢请求返回后不得把旧 design scope children patch 到 docs scope");
|
|
||||||
|
|
||||||
await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
|
|
||||||
|
|
||||||
const openedDocumentId = "local-md:docs~2Fopened-rename.md";
|
|
||||||
const renamedDocumentId = "local-md:docs~2Fopened-renamed.md";
|
|
||||||
const documentUrl = new URL(`${baseUrl}/documents/${encodeURIComponent(openedDocumentId)}`);
|
|
||||||
if (workspaceId) documentUrl.searchParams.set("workspaceId", workspaceId);
|
|
||||||
documentUrl.searchParams.set("sourceKind", "local_folder");
|
|
||||||
documentUrl.searchParams.set("rootUri", rootUri);
|
|
||||||
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
const renameResponse = await context.request.fetch(`${baseUrl}/api/tree/commands`, {
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
action: "rename",
|
|
||||||
workspaceId,
|
|
||||||
sourceKind: "local_folder",
|
|
||||||
rootUri,
|
|
||||||
documentId: openedDocumentId,
|
|
||||||
title: "opened-renamed",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert(renameResponse.ok(), `opened markdown rename command failed: ${renameResponse.status()} ${await renameResponse.text()}`);
|
|
||||||
const bufferStateUrl = new URL(`${baseUrl}/api/documents/buffer-state`);
|
|
||||||
bufferStateUrl.searchParams.set("documentId", renamedDocumentId);
|
|
||||||
if (workspaceId) bufferStateUrl.searchParams.set("workspaceId", workspaceId);
|
|
||||||
bufferStateUrl.searchParams.set("sourceKind", "local_folder");
|
|
||||||
bufferStateUrl.searchParams.set("rootUri", rootUri);
|
|
||||||
bufferStateUrl.searchParams.set("relativePath", "docs/opened-renamed.md");
|
|
||||||
const bufferStateResponse = await context.request.fetch(bufferStateUrl.toString());
|
|
||||||
assert(bufferStateResponse.ok(), `opened markdown rename should rekey buffer: ${bufferStateResponse.status()} ${await bufferStateResponse.text()}`);
|
|
||||||
const bufferStatePayload = await bufferStateResponse.json();
|
|
||||||
assert.equal(bufferStatePayload.result.documentId, renamedDocumentId, "opened markdown rename 后 buffer key 应更新到新 documentId");
|
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
task: TASK,
|
|
||||||
baseUrl,
|
|
||||||
targetChildrenRequests,
|
|
||||||
staleScopeChildrenRequests,
|
|
||||||
cachedExpandMs,
|
|
||||||
}, null, 2));
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
server.kill("SIGINT");
|
|
||||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
|
||||||
if (server.exitCode == null) {
|
|
||||||
await new Promise((resolve) => server.once("exit", resolve));
|
|
||||||
}
|
|
||||||
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
|
|
||||||
process.stderr.write(stderr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const http = require("node:http");
|
|
||||||
const net = require("node:net");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
const { spawn } = require("node:child_process");
|
|
||||||
const { chromium } = require("playwright");
|
|
||||||
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
|
|
||||||
|
|
||||||
const TASK = "task498-starred-page-tree-scope-and-local-edit-smoke";
|
|
||||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
|
|
||||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
|
||||||
|
|
||||||
function resolveChromiumExecutablePath() {
|
|
||||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
|
||||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
||||||
return [
|
|
||||||
"/usr/bin/google-chrome-stable",
|
|
||||||
"/usr/bin/google-chrome",
|
|
||||||
"/snap/bin/chromium",
|
|
||||||
"/usr/bin/chromium",
|
|
||||||
"/usr/bin/chromium-browser",
|
|
||||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPort() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address();
|
|
||||||
const port = address && typeof address === "object" ? address.port : 0;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForHttpOk(url, timeoutMs) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const retry = () => {
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
reject(new Error(`server_not_ready: ${url}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(tick, 250);
|
|
||||||
};
|
|
||||||
const tick = () => {
|
|
||||||
const request = http.get(url, (response) => {
|
|
||||||
response.resume();
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
retry();
|
|
||||||
});
|
|
||||||
request.on("error", retry);
|
|
||||||
request.setTimeout(1000, () => {
|
|
||||||
request.destroy();
|
|
||||||
retry();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileUrlToPath(value) {
|
|
||||||
const url = new URL(value);
|
|
||||||
return decodeURIComponent(url.pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssEscape(value) {
|
|
||||||
return String(value).replace(/["\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setPageRowExpanded(page, nodeId, expanded) {
|
|
||||||
const selector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(nodeId)}"]`;
|
|
||||||
await page.locator(selector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
const current = await page.locator(selector).first().getAttribute("aria-expanded");
|
|
||||||
if ((current === "true") !== expanded) {
|
|
||||||
await page.locator(`${selector} [data-rust-action="toggle"]`).first().click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
}
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ targetNodeId, expected }) => {
|
|
||||||
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(targetNodeId)}"]`);
|
|
||||||
return row && row.getAttribute("aria-expanded") === String(expected);
|
|
||||||
},
|
|
||||||
{ targetNodeId: nodeId, expected: expanded },
|
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signUp(context, baseUrl, actorId) {
|
|
||||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
action: "auth:signIn",
|
|
||||||
args: {
|
|
||||||
provider: "password",
|
|
||||||
params: {
|
|
||||||
email: `${actorId}@example.com`,
|
|
||||||
username: actorId,
|
|
||||||
name: actorId,
|
|
||||||
password: TEST_PASSWORD,
|
|
||||||
flow: "signUp",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert(response.ok(), `测试账号注册失败: ${response.status()} ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDefaultLocalWorkspace(page, baseUrl) {
|
|
||||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
|
||||||
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
||||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const url = new URL(page.url());
|
|
||||||
const rootUri = url.searchParams.get("rootUri")
|
|
||||||
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
|
|
||||||
assert(rootUri, "应进入 local_folder workspace");
|
|
||||||
return {
|
|
||||||
rootUri,
|
|
||||||
workspaceId: url.searchParams.get("workspaceId") || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openLocalDocument(page, baseUrl, rootUri, workspaceId, documentId) {
|
|
||||||
const url = new URL(`/documents/${encodeURIComponent(documentId)}`, baseUrl);
|
|
||||||
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
|
|
||||||
url.searchParams.set("sourceKind", "local_folder");
|
|
||||||
url.searchParams.set("rootUri", rootUri);
|
|
||||||
url.searchParams.set("treeView", "filetree");
|
|
||||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror[contenteditable='true']").first().waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readEditorText(page) {
|
|
||||||
return page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror").first().innerText({
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const port = await pickPort();
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
|
|
||||||
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
|
|
||||||
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
|
|
||||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
|
||||||
cwd: path.join(__dirname, "..", "rust"),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
|
||||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
|
||||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
|
||||||
...controlPlaneEnv,
|
|
||||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
let stderr = "";
|
|
||||||
server.stderr.on("data", (chunk) => {
|
|
||||||
stderr += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
const executablePath = resolveChromiumExecutablePath();
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
...(executablePath ? { executablePath } : {}),
|
|
||||||
});
|
|
||||||
const context = await browser.newContext({ viewport: { width: 1360, height: 920 } });
|
|
||||||
const page = await context.newPage();
|
|
||||||
const diagnostics = [];
|
|
||||||
const viewStateRequests = [];
|
|
||||||
page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`));
|
|
||||||
page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`));
|
|
||||||
page.on("request", (request) => {
|
|
||||||
if (!request.url().includes("/api/tree/view-state")) return;
|
|
||||||
viewStateRequests.push({
|
|
||||||
method: request.method(),
|
|
||||||
url: request.url(),
|
|
||||||
postData: request.postData() || "",
|
|
||||||
at: Date.now(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
page.on("response", async (response) => {
|
|
||||||
if (!response.url().includes("/api/tree/view-state")) return;
|
|
||||||
const request = response.request();
|
|
||||||
const entry = viewStateRequests.findLast((candidate) => candidate.url === request.url() && candidate.method === request.method() && !candidate.status);
|
|
||||||
if (!entry) return;
|
|
||||||
entry.status = response.status();
|
|
||||||
entry.doneAt = Date.now();
|
|
||||||
entry.durationMs = entry.doneAt - entry.at;
|
|
||||||
entry.body = await response.text().catch(() => "");
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
|
||||||
await signUp(context, baseUrl, actorId);
|
|
||||||
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
|
|
||||||
const rootPath = fileUrlToPath(rootUri);
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design"), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n\noriginal\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "Other.md"), "# Other\n\nother\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "Brief.md"), "# Brief\n\nbrief\n", "utf8");
|
|
||||||
|
|
||||||
const fileTreeUrl = new URL(baseUrl);
|
|
||||||
if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId);
|
|
||||||
fileTreeUrl.searchParams.set("sourceKind", "local_folder");
|
|
||||||
fileTreeUrl.searchParams.set("rootUri", rootUri);
|
|
||||||
fileTreeUrl.searchParams.set("treeView", "filetree");
|
|
||||||
await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
const rootPageTreePut = page.waitForResponse((response) => {
|
|
||||||
const postData = response.request().postData() || "";
|
|
||||||
return response.url().includes("/api/tree/view-state")
|
|
||||||
&& response.request().method() === "PUT"
|
|
||||||
&& /"treeKind":"pagetree"/.test(postData)
|
|
||||||
&& /"scope":"root"/.test(postData);
|
|
||||||
}, { timeout: UI_TIMEOUT_MS });
|
|
||||||
await setPageRowExpanded(page, "local-dir:design", false);
|
|
||||||
const rootPageTreeResponse = await rootPageTreePut;
|
|
||||||
assert(rootPageTreeResponse.ok(), `root PageTree view-state 保存失败: ${rootPageTreeResponse.status()}`);
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
const designRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').first();
|
|
||||||
await designRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
await designRow.click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first().waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cloudUrl = new URL(baseUrl);
|
|
||||||
cloudUrl.searchParams.set("workspaceId", "default");
|
|
||||||
await page.goto(cloudUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first();
|
|
||||||
await designShortcut.click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-local-relative-path="design/Brief.md"]').waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const root = document.querySelector("#sidebar-tree-root");
|
|
||||||
return root instanceof HTMLElement && /Brief/.test(root.innerText || "");
|
|
||||||
}, { timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
|
||||||
const state = await page.evaluate(() => {
|
|
||||||
const root = document.querySelector("#sidebar-tree-root");
|
|
||||||
return {
|
|
||||||
href: location.href,
|
|
||||||
pageTreeText: root instanceof HTMLElement ? root.innerText : "",
|
|
||||||
pageTreeHtml: root instanceof HTMLElement ? root.innerHTML.slice(0, 1000) : "",
|
|
||||||
fileTreeText: document.querySelector("#sidebar-file-tree-root")?.textContent || "",
|
|
||||||
fileTreeScope: document.querySelector("#sidebar-file-tree-root")?.getAttribute("data-mnote-filetree-scope") || "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
throw new Error(`${error.message}; state=${JSON.stringify(state)}; diagnostics=${diagnostics.slice(-20).join(" | ")}`);
|
|
||||||
});
|
|
||||||
const pageTreeText = await page.locator("#sidebar-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
|
|
||||||
assert.match(pageTreeText, /Brief/, `星标 design 后页面树应显示 design 内 Markdown: ${pageTreeText}`);
|
|
||||||
assert.doesNotMatch(pageTreeText, /Home|Other/, `星标 design 后页面树不应显示根目录 Markdown: ${pageTreeText}`);
|
|
||||||
assert(
|
|
||||||
viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /"treeKind":"pagetree"/.test(entry.postData) && /"scope":"root"/.test(entry.postData)),
|
|
||||||
`应记录 root PageTree view-state PUT: ${JSON.stringify(viewStateRequests)}`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
viewStateRequests.some((entry) => entry.method === "GET" && entry.status === 200 && entry.url.includes("treeKind=pagetree") && entry.url.includes("scope=design")),
|
|
||||||
`星标 design 应读取 scoped PageTree view-state: ${JSON.stringify(viewStateRequests)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Home.md");
|
|
||||||
const editor = page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror[contenteditable='true']").first();
|
|
||||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
|
||||||
await page.keyboard.press("Backspace");
|
|
||||||
const saveResponsePromise = page.waitForResponse(async (response) => {
|
|
||||||
if (!response.url().includes("/api/page-body/write") || response.request().method() !== "POST") return false;
|
|
||||||
const payload = response.request().postDataJSON();
|
|
||||||
return payload?.documentId === "local-md:Home.md" && response.ok();
|
|
||||||
}, { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.keyboard.type("111", { delay: 10 });
|
|
||||||
await page.waitForFunction(
|
|
||||||
(expected) => (document.querySelector(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror")?.textContent || "").includes(expected),
|
|
||||||
"111",
|
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
const saveResponse = await saveResponsePromise.catch(async (error) => {
|
|
||||||
const state = await page.evaluate(() => ({
|
|
||||||
editorText: document.querySelector(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror")?.textContent || "",
|
|
||||||
status: document.querySelector("[data-runtime-editor-status]")?.getAttribute("data-runtime-editor-status") || "",
|
|
||||||
debugSessions: window.__mnoteDebugDocumentSessions?.snapshot?.() || null,
|
|
||||||
}));
|
|
||||||
throw new Error(`${error.message}; edit_state=${JSON.stringify(state)}; diagnostics=${diagnostics.slice(-20).join(" | ")}`);
|
|
||||||
});
|
|
||||||
assert(saveResponse.ok(), `本地 Markdown 保存失败: ${saveResponse.status()}`);
|
|
||||||
await page.waitForFunction(
|
|
||||||
(expected) => (document.querySelector(".editor-surface .ProseMirror")?.textContent || "").includes(expected),
|
|
||||||
"111",
|
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
assert.match(fs.readFileSync(path.join(rootPath, "Home.md"), "utf8"), /111/, "保存后磁盘 Home.md 应包含 111");
|
|
||||||
|
|
||||||
await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Other.md");
|
|
||||||
await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Home.md");
|
|
||||||
const reloadedText = await readEditorText(page);
|
|
||||||
assert.match(reloadedText, /111/, `切换页面再回 Home.md 后编辑内容应保留: ${reloadedText}`);
|
|
||||||
|
|
||||||
console.log(JSON.stringify({ ok: true, task: TASK, baseUrl, viewStateRequests }, null, 2));
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
server.kill("SIGINT");
|
|
||||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
|
||||||
if (server.exitCode == null) {
|
|
||||||
setTimeout(() => server.kill("SIGKILL"), 2000).unref();
|
|
||||||
}
|
|
||||||
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
|
|
||||||
process.stderr.write(stderr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const http = require("node:http");
|
|
||||||
const net = require("node:net");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
const { spawn } = require("node:child_process");
|
|
||||||
const { chromium } = require("playwright");
|
|
||||||
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
|
|
||||||
|
|
||||||
const TASK = "task499-sidebar-tree-view-state-smoke";
|
|
||||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
|
|
||||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
|
||||||
const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK);
|
|
||||||
|
|
||||||
function resolveChromiumExecutablePath() {
|
|
||||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
|
||||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
||||||
return [
|
|
||||||
"/usr/bin/google-chrome-stable",
|
|
||||||
"/usr/bin/google-chrome",
|
|
||||||
"/snap/bin/chromium",
|
|
||||||
"/usr/bin/chromium",
|
|
||||||
"/usr/bin/chromium-browser",
|
|
||||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPort() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address();
|
|
||||||
const port = address && typeof address === "object" ? address.port : 0;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForHttpOk(url, timeoutMs) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const retry = () => {
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
reject(new Error(`server_not_ready: ${url}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(tick, 250);
|
|
||||||
};
|
|
||||||
const tick = () => {
|
|
||||||
const request = http.get(url, (response) => {
|
|
||||||
response.resume();
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
retry();
|
|
||||||
});
|
|
||||||
request.on("error", retry);
|
|
||||||
request.setTimeout(1000, () => {
|
|
||||||
request.destroy();
|
|
||||||
retry();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileUrlToPath(value) {
|
|
||||||
const url = new URL(value);
|
|
||||||
return decodeURIComponent(url.pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssEscape(value) {
|
|
||||||
return String(value).replace(/["\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signUp(context, baseUrl, actorId) {
|
|
||||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
action: "auth:signIn",
|
|
||||||
args: {
|
|
||||||
provider: "password",
|
|
||||||
params: {
|
|
||||||
email: `${actorId}@example.com`,
|
|
||||||
username: actorId,
|
|
||||||
name: actorId,
|
|
||||||
password: TEST_PASSWORD,
|
|
||||||
flow: "signUp",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert(response.ok(), `测试账号注册失败: ${response.status()} ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDefaultLocalWorkspace(page, baseUrl) {
|
|
||||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
|
||||||
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
||||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const url = new URL(page.url());
|
|
||||||
const rootUri = url.searchParams.get("rootUri")
|
|
||||||
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
|
|
||||||
assert(rootUri, "应进入 local_folder workspace");
|
|
||||||
return {
|
|
||||||
rootUri,
|
|
||||||
workspaceId: url.searchParams.get("workspaceId") || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setFileTreeExpanded(page, relativePath, expanded) {
|
|
||||||
const rowSelector = `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath}"]`;
|
|
||||||
await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
const row = page.locator(rowSelector).first();
|
|
||||||
const current = await row.getAttribute("aria-expanded");
|
|
||||||
if ((current === "true") !== expanded) {
|
|
||||||
await row.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
}
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ selector, expected }) => {
|
|
||||||
const row = document.querySelector(selector);
|
|
||||||
return row && row.getAttribute("aria-expanded") === String(expected);
|
|
||||||
},
|
|
||||||
{ selector: rowSelector, expected: expanded },
|
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setPageRowExpanded(page, nodeId, expanded) {
|
|
||||||
const selector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(nodeId)}"]`;
|
|
||||||
const row = page.locator(selector).first();
|
|
||||||
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
const current = await row.getAttribute("aria-expanded");
|
|
||||||
if ((current === "true") !== expanded) {
|
|
||||||
await row.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
}
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ targetNodeId, expected }) => {
|
|
||||||
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(targetNodeId)}"]`);
|
|
||||||
return row && row.getAttribute("aria-expanded") === String(expected);
|
|
||||||
},
|
|
||||||
{ targetNodeId: nodeId, expected: expanded },
|
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function visibleTreeState(page) {
|
|
||||||
return page.evaluate(() => ({
|
|
||||||
href: location.href,
|
|
||||||
fileTree: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
|
|
||||||
path: row.getAttribute("data-local-relative-path") || "",
|
|
||||||
expanded: row.getAttribute("aria-expanded") || "",
|
|
||||||
text: row.textContent || "",
|
|
||||||
})).filter((row) => row.path),
|
|
||||||
pageTree: Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")).map((row) => ({
|
|
||||||
nodeId: row.getAttribute("data-node-id") || "",
|
|
||||||
expanded: row.getAttribute("aria-expanded") || "",
|
|
||||||
text: row.textContent || "",
|
|
||||||
})),
|
|
||||||
markers: {
|
|
||||||
saved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved") || "",
|
|
||||||
pageApplied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied") || "",
|
|
||||||
loadError: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-load-error") || "",
|
|
||||||
saveError: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-save-error") || "",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
fs.mkdirSync(RESULT_DIR, { recursive: true });
|
|
||||||
const port = await pickPort();
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
|
|
||||||
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
|
|
||||||
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
|
|
||||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
|
||||||
cwd: path.join(__dirname, "..", "rust"),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
|
||||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
|
||||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
|
||||||
...controlPlaneEnv,
|
|
||||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
let stderr = "";
|
|
||||||
server.stderr.on("data", (chunk) => {
|
|
||||||
stderr += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
const executablePath = resolveChromiumExecutablePath();
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
...(executablePath ? { executablePath } : {}),
|
|
||||||
});
|
|
||||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
||||||
const page = await context.newPage();
|
|
||||||
const diagnostics = [];
|
|
||||||
const viewStateRequests = [];
|
|
||||||
const screenshots = [];
|
|
||||||
const timeline = {};
|
|
||||||
page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`));
|
|
||||||
page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`));
|
|
||||||
page.on("request", (request) => {
|
|
||||||
if (request.url().includes("/api/tree/view-state")) {
|
|
||||||
viewStateRequests.push({
|
|
||||||
method: request.method(),
|
|
||||||
url: request.url(),
|
|
||||||
at: Date.now(),
|
|
||||||
postData: request.postData() || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on("response", async (response) => {
|
|
||||||
if (!response.url().includes("/api/tree/view-state")) return;
|
|
||||||
const matching = viewStateRequests.findLast((entry) => entry.url === response.url() && !entry.status);
|
|
||||||
if (!matching) return;
|
|
||||||
matching.status = response.status();
|
|
||||||
matching.doneAt = Date.now();
|
|
||||||
matching.durationMs = matching.doneAt - matching.at;
|
|
||||||
matching.body = await response.text().catch(() => "");
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
|
||||||
await signUp(context, baseUrl, actorId);
|
|
||||||
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
|
|
||||||
const rootPath = fileUrlToPath(rootUri);
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "process"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "reference"), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "05-editor-mainline", "process", "Target.md"), "# Target\n", "utf8");
|
|
||||||
fs.writeFileSync(path.join(rootPath, "design", "05-editor-mainline", "reference", "Ref.md"), "# Ref\n", "utf8");
|
|
||||||
|
|
||||||
const workspaceUrl = new URL(baseUrl);
|
|
||||||
if (workspaceId) workspaceUrl.searchParams.set("workspaceId", workspaceId);
|
|
||||||
workspaceUrl.searchParams.set("sourceKind", "local_folder");
|
|
||||||
workspaceUrl.searchParams.set("rootUri", rootUri);
|
|
||||||
workspaceUrl.searchParams.set("treeView", "filetree");
|
|
||||||
await page.goto(workspaceUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
const designRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').first();
|
|
||||||
await designRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
await designRow.click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first().waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first();
|
|
||||||
timeline.starClickAt = Date.now();
|
|
||||||
await designShortcut.click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-local-relative-path="design/05-editor-mainline"]').waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const root = document.querySelector("#sidebar-tree-root");
|
|
||||||
return root instanceof HTMLElement && /05-editor-mainline|Target|Ref/.test(root.innerText || "");
|
|
||||||
}, { timeout: UI_TIMEOUT_MS });
|
|
||||||
screenshots.push(path.join(RESULT_DIR, "01-after-starred-design.png"));
|
|
||||||
await page.screenshot({ path: screenshots.at(-1), fullPage: true });
|
|
||||||
|
|
||||||
const fileSavePromise = page.waitForResponse((response) => {
|
|
||||||
return response.url().includes("/api/tree/view-state")
|
|
||||||
&& response.request().method() === "PUT"
|
|
||||||
&& /"treeKind":"filetree"/.test(response.request().postData() || "");
|
|
||||||
}, { timeout: UI_TIMEOUT_MS }).catch((error) => error);
|
|
||||||
await setFileTreeExpanded(page, "design/05-editor-mainline", true);
|
|
||||||
await setFileTreeExpanded(page, "design/05-editor-mainline/process", true);
|
|
||||||
const fileSaveResult = await fileSavePromise;
|
|
||||||
if (fileSaveResult instanceof Error) {
|
|
||||||
throw new Error(`未观察到 FileTree view-state PUT: ${fileSaveResult.message}; state=${JSON.stringify(await visibleTreeState(page))}; requests=${JSON.stringify(viewStateRequests)}`);
|
|
||||||
}
|
|
||||||
assert(fileSaveResult.ok(), `FileTree view-state PUT 失败: ${fileSaveResult.status()}`);
|
|
||||||
timeline.fileStateSavedAt = Date.now();
|
|
||||||
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-panel="page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
const pageRows = await page.evaluate(() => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page'][aria-expanded]")).filter((row) => {
|
|
||||||
const rect = row.getBoundingClientRect();
|
|
||||||
const style = window.getComputedStyle(row);
|
|
||||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
||||||
}).map((row) => ({
|
|
||||||
nodeId: row.getAttribute("data-node-id") || "",
|
|
||||||
parentId: row.getAttribute("data-parent-id") || "",
|
|
||||||
expanded: row.getAttribute("aria-expanded") || "",
|
|
||||||
text: row.textContent || "",
|
|
||||||
})).filter((row) => row.nodeId));
|
|
||||||
const siblingPair = pageRows.flatMap((row, index) => {
|
|
||||||
return pageRows.slice(index + 1).filter((candidate) => candidate.parentId === row.parentId).map((candidate) => [row, candidate]);
|
|
||||||
})[0];
|
|
||||||
assert(siblingPair, `PageTree 应至少有两个同级可展开节点: ${JSON.stringify(pageRows)}`);
|
|
||||||
const pageCollapsed = siblingPair[0].nodeId;
|
|
||||||
const pageExpanded = siblingPair[1].nodeId;
|
|
||||||
const pageSavePromise = page.waitForResponse((response) => {
|
|
||||||
return response.url().includes("/api/tree/view-state")
|
|
||||||
&& response.request().method() === "PUT"
|
|
||||||
&& /"treeKind":"pagetree"/.test(response.request().postData() || "");
|
|
||||||
}, { timeout: UI_TIMEOUT_MS }).catch((error) => error);
|
|
||||||
await setPageRowExpanded(page, pageExpanded, false);
|
|
||||||
await setPageRowExpanded(page, pageCollapsed, false);
|
|
||||||
await setPageRowExpanded(page, pageExpanded, true);
|
|
||||||
const pageSaveResult = await pageSavePromise;
|
|
||||||
if (pageSaveResult instanceof Error) {
|
|
||||||
throw new Error(`未观察到 PageTree view-state PUT: ${pageSaveResult.message}; state=${JSON.stringify(await visibleTreeState(page))}; requests=${JSON.stringify(viewStateRequests)}`);
|
|
||||||
}
|
|
||||||
assert(pageSaveResult.ok(), `PageTree view-state PUT 失败: ${pageSaveResult.status()}`);
|
|
||||||
timeline.pageStateSavedAt = Date.now();
|
|
||||||
screenshots.push(path.join(RESULT_DIR, "02-after-state-edits.png"));
|
|
||||||
await page.screenshot({ path: screenshots.at(-1), fullPage: true });
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved") || document.querySelector("#sidebar-file-tree-root"), { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process"]');
|
|
||||||
return row && row.getAttribute("aria-expanded") === "true";
|
|
||||||
}, { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ pageCollapsed, pageExpanded }) => {
|
|
||||||
const collapsed = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(pageCollapsed)}"]`);
|
|
||||||
const expanded = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(pageExpanded)}"]`);
|
|
||||||
return collapsed && expanded
|
|
||||||
&& collapsed.getAttribute("aria-expanded") === "false"
|
|
||||||
&& expanded.getAttribute("aria-expanded") === "true";
|
|
||||||
},
|
|
||||||
{ pageCollapsed, pageExpanded },
|
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
timeline.reloadedStateRestoredAt = Date.now();
|
|
||||||
|
|
||||||
const targetRow = page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process/Target.md"]').first();
|
|
||||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await targetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
await targetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => location.pathname.startsWith("/documents/")
|
|
||||||
&& decodeURIComponent(location.pathname).includes("Target.md")
|
|
||||||
&& new URLSearchParams(location.search).get("fileTreeScope") === "design",
|
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
await page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror").first().waitFor({
|
|
||||||
state: "visible",
|
|
||||||
timeout: UI_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
await page.goBack({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }).catch(() => page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }));
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process"]');
|
|
||||||
return row && row.getAttribute("aria-expanded") === "true";
|
|
||||||
}, { timeout: UI_TIMEOUT_MS });
|
|
||||||
timeline.afterNavigationRestoredAt = Date.now();
|
|
||||||
screenshots.push(path.join(RESULT_DIR, "03-after-reload-and-navigation.png"));
|
|
||||||
await page.screenshot({ path: screenshots.at(-1), fullPage: true });
|
|
||||||
|
|
||||||
const finalState = await visibleTreeState(page);
|
|
||||||
assert.equal(
|
|
||||||
finalState.fileTree.find((row) => row.path === "design/05-editor-mainline")?.expanded,
|
|
||||||
"true",
|
|
||||||
`FileTree 父节点应恢复展开: ${JSON.stringify(finalState.fileTree)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
finalState.fileTree.find((row) => row.path === "design/05-editor-mainline/process")?.expanded,
|
|
||||||
"true",
|
|
||||||
`FileTree process 节点应恢复展开: ${JSON.stringify(finalState.fileTree)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
finalState.pageTree.find((row) => row.nodeId === pageCollapsed)?.expanded,
|
|
||||||
"false",
|
|
||||||
`PageTree 折叠态应恢复: ${JSON.stringify(finalState.pageTree)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
finalState.pageTree.find((row) => row.nodeId === pageExpanded)?.expanded,
|
|
||||||
"true",
|
|
||||||
`PageTree 展开态应恢复: ${JSON.stringify(finalState.pageTree)}`,
|
|
||||||
);
|
|
||||||
assert(viewStateRequests.some((entry) => entry.method === "GET" && entry.status === 200), "应记录 view-state GET");
|
|
||||||
assert(viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /filetree/.test(entry.postData)), "应记录 FileTree view-state PUT");
|
|
||||||
assert(viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /pagetree/.test(entry.postData)), "应记录 PageTree view-state PUT");
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
ok: true,
|
|
||||||
task: TASK,
|
|
||||||
baseUrl,
|
|
||||||
rootUri,
|
|
||||||
workspaceId,
|
|
||||||
pageCollapsed,
|
|
||||||
pageExpanded,
|
|
||||||
timeline,
|
|
||||||
viewStateRequests,
|
|
||||||
finalState,
|
|
||||||
screenshots,
|
|
||||||
};
|
|
||||||
fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(result, null, 2));
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
} catch (error) {
|
|
||||||
const failureState = await visibleTreeState(page).catch(() => null);
|
|
||||||
const failureScreenshot = path.join(RESULT_DIR, "99-failure.png");
|
|
||||||
await page.screenshot({ path: failureScreenshot, fullPage: true }).catch(() => {});
|
|
||||||
const result = {
|
|
||||||
ok: false,
|
|
||||||
task: TASK,
|
|
||||||
error: error && error.stack ? error.stack : String(error),
|
|
||||||
diagnostics: diagnostics.slice(-30),
|
|
||||||
viewStateRequests,
|
|
||||||
failureState,
|
|
||||||
screenshot: failureScreenshot,
|
|
||||||
stderr: stderr.slice(-8000),
|
|
||||||
};
|
|
||||||
fs.writeFileSync(path.join(RESULT_DIR, "failure.json"), JSON.stringify(result, null, 2));
|
|
||||||
console.error(JSON.stringify(result, null, 2));
|
|
||||||
process.exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
await browser.close().catch(() => {});
|
|
||||||
server.kill("SIGTERM");
|
|
||||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
+47
-34
@@ -1,53 +1,66 @@
|
|||||||
---
|
---
|
||||||
name: mnote-vault
|
name: mnote-vault
|
||||||
description: >
|
description: >
|
||||||
MNote AI 密码本(多 agent 唯一策略)。登录优先 mnote.vault.login(复用/刷新 session);
|
MNote AI 密码本(多 agent 唯一策略)。读密与 login/session:mnote-vault CLI + capability token(不依赖 mnote-web)。
|
||||||
取密 mnote.vault.resolve;禁扫 .mnote/vault 文件。Cloudflare 时人机验证后 session 回写。
|
禁扫 .mnote/vault 文件。
|
||||||
---
|
---
|
||||||
|
|
||||||
# MNote 密码箱 / AI 密码本
|
# MNote 密码箱 / AI 密码本
|
||||||
|
|
||||||
**SSOT**:`/home/lix/.agent-infra/vault-policy.md`
|
**SSOT**:`/home/lix/.agent-infra/vault-policy.md`
|
||||||
|
**设计**:`design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`
|
||||||
|
|
||||||
## 稳态:登录(目标约 3 次 shell:鉴权 + list? + login×2)
|
## 读密稳态(P0 · 不依赖 mnote-web)
|
||||||
|
|
||||||
```
|
二进制:`rust/target/debug/mnote-vault`(或 `cargo run -p mnote-vault --`)。
|
||||||
# 本机 mnote-web 鉴权(一次性,非 vault)
|
|
||||||
eval $(node scripts/mnote-vault-cli.js auth-e2e)
|
|
||||||
|
|
||||||
# 可选 list 找 id;已知 id 则跳过
|
```bash
|
||||||
node scripts/mnote-vault-cli.js list
|
# 一次(人 / 安装):签发 token
|
||||||
|
cargo run -q -p mnote-vault --manifest-path rust/Cargo.toml -- issue-token --agent paseo --ttl 90d
|
||||||
|
|
||||||
# 核心:复用 session / 登录并保存
|
# Agent 每次(已知 id)
|
||||||
node scripts/mnote-vault-cli.js login --id <id>
|
export MNOTE_VAULT_TOKEN_FILE="${MNOTE_VAULT_TOKEN_FILE:-$HOME/.config/mnote/vault-tokens/default.token}"
|
||||||
# 再调一次应 reused=true
|
mnote-vault list
|
||||||
|
mnote-vault resolve --id <id> --field password
|
||||||
# Cloudflare:人验证后
|
# 管道只要明文:
|
||||||
node scripts/mnote-vault-cli.js session --id <id> --cookie '...'
|
mnote-vault resolve --id <id> --field password --raw
|
||||||
```
|
```
|
||||||
|
|
||||||
Pi:`mnote.vault.login` / `session` 同语义。
|
| 命令 | 用途 |
|
||||||
|
|
||||||
**禁止**每次:读 policy、猜 /api/auth 路径、list+resolve+多次 chrome 试错。
|
|
||||||
|
|
||||||
| 工具 | 用途 |
|
|
||||||
|------|------|
|
|------|------|
|
||||||
| `mnote.vault.login` | **首选**:复用/刷新登录态 |
|
| `doctor` | workspace / token / hmac 状态(无明文) |
|
||||||
| `mnote.vault.session` | 人机验证后回写 Cookie |
|
| `issue-token` | 签发 capability token(**不是** master key) |
|
||||||
| `mnote.vault.list` / `get` | 选型;L0 无密文 |
|
| `whoami` | 当前 token claims |
|
||||||
| `mnote.vault.resolve` | 仅当需要密码本身(非登录会话) |
|
| `list` / `get` | L0 选型;无密码明文 |
|
||||||
|
| `resolve` | **唯一取密**;审计无 value |
|
||||||
|
| `login` | 复用未过期 session,或 api_first 出站登录并写回 session |
|
||||||
|
| `session` | 浏览器/人机验证后回写 Cookie(不启 3000) |
|
||||||
|
| `serve` | 可选 UDS vaultd;CLI 默认 sock→内嵌 fallback |
|
||||||
|
|
||||||
HTTP/CLI 同语义:`/api/vault/ai/items/{id}/login|session`、`scripts/mnote-vault-cli.js login|session`。
|
**禁止**:为读密 / login / session 去 `auth-e2e` / 起 3000 / 用 cookie 调 `/api/vault/ai/*`。
|
||||||
|
`scripts/mnote-vault-cli.js` 的 web 路径仅调试 UI。
|
||||||
|
|
||||||
|
## 登录稳态(P1d · 同样不依赖 mnote-web)
|
||||||
|
|
||||||
|
需要外站 cookie 时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 优先复用已有 session(无 HTTP 出站)
|
||||||
|
mnote-vault login --id <id>
|
||||||
|
|
||||||
|
# human_required / CF:chrome-bridge 登录后回写
|
||||||
|
mnote-vault session --id <id> --cookie-header 'name=value; …' --source human_bridge
|
||||||
|
|
||||||
|
# 强制重新 api_first 登录(忽略未过期 session)
|
||||||
|
mnote-vault login --id <id> --force-refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
- `resolve` / `session` 写盘:本地 `.mnote/vault/**`(core)。
|
||||||
|
- `login` 的 `api_first` 仅对目标站出站 HTTP;**不**经 mnote-web。
|
||||||
|
- Cloudflare / captcha → `mode=human_required` + `humanInstructions.cli`,完成后 `session` 回写。
|
||||||
|
|
||||||
## 硬规则
|
## 硬规则
|
||||||
|
|
||||||
1. 禁 file/local_file 扫 `.mnote/vault/**`
|
1. 禁 file 扫 `.mnote/vault/**`
|
||||||
2. 聊天不贴 password/cookieHeader
|
2. 聊天不贴 password / cookie / token
|
||||||
3. 长期态存在 **AI 密码本条目**(session + playbook),不另建文件池
|
3. Token `0600`;故障先 `issue-token`,不要默认起 3000
|
||||||
4. 人机验证:本机 **chrome-bridge** / 远程 **Paseo 浏览器** → `session` 回写;过期再验证
|
|
||||||
|
|
||||||
## 工作流
|
|
||||||
|
|
||||||
1. 用户共享条目到 AI 本(自动带默认 playbook:email 优先 + api_first)
|
|
||||||
2. Agent:`login(id)`
|
|
||||||
3. 用返回 cookie 访问站点;或 human_required 后等人回写再 `login`
|
|
||||||
|
|||||||
Reference in New Issue
Block a user