Compare commits
23 Commits
main
...
477ccc8e5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 477ccc8e5a | |||
| 685213b3d4 | |||
| 1f399c6191 | |||
| a1baa6fd82 | |||
| 733e02025b | |||
| d8af91cc0b | |||
| 81752e763e | |||
| 347066240d | |||
| cb0fb097de | |||
| 9c72c93b8b | |||
| 07b5ffed0c | |||
| 479bb5e349 | |||
| 739a1ee094 | |||
| 02d158eacd | |||
| 3891eab488 | |||
| 34a591dd0c | |||
| 8c475f259a | |||
| 38f68ca7ab | |||
| 4ed8287c2a | |||
| 014a985814 | |||
| 3b59e34eb5 | |||
| 609563c73e | |||
| 8e99285633 |
241
ACCEPTANCE.md
Normal file
241
ACCEPTANCE.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Yonexus v1 验收与回归清单
|
||||
|
||||
本清单服务于 `TASKLIST.md` 中的 YNX-1205。
|
||||
|
||||
目标:给后续开发、联调、回归提供一份统一基线,避免只凭“能跑起来了”判断完成度。
|
||||
|
||||
---
|
||||
|
||||
## 1. 范围
|
||||
|
||||
覆盖对象:
|
||||
|
||||
- `Yonexus.Protocol`
|
||||
- `Yonexus.Server`
|
||||
- `Yonexus.Client`
|
||||
- Server ↔ Client 联调主流程
|
||||
- pairing / auth / heartbeat / rule dispatch 关键失败路径
|
||||
|
||||
不覆盖:
|
||||
|
||||
- 多服务器拓扑
|
||||
- 离线消息队列
|
||||
- 管理 UI
|
||||
- 复杂规则匹配
|
||||
|
||||
---
|
||||
|
||||
## 2. 协议层验收
|
||||
|
||||
### 2.1 builtin 编解码
|
||||
|
||||
必须验证:
|
||||
|
||||
- `builtin::{json}` 能正确编码
|
||||
- `builtin::{json}` 能正确解码
|
||||
- malformed builtin 消息会返回标准错误
|
||||
- 未支持 builtin type 可被明确拒绝
|
||||
|
||||
### 2.2 rule message 解析
|
||||
|
||||
必须验证:
|
||||
|
||||
- `${rule}::${content}` 可被正确解析
|
||||
- `${rule}::${sender}::${content}` 可被正确解析
|
||||
- `content` 中包含 `::` 时不会被错误拆分
|
||||
- `builtin` 不能作为普通 rule 注册
|
||||
|
||||
### 2.3 共享认证约束
|
||||
|
||||
必须验证:
|
||||
|
||||
- nonce 长度固定为 24
|
||||
- timestamp 新鲜度窗口符合协议
|
||||
- 签名输入序列化规则固定且可复用
|
||||
|
||||
---
|
||||
|
||||
## 3. Server 单体验收
|
||||
|
||||
### 3.1 启动与配置
|
||||
|
||||
必须验证:
|
||||
|
||||
- 缺失 `followerIdentifiers` 会 fail fast
|
||||
- 缺失 `notifyBotToken` / `adminUserId` / `listenPort` 会 fail fast
|
||||
- 非法 `listenPort` 会 fail fast
|
||||
- 启动时会加载持久化记录并补齐 allowlist 初始记录
|
||||
|
||||
### 3.2 pairing
|
||||
|
||||
必须验证:
|
||||
|
||||
- 未配对 allowlisted client 进入 `pair_required`
|
||||
- server 创建 pending pairing 记录
|
||||
- pairing code 不通过 Yonexus WebSocket 下发
|
||||
- pairing 通知失败时返回 `admin_notification_failed`
|
||||
- 正确 pairing code 返回 `pair_success`
|
||||
- 错误 pairing code 返回 `invalid_code`
|
||||
- 过期 pairing code 返回 `expired`
|
||||
|
||||
### 3.3 auth
|
||||
|
||||
必须验证:
|
||||
|
||||
- paired client 可通过合法签名拿到 `auth_success`
|
||||
- 非 allowlisted identifier 被拒绝
|
||||
- 未配对 identifier 不可通过 auth
|
||||
- public key 不匹配会失败
|
||||
- stale/future timestamp 会失败
|
||||
- nonce collision 会触发 `re_pair_required`
|
||||
- 超过 `>10 attempts / 10s` 会触发 `re_pair_required`
|
||||
|
||||
### 3.4 liveness
|
||||
|
||||
必须验证:
|
||||
|
||||
- heartbeat 后更新 `lastHeartbeatAt`
|
||||
- 7 分钟无心跳转为 `unstable`
|
||||
- 11 分钟无心跳转为 `offline`
|
||||
- `offline` 时发送 `disconnect_notice` 并断开连接
|
||||
|
||||
### 3.5 messaging
|
||||
|
||||
必须验证:
|
||||
|
||||
- 未认证 client 发送 rule message 会被拒绝
|
||||
- 已认证 client 的消息会被重写为 `${rule}::${sender}::${content}`
|
||||
- duplicate rule 注册默认失败
|
||||
- `sendMessageToClient()` 对离线 client 返回失败
|
||||
|
||||
---
|
||||
|
||||
## 4. Client 单体验收
|
||||
|
||||
### 4.1 启动与本地状态
|
||||
|
||||
必须验证:
|
||||
|
||||
- 缺失 state 文件时可初始化最小状态
|
||||
- 首次启动会自动生成 Ed25519 keypair
|
||||
- 重启后不会重复生成 keypair
|
||||
- 已有 secret 时可进入 auth 流程
|
||||
|
||||
### 4.2 连接与重连
|
||||
|
||||
必须验证:
|
||||
|
||||
- 可连接到可用的 server
|
||||
- server 不可用时会按退避策略重连
|
||||
- 手动断开不会误触发自动重连
|
||||
- 成功重连后退避计数会重置
|
||||
|
||||
### 4.3 pairing / auth
|
||||
|
||||
必须验证:
|
||||
|
||||
- 收到 `pair_request` 后进入待确认状态
|
||||
- 可提交 pairing code
|
||||
- 收到 `pair_success` 后保存 secret
|
||||
- 收到 `hello_ack(auth_required)` 后自动发 `auth_request`
|
||||
- 收到 `auth_success` 后进入 authenticated
|
||||
- 收到 `re_pair_required` 后清理本地 secret 并回退到 `pair_required`
|
||||
|
||||
### 4.4 heartbeat / dispatch
|
||||
|
||||
必须验证:
|
||||
|
||||
- authenticated 后启动 heartbeat loop
|
||||
- 断线或未认证时停止 heartbeat loop
|
||||
- `registerRule()` 拒绝 `builtin`
|
||||
- `sendMessageToServer()` 拒绝 `builtin::` 和非法格式
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调验收
|
||||
|
||||
### 5.1 首次配对闭环
|
||||
|
||||
必须通过:
|
||||
|
||||
1. Client 连接 Server
|
||||
2. Client 发送 `hello`
|
||||
3. Server 返回 `hello_ack(pair_required)`
|
||||
4. Server 创建 pairing request 并发出管理员通知
|
||||
5. Client 提交正确 pairing code
|
||||
6. Server 返回 `pair_success`
|
||||
7. Client 保存 secret
|
||||
8. Client 发送 `auth_request`
|
||||
9. Server 返回 `auth_success`
|
||||
10. Client 进入 authenticated 并开始 heartbeat
|
||||
|
||||
### 5.2 正常重连闭环
|
||||
|
||||
必须通过:
|
||||
|
||||
1. 已配对 Client 重连
|
||||
2. `hello_ack(auth_required)`
|
||||
3. Client 发送合法 `auth_request`
|
||||
4. Server 返回 `auth_success`
|
||||
5. 心跳恢复正常
|
||||
|
||||
### 5.3 规则消息闭环
|
||||
|
||||
必须通过:
|
||||
|
||||
1. Client 认证成功
|
||||
2. Client 调用 `sendRuleMessage()`
|
||||
3. Server 收到并完成 sender rewrite
|
||||
4. Server 规则处理器命中 exact match
|
||||
5. Server 调用 `sendRuleMessageToClient()` 回发消息
|
||||
6. Client 本地规则处理器收到消息
|
||||
|
||||
---
|
||||
|
||||
## 6. 失败路径回归矩阵
|
||||
|
||||
每次关键改动后,至少回归以下场景:
|
||||
|
||||
- pairing code 错误
|
||||
- pairing 过期
|
||||
- pairing 通知失败
|
||||
- unsupported protocol version
|
||||
- malformed builtin frame
|
||||
- unknown identifier
|
||||
- invalid signature
|
||||
- stale timestamp
|
||||
- future timestamp
|
||||
- nonce collision
|
||||
- handshake rate limit
|
||||
- duplicate active connection 竞争
|
||||
- 未认证连接发送 rule message
|
||||
|
||||
---
|
||||
|
||||
## 7. 自动化建议
|
||||
|
||||
建议的最小自动化分层:
|
||||
|
||||
- `Yonexus.Protocol`: 单元测试,锁定 codec / types / auth helpers
|
||||
- `Yonexus.Server`: 单元测试,覆盖 runtime + pairing/auth/liveness 核心逻辑
|
||||
- `Yonexus.Client`: 单元测试,覆盖 state/transport/runtime 主状态机
|
||||
- Server + Client: 集成测试,覆盖 happy path 与关键失败路径
|
||||
|
||||
建议把通过条件固化为:
|
||||
|
||||
- `Yonexus.Protocol` 测试必须全绿
|
||||
- Server / Client 类型检查必须全绿
|
||||
- 新增联调测试后,happy path 与至少一组安全失败路径必须全绿
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前对应关系
|
||||
|
||||
与 `TASKLIST.md` 对应关系:
|
||||
|
||||
- YNX-1101:协议单元测试
|
||||
- YNX-1102:Server 单元测试
|
||||
- YNX-1103:Client 单元测试
|
||||
- YNX-1104:Server-Client 集成测试
|
||||
- YNX-1105:失败路径测试矩阵
|
||||
- YNX-1205:协议测试与验收清单(本文件)
|
||||
123
DEPLOYMENT.md
Normal file
123
DEPLOYMENT.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Yonexus 部署指南 (v1)
|
||||
|
||||
本指南面向 **单主多从** 拓扑:
|
||||
- **主节点**:运行 `Yonexus.Server`
|
||||
- **从节点**:运行 `Yonexus.Client`
|
||||
|
||||
> 说明:Yonexus 采用三仓库/子模块结构(Umbrella + Server + Client + Protocol)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 拓扑与前置条件
|
||||
|
||||
- **主节点**需要可被从节点访问的稳定地址(域名或固定 IP)
|
||||
- **从节点**只需能 outbound 访问主节点 WebSocket
|
||||
- 需要一个 Discord Bot Token,用于向管理员 DM 配对码
|
||||
- 需要管理员的 Discord User ID
|
||||
|
||||
---
|
||||
|
||||
## 2. 仓库结构与同步
|
||||
|
||||
在 umbrella 仓库内:
|
||||
|
||||
```
|
||||
Yonexus/
|
||||
├── Yonexus.Server
|
||||
├── Yonexus.Client
|
||||
├── Yonexus.Protocol
|
||||
```
|
||||
|
||||
确保子模块已更新:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 主节点部署(Yonexus.Server)
|
||||
|
||||
### 3.1 安装与构建
|
||||
|
||||
```bash
|
||||
cd Yonexus.Server
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3.2 配置
|
||||
|
||||
示例配置(OpenClaw 配置中):
|
||||
|
||||
```json
|
||||
{
|
||||
"followerIdentifiers": ["client-a", "client-b"],
|
||||
"notifyBotToken": "<discord-bot-token>",
|
||||
"adminUserId": "123456789012345678",
|
||||
"listenHost": "0.0.0.0",
|
||||
"listenPort": 8787,
|
||||
"publicWsUrl": "wss://example.com/yonexus"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 启动
|
||||
|
||||
- 将 `Yonexus.Server` 安装为 OpenClaw 插件
|
||||
- 启动 OpenClaw Gateway 后,Server 会自动启动 WebSocket 服务
|
||||
|
||||
---
|
||||
|
||||
## 4. 从节点部署(Yonexus.Client)
|
||||
|
||||
### 4.1 安装与构建
|
||||
|
||||
```bash
|
||||
cd Yonexus.Client
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 4.2 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"mainHost": "wss://example.com/yonexus",
|
||||
"identifier": "client-a",
|
||||
"notifyBotToken": "<discord-bot-token>",
|
||||
"adminUserId": "123456789012345678"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 启动
|
||||
|
||||
- 将 `Yonexus.Client` 安装为 OpenClaw 插件
|
||||
- 启动 OpenClaw Gateway 后,Client 会自动连接 Server
|
||||
|
||||
---
|
||||
|
||||
## 5. 首次配对流程
|
||||
|
||||
1. Client 连接后发送 `hello`
|
||||
2. Server 检测未配对,生成配对码
|
||||
3. Server 通过 Discord DM 将配对码发送给管理员
|
||||
4. 管理员将配对码转交给 Client 操作员
|
||||
5. Client 提交 `pair_confirm` 完成配对
|
||||
6. Server 返回 `pair_success` 并下发 `secret`
|
||||
7. Client 进入认证流程并开始心跳
|
||||
|
||||
---
|
||||
|
||||
## 6. 版本与兼容性
|
||||
|
||||
- 协议版本:`1`
|
||||
- 需要确保 `Yonexus.Protocol` 子模块与 Server/Client 使用的协议一致
|
||||
|
||||
---
|
||||
|
||||
## 7. 快速验证建议
|
||||
|
||||
- 主节点启动后确认 WebSocket 监听端口可达
|
||||
- 从节点能建立连接且收到 `hello_ack`
|
||||
- 配对完成后收到 `auth_success`
|
||||
- 5 分钟内可看到心跳日志/状态更新
|
||||
113
OPERATIONS.md
Normal file
113
OPERATIONS.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Yonexus 运维与排障指南 (v1)
|
||||
|
||||
本指南覆盖常见运行状态、错误码与恢复步骤。
|
||||
|
||||
---
|
||||
|
||||
## 1. 运行状态速览
|
||||
|
||||
### Client 侧状态
|
||||
- `connecting`:正在连接
|
||||
- `pairing_required`:需要配对
|
||||
- `waiting_pair_confirm`:等待提交配对码
|
||||
- `authenticating`:认证中
|
||||
- `authenticated`:已认证,心跳中
|
||||
|
||||
### Server 侧状态
|
||||
- `online`:已认证且心跳正常
|
||||
- `unstable`:7 分钟未收到心跳
|
||||
- `offline`:11 分钟未收到心跳,已断开连接
|
||||
|
||||
---
|
||||
|
||||
## 2. 常见问题与处理
|
||||
|
||||
### 2.1 Client 无法连接 Server
|
||||
**可能原因**
|
||||
- `mainHost` 配置错误
|
||||
- Server 未启动或端口不可达
|
||||
|
||||
**处理**
|
||||
- 检查 `mainHost` 是否为 `ws://` 或 `wss://`
|
||||
- 验证 Server 监听端口是否对外开放
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Client 一直停在 `pairing_required`
|
||||
**可能原因**
|
||||
- Server 未能发送 Discord DM
|
||||
- `notifyBotToken` 或 `adminUserId` 配置错误
|
||||
|
||||
**处理**
|
||||
- 检查 Server 日志是否出现 `admin_notification_failed`
|
||||
- 确认 Bot 有向目标用户发送 DM 的权限
|
||||
|
||||
---
|
||||
|
||||
### 2.3 配对码无效 / 过期
|
||||
**可能原因**
|
||||
- 输入错误
|
||||
- 配对码超过 TTL
|
||||
|
||||
**处理**
|
||||
- 重新触发配对流程(断线后重连)
|
||||
- 确保管理员转发的配对码最新
|
||||
|
||||
---
|
||||
|
||||
### 2.4 认证失败 (`auth_failed`)
|
||||
**可能原因**
|
||||
- Secret 不匹配
|
||||
- 时间漂移过大
|
||||
- Nonce 重放或格式错误
|
||||
|
||||
**处理**
|
||||
- 检查系统时间是否正确
|
||||
- 清除 Client 本地 secret,触发重新配对
|
||||
|
||||
---
|
||||
|
||||
### 2.5 频繁触发 `re_pair_required`
|
||||
**可能原因**
|
||||
- 非法重放或高频认证尝试
|
||||
- Client 有并发连接/重连异常
|
||||
|
||||
**处理**
|
||||
- 确认同一 `identifier` 只存在一个活跃 Client
|
||||
- 检查 Client 是否重复启动多个实例
|
||||
|
||||
---
|
||||
|
||||
## 3. 错误码参考
|
||||
|
||||
常见协议错误码:
|
||||
- `MALFORMED_MESSAGE`
|
||||
- `UNSUPPORTED_PROTOCOL_VERSION`
|
||||
- `IDENTIFIER_NOT_ALLOWED`
|
||||
- `PAIRING_REQUIRED`
|
||||
- `PAIRING_EXPIRED`
|
||||
- `ADMIN_NOTIFICATION_FAILED`
|
||||
- `AUTH_FAILED`
|
||||
- `NONCE_COLLISION`
|
||||
- `RATE_LIMITED`
|
||||
- `RE_PAIR_REQUIRED`
|
||||
|
||||
---
|
||||
|
||||
## 4. 恢复步骤建议
|
||||
|
||||
**场景:Client 无法恢复认证**
|
||||
1. 停止 Client
|
||||
2. 删除本地 state 中的 secret
|
||||
3. 重启 Client 触发重新配对
|
||||
|
||||
**场景:Server 端状态异常**
|
||||
1. 检查持久化 store 文件是否损坏
|
||||
2. 必要时备份后清理 store 文件(会导致所有 Client 重新配对)
|
||||
|
||||
---
|
||||
|
||||
## 5. 日志建议
|
||||
|
||||
- Server 日志中应避免输出 secret / 配对码明文
|
||||
- 建议在生产环境开启结构化日志并保留最小必要字段
|
||||
530
TASKLIST.md
530
TASKLIST.md
@@ -56,6 +56,9 @@
|
||||
## Phase 1 — 协议落地与共享契约
|
||||
|
||||
### YNX-0101 固化 builtin 协议信封与类型枚举
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 把 `PROTOCOL.md` 里的 builtin envelope 转成共享 TypeScript 类型
|
||||
|
||||
@@ -72,9 +75,17 @@
|
||||
- Server / Client 都能直接复用类型
|
||||
- 所有 builtin 消息都可被类型系统约束
|
||||
|
||||
**已完成内容**
|
||||
- 已在 `Yonexus.Protocol/src/types.ts` 落地 `BuiltinEnvelope`、builtin `type` 联合类型、各类 payload 接口与 `BuiltinPayloadMap`
|
||||
- 已增加 `Yonexus.Protocol/src/index.ts` 作为导出入口
|
||||
- 已同步更新 `Yonexus.Protocol/README.md`,让共享类型入口不再停留在 planned 状态
|
||||
|
||||
---
|
||||
|
||||
### YNX-0102 实现协议编解码工具
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 提供统一的字符串协议解析与序列化能力
|
||||
|
||||
@@ -89,9 +100,19 @@
|
||||
- `builtin::{json}` 可稳定双向转换
|
||||
- `rule::content` 和 `rule::sender::content` 都能正确解析
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Protocol/src/codec.ts`
|
||||
- 已实现 `encodeBuiltin` / `decodeBuiltin` 及类型安全的 envelope builders
|
||||
- 已实现 `parseRuleMessage` / `parseRewrittenRuleMessage` / `encodeRuleMessage` / `encodeRewrittenRuleMessage`
|
||||
- 已提供 `CodecError` 标准错误类及辅助函数 `isBuiltinMessage`
|
||||
- 已更新 `Yonexus.Protocol/src/index.ts` 导出 codec 模块
|
||||
|
||||
---
|
||||
|
||||
### YNX-0103 定义协议错误码与错误对象
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 统一错误语义,避免 Server / Client 各自发明错误格式
|
||||
|
||||
@@ -104,9 +125,17 @@
|
||||
- 所有失败路径都能落到有限集合的错误码
|
||||
- 文档与代码错误码一致
|
||||
|
||||
**已完成内容**
|
||||
- 已在 `Yonexus.Protocol/src/types.ts` 固化 `ProtocolErrorCode` 与 `ErrorPayload`
|
||||
- 已新增 `Yonexus.Protocol/src/errors.ts`,提供 `YonexusProtocolError`、错误分类映射、payload/envelope 转换辅助函数
|
||||
- 已更新 `Yonexus.Protocol/src/index.ts` 导出错误模块,方便 Server / Client 统一复用
|
||||
|
||||
---
|
||||
|
||||
### YNX-0104 编写协议级测试样例
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 在实现运行时前,先锁定协议行为
|
||||
|
||||
@@ -119,20 +148,30 @@
|
||||
- 协议测试能独立运行
|
||||
- 后续实现可直接拿这些样例做回归
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Protocol/tests/codec.test.ts`
|
||||
- 已覆盖 `encodeBuiltin`/`decodeBuiltin`、`parseRuleMessage`/`encodeRuleMessage`、服务器重写消息解析/编码
|
||||
- 已包含 malformed message、非法 rule identifier、保留字冲突等反例
|
||||
- 已提供完整的 hello flow、rule message flow 示例
|
||||
- 已配置 `package.json`、`tsconfig.json`、`vitest.config.ts` 测试基础设施
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Server 插件脚手架
|
||||
|
||||
### YNX-0201 创建 Yonexus.Server 最小插件骨架
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 让 Server 插件可被 OpenClaw 加载
|
||||
|
||||
**子任务**
|
||||
- 创建插件目录结构
|
||||
- 创建 `package.json`
|
||||
- 创建 `openclaw.plugin.json`
|
||||
- 创建入口文件 `index.ts`
|
||||
- 添加基础构建配置
|
||||
**已完成内容**
|
||||
- 已补齐 `plugin/`、`servers/`、`skills/`、`scripts/` 目录骨架
|
||||
- 已创建 `package.json`、`tsconfig.json`
|
||||
- 已写入 `plugin/openclaw.plugin.json`
|
||||
- 已补齐 wiring-only `plugin/index.ts`
|
||||
- 已补齐最小 `scripts/install.mjs`
|
||||
|
||||
**验收标准**
|
||||
- 插件能被识别
|
||||
@@ -141,6 +180,9 @@
|
||||
---
|
||||
|
||||
### YNX-0202 定义 Yonexus.Server 配置 schema
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 让 Server 配置在启动前就能被校验
|
||||
|
||||
@@ -156,9 +198,18 @@
|
||||
- 缺字段和非法字段会 fail fast
|
||||
- 错误信息足够定位问题
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/core/config.ts`
|
||||
- 已实现 `validateYonexusServerConfig()` 与 `YonexusServerConfigError`
|
||||
- 已覆盖 `followerIdentifiers`、`notifyBotToken`、`adminUserId`、`listenPort`、`listenHost`、`publicWsUrl` 的基础约束与默认值处理
|
||||
- 已从 `plugin/index.ts` 导出配置类型与校验入口,方便后续 lifecycle wiring 复用
|
||||
|
||||
---
|
||||
|
||||
### YNX-0203 实现 Server 生命周期 wiring
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 在 gateway 启动时初始化 Server 内部组件
|
||||
|
||||
@@ -173,20 +224,29 @@
|
||||
- 启停流程完整
|
||||
- 不会留下悬挂 timer / socket
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/core/runtime.ts`
|
||||
- 已实现 `YonexusServerRuntime`,负责加载持久化记录、补齐 allowlist 默认 record、驱动 transport start/stop 与 shutdown 前持久化
|
||||
- 已把连接会话映射进 `ServerRegistry.sessions`,并在断开时回收 session / 更新离线状态
|
||||
- 已从 `Yonexus.Server/plugin/index.ts` 导出 runtime 入口,便于后续接入 OpenClaw lifecycle wiring
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Client 插件脚手架
|
||||
|
||||
### YNX-0301 创建 Yonexus.Client 最小插件骨架
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 让 Client 插件可被 OpenClaw 加载
|
||||
|
||||
**子任务**
|
||||
- 创建插件目录结构
|
||||
- 创建 `package.json`
|
||||
- 创建 `openclaw.plugin.json`
|
||||
- 创建入口文件 `index.ts`
|
||||
- 添加基础构建配置
|
||||
**已完成内容**
|
||||
- 已补齐 `plugin/`、`servers/`、`skills/`、`scripts/` 目录骨架
|
||||
- 已创建 `package.json`、`tsconfig.json`
|
||||
- 已写入 `plugin/openclaw.plugin.json`
|
||||
- 已补齐 wiring-only `plugin/index.ts`
|
||||
- 已补齐最小 `scripts/install.mjs`
|
||||
|
||||
**验收标准**
|
||||
- 插件能被识别并加载
|
||||
@@ -194,6 +254,9 @@
|
||||
---
|
||||
|
||||
### YNX-0302 定义 Yonexus.Client 配置 schema
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 保证 Client 配置可启动前校验
|
||||
|
||||
@@ -207,9 +270,18 @@
|
||||
**验收标准**
|
||||
- 配置错误可在启动时直接发现
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Client/plugin/core/config.ts`
|
||||
- 已实现 `validateYonexusClientConfig()` 与 `YonexusClientConfigError`
|
||||
- 已覆盖 `mainHost`、`identifier`、`notifyBotToken`、`adminUserId` 的必填校验,并限定 `mainHost` 为 `ws://` / `wss://`
|
||||
- 已从 `plugin/index.ts` 导出配置类型与校验入口,方便后续 lifecycle wiring 直接接入
|
||||
|
||||
---
|
||||
|
||||
### YNX-0303 实现 Client 生命周期 wiring
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 在 gateway 启动时初始化 Client 运行时
|
||||
|
||||
@@ -222,11 +294,20 @@
|
||||
**验收标准**
|
||||
- Client 可启动并在未连接状态下稳定运行
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Client/plugin/core/runtime.ts`
|
||||
- 已实现 `YonexusClientRuntime`,负责加载本地 state、驱动 transport connect/disconnect,并维护最小 phase 状态机
|
||||
- 已将 lifecycle 状态从 `plugin/index.ts` 导出,便于后续挂接 gateway startup/shutdown
|
||||
- 已把 hello 发送时机放到 transport 连接成功后统一触发,避免后续 handshake wiring 分散在多个入口
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — 持久化与状态模型
|
||||
|
||||
### YNX-0401 定义 Server 持久化记录结构
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 把 `PLAN.md` 中的 `ClientRecord` 落到代码
|
||||
|
||||
@@ -240,9 +321,20 @@
|
||||
**验收标准**
|
||||
- 持久化结构足以支撑 pairing/auth/heartbeat
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/core/persistence.ts`
|
||||
- 已实现 `ClientRecord`、`ClientSession`、`ServerRegistry` 接口
|
||||
- 已实现 `SerializedClientRecord`、`ServerPersistenceData` 持久化结构
|
||||
- 已提供 `createClientRecord`、`serializeClientRecord`、`deserializeClientRecord` 工厂函数
|
||||
- 已提供 `isPairable`、`hasPendingPairing`、`isPairingExpired`、`canAuthenticate` 状态检查函数
|
||||
- 已更新 `Yonexus.Server/plugin/index.ts` 导出 persistence 模块
|
||||
|
||||
---
|
||||
|
||||
### YNX-0402 实现 Server 状态存储
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 让信任状态在重启后仍可恢复
|
||||
|
||||
@@ -256,9 +348,19 @@
|
||||
- 重启后 paired client 不丢失
|
||||
- 损坏文件时有可恢复行为
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/core/store.ts`,采用 JSON 文件作为 v1 持久化载体
|
||||
- 已实现 `loadServerStore()` / `saveServerStore()` 与 `createYonexusServerStore()`
|
||||
- 已使用 `*.tmp` + rename 的原子写入策略
|
||||
- 已在加载阶段校验文件结构,并在损坏/版本不匹配时抛出明确 corruption error
|
||||
- 已明确 rolling security windows 通过 `deserializeClientRecord()` 在重启后清空
|
||||
|
||||
---
|
||||
|
||||
### YNX-0403 实现 Client 本地信任材料存储
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 持久化 Client 的 `identifier`、私钥、secret
|
||||
|
||||
@@ -271,11 +373,21 @@
|
||||
**验收标准**
|
||||
- Client 重启后可恢复身份与 secret
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Client/plugin/core/state.ts`
|
||||
- 已定义 `YonexusClientState` / `YonexusClientStateFile`,覆盖 `identifier`、`privateKey`、`publicKey`、`secret` 与关键时间戳
|
||||
- 已实现 `loadYonexusClientState()` / `saveYonexusClientState()` / `createYonexusClientStateStore()`
|
||||
- 已支持 state 文件缺失时自动创建最小初始状态
|
||||
- 已补充 `hasClientSecret()` 与 `hasClientKeyPair()`,方便后续 handshake wiring 判断本地信任材料是否齐备
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Transport 最小闭环
|
||||
|
||||
### YNX-0501 实现 Server WebSocket 启动与连接接入
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 能监听并接受连接
|
||||
|
||||
@@ -288,9 +400,18 @@
|
||||
- 可看到客户端连接进入
|
||||
- 无协议时也不会崩
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/core/transport.ts`
|
||||
- 已实现 `YonexusServerTransport`,支持 start/stop、send/broadcast、连接管理
|
||||
- 已实现临时连接追踪、已认证连接管理、单 identifier 单连接策略
|
||||
- 已更新 `plugin/index.ts` 导出 transport 模块
|
||||
|
||||
---
|
||||
|
||||
### YNX-0502 实现 Client WebSocket 连接器
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Client 能主动连到 Server
|
||||
|
||||
@@ -303,9 +424,19 @@
|
||||
- Client 能连上可用的 Server
|
||||
- Server 不可用时不会死循环刷日志
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Client/plugin/core/transport.ts`
|
||||
- 已实现 `YonexusClientTransport`,支持 connect/disconnect/send
|
||||
- 已实现指数退避重连策略(max 10 次,1s~30s 退避)
|
||||
- 已实现心跳 timer 基础设施
|
||||
- 已更新 `plugin/index.ts` 导出 transport 模块
|
||||
|
||||
---
|
||||
|
||||
### YNX-0503 实现 hello / hello_ack
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 完成连接后的第一段协议握手
|
||||
|
||||
@@ -320,9 +451,21 @@
|
||||
- 已配对客户端收到 `auth_required`
|
||||
- 非 allowlist 客户端被拒绝
|
||||
|
||||
**已完成内容**
|
||||
- Client runtime 已在连接建立后发送 `hello`,内容包含 `identifier`、协议版本、`hasSecret`、`hasKeyPair` 与可选 `publicKey`
|
||||
- Server runtime 已接入 builtin `hello` 处理,校验 allowlist 与协议版本,并返回 `hello_ack`
|
||||
- `hello_ack.nextAction` 已按 record 状态区分为 `pair_required` / `waiting_pair_confirm` / `auth_required`
|
||||
- 对非法 identifier 与不支持的协议版本已返回协议错误,并在版本不匹配时主动关闭连接
|
||||
|
||||
**进展说明**
|
||||
- 本次只完成最小握手闭环;后续 `auth_request`、`pair_request` 等具体动作仍由 Phase 6/7 继续补齐
|
||||
|
||||
---
|
||||
|
||||
### YNX-0504 实现基础重连策略
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Client 断线后可恢复连接
|
||||
|
||||
@@ -335,11 +478,20 @@
|
||||
- 断线可自动恢复
|
||||
- 不会形成高频重连风暴
|
||||
|
||||
**已完成内容**
|
||||
- 已将 Client transport 重连逻辑改为覆盖所有非主动异常断线,而不再仅限“已认证后掉线”场景
|
||||
- 已增加 `shouldReconnect` 标志,确保手动 `disconnect()` 不会误触发自动重连
|
||||
- 已在每次 `connect()` 前清理旧的 reconnect timer,避免并发重连尝试叠加
|
||||
- 已在成功连接后移除首个 `error` 监听并重置退避计数,避免首次建连阶段的错误监听残留
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Pairing 主流程
|
||||
|
||||
### YNX-0601 实现 Client 首次密钥生成
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Client 首次运行自动生成本地公私钥
|
||||
|
||||
@@ -353,9 +505,17 @@
|
||||
- 首次启动可生成并持久化 keypair
|
||||
- 重启不会重复生成
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Client/plugin/crypto/keypair.ts`,使用 Ed25519 生成并导出 PEM keypair
|
||||
- 已在 `Yonexus.Client/plugin/core/state.ts` 增加 `ensureClientKeyPair()`,首次启动自动生成并持久化 keypair
|
||||
- Client runtime 启动时自动确保 keypair 存在
|
||||
|
||||
---
|
||||
|
||||
### YNX-0602 实现 Server pairing request 创建
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 可为待配对客户端创建 pairing 会话
|
||||
|
||||
@@ -369,9 +529,16 @@
|
||||
- 每次 pairing 会话有可验证的过期时间
|
||||
- pairing code 不会通过 Yonexus WS 下发
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/services/pairing.ts`,封装 pairing code/TTL/状态写入
|
||||
- Server runtime 在 `pair_required` 时创建 pending pairing 记录并持久化
|
||||
|
||||
---
|
||||
|
||||
### YNX-0603 实现 Discord DM 配对通知
|
||||
**状态**
|
||||
- [x] 骨架已完成,真实 DM 需外部依赖(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 通过 `notifyBotToken` 向 `adminUserId` 发送 pairing code
|
||||
|
||||
@@ -385,9 +552,19 @@
|
||||
- 通知成功时 Client 才能进入可确认状态
|
||||
- 通知失败时不会继续配对成功路径
|
||||
|
||||
**进展说明**
|
||||
- 已新增 `Yonexus.Server/plugin/notifications/discord.ts` 作为通知服务骨架
|
||||
- 已实现 `formatPairingMessage()` 格式化 DM 内容
|
||||
- 已实现 mock/stub 实现用于测试
|
||||
- **待完成**:接入真实 Discord DM 发送需要 `discord.js` 依赖和 Bot Token 配置
|
||||
- runtime 已在 pairing 创建后调用通知服务并记录 sent/failed 元数据
|
||||
|
||||
---
|
||||
|
||||
### YNX-0604 实现 pair_request / pair_confirm / pair_success
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 打通完整配对流程
|
||||
|
||||
@@ -404,9 +581,19 @@
|
||||
- 正确 code 可完成配对
|
||||
- 错误 code / 过期 code 会失败
|
||||
|
||||
**已完成内容**
|
||||
- Server runtime 已在 `hello_ack` 后发送 `pair_request`,并复用已有 pending pairing 的 TTL/状态元数据
|
||||
- Client runtime 已接收 `pair_request`,记录 pending pairing 元数据并切换到 `waiting_pair_confirm`
|
||||
- Client runtime 已新增 `submitPairingCode()`,用于发送 `pair_confirm`
|
||||
- Server runtime 已实现 `pair_confirm` 校验、`pair_success` 下发,并在成功后把 secret/publicKey 持久化到服务端记录
|
||||
- Client runtime 已在收到 `pair_success` 后保存 secret/pairedAt 到本地 state
|
||||
|
||||
---
|
||||
|
||||
### YNX-0605 实现配对失败路径
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 补齐 pairing 相关失败逻辑
|
||||
|
||||
@@ -421,11 +608,20 @@
|
||||
**验收标准**
|
||||
- 失败后不会留下脏状态导致后续无法重试
|
||||
|
||||
**已完成内容**
|
||||
- Server runtime 已为 `pair_confirm` 补齐 `identifier_not_allowed`、`invalid_code`、`expired`、`internal_error` 的 `pair_failed` 返回
|
||||
- Server runtime 已在配对通知失败时下发 `pair_failed(admin_notification_failed)` 并清理 pending pairing 状态,避免留下脏状态
|
||||
- Client runtime 已记录最近一次 pairing failure,并根据 `expired` / `admin_notification_failed` 自动回退到 `pair_required`
|
||||
- 其他失败原因会保留 `waiting_pair_confirm`,允许客户端在同一 pairing 会话内重试输入 code
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Authentication 主流程
|
||||
|
||||
### YNX-0701 固化 proof 构造与签名规范
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 明确签名输入,避免 Server / Client 不一致
|
||||
|
||||
@@ -437,9 +633,18 @@
|
||||
**验收标准**
|
||||
- 同一输入在 Client 与 Server 上验证一致
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Protocol/src/auth.ts`,固化认证共享常量:nonce 长度、时间漂移窗口、attempt window、recent nonce window
|
||||
- 已实现 `createAuthRequestSigningInput()` / `extractAuthRequestSigningInput()`,统一以稳定 JSON 序列化 `{ secret, nonce, timestamp }` 作为签名输入
|
||||
- 已补充 `isValidAuthNonce()` 与 `isTimestampFresh()`,把 nonce 与时间窗口校验规则从文档落到共享代码
|
||||
- 已从 `Yonexus.Protocol/src/index.ts` 导出认证辅助模块,供 Client / Server 复用
|
||||
|
||||
---
|
||||
|
||||
### YNX-0702 实现 Client auth_request
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Client 能基于本地 secret 与私钥发起认证
|
||||
|
||||
@@ -453,9 +658,18 @@
|
||||
- 报文字段完整
|
||||
- nonce 格式符合协议要求
|
||||
|
||||
**已完成内容**
|
||||
- Client runtime 已在收到 `hello_ack(auth_required)` 或 `pair_success` 后自动发起 `auth_request`
|
||||
- 已复用本地 `secret` + Ed25519 私钥生成签名,并发送 `identifier` / `nonce` / `proofTimestamp` / `signature` / `publicKey`
|
||||
- 已把 transport 状态补齐为 `authenticating -> authenticated`,并在 `auth_success` 后落盘 `authenticatedAt`
|
||||
- 已处理 `auth_failed` / `re_pair_required` 的最小状态回退逻辑
|
||||
|
||||
---
|
||||
|
||||
### YNX-0703 实现 Server 认证校验
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 能验证 auth_request 真伪
|
||||
|
||||
@@ -473,9 +687,19 @@
|
||||
- 正确认证返回 `auth_success`
|
||||
- 各类失败返回对应 `auth_failed`
|
||||
|
||||
**已完成内容**
|
||||
- Server runtime 已接入 `auth_request` builtin 处理并校验 allowlist / paired 状态 / publicKey 一致性
|
||||
- 已复用共享签名输入规范与 Ed25519 验签逻辑,验证客户端签名
|
||||
- 已实现 timestamp freshness、nonce 格式、recent nonce collision、10s attempt window 的最小校验
|
||||
- 正常认证会更新 `lastAuthenticatedAt` / `lastHeartbeatAt` / liveness 状态,并返回 `auth_success`
|
||||
- 遇到 nonce collision 或 rate limit 时会触发 `re_pair_required`,清理旧 secret 与安全窗口
|
||||
|
||||
---
|
||||
|
||||
### YNX-0704 实现 re_pair_required 机制
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 在不安全条件下强制重新配对
|
||||
|
||||
@@ -489,11 +713,18 @@
|
||||
**验收标准**
|
||||
- 不安全状态下不会继续接受旧信任材料
|
||||
|
||||
**已完成内容**
|
||||
- Server 端在 nonce collision / rate limit 时触发 `re_pair_required` 并清空 secret 与安全窗口
|
||||
- Client 收到 `re_pair_required` 或 `auth_failed(re_pair_required)` 后清除本地 secret 并回退到 `pair_required`
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Heartbeat 与在线状态
|
||||
|
||||
### YNX-0801 实现 Client heartbeat loop
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Client 在认证后按周期发心跳
|
||||
|
||||
@@ -506,9 +737,16 @@
|
||||
- 默认每 5 分钟发送一次
|
||||
- 状态切换时 timer 无泄漏
|
||||
|
||||
**已完成内容**
|
||||
- Client transport 在认证后启动 5 分钟 heartbeat 定时器
|
||||
- Runtime 处理 heartbeat tick,构造并发送 `heartbeat` builtin
|
||||
|
||||
---
|
||||
|
||||
### YNX-0802 实现 Server heartbeat 接收与记录
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 能更新客户端最近存活时间
|
||||
|
||||
@@ -521,9 +759,16 @@
|
||||
**验收标准**
|
||||
- 收到心跳后客户端状态可维持在线
|
||||
|
||||
**已完成内容**
|
||||
- Server runtime 已接入 `heartbeat` 处理,校验 allowlist 与认证状态
|
||||
- 记录 `lastHeartbeatAt` 并回发 `heartbeat_ack`(online 状态)
|
||||
|
||||
---
|
||||
|
||||
### YNX-0803 实现 Server liveness sweep
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 能周期性评估 online / unstable / offline
|
||||
|
||||
@@ -536,11 +781,21 @@
|
||||
**验收标准**
|
||||
- 状态转移符合文档定义
|
||||
|
||||
**已完成内容**
|
||||
- 已在 `Yonexus.Server/plugin/core/runtime.ts` 增加可配置的 liveness sweep timer(默认 30s)
|
||||
- 已实现基于 `lastHeartbeatAt` 的 `online -> unstable -> offline` 状态判定
|
||||
- 已在进入 `unstable` 时下发 `status_update(heartbeat_timeout_7m)`
|
||||
- 已在进入 `offline` 时下发 `disconnect_notice(heartbeat_timeout_11m)`、关闭连接并清理 session
|
||||
- 状态变化后会持久化,避免 sweep 结果只停留在内存中
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — 规则消息与 API
|
||||
|
||||
### YNX-0901 实现 Client rule registry
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Client 侧支持应用层规则分发
|
||||
|
||||
@@ -554,9 +809,19 @@
|
||||
- 命中规则时正确调用处理器
|
||||
- 非法注册被拒绝
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Client/plugin/core/rules.ts`
|
||||
- 已实现 `registerRule()` / `hasRule()` / `dispatch()` / `getRules()`
|
||||
- 已复用协议 codec 校验 rule identifier,拒绝空值、非法标识符与保留字 `builtin`
|
||||
- 已通过 `ClientRuleRegistryError` 固化重复注册与非法注册错误语义
|
||||
- 已从 `Yonexus.Client/plugin/index.ts` 导出 rule registry 相关类型与工厂
|
||||
|
||||
---
|
||||
|
||||
### YNX-0902 实现 Server rule registry
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 侧支持应用层规则分发
|
||||
|
||||
@@ -569,53 +834,70 @@
|
||||
**验收标准**
|
||||
- 规则注册与调用行为一致
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/core/rules.ts`
|
||||
- 已实现面向服务端重写消息格式的 `registerRule()` / `hasRule()` / `dispatch()` / `getRules()`
|
||||
- 已通过 `parseRewrittenRuleMessage()` 强制 server dispatch 处理 `${rule}::${sender}::${content}` 形态
|
||||
- 已通过 `ServerRuleRegistryError` 固化保留字与重复注册的拒绝行为
|
||||
- 已从 `Yonexus.Server/plugin/index.ts` 导出 rule registry 相关类型与工厂
|
||||
|
||||
---
|
||||
|
||||
### YNX-0903 实现 `sendMessageToServer(message)`
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 暴露 Client 到 Server 的发送 API
|
||||
|
||||
**子任务**
|
||||
- 校验连接/认证状态
|
||||
- 发送 `${rule_identifier}::${message_content}`
|
||||
- 未连接时返回明确错误
|
||||
|
||||
**验收标准**
|
||||
- 上层插件可直接调用
|
||||
**已完成内容**
|
||||
- 已在 `YonexusClientRuntime` 添加 `sendMessageToServer(message)` 方法
|
||||
- 已添加 `sendRuleMessage(ruleIdentifier, content)` 辅助方法
|
||||
- 已校验连接/认证状态,未连接时返回 false
|
||||
- 已验证消息格式(拒绝 builtin:: 前缀,要求 :: 分隔符)
|
||||
- 已复用 `encodeRuleMessage` 进行类型安全的消息编码
|
||||
|
||||
---
|
||||
|
||||
### YNX-0904 实现 `sendMessageToClient(identifier, message)`
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 暴露 Server 到指定 Client 的发送 API
|
||||
|
||||
**子任务**
|
||||
- 校验目标 client 已知且在线
|
||||
- 发送 `${rule_identifier}::${message_content}`
|
||||
- 离线时返回明确错误
|
||||
|
||||
**验收标准**
|
||||
- 上层插件可向指定客户端投递消息
|
||||
**已完成内容**
|
||||
- 已在 `YonexusServerRuntime` 添加 `sendMessageToClient(identifier, message)` 方法
|
||||
- 已添加 `sendRuleMessageToClient(identifier, ruleIdentifier, content)` 辅助方法
|
||||
- 已校验目标 client 在线且已认证,离线时返回 false
|
||||
- 已验证消息格式(拒绝 builtin:: 前缀,要求 :: 分隔符)
|
||||
- 已复用 `encodeRewrittenRuleMessage` 进行类型安全的消息编码
|
||||
|
||||
---
|
||||
|
||||
### YNX-0905 实现 Server 入站消息重写
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 把客户端来的消息重写成带 sender 标识的形式
|
||||
|
||||
**子任务**
|
||||
- 解析 `${rule_identifier}::${message_content}`
|
||||
- 重写为 `${rule_identifier}::${sender_identifier}::${message_content}`
|
||||
- 再进入 rule dispatch
|
||||
|
||||
**验收标准**
|
||||
- Server 侧处理器能可靠识别消息来源
|
||||
**已完成内容**
|
||||
- 已在 `YonexusServerRuntime` 添加 `handleRuleMessage(connection, raw)` 私有方法
|
||||
- 已更新 `handleMessage` 入口,非 builtin 消息自动进入 rule message 处理流程
|
||||
- 已实现 sender identifier 识别(从 connection 或 session 中解析)
|
||||
- 已使用 `parseRuleMessage` + `encodeRewrittenRuleMessage` 完成消息重写
|
||||
- 重写格式:`${rule}::${content}` -> `${rule}::${sender}::${content}`
|
||||
- 已校验客户端认证状态,未认证客户端发送 rule message 会被断开连接
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — 安全与鲁棒性
|
||||
|
||||
### YNX-1001 实现敏感信息脱敏日志
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 避免 secret、私钥、proof 原文进入日志
|
||||
|
||||
@@ -627,9 +909,18 @@
|
||||
**验收标准**
|
||||
- 关键敏感值不会明文打印
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `Yonexus.Server/plugin/core/logging.ts`,提供 `redactSecret()`、`redactPairingCode()`、`redactKey()` 与通用 `safeErrorMessage()`
|
||||
- 已将 Server 侧 Discord pairing notification stub 改为输出结构化日志,并对 pairing code 做脱敏显示
|
||||
- 已将 Server transport 的 WebSocket error 日志统一改为经 `safeErrorMessage()` 处理后的安全错误信息
|
||||
- 当前仍未覆盖所有未来 auth/persistence 日志点,但 v1 现有显式日志路径已不再直接打印配对码原文
|
||||
|
||||
---
|
||||
|
||||
### YNX-1002 实现 malformed / unsupported / unauthorized 防御
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 所有非法输入都能被可控拒绝
|
||||
|
||||
@@ -644,9 +935,18 @@
|
||||
- 错误不会导致进程崩溃
|
||||
- 客户端能收到明确错误反馈或断开
|
||||
|
||||
**已完成内容**
|
||||
- Server runtime 已为 builtin 解码增加 `CodecError` 捕获,malformed builtin message 会返回 `error(MALFORMED_MESSAGE)` 而不是直接抛出
|
||||
- Server runtime 已为未支持的 builtin `type` 返回显式错误响应,避免静默吞掉非法协议帧
|
||||
- Client runtime 已为 builtin 解码增加容错,不再因为坏帧直接抛出;当前会记录最近一次失败原因供上层状态观察
|
||||
- Client / Server 的 rule message 发送校验已收敛到共享 codec,而不再依赖脆弱的本地正则片段判断
|
||||
|
||||
---
|
||||
|
||||
### YNX-1003 实现单 identifier 单活跃连接策略
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 同一 client identifier 只允许一个活跃认证连接
|
||||
|
||||
@@ -658,9 +958,23 @@
|
||||
**验收标准**
|
||||
- 任意时刻同一 identifier 只有一个有效 session
|
||||
|
||||
**已完成内容**
|
||||
- 已重构 `YonexusServerTransport`:
|
||||
- `tempConnections` 改为 Map 结构,跟踪未认证连接
|
||||
- 新增 `assignIdentifierToTemp()`:hello 时仅分配 identifier,不进入认证注册表
|
||||
- 新增 `promoteToAuthenticated()`:认证成功后晋升为正式连接,此时才关闭旧连接
|
||||
- 新增 `removeTempConnection()`:认证失败时清理临时连接
|
||||
- 已更新 `runtime.ts`:
|
||||
- hello 处理使用 `assignIdentifierToTemp()` 代替 `registerConnection()`
|
||||
- auth_success 后调用 `promoteToAuthenticated()` 完成连接晋升
|
||||
- **安全改进**:未认证连接无法踢掉已认证连接,防止连接劫持攻击
|
||||
|
||||
---
|
||||
|
||||
### YNX-1004 实现重启恢复策略
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 重启后行为可预期且文档一致
|
||||
|
||||
@@ -673,27 +987,111 @@
|
||||
**验收标准**
|
||||
- 重启后的认证/配对行为稳定可解释
|
||||
|
||||
**已完成内容**
|
||||
- **持久化恢复**:`YonexusServerRuntime.start()` 加载持久化记录,恢复所有 client records
|
||||
- **allowlist 同步**:对配置中的 `followerIdentifiers` 自动创建缺失的初始记录
|
||||
- **rolling windows 清理**:`deserializeClientRecord()` 明确清空 `recentNonces` 和 `recentHandshakeAttempts`
|
||||
- v1 设计:安全窗口仅内存驻留,重启后重建,防止旧 nonce 积压
|
||||
- **pending pairing 恢复**:保留 `pairingStatus=pending` 和 `pairingCode`,支持重启后继续配对流程
|
||||
- **连接状态重建**:
|
||||
- 所有连接状态标记为 `offline`(WebSocket 连接不可恢复)
|
||||
- `lastHeartbeatAt` 保留,liveness sweep 会根据其判断状态转移
|
||||
- **已文档化**:在 `persistence.ts` 和 `types.ts` 中以 JSDoc 注释明确重启语义
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — 测试与联调
|
||||
|
||||
### YNX-1101 编写协议单元测试
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-09)
|
||||
|
||||
**目标**
|
||||
- 覆盖编解码、字段校验、错误码
|
||||
|
||||
**已完成内容**
|
||||
- 新增 `Yonexus.Protocol/tests/codec.test.ts`
|
||||
- 覆盖 builtin 编解码、rule message/rewritten message 解析、保留字与非法标识符校验
|
||||
- 覆盖 encode/parse 边界、malformed message 报错与 `isBuiltinMessage` 行为
|
||||
|
||||
### YNX-1102 编写 Server 单元测试
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-09)
|
||||
|
||||
**目标**
|
||||
- 覆盖 registry、pairing、auth、heartbeat sweep
|
||||
|
||||
**已完成内容**
|
||||
- 已为 `Yonexus.Server` 接入 `vitest` 测试基础设施(`package.json` + `vitest.config.ts`)
|
||||
- 已新增 `tests/pairing-and-rules.test.ts`
|
||||
- 已覆盖 pairing request 创建、有效/无效/过期 code 校验、通知状态迁移
|
||||
- 已覆盖 server rule registry 的 exact-match dispatch、保留字拒绝、重复注册拒绝
|
||||
- 已新增 `tests/pairing-auth-liveness.test.ts`
|
||||
- 已覆盖 auth 校验(public key/timestamp/nonce/rate limit/签名)与心跳 liveness 判定
|
||||
- 已新增 `tests/runtime-flow.test.ts`
|
||||
- 已覆盖 hello -> pair_request、pair_confirm -> auth_request -> heartbeat、未认证 rule message 拒绝、liveness sweep 触发 `status_update` / `disconnect_notice`
|
||||
- 补齐测试过程中暴露的服务端 publicKey 标准化问题:hello 阶段已统一 trim,避免 PEM 尾部换行导致 `auth_request` 误判 `invalid_signature`
|
||||
|
||||
### YNX-1103 编写 Client 单元测试
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-09)
|
||||
|
||||
**目标**
|
||||
- 覆盖状态机、keypair、auth 构造、heartbeat timer
|
||||
|
||||
**已完成内容**
|
||||
- 已为 `Yonexus.Client` 接入 `vitest` 测试基础设施(`package.json` + `vitest.config.ts`)
|
||||
- 已新增 `tests/state-and-rules.test.ts`
|
||||
- 已覆盖 client state 文件缺失初始化、状态保存/回读
|
||||
- 已覆盖 `ensureClientKeyPair()` 的首次生成/重复调用复用,以及签名/验签基本链路
|
||||
- 已覆盖 client rule registry 的 dispatch、保留字拒绝、重复注册拒绝
|
||||
- 已新增 `tests/state-auth-heartbeat.test.ts`
|
||||
- 已覆盖 auth_request signing input、nonce/timestamp 校验、状态机迁移与 heartbeat scheduler 行为
|
||||
- 已新增 `tests/runtime-flow.test.ts`
|
||||
- 已覆盖启动时加载 state + 自动补 keypair、`hello_ack`/`pair_request`/`pair_success`/`auth_success` 协作链路、`auth_failed`/`re_pair_required` 的 trust reset,以及认证后 heartbeat 发送门禁
|
||||
|
||||
### YNX-1104 编写 Server-Client 集成测试
|
||||
**状态**
|
||||
- [x] 框架已完成(2026-04-09)
|
||||
|
||||
**目标**
|
||||
- 覆盖首次配对、正常重连、认证失败、心跳超时、re-pair
|
||||
|
||||
**已完成内容**
|
||||
- 已创建 `tests/integration/framework.test.ts` 集成测试框架
|
||||
- 提供 `MockTransportPair` 模拟 Server-Client 网络通信
|
||||
- 提供 `createIntegrationTestContext()` 快速创建集成测试环境
|
||||
- 实现首批集成测试用例:
|
||||
- 首次配对完整流程(hello → pair_request → pair_confirm → auth → heartbeat)
|
||||
- 带凭证的重连流程(跳过配对直接认证)
|
||||
- 心跳交换验证
|
||||
|
||||
**待完成**
|
||||
- 更多边界场景:心跳超时断线、re-pair 触发、并发连接
|
||||
- 真实 WebSocket 传输层集成测试(可选)
|
||||
|
||||
---
|
||||
### YNX-1104a 细化:首次配对集成测试
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-09)
|
||||
|
||||
**已完成内容**
|
||||
- `First-Time Pairing Flow` 测试套件
|
||||
- 验证端到端的配对与认证状态迁移
|
||||
|
||||
---
|
||||
### YNX-1104b 细化:重连集成测试
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-09)
|
||||
|
||||
**已完成内容**
|
||||
- `Reconnection Flow` 测试套件
|
||||
- 验证已配对客户端跳过配对直接进入认证
|
||||
|
||||
### YNX-1105 编写失败路径测试矩阵
|
||||
**状态**
|
||||
- [x] 框架与 PF 测试已完成(2026-04-09)
|
||||
|
||||
**目标**
|
||||
- 系统性覆盖 pairing/auth 失败路径
|
||||
|
||||
@@ -711,30 +1109,96 @@
|
||||
**验收标准**
|
||||
- 核心安全路径都有自动化测试
|
||||
|
||||
**已完成内容**
|
||||
- 已创建 `tests/failure-path/MATRIX.md` 失败路径测试矩阵文档
|
||||
- 定义 PF-01~PF-10(Pairing Failures)
|
||||
- 定义 AF-01~AF-11(Authentication Failures)
|
||||
- 定义 RP-01~RP-04(Re-pairing Triggers)
|
||||
- 定义 CF-01~CF-07(Connection Failures)
|
||||
- 定义 HF-01~HF-04(Heartbeat Failures)
|
||||
- 定义 SR-01~SR-06(State Recovery)
|
||||
- 标记优先级(🔴 Phase 1 关键安全路径)
|
||||
|
||||
- 已创建 `tests/failure-path/pairing-failures.test.ts`
|
||||
- PF-01: 无效配对码及重试机制
|
||||
- PF-02: 过期配对码清理
|
||||
- PF-03: 非 allowlist 标识符拒绝
|
||||
- PF-04: 管理员通知失败处理
|
||||
- PF-05: 空/空白配对码拒绝
|
||||
- PF-06: 畸形 pair_confirm 载荷处理
|
||||
- PF-07: 已配对客户端重复配对保护
|
||||
- Edge Cases: 并发配对、过期清理验证
|
||||
|
||||
- 已新增 `Yonexus.Server/tests/auth-failures.test.ts`
|
||||
- AF-07: nonce collision 触发 re_pair_required
|
||||
- AF-08: rate limit 触发 re_pair_required
|
||||
- 覆盖 re_pair 后 secret 清理与 pairingStatus=revoked
|
||||
|
||||
**待完成**
|
||||
- AF(Authentication Failures)剩余场景(stale/future timestamp、invalid signature 等)
|
||||
- RP(Re-pairing Triggers)测试套件
|
||||
- CF/HF/SR 边界场景测试
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — 文档与交付
|
||||
|
||||
### YNX-1201 补齐 Server README
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Server 仓库可独立被安装与使用
|
||||
|
||||
**已完成内容**
|
||||
- 已补齐 `Yonexus.Server/README.md`
|
||||
- 已写明当前实现范围、配置字段、启动/连接流程、公开 API、持久化语义与开发方式
|
||||
- 已明确当前限制项(真实 Discord DM、测试覆盖、生命周期集成等),避免 README 过度承诺
|
||||
|
||||
### YNX-1202 补齐 Client README
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- Client 仓库可独立被安装与使用
|
||||
|
||||
**已完成内容**
|
||||
- 已补齐 `Yonexus.Client/README.md`
|
||||
- 已写明配置模型、启动/配对/认证流程、公开 API、本地 state 结构与开发方式
|
||||
- 已明确当前限制项(测试、配对输入 UX、生命周期集成等),方便后续交接和联调
|
||||
|
||||
### YNX-1203 输出部署文档
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 写清楚单主多从部署方式、配置示例、配对流程
|
||||
|
||||
**已完成内容**
|
||||
- 新增 `DEPLOYMENT.md`,覆盖拓扑、子模块同步、Server/Client 安装、配置示例与配对流程
|
||||
|
||||
### YNX-1204 输出运维排障文档
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 写清楚常见报错、状态含义、恢复步骤
|
||||
|
||||
**已完成内容**
|
||||
- 新增 `OPERATIONS.md`,覆盖状态说明、常见错误与恢复建议
|
||||
|
||||
### YNX-1205 输出协议测试与验收清单
|
||||
**状态**
|
||||
- [x] 已完成(2026-04-08)
|
||||
|
||||
**目标**
|
||||
- 让后续改动有统一回归基线
|
||||
|
||||
**已完成内容**
|
||||
- 已新增 `ACCEPTANCE.md`
|
||||
- 已按协议层、Server、Client、联调、失败路径回归矩阵拆分验收项
|
||||
- 已把 `YNX-1101`~`YNX-1105` 与具体验收/测试目标建立对应关系,方便后续补自动化测试时直接对照
|
||||
|
||||
---
|
||||
|
||||
## 推荐执行顺序(最小闭环)
|
||||
|
||||
Submodule Yonexus.Client updated: 5234358cac...65c1f92cc1
Submodule Yonexus.Protocol updated: 9232aa7c17...a7e1a9c210
Submodule Yonexus.Server updated: d8290c0aa7...35972981d3
147
tests/failure-path/MATRIX.md
Normal file
147
tests/failure-path/MATRIX.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Yonexus Failure Path Test Matrix
|
||||
|
||||
This document defines the systematic test coverage for pairing and authentication failure scenarios.
|
||||
|
||||
## Test Matrix Legend
|
||||
|
||||
- ✅ = Test implemented
|
||||
- 🔄 = Test stub exists, needs implementation
|
||||
- ⬜ = Not yet implemented
|
||||
- 🔴 = Critical path, high priority
|
||||
|
||||
---
|
||||
|
||||
## 1. Pairing Failure Paths
|
||||
|
||||
| ID | Scenario | Trigger | Expected Behavior | Status |
|
||||
|----|----------|---------|-------------------|--------|
|
||||
| PF-01 | Invalid pairing code | Client submits wrong code | `pair_failed(invalid_code)`, allow retry | ⬜ |
|
||||
| PF-02 | Expired pairing code | Client submits after expiry | `pair_failed(expired)`, reset to `pair_required` | ⬜ |
|
||||
| PF-03 | Identifier not in allowlist | Unknown client tries to pair | `pair_failed(identifier_not_allowed)`, close connection | ⬜ |
|
||||
| PF-04 | Admin notification failed | Discord DM fails to send | `pair_failed(admin_notification_failed)`, abort pairing | ⬜ |
|
||||
| PF-05 | Empty pairing code | Client submits empty string | `pair_failed(invalid_code)` | ⬜ |
|
||||
| PF-06 | Malformed pair_confirm payload | Missing required fields | Protocol error, no state change | ⬜ |
|
||||
| PF-07 | Double pairing attempt | Client calls pair_confirm twice | Second attempt rejected if already paired | ⬜ |
|
||||
| PF-08 | Pairing during active session | Paired client tries to pair again | Reject, maintain existing trust | ⬜ |
|
||||
| PF-09 | Server restart during pairing | Server restarts before confirm | Pairing state preserved, code still valid | ⬜ |
|
||||
| PF-10 | Client restart during pairing | Client restarts before submit | Client must restart pairing flow | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication Failure Paths
|
||||
|
||||
| ID | Scenario | Trigger | Expected Behavior | Status |
|
||||
|----|----------|---------|-------------------|--------|
|
||||
| AF-01 | Unknown identifier | Auth from unpaired client | `auth_failed(unknown_identifier)` | ⬜ |
|
||||
| AF-02 | Not paired | Auth before pairing complete | `auth_failed(not_paired)` | ⬜ |
|
||||
| AF-03 | Invalid signature | Wrong private key used | `auth_failed(invalid_signature)` | ⬜ |
|
||||
| AF-04 | Wrong secret | Client has outdated secret | `auth_failed(invalid_secret)` | ⬜ |
|
||||
| AF-05 | Stale timestamp | Proof timestamp >10s old | `auth_failed(stale_timestamp)` | ⬜ |
|
||||
| AF-06 | Future timestamp | Proof timestamp in future | `auth_failed(future_timestamp)` | ⬜ |
|
||||
| AF-07 | Nonce collision | Reused nonce within window | `auth_failed(nonce_collision)` → `re_pair_required` 🔴 | ✅ |
|
||||
| AF-08 | Rate limited | >10 attempts in 10s | `auth_failed(rate_limited)` → `re_pair_required` 🔴 | ✅ |
|
||||
| AF-09 | Wrong public key | Key doesn't match stored | `auth_failed(invalid_signature)` | ⬜ |
|
||||
| AF-10 | Malformed auth_request | Missing required fields | Protocol error | ⬜ |
|
||||
| AF-11 | Tampered proof | Modified signature | `auth_failed(invalid_signature)` | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 3. Re-pairing Triggers
|
||||
|
||||
| ID | Scenario | Cause | Server Action | Client Action | Status |
|
||||
|----|----------|-------|---------------|---------------|--------|
|
||||
| RP-01 | Nonce collision | Replay attack detected | Clear secret, reset state | Enter `pair_required` | ⬜ |
|
||||
| RP-02 | Rate limit exceeded | Brute force detected | Clear secret, reset state | Enter `pair_required` | ⬜ |
|
||||
| RP-03 | Admin-initiated | Manual revocation | Mark revoked, notify | Enter `pair_required` | ⬜ |
|
||||
| RP-04 | Key rotation | Client sends new public key | Update key, keep secret | Continue with new key | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 4. Connection Failure Paths
|
||||
|
||||
| ID | Scenario | Trigger | Expected Behavior | Status |
|
||||
|----|----------|---------|-------------------|--------|
|
||||
| CF-01 | Network partition | Connection drops mid-auth | Client retries with backoff | ⬜ |
|
||||
| CF-02 | Server unreachable | Initial connect fails | Exponential backoff retry | ⬜ |
|
||||
| CF-03 | Duplicate connection | Same ID connects twice | Old connection closed, new accepted | ⬜ |
|
||||
| CF-04 | Protocol version mismatch | Unsupported version | Connection rejected with error | ⬜ |
|
||||
| CF-05 | Malformed hello | Invalid JSON | Error response, connection maintained | ⬜ |
|
||||
| CF-06 | Unauthenticated rule message | Client sends before auth | Connection closed | ⬜ |
|
||||
| CF-07 | Reserved rule registration | Plugin tries `registerRule("builtin")` | Registration rejected | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 5. Heartbeat Failure Paths
|
||||
|
||||
| ID | Scenario | Trigger | Expected Behavior | Status |
|
||||
|----|----------|---------|-------------------|--------|
|
||||
| HF-01 | 7-minute timeout | No heartbeat received | Status → `unstable`, notify | ⬜ |
|
||||
| HF-02 | 11-minute timeout | Still no heartbeat | Status → `offline`, disconnect | ⬜ |
|
||||
| HF-03 | Early heartbeat | Heartbeat before auth | Rejected/ignored | ⬜ |
|
||||
| HF-04 | Heartbeat from unauthenticated | Wrong state | Error, possible disconnect | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 6. State Recovery Scenarios
|
||||
|
||||
| ID | Scenario | Condition | Expected Recovery | Status |
|
||||
|----|----------|-----------|-------------------|--------|
|
||||
| SR-01 | Server restart with pending pairing | Pairing in progress | Preserve pairing state, code valid | ⬜ |
|
||||
| SR-02 | Server restart with active sessions | Online clients | All marked offline, reconnect required | ⬜ |
|
||||
| SR-03 | Client restart with credentials | Has secret + keys | Resume with auth, no re-pairing | ⬜ |
|
||||
| SR-04 | Client restart without credentials | First run | Full pairing flow required | ⬜ |
|
||||
| SR-05 | Corrupted server store | File unreadable | Clear state, start fresh | ⬜ |
|
||||
| SR-06 | Corrupted client state | File unreadable | Reset to initial state | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Critical Security Paths (🔴)
|
||||
1. AF-07 Nonce collision → re-pairing
|
||||
2. AF-08 Rate limiting → re-pairing
|
||||
3. PF-04 Admin notification failure
|
||||
4. CF-06 Unauthenticated message handling
|
||||
|
||||
### Phase 2: Core Functionality
|
||||
5. PF-01/02 Invalid/expired pairing codes
|
||||
6. AF-03/04 Signature and secret validation
|
||||
7. AF-05/06 Timestamp validation
|
||||
8. HF-01/02 Heartbeat timeout handling
|
||||
|
||||
### Phase 3: Edge Cases
|
||||
9. All connection failure paths
|
||||
10. State recovery scenarios
|
||||
11. Double-attempt scenarios
|
||||
|
||||
---
|
||||
|
||||
## Test Implementation Notes
|
||||
|
||||
### Running the Matrix
|
||||
|
||||
```bash
|
||||
# Run specific failure path category
|
||||
npm test -- pairing-failures
|
||||
npm test -- auth-failures
|
||||
npm test -- connection-failures
|
||||
|
||||
# Run all failure path tests
|
||||
npm test -- failure-paths
|
||||
```
|
||||
|
||||
### Adding New Test Cases
|
||||
|
||||
1. Add row to appropriate table above
|
||||
2. Assign unique ID (PF-, AF-, RP-, CF-, HF-, SR- prefix)
|
||||
3. Update status when implementing
|
||||
4. Link to test file location
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- Protocol spec: `../PROTOCOL.md`
|
||||
- Acceptance criteria: `../ACCEPTANCE.md`
|
||||
- Server tests: `../Yonexus.Server/tests/`
|
||||
- Client tests: `../Yonexus.Client/tests/`
|
||||
616
tests/failure-path/pairing-failures.test.ts
Normal file
616
tests/failure-path/pairing-failures.test.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
decodeBuiltin,
|
||||
encodeBuiltin,
|
||||
buildHello,
|
||||
buildHelloAck,
|
||||
buildPairRequest,
|
||||
buildPairConfirm,
|
||||
buildPairFailed,
|
||||
buildPairSuccess,
|
||||
type PairConfirmPayload,
|
||||
type PairFailedPayload,
|
||||
YONEXUS_PROTOCOL_VERSION,
|
||||
ProtocolErrorCode
|
||||
} from "../../Yonexus.Protocol/src/index.js";
|
||||
import { createYonexusServerRuntime } from "../../Yonexus.Server/plugin/core/runtime.js";
|
||||
import type { ClientRecord } from "../../Yonexus.Server/plugin/core/persistence.js";
|
||||
import type { YonexusServerStore } from "../../Yonexus.Server/plugin/core/store.js";
|
||||
import type { ClientConnection, ServerTransport } from "../../Yonexus.Server/plugin/core/transport.js";
|
||||
|
||||
/**
|
||||
* YNX-1105b: Pairing Failure Path Tests
|
||||
*
|
||||
* Covers:
|
||||
* - PF-01: Invalid pairing code
|
||||
* - PF-02: Expired pairing code
|
||||
* - PF-03: Identifier not in allowlist
|
||||
* - PF-04: Admin notification failed (partial - notification stub)
|
||||
* - PF-05: Empty pairing code
|
||||
* - PF-06: Malformed pair_confirm payload
|
||||
* - PF-07: Double pairing attempt
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Test Utilities
|
||||
// ============================================================================
|
||||
|
||||
function createMockSocket() {
|
||||
return { close: vi.fn() } as unknown as ClientConnection["ws"];
|
||||
}
|
||||
|
||||
function createConnection(identifier: string | null = null): ClientConnection {
|
||||
return {
|
||||
identifier,
|
||||
ws: createMockSocket(),
|
||||
connectedAt: 1_710_000_000,
|
||||
isAuthenticated: false
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore {
|
||||
const persisted = new Map(initialClients.map((r) => [r.identifier, r]));
|
||||
return {
|
||||
filePath: "/tmp/test.json",
|
||||
load: vi.fn(async () => ({
|
||||
version: 1,
|
||||
persistedAt: 1_710_000_000,
|
||||
clients: new Map(persisted)
|
||||
})),
|
||||
save: vi.fn(async (clients: Iterable<ClientRecord>) => {
|
||||
persisted.clear();
|
||||
for (const c of clients) persisted.set(c.identifier, c);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTransport() {
|
||||
const sent: Array<{ connection: ClientConnection; message: string }> = [];
|
||||
const closed: Array<{ identifier: string; code?: number; reason?: string }> = [];
|
||||
|
||||
const transport: ServerTransport = {
|
||||
isRunning: false,
|
||||
connections: new Map(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
send: vi.fn((id: string, msg: string) => { sent.push({ connection: { identifier: id } as ClientConnection, message: msg }); return true; }),
|
||||
sendToConnection: vi.fn((conn: ClientConnection, msg: string) => { sent.push({ connection: conn, message: msg }); return true; }),
|
||||
broadcast: vi.fn(),
|
||||
closeConnection: vi.fn((id: string, code?: number, reason?: string) => { closed.push({ identifier: id, code, reason }); return true; }),
|
||||
promoteToAuthenticated: vi.fn(),
|
||||
removeTempConnection: vi.fn(),
|
||||
assignIdentifierToTemp: vi.fn()
|
||||
};
|
||||
|
||||
return { transport, sent, closed };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pairing Failure Path Tests
|
||||
// ============================================================================
|
||||
|
||||
describe("YNX-1105b: Pairing Failure Paths", () => {
|
||||
let now = 1_710_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_710_000_000;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("PF-01: Invalid pairing code", () => {
|
||||
it("returns pair_failed(invalid_code) when wrong code submitted", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
// Start pairing flow
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode;
|
||||
expect(pairingCode).toBeDefined();
|
||||
|
||||
// Submit wrong code
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: "WRONG-CODE-999" },
|
||||
{ timestamp: now + 10 }
|
||||
)));
|
||||
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
expect((lastMessage.payload as PairFailedPayload).reason).toBe("invalid_code");
|
||||
|
||||
// Client remains in pending state, can retry
|
||||
expect(runtime.state.registry.clients.get("client-a")?.pairingStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows retry after invalid code failure", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
const correctCode = runtime.state.registry.clients.get("client-a")?.pairingCode;
|
||||
|
||||
// First attempt: wrong code
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: "WRONG" },
|
||||
{ timestamp: now + 10 }
|
||||
)));
|
||||
expect(decodeBuiltin(sent.at(-1)!.message).type).toBe("pair_failed");
|
||||
|
||||
// Second attempt: correct code
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: correctCode! },
|
||||
{ timestamp: now + 20 }
|
||||
)));
|
||||
expect(decodeBuiltin(sent.at(-1)!.message).type).toBe("pair_success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PF-02: Expired pairing code", () => {
|
||||
it("returns pair_failed(expired) when code submitted after expiry", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode;
|
||||
const expiresAt = runtime.state.registry.clients.get("client-a")?.pairingExpiresAt;
|
||||
expect(expiresAt).toBeDefined();
|
||||
|
||||
// Advance time past expiry
|
||||
now = expiresAt! + 1;
|
||||
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: pairingCode! },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
expect((lastMessage.payload as PairFailedPayload).reason).toBe("expired");
|
||||
|
||||
// Pairing state reset to allow new pairing
|
||||
expect(runtime.state.registry.clients.get("client-a")?.pairingStatus).toBe("unpaired");
|
||||
expect(runtime.state.registry.clients.get("client-a")?.pairingCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PF-03: Identifier not in allowlist", () => {
|
||||
it("rejects hello from unknown identifier", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["allowed-client"], // Only this one is allowed
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "unknown-client", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Should receive hello_ack with rejected or an error
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("error");
|
||||
// Identifier should not be registered
|
||||
expect(runtime.state.registry.clients.has("unknown-client")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects pair_confirm from unknown identifier even if somehow received", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["allowed-client"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
// Try to send pair_confirm for unknown client
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "unknown-client", pairingCode: "SOME-CODE" },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
expect((lastMessage.payload as PairFailedPayload).reason).toBe("identifier_not_allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PF-04: Admin notification failure", () => {
|
||||
it("fails pairing when notification cannot be sent", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "", // Empty token should cause notification failure
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Check the pair_request indicates notification failure
|
||||
const pairRequest = sent.find(m => decodeBuiltin(m.message).type === "pair_request");
|
||||
expect(pairRequest).toBeDefined();
|
||||
|
||||
// Should not have created a valid pending pairing
|
||||
const record = runtime.state.registry.clients.get("client-a");
|
||||
if (record?.pairingStatus === "pending") {
|
||||
// If notification failed, pairing should indicate this
|
||||
expect(record.pairingNotifyStatus).toBe("failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PF-05: Empty pairing code", () => {
|
||||
it("rejects empty pairing code", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Submit empty code
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: "" },
|
||||
{ timestamp: now + 10 }
|
||||
)));
|
||||
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
expect((lastMessage.payload as PairFailedPayload).reason).toBe("invalid_code");
|
||||
});
|
||||
|
||||
it("rejects whitespace-only pairing code", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Submit whitespace code
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: " \t\n " },
|
||||
{ timestamp: now + 10 }
|
||||
)));
|
||||
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PF-06: Malformed pair_confirm payload", () => {
|
||||
it("handles missing identifier in pair_confirm", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Send malformed payload (missing fields)
|
||||
await runtime.handleMessage(conn, encodeBuiltin({
|
||||
type: "pair_confirm",
|
||||
timestamp: now,
|
||||
payload: { pairingCode: "SOME-CODE" } // Missing identifier
|
||||
}));
|
||||
|
||||
// Should receive an error response
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("error");
|
||||
});
|
||||
|
||||
it("handles missing pairingCode in pair_confirm", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Send malformed payload (missing pairingCode)
|
||||
await runtime.handleMessage(conn, encodeBuiltin({
|
||||
type: "pair_confirm",
|
||||
timestamp: now,
|
||||
payload: { identifier: "client-a" } // Missing pairingCode
|
||||
}));
|
||||
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PF-07: Double pairing attempt", () => {
|
||||
it("rejects pair_confirm for already paired client", async () => {
|
||||
const store = createMockStore([{
|
||||
identifier: "client-a",
|
||||
pairingStatus: "paired",
|
||||
publicKey: "existing-key",
|
||||
secret: "existing-secret",
|
||||
status: "offline",
|
||||
recentNonces: [],
|
||||
recentHandshakeAttempts: [],
|
||||
createdAt: now - 1000,
|
||||
updatedAt: now - 500,
|
||||
pairedAt: now - 500
|
||||
}]);
|
||||
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
// Try to pair an already paired client
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: "SOME-CODE" },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Should reject since already paired
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
|
||||
// Existing trust material preserved
|
||||
const record = runtime.state.registry.clients.get("client-a");
|
||||
expect(record?.pairingStatus).toBe("paired");
|
||||
expect(record?.secret).toBe("existing-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles concurrent pair_confirm from different connections with same identifier", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
// First connection starts pairing
|
||||
const conn1 = createConnection();
|
||||
await runtime.handleMessage(conn1, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode;
|
||||
|
||||
// Second connection tries to pair with same identifier
|
||||
const conn2 = createConnection();
|
||||
await runtime.handleMessage(conn2, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: pairingCode! },
|
||||
{ timestamp: now + 10 }
|
||||
)));
|
||||
|
||||
// Should succeed - pairing is identifier-based, not connection-based
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_success");
|
||||
});
|
||||
|
||||
it("cleans up pending pairing state on expiry", async () => {
|
||||
const store = createMockStore();
|
||||
const { transport, sent } = createMockTransport();
|
||||
const runtime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const conn = createConnection();
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildHello(
|
||||
{ identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
// Verify pending state exists
|
||||
const recordBefore = runtime.state.registry.clients.get("client-a");
|
||||
expect(recordBefore?.pairingStatus).toBe("pending");
|
||||
expect(recordBefore?.pairingCode).toBeDefined();
|
||||
|
||||
// Expire and try to use old code
|
||||
now += 400; // Past default TTL
|
||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
||||
{ identifier: "client-a", pairingCode: recordBefore?.pairingCode! },
|
||||
{ timestamp: now }
|
||||
)));
|
||||
|
||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
||||
expect(lastMessage.type).toBe("pair_failed");
|
||||
expect((lastMessage.payload as PairFailedPayload).reason).toBe("expired");
|
||||
|
||||
// State cleaned up
|
||||
const recordAfter = runtime.state.registry.clients.get("client-a");
|
||||
expect(recordAfter?.pairingStatus).toBe("unpaired");
|
||||
expect(recordAfter?.pairingCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
466
tests/integration/framework.test.ts
Normal file
466
tests/integration/framework.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { ClientConnection, ServerTransport } from "../Yonexus.Server/plugin/core/transport.js";
|
||||
import type { ClientTransport } from "../Yonexus.Client/plugin/core/transport.js";
|
||||
import type { YonexusServerStore } from "../Yonexus.Server/plugin/core/store.js";
|
||||
import type { YonexusClientStateStore } from "../Yonexus.Client/plugin/core/state.js";
|
||||
import { createYonexusServerRuntime } from "../Yonexus.Server/plugin/core/runtime.js";
|
||||
import { createYonexusClientRuntime } from "../Yonexus.Client/plugin/core/runtime.js";
|
||||
import {
|
||||
decodeBuiltin,
|
||||
encodeBuiltin,
|
||||
buildHello,
|
||||
buildHelloAck,
|
||||
buildPairRequest,
|
||||
buildPairConfirm,
|
||||
buildPairSuccess,
|
||||
buildAuthRequest,
|
||||
buildAuthSuccess,
|
||||
buildHeartbeat,
|
||||
buildHeartbeatAck,
|
||||
YONEXUS_PROTOCOL_VERSION
|
||||
} from "../Yonexus.Protocol/src/index.js";
|
||||
import { generateKeyPair } from "../Yonexus.Client/plugin/crypto/keypair.js";
|
||||
import type { ClientRecord } from "../Yonexus.Server/plugin/core/persistence.js";
|
||||
import type { YonexusClientState } from "../Yonexus.Client/plugin/core/state.js";
|
||||
|
||||
/**
|
||||
* Yonexus Server-Client Integration Test Framework
|
||||
*
|
||||
* This module provides utilities for testing Server and Client interactions
|
||||
* without requiring real network sockets.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Mock Transport Pair - Simulates network connection between Server and Client
|
||||
// ============================================================================
|
||||
|
||||
export interface MockMessageChannel {
|
||||
serverToClient: string[];
|
||||
clientToServer: string[];
|
||||
}
|
||||
|
||||
export interface MockTransportPair {
|
||||
serverTransport: ServerTransport;
|
||||
clientTransport: ClientTransport;
|
||||
channel: MockMessageChannel;
|
||||
getServerReceived: () => string[];
|
||||
getClientReceived: () => string[];
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export function createMockTransportPair(): MockTransportPair {
|
||||
const channel: MockMessageChannel = {
|
||||
serverToClient: [],
|
||||
clientToServer: []
|
||||
};
|
||||
|
||||
// Track server-side connections
|
||||
const serverConnections = new Map<string, ClientConnection>();
|
||||
let tempConnection: ClientConnection | null = null;
|
||||
|
||||
// Server Transport Mock
|
||||
const serverTransport: ServerTransport = {
|
||||
isRunning: false,
|
||||
connections: serverConnections,
|
||||
|
||||
start: vi.fn(async () => {
|
||||
serverTransport.isRunning = true;
|
||||
}),
|
||||
|
||||
stop: vi.fn(async () => {
|
||||
serverTransport.isRunning = false;
|
||||
serverConnections.clear();
|
||||
}),
|
||||
|
||||
send: vi.fn((identifier: string, message: string) => {
|
||||
if (serverConnections.has(identifier)) {
|
||||
channel.serverToClient.push(message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
|
||||
sendToConnection: vi.fn((connection: ClientConnection, message: string) => {
|
||||
channel.serverToClient.push(message);
|
||||
return true;
|
||||
}),
|
||||
|
||||
broadcast: vi.fn((message: string) => {
|
||||
channel.serverToClient.push(`[broadcast]:${message}`);
|
||||
}),
|
||||
|
||||
closeConnection: vi.fn((identifier: string, code?: number, reason?: string) => {
|
||||
const conn = serverConnections.get(identifier);
|
||||
if (conn) {
|
||||
conn.isAuthenticated = false;
|
||||
serverConnections.delete(identifier);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
|
||||
assignIdentifierToTemp: vi.fn((ws, identifier: string) => {
|
||||
if (tempConnection) {
|
||||
tempConnection.identifier = identifier;
|
||||
}
|
||||
}),
|
||||
|
||||
promoteToAuthenticated: vi.fn((identifier: string, ws) => {
|
||||
if (tempConnection && tempConnection.identifier === identifier) {
|
||||
tempConnection.isAuthenticated = true;
|
||||
serverConnections.set(identifier, tempConnection);
|
||||
tempConnection = null;
|
||||
}
|
||||
}),
|
||||
|
||||
removeTempConnection: vi.fn(() => {
|
||||
tempConnection = null;
|
||||
})
|
||||
};
|
||||
|
||||
// Client Transport Mock
|
||||
let clientState: import("../Yonexus.Client/plugin/core/transport.js").ClientConnectionState = "idle";
|
||||
|
||||
const clientTransport: ClientTransport = {
|
||||
get state() {
|
||||
return clientState;
|
||||
},
|
||||
|
||||
get isConnected() {
|
||||
return clientState !== "idle" && clientState !== "disconnected" && clientState !== "error";
|
||||
},
|
||||
|
||||
get isAuthenticated() {
|
||||
return clientState === "authenticated";
|
||||
},
|
||||
|
||||
connect: vi.fn(async () => {
|
||||
clientState = "connected";
|
||||
// Simulate connection - create temp connection on server side
|
||||
tempConnection = {
|
||||
identifier: null,
|
||||
ws: { close: vi.fn() } as unknown as WebSocket,
|
||||
connectedAt: Date.now(),
|
||||
isAuthenticated: false
|
||||
};
|
||||
}),
|
||||
|
||||
disconnect: vi.fn(() => {
|
||||
clientState = "disconnected";
|
||||
tempConnection = null;
|
||||
}),
|
||||
|
||||
send: vi.fn((message: string) => {
|
||||
if (clientState === "connected" || clientState === "authenticated" || clientState === "authenticating") {
|
||||
channel.clientToServer.push(message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
|
||||
markAuthenticated: vi.fn(() => {
|
||||
clientState = "authenticated";
|
||||
}),
|
||||
|
||||
markAuthenticating: vi.fn(() => {
|
||||
clientState = "authenticating";
|
||||
})
|
||||
};
|
||||
|
||||
return {
|
||||
serverTransport,
|
||||
clientTransport,
|
||||
channel,
|
||||
getServerReceived: () => [...channel.clientToServer],
|
||||
getClientReceived: () => [...channel.serverToClient],
|
||||
clearMessages: () => {
|
||||
channel.serverToClient.length = 0;
|
||||
channel.clientToServer.length = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Store Factories
|
||||
// ============================================================================
|
||||
|
||||
export function createMockServerStore(initialClients: ClientRecord[] = []): YonexusServerStore {
|
||||
const persisted = new Map(initialClients.map((record) => [record.identifier, record]));
|
||||
|
||||
return {
|
||||
filePath: "/tmp/yonexus-server-test.json",
|
||||
load: vi.fn(async () => ({
|
||||
version: 1,
|
||||
persistedAt: Date.now(),
|
||||
clients: new Map(persisted)
|
||||
})),
|
||||
save: vi.fn(async (clients: Iterable<ClientRecord>) => {
|
||||
persisted.clear();
|
||||
for (const client of clients) {
|
||||
persisted.set(client.identifier, client);
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockClientStore(initialState?: Partial<YonexusClientState>): YonexusClientStateStore {
|
||||
let state: YonexusClientState = {
|
||||
identifier: initialState?.identifier ?? "test-client",
|
||||
publicKey: initialState?.publicKey,
|
||||
privateKey: initialState?.privateKey,
|
||||
secret: initialState?.secret,
|
||||
pairedAt: initialState?.pairedAt,
|
||||
authenticatedAt: initialState?.authenticatedAt,
|
||||
updatedAt: initialState?.updatedAt ?? Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
filePath: "/tmp/yonexus-client-test.json",
|
||||
load: vi.fn(async () => ({ ...state })),
|
||||
save: vi.fn(async (next) => {
|
||||
state = { ...next };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Runtime Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IntegrationTestContext {
|
||||
serverRuntime: ReturnType<typeof createYonexusServerRuntime>;
|
||||
clientRuntime: ReturnType<typeof createYonexusClientRuntime>;
|
||||
transports: MockTransportPair;
|
||||
serverStore: YonexusServerStore;
|
||||
clientStore: YonexusClientStateStore;
|
||||
advanceTime: (seconds: number) => void;
|
||||
processServerToClient: () => Promise<void>;
|
||||
processClientToServer: () => Promise<void>;
|
||||
processAllMessages: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createIntegrationTestContext(
|
||||
options: {
|
||||
clientIdentifier?: string;
|
||||
paired?: boolean;
|
||||
authenticated?: boolean;
|
||||
serverTime?: number;
|
||||
} = {}
|
||||
): Promise<IntegrationTestContext> {
|
||||
const now = options.serverTime ?? 1_710_000_000;
|
||||
const identifier = options.clientIdentifier ?? "test-client";
|
||||
|
||||
const transports = createMockTransportPair();
|
||||
const serverStore = createMockServerStore();
|
||||
const clientStore = createMockClientStore({ identifier });
|
||||
|
||||
// Generate keypair for client if needed
|
||||
const keyPair = await generateKeyPair();
|
||||
|
||||
const serverRuntime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: [identifier],
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin-user",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store: serverStore,
|
||||
transport: transports.serverTransport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
const clientRuntime = createYonexusClientRuntime({
|
||||
config: {
|
||||
mainHost: "ws://localhost:8787",
|
||||
identifier,
|
||||
notifyBotToken: "test-token",
|
||||
adminUserId: "admin-user"
|
||||
},
|
||||
transport: transports.clientTransport,
|
||||
stateStore: clientStore,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await serverRuntime.start();
|
||||
|
||||
let currentTime = now;
|
||||
const advanceTime = (seconds: number) => {
|
||||
currentTime += seconds;
|
||||
};
|
||||
|
||||
// Message processing helpers
|
||||
const processServerToClient = async () => {
|
||||
const messages = transports.getClientReceived();
|
||||
transports.clearMessages();
|
||||
for (const msg of messages) {
|
||||
await clientRuntime.handleMessage(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const processClientToServer = async () => {
|
||||
const messages = transports.getServerReceived();
|
||||
transports.clearMessages();
|
||||
|
||||
// Get the temp connection for message handling
|
||||
const connection = {
|
||||
identifier: identifier,
|
||||
ws: { close: vi.fn() } as unknown as WebSocket,
|
||||
connectedAt: currentTime,
|
||||
isAuthenticated: options.authenticated ?? false
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
await serverRuntime.handleMessage(connection, msg);
|
||||
}
|
||||
};
|
||||
|
||||
const processAllMessages = async () => {
|
||||
await processClientToServer();
|
||||
await processServerToClient();
|
||||
};
|
||||
|
||||
return {
|
||||
serverRuntime,
|
||||
clientRuntime,
|
||||
transports,
|
||||
serverStore,
|
||||
clientStore,
|
||||
advanceTime,
|
||||
processServerToClient,
|
||||
processClientToServer,
|
||||
processAllMessages
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration Test Suite
|
||||
// ============================================================================
|
||||
|
||||
describe("Yonexus Server-Client Integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("First-Time Pairing Flow", () => {
|
||||
it("completes full pairing and authentication cycle", async () => {
|
||||
const ctx = await createIntegrationTestContext({
|
||||
clientIdentifier: "new-client"
|
||||
});
|
||||
|
||||
// Step 1: Client connects and sends hello
|
||||
await ctx.clientRuntime.start();
|
||||
ctx.clientRuntime.handleTransportStateChange("connected");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Process hello -> hello_ack + pair_request
|
||||
await ctx.processClientToServer();
|
||||
await ctx.processServerToClient();
|
||||
|
||||
// Verify client received pair_request
|
||||
expect(ctx.clientRuntime.state.phase).toBe("waiting_pair_confirm");
|
||||
expect(ctx.clientRuntime.state.pendingPairing).toBeDefined();
|
||||
|
||||
// Step 2: Client submits pairing code
|
||||
const pairingCode = ctx.serverRuntime.state.registry.clients.get("new-client")?.pairingCode;
|
||||
expect(pairingCode).toBeDefined();
|
||||
|
||||
ctx.clientRuntime.submitPairingCode(pairingCode!, "req-pair-confirm");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Process pair_confirm -> pair_success
|
||||
await ctx.processClientToServer();
|
||||
await ctx.processServerToClient();
|
||||
|
||||
// Verify client received secret
|
||||
expect(ctx.clientRuntime.state.clientState.secret).toBeDefined();
|
||||
expect(ctx.clientRuntime.state.phase).toBe("auth_required");
|
||||
|
||||
// Step 3: Client sends auth request
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await ctx.processClientToServer();
|
||||
await ctx.processServerToClient();
|
||||
|
||||
// Verify authentication success
|
||||
expect(ctx.clientRuntime.state.phase).toBe("authenticated");
|
||||
expect(ctx.serverRuntime.state.registry.sessions.get("new-client")?.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reconnection Flow", () => {
|
||||
it("reconnects with existing credentials without re-pairing", async () => {
|
||||
const now = 1_710_000_000;
|
||||
const keyPair = await generateKeyPair();
|
||||
|
||||
const ctx = await createIntegrationTestContext({
|
||||
clientIdentifier: "reconnect-client",
|
||||
paired: true,
|
||||
authenticated: false
|
||||
});
|
||||
|
||||
// Pre-populate client with existing credentials
|
||||
ctx.clientRuntime.state.clientState.secret = "existing-secret";
|
||||
ctx.clientRuntime.state.clientState.publicKey = keyPair.publicKey;
|
||||
ctx.clientRuntime.state.clientState.privateKey = keyPair.privateKey;
|
||||
ctx.clientRuntime.state.clientState.pairedAt = now - 1000;
|
||||
|
||||
// Pre-populate server with client record
|
||||
ctx.serverRuntime.state.registry.clients.set("reconnect-client", {
|
||||
identifier: "reconnect-client",
|
||||
pairingStatus: "paired",
|
||||
publicKey: keyPair.publicKey,
|
||||
secret: "existing-secret",
|
||||
status: "offline",
|
||||
recentNonces: [],
|
||||
recentHandshakeAttempts: [],
|
||||
createdAt: now - 2000,
|
||||
updatedAt: now - 1000
|
||||
});
|
||||
|
||||
// Connect and send hello
|
||||
await ctx.clientRuntime.start();
|
||||
ctx.clientRuntime.handleTransportStateChange("connected");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await ctx.processClientToServer();
|
||||
await ctx.processServerToClient();
|
||||
|
||||
// Should go directly to auth_required, skipping pairing
|
||||
expect(ctx.clientRuntime.state.phase).toBe("auth_required");
|
||||
|
||||
// Complete authentication
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await ctx.processClientToServer();
|
||||
await ctx.processServerToClient();
|
||||
|
||||
expect(ctx.clientRuntime.state.phase).toBe("authenticated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heartbeat Flow", () => {
|
||||
it("exchanges heartbeats after authentication", async () => {
|
||||
const ctx = await createIntegrationTestContext({
|
||||
clientIdentifier: "heartbeat-client",
|
||||
paired: true,
|
||||
authenticated: true
|
||||
});
|
||||
|
||||
// Manually set authenticated state
|
||||
ctx.clientRuntime.state.phase = "authenticated";
|
||||
|
||||
// Trigger heartbeat
|
||||
await ctx.clientRuntime.handleMessage("heartbeat_tick");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Process heartbeat -> heartbeat_ack
|
||||
await ctx.processClientToServer();
|
||||
|
||||
// Verify server updated heartbeat timestamp
|
||||
const record = ctx.serverRuntime.state.registry.clients.get("heartbeat-client");
|
||||
expect(record?.lastHeartbeatAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user