From a73af3b5adbca8419bdfb68033c322a1d8594dfa Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Fri, 7 Aug 2026 17:48:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=E6=9C=AC=E5=9C=B0=20HTTP=20AP?= =?UTF-8?q?I=20=E5=A2=9E=E5=8A=A0=20Token=20=E9=89=B4=E6=9D=83=E4=B8=8E?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 Electron safeStorage 加密存储并自动初始化 API Token - 为 health 以外的接口增加 Bearer Token 鉴权 - 限制 CORS 仅允许可信本地 Origin - 增加鉴权、Token rotation、safeStorage 和手动验收测试 --- docs/agent/api-security.md | 12 + docs/agent/api.md | 16 + docs/agent/reader-skill.md | 11 + docs/agent/release-notes-v2.1.9.md | 11 + docs/skill/wechatexplorer-reader/SKILL.md | 151 +++++----- docs/user-guide/getting-started.md | 4 +- scripts/manual-test-local-api.sh | 116 +++++++ scripts/test-skill-install-instruction.cjs | 36 ++- src/main/api-token-store.ts | 134 +++++++++ src/main/http-server.ts | 77 ++++- src/main/index.ts | 44 ++- src/main/services/local-api-test-service.ts | 60 +++- src/main/services/skill-resource-service.ts | 2 +- src/preload/index.d.ts | 7 + src/preload/index.ts | 5 + src/renderer/src/components/SettingsPanel.tsx | 50 +-- .../src/features/api-center/ApiWorkspace.tsx | 8 +- .../components/ApiRequestTester.tsx | 11 +- .../api-center/components/ApiRuntimePanel.tsx | 66 +++- .../hooks/useApiCenterController.ts | 73 ++++- .../src/features/api-center/model/types.ts | 1 + .../utils/buildSkillInstallInstruction.ts | 2 +- .../utils/confirmApiTokenRotation.ts | 8 + src/renderer/src/styles/api.scss | 17 ++ src/shared/local-api-auth.ts | 14 + src/shared/local-api-test.ts | 13 +- tests/component/api-token-panel.test.tsx | 85 ++++++ tests/e2e/app.spec.ts | 19 ++ tests/e2e/support/electron-main.cjs | 25 ++ tests/integration/local-api-auth.test.ts | 284 ++++++++++++++++++ tests/integration/preload-contract.test.ts | 16 + .../api-token-rotation-confirmation.test.ts | 15 + tests/unit/api-token-store.test.ts | 65 ++++ 33 files changed, 1328 insertions(+), 130 deletions(-) create mode 100644 docs/agent/api-security.md create mode 100644 docs/agent/api.md create mode 100644 docs/agent/reader-skill.md create mode 100644 docs/agent/release-notes-v2.1.9.md create mode 100755 scripts/manual-test-local-api.sh create mode 100644 src/main/api-token-store.ts create mode 100644 src/renderer/src/features/api-center/utils/confirmApiTokenRotation.ts create mode 100644 src/shared/local-api-auth.ts create mode 100644 tests/component/api-token-panel.test.tsx create mode 100644 tests/integration/local-api-auth.test.ts create mode 100644 tests/unit/api-token-rotation-confirmation.test.ts create mode 100644 tests/unit/api-token-store.test.ts diff --git a/docs/agent/api-security.md b/docs/agent/api-security.md new file mode 100644 index 0000000..06c976f --- /dev/null +++ b/docs/agent/api-security.md @@ -0,0 +1,12 @@ +# Local API Security + +WechatExplorer 的安全模型是:本机回环地址 + 高熵 Bearer Token。 + +- Token 使用密码学安全随机源生成,并由 Electron safeStorage 加密保存。 +- 应用升级或首次启动时自动、幂等生成;应用重启后保持不变。 +- API Center 可以显示、复制或重新生成 Token。重新生成后旧 Token 立即失效。 +- `/api/v1/health` 公开且不返回聊天内容、数据库路径、Token 或 Provider 信息。 +- 其他 endpoint 缺少或使用错误 Token 时返回 `401 Unauthorized`。 +- CORS 仅允许精确的 localhost、127.0.0.1 和 ::1 HTTP Origin;无 Origin 的 curl、Node 和本地 Agent 请求正常工作。 + +本地 API 不应暴露到公网或不受信任网络。Bearer Token 提供本机 API 访问保护,但不是公网网关、用户账户系统或完整权限 Scope 系统。 diff --git a/docs/agent/api.md b/docs/agent/api.md new file mode 100644 index 0000000..ef0da0f --- /dev/null +++ b/docs/agent/api.md @@ -0,0 +1,16 @@ +# WechatExplorer Local HTTP API + +WechatExplorer v2.1.9 默认在 `127.0.0.1:6131` 提供 Local HTTP API。 + +- `GET /api/v1/health` 无需鉴权。 +- 其他数据和 Agent endpoint 需要 `Authorization: Bearer `。 +- Token 从 WechatExplorer → API Center → API Token 获取。 +- Token 不得放入 URL、仓库或共享配置。 + +```bash +export WECHATEXPLORER_API_TOKEN="" +curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \ + http://127.0.0.1:6131/api/v1/recent_chat +``` + +完整 endpoint 与使用流程见 [Reader Skill](../skill/wechatexplorer-reader/SKILL.md),安全边界见 [API Security](./api-security.md)。 diff --git a/docs/agent/reader-skill.md b/docs/agent/reader-skill.md new file mode 100644 index 0000000..7ad3a67 --- /dev/null +++ b/docs/agent/reader-skill.md @@ -0,0 +1,11 @@ +# Reader Skill Authentication + +WechatExplorer Reader 是 Local HTTP API Skill,不是 MCP Server。 + +1. 打开 WechatExplorer → API Center。 +2. 确认 API 和数据库已就绪。 +3. 在 API Token 区域复制 Token。 +4. 将它保存到 Agent 自己的本地环境配置:`WECHATEXPLORER_API_TOKEN=`。 +5. 安装 Reader Skill,并让所有数据请求携带 `Authorization: Bearer $WECHATEXPLORER_API_TOKEN`。 + +Codex、Claude Code、OpenClaw 和其他 Agent 均使用相同的 HTTP Bearer Token 模型。WechatExplorer 不会自动把 Token 写入任何 Agent 配置。 diff --git a/docs/agent/release-notes-v2.1.9.md b/docs/agent/release-notes-v2.1.9.md new file mode 100644 index 0000000..e2a2c7f --- /dev/null +++ b/docs/agent/release-notes-v2.1.9.md @@ -0,0 +1,11 @@ +# WechatExplorer v2.1.9 API Authentication + +v2.1.9 为 Local HTTP API 增加 Bearer Token 鉴权。这是有意的 breaking change。 + +- v2.1.8:`GET /api/v1/contact` 可能直接返回数据。 +- v2.1.9:相同请求必须携带 `Authorization: Bearer `,否则返回 `401`。 +- `GET /api/v1/health` 保持公开。 +- 老用户升级后会自动生成并安全保存 Token,不改变原有 apiEnabled、host 或 port 设置。 +- Token 可在 WechatExplorer → API Center 中显示、复制和重新生成。 + +Reader Skill 和本地 Agent 需要使用 `WECHATEXPLORER_API_TOKEN` 更新本机配置。 diff --git a/docs/skill/wechatexplorer-reader/SKILL.md b/docs/skill/wechatexplorer-reader/SKILL.md index 78632e3..ce8a461 100644 --- a/docs/skill/wechatexplorer-reader/SKILL.md +++ b/docs/skill/wechatexplorer-reader/SKILL.md @@ -11,7 +11,8 @@ description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊 - **本服务由 WechatExplorer.app 提供**,数据完全在本地处理,不会上传任何服务器 - 用户必须在 WechatExplorer 主窗口完成**首次密钥配置**(解锁 WCDB 数据库) -- 默认监听 `127.0.0.1:6131`,仅本机可访问,无需鉴权 +- 默认监听 `127.0.0.1:6131`,仅本机可访问 +- 除 health 外的 API 均要求 Bearer Token。Token 获取路径:WeChatExplorer → API Center → API Token → 显示/复制 Token ## 前置条件 @@ -19,21 +20,48 @@ description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊 2. **首次启动时完成密钥配置**:在主界面第一步输入微信数据库密钥(64 位 hex),完成 WCDB 初始化 3. **如需 7×24 提供 API**:用 `WXE_TRAY=1` 或 `--tray` 参数启动 app,启用菜单栏常驻模式(主窗口关闭后服务仍在) +## Authentication + +WechatExplorer Reader 使用的是 **WechatExplorer Local HTTP API**,不是 MCP Server。 + +1. 在 WechatExplorer 中打开 **API Center**。 +2. 在 **API Token** 区域点击“复制 Token”。 +3. 把 Token 保存到 Agent 自己的本地环境配置中: + +```bash +export WECHATEXPLORER_API_TOKEN="" +``` + +health 可以不带 Token: + +```bash +curl http://127.0.0.1:6131/api/v1/health +``` + +除 health 外,所有请求必须使用标准 Bearer header: + +```bash +curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \ + http://127.0.0.1:6131/api/v1/recent_chat +``` + +本文件后续写出的所有 `GET` / `POST` 数据请求都默认包含上述 Authorization header。禁止把 Token 放入 URL query、路径、Skill 文件或仓库。 + ## API 列表 GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图)。所有端点返回 JSON。 -| 端点 | 用途 | 关键参数 | -|------|------|---------| -| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — | -| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — | -| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) | -| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `keyword` | -| `GET /api/v1/recent_chat` | 最近会话 | `limit`(默认 50) | -| `GET /api/v1/chatlog` | 聊天记录 | `talker`、`time` 或 `startTime`/`endTime` | -| `GET /api/v1/group_snapshot` | 群成员快照 | `md5` | -| `GET /api/v1/resolve` | 把昵称/wxid/md5 解析成 md5 | `q` | -| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) | +| 端点 | 用途 | 关键参数 | +| ---------------------------- | ------------------------------------- | ----------------------------------------------------------------- | +| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — | +| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — | +| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) | +| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `keyword` | +| `GET /api/v1/recent_chat` | 最近会话 | `limit`(默认 50) | +| `GET /api/v1/chatlog` | 聊天记录 | `talker`、`time` 或 `startTime`/`endTime` | +| `GET /api/v1/group_snapshot` | 群成员快照 | `md5` | +| `GET /api/v1/resolve` | 把昵称/wxid/md5 解析成 md5 | `q` | +| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) | ### `talker` 参数可接受的值 @@ -51,8 +79,8 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 ```json { - "m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker - "m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer + "m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker + "m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer "type": "group", "md5": "..." } @@ -64,12 +92,12 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 支持以下格式: -| 输入 | 含义 | -|------|------| -| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 | -| `2026-07-01~2026-07-03` | 日期范围(闭区间) | -| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) | -| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 | +| 输入 | 含义 | +| ----------------------------------- | ---------------------------- | +| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 | +| `2026-07-01~2026-07-03` | 日期范围(闭区间) | +| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) | +| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 | 也可以直接传 unix 秒级时间戳作为 `startTime` 和 `endTime`。 @@ -92,6 +120,7 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 **步骤 3**:用计算后的参数调 `chatlog`。 示例: + - 用户: "今天 摸鱼交流群 聊了啥?" - AI: 先 `GET /api/v1/current_time` → 得到 `2026-07-03T14:30:00+08:00` → 计算 `time=2026-07-03` → `GET /api/v1/chatlog?talker=摸鱼交流群&time=2026-07-03` @@ -119,33 +148,41 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 { "title": "话题标题", "timeRange": "10:00-12:30", - "heat": "高", // "高" | "中" | "低" + "heat": "高", // "高" | "中" | "低" "participants": ["张三", "李四"], "summary": "本话题讨论了什么", "conclusion": "可选,达成的结论", "keywords": ["关键词1", "关键词2"] } ], - "resources": [ - { "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" } - ], + "resources": [{ "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" }], "importantMessages": [ { "sender": "张三", "time": "10:23", "content": "原消息文本", "note": "为什么重要" } ], "quotes": [ { - "messages": [{ "sender": "李四", "content": "原话1" }, { "sender": "王五", "content": "原话2" }], + "messages": [ + { "sender": "李四", "content": "原话1" }, + { "sender": "王五", "content": "原话2" } + ], "note": "为什么这些话值得引用" } ], - "qa": [ - { "question": "Q", "answer": "A", "answerer": "解答人(可选)" } - ], + "qa": [{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }], "unresolved": [ - { "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" } + { + "question": "待跟进问题", + "owner": "相关人(可选)", + "status": "待跟进", + "note": "为什么还没结束" + } ], "storylines": [ - { "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" } + { + "title": "剧情线", + "stages": [{ "time": "10:12", "event": "提出问题" }], + "result": "可选结果" + } ], "reversals": [ { "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" } @@ -191,7 +228,7 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 { "success": true, "htmlPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.html", - "pngPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.png", + "pngPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.png", "imageDataUrl": "data:image/png;base64,iVBORw0K..." } ``` @@ -254,12 +291,12 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 模板顶部的 4 个统计格(`消息数 / 活跃人数 / 时间跨度 / 主要话题`)宽度均分,内容过长会被截断或换行: -| 字段 | 推荐格式 | 反例(会撑爆格子) | -|------|---------|----------------| -| `metadata.messageCount` | 纯数字 `"1234"` | `"约 1.2k 条"` | -| `metadata.activeUsers` | 纯数字 `"56"` | `"大约 50 多人"` | -| `metadata.timeSpan` | **持续时长紧凑半角** `"1 h"` / `"30 min"` / `"2 d"` | `"1 小时"` / `"7 小时"` / `"1天3小时"` | -| `metadata.topicCount` 等 | 数字 / 短中文 | 长句子 | +| 字段 | 推荐格式 | 反例(会撑爆格子) | +| ------------------------ | --------------------------------------------------- | -------------------------------------- | +| `metadata.messageCount` | 纯数字 `"1234"` | `"约 1.2k 条"` | +| `metadata.activeUsers` | 纯数字 `"56"` | `"大约 50 多人"` | +| `metadata.timeSpan` | **持续时长紧凑半角** `"1 h"` / `"30 min"` / `"2 d"` | `"1 小时"` / `"7 小时"` / `"1天3小时"` | +| `metadata.topicCount` 等 | 数字 / 短中文 | 长句子 | `timeSpan` 是**首条到末条消息的持续时长**,不是时间区间。**单位用半角空格分隔**: @@ -295,17 +332,20 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 ## 典型工作流示例 **示例 1:今日群聊总结(纯文本)** + 1. `GET /api/v1/current_time` → 获取今天日期 2. `GET /api/v1/chatroom?keyword=技术交流` → 找到目标群 md5 3. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉取今天的聊天 4. AI 用 LLM 生成总结报告(话题 TOP N、最活跃发言者等) **示例 2:搜索特定消息上下文** + 1. `GET /api/v1/chatlog?talker=摸鱼群&time=2026-07-01~2026-07-03` → 粗查近 3 天 2. 在返回的消息中定位关键词出现的时间点 T1, T2, ... 3. 对每个 Ti 分别查 `chatlog?talker=摸鱼群&time=Ti-15min~Ti+15min`,分析上下文 **示例 3:群日报(可视化长图)** + 1. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉今天聊天 2. LLM 按上方 `GroupDailyReport` schema 总结出 `report` + `metadata` 3. `POST /api/v1/report` body = 上述 JSON → 拿到 `htmlPath` / `pngPath` / `imageDataUrl` @@ -313,6 +353,7 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 ## 错误处理 +- `401 unauthorized` → Token 缺失、格式错误、已被重新生成或配置不正确;请回到 API Center 复制当前 Token - `503` → WechatExplorer 未初始化(密钥未配置),提示用户在主窗口完成配置 - `404 talker not found` → talker 不存在,先调 `contact` 或 `resolve` 确认 md5/wxid - `400 missing required parameter` → 检查必填参数(talker / md5 / q) @@ -321,35 +362,11 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 - `400 请求体为空 / 需包含 report 和 metadata` → 调用 `/report` 时 body 必须是非空 JSON,且有这两个顶层字段 - `500 success=false` → 模板渲染失败,通常因 `report` 字段缺失或 `metadata.groupName/reportDate` 为空,检查后重试 -## 配置 Claude Desktop +## 配置 Codex / Claude Code / OpenClaw -把以下加入 `~/Library/Application Support/Claude/claude_desktop_config.json`: +- **Codex**:安装本 Skill,并在启动 Codex 的本地 shell 或项目私有环境中设置 `WECHATEXPLORER_API_TOKEN`。 +- **Claude Code**:安装本 Skill,并在启动 Claude Code 的本地 shell 或私有环境配置中设置 `WECHATEXPLORER_API_TOKEN`。 +- **OpenClaw**:安装本 Skill,把 `WECHATEXPLORER_API_TOKEN` 放入 OpenClaw 自己的本地 secret / environment 配置。 +- **其他 Agent**:确保执行 HTTP 请求的本地进程能读取 `WECHATEXPLORER_API_TOKEN`。 -```json -{ - "mcpServers": { - "wechatexplorer": { - "command": "npx", - "args": ["-y", "@wechatexplorer/mcp-bridge"] - } - } -} -``` - -(待 P3 实现 — MCP bridge 包,在此之前可直接用 `curl` 调用 HTTP API,或通过 mcp-remote 桥接。) - -## 配置 Claude Code / Codex - -在 `~/.claude/settings.json` 或项目级 `.claude/settings.local.json` 中: - -```json -{ - "mcpServers": { - "wechatexplorer": { - "url": "http://127.0.0.1:6131" - } - } -} -``` - -(视 MCP over HTTP 支持情况调整) +不要把 `http://127.0.0.1:6131` 配置成 `mcpServers.url`;6131 提供的是 Local HTTP API,不是 MCP Server。 diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index 968cf21..9ebc694 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -224,7 +224,7 @@ Windows 当前不会扫描二级目录,请确认目录没有多选或少选一 4. 复制安装指令,粘贴给对应 Agent 执行。 5. 安装完成后,让 Agent 读取和总结本地聊天。 -本地 API 默认地址为 `http://127.0.0.1:6131`,默认仅监听本机且无鉴权。详细端点和参数见 [Reader Skill 文档](../skill/wechatexplorer-reader/SKILL.md)。 +本地 API 默认地址为 `http://127.0.0.1:6131`,默认仅监听本机。除 health 外的数据接口需要 Bearer Token;Token 可在 API Center 中显示或复制。详细端点和参数见 [Reader Skill 文档](../skill/wechatexplorer-reader/SKILL.md)。 ### Agent Hub @@ -235,7 +235,7 @@ Windows 当前不会扫描二级目录,请确认目录没有多选或少选一 - WechatExplorer 只读取你有权访问的本机微信数据。 - 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。 - 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。 -- 本地 API 默认监听 `127.0.0.1`,且无鉴权。不要将它暴露在不可信的局域网环境中。 +- 本地 API 默认监听 `127.0.0.1`,并要求 Bearer Token。它仍面向个人本机使用,不建议暴露到公网或不受信任网络。 ## 仍然无法解决? diff --git a/scripts/manual-test-local-api.sh b/scripts/manual-test-local-api.sh new file mode 100755 index 0000000..93474ba --- /dev/null +++ b/scripts/manual-test-local-api.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash + +# WechatExplorer v2.1.9 Local HTTP API 手动验收脚本 +# 仅用于 macOS Terminal;不会写入或输出真实 API Token。 + +set -u + +API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:6131}" +API_BASE_URL="${API_BASE_URL%/}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wechatexplorer-api-test.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +PASS_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 + +pass() { PASS_COUNT=$((PASS_COUNT + 1)); printf 'PASS %s\n' "$1"; } +fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); printf 'FAIL %s%s\n' "$1" "${2:+ ($2)}"; } +skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); printf 'SKIP %s\n' "$1"; } + +printf 'WechatExplorer Local HTTP API 手动测试\n' +printf 'API 地址: %s\n\n' "$API_BASE_URL" +read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN +printf '\n' +if [[ -z "$API_TOKEN" ]]; then + printf 'Token 不能为空。\n' + exit 2 +fi + +request() { + local method="$1" path="$2" auth="$3" origin="$4" body="${5:-}" + local out="$TMP_DIR/body" headers="$TMP_DIR/headers" err="$TMP_DIR/error" + local -a args=(--silent --show-error --max-time 10 -X "$method" -D "$headers" -o "$out" -w '%{http_code}') + [[ "$auth" == 1 ]] && args+=(-H "Authorization: Bearer $API_TOKEN") + [[ "$auth" == invalid ]] && args+=(-H 'Authorization: Bearer invalid') + [[ "$auth" == malformed ]] && args+=(-H 'Authorization: abc') + [[ "$auth" == bearer-only ]] && args+=(-H 'Authorization: Bearer') + [[ -n "$origin" ]] && args+=(-H "Origin: $origin") + if [[ -n "$body" ]]; then args+=(-H 'Content-Type: application/json' --data "$body"); fi + : >"$out" + : >"$headers" + : >"$err" + local status + status="$(curl "${args[@]}" "$API_BASE_URL$path" 2>"$err")" + CURL_STATUS="$status" + CURL_BODY="$(<"$out")" + CURL_HEADERS="$(<"$headers")" +} + +expect_status() { + local name="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then pass "$name ($actual)"; else fail "$name" "期望 ${expected},实际 ${actual:-000}"; fi +} + +printf '%s\n' '--- 基础鉴权 ---' +request GET /api/v1/health 0 '' +expect_status 'health 无 Token' 200 "$CURL_STATUS" + +request GET /api/v1/current_time 0 '' +expect_status '受保护 endpoint 无 Token' 401 "$CURL_STATUS" + +request GET /api/v1/current_time invalid '' +expect_status '错误 Token' 401 "$CURL_STATUS" + +request GET /api/v1/current_time 1 '' +expect_status '正确 Token' 200 "$CURL_STATUS" + +request GET /api/v1/current_time malformed '' +expect_status 'Authorization: abc' 401 "$CURL_STATUS" + +request GET /api/v1/current_time bearer-only '' +expect_status 'Authorization: Bearer' 401 "$CURL_STATUS" + +printf '%s\n' '--- CORS ---' +request OPTIONS /api/v1/health 0 http://localhost +expect_status 'OPTIONS / CORS localhost' 204 "$CURL_STATUS" +if [[ "$CURL_HEADERS" == *'Access-Control-Allow-Origin: http://localhost'* && "$CURL_HEADERS" == *'Access-Control-Allow-Headers: Content-Type, Authorization'* ]]; then + pass 'localhost Origin 响应头' +else + fail 'localhost Origin 响应头' +fi + +request OPTIONS /api/v1/health 0 http://evil.example.com +expect_status 'evil Origin 被拒绝' 403 "$CURL_STATUS" + +request GET /api/v1/health 0 '' +if [[ "$CURL_STATUS" == 200 ]]; then pass '无 Origin 的 curl 请求'; else fail '无 Origin 的 curl 请求' "实际 ${CURL_STATUS:-000}"; fi + +printf '%s\n' '--- API stop 后连接测试 ---' +RUN_STOP_CHECK="${RUN_STOP_CHECK:-0}" +if [[ -t 0 && "$RUN_STOP_CHECK" != 1 ]]; then + read -r -p '现在请在 API Center 停止 API;完成后输入 y 验证连接失败,其他键跳过: ' STOP_CONFIRM + [[ "$STOP_CONFIRM" == y || "$STOP_CONFIRM" == Y ]] && RUN_STOP_CHECK=1 +fi +if [[ "$RUN_STOP_CHECK" == 1 ]]; then + request GET /api/v1/health 0 '' + if [[ "$CURL_STATUS" == 000 ]]; then + pass 'API 已停止后连接失败' + else + fail 'API stop 后连接失败' "仍收到 HTTP ${CURL_STATUS:-000}" + fi +else + skip '未执行 stop 验证;也可在停止 API 后使用 RUN_STOP_CHECK=1 重新运行' +fi + +printf '\n%s\n' '--- 人工验证项目(脚本不会自动操作) ---' +printf '%s\n' '1. API Center 默认隐藏 Token,点击“显示 Token”后可见,再点击隐藏。' +printf '%s\n' '2. 点击“复制 Token”,粘贴到安全位置确认复制成功;终端不要回显 Token。' +printf '%s\n' '3. 点击“重新生成 Token”并确认二次确认提示。' +printf '%s\n' '4. rotation 后,用旧 Token 请求 /api/v1/current_time 应立即返回 401。' +printf '%s\n' '5. 重启 App 后 Token 应保持不变。' +printf '%s\n' '6. 将 apiEnabled=false 后,API 应不再监听(可重新运行本脚本的 stop 测试)。' + +printf '\n结果:PASS=%d FAIL=%d SKIP=%d\n' "$PASS_COUNT" "$FAIL_COUNT" "$SKIP_COUNT" +if (( FAIL_COUNT > 0 )); then exit 1; fi +exit 0 diff --git a/scripts/test-skill-install-instruction.cjs b/scripts/test-skill-install-instruction.cjs index a8c3f97..0bbe501 100644 --- a/scripts/test-skill-install-instruction.cjs +++ b/scripts/test-skill-install-instruction.cjs @@ -15,12 +15,21 @@ const filePath = path.join( 'buildSkillInstallInstruction.ts' ) const source = fs.readFileSync(filePath, 'utf8') -const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText +const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS } +}).outputText const moduleExports = {} -new Function('exports', 'require', 'module', output)(moduleExports, require, { exports: moduleExports }) +new Function('exports', 'require', 'module', output)(moduleExports, require, { + exports: moduleExports +}) const { buildSkillInstallInstruction } = moduleExports -const local = { type: 'local', directoryPath: 'C:/skill/wechatexplorer-reader', skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', version: 'v1.0' } +const local = { + type: 'local', + directoryPath: 'C:/skill/wechatexplorer-reader', + skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', + version: 'v1.0' +} for (const [target, expected] of [ ['codex', 'Codex 项目或用户 Skill 目录'], @@ -28,17 +37,32 @@ for (const [target, expected] of [ ['openclaw', '作为 WechatExplorer Reader Skill 安装'], ['generic', '读取并安装'] ]) { - const text = buildSkillInstallInstruction({ target, source: local, apiBaseUrl: { host: '127.0.0.1', port: 6131 } }) + const text = buildSkillInstallInstruction({ + target, + source: local, + apiBaseUrl: { host: '127.0.0.1', port: 6131 } + }) assert.match(text, new RegExp(expected)) assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/) + assert.match(text, /WECHATEXPLORER_API_TOKEN/) + assert.match(text, /Authorization: Bearer/) + assert.doesNotMatch(text, /mcpServers/) } assert.match( - buildSkillInstallInstruction({ target: 'codex', source: local, apiBaseUrl: { host: '0.0.0.0', port: 7000 } }), + buildSkillInstallInstruction({ + target: 'codex', + source: local, + apiBaseUrl: { host: '0.0.0.0', port: 7000 } + }), /http:\/\/127\.0\.0\.1:7000\/api\/v1\/health/ ) assert.match( - buildSkillInstallInstruction({ target: 'generic', source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' }, apiBaseUrl: { host: 'localhost', port: 6131 } }), + buildSkillInstallInstruction({ + target: 'generic', + source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' }, + apiBaseUrl: { host: 'localhost', port: 6131 } + }), /https:\/\/example\.com\/skill/ ) diff --git a/src/main/api-token-store.ts b/src/main/api-token-store.ts new file mode 100644 index 0000000..07fd5e3 --- /dev/null +++ b/src/main/api-token-store.ts @@ -0,0 +1,134 @@ +import crypto from 'crypto' +import { app, safeStorage } from 'electron' +import fs from 'fs-extra' +import path from 'path' +import type { + ApiTokenActionResult, + ApiTokenRevealResult, + ApiTokenStatus +} from '../shared/local-api-auth' + +const MASKED_TOKEN = '••••••••••••••••' +const TOKEN_BYTES = 32 +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/ + +interface TokenReadResult { + success: boolean + token?: string + error?: string +} + +export class ApiTokenStore { + private cachedToken: string | null = null + + constructor(private readonly filePathOverride?: string) {} + + private get filePath(): string { + return this.filePathOverride || path.join(app.getPath('userData'), 'local-api-token.bin') + } + + getStatus(): ApiTokenStatus { + const available = safeStorage.isEncryptionAvailable() + if (!available) { + return { + available: false, + hasToken: false, + maskedToken: MASKED_TOKEN, + error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。' + } + } + const result = this.read() + return { + available: true, + hasToken: result.success && Boolean(result.token), + maskedToken: MASKED_TOKEN, + ...(result.success ? {} : { error: result.error }) + } + } + + ensureToken(): ApiTokenActionResult { + if (!safeStorage.isEncryptionAvailable()) { + return { + success: false, + available: false, + hasToken: false, + maskedToken: MASKED_TOKEN, + error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。' + } + } + const current = this.read() + if (!current.success) return this.actionError(current.error) + if (current.token) return this.actionSuccess() + return this.persist(this.generateToken()) + } + + revealToken(): ApiTokenRevealResult { + const ensured = this.ensureToken() + if (!ensured.success) return ensured + return { ...ensured, token: this.cachedToken || undefined } + } + + rotateToken(): ApiTokenActionResult { + if (!safeStorage.isEncryptionAvailable()) return this.ensureToken() + return this.persist(this.generateToken()) + } + + getTokenForAuthentication(): string | null { + if (this.cachedToken) return this.cachedToken + const result = this.read() + return result.success ? result.token || null : null + } + + private generateToken(): string { + return crypto.randomBytes(TOKEN_BYTES).toString('base64url') + } + + private read(): TokenReadResult { + if (this.cachedToken) return { success: true, token: this.cachedToken } + if (!safeStorage.isEncryptionAvailable()) { + return { success: false, error: '系统安全存储不可用' } + } + if (!fs.existsSync(this.filePath)) return { success: true } + try { + const token = safeStorage.decryptString(fs.readFileSync(this.filePath)) + if (!TOKEN_PATTERN.test(token)) throw new Error('invalid token data') + this.cachedToken = token + return { success: true, token } + } catch { + return { success: false, error: '已保存的 API Token 无法从系统安全存储读取' } + } + } + + private persist(token: string): ApiTokenActionResult { + try { + fs.ensureDirSync(path.dirname(this.filePath)) + fs.writeFileSync(this.filePath, safeStorage.encryptString(token), { mode: 0o600 }) + fs.chmodSync(this.filePath, 0o600) + this.cachedToken = token + return this.actionSuccess() + } catch { + return this.actionError('API Token 无法保存到系统安全存储') + } + } + + private actionSuccess(): ApiTokenActionResult { + return { + success: true, + available: true, + hasToken: true, + maskedToken: MASKED_TOKEN + } + } + + private actionError(error?: string): ApiTokenActionResult { + return { + success: false, + available: safeStorage.isEncryptionAvailable(), + hasToken: false, + maskedToken: MASKED_TOKEN, + error: error || 'API Token 安全存储不可用' + } + } +} + +export const apiTokenStore = new ApiTokenStore() diff --git a/src/main/http-server.ts b/src/main/http-server.ts index f7ea559..cb4b6e6 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto' import http, { IncomingMessage, ServerResponse, Server } from 'http' import { isReady, @@ -12,6 +13,7 @@ import { GroupReportExportRequest } from '../shared/group-report' import { generateAgentGroupReport } from './services/agent-group-report-service' import { agentHubService } from './services/agent-hub-service' import { safeError, safeLog, safeWarn } from './safe-log' +import { apiTokenStore } from './api-token-store' export const DEFAULT_HTTP_HOST = '127.0.0.1' export const DEFAULT_HTTP_PORT = 6131 @@ -29,6 +31,10 @@ interface RouteContext { body?: unknown } +export interface HttpServerOptions { + tokenProvider?: () => string | null +} + type RouteHandler = (ctx: RouteContext) => void | Promise function sendJson(res: ServerResponse, status: number, payload: unknown): void { @@ -36,12 +42,50 @@ function sendJson(res: ServerResponse, status: number, payload: unknown): void { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), - 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store' }) res.end(body) } +function isAllowedCorsOrigin(origin: string): boolean { + if (!/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(origin)) return false + try { + const parsed = new URL(origin) + if (parsed.protocol !== 'http:') return false + if (parsed.username || parsed.password) return false + return ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname.toLowerCase()) + } catch { + return false + } +} + +function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean { + const origin = req.headers.origin + if (!origin) return true + if (!isAllowedCorsOrigin(origin)) return false + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Vary', 'Origin') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + return true +} + +function isAuthorized(req: IncomingMessage, expectedToken: string | null): boolean { + const header = req.headers.authorization + const match = typeof header === 'string' ? /^Bearer ([A-Za-z0-9_-]+)$/.exec(header) : null + if (!match || !expectedToken) return false + const actualDigest = crypto.createHash('sha256').update(match[1], 'utf8').digest() + const expectedDigest = crypto.createHash('sha256').update(expectedToken, 'utf8').digest() + return crypto.timingSafeEqual(actualDigest, expectedDigest) +} + +function sendUnauthorized(res: ServerResponse): void { + sendJson(res, 401, { + error: 'unauthorized', + message: 'Valid API token required' + }) +} + function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void { sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) }) } @@ -301,24 +345,28 @@ const routes: Record = { export function startHttpServer( host: string = DEFAULT_HTTP_HOST, - port: number = DEFAULT_HTTP_PORT + port: number = DEFAULT_HTTP_PORT, + options: HttpServerOptions = {} ): Promise { + const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication()) return new Promise((resolve, reject) => { const server: Server = http.createServer(async (req, res) => { try { const url = new URL(req.url || '/', `http://${host}:${port}`) + if (!applyCorsHeaders(req, res)) { + return sendError(res, 403, 'Origin 不允许访问本地 API') + } if (req.method === 'OPTIONS') { - res.writeHead(204, { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': '*' - }) + res.writeHead(204) return res.end() } const handler = routes[url.pathname] if (!handler) { return sendError(res, 404, `端点不存在: ${url.pathname}`) } + if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) { + return sendUnauthorized(res) + } let body: string | undefined if (req.method && req.method !== 'GET' && req.method !== 'HEAD') { body = await readBody(req) @@ -391,11 +439,24 @@ export const apiServer = { return this.getState() } + const token = apiTokenStore.ensureToken() + if (!token.success) { + singletonState = { + running: false, + host, + port, + error: token.error || 'API Token 安全存储不可用' + } + return { ...singletonState } + } + const maxAttempts = 4 let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { - singleton = await startHttpServer(host, port) + singleton = await startHttpServer(host, port, { + tokenProvider: () => apiTokenStore.getTokenForAuthentication() + }) singletonState = { running: true, host: singleton.host, diff --git a/src/main/index.ts b/src/main/index.ts index 9704a70..35d6bf9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -43,6 +43,7 @@ import type { LegacyAIConfig } from '../shared/ai-provider' import { DatabaseKeyStore } from './database-key-store' +import { apiTokenStore } from './api-token-store' import { ImageKeyConfigService } from './services/image-key-config-service' import { AIProviderService } from './services/ai-provider-service' import { imageInsightService } from './services/image-insight-service' @@ -58,7 +59,7 @@ import { KeyService as KeyServiceWin } from './key-service-win' import * as chat from './services/chat-service' import { apiServer } from './http-server' import { skillResourceService } from './services/skill-resource-service' -import { testLocalApiRequest } from './services/local-api-test-service' +import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service' import { isWechatRunning } from './services/wechat-process-status' import { inspectImageDecryptionStatus, @@ -321,7 +322,10 @@ function getLocalMediaMimeType(filePath: string): string { } } -function buildImageResponse(image: DecodedImage, includeData = false): { +function buildImageResponse( + image: DecodedImage, + includeData = false +): { success: true data: string isThumb: boolean @@ -443,8 +447,8 @@ app.whenReady().then(async () => { app.getPath('userData'), join(__dirname, 'knowledgeWorker.js') ) - knowledgeSearchService.setVoiceTranscriptResolver((reference) => - voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' } + knowledgeSearchService.setVoiceTranscriptResolver( + (reference) => voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' } ) voiceRecognition.onTranscriptUpdate((update) => knowledgeSearchService?.indexVoiceTranscript(update) @@ -1441,6 +1445,25 @@ app.whenReady().then(async () => { ipcMain.handle('api:getStatus', () => apiServer.getState()) + ipcMain.handle('api:tokenStatus', () => apiTokenStore.ensureToken()) + ipcMain.handle('api:revealToken', () => apiTokenStore.revealToken()) + ipcMain.handle('api:copyToken', () => { + const result = apiTokenStore.revealToken() + if (!result.token) return { ...result, success: false } + try { + clipboard.writeText(result.token) + return { + success: true, + available: result.available, + hasToken: result.hasToken, + maskedToken: result.maskedToken + } + } catch { + return { ...apiTokenStore.getStatus(), success: false, error: 'API Token 复制失败' } + } + }) + ipcMain.handle('api:rotateToken', () => apiTokenStore.rotateToken()) + ipcMain.handle('api:start', async (_, host?: string, port?: number) => { const settings = loadSettings() const target = { @@ -1466,6 +1489,16 @@ app.whenReady().then(async () => { ipcMain.handle('api:revealSkill', () => skillResourceService.reveal()) ipcMain.handle('api:openSkillGithub', () => skillResourceService.openGithub()) ipcMain.handle('api:testLocalRequest', (_, request) => testLocalApiRequest(request)) + ipcMain.handle('api:copyCurl', (_, request) => { + const result = buildLocalApiCurlCommand(request) + if (!result.success || !result.command) return { success: false, error: result.error } + try { + clipboard.writeText(result.command) + return { success: true } + } catch { + return { success: false, error: 'curl 命令复制失败' } + } + }) ipcMain.handle('api:copyText', (_, text: unknown) => { if (typeof text !== 'string' || text.length > 1024 * 1024) { return { success: false, error: '复制内容无效或过大' } @@ -1499,6 +1532,9 @@ app.whenReady().then(async () => { // 启动本地 HTTP API(由 settings.apiEnabled 控制) const settings = loadSettings() + // v2.1.8 and earlier did not have an API token. Generate it once during + // upgrade/startup without changing any existing API or database settings. + apiTokenStore.ensureToken() if (settings.apiEnabled) { await apiServer.start(settings.apiHost, settings.apiPort) } diff --git a/src/main/services/local-api-test-service.ts b/src/main/services/local-api-test-service.ts index 11f4c8f..a088dea 100644 --- a/src/main/services/local-api-test-service.ts +++ b/src/main/services/local-api-test-service.ts @@ -1,5 +1,6 @@ import http from 'http' import { apiServer } from '../http-server' +import { apiTokenStore } from '../api-token-store' import { LOCAL_API_ENDPOINTS, type LocalApiEndpointId, @@ -45,6 +46,47 @@ function parseBody(bodyText: string, contentType?: string): { json?: unknown; bo return { bodyText } } +export function buildLocalApiCurlCommand(payload: unknown): { + success: boolean + command?: string + error?: string +} { + if (!payload || typeof payload !== 'object') return { success: false, error: '请求格式无效' } + const { endpointId, query = {}, body = '' } = payload as Partial + if (!isEndpointId(endpointId)) return { success: false, error: '不允许访问该 API 端点' } + if (!query || typeof query !== 'object' || Array.isArray(query)) + return { success: false, error: '查询参数格式无效' } + if (typeof body !== 'string' || Buffer.byteLength(body) > MAX_BODY_SIZE) + return { success: false, error: '请求体格式无效' } + + const endpoint = LOCAL_API_ENDPOINTS[endpointId] + const entries = Object.entries(query) + if ( + entries.some( + ([key, value]) => !endpoint.queryKeys.includes(key as never) || typeof value !== 'string' + ) + ) { + return { success: false, error: '查询参数不属于当前端点' } + } + const service = apiServer.getState() + const targetHost = requestHost(service.host) + const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost + const url = new URL(endpoint.path, `http://${hostPart}:${service.port}`) + entries.forEach(([key, value]) => { + if (value.trim()) url.searchParams.set(key, value.trim()) + }) + const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication() + if (endpointId !== 'health' && !token) { + return { success: false, error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态' } + } + const authHeader = token ? ` -H 'Authorization: Bearer ${token}'` : '' + const command = + endpoint.method === 'POST' + ? `curl -X POST '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'` + : `curl '${url.toString()}'${authHeader}` + return { success: true, command } +} + export async function testLocalApiRequest(payload: unknown): Promise { if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效') const { endpointId, query = {}, body = '' } = payload as Partial @@ -94,11 +136,27 @@ export async function testLocalApiRequest(payload: unknown): Promise = {} + if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json' + if (token) headers.Authorization = `Bearer ${token}` const request = http.request( url, { method: endpoint.method, - headers: endpoint.method === 'POST' ? { 'Content-Type': 'application/json' } : undefined + headers }, (response) => { const chunks: Buffer[] = [] diff --git a/src/main/services/skill-resource-service.ts b/src/main/services/skill-resource-service.ts index 2a523c2..dc3cf4a 100644 --- a/src/main/services/skill-resource-service.ts +++ b/src/main/services/skill-resource-service.ts @@ -48,7 +48,7 @@ function getStatus(): SkillResourceStatus { } return { available: true, - version: 'v1.0', + version: 'v1.1', filePath, directoryPath, source, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index ba8f2c4..de0c44b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -445,6 +445,10 @@ declare global { port: number error?: string }> + apiTokenStatus: () => Promise + revealApiToken: () => Promise + copyApiToken: () => Promise + rotateApiToken: () => Promise apiStart: ( host?: string, port?: number @@ -469,6 +473,9 @@ declare global { revealReaderSkill: () => Promise<{ success: boolean; error?: string }> openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }> testLocalApiRequest: (request: LocalApiTestRequest) => Promise + copyLocalApiCurl: ( + request: LocalApiTestRequest + ) => Promise copyText: (text: string) => Promise<{ success: boolean; error?: string }> // ============================================================ // AI 图片理解基础设施(ImageInsightService) diff --git a/src/preload/index.ts b/src/preload/index.ts index be393c8..a997f81 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -253,6 +253,10 @@ const api = { disconnectDb: (options?: { closeNative?: boolean }) => ipcRenderer.invoke('db:disconnect', options), apiStatus: () => ipcRenderer.invoke('api:getStatus'), + apiTokenStatus: () => ipcRenderer.invoke('api:tokenStatus'), + revealApiToken: () => ipcRenderer.invoke('api:revealToken'), + copyApiToken: () => ipcRenderer.invoke('api:copyToken'), + rotateApiToken: () => ipcRenderer.invoke('api:rotateToken'), apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port), apiStop: () => ipcRenderer.invoke('api:stop'), apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled), @@ -261,6 +265,7 @@ const api = { revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'), openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'), testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request), + copyLocalApiCurl: (request) => ipcRenderer.invoke('api:copyCurl', request), copyText: (text: string) => ipcRenderer.invoke('api:copyText', text), // ============================================================ // AI 图片理解基础设施(ImageInsightService) diff --git a/src/renderer/src/components/SettingsPanel.tsx b/src/renderer/src/components/SettingsPanel.tsx index c33f947..875a88c 100644 --- a/src/renderer/src/components/SettingsPanel.tsx +++ b/src/renderer/src/components/SettingsPanel.tsx @@ -73,9 +73,12 @@ export const SettingsPanel: React.FC = ({ const [settings, setSettings] = useState(null) const [settingsPath, setSettingsPath] = useState('') const [apiState, setApiState] = useState(null) - const [testStatus, setTestStatus] = useState< - { kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string } - >({ kind: 'idle', message: '' }) + const [testStatus, setTestStatus] = useState<{ + kind: 'idle' | 'ok' | 'fail' + message: string + wxid?: string + accountRoot?: string + }>({ kind: 'idle', message: '' }) const [reopenStatus, setReopenStatus] = useState('') const [imageKeyStatus, setImageKeyStatus] = useState<{ kind: 'idle' | 'ok' | 'fail' @@ -135,7 +138,10 @@ export const SettingsPanel: React.FC = ({ setTestStatus({ kind: 'fail', message: result.error || '连接失败' }) } } catch (error) { - setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) }) + setTestStatus({ + kind: 'fail', + message: error instanceof Error ? error.message : String(error) + }) } finally { setBusy(false) } @@ -226,7 +232,11 @@ export const SettingsPanel: React.FC = ({
{selfInfo.avatar ? ( - {selfInfo.nickname} + {selfInfo.nickname} ) : ( (selfInfo.nickname || selfInfo.wxid || '?').charAt(0) )} @@ -334,7 +344,8 @@ export const SettingsPanel: React.FC = ({
)}
- 目录默认使用数据库根目录,用于查找图片模板文件。Windows 会直接扫描微信内存,请先在微信中打开 2-3 张图片大图。 + 目录默认使用数据库根目录,用于查找图片模板文件。Windows + 会直接扫描微信内存,请先在微信中打开 2-3 张图片大图。
@@ -346,24 +357,23 @@ export const SettingsPanel: React.FC = ({ type="text" className="settings-input" value={settings?.dbRoot ?? ''} - onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)} + onChange={(e) => + setSettings(settings ? { ...settings, dbRoot: e.target.value } : null) + } onBlur={(e) => handleSave({ dbRoot: e.target.value })} placeholder={dbRootPlaceholder} spellCheck={false} />
- {reopenStatus && {reopenStatus}}
- 可填写微信数据总目录或具体账号目录。Windows 通常是 Documents\WeChat Files,macOS 通常是 xwechat_files;程序会自动选择包含 db_storage/session.db 的账号目录。 + 可填写微信数据总目录或具体账号目录。Windows 通常是 Documents\WeChat Files,macOS + 通常是 xwechat_files;程序会自动选择包含 db_storage/session.db 的账号目录。
@@ -412,8 +422,7 @@ export const SettingsPanel: React.FC = ({ />
- 所选内容会发送至你配置的模型服务进行处理。配置沿用原有本地 - localStorage 保存方式。 + 所选内容会发送至你配置的模型服务进行处理。配置沿用原有本地 localStorage 保存方式。
@@ -442,7 +451,9 @@ export const SettingsPanel: React.FC = ({ type="text" className="settings-input settings-input-half" value={settings?.apiHost ?? ''} - onChange={(e) => setSettings(settings ? { ...settings, apiHost: e.target.value } : null)} + onChange={(e) => + setSettings(settings ? { ...settings, apiHost: e.target.value } : null) + } onBlur={(e) => handleSave({ apiHost: e.target.value })} placeholder="host" spellCheck={false} @@ -452,7 +463,9 @@ export const SettingsPanel: React.FC = ({ className="settings-input settings-input-quarter" value={settings?.apiPort ?? 6131} onChange={(e) => - setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null) + setSettings( + settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null + ) } onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })} placeholder="port" @@ -462,7 +475,8 @@ export const SettingsPanel: React.FC = ({
- API 仅本机访问,无鉴权。关闭后 Claude / Codex 等客户端无法读取聊天数据。 + API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center + 中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。
配置文档:docs/skill/wechatexplorer-reader/SKILL.md
diff --git a/src/renderer/src/features/api-center/ApiWorkspace.tsx b/src/renderer/src/features/api-center/ApiWorkspace.tsx index 6ca7708..c093a43 100644 --- a/src/renderer/src/features/api-center/ApiWorkspace.tsx +++ b/src/renderer/src/features/api-center/ApiWorkspace.tsx @@ -54,7 +54,7 @@ export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props onBody={controller.updateBody} onSend={controller.runRequest} onClear={clear} - onCopyCurl={(command) => controller.copyText(command, 'curl 命令已复制')} + onCopyCurl={controller.copyCurl} /> {state.rawMarkdown && ( @@ -67,12 +67,18 @@ export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props {state.toast &&
{state.toast}
} diff --git a/src/renderer/src/features/api-center/components/ApiRequestTester.tsx b/src/renderer/src/features/api-center/components/ApiRequestTester.tsx index f39bf14..be88c3a 100644 --- a/src/renderer/src/features/api-center/components/ApiRequestTester.tsx +++ b/src/renderer/src/features/api-center/components/ApiRequestTester.tsx @@ -14,7 +14,7 @@ interface Props { onBody: (body: string) => void onSend: () => void onClear: () => void - onCopyCurl: (command: string) => Promise + onCopyCurl: () => Promise } export function ApiRequestTester({ @@ -32,13 +32,6 @@ export function ApiRequestTester({ onCopyCurl }: Props): ReactElement { const url = settings ? buildApiUrl(settings.apiHost, settings.apiPort, endpoint.path, params) : '' - const copyCurl = (): void => { - const command = - endpoint.method === 'POST' - ? `curl -X POST '${url}' -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'` - : `curl '${url}'` - void onCopyCurl(command) - } const update = (key: string, value: string): void => onParams({ ...params, [key]: value }) const selectTestImage = async (): Promise => { const result = await window.api.selectAgentHubTestImage() @@ -100,7 +93,7 @@ export function ApiRequestTester({ - + + + +

最近响应

{response ? ( @@ -142,7 +196,7 @@ export function ApiRuntimePanel({

{localOnly ? '本地 API 默认监听 127.0.0.1。WechatExplorer 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。' - : '当前服务并非仅本机访问。请确认局域网环境可信,且注意 API 暂无鉴权。'} + : '当前服务并非仅本机访问。请确认局域网环境可信;API Token 不等同于公网安全防护。'}

diff --git a/src/renderer/src/features/api-center/hooks/useApiCenterController.ts b/src/renderer/src/features/api-center/hooks/useApiCenterController.ts index 716d047..3199dc9 100644 --- a/src/renderer/src/features/api-center/hooks/useApiCenterController.ts +++ b/src/renderer/src/features/api-center/hooks/useApiCenterController.ts @@ -11,6 +11,7 @@ import type { ApiResponse, ApiServiceState, ApiSettings, + ApiTokenStatus, RequestHistoryItem, SkillStatus } from '../model/types' @@ -19,12 +20,15 @@ import { buildSkillInstallInstruction, buildSkillVerificationPrompt } from '../utils/buildSkillInstallInstruction' +import { confirmApiTokenRotation } from '../utils/confirmApiTokenRotation' type RequestState = 'idle' | 'loading' | 'success' | 'error' interface State { settings: ApiSettings | null service: ApiServiceState | null + tokenStatus: ApiTokenStatus | null + revealedToken: string skill: SkillStatus | null endpointId: string params: Record @@ -39,7 +43,13 @@ interface State { } type Action = - | { type: 'loaded'; settings: ApiSettings; service: ApiServiceState; skill: SkillStatus } + | { + type: 'loaded' + settings: ApiSettings + service: ApiServiceState + skill: SkillStatus + tokenStatus: ApiTokenStatus + } | { type: 'endpoint'; endpointId: string; talker?: string } | { type: 'params'; params: Record } | { type: 'body'; body: string } @@ -49,10 +59,15 @@ type Action = | { type: 'markdown'; content: string | null } | { type: 'toast'; message: string } | { type: 'installTarget'; target: AgentInstallTarget } + | { type: 'tokenRevealed'; token: string } + | { type: 'tokenHidden' } + | { type: 'tokenStatus'; status: ApiTokenStatus } const initialState: State = { settings: null, service: null, + tokenStatus: null, + revealedToken: '', skill: null, endpointId: 'health', params: {}, @@ -69,7 +84,13 @@ const initialState: State = { function reducer(state: State, action: Action): State { switch (action.type) { case 'loaded': - return { ...state, settings: action.settings, service: action.service, skill: action.skill } + return { + ...state, + settings: action.settings, + service: action.service, + skill: action.skill, + tokenStatus: action.tokenStatus + } case 'endpoint': { const preset = action.endpointId === 'report' @@ -108,6 +129,12 @@ function reducer(state: State, action: Action): State { return { ...state, toast: action.message } case 'installTarget': return { ...state, installTarget: action.target } + case 'tokenRevealed': + return { ...state, revealedToken: action.token } + case 'tokenHidden': + return { ...state, revealedToken: '' } + case 'tokenStatus': + return { ...state, tokenStatus: action.status, revealedToken: '' } } } @@ -124,6 +151,11 @@ export function useApiCenterController(selectedContact: Contact | null): { reportError: (error: string) => void showToast: (message: string) => void copyText: (text: string, successMessage: string) => Promise + copyCurl: () => Promise + revealToken: () => Promise + hideToken: () => void + copyToken: () => Promise + rotateToken: () => Promise setInstallTarget: (target: AgentInstallTarget) => void copyInstallInstruction: () => Promise copyVerificationPrompt: () => Promise @@ -135,12 +167,13 @@ export function useApiCenterController(selectedContact: Contact | null): { const refresh = useCallback(async (): Promise => { try { - const [{ settings }, service, skill] = await Promise.all([ + const [{ settings }, service, skill, tokenStatus] = await Promise.all([ window.api.getSettings(), window.api.apiStatus(), - window.api.getReaderSkillStatus() + window.api.getReaderSkillStatus(), + window.api.apiTokenStatus() ]) - dispatch({ type: 'loaded', settings, service, skill }) + dispatch({ type: 'loaded', settings, service, skill, tokenStatus }) } catch (error) { dispatch({ type: 'error', @@ -265,6 +298,31 @@ export function useApiCenterController(selectedContact: Contact | null): { }, [showToast] ) + const revealToken = useCallback(async (): Promise => { + const result = await window.api.revealApiToken() + if (result.token) dispatch({ type: 'tokenRevealed', token: result.token }) + else dispatch({ type: 'error', error: result.error || '无法读取 API Token' }) + }, []) + const hideToken = useCallback((): void => dispatch({ type: 'tokenHidden' }), []) + const copyToken = useCallback(async (): Promise => { + const result = await window.api.copyApiToken() + showToast(result.success ? 'Token 已复制' : result.error || 'Token 复制失败') + }, [showToast]) + const rotateToken = useCallback(async (): Promise => { + if (!confirmApiTokenRotation()) return + const result = await window.api.rotateApiToken() + dispatch({ type: 'tokenStatus', status: result }) + showToast(result.success ? 'Token 已重新生成' : result.error || 'Token 重新生成失败') + }, [showToast]) + const copyCurl = useCallback(async (): Promise => { + const endpoint = findEndpoint(state.endpointId) + const result = await window.api.copyLocalApiCurl({ + endpointId: endpoint.id, + query: state.params, + body: state.body + }) + showToast(result.success ? 'curl 命令已复制' : result.error || 'curl 命令复制失败') + }, [showToast, state.body, state.endpointId, state.params]) const setInstallTarget = useCallback( (target: AgentInstallTarget): void => dispatch({ type: 'installTarget', target }), [] @@ -318,6 +376,11 @@ export function useApiCenterController(selectedContact: Contact | null): { reportError, showToast, copyText, + copyCurl, + revealToken, + hideToken, + copyToken, + rotateToken, setInstallTarget, copyInstallInstruction, copyVerificationPrompt, diff --git a/src/renderer/src/features/api-center/model/types.ts b/src/renderer/src/features/api-center/model/types.ts index fd52fe6..5288d70 100644 --- a/src/renderer/src/features/api-center/model/types.ts +++ b/src/renderer/src/features/api-center/model/types.ts @@ -1,4 +1,5 @@ export type ApiMethod = 'GET' | 'POST' +export type { ApiTokenStatus } from '../../../../../shared/local-api-auth' export interface ApiParameter { key: string diff --git a/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts b/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts index 80e4df3..9a203b4 100644 --- a/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts +++ b/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts @@ -33,7 +33,7 @@ export function buildSkillInstallInstruction({ source.type === 'local' ? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:` : `请从以下地址安装 WechatExplorer Reader Skill:\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:` - return `${sourceText}\n\n${healthUrl}\n\n验证 WechatExplorer 本地 API 是否已就绪。安装完成后告诉我验证结果。` + return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 WechatExplorer → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 WECHATEXPLORER_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $WECHATEXPLORER_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。` } export function buildSkillVerificationPrompt(): string { diff --git a/src/renderer/src/features/api-center/utils/confirmApiTokenRotation.ts b/src/renderer/src/features/api-center/utils/confirmApiTokenRotation.ts new file mode 100644 index 0000000..37d987b --- /dev/null +++ b/src/renderer/src/features/api-center/utils/confirmApiTokenRotation.ts @@ -0,0 +1,8 @@ +export const API_TOKEN_ROTATION_CONFIRMATION = + '重新生成后,旧 Token 将立即失效。\n已配置此 API 的 Agent / Reader Skill 需要更新 Token。\n是否继续?' + +export function confirmApiTokenRotation( + confirm: (message: string) => boolean = window.confirm +): boolean { + return confirm(API_TOKEN_ROTATION_CONFIRMATION) +} diff --git a/src/renderer/src/styles/api.scss b/src/renderer/src/styles/api.scss index 7fe1c81..5b35d1f 100644 --- a/src/renderer/src/styles/api.scss +++ b/src/renderer/src/styles/api.scss @@ -631,6 +631,23 @@ color: #915d1e; font: 12px/18px var(--wxex-font); } +.api-token-value { + display: block; + overflow-wrap: anywhere; + margin: 10px 0; + padding: 9px 10px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-sm); + background: var(--wxex-bg-subtle); + color: var(--wxex-text-primary); + font: + 12px/18px ui-monospace, + monospace; + user-select: text; +} +.api-token-section .api-runtime-actions button:last-child { + grid-column: 1 / -1; +} .api-response-summary { display: flex; align-items: center; diff --git a/src/shared/local-api-auth.ts b/src/shared/local-api-auth.ts new file mode 100644 index 0000000..2166e53 --- /dev/null +++ b/src/shared/local-api-auth.ts @@ -0,0 +1,14 @@ +export interface ApiTokenStatus { + available: boolean + hasToken: boolean + maskedToken: string + error?: string +} + +export interface ApiTokenRevealResult extends ApiTokenStatus { + token?: string +} + +export interface ApiTokenActionResult extends ApiTokenStatus { + success: boolean +} diff --git a/src/shared/local-api-test.ts b/src/shared/local-api-test.ts index b8a9852..8667a7e 100644 --- a/src/shared/local-api-test.ts +++ b/src/shared/local-api-test.ts @@ -38,6 +38,17 @@ export interface LocalApiTestResponse { contentType?: string json?: unknown bodyText?: string - errorCode?: 'API_NOT_RUNNING' | 'CONNECTION_REFUSED' | 'TIMEOUT' | 'INVALID_REQUEST' | 'UNKNOWN' + errorCode?: + | 'API_NOT_RUNNING' + | 'TOKEN_UNAVAILABLE' + | 'CONNECTION_REFUSED' + | 'TIMEOUT' + | 'INVALID_REQUEST' + | 'UNKNOWN' + error?: string +} + +export interface LocalApiCurlCopyResult { + success: boolean error?: string } diff --git a/tests/component/api-token-panel.test.tsx b/tests/component/api-token-panel.test.tsx new file mode 100644 index 0000000..7cc539c --- /dev/null +++ b/tests/component/api-token-panel.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ApiRuntimePanel } from '../../src/renderer/src/features/api-center/components/ApiRuntimePanel' + +const token = 'fixture_token_visible_only_after_user_action' + +function renderPanel(revealedToken = ''): { + reveal: ReturnType + copy: ReturnType + rotate: ReturnType +} { + const reveal = vi.fn(async () => undefined) + const copy = vi.fn(async () => undefined) + const rotate = vi.fn(async () => undefined) + render( + undefined)} + onRevealToken={reveal} + onHideToken={vi.fn()} + onCopyToken={copy} + onRotateToken={rotate} + /> + ) + return { reveal, copy, rotate } +} + +describe('API Token panel', () => { + it('masks the token by default and exposes only explicit actions', async () => { + const actions = renderPanel() + expect(screen.getByText('••••••••••••••••')).toBeInTheDocument() + expect(screen.queryByText(token)).not.toBeInTheDocument() + expect(screen.getByText('Token 已生成')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: '显示 Token' })) + expect(actions.reveal).toHaveBeenCalledOnce() + await userEvent.click(screen.getByRole('button', { name: '复制 Token' })) + expect(actions.copy).toHaveBeenCalledOnce() + await userEvent.click(screen.getByRole('button', { name: '重新生成 Token' })) + expect(actions.rotate).toHaveBeenCalledOnce() + }) + + it('shows the full token only when reveal state is explicitly present', () => { + renderPanel(token) + expect(screen.getByText(token)).toBeInTheDocument() + expect(screen.getByRole('button', { name: '隐藏 Token' })).toBeInTheDocument() + }) + + it('shows a safe-storage error and disables token actions when unavailable', () => { + render( + undefined)} + onRevealToken={vi.fn(async () => undefined)} + onHideToken={vi.fn()} + onCopyToken={vi.fn(async () => undefined)} + onRotateToken={vi.fn(async () => undefined)} + /> + ) + expect(screen.getByText(/系统安全存储不可用/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: '显示 Token' })).toBeDisabled() + expect(screen.getByRole('button', { name: '复制 Token' })).toBeDisabled() + expect(screen.getByRole('button', { name: '重新生成 Token' })).toBeDisabled() + }) +}) diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 3830c7d..6fe3c8b 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -114,6 +114,25 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () => } }) +test('API-01 masks, reveals, and confirms rotation of the local API token', async () => { + const fixture = await launchTestApp() + try { + await fixture.page.getByRole('button', { name: 'API' }).click() + await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible() + await expect(fixture.page.getByText('••••••••••••••••')).toBeVisible() + await expect(fixture.page.getByText('fixture-api-token')).toHaveCount(0) + + await fixture.page.getByRole('button', { name: '显示 Token' }).click() + await expect(fixture.page.getByText('fixture-api-token')).toBeVisible() + + fixture.page.once('dialog', (dialog) => dialog.accept()) + await fixture.page.getByRole('button', { name: '重新生成 Token' }).click() + await expect(fixture.page.getByText('Token 已生成')).toBeVisible() + } finally { + await fixture.close() + } +}) + test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => { const fixture = await launchTestApp() try { diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index f483a1b..bb701da 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -316,6 +316,31 @@ handle('app-log:reveal', () => undefined) handle('cache:getSummary', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 })) handle('cache:clear', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 })) handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort })) +handle('api:tokenStatus', () => ({ + success: true, + available: true, + hasToken: true, + maskedToken: '••••••••••••••••' +})) +handle('api:revealToken', () => ({ + available: true, + hasToken: true, + maskedToken: '••••••••••••••••', + token: 'fixture-api-token' +})) +handle('api:copyToken', () => ({ + success: true, + available: true, + hasToken: true, + maskedToken: '••••••••••••••••' +})) +handle('api:rotateToken', () => ({ + success: true, + available: true, + hasToken: true, + maskedToken: '••••••••••••••••' +})) +handle('api:copyCurl', () => ({ success: true })) handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort })) handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort })) handle('api:toggle', (enabled) => ({ diff --git a/tests/integration/local-api-auth.test.ts b/tests/integration/local-api-auth.test.ts new file mode 100644 index 0000000..d51076a --- /dev/null +++ b/tests/integration/local-api-auth.test.ts @@ -0,0 +1,284 @@ +import fs from 'fs-extra' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ + root: `/tmp/wxe-local-api-auth-${process.pid}`, + storageAvailable: true, + contacts: [ + { + m_nsUsrName: 'wxid_fixture', + m_nsNickName: '测试联系人', + md5: 'fixture-md5', + type: 'user' as const + } + ], + testSend: vi.fn(async () => ({ success: true, status: 'sent' })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => fixture.root }, + safeStorage: { + isEncryptionAvailable: () => fixture.storageAvailable, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').replace(/^encrypted:/, '') + } +})) + +vi.mock('../../src/main/services/chat-service', () => ({ + isReady: () => true, + listContacts: () => fixture.contacts, + listMessages: () => [], + getGroupSnapshot: () => ({ members: [] }), + listRecentChat: () => [], + resolveMd5: () => fixture.contacts[0] +})) + +vi.mock('../../src/main/group-report-service', () => ({ + exportGroupReport: vi.fn(async () => ({ success: true })) +})) + +vi.mock('../../src/main/services/agent-group-report-service', () => ({ + generateAgentGroupReport: vi.fn(async () => ({ success: true })) +})) + +vi.mock('../../src/main/services/agent-hub-service', () => ({ + agentHubService: { + getStatus: () => ({ + hub: 'online', + connector: 'online', + dataApi: 'online', + databaseReady: true + }), + testSend: fixture.testSend + } +})) + +import { apiTokenStore } from '../../src/main/api-token-store' +import { apiServer, startHttpServer, type HttpServerHandle } from '../../src/main/http-server' +import { + buildLocalApiCurlCommand, + testLocalApiRequest +} from '../../src/main/services/local-api-test-service' + +const VALID_TOKEN = 'A'.repeat(43) +const handles: HttpServerHandle[] = [] + +function baseUrl(handle: HttpServerHandle): string { + return `http://${handle.host}:${handle.port}` +} + +async function startFixtureServer( + tokenProvider = (): string => VALID_TOKEN +): Promise { + const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider }) + handles.push(handle) + return handle +} + +describe('Local API authentication', () => { + beforeAll(() => fs.ensureDirSync(fixture.root)) + + beforeEach(() => { + fixture.storageAvailable = true + }) + + afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.close())) + await apiServer.stop() + fixture.testSend.mockClear() + }) + + afterAll(() => fs.removeSync(fixture.root)) + + it('keeps health public while protecting contact with a real HTTP request', async () => { + const handle = await startFixtureServer() + const health = await fetch(`${baseUrl(handle)}/api/v1/health`) + expect(health.status).toBe(200) + const healthBody = await health.json() + expect(healthBody).toMatchObject({ ok: true, service: 'WechatExplorer Reader' }) + expect(JSON.stringify(healthBody)).not.toMatch( + /token|authorization|wxid|databasePath|provider/i + ) + await expect(fetch(`${baseUrl(handle)}/api/v1/contact`)).resolves.toMatchObject({ status: 401 }) + await expect( + fetch(`${baseUrl(handle)}/api/v1/contact`, { + headers: { Authorization: 'Bearer invalid' } + }) + ).resolves.toMatchObject({ status: 401 }) + const response = await fetch(`${baseUrl(handle)}/api/v1/contact`, { + headers: { Authorization: `Bearer ${VALID_TOKEN}` } + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ count: 1 }) + }) + + it.each([ + ['GET', '/api/v1/current_time'], + ['GET', '/api/v1/contact'], + ['GET', '/api/v1/chatroom'], + ['GET', '/api/v1/recent_chat'], + ['GET', '/api/v1/chatlog'], + ['GET', '/api/v1/group_snapshot'], + ['GET', '/api/v1/resolve'], + ['POST', '/api/v1/report'], + ['GET', '/api/v1/agent/status'], + ['POST', '/api/v1/agent/group-report'], + ['POST', '/api/v1/agent/send'] + ])('protects every non-health route: %s %s', async (method, pathname) => { + const handle = await startFixtureServer() + const response = await fetch(`${baseUrl(handle)}${pathname}`, { + method, + ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {}) + }) + expect(response.status).toBe(401) + }) + + it.each(['Basic xxx', 'Bearer', 'bearer xxx', 'Bearer xxx', 'xxx'])( + 'rejects the invalid Authorization format %s', + async (authorization) => { + const handle = await startFixtureServer() + const response = await fetch(`${baseUrl(handle)}/api/v1/contact`, { + headers: { Authorization: authorization } + }) + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ + error: 'unauthorized', + message: 'Valid API token required' + }) + } + ) + + it('protects agent/send before entering its original handler', async () => { + const handle = await startFixtureServer() + const url = `${baseUrl(handle)}/api/v1/agent/send` + const init = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to: 'fixture', text: 'test' }) + } + expect((await fetch(url, init)).status).toBe(401) + expect( + ( + await fetch(url, { + ...init, + headers: { ...init.headers, Authorization: 'Bearer invalid' } + }) + ).status + ).toBe(401) + expect(fixture.testSend).not.toHaveBeenCalled() + expect( + ( + await fetch(url, { + ...init, + headers: { ...init.headers, Authorization: `Bearer ${VALID_TOKEN}` } + }) + ).status + ).toBe(200) + expect(fixture.testSend).toHaveBeenCalledOnce() + }) + + it.each([ + 'http://localhost', + 'http://localhost:5173', + 'http://127.0.0.1', + 'http://127.0.0.1:5173', + 'http://[::1]', + 'http://[::1]:5173' + ])('allows the trusted CORS origin %s', async (origin) => { + const handle = await startFixtureServer() + const response = await fetch(`${baseUrl(handle)}/api/v1/health`, { + method: 'OPTIONS', + headers: { + Origin: origin, + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'Authorization' + } + }) + expect(response.status).toBe(204) + expect(response.headers.get('access-control-allow-origin')).toBe(origin) + expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization') + }) + + it.each([ + 'https://localhost', + 'http://localhost.example.com', + 'http://foo.localhost', + 'http://localhost.', + 'http://127.0.0.2', + 'http://2130706433', + 'https://example.com', + 'http://example.com' + ])('rejects the untrusted CORS origin %s', async (origin) => { + const handle = await startFixtureServer() + const response = await fetch(`${baseUrl(handle)}/api/v1/health`, { + method: 'OPTIONS', + headers: { Origin: origin } + }) + expect(response.status).toBe(403) + expect(response.headers.get('access-control-allow-origin')).toBeNull() + }) + + it('allows clients without an Origin header', async () => { + const handle = await startFixtureServer() + const response = await fetch(`${baseUrl(handle)}/api/v1/contact`, { + headers: { Authorization: `Bearer ${VALID_TOKEN}` } + }) + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBeNull() + }) + + it('rotates immediately, authenticates the API Center client, and stops cleanly', async () => { + const state = await apiServer.start('127.0.0.1', 0) + expect(state.running).toBe(true) + const serviceUrl = `http://${state.host}:${state.port}` + const oldToken = apiTokenStore.revealToken().token + expect(oldToken).toBeTruthy() + expect( + ( + await fetch(`${serviceUrl}/api/v1/contact`, { + headers: { Authorization: `Bearer ${oldToken}` } + }) + ).status + ).toBe(200) + await expect(testLocalApiRequest({ endpointId: 'contact' })).resolves.toMatchObject({ + ok: true, + status: 200 + }) + expect(apiTokenStore.rotateToken().success).toBe(true) + const newToken = apiTokenStore.revealToken().token + expect(newToken).not.toBe(oldToken) + expect( + ( + await fetch(`${serviceUrl}/api/v1/contact`, { + headers: { Authorization: `Bearer ${oldToken}` } + }) + ).status + ).toBe(401) + expect( + ( + await fetch(`${serviceUrl}/api/v1/contact`, { + headers: { Authorization: `Bearer ${newToken}` } + }) + ).status + ).toBe(200) + await expect(testLocalApiRequest({ endpointId: 'contact' })).resolves.toMatchObject({ + ok: true, + status: 200 + }) + const curl = buildLocalApiCurlCommand({ endpointId: 'contact', query: { type: 'group' } }) + expect(curl.success).toBe(true) + expect(curl.command).toContain(`Authorization: Bearer ${newToken}`) + expect(curl.command).not.toContain(`token=${newToken}`) + + await apiServer.stop() + await expect(fetch(`${serviceUrl}/api/v1/health`)).rejects.toThrow() + }) + + it('fails closed when Electron safeStorage is unavailable', async () => { + fixture.storageAvailable = false + const state = await apiServer.start('127.0.0.1', 0) + expect(state).toMatchObject({ running: false, host: '127.0.0.1', port: 0 }) + expect(state.error).toContain('系统安全存储不可用') + expect(apiServer.isRunning()).toBe(false) + }) +}) diff --git a/tests/integration/preload-contract.test.ts b/tests/integration/preload-contract.test.ts index 19e371d..81129be 100644 --- a/tests/integration/preload-contract.test.ts +++ b/tests/integration/preload-contract.test.ts @@ -105,6 +105,22 @@ describe('preload IPC contract', () => { expect(api).not.toHaveProperty('send') }) + it('exposes only the intentional API token IPC operations', async () => { + const api = await loadApi() + invoke.mockResolvedValue({ available: true, hasToken: true, maskedToken: '••••' }) + + await api.apiTokenStatus() + expect(invoke).toHaveBeenLastCalledWith('api:tokenStatus') + await api.revealApiToken() + expect(invoke).toHaveBeenLastCalledWith('api:revealToken') + await api.copyApiToken() + expect(invoke).toHaveBeenLastCalledWith('api:copyToken') + await api.rotateApiToken() + expect(invoke).toHaveBeenLastCalledWith('api:rotateToken') + await api.copyLocalApiCurl({ endpointId: 'contact' }) + expect(invoke).toHaveBeenLastCalledWith('api:copyCurl', { endpointId: 'contact' }) + }) + it('unsubscribes the same listener registered for native database changes', async () => { const api = await loadApi() const callback = vi.fn() diff --git a/tests/unit/api-token-rotation-confirmation.test.ts b/tests/unit/api-token-rotation-confirmation.test.ts new file mode 100644 index 0000000..043d987 --- /dev/null +++ b/tests/unit/api-token-rotation-confirmation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest' +import { + API_TOKEN_ROTATION_CONFIRMATION, + confirmApiTokenRotation +} from '../../src/renderer/src/features/api-center/utils/confirmApiTokenRotation' + +describe('API token rotation confirmation', () => { + it('requires explicit confirmation and explains immediate invalidation', () => { + const reject = vi.fn(() => false) + expect(confirmApiTokenRotation(reject)).toBe(false) + expect(reject).toHaveBeenCalledWith(API_TOKEN_ROTATION_CONFIRMATION) + expect(API_TOKEN_ROTATION_CONFIRMATION).toContain('旧 Token 将立即失效') + expect(API_TOKEN_ROTATION_CONFIRMATION).toContain('Agent / Reader Skill 需要更新 Token') + }) +}) diff --git a/tests/unit/api-token-store.test.ts b/tests/unit/api-token-store.test.ts new file mode 100644 index 0000000..76a6b01 --- /dev/null +++ b/tests/unit/api-token-store.test.ts @@ -0,0 +1,65 @@ +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-api-token-store-')) +const storage = vi.hoisted(() => ({ available: true })) + +vi.mock('electron', () => ({ + app: { getPath: () => root }, + safeStorage: { + isEncryptionAvailable: () => storage.available, + encryptString: (value: string) => Buffer.from(value, 'utf8').reverse(), + decryptString: (value: Buffer) => Buffer.from(value).reverse().toString('utf8') + } +})) + +import { ApiTokenStore } from '../../src/main/api-token-store' + +describe('ApiTokenStore', () => { + const filePath = path.join(root, 'fixture-token.bin') + + beforeEach(() => { + storage.available = true + fs.removeSync(filePath) + }) + + afterAll(() => fs.removeSync(root)) + + it('generates a 256-bit base64url token once and persists it', () => { + const firstStore = new ApiTokenStore(filePath) + expect(firstStore.ensureToken()).toMatchObject({ success: true, hasToken: true }) + const first = firstStore.revealToken().token + expect(first).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(fs.readFileSync(filePath, 'utf8')).not.toContain(String(first)) + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600) + + const secondStore = new ApiTokenStore(filePath) + expect(secondStore.ensureToken()).toMatchObject({ success: true, hasToken: true }) + expect(secondStore.revealToken().token).toBe(first) + }) + + it('rotates the token while keeping status responses masked', () => { + const store = new ApiTokenStore(filePath) + store.ensureToken() + const oldToken = store.revealToken().token + const result = store.rotateToken() + const newToken = store.revealToken().token + expect(result).toEqual({ + success: true, + available: true, + hasToken: true, + maskedToken: '••••••••••••••••' + }) + expect(newToken).not.toBe(oldToken) + }) + + it('fails closed without writing plaintext when safeStorage is unavailable', () => { + storage.available = false + const store = new ApiTokenStore(filePath) + expect(store.ensureToken()).toMatchObject({ success: false, available: false, hasToken: false }) + expect(fs.existsSync(filePath)).toBe(false) + expect(store.revealToken().token).toBeUndefined() + }) +})