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:
2026-09-02 19:07:04 +08:00
commit 305cdfb3f3
12 changed files with 3496 additions and 0 deletions
+26
View File
@@ -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
+45
View File
@@ -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)。
+21
View File
@@ -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.
+122
View File
@@ -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.tspi 会自动加载 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
+1920
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
+112
View File
@@ -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
View File
@@ -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_documentsfrom D + to W(第 9 条) */
auditMove(fromBox: string, toNotebook: string, operation: string): void {
this.auditNotebookId(fromBox, ["D"], operation);
this.auditNotebookId(toNotebook, ["W"], operation);
}
}
+432
View File
@@ -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}`);
}),
});
// ============ 渐进式披露 loadergoal 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"])]);
});
}
+222
View File
@@ -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 → 所属笔记本 idblocks.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;
}
}
+207
View File
@@ -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 ✅");
+152
View File
@@ -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 ✅");