mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 为本地 HTTP API 增加 Token 鉴权与安全加固
- 使用 Electron safeStorage 加密存储并自动初始化 API Token - 为 health 以外的接口增加 Bearer Token 鉴权 - 限制 CORS 仅允许可信本地 Origin - 增加鉴权、Token rotation、safeStorage 和手动验收测试
This commit is contained in:
@@ -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 系统。
|
||||
@@ -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)。
|
||||
@@ -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 配置。
|
||||
@@ -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` 更新本机配置。
|
||||
@@ -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。
|
||||
|
||||
@@ -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。它仍面向个人本机使用,不建议暴露到公网或不受信任网络。
|
||||
|
||||
## 仍然无法解决?
|
||||
|
||||
|
||||
Executable
+116
@@ -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
|
||||
@@ -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/
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -48,7 +48,7 @@ function getStatus(): SkillResourceStatus {
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
version: 'v1.0',
|
||||
version: 'v1.1',
|
||||
filePath,
|
||||
directoryPath,
|
||||
source,
|
||||
|
||||
Vendored
+7
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 Files,macOS 通常是 xwechat_files;程序会自动选择包含 db_storage/session.db 的账号目录。
|
||||
可填写微信数据总目录或具体账号目录。Windows 通常是 Documents\WeChat Files,macOS
|
||||
通常是 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user