feat: 为本地 HTTP API 增加 Token 鉴权与安全加固

- 使用 Electron safeStorage 加密存储并自动初始化 API Token
- 为 health 以外的接口增加 Bearer Token 鉴权
- 限制 CORS 仅允许可信本地 Origin
- 增加鉴权、Token rotation、safeStorage 和手动验收测试
This commit is contained in:
Wxw-Gu
2026-08-07 17:48:05 +08:00
parent 0c21008ec3
commit a73af3b5ad
33 changed files with 1328 additions and 130 deletions
+12
View File
@@ -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 系统。
+16
View File
@@ -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>`
- Token 从 WechatExplorer → API Center → API Token 获取。
- Token 不得放入 URL、仓库或共享配置。
```bash
export WECHATEXPLORER_API_TOKEN="<YOUR_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)。
+11
View File
@@ -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=<YOUR_API_TOKEN>`
5. 安装 Reader Skill,并让所有数据请求携带 `Authorization: Bearer $WECHATEXPLORER_API_TOKEN`
Codex、Claude Code、OpenClaw 和其他 Agent 均使用相同的 HTTP Bearer Token 模型。WechatExplorer 不会自动把 Token 写入任何 Agent 配置。
+11
View File
@@ -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 <TOKEN>`,否则返回 `401`
- `GET /api/v1/health` 保持公开。
- 老用户升级后会自动生成并安全保存 Token,不改变原有 apiEnabled、host 或 port 设置。
- Token 可在 WechatExplorer → API Center 中显示、复制和重新生成。
Reader Skill 和本地 Agent 需要使用 `WECHATEXPLORER_API_TOKEN` 更新本机配置。
+84 -67
View File
@@ -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="<YOUR_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。
+2 -2
View File
@@ -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 TokenToken 可在 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。它仍面向个人本机使用,不建议暴露到公网或不受信任网络
## 仍然无法解决?
+116
View File
@@ -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
+30 -6
View File
@@ -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/
)
+134
View File
@@ -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()
+69 -8
View File
@@ -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<void>
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<string, RouteHandler> = {
export function startHttpServer(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
port: number = DEFAULT_HTTP_PORT,
options: HttpServerOptions = {}
): Promise<HttpServerHandle> {
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,
+40 -4
View File
@@ -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)
}
+59 -1
View File
@@ -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<LocalApiTestRequest>
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<LocalApiTestResponse> {
if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效')
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
@@ -94,11 +136,27 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
settled = true
resolve(result)
}
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
if (endpointId !== 'health' && !token) {
return finish({
ok: false,
method: endpoint.method,
path: endpoint.path,
url: url.toString(),
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: 'TOKEN_UNAVAILABLE',
error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态'
})
}
const headers: Record<string, string> = {}
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[] = []
+1 -1
View File
@@ -48,7 +48,7 @@ function getStatus(): SkillResourceStatus {
}
return {
available: true,
version: 'v1.0',
version: 'v1.1',
filePath,
directoryPath,
source,
+7
View File
@@ -445,6 +445,10 @@ declare global {
port: number
error?: string
}>
apiTokenStatus: () => Promise<import('../shared/local-api-auth').ApiTokenStatus>
revealApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenRevealResult>
copyApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenActionResult>
rotateApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenActionResult>
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<LocalApiTestResponse>
copyLocalApiCurl: (
request: LocalApiTestRequest
) => Promise<import('../shared/local-api-test').LocalApiCurlCopyResult>
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
+5
View File
@@ -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)
+32 -18
View File
@@ -73,9 +73,12 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
const [settings, setSettings] = useState<AppSettings | null>(null)
const [settingsPath, setSettingsPath] = useState('')
const [apiState, setApiState] = useState<ApiState | null>(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<string>('')
const [imageKeyStatus, setImageKeyStatus] = useState<{
kind: 'idle' | 'ok' | 'fail'
@@ -135,7 +138,10 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
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<SettingsPanelProps> = ({
<div className="settings-self">
<div className="settings-self-avatar">
{selfInfo.avatar ? (
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
<img
src={selfInfo.avatar}
alt={selfInfo.nickname}
referrerPolicy="no-referrer"
/>
) : (
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
)}
@@ -334,7 +344,8 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
</div>
)}
<div className="settings-hint">
使Windows 2-3
使Windows
2-3
</div>
</section>
@@ -346,24 +357,23 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
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}
/>
</div>
<div className="settings-row">
<button
className="settings-btn"
onClick={handleReopen}
disabled={busy || !dbReady}
>
<button className="settings-btn" onClick={handleReopen} disabled={busy || !dbReady}>
</button>
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
</div>
<div className="settings-hint">
Windows Documents\WeChat FilesmacOS xwechat_files db_storage/session.db
Windows Documents\WeChat FilesmacOS
xwechat_files db_storage/session.db
</div>
</section>
@@ -412,8 +422,7 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
/>
</div>
<div className="settings-hint">
沿
localStorage
沿 localStorage
</div>
</section>
@@ -442,7 +451,9 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
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<SettingsPanelProps> = ({
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<SettingsPanelProps> = ({
</button>
</div>
<div className="settings-hint">
API 访, Claude / Codex
API Bearer Token Token API Center
Claude / Codex
<br />
:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
</div>
@@ -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}
/>
</div>
{state.rawMarkdown && (
@@ -67,12 +67,18 @@ export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props
</main>
<ApiRuntimePanel
service={state.service}
tokenStatus={state.tokenStatus}
revealedToken={state.revealedToken}
dbReady={dbReady}
response={state.response}
history={state.history}
onControl={controller.controlService}
onOpenSettings={onOpenSettings}
onCopy={controller.copyText}
onRevealToken={controller.revealToken}
onHideToken={controller.hideToken}
onCopyToken={controller.copyToken}
onRotateToken={controller.rotateToken}
/>
{state.toast && <div className="app-toast">{state.toast}</div>}
</div>
@@ -14,7 +14,7 @@ interface Props {
onBody: (body: string) => void
onSend: () => void
onClear: () => void
onCopyCurl: (command: string) => Promise<void>
onCopyCurl: () => Promise<void>
}
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<void> => {
const result = await window.api.selectAgentHubTestImage()
@@ -100,7 +93,7 @@ export function ApiRequestTester({
<button type="button" onClick={onClear}>
</button>
<button type="button" onClick={copyCurl}>
<button type="button" onClick={() => void onCopyCurl()}>
curl
</button>
<button
@@ -1,26 +1,43 @@
import type { ApiResponse, ApiServiceState, RequestHistoryItem } from '../model/types'
import type {
ApiResponse,
ApiServiceState,
ApiTokenStatus,
RequestHistoryItem
} from '../model/types'
import { type ReactElement } from 'react'
import { isLoopbackHost } from '../utils/buildApiUrl'
import { formatJson, formatResponseSize, inferResponseCount } from '../utils/formatResponse'
interface Props {
service: ApiServiceState | null
tokenStatus: ApiTokenStatus | null
revealedToken: string
dbReady: boolean
response: ApiResponse | null
history: RequestHistoryItem[]
onControl: (action: 'start' | 'stop' | 'restart') => void
onOpenSettings: () => void
onCopy: (text: string, message: string) => Promise<void>
onRevealToken: () => Promise<void>
onHideToken: () => void
onCopyToken: () => Promise<void>
onRotateToken: () => Promise<void>
}
export function ApiRuntimePanel({
service,
tokenStatus,
revealedToken,
dbReady,
response,
history,
onControl,
onOpenSettings,
onCopy
onCopy,
onRevealToken,
onHideToken,
onCopyToken,
onRotateToken
}: Props): ReactElement {
const host = service?.host || '127.0.0.1'
const port = service?.port || 6131
@@ -33,7 +50,7 @@ export function ApiRuntimePanel({
<div className="api-runtime-title">
<h2></h2>
<span className={service?.running ? 'ready' : 'stopped'}>
{service?.running ? '运行中' : '已停止'}
{service?.running ? 'API 已启用' : '已停止'}
</span>
</div>
<dl>
@@ -49,7 +66,7 @@ export function ApiRuntimePanel({
</div>
<div>
<dt></dt>
<dd></dd>
<dd>{tokenStatus?.hasToken ? 'Bearer Token' : '不可用'}</dd>
</div>
<div>
<dt>访</dt>
@@ -85,8 +102,45 @@ export function ApiRuntimePanel({
</div>
</section>
{!localOnly && (
<p className="api-security-warning">访 API </p>
<p className="api-security-warning">
访Bearer Token
</p>
)}
<section className="api-token-section">
<div className="api-runtime-title">
<h3>API Token</h3>
<span className={tokenStatus?.hasToken ? 'ready' : 'stopped'}>
{tokenStatus?.hasToken ? 'Token 已生成' : 'Token 不可用'}
</span>
</div>
<code className="api-token-value">
{revealedToken || tokenStatus?.maskedToken || '••••••••••••••••'}
</code>
{tokenStatus?.error && <p className="api-inline-error">{tokenStatus.error}</p>}
<div className="api-runtime-actions">
<button
type="button"
disabled={!tokenStatus?.hasToken}
onClick={() => void (revealedToken ? onHideToken() : onRevealToken())}
>
{revealedToken ? '隐藏 Token' : '显示 Token'}
</button>
<button
type="button"
disabled={!tokenStatus?.hasToken}
onClick={() => void onCopyToken()}
>
Token
</button>
<button
type="button"
disabled={!tokenStatus?.available}
onClick={() => void onRotateToken()}
>
Token
</button>
</div>
</section>
<section>
<h3></h3>
{response ? (
@@ -142,7 +196,7 @@ export function ApiRuntimePanel({
<p>
{localOnly
? '本地 API 默认监听 127.0.0.1。WechatExplorer 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
: '当前服务并非仅本机访问。请确认局域网环境可信,且注意 API 暂无鉴权。'}
: '当前服务并非仅本机访问。请确认局域网环境可信;API Token 不等同于公网安全防护。'}
</p>
</section>
</div>
@@ -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<string, string>
@@ -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<string, string> }
| { 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<void>
copyCurl: () => Promise<void>
revealToken: () => Promise<void>
hideToken: () => void
copyToken: () => Promise<void>
rotateToken: () => Promise<void>
setInstallTarget: (target: AgentInstallTarget) => void
copyInstallInstruction: () => Promise<void>
copyVerificationPrompt: () => Promise<void>
@@ -135,12 +167,13 @@ export function useApiCenterController(selectedContact: Contact | null): {
const refresh = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
const result = await window.api.copyApiToken()
showToast(result.success ? 'Token 已复制' : result.error || 'Token 复制失败')
}, [showToast])
const rotateToken = useCallback(async (): Promise<void> => {
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<void> => {
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,
@@ -1,4 +1,5 @@
export type ApiMethod = 'GET' | 'POST'
export type { ApiTokenStatus } from '../../../../../shared/local-api-auth'
export interface ApiParameter {
key: string
@@ -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 {
@@ -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)
}
+17
View File
@@ -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;
+14
View File
@@ -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
}
+12 -1
View File
@@ -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
}
+85
View File
@@ -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<typeof vi.fn>
copy: ReturnType<typeof vi.fn>
rotate: ReturnType<typeof vi.fn>
} {
const reveal = vi.fn(async () => undefined)
const copy = vi.fn(async () => undefined)
const rotate = vi.fn(async () => undefined)
render(
<ApiRuntimePanel
service={{ running: true, host: '127.0.0.1', port: 6131 }}
tokenStatus={{ available: true, hasToken: true, maskedToken: '••••••••••••••••' }}
revealedToken={revealedToken}
dbReady
response={null}
history={[]}
onControl={vi.fn()}
onOpenSettings={vi.fn()}
onCopy={vi.fn(async () => 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(
<ApiRuntimePanel
service={{ running: false, host: '127.0.0.1', port: 6131 }}
tokenStatus={{
available: false,
hasToken: false,
maskedToken: '••••••••••••••••',
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
}}
revealedToken=""
dbReady
response={null}
history={[]}
onControl={vi.fn()}
onOpenSettings={vi.fn()}
onCopy={vi.fn(async () => 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()
})
})
+19
View File
@@ -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 {
+25
View File
@@ -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) => ({
+284
View File
@@ -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<HttpServerHandle> {
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)
})
})
@@ -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()
@@ -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')
})
})
+65
View File
@@ -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()
})
})