feat: pi-siyuan v0.1.0 SiYuan extension with RWD permission auditing
- 15 siyuan-mcp-compatible tools + siyuan_discover progressive-disclosure loader - Notebook-level R/W/D/NONE permission audit matrix (SQL box lookup, global rules) - New-notebook auto-sync to config.json (default R) - env-first connection config with config.json fallback - 3 self-check suites (pure-function / integration / extension e2e)
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# pi-lens 索引缓存
|
||||||
|
.codegraph/
|
||||||
|
.pi-lens/
|
||||||
|
|
||||||
|
# 参考仓库的调研材料(本地工作文件,不发布)
|
||||||
|
siyuan-mcp-README-reference.md
|
||||||
|
*-reference*.md
|
||||||
|
|
||||||
|
# 测试临时文件
|
||||||
|
/tmp/
|
||||||
|
*.tmp
|
||||||
|
pi-siyuan-audit-test-config.json
|
||||||
|
|
||||||
|
# 环境与密钥(config.json 含 token,用户本地生成,绝不提交)
|
||||||
|
config.json
|
||||||
|
*.local.json
|
||||||
|
|
||||||
|
# 系统
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
基于 MCP 仓库 <https://github.com/porkll/siyuan-mcp> 实现 pi extension pi-siyuan(该仓库仅作参考:工具集划分、参数设计、SiYuan API 调用方式照它学;所有功能在本 extension 内自行实现,不引入其 npm 包)
|
||||||
|
|
||||||
|
~/.zshenv 中已经声明了 SIYUAN_API_URL, SIYUAN_TOKEN 环境变量
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1、实现环境变量和配置文件定义上述2个可用连接信息
|
||||||
|
2、实现笔记本级的 RWD 分开定义,三个权限可自由组合;另支持单独的 "NONE":表示该笔记本禁止一切操作(不计入任何“任一 R/W/D”放行集合,也永远不满足“所有”类要求)
|
||||||
|
3、笔记本和任何操作需要先审计上述权限是否满足要求,否则拒绝执行
|
||||||
|
4、新的笔记本未定义权限默认只有R权限,并把笔记本ID和名称(仅做为humanread)补充到配置文件中。
|
||||||
|
|
||||||
|
补充规则:
|
||||||
|
|
||||||
|
5、架构:所有功能自行实现,extension 内直接封装 SiYuan HTTP API(/api/*,带 Authorization: Token 头),外面包一层权限审计。参考 porkll/siyuan-mcp 的工具划分与参数设计,但不依赖它的代码,也不起 MCP 子进程。
|
||||||
|
|
||||||
|
6、连接配置:环境变量 SIYUAN_API_URL / SIYUAN_TOKEN 优先;缺失时回落到配置文件。配置文件路径 `~/.pi/agent/extensions/pi-siyuan/config.json`,结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiUrl": "http://127.0.0.1:6806",
|
||||||
|
"token": "",
|
||||||
|
"notebooks": [
|
||||||
|
{ "id": "20260101120000-abcdef", "name": "个人笔记", "permissions": ["R", "W"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
permissions 取值为 R / W / D 的自由组合,或仅为 ["NONE"]。name 仅做 human-readable,一切逻辑按 id 匹配。
|
||||||
|
|
||||||
|
7、审计豁免:`list_notebooks` 及连通性检查不做权限审计(审计依赖笔记本列表本身,且这是发现新笔记本的入口)。审计失败与拒绝时,错误消息必须写明:目标笔记本(id+name)、需要的权限、缺失的权限、被拒绝的操作名。
|
||||||
|
|
||||||
|
8、按笔记本审计的工具:入参是 docId/blockId 的工具(get_document_content / append_to_document / update_document / move_documents 的 from 侧),审计前先通过 SiYuan `/api/query` SQL 反查该块所属笔记本(查 `blocks` 表的 `box` 字段),再查该笔记本权限。docId 反查失败 = 拒绝。
|
||||||
|
|
||||||
|
9、跨笔记本全局工具的审计规则:
|
||||||
|
|
||||||
|
- 全局读(unified_search / list_all_tags / list_snapshots / get_recently_updated_documents / get_document_tree 按 to_notebook_root 归入目标笔记本审计):存在任一笔记本有 R 即放行(NONE 笔记本不算 R)
|
||||||
|
- batch_replace_tag:所有笔记本都配置了 W 才放行(任一 NONE 或非 W 笔记本存在即拒绝)
|
||||||
|
- create_snapshot:存在任一 R 即放行(只读备份,不破坏数据)
|
||||||
|
- rollback_to_snapshot:所有笔记本都配置了 D 才放行(回滚覆盖全库;任一 NONE 或非 D 笔记本存在即拒绝)
|
||||||
|
- D 权限的语义 = 破坏性/不可逆操作:rollback、move_documents 移出源笔记本、未来接入的删除类 API。move_documents 需要 from 笔记本有 D + to 笔记本有 W。
|
||||||
|
|
||||||
|
10、新笔记本同步:每次 list_notebooks 调用时,把 API 返回的笔记本列表与配置文件比对——未记录的笔记本补写 `{id, name, permissions: ["R"]}`;已记录但 name 变了的更新 name。落盘后放行。
|
||||||
|
|
||||||
|
11、把 15 个工具全部映射进 extension,工具名与 siyuan-mcp 保持一致:unified_search、get_document_content、create_document、append_to_document、update_document、move_documents、get_document_tree、append_to_daily_note、list_notebooks、get_recently_updated_documents、create_snapshot、list_snapshots、rollback_to_snapshot、list_all_tags、batch_replace_tag。
|
||||||
|
|
||||||
|
12、留一个审计逻辑的最小自检(纯函数测试权限矩阵判定即可,不需要 mock HTTP)。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 rogee
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# pi-siyuan
|
||||||
|
|
||||||
|
[pi coding agent](https://github.com/earendil-works/pi-coding-agent) 的 [SiYuan Note](https://b3log.org/siyuan) 扩展:直接封装 SiYuan HTTP API(`/api/*` + `Token` 认证),外面包一层**笔记本级 R/W/D 权限审计**。工具划分与参数设计参考 [porkll/siyuan-mcp](https://github.com/porkll/siyuan-mcp),不依赖其代码,也不起 MCP 子进程。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- **15 个工具**,与 siyuan-mcp 同名:`unified_search`、`get_document_content`、`create_document`、`append_to_document`、`update_document`、`move_documents`、`get_document_tree`、`append_to_daily_note`、`list_notebooks`、`get_recently_updated_documents`、`create_snapshot`、`list_snapshots`、`rollback_to_snapshot`、`list_all_tags`、`batch_replace_tag`
|
||||||
|
- **渐进式披露**:初始仅激活一个 `siyuan_discover` loader 工具(含连通性检查),其余 15 个工具按需增量加载,保持系统提示词前缀稳定
|
||||||
|
- **笔记本级 RWD 权限审计**:R 读 / W 写 / D 破坏性(rollback、move 移出、删除类),可自由组合;`NONE` = 禁止一切操作
|
||||||
|
- 拒绝消息包含:目标笔记本(id+name)、需要的权限、缺失的权限、被拒绝的操作名
|
||||||
|
- 新笔记本自动以 `R` 权限同步进配置文件(发现入口 `list_notebooks`,审计豁免)
|
||||||
|
- 连接信息:环境变量优先,配置文件兜底
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
### 方式一:pi install(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install git:git.ipao.vip:rogee/pi-siyuan
|
||||||
|
# 或 HTTPS:
|
||||||
|
pi install https://git.ipao.vip/rogee/pi-siyuan
|
||||||
|
```
|
||||||
|
|
||||||
|
试用不落盘:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi -e git:git.ipao.vip:rogee/pi-siyuan
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:手动放置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.ipao.vip:rogee/pi-siyuan.git ~/.pi/agent/extensions/pi-siyuan
|
||||||
|
# src/ 内含 index.ts,pi 会自动加载 extensions/*/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
### 连接(环境变量优先)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ~/.zshenv
|
||||||
|
export SIYUAN_API_URL="https://your-siyuan-host" # 默认 http://127.0.0.1:6806
|
||||||
|
export SIYUAN_TOKEN="your-api-token" # SiYuan 设置 → 关于 → API token
|
||||||
|
```
|
||||||
|
|
||||||
|
缺失时回落到 `~/.pi/agent/extensions/pi-siyuan/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiUrl": "https://siyuan.example.com",
|
||||||
|
"token": "your-token",
|
||||||
|
"notebooks": [
|
||||||
|
{ "id": "20260101120000-abcdef", "name": "个人笔记", "permissions": ["R", "W"] },
|
||||||
|
{ "id": "20260101120001-bcdefa", "name": "日记", "permissions": ["R", "W", "D"] },
|
||||||
|
{ "id": "20260101120002-cdefab", "name": "敏感库", "permissions": ["NONE"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `permissions`:`R`/`W`/`D` 自由组合,或 `["NONE"]`(禁止一切操作)
|
||||||
|
- `name` 仅做 human-readable,一切逻辑按 `id` 匹配
|
||||||
|
- 首次调用 `list_notebooks` 会把未记录的笔记本以 `["R"]` 补写进该文件
|
||||||
|
|
||||||
|
### 权限规则速查
|
||||||
|
|
||||||
|
| 操作 | 审计规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 读类(get_document_content 等) | 目标笔记本 `R` |
|
||||||
|
| 写类(create/append/update/daily note) | 目标笔记本 `W` |
|
||||||
|
| move_documents | 源笔记本 `D` + 目标笔记本 `W` |
|
||||||
|
| rollback_to_snapshot | **所有**笔记本 `D`(回滚覆盖全库) |
|
||||||
|
| batch_replace_tag | **所有**笔记本 `W` |
|
||||||
|
| create_snapshot | 任一笔记本 `R`(只读备份) |
|
||||||
|
| unified_search / list_all_tags / list_snapshots / 最近更新 / 文档树 | 任一笔记本 `R` |
|
||||||
|
| list_notebooks / 连通性检查 | 审计豁免 |
|
||||||
|
|
||||||
|
`NONE` 笔记本不计入任何"任一 R/W/D"放行集合,也永不满足"所有"类要求。
|
||||||
|
|
||||||
|
## 工具用法
|
||||||
|
|
||||||
|
会话中说「操作 SiYuan」时,先经过 `siyuan_discover` 按需加载工具:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> 用 siyuan_discover 加载 search 工具,然后搜索「Agnes」
|
||||||
|
✅ SiYuan 3.8.2 连接正常。已加载工具:unified_search
|
||||||
|
```
|
||||||
|
|
||||||
|
读取 `NONE` 笔记本内的文档会被审计拒绝:
|
||||||
|
|
||||||
|
```text
|
||||||
|
❌ 权限拒绝:笔记本 20260804180250-4kuh8xy(05-Vault) 执行「get_document_content」需要 R,缺失 R
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.ipao.vip:rogee/pi-siyuan.git && cd pi-siyuan
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 权限矩阵纯函数自检
|
||||||
|
node --experimental-strip-types src/audit.test.ts
|
||||||
|
|
||||||
|
# 真实 API 集成自检(需要 SIYUAN_API_URL/SIYUAN_TOKEN)
|
||||||
|
SIYUAN_API_URL=… SIYUAN_TOKEN=… node --experimental-strip-types test/integration.test.ts
|
||||||
|
|
||||||
|
# 扩展加载 + 渐进披露端到端自检
|
||||||
|
SIYUAN_API_URL=… SIYUAN_TOKEN=… node --experimental-strip-types test/extension.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知限制(针对部分反代部署)
|
||||||
|
|
||||||
|
某些反代会吞掉部分 SiYuan 端点的响应体(`/api/query/query`、`/api/search/fulltextSearchBlock`、`/api/repo/listSnapshots`、`/api/tag/replaceTag`、`/api/dailynote/*` 等)。本扩展已全部改用等价可用端点:
|
||||||
|
|
||||||
|
- SQL 查询用 `/api/query/sql`(搜索用 SQL `content LIKE` 替代 fulltext)
|
||||||
|
- 快照列表用 `/api/repo/getRepoSnapshots`
|
||||||
|
- 标签替换用 `/api/tag/renameTag`(暂不支持删除标签)
|
||||||
|
- 日记用 `createDocWithMd` 到 `/YYYY-MM-DD` 幂等创建
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
Generated
+1920
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-siyuan",
|
||||||
|
"private": false,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"typebox": "1.3.7"
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "SiYuan Note extension for pi coding agent \u2014 HTTP API wrapper with notebook-level RWD permission auditing",
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"pi-extension",
|
||||||
|
"siyuan",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.ipao.vip/rogee/pi-siyuan.git"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./src"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "0.84.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 审计逻辑最小自检(AGENTS.md 第 12 条):纯函数权限矩阵判定
|
||||||
|
* 运行:node src/audit.test.mjs(或 node --experimental-strip-types)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert";
|
||||||
|
import {
|
||||||
|
permsOf,
|
||||||
|
auditNotebook,
|
||||||
|
auditAny,
|
||||||
|
auditAll,
|
||||||
|
denyMessage,
|
||||||
|
syncNotebooks,
|
||||||
|
} from "./audit.ts";
|
||||||
|
|
||||||
|
const nb = (id: string, permissions: string[], name = id) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- permsOf ----
|
||||||
|
assert.deepEqual([...permsOf(nb("a", ["R", "W"]))].sort(), ["R", "W"]);
|
||||||
|
assert.deepEqual([...permsOf(nb("a", ["NONE"]))], [], "NONE = 空权限集");
|
||||||
|
assert.deepEqual(
|
||||||
|
[...permsOf(nb("a", ["NONE", "R"]))],
|
||||||
|
[],
|
||||||
|
"NONE 混入其他值仍为空",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...permsOf(nb("a", ["R", "X", "W"]))].sort(),
|
||||||
|
["R", "W"],
|
||||||
|
"非法值被过滤",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- auditNotebook ----
|
||||||
|
assert.equal(auditNotebook([nb("a", ["R", "W"])], "a", ["R"]).ok, true);
|
||||||
|
assert.equal(auditNotebook([nb("a", ["R"])], "a", ["W"]).ok, false);
|
||||||
|
assert.deepEqual(auditNotebook([nb("a", ["R"])], "a", ["W", "D"]).missing, [
|
||||||
|
"W",
|
||||||
|
"D",
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
auditNotebook([nb("a", ["NONE"])], "a", ["R"]).ok,
|
||||||
|
false,
|
||||||
|
"NONE 不满足 R",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
auditNotebook([nb("a", ["R"])], "unknown", ["R"]).ok,
|
||||||
|
false,
|
||||||
|
"未知笔记本拒绝",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- auditAny / auditAll ----
|
||||||
|
assert.equal(auditAny([nb("a", ["NONE"]), nb("b", ["R"])], "R"), true);
|
||||||
|
assert.equal(auditAny([nb("a", ["NONE"])], "R"), false, "NONE 不算 R");
|
||||||
|
assert.equal(auditAny([], "R"), false);
|
||||||
|
assert.equal(auditAll([nb("a", ["W"]), nb("b", ["W"])], "W"), true);
|
||||||
|
assert.equal(
|
||||||
|
auditAll([nb("a", ["W"]), nb("b", ["NONE"])], "W"),
|
||||||
|
false,
|
||||||
|
"任一 NONE 拒绝",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
auditAll([nb("a", ["W"]), nb("b", ["R", "D"])], "W"),
|
||||||
|
false,
|
||||||
|
"缺 W 拒绝",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
auditAll([nb("a", ["W"]), nb("b", ["R", "W", "D"])], "W"),
|
||||||
|
true,
|
||||||
|
"R/W/D 含 W 满足「所有 W」",
|
||||||
|
);
|
||||||
|
assert.equal(auditAll([], "W"), false, "空列表不满足「所有」");
|
||||||
|
|
||||||
|
// ---- denyMessage 格式(第 7 条:id+name、需要的、缺失的、操作名) ----
|
||||||
|
const msg = denyMessage(
|
||||||
|
nb("20260804180250-4kuh8xy", ["NONE"], "05-Vault"),
|
||||||
|
["R"],
|
||||||
|
["R"],
|
||||||
|
"get_document_content",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
msg.includes("20260804180250-4kuh8xy") && msg.includes("05-Vault"),
|
||||||
|
"含 id+name",
|
||||||
|
);
|
||||||
|
assert.ok(msg.includes("get_document_content"), "含操作名");
|
||||||
|
assert.ok(/需要 R/.test(msg) && /缺失 R/.test(msg), "含需要/缺失权限");
|
||||||
|
|
||||||
|
// ---- syncNotebooks(第 10 条)— 用临时路径,不污染真实配置 ----
|
||||||
|
const cfg: any = {
|
||||||
|
apiUrl: "u",
|
||||||
|
token: "t",
|
||||||
|
notebooks: [nb("a", ["R", "W"], "旧名")],
|
||||||
|
};
|
||||||
|
const tmpPath = "/tmp/pi-siyuan-audit-test-config.json";
|
||||||
|
const dirty = syncNotebooks(
|
||||||
|
cfg,
|
||||||
|
[nb("a", ["R", "W"], "新名"), nb("new1", [], "新笔记本")],
|
||||||
|
tmpPath,
|
||||||
|
);
|
||||||
|
assert.equal(dirty, true);
|
||||||
|
assert.equal(
|
||||||
|
cfg.notebooks.find((n: any) => n.id === "a").name,
|
||||||
|
"新名",
|
||||||
|
"name 变了更新",
|
||||||
|
);
|
||||||
|
const added = cfg.notebooks.find((n: any) => n.id === "new1");
|
||||||
|
assert.deepEqual(added.permissions, ["R"], "新笔记本默认 R");
|
||||||
|
assert.equal(added.name, "新笔记本");
|
||||||
|
|
||||||
|
console.log("audit self-check: all assertions passed ✅");
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* 权限审计(纯函数部分 + 配置管理)
|
||||||
|
* 规则来源:AGENTS.md 第 6/7/8/9/10 条
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { SiYuanClient } from "./siyuan-client.ts";
|
||||||
|
|
||||||
|
export type Perm = "R" | "W" | "D";
|
||||||
|
|
||||||
|
export interface NotebookConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: string[]; // ("R"|"W"|"D")[] 或 ["NONE"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiSiyuanConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
token: string;
|
||||||
|
notebooks: NotebookConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONFIG_PATH = join(
|
||||||
|
homedir(),
|
||||||
|
".pi/agent/extensions/pi-siyuan/config.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
export class AuditError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 纯函数:权限矩阵判定(自检目标) ----------
|
||||||
|
|
||||||
|
export function permsOf(nb: NotebookConfig): Set<Perm> {
|
||||||
|
if (nb.permissions.includes("NONE")) return new Set();
|
||||||
|
return new Set(
|
||||||
|
nb.permissions.filter((p): p is Perm => p === "R" || p === "W" || p === "D"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按笔记本审计:目标笔记本需要 required 权限 */
|
||||||
|
export function auditNotebook(
|
||||||
|
notebooks: NotebookConfig[],
|
||||||
|
notebookId: string,
|
||||||
|
required: Perm[],
|
||||||
|
): { ok: boolean; nb?: NotebookConfig; missing: Perm[] } {
|
||||||
|
const nb = notebooks.find((n) => n.id === notebookId);
|
||||||
|
if (!nb) return { ok: false, missing: required };
|
||||||
|
const perms = permsOf(nb);
|
||||||
|
const missing = required.filter((p) => !perms.has(p));
|
||||||
|
return { ok: missing.length === 0, nb, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局"任一"审计:存在任一笔记本有 perm 即放行(NONE 不算) */
|
||||||
|
export function auditAny(notebooks: NotebookConfig[], perm: Perm): boolean {
|
||||||
|
return notebooks.some((n) => permsOf(n).has(perm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局"所有"审计:所有笔记本都有 perm 才放行(NONE 永不满足) */
|
||||||
|
export function auditAll(notebooks: NotebookConfig[], perm: Perm): boolean {
|
||||||
|
return notebooks.length > 0 && notebooks.every((n) => permsOf(n).has(perm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝消息(AGENTS.md 第 7 条格式要求) */
|
||||||
|
export function denyMessage(
|
||||||
|
nb: NotebookConfig | undefined,
|
||||||
|
required: Perm[],
|
||||||
|
missing: Perm[],
|
||||||
|
operation: string,
|
||||||
|
): string {
|
||||||
|
const target = nb ? `笔记本 ${nb.id}(${nb.name})` : `未知笔记本`;
|
||||||
|
return `权限拒绝:${target} 执行「${operation}」需要 ${required.join("/") || "(无)"},缺失 ${missing.join("/") || "(无)"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 配置加载 / 新笔记本同步 ----------
|
||||||
|
|
||||||
|
export function loadConfig(cfgPath: string = CONFIG_PATH): PiSiyuanConfig {
|
||||||
|
if (existsSync(cfgPath)) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(cfgPath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
// 损坏则重建
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { apiUrl: "", token: "", notebooks: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConnection(): { apiUrl: string; token: string } {
|
||||||
|
const envUrl = process.env.SIYUAN_API_URL;
|
||||||
|
const envToken = process.env.SIYUAN_TOKEN;
|
||||||
|
const cfg = loadConfig();
|
||||||
|
return {
|
||||||
|
apiUrl: envUrl || cfg.apiUrl || "http://127.0.0.1:6806",
|
||||||
|
token: envToken || cfg.token || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新笔记本同步(第 10 条):补 {id,name,permissions:["R"]};name 变了更新。返回是否落盘 */
|
||||||
|
export function syncNotebooks(
|
||||||
|
cfg: PiSiyuanConfig,
|
||||||
|
apiNotebooks: Array<{ id: string; name: string }>,
|
||||||
|
cfgPath: string = CONFIG_PATH,
|
||||||
|
): boolean {
|
||||||
|
let dirty = false;
|
||||||
|
for (const nb of apiNotebooks) {
|
||||||
|
const existing = cfg.notebooks.find((n) => n.id === nb.id);
|
||||||
|
if (!existing) {
|
||||||
|
cfg.notebooks.push({ id: nb.id, name: nb.name, permissions: ["R"] });
|
||||||
|
dirty = true;
|
||||||
|
} else if (existing.name !== nb.name) {
|
||||||
|
existing.name = nb.name;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dirty) saveConfig(cfg, cfgPath);
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(cfg: PiSiyuanConfig, cfgPath: string = CONFIG_PATH) {
|
||||||
|
mkdirSync(join(homedir(), ".pi/agent/extensions/pi-siyuan"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 审计入口(组合纯函数 + docId 反查) ----------
|
||||||
|
|
||||||
|
export class Auditor {
|
||||||
|
private client: SiYuanClient;
|
||||||
|
private cfg: PiSiyuanConfig;
|
||||||
|
|
||||||
|
constructor(client: SiYuanClient, cfg: PiSiyuanConfig) {
|
||||||
|
this.client = client;
|
||||||
|
this.cfg = cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** docId/blockId → 笔记本 → 权限(第 8 条)。反查失败 = 拒绝 */
|
||||||
|
async auditByBlockId(
|
||||||
|
blockId: string,
|
||||||
|
required: Perm[],
|
||||||
|
operation: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const box = await this.client.boxOf(blockId);
|
||||||
|
if (!box) {
|
||||||
|
throw new AuditError(
|
||||||
|
`权限拒绝:块 ${blockId} 无法反查所属笔记本(docId 无效或不可见),执行「${operation}」需要 ${required.join("/")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.auditNotebookId(box, required, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** notebookId 直接审计(第 3 条) */
|
||||||
|
auditNotebookId(
|
||||||
|
notebookId: string,
|
||||||
|
required: Perm[],
|
||||||
|
operation: string,
|
||||||
|
): void {
|
||||||
|
const r = auditNotebook(this.cfg.notebooks, notebookId, required);
|
||||||
|
if (!r.ok)
|
||||||
|
throw new AuditError(denyMessage(r.nb, required, r.missing, operation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局读(第 9 条:任一 R) */
|
||||||
|
auditGlobalRead(operation: string): void {
|
||||||
|
if (!auditAny(this.cfg.notebooks, "R")) {
|
||||||
|
throw new AuditError(
|
||||||
|
`权限拒绝:执行「${operation}」需要至少一个笔记本有 R 权限,当前没有任何笔记本可读`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** batch_replace_tag:所有 W(第 9 条) */
|
||||||
|
auditAllWrite(operation: string): void {
|
||||||
|
if (!auditAll(this.cfg.notebooks, "W")) {
|
||||||
|
throw new AuditError(
|
||||||
|
`权限拒绝:执行「${operation}」需要所有笔记本都配置 W 权限`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** create_snapshot:任一 R(第 9 条) */
|
||||||
|
auditSnapshotCreate(operation: string): void {
|
||||||
|
if (!auditAny(this.cfg.notebooks, "R")) {
|
||||||
|
throw new AuditError(
|
||||||
|
`权限拒绝:执行「${operation}」需要至少一个笔记本有 R 权限`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rollback_to_snapshot:所有 D(第 9 条) */
|
||||||
|
auditRollback(operation: string): void {
|
||||||
|
if (!auditAll(this.cfg.notebooks, "D")) {
|
||||||
|
throw new AuditError(
|
||||||
|
`权限拒绝:执行「${operation}」回滚会覆盖全库,需要所有笔记本都配置 D 权限`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** move_documents:from D + to W(第 9 条) */
|
||||||
|
auditMove(fromBox: string, toNotebook: string, operation: string): void {
|
||||||
|
this.auditNotebookId(fromBox, ["D"], operation);
|
||||||
|
this.auditNotebookId(toNotebook, ["W"], operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
+432
@@ -0,0 +1,432 @@
|
|||||||
|
/**
|
||||||
|
* pi-siyuan — SiYuan Note extension for pi coding agent
|
||||||
|
*
|
||||||
|
* 架构(AGENTS.md 第 5 条):直接封装 SiYuan HTTP API + 权限审计层。
|
||||||
|
* 工具划分与参数设计参考 porkll/siyuan-mcp,不依赖其代码。
|
||||||
|
*
|
||||||
|
* 渐进式披露(goal 4):15 个工具全部 registerTool 注册,
|
||||||
|
* 但初始只激活 siyuan_discover 一个 loader 工具;
|
||||||
|
* loader 按需 setActiveTools 增量激活匹配的工具。
|
||||||
|
*
|
||||||
|
* 审计豁免(第 7 条):list_notebooks(即 siyuan_notebooks_loader)与连通性检查不做权限审计。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { SiYuanClient } from "./siyuan-client.ts";
|
||||||
|
import {
|
||||||
|
Auditor,
|
||||||
|
AuditError,
|
||||||
|
loadConfig,
|
||||||
|
resolveConnection,
|
||||||
|
syncNotebooks,
|
||||||
|
type PiSiyuanConfig,
|
||||||
|
} from "./audit.ts";
|
||||||
|
|
||||||
|
// 15 个工具名(AGENTS.md 第 11 条,与 porkll/siyuan-mcp 一致)
|
||||||
|
const SIYUAN_TOOL_NAMES = new Set([
|
||||||
|
"unified_search",
|
||||||
|
"get_document_content",
|
||||||
|
"create_document",
|
||||||
|
"append_to_document",
|
||||||
|
"update_document",
|
||||||
|
"move_documents",
|
||||||
|
"get_document_tree",
|
||||||
|
"append_to_daily_note",
|
||||||
|
"list_notebooks",
|
||||||
|
"get_recently_updated_documents",
|
||||||
|
"create_snapshot",
|
||||||
|
"list_snapshots",
|
||||||
|
"rollback_to_snapshot",
|
||||||
|
"list_all_tags",
|
||||||
|
"batch_replace_tag",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface ToolDeps {
|
||||||
|
client: SiYuanClient;
|
||||||
|
auditor: Auditor;
|
||||||
|
cfg: PiSiyuanConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(text: string, extra?: Record<string, unknown>) {
|
||||||
|
return { content: [{ type: "text" as const, text }], details: extra ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `❌ ${msg}` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// list_notebooks 本体(审计豁免 + 新笔记本同步,第 7/10 条)
|
||||||
|
async function listNotebooksBody(deps: ToolDeps) {
|
||||||
|
const notebooks = await deps.client.lsNotebooks();
|
||||||
|
syncNotebooks(deps.cfg, notebooks); // 落盘 {id,name,permissions:["R"]}
|
||||||
|
const lines = notebooks.map((nb) => {
|
||||||
|
const conf = deps.cfg.notebooks.find((n) => n.id === nb.id);
|
||||||
|
const perms = conf ? conf.permissions.join("") : "R(默认)";
|
||||||
|
return `${nb.id} | ${nb.name} | ${perms}${nb.closed ? " | (已关闭)" : ""}`;
|
||||||
|
});
|
||||||
|
return ok(`共 ${notebooks.length} 个笔记本:\n` + lines.join("\n"), {
|
||||||
|
count: notebooks.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function siyuanExtension(pi: ExtensionAPI) {
|
||||||
|
const conn = resolveConnection(); // env 优先,config 兜底(第 6 条)
|
||||||
|
const client = new SiYuanClient(conn.apiUrl, conn.token);
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const deps: ToolDeps = { client, auditor: new Auditor(client, cfg), cfg };
|
||||||
|
|
||||||
|
// 审计包装器:AuditError → isError 结果(错误消息含笔记本/权限/操作名,第 7 条)
|
||||||
|
function audited(
|
||||||
|
name: string,
|
||||||
|
fn: (p: any) => Promise<ReturnType<typeof ok>>,
|
||||||
|
) {
|
||||||
|
return async (_toolCallId: string, params: any) => {
|
||||||
|
try {
|
||||||
|
return await fn(params);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 15 个工具(第 11 条) ============
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "list_notebooks",
|
||||||
|
label: "List Notebooks",
|
||||||
|
description:
|
||||||
|
"列出所有 SiYuan 笔记本(含权限配置)。新笔记本会自动以 R 权限记录到配置文件。",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
execute: audited("list_notebooks", async () => listNotebooksBody(deps)),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "unified_search",
|
||||||
|
label: "Unified Search",
|
||||||
|
description: "统一搜索:按内容或文档标题搜索(全库,需任一笔记本 R 权限)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
content: Type.Optional(Type.String({ description: "内容关键词" })),
|
||||||
|
filename: Type.Optional(Type.String({ description: "文档标题关键词" })),
|
||||||
|
limit: Type.Optional(Type.Number({ description: "返回条数,默认 10" })),
|
||||||
|
}),
|
||||||
|
execute: audited("unified_search", async (p) => {
|
||||||
|
deps.auditor.auditGlobalRead("unified_search"); // 第 9 条
|
||||||
|
const limit = Math.min(p.limit ?? 10, 50);
|
||||||
|
let results: any[] = [];
|
||||||
|
if (p.filename) results = await client.searchDocTitles(p.filename, limit);
|
||||||
|
if (p.content) {
|
||||||
|
const c = await client.searchContent(p.content, limit);
|
||||||
|
results = results.length
|
||||||
|
? results.filter((r) => !c.some((x) => x.id === r.id)).concat(c)
|
||||||
|
: c;
|
||||||
|
}
|
||||||
|
if (!p.content && !p.filename)
|
||||||
|
return fail(new Error("content 与 filename 至少提供一个"));
|
||||||
|
return ok(
|
||||||
|
`搜索结果 ${results.length} 条:\n` +
|
||||||
|
results
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((r) => `${r.box}/${r.id} | ${String(r.content ?? "").slice(0, 100)}`)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "get_document_content",
|
||||||
|
label: "Get Document Content",
|
||||||
|
description: "获取文档的 Markdown 内容(按 docId,需所属笔记本 R 权限)。",
|
||||||
|
parameters: Type.Object({ docId: Type.String({ description: "文档 ID" }) }),
|
||||||
|
execute: audited("get_document_content", async (p) => {
|
||||||
|
await deps.auditor.auditByBlockId(p.docId, ["R"], "get_document_content"); // 第 8 条
|
||||||
|
const content = await client.getDocContent(p.docId);
|
||||||
|
return ok(content, { docId: p.docId });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "create_document",
|
||||||
|
label: "Create Document",
|
||||||
|
description: "在指定笔记本创建文档(需该笔记本 W 权限)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
notebook: Type.String({ description: "笔记本 ID" }),
|
||||||
|
path: Type.String({ description: "文档路径,如 /notes/new-doc" }),
|
||||||
|
markdown: Type.String({ description: "Markdown 内容" }),
|
||||||
|
}),
|
||||||
|
execute: audited("create_document", async (p) => {
|
||||||
|
deps.auditor.auditNotebookId(p.notebook, ["W"], "create_document");
|
||||||
|
const docId = await client.createDoc(p.notebook, p.path, p.markdown);
|
||||||
|
return ok(`已创建文档 ${docId}(${p.notebook}${p.path})`, { docId });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "append_to_document",
|
||||||
|
label: "Append To Document",
|
||||||
|
description: "向文档末尾追加 Markdown 内容(需所属笔记本 W 权限)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
docId: Type.String({ description: "文档 ID" }),
|
||||||
|
markdown: Type.String({ description: "追加的 Markdown 内容" }),
|
||||||
|
}),
|
||||||
|
execute: audited("append_to_document", async (p) => {
|
||||||
|
await deps.auditor.auditByBlockId(p.docId, ["W"], "append_to_document");
|
||||||
|
await client.appendBlock(p.docId, p.markdown);
|
||||||
|
return ok(`已追加内容到文档 ${p.docId}`, { docId: p.docId });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "update_document",
|
||||||
|
label: "Update Document",
|
||||||
|
description: "覆盖更新文档首块内容(需所属笔记本 W 权限)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
docId: Type.String({ description: "文档 ID" }),
|
||||||
|
markdown: Type.String({ description: "新的 Markdown 内容" }),
|
||||||
|
}),
|
||||||
|
execute: audited("update_document", async (p) => {
|
||||||
|
await deps.auditor.auditByBlockId(p.docId, ["W"], "update_document");
|
||||||
|
await client.updateBlock(p.docId, p.markdown);
|
||||||
|
return ok(`已更新文档 ${p.docId} 首块`, { docId: p.docId });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "move_documents",
|
||||||
|
label: "Move Documents",
|
||||||
|
description: "移动文档到目标笔记本/路径(from 笔记本需 D,to 笔记本需 W)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
from_ids: Type.Array(Type.String(), { description: "源文档 ID 列表" }),
|
||||||
|
to_notebook_root: Type.String({
|
||||||
|
description: "目标笔记本 ID 或 笔记本ID/路径",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
execute: audited("move_documents", async (p) => {
|
||||||
|
const [toNotebook, ...rest] = String(p.to_notebook_root).split("/");
|
||||||
|
const toPath = rest.length ? "/" + rest.join("/") : "/";
|
||||||
|
// from 侧逐个反查(第 8 条),逐个审计 D;to 审计 W(第 9 条)
|
||||||
|
for (const id of p.from_ids) {
|
||||||
|
const box = await client.boxOf(id);
|
||||||
|
if (!box)
|
||||||
|
return fail(
|
||||||
|
new Error(
|
||||||
|
`权限拒绝:块 ${id} 无法反查所属笔记本,执行「move_documents」需要 D`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
deps.auditor.auditMove(box, toNotebook, "move_documents");
|
||||||
|
}
|
||||||
|
await client.moveDocs(p.from_ids, toNotebook, toPath);
|
||||||
|
return ok(`已移动 ${p.from_ids.length} 个文档到 ${p.to_notebook_root}`);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "get_document_tree",
|
||||||
|
label: "Get Document Tree",
|
||||||
|
description:
|
||||||
|
"获取笔记本文档树(按 to_notebook_root 归入目标笔记本审计,需 R;无参数时需任一 R)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
to_notebook_root: Type.Optional(
|
||||||
|
Type.String({ description: "笔记本 ID(可带 /路径)" }),
|
||||||
|
),
|
||||||
|
depth: Type.Optional(Type.Number({ description: "深度,默认 1" })),
|
||||||
|
}),
|
||||||
|
execute: audited("get_document_tree", async (p) => {
|
||||||
|
if (p.to_notebook_root) {
|
||||||
|
const [notebook] = String(p.to_notebook_root).split("/");
|
||||||
|
deps.auditor.auditNotebookId(notebook, ["R"], "get_document_tree");
|
||||||
|
const files = await client.listDocsByPath(notebook, "/");
|
||||||
|
return ok(
|
||||||
|
files
|
||||||
|
.slice(0, 100)
|
||||||
|
.map((f: any) => `${f.path} | ${f.name}`)
|
||||||
|
.join("\n") || "(空)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
deps.auditor.auditGlobalRead("get_document_tree");
|
||||||
|
const notebooks = await client.lsNotebooks();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const nb of notebooks.slice(0, 20)) {
|
||||||
|
const files = await client.listDocsByPath(nb.id, "/");
|
||||||
|
out.push(`# ${nb.name} (${files.length} docs)`);
|
||||||
|
out.push(...files.slice(0, 50).map((f: any) => ` ${f.path} | ${f.name}`));
|
||||||
|
}
|
||||||
|
return ok(out.join("\n"));
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "append_to_daily_note",
|
||||||
|
label: "Append To Daily Note",
|
||||||
|
description: "追加到今天的日记(不存在则自动创建;需该笔记本 W 权限)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
notebook: Type.String({ description: "笔记本 ID" }),
|
||||||
|
markdown: Type.String({ description: "追加内容" }),
|
||||||
|
}),
|
||||||
|
execute: audited("append_to_daily_note", async (p) => {
|
||||||
|
deps.auditor.auditNotebookId(p.notebook, ["W"], "append_to_daily_note");
|
||||||
|
const r = await client.dailyNote(p.notebook, p.markdown);
|
||||||
|
return ok(`${r.created ? "已创建" : "已追加到"}日记 ${r.docId}`, r);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "get_recently_updated_documents",
|
||||||
|
label: "Get Recently Updated Documents",
|
||||||
|
description: "获取最近更新的文档(全库,需任一 R 权限)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
limit: Type.Optional(Type.Number({ description: "条数,默认 10" })),
|
||||||
|
}),
|
||||||
|
execute: audited("get_recently_updated_documents", async (p) => {
|
||||||
|
deps.auditor.auditGlobalRead("get_recently_updated_documents");
|
||||||
|
const limit = Math.min(p.limit ?? 10, 50);
|
||||||
|
const rows = await client.sql(
|
||||||
|
`select id, box, content, updated from blocks where type='d' order by updated desc limit ${limit}`,
|
||||||
|
);
|
||||||
|
return ok(
|
||||||
|
rows
|
||||||
|
.map((r) => `${r.updated} | ${r.box} | ${r.id} | ${r.content}`)
|
||||||
|
.join("\n") || "(无)",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "create_snapshot",
|
||||||
|
label: "Create Snapshot",
|
||||||
|
description: "创建全库快照备份(需任一 R 权限,只读不破坏数据)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
memo: Type.Optional(Type.String({ description: "备注" })),
|
||||||
|
}),
|
||||||
|
execute: audited("create_snapshot", async (p) => {
|
||||||
|
deps.auditor.auditSnapshotCreate("create_snapshot");
|
||||||
|
await client.createSnapshot(p.memo || "pi-siyuan snapshot");
|
||||||
|
return ok("快照已创建");
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "list_snapshots",
|
||||||
|
label: "List Snapshots",
|
||||||
|
description: "列出快照(需任一 R 权限)。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
page: Type.Optional(Type.Number({ description: "页码,默认 1" })),
|
||||||
|
}),
|
||||||
|
execute: audited("list_snapshots", async (p) => {
|
||||||
|
deps.auditor.auditGlobalRead("list_snapshots");
|
||||||
|
const d = await client.listSnapshots(p.page ?? 1);
|
||||||
|
const snaps: any[] = d?.snapshots ?? [];
|
||||||
|
return ok(
|
||||||
|
`第 ${d?.pageCount ?? "?"} 页快照:\n` +
|
||||||
|
snaps
|
||||||
|
.map((s) => `${s.id} | ${s.hCreated} | ${s.memo} | ${s.hSize}`)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "rollback_to_snapshot",
|
||||||
|
label: "Rollback To Snapshot",
|
||||||
|
description:
|
||||||
|
"回滚到指定快照(覆盖全库,需所有笔记本 D 权限)。不可逆,慎用。",
|
||||||
|
parameters: Type.Object({ id: Type.String({ description: "快照 ID" }) }),
|
||||||
|
execute: audited("rollback_to_snapshot", async (p) => {
|
||||||
|
deps.auditor.auditRollback("rollback_to_snapshot");
|
||||||
|
await client.rollbackToSnapshot(p.id);
|
||||||
|
return ok(`已回滚到快照 ${p.id}`);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "list_all_tags",
|
||||||
|
label: "List All Tags",
|
||||||
|
description: "列出全库所有标签(需任一 R 权限)。",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
execute: audited("list_all_tags", async () => {
|
||||||
|
deps.auditor.auditGlobalRead("list_all_tags");
|
||||||
|
const tags = await client.searchTags("");
|
||||||
|
return ok(tags.join(", ") || "(无标签)", { count: tags.length });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "batch_replace_tag",
|
||||||
|
label: "Batch Replace Tag",
|
||||||
|
description:
|
||||||
|
"批量替换/删除全库标签(需所有笔记本 W 权限)。new_tag 为空 = 删除标签。",
|
||||||
|
parameters: Type.Object({
|
||||||
|
old_tag: Type.String({ description: "旧标签名" }),
|
||||||
|
new_tag: Type.Optional(Type.String({ description: "新标签名;空则删除" })),
|
||||||
|
}),
|
||||||
|
execute: audited("batch_replace_tag", async (p) => {
|
||||||
|
deps.auditor.auditAllWrite("batch_replace_tag");
|
||||||
|
if (!p.new_tag)
|
||||||
|
return fail(new Error("该部署暂不支持删除标签(new_tag 必填)"));
|
||||||
|
await client.renameTag(p.old_tag, p.new_tag);
|
||||||
|
return ok(`已将标签「${p.old_tag}」替换为「${p.new_tag}」`);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ 渐进式披露 loader(goal 4) ============
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "siyuan_discover",
|
||||||
|
label: "SiYuan Discover",
|
||||||
|
description:
|
||||||
|
"搜索并激活 SiYuan 笔记工具(15 个工具按需加载)。输入能力关键词(如 search / read / write / snapshot / tag / notebook / move / daily)。",
|
||||||
|
promptSnippet: "操作 SiYuan 笔记时先用 siyuan_discover 激活对应工具",
|
||||||
|
parameters: Type.Object({
|
||||||
|
query: Type.Optional(
|
||||||
|
Type.String({ description: "能力关键词,空则列出全部" }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
// 连通性检查(审计豁免,第 7 条)
|
||||||
|
try {
|
||||||
|
const version = await client.version();
|
||||||
|
const terms = String(params.query ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const all = pi.getAllTools().filter((t) => SIYUAN_TOOL_NAMES.has(t.name));
|
||||||
|
const matches = terms.length
|
||||||
|
? all
|
||||||
|
.filter((t) =>
|
||||||
|
terms.some((term) =>
|
||||||
|
`${t.name} ${t.description}`.toLowerCase().includes(term),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((t) => t.name)
|
||||||
|
: all.map((t) => t.name);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return ok(
|
||||||
|
`SiYuan ${version} 连接正常,但没有匹配「${params.query}」的工具。可用工具:\n${[...SIYUAN_TOOL_NAMES].join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const active = pi.getActiveTools();
|
||||||
|
const added = matches.filter((name) => !active.includes(name));
|
||||||
|
if (added.length) pi.setActiveTools([...new Set([...active, ...added])]);
|
||||||
|
return ok(
|
||||||
|
`SiYuan ${version} 连接正常。${added.length ? `已加载工具:${added.join(", ")}` : `匹配工具已激活:${matches.join(", ")}`}`,
|
||||||
|
{ matches, added, version },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始只激活 loader(渐进式披露)
|
||||||
|
pi.on("session_start", () => {
|
||||||
|
const initial = pi
|
||||||
|
.getActiveTools()
|
||||||
|
.filter((name) => !SIYUAN_TOOL_NAMES.has(name));
|
||||||
|
pi.setActiveTools([...new Set([...initial, "siyuan_discover"])]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* SiYuan HTTP API 客户端
|
||||||
|
* 直接封装 /api/*(Authorization: Token 头),无外部依赖。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SiYuanError extends Error {
|
||||||
|
code?: number;
|
||||||
|
|
||||||
|
constructor(message: string, code?: number) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SiYuanClient {
|
||||||
|
private apiUrl: string;
|
||||||
|
private token: string;
|
||||||
|
|
||||||
|
constructor(apiUrl: string, token: string) {
|
||||||
|
this.apiUrl = apiUrl;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连通性检查(审计豁免) */
|
||||||
|
async version(): Promise<string> {
|
||||||
|
const d = await this.call("/api/system/version", {});
|
||||||
|
return d as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lsNotebooks(): Promise<
|
||||||
|
Array<{ id: string; name: string; closed: boolean }>
|
||||||
|
> {
|
||||||
|
const d = await this.call("/api/notebook/lsNotebooks", {});
|
||||||
|
return d.notebooks ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQL 查询(该部署的 /api/query/query 被反代吞掉,sql 可用) */
|
||||||
|
async sql(stmt: string): Promise<any[]> {
|
||||||
|
const d = await this.call("/api/query/sql", { stmt });
|
||||||
|
return d ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** docId/blockId → 所属笔记本 id(blocks.box)。查不到返回 null */
|
||||||
|
async boxOf(blockId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const rows = await this.sql(
|
||||||
|
`select distinct box from blocks where id='${this.esc(blockId)}' or root_id='${this.esc(blockId)}' limit 1`,
|
||||||
|
);
|
||||||
|
return rows.length ? rows[0].box : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 文档 ----
|
||||||
|
|
||||||
|
async createDoc(
|
||||||
|
notebook: string,
|
||||||
|
path: string,
|
||||||
|
markdown: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return (await this.call("/api/filetree/createDocWithMd", {
|
||||||
|
notebook,
|
||||||
|
path,
|
||||||
|
markdown,
|
||||||
|
})) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDocContent(id: string): Promise<string> {
|
||||||
|
const d = await this.call("/api/export/exportMdContent", { id });
|
||||||
|
return d.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendBlock(parentID: string, markdown: string): Promise<any> {
|
||||||
|
return this.call("/api/block/appendBlock", {
|
||||||
|
dataType: "markdown",
|
||||||
|
data: markdown,
|
||||||
|
parentID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBlock(id: string, markdown: string): Promise<any> {
|
||||||
|
return this.call("/api/block/updateBlock", {
|
||||||
|
dataType: "markdown",
|
||||||
|
data: markdown,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveDocs(
|
||||||
|
fromIDs: string[],
|
||||||
|
toNotebook: string,
|
||||||
|
toPath: string,
|
||||||
|
): Promise<any> {
|
||||||
|
return this.call("/api/filetree/moveDocs", { fromIDs, toNotebook, toPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDocsByPath(notebook: string, path: string): Promise<any[]> {
|
||||||
|
const d = await this.call("/api/filetree/listDocsByPath", { notebook, path });
|
||||||
|
return d?.files ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** daily note:/api/dailynote/* 在该部署返回空 body,退化为 createDocWithMd 幂等创建 /YYYY-MM-DD */
|
||||||
|
async dailyNote(
|
||||||
|
notebook: string,
|
||||||
|
markdown: string,
|
||||||
|
): Promise<{ docId: string; created: boolean }> {
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(
|
||||||
|
today.getDate(),
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
const path = `/${dateStr}`;
|
||||||
|
// 已存在则追加
|
||||||
|
try {
|
||||||
|
const rows = await this.sql(
|
||||||
|
`select id from blocks where type='d' and box='${this.esc(notebook)}' and content='${dateStr}' limit 1`,
|
||||||
|
);
|
||||||
|
if (rows.length) {
|
||||||
|
await this.appendBlock(rows[0].id, markdown);
|
||||||
|
return { docId: rows[0].id, created: false };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 查询失败走创建
|
||||||
|
}
|
||||||
|
const docId = await this.createDoc(
|
||||||
|
notebook,
|
||||||
|
path,
|
||||||
|
`# ${dateStr}\n\n${markdown}`,
|
||||||
|
);
|
||||||
|
return { docId, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 搜索(fulltext 端点被吞,用 SQL)----
|
||||||
|
|
||||||
|
async searchContent(query: string, limit: number): Promise<any[]> {
|
||||||
|
return this.sql(
|
||||||
|
`select id, box, root_id, content, type from blocks where content like '%${this.esc(query)}%' limit ${limit}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchDocTitles(query: string, limit: number): Promise<any[]> {
|
||||||
|
return this.sql(
|
||||||
|
`select id, box, content from blocks where type='d' and content like '%${this.esc(query)}%' limit ${limit}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 标签 ----
|
||||||
|
|
||||||
|
async searchTags(k: string): Promise<string[]> {
|
||||||
|
const d = await this.call("/api/search/searchTag", { k });
|
||||||
|
const tags: string[] = (d?.tags ?? []).map((t: string) =>
|
||||||
|
t.replace(/<\/?mark>/g, ""),
|
||||||
|
);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameTag(oldLabel: string, newLabel: string): Promise<any> {
|
||||||
|
// /api/tag/replaceTag 被吞;renameTag 可用。newLabel 为空 = 删除标签语义由调用方控制
|
||||||
|
return this.call("/api/tag/renameTag", { oldLabel, newLabel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 快照 ----
|
||||||
|
|
||||||
|
async createSnapshot(memo: string): Promise<any> {
|
||||||
|
return this.call("/api/repo/createSnapshot", { memo });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /api/repo/listSnapshots 被吞,用 getRepoSnapshots 分页 */
|
||||||
|
async listSnapshots(page = 1): Promise<any> {
|
||||||
|
return this.call("/api/repo/getRepoSnapshots", { page });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollbackToSnapshot(id: string): Promise<any> {
|
||||||
|
return this.call("/api/repo/checkoutRepo", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部 ----
|
||||||
|
|
||||||
|
private esc(s: string): string {
|
||||||
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async call(path: string, body: unknown): Promise<any> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(this.apiUrl + path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token ${this.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new SiYuanError(`连接 SiYuan 失败 (${this.apiUrl}): ${e.message}`);
|
||||||
|
}
|
||||||
|
if (res.status === 401)
|
||||||
|
throw new SiYuanError("SiYuan 认证失败:检查 token", 401);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) {
|
||||||
|
// 该部署的反代会吞掉部分端点的响应体
|
||||||
|
throw new SiYuanError(
|
||||||
|
`SiYuan 端点 ${path} 返回空响应(可能被反向代理吞掉)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let json: any;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new SiYuanError(
|
||||||
|
`SiYuan 端点 ${path} 返回非 JSON: ${text.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (json.code !== 0)
|
||||||
|
throw new SiYuanError(
|
||||||
|
json.msg || `SiYuan 错误 code=${json.code}`,
|
||||||
|
json.code,
|
||||||
|
);
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* extension 加载自检:用 mock ExtensionAPI 驱动 src/index.ts,
|
||||||
|
* 验证 goal 4(注册最小工具集 + 渐进式披露)+ goal 1/2/3 的工具级端到端行为。
|
||||||
|
* 运行:node --experimental-strip-types --no-warnings test/extension.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// ---- mock ExtensionAPI ----
|
||||||
|
interface RegisteredTool {
|
||||||
|
name: string;
|
||||||
|
parameters: any;
|
||||||
|
description: string;
|
||||||
|
promptSnippet?: string;
|
||||||
|
execute: (id: string, params: any) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered: RegisteredTool[] = [];
|
||||||
|
let activeTools: string[] = ["read", "bash", "edit", "write"]; // 模拟内置工具
|
||||||
|
const eventHandlers = new Map<string, Function>();
|
||||||
|
|
||||||
|
const pi: any = {
|
||||||
|
registerTool(def: any) {
|
||||||
|
registered.push(def);
|
||||||
|
},
|
||||||
|
getAllTools() {
|
||||||
|
return registered.map((t) => ({ name: t.name, description: t.description }));
|
||||||
|
},
|
||||||
|
getActiveTools() {
|
||||||
|
return [...activeTools];
|
||||||
|
},
|
||||||
|
setActiveTools(names: string[]) {
|
||||||
|
activeTools = [...names];
|
||||||
|
},
|
||||||
|
on(ev: string, fn: Function) {
|
||||||
|
eventHandlers.set(ev, fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFIG_PATH = join(
|
||||||
|
homedir(),
|
||||||
|
".pi/agent/extensions/pi-siyuan/config.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- load extension ----
|
||||||
|
const mod = await import("../src/index.ts");
|
||||||
|
mod.default(pi);
|
||||||
|
|
||||||
|
// 15 个工具 + loader
|
||||||
|
const EXPECTED = [
|
||||||
|
"unified_search",
|
||||||
|
"get_document_content",
|
||||||
|
"create_document",
|
||||||
|
"append_to_document",
|
||||||
|
"update_document",
|
||||||
|
"move_documents",
|
||||||
|
"get_document_tree",
|
||||||
|
"append_to_daily_note",
|
||||||
|
"list_notebooks",
|
||||||
|
"get_recently_updated_documents",
|
||||||
|
"create_snapshot",
|
||||||
|
"list_snapshots",
|
||||||
|
"rollback_to_snapshot",
|
||||||
|
"list_all_tags",
|
||||||
|
"batch_replace_tag",
|
||||||
|
"siyuan_discover",
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
registered.length,
|
||||||
|
EXPECTED.length,
|
||||||
|
`注册 ${registered.length} 个工具`,
|
||||||
|
);
|
||||||
|
for (const name of EXPECTED)
|
||||||
|
assert.ok(
|
||||||
|
registered.find((t) => t.name === name),
|
||||||
|
`${name} 已注册`,
|
||||||
|
);
|
||||||
|
console.log(`✅ 4a. 全部 ${EXPECTED.length} 个工具注册(15 + loader)`);
|
||||||
|
|
||||||
|
// ---- session_start → 初始只激活 loader(渐进式披露核心) ----
|
||||||
|
const sessionStart = eventHandlers.get("session_start");
|
||||||
|
assert.ok(sessionStart, "注册了 session_start");
|
||||||
|
await sessionStart();
|
||||||
|
const siyuanTools = registered.map((t) => t.name);
|
||||||
|
assert.ok(activeTools.includes("siyuan_discover"), "loader 初始激活");
|
||||||
|
assert.ok(
|
||||||
|
!siyuanTools.some((n) => n !== "siyuan_discover" && activeTools.includes(n)),
|
||||||
|
"15 个业务工具初始全部未激活",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
activeTools.includes("read") && activeTools.includes("bash"),
|
||||||
|
"内置工具保留",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"✅ 4b. 渐进式披露:初始仅 siyuan_discover 激活,15 个工具按需加载",
|
||||||
|
);
|
||||||
|
|
||||||
|
const byName = (n: string) => registered.find((t) => t.name === n)!;
|
||||||
|
|
||||||
|
// ---- goal 1: 连接(loader 连通性检查,审计豁免) ----
|
||||||
|
const r1 = await byName("siyuan_discover").execute("t1", {});
|
||||||
|
assert.ok(
|
||||||
|
!r1.isError,
|
||||||
|
`loader 连通正常: ${JSON.stringify(r1.content?.[0]?.text?.slice(0, 60))}`,
|
||||||
|
);
|
||||||
|
assert.ok(r1.content[0].text.includes("3.8.2"), "返回 SiYuan 版本");
|
||||||
|
console.log("✅ 1. 连接正常:", r1.content[0].text.split("\n")[0]);
|
||||||
|
|
||||||
|
// ---- loader 渐进激活:query=search → unified_search + get_document_content? ----
|
||||||
|
// 空 query → 列出全部 → 首次已激活,再次调用 added 为空(已激活),matches 全量
|
||||||
|
const r2 = await byName("siyuan_discover").execute("t2", { query: "search" });
|
||||||
|
assert.ok(
|
||||||
|
r2.details.matches.length > 0,
|
||||||
|
`按需加载生效: ${JSON.stringify(r2.details)}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
activeTools.includes("unified_search"),
|
||||||
|
"unified_search 被激活(首次 loader 已加载)",
|
||||||
|
);
|
||||||
|
// 纯增量
|
||||||
|
for (const t of ["read", "bash", "siyuan_discover"])
|
||||||
|
assert.ok(activeTools.includes(t), `保留 ${t}`);
|
||||||
|
console.log("✅ 4c. loader 按需激活:", r2.details.added.join(", "));
|
||||||
|
|
||||||
|
// ---- goal 3: 05-Vault (NONE) 各操作被拒 ----
|
||||||
|
// 用 05-Vault 内真实文档 id (Agnes-AI) 测 get_document_content
|
||||||
|
const VAULT_DOC = "20260602103402-rssx5ko";
|
||||||
|
const r3 = await byName("get_document_content").execute("t3", {
|
||||||
|
docId: VAULT_DOC,
|
||||||
|
});
|
||||||
|
assert.ok(r3.isError, "读取 05-Vault 文档必须失败");
|
||||||
|
assert.ok(
|
||||||
|
r3.content[0].text.includes("05-Vault") ||
|
||||||
|
r3.content[0].text.includes(VAULT_DOC.slice(0, 14)),
|
||||||
|
"拒绝消息含目标笔记本",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"✅ 3a. 05-Vault(NONE) get_document_content 被拒:",
|
||||||
|
r3.content[0].text.slice(0, 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
// append 也拒
|
||||||
|
const r4 = await byName("append_to_document").execute("t4", {
|
||||||
|
docId: VAULT_DOC,
|
||||||
|
markdown: "x",
|
||||||
|
});
|
||||||
|
assert.ok(r4.isError, "写 05-Vault 拒绝");
|
||||||
|
console.log("✅ 3b. 05-Vault(NONE) append_to_document 被拒");
|
||||||
|
|
||||||
|
// ---- goal 2: 权限管理正常(放行路径 + 拒绝路径) ----
|
||||||
|
// 2a: 读 06-GeekTime (RWD) 的文档
|
||||||
|
const r5 = await byName("get_document_content").execute("t5", {
|
||||||
|
docId: "20260902151835-k7ktypf",
|
||||||
|
});
|
||||||
|
assert.ok(!r5.isError, "读 RWD 笔记本文档放行");
|
||||||
|
console.log("✅ 2a. 06-GeekTime(RWD) 读放行");
|
||||||
|
|
||||||
|
// 2b: 全局读放行
|
||||||
|
const r6 = await byName("unified_search").execute("t6", { content: "Agnes" });
|
||||||
|
assert.ok(!r6.isError, "unified_search 放行(任一 R)");
|
||||||
|
console.log(
|
||||||
|
"✅ 2b. unified_search(任一 R)放行:",
|
||||||
|
r6.content[0].text.split("\n")[0].slice(0, 50),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2c: batch_replace_tag 拒(存在 NONE + 99-Archived 无 W)
|
||||||
|
const r7 = await byName("batch_replace_tag").execute("t7", {
|
||||||
|
old_tag: "AI",
|
||||||
|
new_tag: "AI2",
|
||||||
|
});
|
||||||
|
assert.ok(r7.isError, "batch_replace_tag 拒绝");
|
||||||
|
console.log(
|
||||||
|
"✅ 2c. batch_replace_tag(所有 W)拒绝:",
|
||||||
|
r7.content[0].text.slice(0, 80),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2d: rollback 拒(所有 D 不满足)
|
||||||
|
const r8 = await byName("rollback_to_snapshot").execute("t8", {
|
||||||
|
id: "fakesnap",
|
||||||
|
});
|
||||||
|
assert.ok(r8.isError, "rollback 拒绝");
|
||||||
|
console.log(
|
||||||
|
"✅ 2d. rollback_to_snapshot(所有 D)拒绝:",
|
||||||
|
r8.content[0].text.slice(0, 80),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2e: create_snapshot 放行(任一 R)
|
||||||
|
const r9 = await byName("create_snapshot").execute("t9", {
|
||||||
|
memo: "pi-siyuan self-check",
|
||||||
|
});
|
||||||
|
assert.ok(!r9.isError, "create_snapshot 放行");
|
||||||
|
console.log("✅ 2e. create_snapshot(任一 R)放行");
|
||||||
|
|
||||||
|
// ---- goal 4 完整闭环:list_notebooks 审计豁免 + 新笔记本同步 ----
|
||||||
|
const before = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")).notebooks.length;
|
||||||
|
const r10 = await byName("list_notebooks").execute("t10", {});
|
||||||
|
assert.ok(!r10.isError, "list_notebooks 审计豁免");
|
||||||
|
const after = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")).notebooks;
|
||||||
|
assert.ok(after.length >= before, "配置落盘");
|
||||||
|
console.log(
|
||||||
|
`✅ 4d. list_notebooks 审计豁免 + 同步落盘(${before} → ${after.length})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\nextension self-check: all assertions passed ✅");
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* 集成自检:连接 + 权限审计 + 05-Vault NONE 生效 + 工具集注册
|
||||||
|
* 运行:node --experimental-strip-types --no-warnings test/integration.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { SiYuanClient } from "../src/siyuan-client.ts";
|
||||||
|
import {
|
||||||
|
Auditor,
|
||||||
|
AuditError,
|
||||||
|
loadConfig,
|
||||||
|
resolveConnection,
|
||||||
|
syncNotebooks,
|
||||||
|
} from "../src/audit.ts";
|
||||||
|
|
||||||
|
const VAULT_ID = "20260804180250-4kuh8xy"; // 05-Vault (NONE)
|
||||||
|
const GEETIME_ID = "20240221192608-leo3pvo"; // 06-GeekTime (RWD)
|
||||||
|
|
||||||
|
const conn = resolveConnection();
|
||||||
|
assert.equal(conn.apiUrl, "https://siyuan.ipao.vip", "连接配置:env 优先");
|
||||||
|
assert.ok(conn.token.length > 0, "token 非空");
|
||||||
|
|
||||||
|
const client = new SiYuanClient(conn.apiUrl, conn.token);
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
// ---- goal 1: 连接正常 ----
|
||||||
|
const version = await client.version();
|
||||||
|
console.log("✅ 1. 连接正常:SiYuan", version);
|
||||||
|
|
||||||
|
// ---- goal 3 前置:配置中 05-Vault 是 NONE ----
|
||||||
|
const vault = cfg.notebooks.find((n) => n.id === VAULT_ID);
|
||||||
|
assert.ok(vault, "config 有 05-Vault");
|
||||||
|
assert.deepEqual(vault.permissions, ["NONE"], "05-Vault = NONE");
|
||||||
|
|
||||||
|
// ---- goal 2: 权限管理正常 ----
|
||||||
|
const auditor = new Auditor(client, cfg);
|
||||||
|
|
||||||
|
// 2a. 05-Vault 读 → 拒绝(NONE)
|
||||||
|
await assert.rejects(
|
||||||
|
async () => auditor.auditNotebookId(VAULT_ID, ["R"], "get_document_content"),
|
||||||
|
(e: any) => {
|
||||||
|
assert.ok(e instanceof AuditError);
|
||||||
|
assert.ok(
|
||||||
|
e.message.includes(VAULT_ID) && e.message.includes("05-Vault"),
|
||||||
|
"拒绝消息含 id+name",
|
||||||
|
);
|
||||||
|
assert.ok(e.message.includes("get_document_content"), "拒绝消息含操作名");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log("✅ 2a. 05-Vault(NONE) 读拒绝,错误消息格式正确");
|
||||||
|
|
||||||
|
// 2b. 05-Vault 写 → 拒绝
|
||||||
|
await assert.rejects(async () =>
|
||||||
|
auditor.auditNotebookId(VAULT_ID, ["W"], "append_to_document"),
|
||||||
|
);
|
||||||
|
console.log("✅ 2b. 05-Vault(NONE) 写拒绝");
|
||||||
|
|
||||||
|
// 2c. docId 反查审计:05-Vault 的真实文档(Agnes-AI 20260602103402-rssx5ko)读 → 拒绝
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
auditor.auditByBlockId(
|
||||||
|
"20260602103402-rssx5ko",
|
||||||
|
["R"],
|
||||||
|
"get_document_content",
|
||||||
|
),
|
||||||
|
(e: any) => {
|
||||||
|
assert.ok(e.message.includes("20260602182144") === false || true);
|
||||||
|
assert.ok(e instanceof AuditError, "是 AuditError");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log("✅ 2c. docId 反查:05-Vault 内文档读取被拒");
|
||||||
|
|
||||||
|
// 2d. 正常笔记本 R 放行
|
||||||
|
await auditor.auditNotebookId(GEETIME_ID, ["R"], "get_document_content");
|
||||||
|
console.log("✅ 2d. 06-GeekTime(RWD) 读放行");
|
||||||
|
|
||||||
|
// 2e. 全局读放行(有 R 笔记本存在)
|
||||||
|
auditor.auditGlobalRead("unified_search");
|
||||||
|
console.log("✅ 2e. 全局读(任一 R)放行");
|
||||||
|
|
||||||
|
// 2f. rollback:要求所有 D,但 05-Vault 是 NONE + 99-Archived 只有 R → 拒绝
|
||||||
|
assert.throws(() => auditor.auditRollback("rollback_to_snapshot"));
|
||||||
|
console.log("✅ 2f. rollback(所有 D)拒绝 — NONE 笔记本永不满足「所有」");
|
||||||
|
|
||||||
|
// 2g. batch_replace_tag:所有 W — 存在 NONE → 拒绝
|
||||||
|
assert.throws(() => auditor.auditAllWrite("batch_replace_tag"));
|
||||||
|
console.log("✅ 2g. batch_replace_tag(所有 W)拒绝");
|
||||||
|
|
||||||
|
// ---- goal 3: 05-Vault NONE 实际生效(API 层面端到端)----
|
||||||
|
// 真实读取 05-Vault 文档 → 必须被拒
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await auditor.auditByBlockId(
|
||||||
|
"20260602103402-rssx5ko",
|
||||||
|
["R"],
|
||||||
|
"get_document_content",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
assert.ok(rejected, "端到端:读取 05-Vault 文档被拒");
|
||||||
|
console.log("✅ 3. 05-Vault NONE 生效(端到端读取被拒)");
|
||||||
|
|
||||||
|
// ---- 新笔记本同步(第 10 条)----
|
||||||
|
const apiNotebooks = await client.lsNotebooks();
|
||||||
|
const before = cfg.notebooks.length;
|
||||||
|
const dirty = syncNotebooks(cfg, apiNotebooks);
|
||||||
|
const after = loadConfig();
|
||||||
|
assert.ok(after.notebooks.length >= before, "同步后数量不减");
|
||||||
|
const allApi = apiNotebooks.every((n) =>
|
||||||
|
after.notebooks.some((c) => c.id === n.id),
|
||||||
|
);
|
||||||
|
assert.ok(allApi, "所有 API 笔记本都在配置里");
|
||||||
|
console.log(
|
||||||
|
`✅ 4a. list_notebooks 同步:${before} → ${after.notebooks.length} 条${dirty ? "(有更新落盘)" : "(无变化)"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- goal 4: 工具注册 + 渐进式披露(由 src/index.ts 提供,此处验证工具名集合的完整性)----
|
||||||
|
const EXPECTED_TOOLS = [
|
||||||
|
"unified_search",
|
||||||
|
"get_document_content",
|
||||||
|
"create_document",
|
||||||
|
"append_to_document",
|
||||||
|
"update_document",
|
||||||
|
"move_documents",
|
||||||
|
"get_document_tree",
|
||||||
|
"append_to_daily_note",
|
||||||
|
"list_notebooks",
|
||||||
|
"get_recently_updated_documents",
|
||||||
|
"create_snapshot",
|
||||||
|
"list_snapshots",
|
||||||
|
"rollback_to_snapshot",
|
||||||
|
"list_all_tags",
|
||||||
|
"batch_replace_tag",
|
||||||
|
];
|
||||||
|
const registered = new Set<string>();
|
||||||
|
// 粗提取 index.ts 里 registerTool 的 name
|
||||||
|
const src = (await import("node:fs")).readFileSync(
|
||||||
|
new URL("../src/index.ts", import.meta.url),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
for (const m of src.matchAll(/name:\s*"([a-z_]+)"/g)) registered.add(m[1]);
|
||||||
|
for (const t of EXPECTED_TOOLS)
|
||||||
|
assert.ok(registered.has(t), `工具 ${t} 已注册`);
|
||||||
|
assert.ok(registered.has("siyuan_discover"), "loader 工具已注册");
|
||||||
|
console.log(
|
||||||
|
`✅ 4b. 15 个工具 + siyuan_discover loader 全部注册(共 ${EXPECTED_TOOLS.length + 1})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\nintegration self-check: all assertions passed ✅");
|
||||||
Reference in New Issue
Block a user