Compare commits
2 Commits
dev/2026-0
...
3b26f3d083
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b26f3d083 | |||
| a0e926594f |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,2 +1,6 @@
|
|||||||
.idea/
|
node_modules/
|
||||||
tests/docker/.env
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/*.json
|
||||||
|
!data/.gitkeep
|
||||||
|
|||||||
9
.gitmodules
vendored
9
.gitmodules
vendored
@@ -1,9 +0,0 @@
|
|||||||
[submodule "Yonexus.Server"]
|
|
||||||
path = Yonexus.Server
|
|
||||||
url = https://git.hangman-lab.top/nav/Yonexus.Server.git
|
|
||||||
[submodule "Yonexus.Client"]
|
|
||||||
path = Yonexus.Client
|
|
||||||
url = https://git.hangman-lab.top/nav/Yonexus.Client.git
|
|
||||||
[submodule "Yonexus.Protocol"]
|
|
||||||
path = Yonexus.Protocol
|
|
||||||
url = https://git.hangman-lab.top/nav/Yonexus.Protocol.git
|
|
||||||
256
ACCEPTANCE.md
256
ACCEPTANCE.md
@@ -1,256 +0,0 @@
|
|||||||
# 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 与至少一组安全失败路径必须全绿
|
|
||||||
|
|
||||||
推荐的 umbrella 仓库最小回归入口:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/validate-v1.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
该脚本会顺序执行:
|
|
||||||
- `Yonexus.Protocol`: `npm run check && npm run test`
|
|
||||||
- `Yonexus.Server`: `npm run check && npm run test`
|
|
||||||
- `Yonexus.Client`: `npm run check && npm run test`
|
|
||||||
|
|
||||||
如果某个子仓库尚未安装依赖,脚本会优先自动执行:
|
|
||||||
- 有 `package-lock.json` 时使用 `npm ci`
|
|
||||||
- 否则回退到 `npm install`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 当前对应关系
|
|
||||||
|
|
||||||
与 `TASKLIST.md` 对应关系:
|
|
||||||
|
|
||||||
- YNX-1101:协议单元测试
|
|
||||||
- YNX-1102:Server 单元测试
|
|
||||||
- YNX-1103:Client 单元测试
|
|
||||||
- YNX-1104:Server-Client 集成测试
|
|
||||||
- YNX-1105:失败路径测试矩阵
|
|
||||||
- YNX-1205:协议测试与验收清单(本文件)
|
|
||||||
351
ARCHITECTURE.md
351
ARCHITECTURE.md
@@ -1,351 +0,0 @@
|
|||||||
# Yonexus — Architecture Overview
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
Yonexus is a cross-instance communication system for OpenClaw.
|
|
||||||
|
|
||||||
The repository `Yonexus` is the **umbrella/specification repository** for the system. It contains:
|
|
||||||
- high-level planning
|
|
||||||
- architecture documents
|
|
||||||
- references to implementation repositories as git submodules
|
|
||||||
|
|
||||||
Yonexus is implemented as three repositories:
|
|
||||||
- `Yonexus.Server` — central hub plugin
|
|
||||||
- `Yonexus.Client` — client plugin
|
|
||||||
- `Yonexus.Protocol` — shared protocol specification, referenced as a submodule by both
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Repository Roles
|
|
||||||
|
|
||||||
## 2.1 `Yonexus` (umbrella repo)
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- system-level planning
|
|
||||||
- architecture documents
|
|
||||||
- cross-cutting decisions that apply to both server and client
|
|
||||||
- coordination of sub-repositories via git submodules
|
|
||||||
|
|
||||||
This repository should contain:
|
|
||||||
- top-level planning docs
|
|
||||||
- architecture overview
|
|
||||||
- feature checklists
|
|
||||||
- cross-cutting design rationale
|
|
||||||
|
|
||||||
It references:
|
|
||||||
- `Yonexus.Server` (submodule)
|
|
||||||
- `Yonexus.Client` (submodule)
|
|
||||||
- `Yonexus.Protocol` (submodule)
|
|
||||||
|
|
||||||
## 2.2 `Yonexus.Protocol`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- protocol specification (PROTOCOL.md)
|
|
||||||
- canonical JSON shape references
|
|
||||||
- shared type definitions (planned)
|
|
||||||
|
|
||||||
Referenced as a submodule by:
|
|
||||||
- `Yonexus.Server/protocol`
|
|
||||||
- `Yonexus.Client/protocol`
|
|
||||||
|
|
||||||
This is the **single source of truth** for the Yonexus protocol. Both server and client implementations must conform to the protocol defined here.
|
|
||||||
|
|
||||||
## 2.3 `Yonexus.Server`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- implementation of the central hub/server plugin
|
|
||||||
- server-side connection management
|
|
||||||
- server-side pairing/authentication/state tracking
|
|
||||||
- server-side dispatch and routing behavior
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
- `PLAN.md`
|
|
||||||
- implementation code
|
|
||||||
|
|
||||||
## 2.4 `Yonexus.Client`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- implementation of the client plugin
|
|
||||||
- outbound connection to `Yonexus.Server`
|
|
||||||
- local identity/keypair/secret management
|
|
||||||
- client-side pairing confirmation and authenticated reconnect
|
|
||||||
- client-side heartbeat and message sending
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
- `PLAN.md`
|
|
||||||
- implementation code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Repository Graph
|
|
||||||
|
|
||||||
```
|
|
||||||
Yonexus (umbrella)
|
|
||||||
├── Yonexus.Protocol (submodule)
|
|
||||||
├── Yonexus.Server (submodule)
|
|
||||||
│ └── protocol/ (nested submodule -> Yonexus.Protocol)
|
|
||||||
└── Yonexus.Client (submodule)
|
|
||||||
└── protocol/ (nested submodule -> Yonexus.Protocol)
|
|
||||||
```
|
|
||||||
|
|
||||||
Policy:
|
|
||||||
- protocol changes are always committed to `Yonexus.Protocol` first
|
|
||||||
- `Yonexus.Server` and `Yonexus.Client` update their `protocol/` submodule ref after protocol version is stable
|
|
||||||
- umbrella `Yonexus` updates its submodule refs after server/client have stable versions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. System Topology
|
|
||||||
|
|
||||||
A Yonexus deployment contains:
|
|
||||||
- exactly one `Yonexus.Server` instance
|
|
||||||
- one or more `Yonexus.Client` instances
|
|
||||||
|
|
||||||
Topology assumptions:
|
|
||||||
- `Yonexus.Server` runs on an OpenClaw instance reachable at a stable address
|
|
||||||
- each `Yonexus.Client` connects outbound to the server
|
|
||||||
- clients do not directly connect to each other in v1
|
|
||||||
- cross-client coordination is relayed through the server
|
|
||||||
|
|
||||||
Visual model:
|
|
||||||
|
|
||||||
```
|
|
||||||
Yonexus.Client A --->
|
|
||||||
\
|
|
||||||
Yonexus.Client B ----> Yonexus.Server
|
|
||||||
/
|
|
||||||
Yonexus.Client C --->
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Shared vs Split Responsibilities
|
|
||||||
|
|
||||||
## 5.1 Yonexus.Protocol — Shared
|
|
||||||
|
|
||||||
These belong to the protocol repo and apply to both plugins:
|
|
||||||
- protocol format and message categories
|
|
||||||
- builtin message types and their semantics
|
|
||||||
- pairing security model
|
|
||||||
- nonce/timestamp validation rules
|
|
||||||
- heartbeat timing rules
|
|
||||||
- message rewrite rules
|
|
||||||
- reserved rule namespace (`builtin`)
|
|
||||||
- canonical JSON shapes
|
|
||||||
- naming and terminology
|
|
||||||
|
|
||||||
## 5.2 Server-Only Concerns (Yonexus.Server)
|
|
||||||
|
|
||||||
These belong in `Yonexus.Server`:
|
|
||||||
- WebSocket server startup
|
|
||||||
- listen host/port config
|
|
||||||
- client registry persistence
|
|
||||||
- public key / secret storage
|
|
||||||
- pairing code generation
|
|
||||||
- Discord DM notification to admin
|
|
||||||
- auth proof verification
|
|
||||||
- liveness status tracking
|
|
||||||
- client message rewriting and dispatch on server side
|
|
||||||
- sending messages to connected clients
|
|
||||||
|
|
||||||
## 5.3 Client-Only Concerns (Yonexus.Client)
|
|
||||||
|
|
||||||
These belong in `Yonexus.Client`:
|
|
||||||
- WebSocket client connection management
|
|
||||||
- reconnect/backoff logic
|
|
||||||
- local keypair generation
|
|
||||||
- local secret persistence
|
|
||||||
- pairing code submission
|
|
||||||
- auth proof construction/signing
|
|
||||||
- heartbeat sending
|
|
||||||
- sending messages to server
|
|
||||||
- receiving server messages and local dispatch
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Communication Model
|
|
||||||
|
|
||||||
## 6.1 Transport
|
|
||||||
|
|
||||||
Transport is WebSocket.
|
|
||||||
|
|
||||||
- `Yonexus.Server` acts as server
|
|
||||||
- `Yonexus.Client` acts as client
|
|
||||||
|
|
||||||
## 6.2 Message Categories
|
|
||||||
|
|
||||||
Two message categories exist on the same transport:
|
|
||||||
|
|
||||||
### Builtin protocol messages
|
|
||||||
Used for:
|
|
||||||
- hello/session setup
|
|
||||||
- pairing
|
|
||||||
- authentication
|
|
||||||
- heartbeat
|
|
||||||
- lifecycle/status
|
|
||||||
- protocol errors
|
|
||||||
|
|
||||||
Format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{json}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Application rule messages
|
|
||||||
Used for higher-level cross-instance communication.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
Server rewrite rule:
|
|
||||||
|
|
||||||
When server receives a message from a client, before dispatch it rewrites:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${sender_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Security Architecture
|
|
||||||
|
|
||||||
## 7.1 Pairing Model
|
|
||||||
|
|
||||||
Pairing is intentionally out-of-band.
|
|
||||||
|
|
||||||
When a new client needs pairing:
|
|
||||||
- server generates a pairing code
|
|
||||||
- server sends that code to a human administrator via Discord DM
|
|
||||||
- server does **not** send the code over the Yonexus WebSocket channel
|
|
||||||
- human relays the code to the client side manually
|
|
||||||
- client submits the code back to the server
|
|
||||||
|
|
||||||
This preserves a basic human-mediated trust step.
|
|
||||||
|
|
||||||
## 7.2 Post-Pairing Authentication
|
|
||||||
|
|
||||||
After pairing:
|
|
||||||
- server issues a shared secret
|
|
||||||
- client stores secret locally
|
|
||||||
- client already has a private key
|
|
||||||
- reconnect auth uses signed proof derived from:
|
|
||||||
- secret
|
|
||||||
- nonce
|
|
||||||
- timestamp
|
|
||||||
|
|
||||||
## 7.3 Replay Protection
|
|
||||||
|
|
||||||
Server enforces:
|
|
||||||
- timestamp freshness (`< 10s` drift)
|
|
||||||
- nonce collision detection
|
|
||||||
- handshake rate threshold (`>10 attempts in 10s` is unsafe)
|
|
||||||
- re-pair requirement after unsafe conditions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. State Ownership
|
|
||||||
|
|
||||||
## 8.1 Server-Owned State
|
|
||||||
|
|
||||||
Canonical server-owned state includes:
|
|
||||||
- allowed client identifiers
|
|
||||||
- trust state for each client
|
|
||||||
- client public key
|
|
||||||
- client secret
|
|
||||||
- pairing state
|
|
||||||
- pairing notification state
|
|
||||||
- recent nonce window
|
|
||||||
- recent handshake attempt window
|
|
||||||
- client liveness state
|
|
||||||
|
|
||||||
## 8.2 Client-Owned State
|
|
||||||
|
|
||||||
Canonical client-owned state includes:
|
|
||||||
- client identifier
|
|
||||||
- client private key
|
|
||||||
- client public key
|
|
||||||
- current shared secret
|
|
||||||
- last successful local trust metadata if needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Plugin API Boundaries
|
|
||||||
|
|
||||||
## 9.1 Yonexus.Server API
|
|
||||||
|
|
||||||
Planned public API:
|
|
||||||
- `sendMessageToClient(identifier, message)`
|
|
||||||
- `registerRule(rule, processor)`
|
|
||||||
|
|
||||||
## 9.2 Yonexus.Client API
|
|
||||||
|
|
||||||
Planned public API:
|
|
||||||
- `sendMessageToServer(message)`
|
|
||||||
- `registerRule(rule, processor)`
|
|
||||||
|
|
||||||
The protocol defines semantics; implementation details belong in each submodule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Documentation Ownership
|
|
||||||
|
|
||||||
## 10.1 Umbrella Repo Docs
|
|
||||||
|
|
||||||
Should contain:
|
|
||||||
- system architecture
|
|
||||||
- cross-cutting feature list
|
|
||||||
- global design rationale
|
|
||||||
- cross-repo coordination notes
|
|
||||||
|
|
||||||
## 10.2 Protocol Repo Docs
|
|
||||||
|
|
||||||
Must contain:
|
|
||||||
- protocol specification (PROTOCOL.md)
|
|
||||||
- canonical message shapes
|
|
||||||
- protocol versioning notes
|
|
||||||
|
|
||||||
## 10.3 Server Repo Docs
|
|
||||||
|
|
||||||
Should contain:
|
|
||||||
- server setup
|
|
||||||
- server config reference
|
|
||||||
- server persistence model
|
|
||||||
- server operational behavior
|
|
||||||
- implementation tasks
|
|
||||||
|
|
||||||
## 10.4 Client Repo Docs
|
|
||||||
|
|
||||||
Should contain:
|
|
||||||
- client setup
|
|
||||||
- client config reference
|
|
||||||
- client local storage model
|
|
||||||
- client reconnect/heartbeat behavior
|
|
||||||
- implementation tasks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Development Flow
|
|
||||||
|
|
||||||
Recommended flow:
|
|
||||||
1. define cross-cutting behavior in `Yonexus` umbrella
|
|
||||||
2. finalize protocol in `Yonexus.Protocol`
|
|
||||||
3. update submodule refs in `Yonexus.Server` and `Yonexus.Client`
|
|
||||||
4. implement server-side protocol handling in `Yonexus.Server`
|
|
||||||
5. implement client-side protocol handling in `Yonexus.Client`
|
|
||||||
6. keep protocol changes synchronized back into umbrella docs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Non-Goals of the Umbrella Repo
|
|
||||||
|
|
||||||
The umbrella repo should avoid becoming:
|
|
||||||
- the place where all implementation code lives
|
|
||||||
- a dumping ground for server-only or client-only details
|
|
||||||
- a duplicate of submodule READMEs without system-level value
|
|
||||||
|
|
||||||
Its job is coordination, not code concentration.
|
|
||||||
123
DEPLOYMENT.md
123
DEPLOYMENT.md
@@ -1,123 +0,0 @@
|
|||||||
# 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 分钟内可看到心跳日志/状态更新
|
|
||||||
295
FEAT.md
295
FEAT.md
@@ -1,295 +0,0 @@
|
|||||||
# Yonexus — Feature Checklist
|
|
||||||
|
|
||||||
## Project Direction
|
|
||||||
|
|
||||||
Yonexus is a **two-plugin** cross-instance communication system for OpenClaw:
|
|
||||||
- `Yonexus.Server`
|
|
||||||
- `Yonexus.Client`
|
|
||||||
|
|
||||||
This repository now targets the split-plugin architecture only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shared Terminology and v1 Scope
|
|
||||||
|
|
||||||
Canonical terms used across the project:
|
|
||||||
- `identifier`: unique logical client name
|
|
||||||
- `rule_identifier`: exact-match application route key
|
|
||||||
- `builtin`: reserved protocol/system route namespace
|
|
||||||
- `pairingCode`: short-lived out-of-band pairing code
|
|
||||||
- `secret`: shared secret issued after pairing
|
|
||||||
- `publicKey` / `privateKey`: client signing keypair
|
|
||||||
|
|
||||||
Locked v1 decisions:
|
|
||||||
- `heartbeat_ack` is optional
|
|
||||||
- client reconnect uses exponential backoff
|
|
||||||
- rule matching is exact-match only
|
|
||||||
- offline sends fail immediately instead of queueing
|
|
||||||
- `mainHost` is expected to be a full `ws://` or `wss://` URL
|
|
||||||
|
|
||||||
Explicit v1 non-goals:
|
|
||||||
- multi-server topology
|
|
||||||
- direct client-to-client sockets
|
|
||||||
- offline queueing guarantees
|
|
||||||
- advanced rule matching
|
|
||||||
- management UI
|
|
||||||
- admin approval control plane beyond human relay of pairing codes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Yonexus.Server Features
|
|
||||||
|
|
||||||
### 1.1 Server Runtime
|
|
||||||
- WebSocket server startup on OpenClaw gateway boot
|
|
||||||
- Configurable bind host / bind port
|
|
||||||
- Optional public WebSocket URL metadata
|
|
||||||
- Connection accept / close lifecycle handling
|
|
||||||
- One active authenticated connection per client identifier
|
|
||||||
|
|
||||||
### 1.2 Client Registry
|
|
||||||
- In-memory active client session registry
|
|
||||||
- Persistent client trust registry keyed by `identifier`
|
|
||||||
- Store client public key
|
|
||||||
- Store shared secret
|
|
||||||
- Store pairing state and expiry
|
|
||||||
- Store pairing notification metadata
|
|
||||||
- Store heartbeat timestamps
|
|
||||||
- Store recent security windows (nonce / handshake attempts)
|
|
||||||
- Store liveness state (`online | unstable | offline`)
|
|
||||||
|
|
||||||
### 1.3 Allowlist and Validation
|
|
||||||
- `followerIdentifiers` allowlist enforcement
|
|
||||||
- Reject unknown client identifiers
|
|
||||||
- Reject malformed builtin payloads
|
|
||||||
- Reject unsupported protocol versions
|
|
||||||
|
|
||||||
### 1.4 Pairing Flow
|
|
||||||
- Generate pairing code
|
|
||||||
- Generate pairing expiry / TTL
|
|
||||||
- Start pending pairing session
|
|
||||||
- Never send pairing code over Yonexus WebSocket
|
|
||||||
- Send pairing code to human admin via Discord DM using `notifyBotToken`
|
|
||||||
- Include target client `identifier` in pairing DM
|
|
||||||
- Accept client-submitted pairing code via builtin protocol
|
|
||||||
- Fail pairing on invalid code / expired code / notification failure
|
|
||||||
- Issue shared secret after successful pairing
|
|
||||||
- Persist paired trust material
|
|
||||||
|
|
||||||
### 1.5 Authentication
|
|
||||||
- Verify signed proof from client
|
|
||||||
- Validate stored secret
|
|
||||||
- Validate nonce format and uniqueness
|
|
||||||
- Validate timestamp drift `< 10s`
|
|
||||||
- Track recent handshake attempts
|
|
||||||
- Enforce `>10 attempts / 10s` unsafe threshold
|
|
||||||
- Trigger re-pair on unsafe condition
|
|
||||||
- Rotate or invalidate trust state when required
|
|
||||||
|
|
||||||
### 1.6 Heartbeat and Status
|
|
||||||
- Receive heartbeat from authenticated clients
|
|
||||||
- Update `lastHeartbeatAt`
|
|
||||||
- Mark client `unstable` after 7 minutes without heartbeat
|
|
||||||
- Mark client `offline` after 11 minutes without heartbeat
|
|
||||||
- Close socket when client becomes offline
|
|
||||||
- Optional heartbeat acknowledgement
|
|
||||||
- Periodic server-side status sweep timer
|
|
||||||
|
|
||||||
### 1.7 Messaging and Dispatch
|
|
||||||
- `sendMessageToClient(identifier, message)` API
|
|
||||||
- Rewrite inbound client messages to `${rule_identifier}::${sender_identifier}::${message_content}`
|
|
||||||
- Builtin message routing
|
|
||||||
- Rule registry for application messages
|
|
||||||
- First-match rule dispatch
|
|
||||||
- Reject reserved rule `builtin`
|
|
||||||
- Reject duplicate rule registration by default
|
|
||||||
|
|
||||||
### 1.8 Operations and Safety
|
|
||||||
- Structured errors for pairing/auth/transport failures
|
|
||||||
- Redacted logging for sensitive values
|
|
||||||
- Restart-safe persistent storage for trust state
|
|
||||||
- Clear or safely rebuild rolling security windows on restart
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Yonexus.Client Features
|
|
||||||
|
|
||||||
### 2.1 Client Runtime
|
|
||||||
- WebSocket client startup on OpenClaw gateway boot
|
|
||||||
- Connect to configured `mainHost`
|
|
||||||
- Disconnect / reconnect lifecycle handling
|
|
||||||
- Retry/backoff reconnect strategy
|
|
||||||
|
|
||||||
### 2.2 Local Identity and Trust Material
|
|
||||||
- Persist local `identifier`
|
|
||||||
- Generate public/private keypair on first run
|
|
||||||
- Persist private key locally
|
|
||||||
- Persist server-issued secret locally
|
|
||||||
- Load existing trust material on restart
|
|
||||||
|
|
||||||
### 2.3 Pairing Flow
|
|
||||||
- Send `hello` after connect
|
|
||||||
- Enter pairing mode when server requires pairing
|
|
||||||
- Receive pairing metadata without receiving code itself
|
|
||||||
- Accept human-provided pairing code on client side
|
|
||||||
- Send pairing confirmation to server
|
|
||||||
- Store secret after `pair_success`
|
|
||||||
|
|
||||||
### 2.4 Authentication
|
|
||||||
- Build proof from `secret + nonce + timestamp`
|
|
||||||
- Prefer canonical serialized payload for signing
|
|
||||||
- Sign proof with local private key
|
|
||||||
- Send builtin `auth_request`
|
|
||||||
- Handle `auth_success`
|
|
||||||
- Handle `auth_failed`
|
|
||||||
- Handle `re_pair_required`
|
|
||||||
|
|
||||||
### 2.5 Heartbeat
|
|
||||||
- Start heartbeat loop after authentication
|
|
||||||
- Send heartbeat every 5 minutes
|
|
||||||
- Stop heartbeat when disconnected / unauthenticated
|
|
||||||
- Handle optional heartbeat acknowledgement
|
|
||||||
|
|
||||||
### 2.6 Messaging and Dispatch
|
|
||||||
- `sendMessageToServer(message)` API
|
|
||||||
- Builtin message routing
|
|
||||||
- Rule registry for application messages
|
|
||||||
- First-match rule dispatch
|
|
||||||
- Reject reserved rule `builtin`
|
|
||||||
- Reject duplicate rule registration by default
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Shared Protocol Features
|
|
||||||
|
|
||||||
### 3.1 Builtin Wire Format
|
|
||||||
- `builtin::{json}` message format
|
|
||||||
- Standard builtin envelope with `type`, `requestId`, `timestamp`, `payload`
|
|
||||||
- UTC unix seconds as protocol timestamp unit
|
|
||||||
|
|
||||||
### 3.2 Builtin Types
|
|
||||||
- `hello`
|
|
||||||
- `hello_ack`
|
|
||||||
- `pair_request`
|
|
||||||
- `pair_confirm`
|
|
||||||
- `pair_success`
|
|
||||||
- `pair_failed`
|
|
||||||
- `auth_request`
|
|
||||||
- `auth_success`
|
|
||||||
- `auth_failed`
|
|
||||||
- `re_pair_required`
|
|
||||||
- `heartbeat`
|
|
||||||
- `heartbeat_ack`
|
|
||||||
- `status_update`
|
|
||||||
- `disconnect_notice`
|
|
||||||
- `error`
|
|
||||||
|
|
||||||
### 3.3 Security Constraints
|
|
||||||
- Pairing code must be delivered out-of-band only
|
|
||||||
- Pairing code must not travel over Yonexus WebSocket
|
|
||||||
- Nonce length fixed at 24 random characters
|
|
||||||
- Nonce replay detection window
|
|
||||||
- Timestamp freshness validation
|
|
||||||
- Rate-limit / unsafe reconnect detection
|
|
||||||
|
|
||||||
### 3.4 Rule Message Format
|
|
||||||
- Application messages use `${rule_identifier}::${message_content}`
|
|
||||||
- Server rewrites inbound client messages before dispatch
|
|
||||||
- Rule matching is exact-match in v1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Configuration Features
|
|
||||||
|
|
||||||
### 4.1 Yonexus.Server Config
|
|
||||||
- `followerIdentifiers: string[]`
|
|
||||||
- `notifyBotToken: string`
|
|
||||||
- `adminUserId: string`
|
|
||||||
- `listenHost?: string`
|
|
||||||
- `listenPort: number`
|
|
||||||
- `publicWsUrl?: string`
|
|
||||||
|
|
||||||
### 4.2 Yonexus.Client Config
|
|
||||||
- `mainHost: string`
|
|
||||||
- `identifier: string`
|
|
||||||
- `notifyBotToken: string`
|
|
||||||
- `adminUserId: string`
|
|
||||||
|
|
||||||
### 4.3 Validation
|
|
||||||
- Fail startup on missing required fields
|
|
||||||
- Fail startup on invalid config shape
|
|
||||||
- Validate required split-plugin semantics per side
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Docs and Deliverables
|
|
||||||
|
|
||||||
### Required Planning / Spec Docs
|
|
||||||
- `PLAN.md`
|
|
||||||
- `PROTOCOL.md`
|
|
||||||
- `FEAT.md`
|
|
||||||
|
|
||||||
### Next Implementation Deliverables
|
|
||||||
- server plugin manifest
|
|
||||||
- client plugin manifest
|
|
||||||
- README for dual-plugin architecture
|
|
||||||
- implementation task breakdown
|
|
||||||
- protocol test cases
|
|
||||||
- pairing/auth failure-path test matrix
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Suggested Delivery Order
|
|
||||||
|
|
||||||
### Phase 0 — Planning
|
|
||||||
- [x] Rewrite project direction
|
|
||||||
- [x] Define split-plugin model
|
|
||||||
- [x] Write protocol draft
|
|
||||||
- [x] Write feature checklist
|
|
||||||
|
|
||||||
### Phase 1 — Skeleton
|
|
||||||
- [ ] Create `Yonexus.Server` plugin scaffold
|
|
||||||
- [ ] Create `Yonexus.Client` plugin scaffold
|
|
||||||
- [ ] Add config schema / manifests
|
|
||||||
- [ ] Add minimal startup hooks
|
|
||||||
|
|
||||||
### Phase 2 — Transport
|
|
||||||
- [ ] Implement WebSocket server
|
|
||||||
- [ ] Implement WebSocket client
|
|
||||||
- [ ] Implement hello / hello_ack flow
|
|
||||||
- [ ] Implement reconnect baseline
|
|
||||||
|
|
||||||
### Phase 3 — Pairing and Auth
|
|
||||||
- [ ] Implement keypair generation
|
|
||||||
- [ ] Implement pairing creation
|
|
||||||
- [ ] Implement Discord DM notification
|
|
||||||
- [ ] Implement pairing confirmation
|
|
||||||
- [ ] Implement secret issuance
|
|
||||||
- [ ] Implement signed auth proof validation
|
|
||||||
- [ ] Implement nonce and rate-limit protection
|
|
||||||
|
|
||||||
### Phase 4 — Heartbeat and Messaging
|
|
||||||
- [ ] Implement heartbeat loop
|
|
||||||
- [ ] Implement server status sweep
|
|
||||||
- [ ] Implement `sendMessageToServer`
|
|
||||||
- [ ] Implement `sendMessageToClient`
|
|
||||||
- [ ] Implement rule registry and dispatch
|
|
||||||
|
|
||||||
### Phase 5 — Hardening
|
|
||||||
- [ ] Add persistence
|
|
||||||
- [ ] Add restart recovery behavior
|
|
||||||
- [ ] Add structured errors
|
|
||||||
- [ ] Add logging/redaction
|
|
||||||
- [ ] Add integration tests
|
|
||||||
- [ ] Add operator docs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Non-Goals
|
|
||||||
|
|
||||||
Not in initial scope unless explicitly added later:
|
|
||||||
- direct client-to-client sockets
|
|
||||||
- multi-server topology
|
|
||||||
- distributed consensus
|
|
||||||
- queueing guarantees for offline clients
|
|
||||||
- management UI
|
|
||||||
- advanced pattern matching for rules
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
# OpenClaw 插件开发经验教训
|
|
||||||
|
|
||||||
> 记录插件开发过程中踩过的坑,供后续迭代参考。最初源自 Dirigent,后续经验来自 Yonexus。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. OpenClaw 热重载与模块状态
|
|
||||||
|
|
||||||
**问题**:OpenClaw 每次热重载(hot-reload)会把插件模块放入新的 VM 隔离上下文,模块级变量全部重置。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 错误:热重载后 Map 被清空,turn 状态丢失
|
|
||||||
const channelStates = new Map<string, ChannelTurnState>();
|
|
||||||
```
|
|
||||||
|
|
||||||
**解法**:把需要跨热重载持久化的状态挂在 `globalThis` 上。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ 正确:globalThis 绑定在 Node.js 进程层面,热重载不影响
|
|
||||||
function channelStates(): Map<string, ChannelTurnState> {
|
|
||||||
if (!(_G._tmChannelStates instanceof Map))
|
|
||||||
_G._tmChannelStates = new Map();
|
|
||||||
return _G._tmChannelStates as Map<string, ChannelTurnState>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**规则**:
|
|
||||||
- 业务状态(turn state、speaker list、pending turns)→ `globalThis`
|
|
||||||
- 热重载内部的临时变量(局部锁、dedup set)→ `globalThis`(理由同上)
|
|
||||||
- 无状态工具函数 → 普通模块变量即可
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Hook 事件重复触发(Event Deduplication)
|
|
||||||
|
|
||||||
**问题**:OpenClaw 热重载会把新的 handler 叠加在旧的 handler 上,同一事件(如 `agent_end`、`before_model_resolve`)被多个 handler 实例处理,导致:
|
|
||||||
- Turn 被推进两次
|
|
||||||
- Speaker 被重复 suppress
|
|
||||||
- Schedule trigger 重复发送
|
|
||||||
|
|
||||||
**解法**:用挂在 `globalThis` 上的 `WeakSet`(事件对象)或 `Set`(runId)做去重。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// before_model_resolve:事件对象去重(WeakSet 自动 GC)
|
|
||||||
const processed = new WeakSet<object>();
|
|
||||||
api.on("before_model_resolve", async (event) => {
|
|
||||||
if (processed.has(event as object)) return;
|
|
||||||
processed.add(event as object);
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
|
|
||||||
// agent_end:runId 去重(Set + 上限淘汰)
|
|
||||||
const processedRunIds = new Set<string>();
|
|
||||||
api.on("agent_end", async (event) => {
|
|
||||||
const runId = (event as any).runId;
|
|
||||||
if (processedRunIds.has(runId)) return;
|
|
||||||
processedRunIds.add(runId);
|
|
||||||
if (processedRunIds.size > 500) {
|
|
||||||
processedRunIds.delete(processedRunIds.values().next().value);
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**规则**:所有 hook handler 必须有去重逻辑,dedup 结构本身也要挂在 `globalThis`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Gateway 生命周期事件与 Agent 会话事件的区别
|
|
||||||
|
|
||||||
**问题**:`gateway_start` / `gateway_stop` 是全局事件,只触发一次。但 `register()` 每次热重载都会被调用,导致 `gateway_start` handler 被重复注册,sidecar 被重复启动。
|
|
||||||
|
|
||||||
**解法**:用 `globalThis` flag 保证只注册一次。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const _G = globalThis as Record<string, unknown>;
|
|
||||||
const LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered";
|
|
||||||
|
|
||||||
if (!_G[LIFECYCLE_KEY]) {
|
|
||||||
_G[LIFECYCLE_KEY] = true;
|
|
||||||
startSideCar(...);
|
|
||||||
api.on("gateway_stop", () => stopSideCar(...));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**规则**:
|
|
||||||
- `gateway_start` / `gateway_stop` handler → `globalThis` flag 保护
|
|
||||||
- `before_model_resolve` / `agent_end` / `message_received` → 每次 `register()` 都注册,但靠 event dedup 防止重复处理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ChannelStore 文件缓存陷阱
|
|
||||||
|
|
||||||
**问题**:`ChannelStore` 懒加载文件(第一次读后设 `loaded=true` 不再重读)。如果在 gateway 运行期间直接编辑 `dirigent-channels.json`,已存在的 `ChannelStore` 实例不会感知变化,`getMode()` 对新增 channel 返回 `"none"`,导致 turn management 完全失效(before_model_resolve 看到 `mode === "none"` 直接 return,不做任何 suppress)。
|
|
||||||
|
|
||||||
**现象**:新 channel 里所有 agent 同时响应,日志里没有任何 `before_model_resolve` 的 suppressing 或 anchor set 日志。
|
|
||||||
|
|
||||||
**解法(当前)**:编辑 `dirigent-channels.json` 后必须 `openclaw gateway restart`。
|
|
||||||
|
|
||||||
**更好的长期方案**:`ChannelStore` 应该在 `setMode()`/`setLockedMode()` 时通知所有实例,或改用 `fs.watch()` 监听文件变化,或每次 `getMode()` 都从文件读(对 read 频率低的场景可以接受)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Discord 权限 Overwrite 的 type 字段
|
|
||||||
|
|
||||||
**问题**:设置 channel permission overwrite 时,`type` 字段含义:
|
|
||||||
- `type: 0` → 针对 **role**(角色)
|
|
||||||
- `type: 1` → 针对 **member**(成员/用户)
|
|
||||||
|
|
||||||
将 bot 用户 ID 作为 member overwrite 时必须用 `type: 1`,用 `type: 0` 会返回错误或静默失败(Discord 会把 ID 当 role 处理)。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ 正确
|
|
||||||
{ id: botUserId, type: 1, allow: "68608", deny: "0" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**常用 permission bitmask**:
|
|
||||||
- VIEW_CHANNEL = 1024 (1 << 10)
|
|
||||||
- SEND_MESSAGES = 2048 (1 << 11)
|
|
||||||
- READ_MESSAGE_HISTORY = 65536 (1 << 16)
|
|
||||||
- 三者合计 = 68608
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. AgentTool 的 execute API(非 handler)
|
|
||||||
|
|
||||||
**问题**:OpenClaw Plugin SDK 要求 tool 使用 `execute: async (toolCallId, params) => {}` 接口,不是 `handler:`。如果需要 `ctx.agentId`,要使用工厂函数形式。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ 正确
|
|
||||||
api.registerTool({
|
|
||||||
name: "my-tool",
|
|
||||||
// ...schema...
|
|
||||||
execute: async (toolCallId, params) => {
|
|
||||||
// toolCallId 是 string,params 是入参对象
|
|
||||||
return { result: "ok" };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ 需要 agentId 时
|
|
||||||
api.registerTool((ctx) => ({
|
|
||||||
name: "my-tool",
|
|
||||||
execute: async (toolCallId, params) => {
|
|
||||||
const agentId = ctx.agentId;
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Sidecar 锁文件防重复启动
|
|
||||||
|
|
||||||
**问题**:gateway 重启或热重载时 `startSideCar()` 可能被多次调用,导致多个 sidecar 进程竞争同一端口。
|
|
||||||
|
|
||||||
**解法**:写 lock 文件(`/tmp/dirigent-sidecar.lock`),启动前检查文件是否存在且对应进程仍在运行。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const lockFile = "/tmp/dirigent-sidecar.lock";
|
|
||||||
if (fs.existsSync(lockFile)) {
|
|
||||||
const pid = Number(fs.readFileSync(lockFile, "utf8").trim());
|
|
||||||
if (isProcessAlive(pid)) {
|
|
||||||
logger.info("sidecar already running, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 启动 sidecar,写 lock file
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 并发 advanceSpeaker 竞争
|
|
||||||
|
|
||||||
**问题**:两个 VM 上下文的 `agent_end` handler 可能同时执行,两者都通过了 runId 去重(runId 不同),都调用 `advanceSpeaker`,导致 speaker index 被推进两次。
|
|
||||||
|
|
||||||
**解法**:在 `advanceSpeaker` 入口加 per-channel 锁(`Set<string>` 挂在 `globalThis`)。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (advancingChannels.has(channelId)) return; // 已有并发调用,跳过
|
|
||||||
advancingChannels.add(channelId);
|
|
||||||
try {
|
|
||||||
await advanceSpeaker(...);
|
|
||||||
} finally {
|
|
||||||
advancingChannels.delete(channelId);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. isTurnPending 的生命周期边界
|
|
||||||
|
|
||||||
**问题**:`clearTurnPending` 的位置影响正确性:
|
|
||||||
- 太早(在 `advanceSpeaker` 前清除)→ 下一个 wakeup 可能被误判为合法 turn,在 cycle boundary 期间 index 尚未更新导致 speaker 错误
|
|
||||||
- 太晚无问题,但在 `pollForTailMatch` 期间必须保持 `isTurnPending=true`,否则 re-trigger 会被当作合法 turn 重入
|
|
||||||
|
|
||||||
**正确位置**:`advanceSpeaker` 完成后、`triggerNextSpeaker` 前。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Discord Gateway 重连后的消息丢失
|
|
||||||
|
|
||||||
**问题**:Gateway 重启后,bot 重新连接 Discord WS 有延迟(10–30s)。如果在 bot 完全连接前就发送 schedule trigger(`<@bot_id>➡️`),bot 会错过该消息(WS 不推送历史消息)。
|
|
||||||
|
|
||||||
**现象**:发送了 trigger,channel 里能看到消息,但 bot 没有响应。
|
|
||||||
|
|
||||||
**解法**:
|
|
||||||
1. Gateway 重启后等待所有 bot 的 `discord client initialized` 日志出现再发种子消息
|
|
||||||
2. 或手动补发 trigger
|
|
||||||
|
|
||||||
**长期方案**:sidecar 可以暴露一个 `/status` 接口,等待所有 Discord 账号连接就绪后再允许外部发消息。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 连接型插件的热重载陷阱(Yonexus)
|
|
||||||
|
|
||||||
**问题**:Yonexus.Client / Yonexus.Server 是"连接型插件"——插件本身管理一条持久 WebSocket 连接(或监听端口)。如果用模块级变量做启动防重复保护:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 错误:热重载后新 VM 上下文重置,_started = false → 第二个 runtime 被创建
|
|
||||||
let _started = false;
|
|
||||||
export function createPlugin(api) {
|
|
||||||
if (_started) return;
|
|
||||||
_started = true;
|
|
||||||
const runtime = createRuntime(...);
|
|
||||||
runtime.start();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
热重载后:
|
|
||||||
- **服务端**:第二个 runtime 尝试 bind 同一端口 → EADDRINUSE → `runtime.start()` 抛出 → 被 `.catch` 静默吞掉,但 `globalThis.__yonexusServer` 已被覆盖为指向新的(未启动的)transport → `sendRule()` 永远返回 false
|
|
||||||
- **客户端**:第二个 runtime 成功建立了新的 WebSocket 连接,与旧连接并存,产生重复认证
|
|
||||||
|
|
||||||
**解法**:
|
|
||||||
```typescript
|
|
||||||
// ✅ 正确:用 globalThis 保护,热重载后新 VM 上下文也能看到 flag
|
|
||||||
const _G = globalThis as Record<string, unknown>;
|
|
||||||
const STARTED_KEY = "_yonexusClientStarted";
|
|
||||||
|
|
||||||
export function createPlugin(api) {
|
|
||||||
if (_G[STARTED_KEY]) {
|
|
||||||
// 热重载时更新 __yonexusClient 指向仍在运行的旧 runtime(存在 globalThis 上)
|
|
||||||
// 无需重新启动
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_G[STARTED_KEY] = true;
|
|
||||||
// ... 创建并启动 runtime
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
如果需要让热重载后新注册的 hook/rule 生效,还需把 `ruleRegistry`、`onXxxCallbacks` 等也存到 `globalThis`,而不是在函数体内每次新建。
|
|
||||||
|
|
||||||
**规则**:
|
|
||||||
- 任何管理持久连接/监听端口的插件,其启动 flag 必须放 `globalThis`
|
|
||||||
- 相关的 registry、回调数组也应放 `globalThis`,否则热重载后 `__pluginId` API 对象被覆盖,旧 runtime 的回调数组失去引用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. WebSocket 服务端 Transport 的消息路由竞态(Yonexus)
|
|
||||||
|
|
||||||
**问题**:Server transport 在 `ws.on("message")` 里通过 identifier 查 `_connections` 得到 `ClientConnection`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 危险:当 ws_new 还在 tempConnections,但 _connections["test-client"] 指向即将关闭的 ws_old 时
|
|
||||||
const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn;
|
|
||||||
```
|
|
||||||
|
|
||||||
**场景**:
|
|
||||||
1. `ws_old`(外部测试脚本)已认证,`_connections["test-client"] = ws_old`
|
|
||||||
2. `ws_new`(插件重连)发 hello → 进入 tempConnections,assignedIdentifier = "test-client"
|
|
||||||
3. 插件发 `auth_request` → message handler 查 `_connections.get("test-client")` → 返回 ws_old
|
|
||||||
4. `promoteToAuthenticated("test-client", ws_old)` → ws_old 不在 tempConnections → 返回 false
|
|
||||||
5. `onClientAuthenticated` 仍然触发 → `_connections.get("test-client")` = ws_old(已关闭)→ `sendRule` 返回 false
|
|
||||||
|
|
||||||
**解法**:消息路由时,如果发送方 `ws` 仍在 `tempConnections`,直接用 `tempConn`(持有正确 ws 引用的本地对象),**不再** fallback 到 `_connections`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ 正确:按 ws 引用路由,不按 identifier 路由
|
|
||||||
if (this.tempConnections.has(ws)) {
|
|
||||||
this.options.onMessage(tempConn, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ws 已 promote,从 _connections 中找
|
|
||||||
let connection = tempConn;
|
|
||||||
for (const [, conn] of this._connections) {
|
|
||||||
if (conn.ws === ws) { connection = conn; break; }
|
|
||||||
}
|
|
||||||
this.options.onMessage(connection, message);
|
|
||||||
```
|
|
||||||
|
|
||||||
**附加修复**:`promoteToAuthenticated` 的返回值不应被忽略。只有 promote 成功时才触发 `onClientAuthenticated`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const promoted = transport.promoteToAuthenticated(identifier, connection.ws);
|
|
||||||
if (promoted) {
|
|
||||||
options.onClientAuthenticated?.(identifier);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**规则**:WebSocket 服务端的消息路由应始终以**发送方的 ws 对象引用**为准,不以 identifier 查映射表。identifier 可能在 tempConnections 和 _connections 之间的过渡期产生歧义。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 服务端 Session 竞态 → 客户端 re-hello 恢复(Yonexus)
|
|
||||||
|
|
||||||
**问题**:服务端在已认证连接关闭时(`onDisconnect`)删除对应的 session。如果另一个客户端连接(同 identifier)的 `auth_request` 恰好在 session 被删除之后到达,服务端返回 `auth_failed("not_paired")`,即使客户端持有有效 secret。
|
|
||||||
|
|
||||||
**场景**:
|
|
||||||
1. 测试脚本 ws_1 已认证 → session["test-client"] 存在
|
|
||||||
2. 插件 ws_2 发送 hello → session["test-client"] 被覆写(socket = ws_2)
|
|
||||||
3. 测试脚本 ws_1 关闭 → `handleDisconnect("test-client")` → `sessions.delete("test-client")`
|
|
||||||
4. 插件 ws_2 发 `auth_request` → session 不存在 → `auth_failed("not_paired")`
|
|
||||||
5. 插件有 secret,但 `auth_required` 状态没有 re-hello 逻辑 → 永远卡住
|
|
||||||
|
|
||||||
**解法**:客户端收到 `auth_failed("not_paired")` 且持有有效 secret 时,重新发送 hello 以在服务端创建新 session,然后重试认证:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (payload.reason === "not_paired" && hasClientSecret(this.clientState)) {
|
|
||||||
this.sendHello(); // 重建 session,触发 hello_ack("auth_required") → sendAuthRequest()
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**规则**:客户端凡是遇到"自己有凭据但服务端找不到 session"的错误,都应尝试重走 hello 流程,而不是直接进入 `auth_required` 等待用户干预。
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
# OpenClaw 插件开发规范与流程
|
|
||||||
|
|
||||||
> 基于 Dirigent 插件的实际开发经验整理,适用于任何 OpenClaw 插件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、插件项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
proj-root/ # 插件项目根目录
|
|
||||||
plugin/ # 插件本体(安装时复制到 ~/.openclaw/plugins/<id>/)
|
|
||||||
index.ts # 插件入口,export default { id, name, register }
|
|
||||||
openclaw.plugin.json # 插件 config schema 声明
|
|
||||||
package.json # name、version、type: module
|
|
||||||
hooks/
|
|
||||||
before-model-resolve.ts
|
|
||||||
agent-end.ts
|
|
||||||
message-received.ts
|
|
||||||
tools/
|
|
||||||
register-tools.ts
|
|
||||||
commands/
|
|
||||||
my-command.ts
|
|
||||||
core/ # 纯业务逻辑,不依赖 plugin-sdk,便于单元测试
|
|
||||||
my-store.ts
|
|
||||||
web/ # HTTP 路由(可选)
|
|
||||||
my-api.ts
|
|
||||||
services/ # 插件管理的 sidecar 进程(随插件一起安装)
|
|
||||||
main.mjs # sidecar 入口
|
|
||||||
sub-service/
|
|
||||||
index.mjs
|
|
||||||
skills/ # 插件提供的 OpenClaw skill
|
|
||||||
my-skill/
|
|
||||||
SKILL.md
|
|
||||||
scripts/ # 安装、测试、开发辅助脚本
|
|
||||||
install.mjs # --install / --uninstall
|
|
||||||
smoke-test.sh
|
|
||||||
docs/ # 文档
|
|
||||||
IMPLEMENTATION.md
|
|
||||||
dist/ # 构建产物(gitignore),install 脚本生成
|
|
||||||
```
|
|
||||||
|
|
||||||
**约定**:
|
|
||||||
- 文件名用 kebab-case,导出函数用 camelCase
|
|
||||||
- `plugin/core/` 只放纯逻辑,不 import `openclaw/plugin-sdk`,便于单元测试
|
|
||||||
- Hook 注册逻辑独立在 `hooks/` 目录,不写在 `index.ts` 里
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、插件入口(index.ts)
|
|
||||||
|
|
||||||
### 2.1 Hook 型插件(常见场景)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
||||||
|
|
||||||
// ── 全局生命周期保护 ──
|
|
||||||
const _G = globalThis as Record<string, unknown>;
|
|
||||||
const LIFECYCLE_KEY = "_myPluginGatewayLifecycleRegistered";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
id: "my-plugin",
|
|
||||||
name: "My Plugin",
|
|
||||||
register(api: OpenClawPluginApi) {
|
|
||||||
const config = normalizeConfig(api);
|
|
||||||
|
|
||||||
// Gateway 生命周期:只注册一次
|
|
||||||
if (!_G[LIFECYCLE_KEY]) {
|
|
||||||
_G[LIFECYCLE_KEY] = true;
|
|
||||||
// 启动 sidecar、初始化全局资源等
|
|
||||||
api.on("gateway_stop", () => { /* 清理 */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent 会话 hook:每次 register() 都注册,event-level dedup 防重复处理
|
|
||||||
registerBeforeModelResolveHook({ api, config });
|
|
||||||
registerAgentEndHook({ api, config });
|
|
||||||
registerMessageReceivedHook({ api, config });
|
|
||||||
|
|
||||||
// Tools / Commands / Web
|
|
||||||
registerMyTools(api, config);
|
|
||||||
|
|
||||||
api.logger.info("my-plugin: registered");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 连接型插件(管理持久 WebSocket / TCP 连接)
|
|
||||||
|
|
||||||
插件本身作为 WebSocket 客户端或服务端时,必须把**启动 flag、runtime 引用、所有共享状态**全部挂在 `globalThis`。模块级 `let _started = false` 在热重载后新 VM 上下文中重置,导致第二个连接被建立(客户端)或端口被二次 bind(服务端)。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const _G = globalThis as Record<string, unknown>;
|
|
||||||
const STARTED_KEY = "_myConnPluginStarted";
|
|
||||||
const RUNTIME_KEY = "_myConnPluginRuntime";
|
|
||||||
const REGISTRY_KEY = "_myConnPluginRuleRegistry";
|
|
||||||
const CALLBACKS_KEY = "_myConnPluginOnReadyCallbacks";
|
|
||||||
|
|
||||||
export function createPlugin(api: { rootDir: string; pluginConfig: unknown }): void {
|
|
||||||
// 每次 register() 都把最新的 registry / callbacks 挂到 globalThis,
|
|
||||||
// 供其他插件通过 __myConnPlugin 引用
|
|
||||||
if (!(_G[REGISTRY_KEY] instanceof MyRuleRegistry)) {
|
|
||||||
_G[REGISTRY_KEY] = createRuleRegistry();
|
|
||||||
}
|
|
||||||
if (!Array.isArray(_G[CALLBACKS_KEY])) {
|
|
||||||
_G[CALLBACKS_KEY] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const registry = _G[REGISTRY_KEY] as MyRuleRegistry;
|
|
||||||
const callbacks = _G[CALLBACKS_KEY] as Array<() => void>;
|
|
||||||
|
|
||||||
// 暴露跨插件 API(每次都覆写,使 sendRule 等闭包捕获的 runtimeRef 是最新的)
|
|
||||||
_G["__myConnPlugin"] = {
|
|
||||||
registry,
|
|
||||||
onReady: callbacks,
|
|
||||||
sendMessage: (msg: string) =>
|
|
||||||
(_G[RUNTIME_KEY] as MyRuntime | undefined)?.send(msg) ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 只启动一次——不管热重载多少次
|
|
||||||
if (_G[STARTED_KEY]) return;
|
|
||||||
_G[STARTED_KEY] = true;
|
|
||||||
|
|
||||||
const runtime = createRuntime({ registry, onReady: (id) => callbacks.forEach(cb => cb()) });
|
|
||||||
_G[RUNTIME_KEY] = runtime;
|
|
||||||
|
|
||||||
process.once("SIGTERM", () => runtime.stop().catch(console.error));
|
|
||||||
runtime.start().catch(console.error);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键区别**:
|
|
||||||
- `STARTED_KEY` 检查放在**最后**,在暴露 API 之后。这样热重载时 API 对象仍被更新(新模块的闭包),但 runtime 不会重复启动。
|
|
||||||
- `sendMessage` 闭包通过 `_G[RUNTIME_KEY]` 访问 runtime,不依赖模块级变量。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、Config Schema(openclaw.plugin.json)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://openclaw.ai/schemas/plugin-config.json",
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"myToken": { "type": "string" },
|
|
||||||
"myFlag": { "type": "boolean", "default": false },
|
|
||||||
"myPort": { "type": "number", "default": 9000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:
|
|
||||||
- `additionalProperties: false` 是强制的——OpenClaw 会用 schema 验证 config,多余字段报错
|
|
||||||
- 删除废弃字段时必须同步从 schema 里移除,否则旧 config 会导致 gateway 启动失败
|
|
||||||
- 敏感字段(token、key)不要设 `default`,让用户手动配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、Hook 注册规范
|
|
||||||
|
|
||||||
### 4.1 before_model_resolve
|
|
||||||
|
|
||||||
**用途**:在模型调用前干预,可以覆盖 model/provider。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ── 去重 ──
|
|
||||||
const _DEDUP_KEY = "_myPluginBMRDedup";
|
|
||||||
if (!(_G[_DEDUP_KEY] instanceof WeakSet)) _G[_DEDUP_KEY] = new WeakSet<object>();
|
|
||||||
const dedup = _G[_DEDUP_KEY] as WeakSet<object>;
|
|
||||||
|
|
||||||
api.on("before_model_resolve", async (event, ctx) => {
|
|
||||||
if (dedup.has(event as object)) return;
|
|
||||||
dedup.add(event as object);
|
|
||||||
|
|
||||||
const sessionKey = ctx.sessionKey;
|
|
||||||
if (!sessionKey) return;
|
|
||||||
|
|
||||||
// 返回 modelOverride 即覆盖,无返回值则不干预
|
|
||||||
return { modelOverride: "no-reply", providerOverride: "dirigent" };
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**规则**:
|
|
||||||
- 必须有 WeakSet dedup(挂 globalThis)
|
|
||||||
- 返回值是 `{ modelOverride, providerOverride }` 或 `undefined`
|
|
||||||
- 异步操作(Discord API 调用等)尽量 try/catch,避免 unhandled rejection
|
|
||||||
|
|
||||||
### 4.2 agent_end
|
|
||||||
|
|
||||||
**用途**:agent 一轮对话结束后触发,用于推进状态、发送下一轮触发消息。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ── 去重 ──
|
|
||||||
const _DEDUP_KEY = "_myPluginAgentEndDedup";
|
|
||||||
if (!(_G[_DEDUP_KEY] instanceof Set)) _G[_DEDUP_KEY] = new Set<string>();
|
|
||||||
const dedup = _G[_DEDUP_KEY] as Set<string>;
|
|
||||||
|
|
||||||
api.on("agent_end", async (event, ctx) => {
|
|
||||||
const runId = (event as any).runId as string;
|
|
||||||
if (runId) {
|
|
||||||
if (dedup.has(runId)) return;
|
|
||||||
dedup.add(runId);
|
|
||||||
if (dedup.size > 500) dedup.delete(dedup.values().next().value!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 agent 最终回复文本
|
|
||||||
const messages = (event as any).messages as unknown[] ?? [];
|
|
||||||
const finalText = extractFinalText(messages); // 找最后一条 role=assistant 的文本
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**规则**:
|
|
||||||
- 用 runId + Set 去重(WeakSet 不适合,runId 是 string)
|
|
||||||
- Set 要有上限淘汰(防内存泄漏)
|
|
||||||
- 提取 finalText 要从 messages 数组末尾向前找 `role === "assistant"`
|
|
||||||
|
|
||||||
### 4.3 message_received
|
|
||||||
|
|
||||||
**用途**:收到 Discord 新消息时触发。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
api.on("message_received", async (event, ctx) => {
|
|
||||||
try {
|
|
||||||
// channelId 提取逻辑(多个来源,兼容性处理)
|
|
||||||
const channelId = extractChannelId(ctx, event);
|
|
||||||
if (!channelId) return;
|
|
||||||
// ...
|
|
||||||
} catch (err) {
|
|
||||||
api.logger.warn(`my-plugin: message_received error: ${err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、Tool 注册规范
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 无需 ctx 的工具
|
|
||||||
api.registerTool({
|
|
||||||
name: "my-tool",
|
|
||||||
description: "Does something",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
param: { type: "string", description: "..." },
|
|
||||||
},
|
|
||||||
required: ["param"],
|
|
||||||
},
|
|
||||||
execute: async (toolCallId, params) => {
|
|
||||||
const { param } = params as { param: string };
|
|
||||||
return { result: "ok" };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 需要 ctx(agentId 等)的工具:工厂函数形式
|
|
||||||
api.registerTool((ctx) => ({
|
|
||||||
name: "my-contextual-tool",
|
|
||||||
description: "...",
|
|
||||||
inputSchema: { /* ... */ },
|
|
||||||
execute: async (toolCallId, params) => {
|
|
||||||
const agentId = ctx.agentId;
|
|
||||||
// ...
|
|
||||||
return { result: agentId };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:接口是 `execute: async (toolCallId, params)` 而不是 `handler:`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、State 管理规范
|
|
||||||
|
|
||||||
| 数据类型 | 存放位置 | 原因 |
|
|
||||||
|---|---|---|
|
|
||||||
| 跨请求的业务状态(turn state 等) | `globalThis` | 热重载后模块变量重置 |
|
|
||||||
| Event dedup Set/WeakSet | `globalThis` | 同上 |
|
|
||||||
| 全局初始化 flag(gateway_start/stop) | `globalThis` | 防重复注册 |
|
|
||||||
| 连接型插件:启动 flag | `globalThis` | 热重载后模块变量重置,否则重复建连 |
|
|
||||||
| 连接型插件:runtime 引用 | `globalThis` | sendXxx 闭包需要访问仍在运行的实例 |
|
|
||||||
| 连接型插件:rule registry / 回调数组 | `globalThis` | 热重载后需与 runtime 共享同一实例 |
|
|
||||||
| 跨插件公共 API 对象(`__pluginId`) | `globalThis` | 其他插件通过 globalThis 访问 |
|
|
||||||
| 无状态工具函数 | 模块级 | 无需持久化 |
|
|
||||||
| 文件持久化数据(channel store 等) | 文件 + 内存缓存 | 需要跨 gateway 重启持久化 |
|
|
||||||
|
|
||||||
**globalThis 命名约定**:
|
|
||||||
```
|
|
||||||
_<pluginId>PluginXxx # 内部状态,例如 _yonexusClientPluginStarted
|
|
||||||
__<pluginId> # 跨插件公共 API,例如 __yonexusClient
|
|
||||||
```
|
|
||||||
内部状态用单下划线前缀,跨插件 API 用双下划线前缀,防止和其他插件冲突。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、安装脚本规范(scripts/install.mjs)
|
|
||||||
|
|
||||||
每个插件应提供标准安装脚本,支持 `--install` / `--uninstall` / `--update`。
|
|
||||||
|
|
||||||
```
|
|
||||||
install 做的事:
|
|
||||||
1. 构建 dist(复制 plugin/ 和 services/ 到 dist/)
|
|
||||||
2. 复制 dist 到 ~/.openclaw/plugins/<plugin-id>/
|
|
||||||
3. 安装 skills(支持合并已有 skill 数据)
|
|
||||||
4. 配置 plugins.entries.<id>.enabled = true
|
|
||||||
5. 设置默认 config 字段(setIfMissing,不覆盖已有值,不触碰敏感字段)
|
|
||||||
6. 添加到 plugins.allow 列表
|
|
||||||
7. 配置 model provider(如有 sidecar)
|
|
||||||
|
|
||||||
uninstall 做的事:
|
|
||||||
1. 从 plugins.allow 移除
|
|
||||||
2. 删除 plugins.entries.<id>
|
|
||||||
3. 删除 plugins.load.paths 中的条目
|
|
||||||
4. 删除安装目录
|
|
||||||
5. 删除 skills
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键细节**:
|
|
||||||
- 安装前先 `fs.rmSync(distDir, { recursive: true })` 清空旧 dist,防止残留文件
|
|
||||||
- `setIfMissing`:只写入 undefined/null 的字段,不覆盖用户已设置的值
|
|
||||||
- 敏感字段(token、secret)**绝对不要**在安装脚本中 set,注释说明需手动配置
|
|
||||||
- schema 里有 `additionalProperties: false` 时,安装脚本写入的每个 config key 都必须在 schema 里声明
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、开发调试流程
|
|
||||||
|
|
||||||
### 日常开发循环
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 修改代码(plugin/ 或 services/)
|
|
||||||
# 2. 重新安装
|
|
||||||
node scripts/install.mjs --install
|
|
||||||
|
|
||||||
# 3. 重启 gateway(必须!ChannelStore 等有文件缓存)
|
|
||||||
openclaw gateway restart
|
|
||||||
|
|
||||||
# 4. 观察日志
|
|
||||||
openclaw logs --follow # 或 tail -f /tmp/openclaw/openclaw-$(date +%F).log
|
|
||||||
|
|
||||||
# 5. 发送测试消息验证
|
|
||||||
```
|
|
||||||
|
|
||||||
### 日志关键词速查
|
|
||||||
|
|
||||||
| 关键词 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `plugin registered` | register() 执行完毕 |
|
|
||||||
| `startSideCar called` / `already running` | sidecar 启动/已存在 |
|
|
||||||
| `before_model_resolve anchor set` | 当前 speaker 正常走到模型调用 |
|
|
||||||
| `before_model_resolve suppressing` | 非 speaker 被 suppress |
|
|
||||||
| `agent_end skipping stale turn` | stale NO_REPLY 被正确过滤 |
|
|
||||||
| `triggered next speaker` | 下一轮触发成功 |
|
|
||||||
| `entered dormant` | channel 进入休眠 |
|
|
||||||
| `moderator-callback woke dormant` | 休眠被外部消息唤醒 |
|
|
||||||
| `must NOT have additional properties` | schema 与实际 config 不一致 |
|
|
||||||
|
|
||||||
### TypeScript 类型检查
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make check # tsc --noEmit
|
|
||||||
make check-rules # 验证 rule fixture
|
|
||||||
make check-files # 验证必要文件存在
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sidecar smoke test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make smoke # 测试 no-reply API 是否正常响应
|
|
||||||
# 等价于:
|
|
||||||
curl -s http://127.0.0.1:8787/no-reply/v1/chat/completions \
|
|
||||||
-X POST -H "Content-Type: application/json" \
|
|
||||||
-d '{"model":"no-reply","messages":[{"role":"user","content":"hi"}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、常见陷阱 Checklist
|
|
||||||
|
|
||||||
在提 PR 或部署前,检查以下项目:
|
|
||||||
|
|
||||||
**通用**
|
|
||||||
- [ ] 所有 hook handler 有 event dedup(WeakSet for before_model_resolve,Set+runId for agent_end)
|
|
||||||
- [ ] dedup 结构挂在 `globalThis`,不是模块级变量
|
|
||||||
- [ ] gateway 生命周期事件(gateway_start/stop)有 `globalThis` flag 保护
|
|
||||||
- [ ] 业务状态(Map/Set)挂在 `globalThis`
|
|
||||||
- [ ] `openclaw.plugin.json` 里的 schema 与实际使用的 config 字段完全对齐
|
|
||||||
- [ ] 安装脚本没有 set 任何 schema 中不存在的 config 字段
|
|
||||||
- [ ] 敏感字段(token)不在安装脚本中 set,有注释说明手动配置方式
|
|
||||||
- [ ] 安装前有 `fs.rmSync(distDir)` 清理旧文件
|
|
||||||
- [ ] 新增 channel 后需要 `openclaw gateway restart`(文档或 CLI 提示)
|
|
||||||
- [ ] Discord permission overwrite 用 `type: 1`(member),不是 `type: 0`(role)
|
|
||||||
- [ ] Sidecar 有锁文件防重复启动
|
|
||||||
- [ ] `agent_end` 的 Set 有上限淘汰(`size > 500` 时删 oldest)
|
|
||||||
|
|
||||||
**连接型插件(WebSocket / TCP)**
|
|
||||||
- [ ] 启动 flag 用 `globalThis` 而非模块级 `let`,防热重载重复建连
|
|
||||||
- [ ] runtime 引用存 `globalThis`,send 相关闭包通过 `_G[RUNTIME_KEY]` 访问
|
|
||||||
- [ ] `ruleRegistry`、回调数组等共享对象存 `globalThis`,首次不存在时才初始化
|
|
||||||
- [ ] 跨插件 API 对象(`__pluginId`)**每次** `register()` 都覆写(更新闭包),但 runtime 只启动一次
|
|
||||||
- [ ] 消费方插件(注册进 registry 的插件)做好"provider 未加载"的防御判断
|
|
||||||
- [ ] `.env` 文件加入 `.gitignore`,提交 `.env.example` 作为模板
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、跨插件 GlobalThis API 模式
|
|
||||||
|
|
||||||
当一个插件需要向同进程内的其他插件暴露功能(如 rule registry、send 接口、事件回调)时,使用 `globalThis.__pluginId` 约定。
|
|
||||||
|
|
||||||
### 提供方(Provider)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 每次 register() 都更新暴露的对象(使 sendXxx 闭包始终指向最新 runtime)
|
|
||||||
// 但注意 registry / callbacks 用 globalThis 保证跨热重载稳定
|
|
||||||
|
|
||||||
const _G = globalThis as Record<string, unknown>;
|
|
||||||
|
|
||||||
// 1. 确保 registry 和 callbacks 只初始化一次
|
|
||||||
if (!(_G["_myPluginRegistry"] instanceof MyRegistry)) {
|
|
||||||
_G["_myPluginRegistry"] = new MyRegistry();
|
|
||||||
}
|
|
||||||
if (!Array.isArray(_G["_myPluginCallbacks"])) {
|
|
||||||
_G["_myPluginCallbacks"] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 覆写公共 API 对象(闭包捕获最新 runtime)
|
|
||||||
_G["__myPlugin"] = {
|
|
||||||
registry: _G["_myPluginRegistry"] as MyRegistry,
|
|
||||||
onEvent: _G["_myPluginCallbacks"] as Array<(data: unknown) => void>,
|
|
||||||
send: (msg: string): boolean =>
|
|
||||||
(_G["_myPluginRuntime"] as MyRuntime | undefined)?.send(msg) ?? false,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 消费方(Consumer)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export default function register(_api) {
|
|
||||||
const provider = (globalThis as Record<string, unknown>)["__myPlugin"];
|
|
||||||
if (!provider) {
|
|
||||||
console.error("[my-consumer] __myPlugin not found — ensure provider loads first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册 rule
|
|
||||||
(provider as { registry: MyRegistry }).registry.registerRule("my_rule", handler);
|
|
||||||
|
|
||||||
// 订阅事件
|
|
||||||
(provider as { onEvent: Array<() => void> }).onEvent.push(() => {
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 加载顺序
|
|
||||||
|
|
||||||
`plugins.allow` 数组中 provider 必须排在 consumer **之前**,OpenClaw 按顺序加载插件。consumer 应在 `register()` 入口做 `if (!provider) return` 防御,避免 provider 未加载时崩溃。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十一、Config 变更流程
|
|
||||||
|
|
||||||
当需要新增、重命名或删除 config 字段时:
|
|
||||||
|
|
||||||
1. **先改 `openclaw.plugin.json`**(schema 是 source of truth)
|
|
||||||
2. 改 `plugin/index.ts` 中的 `PluginConfig` 类型和 `normalizeConfig()`
|
|
||||||
3. 改安装脚本(`scripts/install.mjs`)中的 `setIfMissing` 调用
|
|
||||||
4. 更新 `README.md` 中的 config 表格
|
|
||||||
5. 如果是重命名,需要告知用户手动迁移现有 `openclaw.json` 中的 config key
|
|
||||||
|
|
||||||
**重命名示例**(`noReplyPort` → `sideCarPort`):
|
|
||||||
```bash
|
|
||||||
# 用户侧迁移
|
|
||||||
openclaw config unset plugins.entries.dirigent.config.noReplyPort
|
|
||||||
openclaw config set plugins.entries.dirigent.config.sideCarPort 8787
|
|
||||||
```
|
|
||||||
113
OPERATIONS.md
113
OPERATIONS.md
@@ -1,113 +0,0 @@
|
|||||||
# 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 / 配对码明文
|
|
||||||
- 建议在生产环境开启结构化日志并保留最小必要字段
|
|
||||||
609
PLAN.md
609
PLAN.md
@@ -1,609 +0,0 @@
|
|||||||
# Yonexus — Project Plan
|
|
||||||
|
|
||||||
## 1. Goal
|
|
||||||
|
|
||||||
Yonexus is a cross-instance communication system for OpenClaw, implemented as **two separate plugins**:
|
|
||||||
|
|
||||||
- `Yonexus.Server`
|
|
||||||
- `Yonexus.Client`
|
|
||||||
|
|
||||||
Together they provide:
|
|
||||||
- communication between multiple OpenClaw instances
|
|
||||||
- a central WebSocket hub model
|
|
||||||
- client pairing and authentication
|
|
||||||
- heartbeat-based client liveness tracking
|
|
||||||
- rule-based message dispatch
|
|
||||||
- out-of-band pairing notification to a human administrator via Discord DM
|
|
||||||
- TypeScript interfaces for higher-level plugin/runtime integrations
|
|
||||||
|
|
||||||
This project is no longer a role-switched single plugin. It is now explicitly split into two installable plugins with distinct responsibilities.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Plugin Split
|
|
||||||
|
|
||||||
## 2.1 Yonexus.Server
|
|
||||||
|
|
||||||
`Yonexus.Server` is installed only on the main OpenClaw instance.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- start and maintain the WebSocket server
|
|
||||||
- accept incoming client connections
|
|
||||||
- maintain the client registry
|
|
||||||
- handle pairing flow
|
|
||||||
- verify authentication proofs
|
|
||||||
- track heartbeat and connection state
|
|
||||||
- route or relay messages to connected clients
|
|
||||||
- rewrite inbound client messages before rule dispatch
|
|
||||||
- send Discord DM pairing notifications to the human administrator
|
|
||||||
|
|
||||||
## 2.2 Yonexus.Client
|
|
||||||
|
|
||||||
`Yonexus.Client` is installed on follower OpenClaw instances.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- connect to the configured Yonexus server
|
|
||||||
- generate and persist local keypair on first use
|
|
||||||
- persist local client identity and secret
|
|
||||||
- perform pairing confirmation
|
|
||||||
- perform authenticated reconnect
|
|
||||||
- send periodic heartbeats
|
|
||||||
- expose client-side messaging and rule registration APIs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Deployment Model
|
|
||||||
|
|
||||||
A Yonexus network contains:
|
|
||||||
- exactly one OpenClaw instance running `Yonexus.Server`
|
|
||||||
- one or more OpenClaw instances running `Yonexus.Client`
|
|
||||||
|
|
||||||
Topology rules:
|
|
||||||
- `Yonexus.Server` must be reachable via fixed IP/domain or otherwise stable addressable endpoint
|
|
||||||
- `Yonexus.Client` instances do not need stable public IP/domain
|
|
||||||
- all `Yonexus.Client` instances connect outbound to the `Yonexus.Server` WebSocket endpoint
|
|
||||||
- no direct client-to-client communication is required in v1
|
|
||||||
- inter-client communication, if needed, is relayed by `Yonexus.Server`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Configuration Model
|
|
||||||
|
|
||||||
## 4.1 Yonexus.Server Config
|
|
||||||
|
|
||||||
```ts
|
|
||||||
followerIdentifiers: string[]
|
|
||||||
notifyBotToken: string
|
|
||||||
adminUserId: string
|
|
||||||
listenHost?: string
|
|
||||||
listenPort: number
|
|
||||||
publicWsUrl?: string
|
|
||||||
```
|
|
||||||
|
|
||||||
Semantics:
|
|
||||||
- `followerIdentifiers`: allowlist of client identifiers permitted to pair/connect
|
|
||||||
- `notifyBotToken`: Discord bot token used to send pairing notifications
|
|
||||||
- `adminUserId`: Discord user id of the human administrator who receives pairing codes by DM
|
|
||||||
- `listenHost`: local bind host for WebSocket server
|
|
||||||
- `listenPort`: local bind port for WebSocket server
|
|
||||||
- `publicWsUrl`: optional canonical external URL advertised/documented for clients
|
|
||||||
|
|
||||||
## 4.2 Yonexus.Client Config
|
|
||||||
|
|
||||||
```ts
|
|
||||||
mainHost: string
|
|
||||||
identifier: string
|
|
||||||
notifyBotToken: string
|
|
||||||
adminUserId: string
|
|
||||||
```
|
|
||||||
|
|
||||||
Semantics:
|
|
||||||
- `mainHost`: WebSocket endpoint of `Yonexus.Server`
|
|
||||||
- `identifier`: unique identity of this client inside the Yonexus network
|
|
||||||
- `notifyBotToken`: kept aligned with shared config expectations if future client-side notification behaviors are needed
|
|
||||||
- `adminUserId`: human administrator identity reference shared with the Yonexus system
|
|
||||||
|
|
||||||
## 4.3 Validation Rules
|
|
||||||
|
|
||||||
### Yonexus.Server
|
|
||||||
- must provide `followerIdentifiers`
|
|
||||||
- must provide `notifyBotToken`
|
|
||||||
- must provide `adminUserId`
|
|
||||||
- must provide `listenPort`
|
|
||||||
- must be deployed on a reachable/stable endpoint
|
|
||||||
|
|
||||||
### Yonexus.Client
|
|
||||||
- must provide `mainHost`
|
|
||||||
- must provide `identifier`
|
|
||||||
- must provide `notifyBotToken`
|
|
||||||
- must provide `adminUserId`
|
|
||||||
|
|
||||||
### Shared
|
|
||||||
- invalid or missing required fields must fail plugin initialization
|
|
||||||
- unknown client identifiers must be rejected by `Yonexus.Server`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4.4 Shared Terminology Baseline
|
|
||||||
|
|
||||||
These names are normative across umbrella docs, protocol docs, and implementation repos:
|
|
||||||
|
|
||||||
- `identifier`: the unique logical name of a client/follower instance.
|
|
||||||
- `rule_identifier`: the exact-match application route key.
|
|
||||||
- `builtin`: reserved rule namespace for protocol/system frames.
|
|
||||||
- `pairingCode`: short-lived out-of-band code delivered to the human admin.
|
|
||||||
- `secret`: server-issued shared secret used for reconnect proof construction.
|
|
||||||
- `publicKey` / `privateKey`: client-held signing keypair.
|
|
||||||
- `nextAction`: server-directed next step returned by `hello_ack`.
|
|
||||||
|
|
||||||
Implementations should avoid introducing alternative synonyms for these fields unless there is a versioned migration plan.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Runtime Lifecycle
|
|
||||||
|
|
||||||
## 5.1 Yonexus.Server Startup
|
|
||||||
|
|
||||||
On OpenClaw gateway startup:
|
|
||||||
- initialize persistent client registry
|
|
||||||
- start WebSocket server
|
|
||||||
- register builtin protocol handlers
|
|
||||||
- register application rule registry
|
|
||||||
- start heartbeat/status sweep timer
|
|
||||||
|
|
||||||
## 5.2 Yonexus.Client Startup
|
|
||||||
|
|
||||||
On OpenClaw gateway startup:
|
|
||||||
- load local persisted identity, private key, and secret state
|
|
||||||
- generate keypair if absent
|
|
||||||
- connect to `mainHost`
|
|
||||||
- perform pairing or authentication flow depending on local state
|
|
||||||
- start heartbeat schedule after successful authentication
|
|
||||||
- attempt reconnect when disconnected
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Server Registry and Persistence
|
|
||||||
|
|
||||||
`Yonexus.Server` must maintain a registry keyed by client `identifier`.
|
|
||||||
|
|
||||||
Each client record contains at minimum:
|
|
||||||
- `identifier`
|
|
||||||
- `publicKey`
|
|
||||||
- `secret`
|
|
||||||
- pairing state
|
|
||||||
- pairing expiration data
|
|
||||||
- pairing notification metadata
|
|
||||||
- connection status
|
|
||||||
- security counters/window data
|
|
||||||
- heartbeat timestamps
|
|
||||||
- last known session metadata
|
|
||||||
|
|
||||||
The registry must use:
|
|
||||||
- in-memory runtime state for active connections and recent security windows
|
|
||||||
- persistent on-disk storage for durable trust state
|
|
||||||
|
|
||||||
### 6.1 Proposed Server Record Shape
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ClientRecord {
|
|
||||||
identifier: string;
|
|
||||||
publicKey?: string;
|
|
||||||
secret?: string;
|
|
||||||
pairingStatus: "unpaired" | "pending" | "paired" | "revoked";
|
|
||||||
pairingCode?: string;
|
|
||||||
pairingExpiresAt?: number;
|
|
||||||
pairingNotifiedAt?: number;
|
|
||||||
pairingNotifyStatus?: "pending" | "sent" | "failed";
|
|
||||||
status: "online" | "offline" | "unstable";
|
|
||||||
lastHeartbeatAt?: number;
|
|
||||||
lastAuthenticatedAt?: number;
|
|
||||||
recentNonces: Array<{
|
|
||||||
nonce: string;
|
|
||||||
timestamp: number;
|
|
||||||
}>;
|
|
||||||
recentHandshakeAttempts: number[];
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Pairing and Authentication
|
|
||||||
|
|
||||||
## 7.1 First Connection and Key Generation
|
|
||||||
|
|
||||||
When a client connects to the server for the first time:
|
|
||||||
- `Yonexus.Client` generates a public/private key pair locally
|
|
||||||
- the private key remains only on the client instance
|
|
||||||
- the public key is sent to `Yonexus.Server` during handshake
|
|
||||||
|
|
||||||
If the server sees that:
|
|
||||||
- the client identifier is allowed, and
|
|
||||||
- there is no valid `secret` currently associated with that identifier
|
|
||||||
|
|
||||||
then the server must enter pairing flow.
|
|
||||||
|
|
||||||
## 7.2 Pairing Flow
|
|
||||||
|
|
||||||
### Step A: Pairing Request Creation
|
|
||||||
`Yonexus.Server` generates:
|
|
||||||
- a random pairing string
|
|
||||||
- an expiration time
|
|
||||||
|
|
||||||
The pairing string must **not** be sent to the client over WebSocket.
|
|
||||||
|
|
||||||
Instead, `Yonexus.Server` uses `notifyBotToken` to send a Discord DM to `adminUserId` containing:
|
|
||||||
- the client `identifier`
|
|
||||||
- the generated `pairingCode`
|
|
||||||
- the expiration time
|
|
||||||
|
|
||||||
### Step B: Pairing Confirmation
|
|
||||||
The client must provide the pairing code back to the server before expiration.
|
|
||||||
|
|
||||||
How the client operator obtains the pairing code is intentionally out-of-band from the Yonexus WebSocket channel. The server only trusts that the code came through some human-mediated path.
|
|
||||||
|
|
||||||
If the client sends the correct pairing code before expiration:
|
|
||||||
- pairing succeeds
|
|
||||||
|
|
||||||
### Step C: Secret Issuance
|
|
||||||
After successful pairing:
|
|
||||||
- `Yonexus.Server` generates a random `secret`
|
|
||||||
- `Yonexus.Server` returns that `secret` to the client
|
|
||||||
- `Yonexus.Server` stores client `publicKey` + `secret`
|
|
||||||
- `Yonexus.Client` stores private key + secret locally
|
|
||||||
|
|
||||||
If Discord DM delivery fails:
|
|
||||||
- pairing must not proceed
|
|
||||||
- server should mark the pairing attempt as failed or pending-error
|
|
||||||
- client must not receive a usable pairing code through the protocol channel
|
|
||||||
|
|
||||||
If pairing expires before confirmation:
|
|
||||||
- pairing fails
|
|
||||||
- the client must restart the pairing process
|
|
||||||
|
|
||||||
## 7.3 Reconnect Authentication Flow
|
|
||||||
|
|
||||||
After pairing is complete, future client authentication must use:
|
|
||||||
- the stored `secret`
|
|
||||||
- a 24-character random nonce
|
|
||||||
- current UTC Unix timestamp
|
|
||||||
|
|
||||||
The client builds a proof payload from:
|
|
||||||
- `secret`
|
|
||||||
- `nonce`
|
|
||||||
- `timestamp`
|
|
||||||
|
|
||||||
Logical concatenation order:
|
|
||||||
|
|
||||||
```text
|
|
||||||
secret + nonce + timestamp
|
|
||||||
```
|
|
||||||
|
|
||||||
Implementation recommendation:
|
|
||||||
- use a canonical serialized object and sign its bytes rather than naive string concatenation in code
|
|
||||||
|
|
||||||
The client signs the proof using its private key and sends it to the server.
|
|
||||||
|
|
||||||
The server verifies:
|
|
||||||
1. identifier is known and paired
|
|
||||||
2. public key matches stored state
|
|
||||||
3. proof contains the correct `secret`
|
|
||||||
4. timestamp difference from current time is less than 10 seconds
|
|
||||||
5. nonce does not collide with the recent nonce window
|
|
||||||
6. handshake attempts in the last 10 seconds do not exceed 10
|
|
||||||
|
|
||||||
If all checks pass:
|
|
||||||
- authentication succeeds
|
|
||||||
- the client is considered authenticated for the session
|
|
||||||
|
|
||||||
If any check fails:
|
|
||||||
- authentication fails
|
|
||||||
- server may downgrade or revoke trust state
|
|
||||||
|
|
||||||
## 7.4 Unsafe Condition Handling
|
|
||||||
|
|
||||||
The connection is considered unsafe and must return to pairing flow if either is true:
|
|
||||||
- more than 10 handshake attempts occur within 10 seconds
|
|
||||||
- the presented nonce collides with one of the last 10 nonces observed within the recent window
|
|
||||||
|
|
||||||
When unsafe:
|
|
||||||
- existing trust state must no longer be accepted for authentication
|
|
||||||
- the client must re-pair
|
|
||||||
- server should clear or rotate the stored `secret`
|
|
||||||
- server should reset security windows as part of re-pairing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Heartbeat and Client Status
|
|
||||||
|
|
||||||
The server must track each client’s liveness state:
|
|
||||||
- `online`
|
|
||||||
- `unstable`
|
|
||||||
- `offline`
|
|
||||||
|
|
||||||
## 8.1 Heartbeat Rules
|
|
||||||
|
|
||||||
Each client must send a heartbeat to the server every 5 minutes.
|
|
||||||
|
|
||||||
## 8.2 Status Transitions
|
|
||||||
|
|
||||||
### online
|
|
||||||
A client is `online` when:
|
|
||||||
- it has an active authenticated WebSocket connection, and
|
|
||||||
- the server has received a recent heartbeat
|
|
||||||
|
|
||||||
### unstable
|
|
||||||
A client becomes `unstable` when:
|
|
||||||
- no heartbeat has been received for 7 minutes
|
|
||||||
|
|
||||||
### offline
|
|
||||||
A client becomes `offline` when:
|
|
||||||
- no heartbeat has been received for 11 minutes
|
|
||||||
|
|
||||||
When a client becomes `offline`:
|
|
||||||
- the server must close/terminate the WebSocket connection for that client
|
|
||||||
|
|
||||||
## 8.3 Status Evaluation Strategy
|
|
||||||
|
|
||||||
The server should run a periodic status sweep timer.
|
|
||||||
|
|
||||||
Recommended interval:
|
|
||||||
- every 30 to 60 seconds
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Messaging Model
|
|
||||||
|
|
||||||
Yonexus provides rule-based message dispatch over WebSocket.
|
|
||||||
|
|
||||||
## 9.1 Base Message Format
|
|
||||||
|
|
||||||
All application messages must use the format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9.2 Server-Side Rewriting
|
|
||||||
|
|
||||||
When `Yonexus.Server` receives a message from a client, before rule matching it must rewrite the message into:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${sender_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures server-side processors can identify which client sent the message.
|
|
||||||
|
|
||||||
## 9.3 Builtin Rule Namespace
|
|
||||||
|
|
||||||
The reserved rule identifier is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin
|
|
||||||
```
|
|
||||||
|
|
||||||
It is used internally for:
|
|
||||||
- handshake
|
|
||||||
- pairing
|
|
||||||
- heartbeat
|
|
||||||
- protocol/system messages
|
|
||||||
|
|
||||||
User code must not be allowed to register handlers for `builtin`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. TypeScript API Surface
|
|
||||||
|
|
||||||
## 10.1 Yonexus.Client API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
sendMessageToServer(message: string): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- sends message to connected `Yonexus.Server`
|
|
||||||
- message must already conform to `${rule_identifier}::${message_content}`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- rejects `builtin`
|
|
||||||
- rejects duplicate rule registration unless explicit override support is added later
|
|
||||||
|
|
||||||
## 10.2 Yonexus.Server API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
sendMessageToClient(identifier: string, message: string): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- target client must be known and currently connected/authenticated
|
|
||||||
- message must already conform to `${rule_identifier}::${message_content}`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- rejects `builtin`
|
|
||||||
- rejects duplicate rule registration unless explicit override support is added later
|
|
||||||
- processors are invoked with the final received string after any server-side rewrite
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Hooks and Integration
|
|
||||||
|
|
||||||
## 11.1 Yonexus.Server Hooking
|
|
||||||
|
|
||||||
`Yonexus.Server` must register hooks so that when OpenClaw gateway starts:
|
|
||||||
- the WebSocket server is started
|
|
||||||
- the server registry is initialized
|
|
||||||
- builtin protocol handling is enabled
|
|
||||||
- heartbeat sweep begins
|
|
||||||
|
|
||||||
## 11.2 Yonexus.Client Behavior
|
|
||||||
|
|
||||||
`Yonexus.Client` must:
|
|
||||||
- connect outbound to `mainHost`
|
|
||||||
- manage local trust material
|
|
||||||
- handle pairing/authentication transitions
|
|
||||||
- emit heartbeats after authentication
|
|
||||||
- reconnect after disconnect with retry/backoff behavior
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Storage Strategy
|
|
||||||
|
|
||||||
## 12.1 Yonexus.Server Storage
|
|
||||||
|
|
||||||
Server persists at minimum:
|
|
||||||
- identifier
|
|
||||||
- public key
|
|
||||||
- secret
|
|
||||||
- trust state
|
|
||||||
- pairing code + expiry if pairing is pending
|
|
||||||
- pairing notification metadata
|
|
||||||
- last known status
|
|
||||||
- metadata timestamps
|
|
||||||
|
|
||||||
May persist or reset on restart:
|
|
||||||
- recent nonces
|
|
||||||
- recent handshake attempts
|
|
||||||
|
|
||||||
Recommended v1:
|
|
||||||
- clear rolling security windows on restart
|
|
||||||
- keep long-lived trust records
|
|
||||||
|
|
||||||
## 12.2 Yonexus.Client Storage
|
|
||||||
|
|
||||||
Client persists at minimum:
|
|
||||||
- identifier
|
|
||||||
- private key
|
|
||||||
- secret
|
|
||||||
- optional last successful pair/auth metadata
|
|
||||||
|
|
||||||
Security notes:
|
|
||||||
- private key must never be sent to the server
|
|
||||||
- secret must be treated as sensitive material
|
|
||||||
- encryption-at-rest can be a future enhancement, but any plaintext local storage must be documented as a limitation if used initially
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Error Handling
|
|
||||||
|
|
||||||
Structured errors should exist for at least:
|
|
||||||
- invalid configuration
|
|
||||||
- unauthorized identifier
|
|
||||||
- pairing required
|
|
||||||
- pairing expired
|
|
||||||
- pairing notification failure
|
|
||||||
- handshake verification failure
|
|
||||||
- replay/nonce collision detected
|
|
||||||
- unsafe handshake rate detected
|
|
||||||
- target client not connected
|
|
||||||
- duplicate rule registration
|
|
||||||
- reserved rule registration
|
|
||||||
- malformed message
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Initial Implementation Phases
|
|
||||||
|
|
||||||
## Phase 0 — Protocol and Skeleton
|
|
||||||
- finalize split-plugin configuration schema
|
|
||||||
- define persistent data models
|
|
||||||
- define builtin protocol messages
|
|
||||||
- define startup hooks for both plugins
|
|
||||||
- define rule registry behavior
|
|
||||||
- define Discord DM notification flow
|
|
||||||
|
|
||||||
## Phase 1 — Transport MVP
|
|
||||||
- Yonexus.Server WebSocket server startup
|
|
||||||
- Yonexus.Client WebSocket client startup
|
|
||||||
- reconnect logic
|
|
||||||
- builtin protocol channel
|
|
||||||
- persistent registry/state scaffolding
|
|
||||||
|
|
||||||
## Phase 2 — Pairing and Authentication
|
|
||||||
- client keypair generation
|
|
||||||
- pairing request creation
|
|
||||||
- Discord DM notification to admin user
|
|
||||||
- pairing confirmation flow
|
|
||||||
- secret issuance and persistence
|
|
||||||
- signed proof verification
|
|
||||||
- nonce/replay protection
|
|
||||||
- unsafe-condition reset to pairing
|
|
||||||
|
|
||||||
## Phase 3 — Heartbeat and Status Tracking
|
|
||||||
- client heartbeat sender
|
|
||||||
- server heartbeat receiver
|
|
||||||
- periodic sweep
|
|
||||||
- status transitions: online / unstable / offline
|
|
||||||
- forced disconnect on offline
|
|
||||||
|
|
||||||
## Phase 4 — Public APIs and Dispatch
|
|
||||||
- `sendMessageToServer`
|
|
||||||
- `sendMessageToClient`
|
|
||||||
- `registerRule`
|
|
||||||
- first-match dispatch
|
|
||||||
- server-side sender rewrite behavior
|
|
||||||
|
|
||||||
## Phase 5 — Hardening and Docs
|
|
||||||
- integration tests
|
|
||||||
- failure-path coverage
|
|
||||||
- restart recovery checks
|
|
||||||
- protocol docs
|
|
||||||
- operator setup docs for server/client deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Non-Goals for Initial Version
|
|
||||||
|
|
||||||
Not required in the first version unless explicitly added later:
|
|
||||||
- direct client-to-client sockets
|
|
||||||
- multi-server clustering
|
|
||||||
- distributed consensus
|
|
||||||
- offline message queues or guaranteed delivery to disconnected clients
|
|
||||||
- advanced rule matching beyond exact string match
|
|
||||||
- message ordering guarantees across reconnects
|
|
||||||
- end-to-end payload encryption beyond the pairing/authentication requirements
|
|
||||||
- management UI
|
|
||||||
- admin-side approve/deny control plane beyond human relay of pairing codes
|
|
||||||
- encryption-at-rest hardening beyond documenting current local storage limitations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. v1 Decisions Locked for Current Implementation
|
|
||||||
|
|
||||||
The following implementation-boundary decisions are now treated as settled for v1:
|
|
||||||
|
|
||||||
1. Signing algorithm default: Ed25519.
|
|
||||||
2. `mainHost` should be configured as a full `ws://` or `wss://` URL in v1.
|
|
||||||
3. Human relay of the pairing code is sufficient for v1; richer admin approve/deny control can wait.
|
|
||||||
4. `heartbeat_ack` remains optional.
|
|
||||||
5. Client reconnect uses exponential backoff.
|
|
||||||
6. Rule identifiers are exact-match strings only in v1.
|
|
||||||
7. Outbound sends to offline clients fail immediately rather than queueing.
|
|
||||||
|
|
||||||
## 17. Open Questions To Confirm Later
|
|
||||||
|
|
||||||
1. On unsafe condition, should the old public key be retained or should the client generate a new keypair?
|
|
||||||
2. Should future versions support explicit key rotation without full re-pairing?
|
|
||||||
3. Should offline clients support queued outbound messages from server in a later version?
|
|
||||||
4. Are richer admin approval workflows worth adding after v1 stabilizes?
|
|
||||||
5. Should encryption-at-rest become a hard requirement in v2?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. Immediate Next Deliverables
|
|
||||||
|
|
||||||
After this plan, the next files to create should be:
|
|
||||||
- `FEAT.md` — feature checklist derived from this plan
|
|
||||||
- `README.md` — concise system overview for both plugins
|
|
||||||
- `plugin.server.json` or equivalent server plugin manifest
|
|
||||||
- `plugin.client.json` or equivalent client plugin manifest
|
|
||||||
- implementation task breakdown
|
|
||||||
866
PROTOCOL.md
866
PROTOCOL.md
@@ -1,866 +0,0 @@
|
|||||||
# Yonexus Protocol Specification
|
|
||||||
|
|
||||||
Version: draft v0.3
|
|
||||||
Status: planning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
This document defines the built-in Yonexus communication protocol used between:
|
|
||||||
- `Yonexus.Server`
|
|
||||||
- `Yonexus.Client`
|
|
||||||
|
|
||||||
The protocol covers:
|
|
||||||
- connection setup
|
|
||||||
- pairing
|
|
||||||
- authentication
|
|
||||||
- heartbeat
|
|
||||||
- status/lifecycle events
|
|
||||||
- protocol-level errors
|
|
||||||
- transport of application rule messages over the same WebSocket channel
|
|
||||||
|
|
||||||
Important security rule:
|
|
||||||
- pairing codes must **not** be delivered to `Yonexus.Client` over the Yonexus WebSocket channel
|
|
||||||
- pairing codes must be delivered out-of-band to a human administrator via Discord DM
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.1 Canonical Terminology
|
|
||||||
|
|
||||||
These names are treated as protocol-level canonical terms:
|
|
||||||
|
|
||||||
- `identifier`: unique logical identity of a Yonexus client instance.
|
|
||||||
- `rule_identifier`: exact-match routing key for application messages.
|
|
||||||
- `builtin`: reserved protocol namespace used only for Yonexus control frames.
|
|
||||||
- `pairingCode`: short-lived out-of-band code generated by the server for human-mediated pairing.
|
|
||||||
- `secret`: server-issued shared secret used in reconnect authentication proof construction.
|
|
||||||
- `publicKey` / `privateKey`: client signing keypair.
|
|
||||||
- `nextAction`: the server's directed next step in `hello_ack`.
|
|
||||||
|
|
||||||
The protocol and implementation repos should prefer these exact names over synonyms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Transport
|
|
||||||
|
|
||||||
Transport is WebSocket.
|
|
||||||
|
|
||||||
- `Yonexus.Server` listens as WebSocket server
|
|
||||||
- `Yonexus.Client` connects as WebSocket client
|
|
||||||
- protocol frames are UTF-8 text in v1
|
|
||||||
- binary frames are not required in v1
|
|
||||||
|
|
||||||
Client connects to configured `mainHost`, which in v1 should be a full WebSocket URL:
|
|
||||||
- `ws://host:port/path`
|
|
||||||
- `wss://host:port/path`
|
|
||||||
|
|
||||||
Recommended canonical config:
|
|
||||||
- require/prefer a full WebSocket URL in v1 rather than raw `host:port`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Message Categories
|
|
||||||
|
|
||||||
## 3.1 Builtin Protocol Messages
|
|
||||||
|
|
||||||
Builtin messages always use:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::${json_payload}
|
|
||||||
```
|
|
||||||
|
|
||||||
`builtin` is reserved and must not be registered by user code.
|
|
||||||
|
|
||||||
## 3.2 Application Rule Messages
|
|
||||||
|
|
||||||
Application messages use:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
When `Yonexus.Server` receives a rule message from a client, it must rewrite it before dispatch to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${sender_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Builtin Envelope
|
|
||||||
|
|
||||||
Builtin wire format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{JSON}
|
|
||||||
```
|
|
||||||
|
|
||||||
Canonical envelope:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface BuiltinEnvelope {
|
|
||||||
type: string;
|
|
||||||
requestId?: string;
|
|
||||||
timestamp?: number; // UTC unix seconds
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- `timestamp` uses UTC unix seconds
|
|
||||||
- `requestId` is used for correlation where needed
|
|
||||||
- `payload` content depends on `type`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Builtin Message Types
|
|
||||||
|
|
||||||
## 5.1 Session Setup
|
|
||||||
|
|
||||||
### `hello`
|
|
||||||
Sent by `Yonexus.Client` immediately after WebSocket connection opens.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- declare identifier
|
|
||||||
- advertise current auth material state
|
|
||||||
- announce protocol version
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"hello",
|
|
||||||
"requestId":"req_001",
|
|
||||||
"timestamp":1711886400,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"hasSecret":true,
|
|
||||||
"hasKeyPair":true,
|
|
||||||
"publicKey":"<optional-public-key>",
|
|
||||||
"protocolVersion":"1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `hello_ack`
|
|
||||||
Sent by `Yonexus.Server` in response to `hello`.
|
|
||||||
|
|
||||||
Possible `nextAction` values:
|
|
||||||
- `pair_required`
|
|
||||||
- `auth_required`
|
|
||||||
- `rejected`
|
|
||||||
- `waiting_pair_confirm`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"hello_ack",
|
|
||||||
"requestId":"req_001",
|
|
||||||
"timestamp":1711886401,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"nextAction":"pair_required"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5.2 Pairing Flow
|
|
||||||
|
|
||||||
## 5.2.1 Pairing Design Rule
|
|
||||||
|
|
||||||
`Yonexus.Server` must never send the actual `pairingCode` to `Yonexus.Client` through the Yonexus WebSocket channel.
|
|
||||||
|
|
||||||
The pairing code must be delivered to the configured human administrator using:
|
|
||||||
- `notifyBotToken`
|
|
||||||
- `adminUserId`
|
|
||||||
|
|
||||||
Specifically:
|
|
||||||
- `Yonexus.Server` sends a Discord DM to the configured admin user
|
|
||||||
- the DM contains the client identifier and pairing code
|
|
||||||
- the human relays the code to the client side by some trusted out-of-band path
|
|
||||||
|
|
||||||
## 5.2.2 Pairing Request Creation
|
|
||||||
|
|
||||||
When pairing is required, `Yonexus.Server` generates:
|
|
||||||
- `pairingCode`
|
|
||||||
- `expiresAt`
|
|
||||||
- `ttlSeconds`
|
|
||||||
|
|
||||||
The admin DM must include at minimum:
|
|
||||||
- `identifier`
|
|
||||||
- `pairingCode`
|
|
||||||
- `expiresAt` or TTL
|
|
||||||
|
|
||||||
Example DM body:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Yonexus pairing request
|
|
||||||
identifier: client-a
|
|
||||||
pairingCode: ABCD-1234-XYZ
|
|
||||||
expiresAt: 1711886702
|
|
||||||
```
|
|
||||||
|
|
||||||
### `pair_request`
|
|
||||||
Sent by `Yonexus.Server` to `Yonexus.Client` after pairing starts.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- indicate that pairing has started
|
|
||||||
- indicate whether admin notification succeeded
|
|
||||||
- provide expiry metadata without revealing the code
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"pair_request",
|
|
||||||
"requestId":"req_002",
|
|
||||||
"timestamp":1711886402,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"expiresAt":1711886702,
|
|
||||||
"ttlSeconds":300,
|
|
||||||
"adminNotification":"sent",
|
|
||||||
"codeDelivery":"out_of_band"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed `adminNotification` values:
|
|
||||||
- `sent`
|
|
||||||
- `failed`
|
|
||||||
|
|
||||||
If notification failed, pairing must not proceed until retried successfully.
|
|
||||||
|
|
||||||
### `pair_confirm`
|
|
||||||
Sent by `Yonexus.Client` to confirm pairing.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- submit the pairing code obtained out-of-band
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"pair_confirm",
|
|
||||||
"requestId":"req_002",
|
|
||||||
"timestamp":1711886410,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"pairingCode":"ABCD-1234-XYZ"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `pair_success`
|
|
||||||
Sent by `Yonexus.Server` after successful pairing.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- return generated secret
|
|
||||||
- confirm trusted pairing state
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"pair_success",
|
|
||||||
"requestId":"req_002",
|
|
||||||
"timestamp":1711886411,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"secret":"<random-secret>",
|
|
||||||
"pairedAt":1711886411
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `pair_failed`
|
|
||||||
Sent by `Yonexus.Server` when pairing fails.
|
|
||||||
|
|
||||||
Typical reasons:
|
|
||||||
- `expired`
|
|
||||||
- `invalid_code`
|
|
||||||
- `identifier_not_allowed`
|
|
||||||
- `admin_notification_failed`
|
|
||||||
- `internal_error`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"pair_failed",
|
|
||||||
"requestId":"req_002",
|
|
||||||
"timestamp":1711886710,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"reason":"expired"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5.3 Authentication Flow
|
|
||||||
|
|
||||||
After pairing, reconnect authentication uses:
|
|
||||||
- stored `secret`
|
|
||||||
- 24-character random nonce
|
|
||||||
- current UTC unix timestamp
|
|
||||||
- client private key
|
|
||||||
|
|
||||||
## 5.3.1 Proof Construction
|
|
||||||
|
|
||||||
Logical proof content:
|
|
||||||
|
|
||||||
```text
|
|
||||||
secret + nonce + timestamp
|
|
||||||
```
|
|
||||||
|
|
||||||
Implementation recommendation:
|
|
||||||
- use canonical serialized object bytes for signing
|
|
||||||
|
|
||||||
Recommended logical form:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"secret":"...",
|
|
||||||
"nonce":"...",
|
|
||||||
"timestamp":1711886500
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5.3.2 Signature Primitive
|
|
||||||
|
|
||||||
Recommended primitive:
|
|
||||||
- digital signature using client private key
|
|
||||||
- verification using stored client public key on server
|
|
||||||
|
|
||||||
### `auth_request`
|
|
||||||
Sent by `Yonexus.Client` after pairing or on reconnect.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"auth_request",
|
|
||||||
"requestId":"req_003",
|
|
||||||
"timestamp":1711886500,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"nonce":"RANDOM24CHARACTERSTRINGX",
|
|
||||||
"proofTimestamp":1711886500,
|
|
||||||
"signature":"<base64-signature>",
|
|
||||||
"publicKey":"<optional-public-key-if-rotating>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Server validation:
|
|
||||||
1. identifier is allowlisted
|
|
||||||
2. identifier exists in registry
|
|
||||||
3. client is in paired state
|
|
||||||
4. public key matches expected key if provided
|
|
||||||
5. signature verifies successfully
|
|
||||||
6. proof contains correct secret
|
|
||||||
7. `abs(now - proofTimestamp) < 10`
|
|
||||||
8. nonce has not appeared in recent nonce window
|
|
||||||
9. handshake attempts in last 10 seconds do not exceed 10
|
|
||||||
|
|
||||||
### `auth_success`
|
|
||||||
Sent by `Yonexus.Server` on success.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"auth_success",
|
|
||||||
"requestId":"req_003",
|
|
||||||
"timestamp":1711886501,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"authenticatedAt":1711886501,
|
|
||||||
"status":"online"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `auth_failed`
|
|
||||||
Sent by `Yonexus.Server` on auth failure.
|
|
||||||
|
|
||||||
Allowed reasons include:
|
|
||||||
- `unknown_identifier`
|
|
||||||
- `not_paired`
|
|
||||||
- `invalid_signature`
|
|
||||||
- `invalid_secret`
|
|
||||||
- `stale_timestamp`
|
|
||||||
- `future_timestamp`
|
|
||||||
- `nonce_collision`
|
|
||||||
- `rate_limited`
|
|
||||||
- `re_pair_required`
|
|
||||||
|
|
||||||
### `re_pair_required`
|
|
||||||
Sent by `Yonexus.Server` when trust state must be reset.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"re_pair_required",
|
|
||||||
"requestId":"req_004",
|
|
||||||
"timestamp":1711886510,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"reason":"nonce_collision"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5.4 Heartbeat
|
|
||||||
|
|
||||||
### `heartbeat`
|
|
||||||
Sent by `Yonexus.Client` every 5 minutes after authentication.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"heartbeat",
|
|
||||||
"timestamp":1711886800,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"status":"alive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `heartbeat_ack`
|
|
||||||
Optional response by `Yonexus.Server`.
|
|
||||||
|
|
||||||
v1 policy:
|
|
||||||
- `heartbeat_ack` may be enabled by the server but clients must not require it for healthy operation
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"heartbeat_ack",
|
|
||||||
"timestamp":1711886801,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"status":"online"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5.5 Status / Lifecycle Notifications
|
|
||||||
|
|
||||||
### `status_update`
|
|
||||||
Sent by `Yonexus.Server` when client state changes.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"status_update",
|
|
||||||
"timestamp":1711887220,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"status":"unstable",
|
|
||||||
"reason":"heartbeat_timeout_7m"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `disconnect_notice`
|
|
||||||
Sent by `Yonexus.Server` before deliberate close.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{
|
|
||||||
"type":"disconnect_notice",
|
|
||||||
"timestamp":1711887460,
|
|
||||||
"payload":{
|
|
||||||
"identifier":"client-a",
|
|
||||||
"reason":"heartbeat_timeout_11m"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5.6 Errors
|
|
||||||
|
|
||||||
### `error`
|
|
||||||
Generic protocol-level error.
|
|
||||||
|
|
||||||
Recommended builtin error codes:
|
|
||||||
- `MALFORMED_MESSAGE`
|
|
||||||
- `UNSUPPORTED_PROTOCOL_VERSION`
|
|
||||||
- `IDENTIFIER_NOT_ALLOWED`
|
|
||||||
- `PAIRING_REQUIRED`
|
|
||||||
- `PAIRING_EXPIRED`
|
|
||||||
- `ADMIN_NOTIFICATION_FAILED`
|
|
||||||
- `AUTH_FAILED`
|
|
||||||
- `NONCE_COLLISION`
|
|
||||||
- `RATE_LIMITED`
|
|
||||||
- `RE_PAIR_REQUIRED`
|
|
||||||
- `CLIENT_OFFLINE`
|
|
||||||
- `INTERNAL_ERROR`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. State Machines
|
|
||||||
|
|
||||||
## 6.1 Client State Machine
|
|
||||||
|
|
||||||
Suggested `Yonexus.Client` states:
|
|
||||||
- `idle`
|
|
||||||
- `connecting`
|
|
||||||
- `connected`
|
|
||||||
- `pairing_required`
|
|
||||||
- `pairing_pending`
|
|
||||||
- `paired`
|
|
||||||
- `authenticating`
|
|
||||||
- `authenticated`
|
|
||||||
- `reconnecting`
|
|
||||||
- `error`
|
|
||||||
|
|
||||||
Typical transitions:
|
|
||||||
|
|
||||||
```text
|
|
||||||
idle
|
|
||||||
-> connecting
|
|
||||||
-> connected
|
|
||||||
-> (pairing_required | authenticating)
|
|
||||||
|
|
||||||
pairing_required
|
|
||||||
-> pairing_pending
|
|
||||||
-> paired
|
|
||||||
-> authenticating
|
|
||||||
-> authenticated
|
|
||||||
|
|
||||||
authenticated
|
|
||||||
-> reconnecting
|
|
||||||
-> connecting
|
|
||||||
```
|
|
||||||
|
|
||||||
On `re_pair_required`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
authenticated | authenticating -> pairing_required
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6.2 Server-Side Client State
|
|
||||||
|
|
||||||
Per client trust state:
|
|
||||||
- `unpaired`
|
|
||||||
- `pending`
|
|
||||||
- `paired`
|
|
||||||
- `revoked`
|
|
||||||
|
|
||||||
Per client liveness state:
|
|
||||||
- `online`
|
|
||||||
- `unstable`
|
|
||||||
- `offline`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Security Windows and Replay Protection
|
|
||||||
|
|
||||||
## 7.1 Nonce Requirements
|
|
||||||
|
|
||||||
Nonce rules:
|
|
||||||
- exactly 24 random characters
|
|
||||||
- fresh per auth attempt
|
|
||||||
- must not repeat within recent security window
|
|
||||||
|
|
||||||
## 7.2 Recent Nonce Window
|
|
||||||
|
|
||||||
Server stores for each client:
|
|
||||||
- the last 10 nonces seen within the recent validity window
|
|
||||||
|
|
||||||
If a nonce collides:
|
|
||||||
- authentication fails
|
|
||||||
- server marks condition unsafe
|
|
||||||
- client must re-pair
|
|
||||||
|
|
||||||
## 7.3 Handshake Attempt Window
|
|
||||||
|
|
||||||
Server stores recent handshake attempt timestamps.
|
|
||||||
|
|
||||||
If more than 10 handshake attempts occur within 10 seconds:
|
|
||||||
- authentication fails
|
|
||||||
- server marks condition unsafe
|
|
||||||
- client must re-pair
|
|
||||||
|
|
||||||
## 7.4 Time Drift Validation
|
|
||||||
|
|
||||||
Server validates:
|
|
||||||
|
|
||||||
```text
|
|
||||||
abs(current_utc_unix_time - proofTimestamp) < 10
|
|
||||||
```
|
|
||||||
|
|
||||||
If validation fails:
|
|
||||||
- auth fails
|
|
||||||
- no session is established
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Rule Message Dispatch
|
|
||||||
|
|
||||||
All non-builtin messages use:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
Client to server example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
chat_sync::{"conversationId":"abc","body":"hello"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Server rewrites before matching:
|
|
||||||
|
|
||||||
```text
|
|
||||||
chat_sync::client-a::{"conversationId":"abc","body":"hello"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Dispatch algorithm:
|
|
||||||
1. parse first delimiter section as `rule_identifier`
|
|
||||||
2. if `rule_identifier === builtin`, route to builtin protocol handler
|
|
||||||
3. otherwise iterate registered rules in registration order
|
|
||||||
4. invoke the first exact match
|
|
||||||
5. ignore/log if no match is found
|
|
||||||
|
|
||||||
v1 policy:
|
|
||||||
- rule matching is exact string match only; prefix, wildcard, and regex routing are out of scope
|
|
||||||
|
|
||||||
Processor input:
|
|
||||||
- on client: `${rule_identifier}::${message_content}`
|
|
||||||
- on server for client-originated messages: `${rule_identifier}::${sender_identifier}::${message_content}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Connection Rules
|
|
||||||
|
|
||||||
Server should reject connection attempts when:
|
|
||||||
- identifier is absent
|
|
||||||
- identifier is not in configured allowlist
|
|
||||||
- protocol version is unsupported
|
|
||||||
- hello/auth payload is malformed
|
|
||||||
|
|
||||||
Recommended v1 policy:
|
|
||||||
- only one active authenticated connection per client identifier
|
|
||||||
- terminate old connection and accept new one after successful auth
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Persistence Semantics
|
|
||||||
|
|
||||||
## 10.1 Yonexus.Server Persists
|
|
||||||
|
|
||||||
At minimum:
|
|
||||||
- identifier
|
|
||||||
- public key
|
|
||||||
- secret
|
|
||||||
- trust state
|
|
||||||
- pairing code + expiry if pending
|
|
||||||
- pairing notification metadata
|
|
||||||
- last known liveness status
|
|
||||||
- metadata timestamps
|
|
||||||
|
|
||||||
May persist or reset on restart:
|
|
||||||
- recent nonces
|
|
||||||
- recent handshake attempts
|
|
||||||
|
|
||||||
Recommended v1:
|
|
||||||
- clear rolling security windows on restart
|
|
||||||
- keep long-lived trust records
|
|
||||||
|
|
||||||
## 10.2 Yonexus.Client Persists
|
|
||||||
|
|
||||||
At minimum:
|
|
||||||
- identifier
|
|
||||||
- private key
|
|
||||||
- secret
|
|
||||||
- optional last successful pair/auth metadata
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Versioning
|
|
||||||
|
|
||||||
Protocol version is advertised during `hello`.
|
|
||||||
|
|
||||||
Initial version:
|
|
||||||
|
|
||||||
```text
|
|
||||||
1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Canonical JSON Shapes
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface HelloPayload {
|
|
||||||
identifier: string;
|
|
||||||
hasSecret: boolean;
|
|
||||||
hasKeyPair: boolean;
|
|
||||||
publicKey?: string;
|
|
||||||
protocolVersion: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PairRequestPayload {
|
|
||||||
identifier: string;
|
|
||||||
expiresAt: number;
|
|
||||||
ttlSeconds: number;
|
|
||||||
adminNotification: "sent" | "failed";
|
|
||||||
codeDelivery: "out_of_band";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PairConfirmPayload {
|
|
||||||
identifier: string;
|
|
||||||
pairingCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PairSuccessPayload {
|
|
||||||
identifier: string;
|
|
||||||
secret: string;
|
|
||||||
pairedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthRequestPayload {
|
|
||||||
identifier: string;
|
|
||||||
nonce: string;
|
|
||||||
proofTimestamp: number;
|
|
||||||
signature: string;
|
|
||||||
publicKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HeartbeatPayload {
|
|
||||||
identifier: string;
|
|
||||||
status: "alive";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Example Flows
|
|
||||||
|
|
||||||
## 13.1 First-Time Pairing Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client connects WS
|
|
||||||
Client -> builtin::hello
|
|
||||||
Server sends Discord DM to configured admin with identifier + pairingCode
|
|
||||||
Server -> builtin::hello_ack(nextAction=pair_required)
|
|
||||||
Server -> builtin::pair_request(expiresAt, adminNotification=sent, codeDelivery=out_of_band)
|
|
||||||
Human reads DM and relays pairingCode to client side
|
|
||||||
Client -> builtin::pair_confirm(pairingCode)
|
|
||||||
Server -> builtin::pair_success(secret)
|
|
||||||
Client stores secret
|
|
||||||
Client -> builtin::auth_request(signature over secret+nonce+timestamp)
|
|
||||||
Server -> builtin::auth_success
|
|
||||||
Client enters authenticated state
|
|
||||||
```
|
|
||||||
|
|
||||||
## 13.2 Normal Reconnect Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client connects WS
|
|
||||||
Client -> builtin::hello(hasSecret=true)
|
|
||||||
Server -> builtin::hello_ack(nextAction=auth_required)
|
|
||||||
Client -> builtin::auth_request(...)
|
|
||||||
Server -> builtin::auth_success
|
|
||||||
Client begins heartbeat schedule
|
|
||||||
```
|
|
||||||
|
|
||||||
## 13.3 Unsafe Replay / Collision Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> builtin::auth_request(nonce collision)
|
|
||||||
Server detects unsafe condition
|
|
||||||
Server -> builtin::re_pair_required(reason=nonce_collision)
|
|
||||||
Server invalidates existing secret/session trust
|
|
||||||
Server sends fresh Discord DM pairing notification on next pairing start
|
|
||||||
Client returns to pairing_required state
|
|
||||||
```
|
|
||||||
|
|
||||||
## 13.4 Heartbeat Timeout Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client authenticated
|
|
||||||
No heartbeat for 7 min -> server marks unstable
|
|
||||||
No heartbeat for 11 min -> server marks offline
|
|
||||||
Server -> builtin::disconnect_notice
|
|
||||||
Server closes WS connection
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Implementation Notes
|
|
||||||
|
|
||||||
## 14.1 Parsing
|
|
||||||
|
|
||||||
Because the wire format is string-based with `::` delimiters:
|
|
||||||
- only the first delimiter split should determine the `rule_identifier`
|
|
||||||
- for `builtin`, the remainder is parsed as JSON once
|
|
||||||
- message content itself may contain `::`, so avoid naive full split logic
|
|
||||||
|
|
||||||
## 14.2 Discord DM Notification
|
|
||||||
|
|
||||||
When pairing starts on `Yonexus.Server`:
|
|
||||||
- use configured `notifyBotToken`
|
|
||||||
- send DM to `adminUserId`
|
|
||||||
- include only required pairing data
|
|
||||||
- if DM send fails, surface pairing notification failure
|
|
||||||
|
|
||||||
Sensitive values that must never be logged in plaintext:
|
|
||||||
- `secret`
|
|
||||||
- private key
|
|
||||||
- raw proof material
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Open Clarifications
|
|
||||||
|
|
||||||
1. Exact signing algorithm: Ed25519 is a strong default candidate
|
|
||||||
2. Secret length and generation requirements
|
|
||||||
3. Pairing code format and length
|
|
||||||
4. Is human code relay enough for v1, or should admin approve/deny controls be added later?
|
|
||||||
5. Should `heartbeat_ack` be mandatory or optional?
|
|
||||||
6. Should client reconnect use exponential backoff?
|
|
||||||
7. Should duplicate active connections replace old sessions or be rejected in stricter modes?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Reserved Builtin Types
|
|
||||||
|
|
||||||
Reserved builtin `type` values:
|
|
||||||
- `hello`
|
|
||||||
- `hello_ack`
|
|
||||||
- `pair_request`
|
|
||||||
- `pair_confirm`
|
|
||||||
- `pair_success`
|
|
||||||
- `pair_failed`
|
|
||||||
- `auth_request`
|
|
||||||
- `auth_success`
|
|
||||||
- `auth_failed`
|
|
||||||
- `re_pair_required`
|
|
||||||
- `heartbeat`
|
|
||||||
- `heartbeat_ack`
|
|
||||||
- `status_update`
|
|
||||||
- `disconnect_notice`
|
|
||||||
- `error`
|
|
||||||
|
|
||||||
These names are reserved by Yonexus and must not be repurposed by user rules.
|
|
||||||
267
README.md
267
README.md
@@ -1,205 +1,118 @@
|
|||||||
|
[English](./README.md) | [中文](./README.zh.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Yonexus
|
# Yonexus
|
||||||
|
|
||||||
Yonexus is a cross-instance communication system for OpenClaw built as **three separate repositories**:
|
Yonexus is an OpenClaw plugin for organization hierarchy and agent identity management.
|
||||||
|
|
||||||
| Repository | Role |
|
## Features
|
||||||
|---|---|
|
|
||||||
| `Yonexus` | Umbrella — architecture, planning, and coordination |
|
|
||||||
| `Yonexus.Server` | Central hub plugin — accepts client connections, handles pairing/authentication |
|
|
||||||
| `Yonexus.Client` | Client plugin — connects to server, manages local identity |
|
|
||||||
| `Yonexus.Protocol` | Shared protocol specification — referenced as a submodule by both Server and Client |
|
|
||||||
|
|
||||||
## Overview
|
- Organization hierarchy: `Organization -> Department -> Team -> Agent`
|
||||||
|
- Filesystem-backed resource layout under `${openclaw dir}/yonexus`
|
||||||
|
- Agent registration and multi-identity assignment
|
||||||
|
- Supervisor relationship mapping (does **not** imply permissions)
|
||||||
|
- Role-based authorization
|
||||||
|
- Query DSL: `eq | contains | regex`
|
||||||
|
- Queryable field whitelist via schema (`queryable: true`)
|
||||||
|
- Scope shared memory adapter (`org/dept/team`)
|
||||||
|
- JSON persistence for structure data
|
||||||
|
- Audit logs and structured error codes
|
||||||
|
- Import / export support
|
||||||
|
|
||||||
### Yonexus.Server
|
## Project Layout
|
||||||
Installed on the central OpenClaw instance.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- run the WebSocket server
|
|
||||||
- maintain the client registry
|
|
||||||
- handle pairing and authentication
|
|
||||||
- track heartbeat and liveness state
|
|
||||||
- relay messages to connected clients
|
|
||||||
- rewrite inbound client messages before rule dispatch
|
|
||||||
- notify a human administrator of pairing requests via Discord DM
|
|
||||||
|
|
||||||
### Yonexus.Client
|
|
||||||
Installed on follower OpenClaw instances.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- connect to `Yonexus.Server`
|
|
||||||
- manage local keypair and shared secret
|
|
||||||
- complete pairing with out-of-band pairing code
|
|
||||||
- authenticate on reconnect
|
|
||||||
- send periodic heartbeat
|
|
||||||
- send messages to server
|
|
||||||
- receive messages from server via rule dispatch
|
|
||||||
|
|
||||||
### Yonexus.Protocol
|
|
||||||
Shared protocol specification repository. Both `Yonexus.Server` and `Yonexus.Client` reference this as a submodule at `protocol/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Yonexus (umbrella)
|
|
||||||
├── Yonexus.Protocol ← shared protocol submodule
|
|
||||||
├── Yonexus.Server ← server plugin submodule
|
|
||||||
│ └── protocol/ ← points to Yonexus.Protocol
|
|
||||||
└── Yonexus.Client ← client plugin submodule
|
|
||||||
└── protocol/ ← points to Yonexus.Protocol
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
A Yonexus network contains:
|
|
||||||
- exactly one OpenClaw instance running `Yonexus.Server`
|
|
||||||
- one or more OpenClaw instances running `Yonexus.Client`
|
|
||||||
|
|
||||||
Topology rules:
|
|
||||||
- `Yonexus.Server` must be reachable via a stable address
|
|
||||||
- `Yonexus.Client` instances connect outbound to the server
|
|
||||||
- direct client-to-client sockets are not required in v1
|
|
||||||
- client-to-client communication, if needed, is relayed by the server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
Pairing is intentionally **out-of-band**.
|
|
||||||
|
|
||||||
When a new client needs pairing:
|
|
||||||
- the server generates a pairing code
|
|
||||||
- the server sends that pairing code by Discord DM to a configured human admin
|
|
||||||
- the pairing code is **not** sent over the Yonexus WebSocket connection
|
|
||||||
- the human relays the code to the client side manually
|
|
||||||
- the client submits the code back through the protocol
|
|
||||||
|
|
||||||
After pairing:
|
|
||||||
- the server issues a shared secret
|
|
||||||
- the client stores its private key and secret locally
|
|
||||||
- reconnect authentication uses signed proof derived from `secret + nonce + timestamp`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Repository Spec Files
|
|
||||||
|
|
||||||
### Umbrella (`Yonexus`)
|
|
||||||
- `PLAN.md` — project plan and architecture
|
|
||||||
- `ARCHITECTURE.md` — architecture overview and repository graph
|
|
||||||
- `FEAT.md` — implementation feature checklist
|
|
||||||
|
|
||||||
### Protocol (`Yonexus.Protocol`)
|
|
||||||
- `PROTOCOL.md` — shared communication protocol specification
|
|
||||||
|
|
||||||
### Server (`Yonexus.Server`)
|
|
||||||
- `PLAN.md` — server-specific implementation plan
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
|
|
||||||
### Client (`Yonexus.Client`)
|
|
||||||
- `PLAN.md` — client-specific implementation plan
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned TypeScript APIs
|
|
||||||
|
|
||||||
### Yonexus.Server
|
|
||||||
```ts
|
|
||||||
sendMessageToClient(identifier: string, message: string): Promise<void>
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
### Yonexus.Client
|
|
||||||
```ts
|
|
||||||
sendMessageToServer(message: string): Promise<void>
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
Message format:
|
|
||||||
```text
|
```text
|
||||||
${rule_identifier}::${message_content}
|
.
|
||||||
|
├─ plugin/
|
||||||
|
│ ├─ index.ts # wiring: init, register commands/hooks/tools
|
||||||
|
│ ├─ commands/ # slash commands
|
||||||
|
│ ├─ tools/ # query & resource tools
|
||||||
|
│ ├─ hooks/ # lifecycle hooks
|
||||||
|
│ └─ core/ # business logic, models, store, permissions
|
||||||
|
├─ skills/ # skill definitions
|
||||||
|
├─ docs/ # project documentation
|
||||||
|
├─ scripts/ # demo & utility scripts
|
||||||
|
├─ tests/ # tests
|
||||||
|
├─ install.mjs # install/uninstall script
|
||||||
|
├─ plugin.json # plugin manifest
|
||||||
|
├─ README.md
|
||||||
|
└─ README.zh.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Reserved rule: `builtin`
|
## Requirements
|
||||||
|
|
||||||
---
|
- Node.js 22+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
## Planned Server Config
|
## Quick Start
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
npm install
|
||||||
"followerIdentifiers": ["client-a", "client-b"],
|
npm run build
|
||||||
"notifyBotToken": "<discord-bot-token>",
|
npm run test:smoke
|
||||||
"adminUserId": "123456789012345678",
|
npm run demo
|
||||||
"listenHost": "0.0.0.0",
|
|
||||||
"listenPort": 8787,
|
|
||||||
"publicWsUrl": "wss://example.com/yonexus"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Planned Client Config
|
## Install / Uninstall
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
# Install (builds and copies to ~/.openclaw/plugins/yonexus)
|
||||||
"mainHost": "wss://example.com/yonexus",
|
node install.mjs --install
|
||||||
"identifier": "client-a",
|
|
||||||
"notifyBotToken": "<discord-bot-token>",
|
# Install to custom openclaw profile path
|
||||||
"adminUserId": "123456789012345678"
|
node install.mjs --install --openclaw-profile-path /path/to/.openclaw
|
||||||
}
|
|
||||||
|
# Uninstall
|
||||||
|
node install.mjs --uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Configuration
|
||||||
|
|
||||||
## Shared Terminology
|
`plugin.json` includes default config:
|
||||||
|
|
||||||
To keep the umbrella repo, protocol repo, and both plugin repos aligned, Yonexus uses these terms consistently:
|
- `name`: `yonexus`
|
||||||
|
- `entry`: `dist/yonexus/index.js`
|
||||||
|
- `config.dataFile`: `./data/org.json`
|
||||||
|
- `config.registrars`: whitelist for registrar agents
|
||||||
|
- `config.schema`: metadata field schema and queryability
|
||||||
|
|
||||||
- `identifier`: the stable logical name of a follower/client instance, unique within one Yonexus network.
|
## Implemented APIs
|
||||||
- `rule_identifier`: the exact-match application routing key used in `${rule_identifier}::${message_content}`.
|
|
||||||
- `builtin`: the reserved rule namespace for Yonexus protocol/control messages only.
|
|
||||||
- `pairingCode`: the short-lived out-of-band code generated by `Yonexus.Server` and delivered to a human admin by Discord DM.
|
|
||||||
- `secret`: the server-issued shared secret established after successful pairing and used in reconnect authentication proof construction.
|
|
||||||
- `publicKey` / `privateKey`: the client-owned signing keypair used for auth proof signing and verification.
|
|
||||||
- `nextAction`: the server decision returned in `hello_ack`, currently one of `pair_required`, `waiting_pair_confirm`, `auth_required`, or `rejected`.
|
|
||||||
|
|
||||||
## v1 Scope Boundaries
|
Core:
|
||||||
|
- `createOrganization(actor, name)`
|
||||||
|
- `createDepartment(actor, name, orgId)`
|
||||||
|
- `createTeam(actor, name, deptId)`
|
||||||
|
- `registerAgent(actor, agentId, name, roles?)`
|
||||||
|
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
|
||||||
|
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
|
||||||
|
- `whoami(agentId)`
|
||||||
|
- `queryAgents(actor, scope, query)`
|
||||||
|
|
||||||
In scope for v1:
|
Management:
|
||||||
- WebSocket transport between one server and one or more clients
|
- `renameDepartment(actor, deptId, newName)`
|
||||||
- out-of-band pairing via Discord DM to a human administrator
|
- `renameTeam(actor, teamId, newName, deptId?)`
|
||||||
- signed reconnect authentication using `secret + nonce + timestamp`
|
- `migrateTeam(actor, teamId, newDeptId)`
|
||||||
- heartbeat/liveness tracking (`online | unstable | offline`)
|
- `deleteDepartment(actor, deptId)`
|
||||||
- exact-match rule dispatch
|
- `deleteTeam(actor, teamId, deptId?)`
|
||||||
- lightweight persistence for trust/state material
|
|
||||||
- optional `heartbeat_ack`
|
|
||||||
- exponential reconnect backoff on the client side
|
|
||||||
|
|
||||||
Explicitly out of scope for v1:
|
Docs:
|
||||||
- multi-server topology
|
- `getDocs(scope, topic, keyword)`
|
||||||
- direct client-to-client sockets
|
|
||||||
- offline message queues / delivery guarantees
|
|
||||||
- advanced rule matching (prefix/regex/wildcard)
|
|
||||||
- management UI
|
|
||||||
- distributed consensus / clustering
|
|
||||||
- automatic admin approve/deny workflows beyond human relay of the pairing code
|
|
||||||
- encryption-at-rest hardening beyond documenting local sensitive storage behavior
|
|
||||||
|
|
||||||
## Status
|
Data & audit:
|
||||||
|
- `exportData(actor)`
|
||||||
|
- `importData(actor, state)`
|
||||||
|
- `listAuditLogs(limit?, offset?)`
|
||||||
|
|
||||||
- umbrella/specification repo is aligned with the split architecture
|
## Testing
|
||||||
- core implementation work is underway in `Yonexus.Server`, `Yonexus.Client`, and `Yonexus.Protocol`
|
|
||||||
- protocol/types/codec/test scaffolding already exists in `Yonexus.Protocol`
|
|
||||||
- runtime, transport, pairing, auth, heartbeat, rule dispatch, and test coverage are largely implemented in submodules; remaining work is focused on boundary cleanup and leftover failure-path coverage
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
npm run test:smoke
|
||||||
|
```
|
||||||
|
|
||||||
## Repository URLs
|
## Notes
|
||||||
|
|
||||||
- [Yonexus (umbrella)](https://git.hangman-lab.top/nav/Yonexus)
|
- Structure data is persisted in JSON, not memory_store.
|
||||||
- [Yonexus.Server](https://git.hangman-lab.top/nav/Yonexus.Server)
|
- Shared scope memory is handled via the scope memory adapter.
|
||||||
- [Yonexus.Client](https://git.hangman-lab.top/nav/Yonexus.Client)
|
- Unknown metadata fields are dropped during identity assignment.
|
||||||
- [Yonexus.Protocol](https://git.hangman-lab.top/nav/Yonexus.Protocol)
|
- `queryAgents` enforces schema queryable constraints.
|
||||||
|
|||||||
118
README.zh.md
Normal file
118
README.zh.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
[English](./README.md) | [中文](./README.zh.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Yonexus
|
||||||
|
|
||||||
|
Yonexus 是一个用于 OpenClaw 的组织结构与 Agent 身份管理插件。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 组织层级:`Organization -> Department -> Team -> Agent`
|
||||||
|
- 基于文件系统的资源目录:`${openclaw dir}/yonexus`
|
||||||
|
- Agent 注册与多身份(Identity)管理
|
||||||
|
- 上下级关系(Supervisor,**不自动赋权**)
|
||||||
|
- 基于角色的权限控制
|
||||||
|
- Query DSL:`eq | contains | regex`
|
||||||
|
- 基于 schema 的可查询字段白名单(`queryable: true`)
|
||||||
|
- scope 共享记忆适配(org/dept/team)
|
||||||
|
- 结构化数据 JSON 持久化
|
||||||
|
- 审计日志与结构化错误码
|
||||||
|
- 导入 / 导出能力
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├─ plugin/
|
||||||
|
│ ├─ index.ts # 接线层:初始化、注册命令/hooks/tools
|
||||||
|
│ ├─ commands/ # slash commands
|
||||||
|
│ ├─ tools/ # 查询与资源工具
|
||||||
|
│ ├─ hooks/ # 生命周期钩子
|
||||||
|
│ └─ core/ # 业务逻辑、模型、存储、权限
|
||||||
|
├─ skills/ # 技能定义
|
||||||
|
├─ docs/ # 项目文档
|
||||||
|
├─ scripts/ # 演示与工具脚本
|
||||||
|
├─ tests/ # 测试
|
||||||
|
├─ install.mjs # 安装/卸载脚本
|
||||||
|
├─ plugin.json # 插件清单
|
||||||
|
├─ README.md
|
||||||
|
└─ README.zh.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run test:smoke
|
||||||
|
npm run demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装 / 卸载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装(构建并复制到 ~/.openclaw/plugins/yonexus)
|
||||||
|
node install.mjs --install
|
||||||
|
|
||||||
|
# 安装到自定义 openclaw profile 路径
|
||||||
|
node install.mjs --install --openclaw-profile-path /path/to/.openclaw
|
||||||
|
|
||||||
|
# 卸载
|
||||||
|
node install.mjs --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
`plugin.json` 默认包含以下配置:
|
||||||
|
|
||||||
|
- `name`: `yonexus`
|
||||||
|
- `entry`: `dist/yonexus/index.js`
|
||||||
|
- `config.dataFile`: `./data/org.json`
|
||||||
|
- `config.registrars`: 注册人白名单
|
||||||
|
- `config.schema`: 元数据字段定义与可查询性
|
||||||
|
|
||||||
|
## 已实现 API
|
||||||
|
|
||||||
|
核心 API:
|
||||||
|
- `createOrganization(actor, name)`
|
||||||
|
- `createDepartment(actor, name, orgId)`
|
||||||
|
- `createTeam(actor, name, deptId)`
|
||||||
|
- `registerAgent(actor, agentId, name, roles?)`
|
||||||
|
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
|
||||||
|
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
|
||||||
|
- `whoami(agentId)`
|
||||||
|
- `queryAgents(actor, scope, query)`
|
||||||
|
|
||||||
|
管理 API:
|
||||||
|
- `renameDepartment(actor, deptId, newName)`
|
||||||
|
- `renameTeam(actor, teamId, newName, deptId?)`
|
||||||
|
- `migrateTeam(actor, teamId, newDeptId)`
|
||||||
|
- `deleteDepartment(actor, deptId)`
|
||||||
|
- `deleteTeam(actor, teamId, deptId?)`
|
||||||
|
|
||||||
|
文档检索:
|
||||||
|
- `getDocs(scope, topic, keyword)`
|
||||||
|
|
||||||
|
数据与审计:
|
||||||
|
- `exportData(actor)`
|
||||||
|
- `importData(actor, state)`
|
||||||
|
- `listAuditLogs(limit?, offset?)`
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 结构数据保存在 JSON 文件,不进入 memory_store。
|
||||||
|
- 共享记忆通过 scope memory 适配器处理。
|
||||||
|
- 分配 identity 时,未知 meta 字段会被丢弃。
|
||||||
|
- `queryAgents` 会严格校验字段是否在 schema 中标记为可查询。
|
||||||
1344
TASKLIST.md
1344
TASKLIST.md
File diff suppressed because it is too large
Load Diff
Submodule Yonexus.Client deleted from 8b26919790
Submodule Yonexus.Protocol deleted from 2611304084
Submodule Yonexus.Server deleted from a8748f8c55
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
54
docs/AGENT_TASKS.md
Normal file
54
docs/AGENT_TASKS.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Yonexus — AGENT_TASKS
|
||||||
|
|
||||||
|
> 目标:将插件拆解为可执行任务(按阶段/优先级)。
|
||||||
|
|
||||||
|
## Phase 0 — 基础准备(P0)
|
||||||
|
- [x] 明确插件运行环境/依赖(OpenClaw 版本、Node 版本)
|
||||||
|
- [x] 定义最终配置文件格式(schema + permissions + registrars)
|
||||||
|
- [x] 统一 ID 规则(org/dept/team/agent)
|
||||||
|
|
||||||
|
## Phase 1 — MVP 核心(P0)
|
||||||
|
### 数据与存储
|
||||||
|
- [x] 设计数据模型(Org/Dept/Team/Agent/Identity/Supervisor)
|
||||||
|
- [x] 实现 in-memory store + JSON 持久化
|
||||||
|
- [x] 定义 CRUD API
|
||||||
|
|
||||||
|
### 权限系统
|
||||||
|
- [x] 实现权限角色(Org Admin / Dept Admin / Team Lead / Agent)
|
||||||
|
- [x] 实现权限校验函数 authorize(action, actor, scope)
|
||||||
|
- [x] 实现 registrars 白名单(禁止自注册)
|
||||||
|
|
||||||
|
### 工具/API
|
||||||
|
- [x] create_department
|
||||||
|
- [x] create_team
|
||||||
|
- [x] register_agent
|
||||||
|
- [x] assign_identity
|
||||||
|
- [x] set_supervisor
|
||||||
|
- [x] whoami
|
||||||
|
- [x] query_agents
|
||||||
|
|
||||||
|
### Query DSL
|
||||||
|
- [x] filters/op 解析(eq / contains / regex)
|
||||||
|
- [x] schema queryable 字段约束
|
||||||
|
- [x] pagination(limit/offset)
|
||||||
|
|
||||||
|
### Scope Memory
|
||||||
|
- [x] scope_memory.put(scopeId, text, metadata)
|
||||||
|
- [x] scope_memory.search(scopeId, query, limit)
|
||||||
|
- [x] 兼容 memory-lancedb-pro
|
||||||
|
|
||||||
|
## Phase 2 — v1 增强(P1)
|
||||||
|
- [x] 模糊/正则性能优化(索引/缓存)
|
||||||
|
- [x] 管理命令与校验(重命名/删除/迁移)
|
||||||
|
- [x] 完善错误码与审计日志
|
||||||
|
- [x] 增加导入/导出工具
|
||||||
|
|
||||||
|
## Phase 3 — 体验与文档(P1)
|
||||||
|
- [x] README(安装/配置/示例)
|
||||||
|
- [x] 示例数据集与演示脚本
|
||||||
|
- [x] 安装脚本完善(build + copy 到 dist/yonexus)
|
||||||
|
|
||||||
|
## Risk & Notes
|
||||||
|
- 结构数据不进 memory_store(只做 scope 共享记忆)
|
||||||
|
- queryable 字段必须严格按 schema 控制
|
||||||
|
- supervisor 关系不隐含权限
|
||||||
86
docs/FEAT.md
Normal file
86
docs/FEAT.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# FEAT — Yonexus Feature List
|
||||||
|
|
||||||
|
## Existing Features
|
||||||
|
|
||||||
|
### Core Model & Storage
|
||||||
|
- Organization / Department / Team / Agent / Identity / Supervisor data model
|
||||||
|
- In-memory runtime with JSON persistence (`data/org.json`)
|
||||||
|
- Import/export of structure data
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
- Role model: `org_admin`, `dept_admin`, `team_lead`, `agent`
|
||||||
|
- `authorize(action, actor, scope)` permission check
|
||||||
|
- Registrar whitelist (`registrars`) and bootstrap registration support
|
||||||
|
|
||||||
|
### Core APIs
|
||||||
|
- `createOrganization(actor, name)`
|
||||||
|
- `createDepartment(actor, name, orgId)`
|
||||||
|
- `createTeam(actor, name, deptId)`
|
||||||
|
- `registerAgent(actor, agentId, name, roles?)`
|
||||||
|
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
|
||||||
|
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
|
||||||
|
- `whoami(agentId)`
|
||||||
|
- `queryAgents(actor, scope, query)`
|
||||||
|
|
||||||
|
### Query & Search
|
||||||
|
- Query ops: `eq`, `contains`, `regex`
|
||||||
|
- Schema `queryable` whitelist enforcement
|
||||||
|
- Pagination (`limit`, `offset`)
|
||||||
|
- Basic query performance optimization (regex cache + ordered filter eval)
|
||||||
|
|
||||||
|
### Management & Audit
|
||||||
|
- `renameDepartment`, `renameTeam`, `migrateTeam`, `deleteDepartment`, `deleteTeam`
|
||||||
|
- Structured errors via `YonexusError`
|
||||||
|
- In-memory audit log (`listAuditLogs`)
|
||||||
|
|
||||||
|
### Scope Memory
|
||||||
|
- Scope memory adapter:
|
||||||
|
- `scope_memory.put(scopeId, text, metadata)`
|
||||||
|
- `scope_memory.search(scopeId, query, limit)`
|
||||||
|
|
||||||
|
### Developer Experience
|
||||||
|
- `README.md` + `README.zh.md`
|
||||||
|
- Example data (`examples/sample-data.json`)
|
||||||
|
- Demo script (`scripts/demo.ts`)
|
||||||
|
- Smoke test (`tests/smoke.ts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Features (from NEW_FEAT)
|
||||||
|
|
||||||
|
### 1) Filesystem Resource Layout
|
||||||
|
Data-only filesystem tree under:
|
||||||
|
- `${openclaw dir}/yonexus/organizations/<org-name>/...`
|
||||||
|
|
||||||
|
Auto-create (idempotent):
|
||||||
|
- On `createOrganization`:
|
||||||
|
- `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On `createTeam`:
|
||||||
|
- `teams/<team-name>/agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On `assignIdentity`:
|
||||||
|
- `teams/<team-name>/agents/<agent-id>/docs|notes|knowledge|rules|lessons|workflows`
|
||||||
|
|
||||||
|
### 2) Document Query Tool
|
||||||
|
New API:
|
||||||
|
- `getDocs(scope, topic, keyword)`
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `scope`: `organization | department | team | agent`
|
||||||
|
- `topic`: `docs | notes | knowledge | rules | lessons | workflows`
|
||||||
|
- `keyword`: regex string
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Read-only search by filename regex under filesystem resources
|
||||||
|
- Structured output:
|
||||||
|
- `----ORG`
|
||||||
|
- `----DEPT`
|
||||||
|
- `----TEAM`
|
||||||
|
- `----AGENT`
|
||||||
|
- Invalid regex returns structured error (`YonexusError: VALIDATION_ERROR`)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `${openclaw dir}` resolution order:
|
||||||
|
1. `YonexusOptions.openclawDir`
|
||||||
|
2. `OPENCLAW_DIR` env
|
||||||
|
3. `${HOME}/.openclaw`
|
||||||
|
- Plugin code is not written into `${openclaw dir}/yonexus`; only data folders/files are used there.
|
||||||
127
docs/PLAN.md
Normal file
127
docs/PLAN.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Yonexus — Project Plan
|
||||||
|
|
||||||
|
## 1) Goal
|
||||||
|
Build an OpenClaw plugin that models organization hierarchy and agent identities, supports supervisor relationships, provides query tools for agents, and uses shared memory per scope (org/department/team).
|
||||||
|
|
||||||
|
## 2) Core Concepts
|
||||||
|
- **Hierarchy**: Organization → Department → Team → Agent
|
||||||
|
- **Supervisor**: each agent may have exactly one supervisor
|
||||||
|
- **Identity**: an agent can hold multiple identities across teams/departments
|
||||||
|
- **Schema-driven metadata**: configurable fields with per-field queryability
|
||||||
|
- **Scope memory**: shared memory for org/department/team (using `memory_store`, compatible with memory-lancedb-pro)
|
||||||
|
|
||||||
|
## 3) Storage Strategy
|
||||||
|
- **Structure & identity data**: in-memory + JSON persistence (no memory_store)
|
||||||
|
- **Shared memory**: memory_store keyed by scope (`org:{id}`, `dept:{id}`, `team:{id}`)
|
||||||
|
- **Filesystem resources** (OpenClaw install dir `${openclaw dir}`):
|
||||||
|
- Create a data-only folder at `${openclaw dir}/yonexus` (no plugin code here)
|
||||||
|
- `yonexus/organizations/<org-name>/` contains: `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On **create_organization**: create `<org-name>` folder and its subfolders
|
||||||
|
- On **create_team**: create `organizations/<org-name>/teams/<team-name>/` with `agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On **assign_identity**: create `organizations/<org-name>/teams/<team-name>/agents/<agent-id>/` with `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
|
||||||
|
## 4) Permissions Model (B)
|
||||||
|
Roles:
|
||||||
|
- Org Admin
|
||||||
|
- Dept Admin
|
||||||
|
- Team Lead
|
||||||
|
- Agent
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Supervisor is **not** a role (no inherent permissions)
|
||||||
|
- Registration **not** self-service
|
||||||
|
- only configured agent list or human via slash command
|
||||||
|
|
||||||
|
Permission matrix (recommended):
|
||||||
|
- create_department → Org Admin
|
||||||
|
- create_team → Org Admin, Dept Admin (same dept)
|
||||||
|
- assign_identity → Org Admin, Dept Admin (same dept), Team Lead (same team)
|
||||||
|
- register_agent → Org Admin, Dept Admin, Team Lead (scope-limited)
|
||||||
|
- set_supervisor → Org Admin, Dept Admin (same dept)
|
||||||
|
- query → all roles, but only schema fields with `queryable: true`
|
||||||
|
|
||||||
|
## 5) Schema Configuration (example)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"position": { "type": "string", "queryable": true },
|
||||||
|
"discord_user_id": { "type": "string", "queryable": true },
|
||||||
|
"git_user_name": { "type": "string", "queryable": true },
|
||||||
|
"department": { "type": "string", "queryable": false },
|
||||||
|
"team": { "type": "string", "queryable": false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6) Tool/API Surface (MVP)
|
||||||
|
- `create_organization(name)`
|
||||||
|
- `create_department(name, orgId)`
|
||||||
|
- `create_team(name, deptId)`
|
||||||
|
- `register_agent(agentId, name)`
|
||||||
|
- `assign_identity(agentId, deptId, teamId, meta)`
|
||||||
|
- `set_supervisor(actor, agentId, supervisorId)`
|
||||||
|
- `whoami(agentId)` → identities + supervisor + roles
|
||||||
|
- `query_agents(filters, options)` → list; supports `eq | contains | regex`
|
||||||
|
|
||||||
|
Query example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filters": [
|
||||||
|
{"field":"discord_user_id","op":"eq","value":"123"},
|
||||||
|
{"field":"git_user_name","op":"regex","value":"^hang"}
|
||||||
|
],
|
||||||
|
"options": {"limit": 20, "offset": 0}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7) Data Model (MVP)
|
||||||
|
- Organization { id, name }
|
||||||
|
- Department { id, name, orgId }
|
||||||
|
- Team { id, name, deptId }
|
||||||
|
- Agent { id, name, roles[] }
|
||||||
|
- Identity { id, agentId, deptId, teamId, meta }
|
||||||
|
- Supervisor { agentId, supervisorId }
|
||||||
|
|
||||||
|
## 8) Milestones
|
||||||
|
**Phase 0 (Design)**
|
||||||
|
- finalize schema
|
||||||
|
- confirm permission rules
|
||||||
|
|
||||||
|
**Phase 1 (MVP)**
|
||||||
|
- storage + JSON persistence
|
||||||
|
- core models + tools
|
||||||
|
- query DSL
|
||||||
|
- scope memory adapter
|
||||||
|
|
||||||
|
**Phase 2 (v1)**
|
||||||
|
- policy refinements
|
||||||
|
- better query pagination & filtering
|
||||||
|
- management commands & validation
|
||||||
|
|
||||||
|
## 9) Project Structure (recommended)
|
||||||
|
```
|
||||||
|
openclaw-plugin-yonexus/
|
||||||
|
├─ plugin.json
|
||||||
|
├─ src/
|
||||||
|
│ ├─ index.ts
|
||||||
|
│ ├─ store/ # in-memory + JSON persistence
|
||||||
|
│ ├─ models/
|
||||||
|
│ ├─ permissions/
|
||||||
|
│ ├─ tools/
|
||||||
|
│ ├─ memory/
|
||||||
|
│ └─ utils/
|
||||||
|
├─ scripts/
|
||||||
|
│ └─ install.sh
|
||||||
|
├─ dist/
|
||||||
|
│ └─ yonexus/ # build output target
|
||||||
|
└─ data/
|
||||||
|
└─ org.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10) Install Script Requirement
|
||||||
|
- Provide `scripts/install.sh`
|
||||||
|
- It should register the OpenClaw plugin name as **`yonexus`**
|
||||||
|
- Build artifacts must be placed into **`dist/yonexus`**
|
||||||
|
|
||||||
|
## 11) Notes & Decisions
|
||||||
|
- Structure data is not stored in memory_store.
|
||||||
|
- Shared memory uses memory_store (compatible with memory-lancedb-pro).
|
||||||
|
- Queryable fields are whitelisted via schema.
|
||||||
44
examples/sample-data.json
Normal file
44
examples/sample-data.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"organizations": [
|
||||||
|
{ "id": "org:yonexus", "name": "Yonexus" }
|
||||||
|
],
|
||||||
|
"departments": [
|
||||||
|
{ "id": "dept:platform", "name": "Platform", "orgId": "org:yonexus" },
|
||||||
|
{ "id": "dept:ai", "name": "AI", "orgId": "org:yonexus" }
|
||||||
|
],
|
||||||
|
"teams": [
|
||||||
|
{ "id": "team:platform-core", "name": "Core", "deptId": "dept:platform" },
|
||||||
|
{ "id": "team:ai-agent", "name": "Agent", "deptId": "dept:ai" }
|
||||||
|
],
|
||||||
|
"agents": [
|
||||||
|
{ "id": "orion", "name": "Orion", "roles": ["org_admin", "agent"] },
|
||||||
|
{ "id": "hangman", "name": "Hangman", "roles": ["agent"] }
|
||||||
|
],
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"id": "identity:orion-platform",
|
||||||
|
"agentId": "orion",
|
||||||
|
"deptId": "dept:platform",
|
||||||
|
"teamId": "team:platform-core",
|
||||||
|
"meta": {
|
||||||
|
"position": "assistant",
|
||||||
|
"discord_user_id": "1474088632750047324",
|
||||||
|
"git_user_name": "orion"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "identity:hangman-ai",
|
||||||
|
"agentId": "hangman",
|
||||||
|
"deptId": "dept:ai",
|
||||||
|
"teamId": "team:ai-agent",
|
||||||
|
"meta": {
|
||||||
|
"position": "owner",
|
||||||
|
"discord_user_id": "561921120408698910",
|
||||||
|
"git_user_name": "hangman"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supervisors": [
|
||||||
|
{ "agentId": "orion", "supervisorId": "hangman" }
|
||||||
|
]
|
||||||
|
}
|
||||||
145
install.mjs
Normal file
145
install.mjs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yonexus Plugin Installer v0.2.0
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node install.mjs --install
|
||||||
|
* node install.mjs --install --openclaw-profile-path /path/to/.openclaw
|
||||||
|
* node install.mjs --uninstall
|
||||||
|
* node install.mjs --uninstall --openclaw-profile-path /path/to/.openclaw
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs';
|
||||||
|
import { dirname, join, resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = resolve(dirname(__filename));
|
||||||
|
|
||||||
|
const PLUGIN_NAME = 'yonexus';
|
||||||
|
const SRC_DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME);
|
||||||
|
|
||||||
|
// ── Parse arguments ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const isInstall = args.includes('--install');
|
||||||
|
const isUninstall = args.includes('--uninstall');
|
||||||
|
|
||||||
|
const profileIdx = args.indexOf('--openclaw-profile-path');
|
||||||
|
let openclawProfilePath = null;
|
||||||
|
if (profileIdx !== -1 && args[profileIdx + 1]) {
|
||||||
|
openclawProfilePath = resolve(args[profileIdx + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenclawPath() {
|
||||||
|
if (openclawProfilePath) return openclawProfilePath;
|
||||||
|
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
|
||||||
|
return join(homedir(), '.openclaw');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
function log(msg, color = 'reset') { console.log(`${c[color]}${msg}${c.reset}`); }
|
||||||
|
function logOk(msg) { log(` ✓ ${msg}`, 'green'); }
|
||||||
|
function logWarn(msg) { log(` ⚠ ${msg}`, 'yellow'); }
|
||||||
|
function logErr(msg) { log(` ✗ ${msg}`, 'red'); }
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function copyDir(src, dest) {
|
||||||
|
mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = join(src, entry.name);
|
||||||
|
const d = join(dest, entry.name);
|
||||||
|
if (entry.name === 'node_modules') continue;
|
||||||
|
entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Install ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
console.log('');
|
||||||
|
log('╔══════════════════════════════════════════════╗', 'cyan');
|
||||||
|
log('║ Yonexus Plugin Installer v0.2.0 ║', 'cyan');
|
||||||
|
log('╚══════════════════════════════════════════════╝', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 1. Build
|
||||||
|
log('[1/3] Building...', 'cyan');
|
||||||
|
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
|
||||||
|
execSync('npm run build', { cwd: __dirname, stdio: 'inherit' });
|
||||||
|
|
||||||
|
if (!existsSync(SRC_DIST_DIR)) {
|
||||||
|
logErr(`Build output not found at ${SRC_DIST_DIR}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
logOk('Build complete');
|
||||||
|
|
||||||
|
// 2. Copy to plugins dir
|
||||||
|
log('[2/3] Installing...', 'cyan');
|
||||||
|
const openclawPath = resolveOpenclawPath();
|
||||||
|
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
|
||||||
|
|
||||||
|
log(` OpenClaw path: ${openclawPath}`, 'blue');
|
||||||
|
|
||||||
|
if (existsSync(destDir)) rmSync(destDir, { recursive: true, force: true });
|
||||||
|
copyDir(SRC_DIST_DIR, destDir);
|
||||||
|
logOk(`Plugin files → ${destDir}`);
|
||||||
|
|
||||||
|
// 3. Summary
|
||||||
|
log('[3/3] Done!', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
log('✓ Yonexus installed successfully!', 'green');
|
||||||
|
console.log('');
|
||||||
|
log('Next steps:', 'blue');
|
||||||
|
log(' openclaw gateway restart', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Uninstall ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function uninstall() {
|
||||||
|
console.log('');
|
||||||
|
log('Uninstalling Yonexus...', 'cyan');
|
||||||
|
|
||||||
|
const openclawPath = resolveOpenclawPath();
|
||||||
|
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
|
||||||
|
|
||||||
|
if (existsSync(destDir)) {
|
||||||
|
rmSync(destDir, { recursive: true, force: true });
|
||||||
|
logOk(`Removed ${destDir}`);
|
||||||
|
} else {
|
||||||
|
logWarn(`${destDir} not found, nothing to remove`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
log('✓ Yonexus uninstalled.', 'green');
|
||||||
|
log('\nNext: openclaw gateway restart', 'yellow');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!isInstall && !isUninstall) {
|
||||||
|
console.log('');
|
||||||
|
log('Yonexus Plugin Installer', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
log('Usage:', 'blue');
|
||||||
|
log(' node install.mjs --install Install plugin', 'reset');
|
||||||
|
log(' node install.mjs --install --openclaw-profile-path <path> Install to custom path', 'reset');
|
||||||
|
log(' node install.mjs --uninstall Uninstall plugin', 'reset');
|
||||||
|
log(' node install.mjs --uninstall --openclaw-profile-path <path> Uninstall from custom path', 'reset');
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstall) install();
|
||||||
|
if (isUninstall) uninstall();
|
||||||
591
package-lock.json
generated
Normal file
591
package-lock.json
generated
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.3",
|
||||||
|
"@esbuild/android-arm": "0.27.3",
|
||||||
|
"@esbuild/android-arm64": "0.27.3",
|
||||||
|
"@esbuild/android-x64": "0.27.3",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.3",
|
||||||
|
"@esbuild/darwin-x64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.3",
|
||||||
|
"@esbuild/linux-arm": "0.27.3",
|
||||||
|
"@esbuild/linux-arm64": "0.27.3",
|
||||||
|
"@esbuild/linux-ia32": "0.27.3",
|
||||||
|
"@esbuild/linux-loong64": "0.27.3",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.3",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.3",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.3",
|
||||||
|
"@esbuild/linux-s390x": "0.27.3",
|
||||||
|
"@esbuild/linux-x64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.3",
|
||||||
|
"@esbuild/sunos-x64": "0.27.3",
|
||||||
|
"@esbuild/win32-arm64": "0.27.3",
|
||||||
|
"@esbuild/win32-ia32": "0.27.3",
|
||||||
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||||
|
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "Yonexus OpenClaw plugin: hierarchy, identities, permissions, and scoped memory",
|
||||||
|
"main": "dist/yonexus/index.js",
|
||||||
|
"types": "dist/yonexus/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json && cp plugin.json dist/yonexus/plugin.json",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"prepare": "npm run clean && npm run build",
|
||||||
|
"test:smoke": "tsx tests/smoke.ts",
|
||||||
|
"demo": "tsx scripts/demo.ts"
|
||||||
|
},
|
||||||
|
"keywords": ["openclaw", "plugin", "organization", "agents"],
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"tsx": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Yonexus.Client",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Yonexus client plugin for cross-instance OpenClaw communication",
|
|
||||||
"entry": "dist/client/index.js",
|
|
||||||
"permissions": [],
|
|
||||||
"config": {
|
|
||||||
"mainHost": "",
|
|
||||||
"identifier": "",
|
|
||||||
"notifyBotToken": "",
|
|
||||||
"adminUserId": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
plugin.json
Normal file
21
plugin.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "yonexus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"entry": "dist/yonexus/index.js",
|
||||||
|
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
|
||||||
|
"permissions": [
|
||||||
|
"memory_store",
|
||||||
|
"memory_recall"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"dataFile": "./data/org.json",
|
||||||
|
"registrars": [],
|
||||||
|
"schema": {
|
||||||
|
"position": { "type": "string", "queryable": true },
|
||||||
|
"discord_user_id": { "type": "string", "queryable": true },
|
||||||
|
"git_user_name": { "type": "string", "queryable": true },
|
||||||
|
"department": { "type": "string", "queryable": false },
|
||||||
|
"team": { "type": "string", "queryable": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Yonexus.Server",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Yonexus central server plugin for cross-instance OpenClaw communication",
|
|
||||||
"entry": "dist/server/index.js",
|
|
||||||
"permissions": [],
|
|
||||||
"config": {
|
|
||||||
"followerIdentifiers": [],
|
|
||||||
"notifyBotToken": "",
|
|
||||||
"adminUserId": "",
|
|
||||||
"listenHost": "0.0.0.0",
|
|
||||||
"listenPort": 8787,
|
|
||||||
"publicWsUrl": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
plugin/commands/.gitkeep
Normal file
0
plugin/commands/.gitkeep
Normal file
18
plugin/core/config/defaults.ts
Normal file
18
plugin/core/config/defaults.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { StoreState, YonexusSchema } from "../models/types";
|
||||||
|
|
||||||
|
export const DEFAULT_STATE: StoreState = {
|
||||||
|
organizations: [],
|
||||||
|
departments: [],
|
||||||
|
teams: [],
|
||||||
|
agents: [],
|
||||||
|
identities: [],
|
||||||
|
supervisors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEMA: YonexusSchema = {
|
||||||
|
position: { type: "string", queryable: true },
|
||||||
|
discord_user_id: { type: "string", queryable: true },
|
||||||
|
git_user_name: { type: "string", queryable: true },
|
||||||
|
department: { type: "string", queryable: false },
|
||||||
|
team: { type: "string", queryable: false }
|
||||||
|
};
|
||||||
16
plugin/core/memory/scopeMemory.ts
Normal file
16
plugin/core/memory/scopeMemory.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface MemoryPort {
|
||||||
|
store(input: { text: string; scope: string; metadata?: Record<string, string> }): Promise<unknown>;
|
||||||
|
recall(input: { query: string; scope: string; limit?: number }): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScopeMemory {
|
||||||
|
constructor(private readonly memory: MemoryPort) {}
|
||||||
|
|
||||||
|
async put(scopeId: string, text: string, metadata?: Record<string, string>): Promise<unknown> {
|
||||||
|
return this.memory.store({ text, scope: scopeId, metadata });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(scopeId: string, query: string, limit = 5): Promise<unknown> {
|
||||||
|
return this.memory.recall({ query, scope: scopeId, limit });
|
||||||
|
}
|
||||||
|
}
|
||||||
10
plugin/core/models/audit.ts
Normal file
10
plugin/core/models/audit.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
ts: string;
|
||||||
|
actorId: string;
|
||||||
|
action: string;
|
||||||
|
target?: string;
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
message?: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
19
plugin/core/models/errors.ts
Normal file
19
plugin/core/models/errors.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type ErrorCode =
|
||||||
|
| 'PERMISSION_DENIED'
|
||||||
|
| 'NOT_FOUND'
|
||||||
|
| 'ALREADY_EXISTS'
|
||||||
|
| 'VALIDATION_ERROR'
|
||||||
|
| 'FIELD_NOT_QUERYABLE'
|
||||||
|
| 'INVALID_SUPERVISOR'
|
||||||
|
| 'REGISTRAR_DENIED';
|
||||||
|
|
||||||
|
export class YonexusError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'YonexusError';
|
||||||
|
}
|
||||||
|
}
|
||||||
93
plugin/core/models/types.ts
Normal file
93
plugin/core/models/types.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
export type Role = "org_admin" | "dept_admin" | "team_lead" | "agent";
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
deptId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Identity {
|
||||||
|
id: string;
|
||||||
|
agentId: string;
|
||||||
|
deptId: string;
|
||||||
|
teamId: string;
|
||||||
|
meta: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Supervisor {
|
||||||
|
agentId: string;
|
||||||
|
supervisorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaField {
|
||||||
|
type: "string";
|
||||||
|
queryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YonexusSchema {
|
||||||
|
[field: string]: SchemaField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreState {
|
||||||
|
organizations: Organization[];
|
||||||
|
departments: Department[];
|
||||||
|
teams: Team[];
|
||||||
|
agents: Agent[];
|
||||||
|
identities: Identity[];
|
||||||
|
supervisors: Supervisor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Actor {
|
||||||
|
agentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| "create_organization"
|
||||||
|
| "create_department"
|
||||||
|
| "create_team"
|
||||||
|
| "register_agent"
|
||||||
|
| "assign_identity"
|
||||||
|
| "set_supervisor"
|
||||||
|
| "query_agents";
|
||||||
|
|
||||||
|
export type DocsScope = "organization" | "department" | "team" | "agent";
|
||||||
|
export type DocsTopic = "docs" | "notes" | "knowledge" | "rules" | "lessons" | "workflows";
|
||||||
|
|
||||||
|
export interface Scope {
|
||||||
|
orgId?: string;
|
||||||
|
deptId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryFilter {
|
||||||
|
field: string;
|
||||||
|
op: "eq" | "contains" | "regex";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
filters: QueryFilter[];
|
||||||
|
options?: QueryOptions;
|
||||||
|
}
|
||||||
40
plugin/core/permissions/authorize.ts
Normal file
40
plugin/core/permissions/authorize.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { YonexusError } from '../models/errors';
|
||||||
|
import type { Action, Actor, Scope } from "../models/types";
|
||||||
|
import { JsonStore } from "../store/jsonStore";
|
||||||
|
|
||||||
|
function hasRole(store: JsonStore, actor: Actor, role: string): boolean {
|
||||||
|
const me = store.findAgent(actor.agentId);
|
||||||
|
return Boolean(me?.roles.includes(role as never));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inDeptScope(scope: Scope): boolean {
|
||||||
|
return Boolean(scope.deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inTeamScope(scope: Scope): boolean {
|
||||||
|
return Boolean(scope.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorize(action: Action, actor: Actor, scope: Scope, store: JsonStore): void {
|
||||||
|
const orgAdmin = hasRole(store, actor, "org_admin");
|
||||||
|
const deptAdmin = hasRole(store, actor, "dept_admin") && inDeptScope(scope);
|
||||||
|
const teamLead = hasRole(store, actor, "team_lead") && inTeamScope(scope);
|
||||||
|
const agent = hasRole(store, actor, "agent");
|
||||||
|
|
||||||
|
const allowed =
|
||||||
|
(action === "create_organization" && orgAdmin) ||
|
||||||
|
(action === "create_department" && orgAdmin) ||
|
||||||
|
(action === "create_team" && (orgAdmin || deptAdmin)) ||
|
||||||
|
(action === "assign_identity" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||||
|
(action === "register_agent" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||||
|
(action === "set_supervisor" && (orgAdmin || deptAdmin)) ||
|
||||||
|
(action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent));
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
throw new YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, {
|
||||||
|
action,
|
||||||
|
actorId: actor.agentId,
|
||||||
|
scope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
19
plugin/core/store/auditStore.ts
Normal file
19
plugin/core/store/auditStore.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AuditLogEntry } from '../models/audit';
|
||||||
|
|
||||||
|
const MAX_AUDIT = 1000;
|
||||||
|
|
||||||
|
export class AuditStore {
|
||||||
|
private logs: AuditLogEntry[] = [];
|
||||||
|
|
||||||
|
append(entry: AuditLogEntry): AuditLogEntry {
|
||||||
|
this.logs.push(entry);
|
||||||
|
if (this.logs.length > MAX_AUDIT) this.logs.shift();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(limit = 100, offset = 0): AuditLogEntry[] {
|
||||||
|
const safeLimit = Math.min(Math.max(1, limit), 500);
|
||||||
|
const safeOffset = Math.max(0, offset);
|
||||||
|
return this.logs.slice(safeOffset, safeOffset + safeLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
plugin/core/store/jsonStore.ts
Normal file
156
plugin/core/store/jsonStore.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { DEFAULT_STATE } from "../config/defaults";
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
Department,
|
||||||
|
Identity,
|
||||||
|
Organization,
|
||||||
|
StoreState,
|
||||||
|
Supervisor,
|
||||||
|
Team
|
||||||
|
} from "../models/types";
|
||||||
|
import { readJsonFile, writeJsonFile } from "../utils/fs";
|
||||||
|
|
||||||
|
export class JsonStore {
|
||||||
|
private state: StoreState;
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
this.state = readJsonFile<StoreState>(filePath, DEFAULT_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
writeJsonFile(this.filePath, this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): StoreState {
|
||||||
|
return JSON.parse(JSON.stringify(this.state)) as StoreState;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(state: StoreState): void {
|
||||||
|
this.state = JSON.parse(JSON.stringify(state)) as StoreState;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrganization(org: Organization): Organization {
|
||||||
|
this.state.organizations.push(org);
|
||||||
|
this.save();
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDepartment(dept: Department): Department {
|
||||||
|
this.state.departments.push(dept);
|
||||||
|
this.save();
|
||||||
|
return dept;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameDepartment(deptId: string, name: string): Department | undefined {
|
||||||
|
const dept = this.findDepartment(deptId);
|
||||||
|
if (!dept) return undefined;
|
||||||
|
dept.name = name;
|
||||||
|
this.save();
|
||||||
|
return dept;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDepartment(deptId: string): boolean {
|
||||||
|
const before = this.state.departments.length;
|
||||||
|
this.state.departments = this.state.departments.filter((d) => d.id !== deptId);
|
||||||
|
this.state.teams = this.state.teams.filter((t) => t.deptId !== deptId);
|
||||||
|
this.state.identities = this.state.identities.filter((i) => i.deptId !== deptId);
|
||||||
|
const changed = this.state.departments.length !== before;
|
||||||
|
if (changed) this.save();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
addTeam(team: Team): Team {
|
||||||
|
this.state.teams.push(team);
|
||||||
|
this.save();
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameTeam(teamId: string, name: string): Team | undefined {
|
||||||
|
const team = this.findTeam(teamId);
|
||||||
|
if (!team) return undefined;
|
||||||
|
team.name = name;
|
||||||
|
this.save();
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateTeam(teamId: string, newDeptId: string): Team | undefined {
|
||||||
|
const team = this.findTeam(teamId);
|
||||||
|
if (!team) return undefined;
|
||||||
|
team.deptId = newDeptId;
|
||||||
|
for (const identity of this.state.identities) {
|
||||||
|
if (identity.teamId === teamId) identity.deptId = newDeptId;
|
||||||
|
}
|
||||||
|
this.save();
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTeam(teamId: string): boolean {
|
||||||
|
const before = this.state.teams.length;
|
||||||
|
this.state.teams = this.state.teams.filter((t) => t.id !== teamId);
|
||||||
|
this.state.identities = this.state.identities.filter((i) => i.teamId !== teamId);
|
||||||
|
const changed = before !== this.state.teams.length;
|
||||||
|
if (changed) this.save();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
addAgent(agent: Agent): Agent {
|
||||||
|
this.state.agents.push(agent);
|
||||||
|
this.save();
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
addIdentity(identity: Identity): Identity {
|
||||||
|
this.state.identities.push(identity);
|
||||||
|
this.save();
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertSupervisor(rel: Supervisor): Supervisor {
|
||||||
|
const idx = this.state.supervisors.findIndex((x) => x.agentId === rel.agentId);
|
||||||
|
if (idx >= 0) this.state.supervisors[idx] = rel;
|
||||||
|
else this.state.supervisors.push(rel);
|
||||||
|
this.save();
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
findAgent(agentId: string): Agent | undefined {
|
||||||
|
return this.state.agents.find((a) => a.id === agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findOrganization(orgId: string): Organization | undefined {
|
||||||
|
return this.state.organizations.find((o) => o.id === orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listOrganizations(): Organization[] {
|
||||||
|
return this.state.organizations;
|
||||||
|
}
|
||||||
|
|
||||||
|
findDepartment(deptId: string): Department | undefined {
|
||||||
|
return this.state.departments.find((d) => d.id === deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listDepartments(): Department[] {
|
||||||
|
return this.state.departments;
|
||||||
|
}
|
||||||
|
|
||||||
|
findTeam(teamId: string): Team | undefined {
|
||||||
|
return this.state.teams.find((t) => t.id === teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listTeams(): Team[] {
|
||||||
|
return this.state.teams;
|
||||||
|
}
|
||||||
|
|
||||||
|
listAgents(): Agent[] {
|
||||||
|
return this.state.agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
listIdentities(): Identity[] {
|
||||||
|
return this.state.identities;
|
||||||
|
}
|
||||||
|
|
||||||
|
findSupervisor(agentId: string): Supervisor | undefined {
|
||||||
|
return this.state.supervisors.find((s) => s.agentId === agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
plugin/core/utils/fs.ts
Normal file
18
plugin/core/utils/fs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function ensureDirForFile(filePath: string): void {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsonFile<T>(filePath: string, fallback: T): T {
|
||||||
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJsonFile(filePath: string, data: unknown): void {
|
||||||
|
ensureDirForFile(filePath);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||||
|
}
|
||||||
10
plugin/core/utils/id.ts
Normal file
10
plugin/core/utils/id.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const SAFE = /[^a-z0-9]+/g;
|
||||||
|
|
||||||
|
export function slug(input: string): string {
|
||||||
|
return input.trim().toLowerCase().replace(SAFE, "-").replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeId(prefix: string, input: string): string {
|
||||||
|
const s = slug(input);
|
||||||
|
return `${prefix}:${s || "unknown"}`;
|
||||||
|
}
|
||||||
245
plugin/core/yonexus.ts
Normal file
245
plugin/core/yonexus.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { DEFAULT_SCHEMA } from "./config/defaults";
|
||||||
|
import type { AuditLogEntry } from "./models/audit";
|
||||||
|
import { YonexusError } from "./models/errors";
|
||||||
|
import type {
|
||||||
|
Actor,
|
||||||
|
Agent,
|
||||||
|
DocsScope,
|
||||||
|
DocsTopic,
|
||||||
|
Identity,
|
||||||
|
QueryInput,
|
||||||
|
Scope,
|
||||||
|
StoreState,
|
||||||
|
YonexusSchema
|
||||||
|
} from "./models/types";
|
||||||
|
import { authorize } from "./permissions/authorize";
|
||||||
|
import { AuditStore } from "./store/auditStore";
|
||||||
|
import { JsonStore } from "./store/jsonStore";
|
||||||
|
import { queryIdentities } from "../tools/query";
|
||||||
|
import { ResourceLayout } from "../tools/resources";
|
||||||
|
import { makeId } from "./utils/id";
|
||||||
|
|
||||||
|
export interface YonexusOptions {
|
||||||
|
dataFile?: string;
|
||||||
|
schema?: YonexusSchema;
|
||||||
|
registrars?: string[];
|
||||||
|
openclawDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Yonexus {
|
||||||
|
private readonly schema: YonexusSchema;
|
||||||
|
private readonly registrars: Set<string>;
|
||||||
|
private readonly store: JsonStore;
|
||||||
|
private readonly audit = new AuditStore();
|
||||||
|
private readonly resources: ResourceLayout;
|
||||||
|
|
||||||
|
constructor(options: YonexusOptions = {}) {
|
||||||
|
const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json");
|
||||||
|
this.store = new JsonStore(dataFile);
|
||||||
|
this.schema = options.schema ?? DEFAULT_SCHEMA;
|
||||||
|
this.registrars = new Set(options.registrars ?? []);
|
||||||
|
|
||||||
|
const openclawDir =
|
||||||
|
options.openclawDir ??
|
||||||
|
process.env.OPENCLAW_DIR ??
|
||||||
|
path.resolve(process.env.HOME ?? process.cwd(), ".openclaw");
|
||||||
|
this.resources = new ResourceLayout(path.join(openclawDir, "yonexus"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(entry: Omit<AuditLogEntry, "id" | "ts">): void {
|
||||||
|
this.audit.append({
|
||||||
|
id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`),
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
...entry
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createOrganization(actor: Actor, name: string) {
|
||||||
|
authorize("create_organization", actor, {}, this.store);
|
||||||
|
const orgId = makeId("org", name);
|
||||||
|
if (this.store.findOrganization(orgId)) {
|
||||||
|
throw new YonexusError("ALREADY_EXISTS", `organization_exists: ${orgId}`);
|
||||||
|
}
|
||||||
|
const org = this.store.addOrganization({ id: orgId, name });
|
||||||
|
this.resources.ensureOrganization(name);
|
||||||
|
this.log({ actorId: actor.agentId, action: "create_organization", target: org.id, status: "ok" });
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDepartment(actor: Actor, name: string, orgId: string) {
|
||||||
|
try {
|
||||||
|
authorize("create_department", actor, { orgId }, this.store);
|
||||||
|
if (!this.store.findOrganization(orgId)) {
|
||||||
|
throw new YonexusError("NOT_FOUND", `organization_not_found: ${orgId}`, { orgId });
|
||||||
|
}
|
||||||
|
const dept = { id: makeId("dept", name), name, orgId };
|
||||||
|
const result = this.store.addDepartment(dept);
|
||||||
|
this.log({ actorId: actor.agentId, action: "create_department", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.log({
|
||||||
|
actorId: actor.agentId,
|
||||||
|
action: "create_department",
|
||||||
|
target: name,
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createTeam(actor: Actor, name: string, deptId: string) {
|
||||||
|
authorize("create_team", actor, { deptId }, this.store);
|
||||||
|
const dept = this.store.findDepartment(deptId);
|
||||||
|
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId });
|
||||||
|
|
||||||
|
const org = this.store.findOrganization(dept.orgId);
|
||||||
|
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
|
||||||
|
|
||||||
|
const team = { id: makeId("team", `${deptId}-${name}`), name, deptId };
|
||||||
|
const result = this.store.addTeam(team);
|
||||||
|
this.resources.ensureTeam(org.name, name);
|
||||||
|
this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) {
|
||||||
|
if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) {
|
||||||
|
throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBootstrap = this.store.listAgents().length === 0 && actor.agentId === agentId;
|
||||||
|
if (!isBootstrap) authorize("register_agent", actor, {}, this.store);
|
||||||
|
|
||||||
|
if (this.store.findAgent(agentId)) {
|
||||||
|
throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId });
|
||||||
|
}
|
||||||
|
const result = this.store.addAgent({ id: agentId, name, roles });
|
||||||
|
this.log({ actorId: actor.agentId, action: "register_agent", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record<string, string>): Identity {
|
||||||
|
authorize("assign_identity", actor, { deptId, teamId }, this.store);
|
||||||
|
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||||
|
|
||||||
|
const dept = this.store.findDepartment(deptId);
|
||||||
|
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||||
|
|
||||||
|
const team = this.store.findTeam(teamId);
|
||||||
|
if (!team) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
|
||||||
|
const org = this.store.findOrganization(dept.orgId);
|
||||||
|
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
|
||||||
|
|
||||||
|
const validatedMeta: Record<string, string> = {};
|
||||||
|
for (const [field, value] of Object.entries(meta)) {
|
||||||
|
if (!this.schema[field]) continue;
|
||||||
|
validatedMeta[field] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.store.addIdentity({
|
||||||
|
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
|
||||||
|
agentId,
|
||||||
|
deptId,
|
||||||
|
teamId,
|
||||||
|
meta: validatedMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resources.ensureAgent(org.name, team.name, agentId);
|
||||||
|
this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) {
|
||||||
|
authorize("set_supervisor", actor, { deptId }, this.store);
|
||||||
|
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||||
|
if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`);
|
||||||
|
if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference");
|
||||||
|
const result = this.store.upsertSupervisor({ agentId, supervisorId });
|
||||||
|
this.log({ actorId: actor.agentId, action: "set_supervisor", target: `${agentId}->${supervisorId}`, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
whoami(agentId: string) {
|
||||||
|
const agent = this.store.findAgent(agentId);
|
||||||
|
if (!agent) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||||
|
|
||||||
|
const identities = this.store.listIdentities().filter((x) => x.agentId === agentId);
|
||||||
|
const supervisor = this.store.findSupervisor(agentId);
|
||||||
|
return { agent, identities, supervisor };
|
||||||
|
}
|
||||||
|
|
||||||
|
queryAgents(actor: Actor, scope: Scope, query: QueryInput) {
|
||||||
|
authorize("query_agents", actor, scope, this.store);
|
||||||
|
const identities = this.store.listIdentities();
|
||||||
|
return queryIdentities(identities, query, this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string {
|
||||||
|
return this.resources.getDocs(scope, topic, keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
renameDepartment(actor: Actor, deptId: string, newName: string) {
|
||||||
|
authorize("create_department", actor, {}, this.store);
|
||||||
|
const updated = this.store.renameDepartment(deptId, newName);
|
||||||
|
if (!updated) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "rename_department", target: deptId, status: "ok" });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameTeam(actor: Actor, teamId: string, newName: string, deptId?: string) {
|
||||||
|
authorize("create_team", actor, { deptId }, this.store);
|
||||||
|
const updated = this.store.renameTeam(teamId, newName);
|
||||||
|
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "rename_team", target: teamId, status: "ok" });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateTeam(actor: Actor, teamId: string, newDeptId: string) {
|
||||||
|
authorize("create_team", actor, { deptId: newDeptId }, this.store);
|
||||||
|
if (!this.store.findDepartment(newDeptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${newDeptId}`);
|
||||||
|
const updated = this.store.migrateTeam(teamId, newDeptId);
|
||||||
|
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "migrate_team", target: `${teamId}->${newDeptId}`, status: "ok" });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDepartment(actor: Actor, deptId: string) {
|
||||||
|
authorize("create_department", actor, {}, this.store);
|
||||||
|
const ok = this.store.deleteDepartment(deptId);
|
||||||
|
if (!ok) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "delete_department", target: deptId, status: "ok" });
|
||||||
|
return { ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTeam(actor: Actor, teamId: string, deptId?: string) {
|
||||||
|
authorize("create_team", actor, { deptId }, this.store);
|
||||||
|
const ok = this.store.deleteTeam(teamId);
|
||||||
|
if (!ok) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "delete_team", target: teamId, status: "ok" });
|
||||||
|
return { ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData(actor: Actor): StoreState {
|
||||||
|
authorize("query_agents", actor, {}, this.store);
|
||||||
|
this.log({ actorId: actor.agentId, action: "export_data", status: "ok" });
|
||||||
|
return this.store.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
importData(actor: Actor, state: StoreState): { ok: true } {
|
||||||
|
authorize("create_department", actor, {}, this.store);
|
||||||
|
this.store.replace(state);
|
||||||
|
this.log({ actorId: actor.agentId, action: "import_data", status: "ok" });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
listAuditLogs(limit = 100, offset = 0): AuditLogEntry[] {
|
||||||
|
return this.audit.list(limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugSnapshot(): StoreState {
|
||||||
|
return this.store.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
0
plugin/hooks/.gitkeep
Normal file
0
plugin/hooks/.gitkeep
Normal file
45
plugin/index.ts
Normal file
45
plugin/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Yonexus Plugin Entry
|
||||||
|
*
|
||||||
|
* This file is the wiring layer: it initializes and re-exports the core
|
||||||
|
* Yonexus class along with models, tools, and memory adapters.
|
||||||
|
* No business logic lives here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Core ────────────────────────────────────────────────────────────────
|
||||||
|
export { Yonexus, type YonexusOptions } from "./core/yonexus";
|
||||||
|
|
||||||
|
// ── Models ──────────────────────────────────────────────────────────────
|
||||||
|
export { YonexusError } from "./core/models/errors";
|
||||||
|
export type { ErrorCode } from "./core/models/errors";
|
||||||
|
export type { AuditLogEntry } from "./core/models/audit";
|
||||||
|
export type {
|
||||||
|
Action,
|
||||||
|
Actor,
|
||||||
|
Agent,
|
||||||
|
Department,
|
||||||
|
DocsScope,
|
||||||
|
DocsTopic,
|
||||||
|
Identity,
|
||||||
|
Organization,
|
||||||
|
QueryFilter,
|
||||||
|
QueryInput,
|
||||||
|
QueryOptions,
|
||||||
|
Role,
|
||||||
|
SchemaField,
|
||||||
|
Scope,
|
||||||
|
StoreState,
|
||||||
|
Supervisor,
|
||||||
|
Team,
|
||||||
|
YonexusSchema,
|
||||||
|
} from "./core/models/types";
|
||||||
|
|
||||||
|
// ── Tools ───────────────────────────────────────────────────────────────
|
||||||
|
export { queryIdentities } from "./tools/query";
|
||||||
|
export { ResourceLayout } from "./tools/resources";
|
||||||
|
|
||||||
|
// ── Memory ──────────────────────────────────────────────────────────────
|
||||||
|
export { ScopeMemory, type MemoryPort } from "./core/memory/scopeMemory";
|
||||||
|
|
||||||
|
// ── Default export ──────────────────────────────────────────────────────
|
||||||
|
export { Yonexus as default } from "./core/yonexus";
|
||||||
75
plugin/tools/query.ts
Normal file
75
plugin/tools/query.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { YonexusError } from '../core/models/errors';
|
||||||
|
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../core/models/types";
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 20;
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
const regexCache = new Map<string, RegExp>();
|
||||||
|
const containsCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function getRegex(pattern: string): RegExp {
|
||||||
|
const cached = regexCache.get(pattern);
|
||||||
|
if (cached) return cached;
|
||||||
|
const created = new RegExp(pattern);
|
||||||
|
regexCache.set(pattern, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNeedle(value: string): string {
|
||||||
|
const cached = containsCache.get(value);
|
||||||
|
if (cached) return cached;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
containsCache.set(value, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQueryable(field: string, schema: YonexusSchema): boolean {
|
||||||
|
return Boolean(schema[field]?.queryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchFilter(identity: Identity, filter: QueryFilter): boolean {
|
||||||
|
const raw = identity.meta[filter.field] ?? "";
|
||||||
|
|
||||||
|
switch (filter.op) {
|
||||||
|
case "eq":
|
||||||
|
return raw === filter.value;
|
||||||
|
case "contains":
|
||||||
|
return raw.toLowerCase().includes(normalizeNeedle(filter.value));
|
||||||
|
case "regex": {
|
||||||
|
const re = getRegex(filter.value);
|
||||||
|
return re.test(raw);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(options?: QueryOptions): Required<QueryOptions> {
|
||||||
|
const limit = Math.min(Math.max(1, options?.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
||||||
|
const offset = Math.max(0, options?.offset ?? 0);
|
||||||
|
return { limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFilters(filters: QueryFilter[]): QueryFilter[] {
|
||||||
|
const weight = (f: QueryFilter): number => {
|
||||||
|
if (f.op === 'eq') return 1;
|
||||||
|
if (f.op === 'contains') return 2;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
return [...filters].sort((a, b) => weight(a) - weight(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] {
|
||||||
|
for (const filter of input.filters) {
|
||||||
|
if (!isQueryable(filter.field, schema)) {
|
||||||
|
throw new YonexusError('FIELD_NOT_QUERYABLE', `field_not_queryable: ${filter.field}`, {
|
||||||
|
field: filter.field
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedFilters = sortFilters(input.filters);
|
||||||
|
const filtered = identities.filter((identity) => orderedFilters.every((f) => matchFilter(identity, f)));
|
||||||
|
const { limit, offset } = normalizeOptions(input.options);
|
||||||
|
return filtered.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
111
plugin/tools/resources.ts
Normal file
111
plugin/tools/resources.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { YonexusError } from '../core/models/errors';
|
||||||
|
import type { DocsScope, DocsTopic } from '../core/models/types';
|
||||||
|
import { slug } from '../core/utils/id';
|
||||||
|
|
||||||
|
const TOPICS: DocsTopic[] = ['docs', 'notes', 'knowledge', 'rules', 'lessons', 'workflows'];
|
||||||
|
|
||||||
|
function ensureDirs(base: string, dirs: string[]): void {
|
||||||
|
for (const d of dirs) fs.mkdirSync(path.join(base, d), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResourceLayout {
|
||||||
|
constructor(private readonly rootDir: string) {}
|
||||||
|
|
||||||
|
get organizationsRoot(): string {
|
||||||
|
return path.join(this.rootDir, 'organizations');
|
||||||
|
}
|
||||||
|
|
||||||
|
orgPath(orgName: string): string {
|
||||||
|
return path.join(this.organizationsRoot, slug(orgName));
|
||||||
|
}
|
||||||
|
|
||||||
|
teamPath(orgName: string, teamName: string): string {
|
||||||
|
return path.join(this.orgPath(orgName), 'teams', slug(teamName));
|
||||||
|
}
|
||||||
|
|
||||||
|
agentPath(orgName: string, teamName: string, agentId: string): string {
|
||||||
|
return path.join(this.teamPath(orgName, teamName), 'agents', slug(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureOrganization(orgName: string): void {
|
||||||
|
const root = this.orgPath(orgName);
|
||||||
|
ensureDirs(root, ['teams', ...TOPICS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTeam(orgName: string, teamName: string): void {
|
||||||
|
const root = this.teamPath(orgName, teamName);
|
||||||
|
ensureDirs(root, ['agents', ...TOPICS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAgent(orgName: string, teamName: string, agentId: string): void {
|
||||||
|
const root = this.agentPath(orgName, teamName, agentId);
|
||||||
|
ensureDirs(root, TOPICS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readTopicFiles(topicRoot: string, keyword: string): string[] {
|
||||||
|
if (!fs.existsSync(topicRoot)) return [];
|
||||||
|
let re: RegExp;
|
||||||
|
try {
|
||||||
|
re = new RegExp(keyword);
|
||||||
|
} catch {
|
||||||
|
throw new YonexusError('VALIDATION_ERROR', 'invalid_regex', { keyword });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(topicRoot, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.isFile() && re.test(e.name))
|
||||||
|
.map((e) => path.join(topicRoot, e.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string {
|
||||||
|
const groups: Record<'ORG' | 'DEPT' | 'TEAM' | 'AGENT', string[]> = {
|
||||||
|
ORG: [],
|
||||||
|
DEPT: [],
|
||||||
|
TEAM: [],
|
||||||
|
AGENT: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgsRoot = this.organizationsRoot;
|
||||||
|
if (!fs.existsSync(orgsRoot)) {
|
||||||
|
return '----ORG\n----DEPT\n----TEAM\n----AGENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgs = fs.readdirSync(orgsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
||||||
|
|
||||||
|
for (const orgName of orgs) {
|
||||||
|
const orgPath = path.join(orgsRoot, orgName);
|
||||||
|
if (scope === 'organization') {
|
||||||
|
groups.ORG.push(...this.readTopicFiles(path.join(orgPath, topic), keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamsRoot = path.join(orgPath, 'teams');
|
||||||
|
if (!fs.existsSync(teamsRoot)) continue;
|
||||||
|
|
||||||
|
const teams = fs.readdirSync(teamsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
||||||
|
for (const teamName of teams) {
|
||||||
|
const teamPath = path.join(teamsRoot, teamName);
|
||||||
|
if (scope === 'team') {
|
||||||
|
groups.TEAM.push(...this.readTopicFiles(path.join(teamPath, topic), keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentsRoot = path.join(teamPath, 'agents');
|
||||||
|
if (!fs.existsSync(agentsRoot)) continue;
|
||||||
|
|
||||||
|
const agents = fs.readdirSync(agentsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
||||||
|
for (const agentId of agents) {
|
||||||
|
if (scope === 'agent') {
|
||||||
|
groups.AGENT.push(...this.readTopicFiles(path.join(agentsRoot, agentId, topic), keyword));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// department folders are not defined in this layout; reserved empty group for compatible output.
|
||||||
|
const printGroup = (k: 'ORG' | 'DEPT' | 'TEAM' | 'AGENT'): string =>
|
||||||
|
[`----${k}`, ...groups[k]].join('\n');
|
||||||
|
|
||||||
|
return [printGroup('ORG'), printGroup('DEPT'), printGroup('TEAM'), printGroup('AGENT')].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
scripts/demo.ts
Normal file
34
scripts/demo.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { Yonexus } from '../plugin/index';
|
||||||
|
|
||||||
|
const dataFile = path.resolve(process.cwd(), 'data/demo-org.json');
|
||||||
|
if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile);
|
||||||
|
|
||||||
|
const yx = new Yonexus({ dataFile, registrars: ['orion'] });
|
||||||
|
|
||||||
|
yx.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']);
|
||||||
|
yx.registerAgent({ agentId: 'orion' }, 'hangman', 'Hangman', ['agent']);
|
||||||
|
|
||||||
|
const org = yx.createOrganization({ agentId: 'orion' }, 'Yonexus');
|
||||||
|
const dept = yx.createDepartment({ agentId: 'orion' }, 'Platform', org.id);
|
||||||
|
const team = yx.createTeam({ agentId: 'orion' }, 'Core', dept.id);
|
||||||
|
|
||||||
|
yx.assignIdentity({ agentId: 'orion' }, 'orion', dept.id, team.id, {
|
||||||
|
position: 'assistant',
|
||||||
|
discord_user_id: '1474088632750047324',
|
||||||
|
git_user_name: 'orion'
|
||||||
|
});
|
||||||
|
|
||||||
|
yx.setSupervisor({ agentId: 'orion' }, 'orion', 'hangman', dept.id);
|
||||||
|
|
||||||
|
const query = yx.queryAgents(
|
||||||
|
{ agentId: 'orion' },
|
||||||
|
{ deptId: dept.id },
|
||||||
|
{
|
||||||
|
filters: [{ field: 'git_user_name', op: 'eq', value: 'orion' }],
|
||||||
|
options: { limit: 10, offset: 0 }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ dept, team, query, audit: yx.listAuditLogs(20, 0) }, null, 2));
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
|
|
||||||
run_step() {
|
|
||||||
local title="$1"
|
|
||||||
shift
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo ">>> ${title}"
|
|
||||||
"$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_node_modules() {
|
|
||||||
local dir="$1"
|
|
||||||
|
|
||||||
if [[ -d "$ROOT_DIR/$dir/node_modules" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local install_cmd="npm install"
|
|
||||||
if [[ -f "$ROOT_DIR/$dir/package-lock.json" ]]; then
|
|
||||||
install_cmd="npm ci"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Dependencies missing in $dir (node_modules not found). Bootstrapping with: ${install_cmd}"
|
|
||||||
run_step "${dir}: ${install_cmd}" bash -lc "cd '$ROOT_DIR/$dir' && ${install_cmd}"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_npm_script() {
|
|
||||||
local dir="$1"
|
|
||||||
local script="$2"
|
|
||||||
|
|
||||||
ensure_node_modules "$dir"
|
|
||||||
run_step "${dir}: npm run ${script}" bash -lc "cd '$ROOT_DIR/$dir' && npm run ${script}"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_npm_script "Yonexus.Protocol" check
|
|
||||||
run_npm_script "Yonexus.Protocol" test
|
|
||||||
run_npm_script "Yonexus.Server" check
|
|
||||||
run_npm_script "Yonexus.Server" test
|
|
||||||
run_npm_script "Yonexus.Client" check
|
|
||||||
run_npm_script "Yonexus.Client" test
|
|
||||||
|
|
||||||
echo
|
|
||||||
printf 'Yonexus v1 validation passed.\n'
|
|
||||||
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
@@ -1,33 +0,0 @@
|
|||||||
// Singleton guard — openclaw calls register() twice per process
|
|
||||||
let _registered = false;
|
|
||||||
|
|
||||||
export default function register(_api) {
|
|
||||||
if (_registered) return;
|
|
||||||
_registered = true;
|
|
||||||
|
|
||||||
const client = globalThis.__yonexusClient;
|
|
||||||
if (!client) {
|
|
||||||
console.error('[client-test] __yonexusClient not on globalThis — ensure Yonexus.Client loads first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[client-test] __yonexusClient available, keys:', Object.keys(client));
|
|
||||||
|
|
||||||
// Register test_pong rule
|
|
||||||
// Received format (plain rule message from server): test_pong::<content>
|
|
||||||
client.ruleRegistry.registerRule('test_pong', (raw) => {
|
|
||||||
const sep = raw.indexOf('::');
|
|
||||||
const content = raw.slice(sep + 2);
|
|
||||||
console.log(`[client-test] MATCH test_pong content="${content}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// When authenticated, send one matching and one non-matching rule message to server
|
|
||||||
client.onAuthenticated.push(() => {
|
|
||||||
console.log('[client-test] Authenticated — sending test_ping + other_rule to server');
|
|
||||||
const s1 = client.sendRule('test_ping', 'hello-from-client');
|
|
||||||
const s2 = client.sendRule('other_rule', 'other-from-client');
|
|
||||||
console.log(`[client-test] sendRule results: test_ping=${s1} other_rule=${s2}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[client-test] registered test_pong rule and onAuthenticated callback');
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "yonexus-client-test",
|
|
||||||
"name": "Yonexus Client Test Plugin",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Test plugin for Yonexus.Client rule routing",
|
|
||||||
"entry": "./index.mjs",
|
|
||||||
"permissions": [],
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Build context: repo root (Yonexus/)
|
|
||||||
# ── Stage 1: compile ──────────────────────────────────────────────────────────
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Client imports Yonexus.Protocol only
|
|
||||||
COPY Yonexus.Protocol/src ./Yonexus.Protocol/src
|
|
||||||
|
|
||||||
COPY Yonexus.Client/package.json ./Yonexus.Client/
|
|
||||||
COPY Yonexus.Client/package-lock.json ./Yonexus.Client/
|
|
||||||
COPY Yonexus.Client/tsconfig.json ./Yonexus.Client/
|
|
||||||
COPY Yonexus.Client/plugin ./Yonexus.Client/plugin
|
|
||||||
|
|
||||||
WORKDIR /build/Yonexus.Client
|
|
||||||
RUN npm ci
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
|
|
||||||
FROM node:22-alpine AS runtime
|
|
||||||
|
|
||||||
RUN npm install -g openclaw@2026.4.9
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Layout expected by install.mjs: repoRoot = /app, sourceDist = /app/dist
|
|
||||||
COPY --from=builder /build/Yonexus.Client/dist ./dist
|
|
||||||
COPY --from=builder /build/Yonexus.Client/node_modules ./node_modules
|
|
||||||
COPY Yonexus.Client/package.json ./package.json
|
|
||||||
COPY Yonexus.Client/plugin/openclaw.plugin.json ./plugin/openclaw.plugin.json
|
|
||||||
COPY Yonexus.Client/scripts/install.mjs ./scripts/install.mjs
|
|
||||||
|
|
||||||
COPY tests/docker/client-test-plugin /app/client-test-plugin
|
|
||||||
COPY tests/docker/client/entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
: "${IDENTIFIER:?IDENTIFIER is required}"
|
|
||||||
: "${NOTIFY_BOT_TOKEN:?NOTIFY_BOT_TOKEN is required}"
|
|
||||||
: "${ADMIN_USER_ID:?ADMIN_USER_ID is required}"
|
|
||||||
: "${YONEXUS_SERVER_URL:?YONEXUS_SERVER_URL is required}"
|
|
||||||
|
|
||||||
STATE_DIR=/app/.openclaw-state
|
|
||||||
PLUGIN_DIR="$STATE_DIR/plugins/Yonexus.Client"
|
|
||||||
TEST_PLUGIN_DIR="$STATE_DIR/plugins/yonexus-client-test"
|
|
||||||
|
|
||||||
# Install plugin dist + manifest into isolated state directory
|
|
||||||
node /app/scripts/install.mjs --install --openclaw-profile-path "$STATE_DIR"
|
|
||||||
# Symlink node_modules so bare-module imports (e.g. ws) resolve from plugin dir
|
|
||||||
ln -sf /app/node_modules "$PLUGIN_DIR/node_modules"
|
|
||||||
|
|
||||||
# Install test plugin (plain .mjs, no compilation needed)
|
|
||||||
mkdir -p "$TEST_PLUGIN_DIR"
|
|
||||||
cp /app/client-test-plugin/index.mjs "$TEST_PLUGIN_DIR/"
|
|
||||||
cp /app/client-test-plugin/openclaw.plugin.json "$TEST_PLUGIN_DIR/"
|
|
||||||
|
|
||||||
# Write openclaw config — plugin id is "yonexus-client" per openclaw.plugin.json
|
|
||||||
mkdir -p "$STATE_DIR"
|
|
||||||
cat > "$STATE_DIR/openclaw.json" << EOF
|
|
||||||
{
|
|
||||||
"meta": { "lastTouchedVersion": "2026.4.9" },
|
|
||||||
"gateway": { "bind": "loopback" },
|
|
||||||
"agents": { "defaults": { "workspace": "$STATE_DIR/workspace" } },
|
|
||||||
"plugins": {
|
|
||||||
"allow": ["yonexus-client", "yonexus-client-test"],
|
|
||||||
"load": { "paths": ["$PLUGIN_DIR", "$TEST_PLUGIN_DIR"] },
|
|
||||||
"installs": {
|
|
||||||
"yonexus-client": {
|
|
||||||
"source": "path",
|
|
||||||
"sourcePath": "$PLUGIN_DIR",
|
|
||||||
"installPath": "$PLUGIN_DIR",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"installedAt": "2026-04-10T00:00:00.000Z"
|
|
||||||
},
|
|
||||||
"yonexus-client-test": {
|
|
||||||
"source": "path",
|
|
||||||
"sourcePath": "$TEST_PLUGIN_DIR",
|
|
||||||
"installPath": "$TEST_PLUGIN_DIR",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"installedAt": "2026-04-10T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entries": {
|
|
||||||
"yonexus-client": {
|
|
||||||
"enabled": true,
|
|
||||||
"config": {
|
|
||||||
"mainHost": "$YONEXUS_SERVER_URL",
|
|
||||||
"identifier": "$IDENTIFIER",
|
|
||||||
"notifyBotToken": "$NOTIFY_BOT_TOKEN",
|
|
||||||
"adminUserId": "$ADMIN_USER_ID"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"yonexus-client-test": {
|
|
||||||
"enabled": true,
|
|
||||||
"config": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
export OPENCLAW_STATE_DIR="$STATE_DIR"
|
|
||||||
exec openclaw gateway run --allow-unconfigured
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
services:
|
|
||||||
yonexus-server:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: tests/docker/server/Dockerfile
|
|
||||||
environment:
|
|
||||||
# Identifier the client will use — must match IDENTIFIER on the client side
|
|
||||||
CLIENT_IDENTIFIER: test-client
|
|
||||||
# Required: Discord bot token and admin user ID for pairing notifications
|
|
||||||
NOTIFY_BOT_TOKEN: ${NOTIFY_BOT_TOKEN}
|
|
||||||
ADMIN_USER_ID: ${ADMIN_USER_ID}
|
|
||||||
# Optional: override the publicWsUrl advertised to clients
|
|
||||||
# PUBLIC_WS_URL: ws://yonexus-server:8787
|
|
||||||
networks:
|
|
||||||
- yonexus-net
|
|
||||||
healthcheck:
|
|
||||||
# Wait until the Yonexus WebSocket port is accepting connections
|
|
||||||
test:
|
|
||||||
- CMD
|
|
||||||
- node
|
|
||||||
- -e
|
|
||||||
- "require('net').createConnection({port:8787,host:'127.0.0.1'}).on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 12
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
yonexus-client:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: tests/docker/client/Dockerfile
|
|
||||||
environment:
|
|
||||||
# Must match CLIENT_IDENTIFIER on the server side
|
|
||||||
IDENTIFIER: test-client
|
|
||||||
NOTIFY_BOT_TOKEN: ${NOTIFY_BOT_TOKEN}
|
|
||||||
ADMIN_USER_ID: ${ADMIN_USER_ID}
|
|
||||||
YONEXUS_SERVER_URL: ws://yonexus-server:8787
|
|
||||||
networks:
|
|
||||||
- yonexus-net
|
|
||||||
depends_on:
|
|
||||||
yonexus-server:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
networks:
|
|
||||||
yonexus-net:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// Singleton guard — openclaw calls register() twice per process
|
|
||||||
let _registered = false;
|
|
||||||
|
|
||||||
export default function register(_api) {
|
|
||||||
if (_registered) return;
|
|
||||||
_registered = true;
|
|
||||||
|
|
||||||
const server = globalThis.__yonexusServer;
|
|
||||||
if (!server) {
|
|
||||||
console.error('[server-test] __yonexusServer not on globalThis — ensure Yonexus.Server loads first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[server-test] __yonexusServer available, keys:', Object.keys(server));
|
|
||||||
|
|
||||||
// Register test_ping rule
|
|
||||||
// Received format (rewritten by server): test_ping::<senderIdentifier>::<content>
|
|
||||||
server.ruleRegistry.registerRule('test_ping', (raw) => {
|
|
||||||
const firstSep = raw.indexOf('::');
|
|
||||||
const rest = raw.slice(firstSep + 2);
|
|
||||||
const secondSep = rest.indexOf('::');
|
|
||||||
const sender = rest.slice(0, secondSep);
|
|
||||||
const content = rest.slice(secondSep + 2);
|
|
||||||
console.log(`[server-test] MATCH test_ping from="${sender}" content="${content}"`);
|
|
||||||
// Echo back to sender via test_pong
|
|
||||||
const sent = server.sendRule(sender, 'test_pong', `echo-${content}`);
|
|
||||||
console.log(`[server-test] echo sent=${sent}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// When a client authenticates, send one matching and one non-matching rule message
|
|
||||||
server.onClientAuthenticated.push((identifier) => {
|
|
||||||
console.log(`[server-test] Client "${identifier}" authenticated — sending test_pong + other_rule`);
|
|
||||||
const s1 = server.sendRule(identifier, 'test_pong', 'welcome-from-server');
|
|
||||||
const s2 = server.sendRule(identifier, 'other_rule', 'other-from-server');
|
|
||||||
console.log(`[server-test] sendRule results: test_pong=${s1} other_rule=${s2}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[server-test] registered test_ping rule and onClientAuthenticated callback');
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "yonexus-server-test",
|
|
||||||
"name": "Yonexus Server Test Plugin",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Test plugin for Yonexus.Server rule routing",
|
|
||||||
"entry": "./index.mjs",
|
|
||||||
"permissions": [],
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Build context: repo root (Yonexus/)
|
|
||||||
# ── Stage 1: compile ──────────────────────────────────────────────────────────
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Server imports Yonexus.Protocol and Yonexus.Client/crypto — all needed for tsc
|
|
||||||
COPY Yonexus.Protocol/src ./Yonexus.Protocol/src
|
|
||||||
COPY Yonexus.Client/plugin/crypto ./Yonexus.Client/plugin/crypto
|
|
||||||
|
|
||||||
COPY Yonexus.Server/package.json ./Yonexus.Server/
|
|
||||||
COPY Yonexus.Server/package-lock.json ./Yonexus.Server/
|
|
||||||
COPY Yonexus.Server/tsconfig.json ./Yonexus.Server/
|
|
||||||
COPY Yonexus.Server/plugin ./Yonexus.Server/plugin
|
|
||||||
|
|
||||||
WORKDIR /build/Yonexus.Server
|
|
||||||
RUN npm ci
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
|
|
||||||
FROM node:22-alpine AS runtime
|
|
||||||
|
|
||||||
RUN npm install -g openclaw@2026.4.9
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Layout expected by install.mjs: repoRoot = /app, sourceDist = /app/dist
|
|
||||||
COPY --from=builder /build/Yonexus.Server/dist ./dist
|
|
||||||
COPY --from=builder /build/Yonexus.Server/node_modules ./node_modules
|
|
||||||
COPY Yonexus.Server/package.json ./package.json
|
|
||||||
COPY Yonexus.Server/plugin/openclaw.plugin.json ./plugin/openclaw.plugin.json
|
|
||||||
COPY Yonexus.Server/scripts/install.mjs ./scripts/install.mjs
|
|
||||||
|
|
||||||
COPY tests/docker/server-test-plugin /app/server-test-plugin
|
|
||||||
COPY tests/docker/server/entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
EXPOSE 8787
|
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
: "${CLIENT_IDENTIFIER:?CLIENT_IDENTIFIER is required}"
|
|
||||||
: "${NOTIFY_BOT_TOKEN:?NOTIFY_BOT_TOKEN is required}"
|
|
||||||
: "${ADMIN_USER_ID:?ADMIN_USER_ID is required}"
|
|
||||||
|
|
||||||
STATE_DIR=/app/.openclaw-state
|
|
||||||
PLUGIN_DIR="$STATE_DIR/plugins/Yonexus.Server"
|
|
||||||
TEST_PLUGIN_DIR="$STATE_DIR/plugins/yonexus-server-test"
|
|
||||||
SERVER_WS_URL="${PUBLIC_WS_URL:-ws://yonexus-server:8787}"
|
|
||||||
|
|
||||||
# Install plugin dist + manifest into isolated state directory
|
|
||||||
node /app/scripts/install.mjs --install --openclaw-profile-path "$STATE_DIR"
|
|
||||||
# Symlink node_modules so bare-module imports (e.g. ws) resolve from plugin dir
|
|
||||||
ln -sf /app/node_modules "$PLUGIN_DIR/node_modules"
|
|
||||||
|
|
||||||
# Install test plugin (plain .mjs, no compilation needed)
|
|
||||||
mkdir -p "$TEST_PLUGIN_DIR"
|
|
||||||
cp /app/server-test-plugin/index.mjs "$TEST_PLUGIN_DIR/"
|
|
||||||
cp /app/server-test-plugin/openclaw.plugin.json "$TEST_PLUGIN_DIR/"
|
|
||||||
|
|
||||||
# Write openclaw config — plugin id is "yonexus-server" per openclaw.plugin.json
|
|
||||||
mkdir -p "$STATE_DIR"
|
|
||||||
cat > "$STATE_DIR/openclaw.json" << EOF
|
|
||||||
{
|
|
||||||
"meta": { "lastTouchedVersion": "2026.4.9" },
|
|
||||||
"gateway": { "bind": "loopback" },
|
|
||||||
"agents": { "defaults": { "workspace": "$STATE_DIR/workspace" } },
|
|
||||||
"plugins": {
|
|
||||||
"allow": ["yonexus-server", "yonexus-server-test"],
|
|
||||||
"load": { "paths": ["$PLUGIN_DIR", "$TEST_PLUGIN_DIR"] },
|
|
||||||
"installs": {
|
|
||||||
"yonexus-server": {
|
|
||||||
"source": "path",
|
|
||||||
"sourcePath": "$PLUGIN_DIR",
|
|
||||||
"installPath": "$PLUGIN_DIR",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"installedAt": "2026-04-10T00:00:00.000Z"
|
|
||||||
},
|
|
||||||
"yonexus-server-test": {
|
|
||||||
"source": "path",
|
|
||||||
"sourcePath": "$TEST_PLUGIN_DIR",
|
|
||||||
"installPath": "$TEST_PLUGIN_DIR",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"installedAt": "2026-04-10T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entries": {
|
|
||||||
"yonexus-server": {
|
|
||||||
"enabled": true,
|
|
||||||
"config": {
|
|
||||||
"followerIdentifiers": ["$CLIENT_IDENTIFIER"],
|
|
||||||
"notifyBotToken": "$NOTIFY_BOT_TOKEN",
|
|
||||||
"adminUserId": "$ADMIN_USER_ID",
|
|
||||||
"listenHost": "0.0.0.0",
|
|
||||||
"listenPort": 8787,
|
|
||||||
"publicWsUrl": "$SERVER_WS_URL"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"yonexus-server-test": {
|
|
||||||
"enabled": true,
|
|
||||||
"config": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
export OPENCLAW_STATE_DIR="$STATE_DIR"
|
|
||||||
exec openclaw gateway run --allow-unconfigured
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# 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
|
|
||||||
- ⏸ = Deferred / intentionally out of v1 scope
|
|
||||||
- 🔴 = 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_signature)`;`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 payload / missing required hello fields | 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 | Surface corruption error clearly for operator handling | ✅ |
|
|
||||||
| SR-06 | Corrupted client state | File unreadable | Surface corruption error clearly for operator handling | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Current Notes
|
|
||||||
|
|
||||||
- AF-04 (`invalid_secret`) 目前明确按 v1 语义并入 `invalid_signature`,不再单独视为未完成缺口;若后续要保留独立错误码,需要先同步更新协议与实现。
|
|
||||||
- RP-03(管理员主动撤销)与 RP-04(key rotation)继续作为 v2+ 议题保留,不阻塞当前 v1 交付判断。
|
|
||||||
- 本轮已补齐 AF-01/02/03/05/06/09/10/11、RP-01/02、CF-01/02/03/04/05/07、HF-01/02、PF-08/09/10、SR-01/02/03/04/05/06。
|
|
||||||
|
|
||||||
### Umbrella Validation Entry Point
|
|
||||||
|
|
||||||
在 umbrella 仓库根目录可运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/validate-v1.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
它会顺序执行:
|
|
||||||
- `Yonexus.Protocol` 测试
|
|
||||||
- `Yonexus.Server` 类型检查 + 测试
|
|
||||||
- `Yonexus.Client` 类型检查 + 测试
|
|
||||||
|
|
||||||
### 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/`
|
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
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("PF-08: pairing attempt during an active paired session is rejected without losing trust", async () => {
|
|
||||||
const store = createMockStore([{
|
|
||||||
identifier: "client-a",
|
|
||||||
pairingStatus: "paired",
|
|
||||||
publicKey: "existing-key",
|
|
||||||
secret: "existing-secret",
|
|
||||||
status: "online",
|
|
||||||
recentNonces: [],
|
|
||||||
recentHandshakeAttempts: [],
|
|
||||||
createdAt: now - 1000,
|
|
||||||
updatedAt: now - 10,
|
|
||||||
pairedAt: now - 500,
|
|
||||||
lastAuthenticatedAt: now - 5,
|
|
||||||
lastHeartbeatAt: now - 5
|
|
||||||
}]);
|
|
||||||
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("client-a");
|
|
||||||
await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm(
|
|
||||||
{ identifier: "client-a", pairingCode: "NEW-PAIR-CODE" },
|
|
||||||
{ timestamp: now }
|
|
||||||
)));
|
|
||||||
|
|
||||||
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
|
|
||||||
expect(lastMessage.type).toBe("pair_failed");
|
|
||||||
expect((lastMessage.payload as PairFailedPayload).reason).toBe("internal_error");
|
|
||||||
|
|
||||||
const record = runtime.state.registry.clients.get("client-a");
|
|
||||||
expect(record).toMatchObject({
|
|
||||||
pairingStatus: "paired",
|
|
||||||
secret: "existing-secret",
|
|
||||||
publicKey: "existing-key",
|
|
||||||
status: "online"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,630 +0,0 @@
|
|||||||
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,
|
|
||||||
createAuthRequestSigningInput,
|
|
||||||
YONEXUS_PROTOCOL_VERSION
|
|
||||||
} from "../../Yonexus.Protocol/src/index.js";
|
|
||||||
import { generateKeyPair, signMessage } 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;
|
|
||||||
initialClientState?: Partial<YonexusClientState>;
|
|
||||||
initialServerClients?: ClientRecord[];
|
|
||||||
} = {}
|
|
||||||
): Promise<IntegrationTestContext> {
|
|
||||||
const initialNow = options.serverTime ?? 1_710_000_000;
|
|
||||||
const identifier = options.clientIdentifier ?? "test-client";
|
|
||||||
|
|
||||||
const transports = createMockTransportPair();
|
|
||||||
const serverStore = createMockServerStore(options.initialServerClients ?? []);
|
|
||||||
const clientStore = createMockClientStore({ identifier, ...options.initialClientState });
|
|
||||||
|
|
||||||
// Generate keypair for client if needed
|
|
||||||
const keyPair = await generateKeyPair();
|
|
||||||
|
|
||||||
let currentTime = initialNow;
|
|
||||||
|
|
||||||
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: () => currentTime
|
|
||||||
});
|
|
||||||
|
|
||||||
const clientRuntime = createYonexusClientRuntime({
|
|
||||||
config: {
|
|
||||||
mainHost: "ws://localhost:8787",
|
|
||||||
identifier,
|
|
||||||
notifyBotToken: "test-token",
|
|
||||||
adminUserId: "admin-user"
|
|
||||||
},
|
|
||||||
transport: transports.clientTransport,
|
|
||||||
stateStore: clientStore,
|
|
||||||
now: () => currentTime
|
|
||||||
});
|
|
||||||
|
|
||||||
await serverRuntime.start();
|
|
||||||
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,
|
|
||||||
initialClientState: {
|
|
||||||
secret: "existing-secret",
|
|
||||||
publicKey: keyPair.publicKey.trim(),
|
|
||||||
privateKey: keyPair.privateKey,
|
|
||||||
pairedAt: now - 1000,
|
|
||||||
updatedAt: now - 1000
|
|
||||||
},
|
|
||||||
initialServerClients: [
|
|
||||||
{
|
|
||||||
identifier: "reconnect-client",
|
|
||||||
pairingStatus: "paired",
|
|
||||||
publicKey: keyPair.publicKey.trim(),
|
|
||||||
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 now = 1_710_000_000;
|
|
||||||
const keyPair = await generateKeyPair();
|
|
||||||
const ctx = await createIntegrationTestContext({
|
|
||||||
clientIdentifier: "heartbeat-client",
|
|
||||||
serverTime: now,
|
|
||||||
initialClientState: {
|
|
||||||
secret: "existing-secret",
|
|
||||||
publicKey: keyPair.publicKey.trim(),
|
|
||||||
privateKey: keyPair.privateKey,
|
|
||||||
pairedAt: now - 1000,
|
|
||||||
updatedAt: now - 1000
|
|
||||||
},
|
|
||||||
initialServerClients: [
|
|
||||||
{
|
|
||||||
identifier: "heartbeat-client",
|
|
||||||
pairingStatus: "paired",
|
|
||||||
publicKey: keyPair.publicKey.trim(),
|
|
||||||
secret: "existing-secret",
|
|
||||||
status: "offline",
|
|
||||||
recentNonces: [],
|
|
||||||
recentHandshakeAttempts: [],
|
|
||||||
createdAt: now - 2000,
|
|
||||||
updatedAt: now - 1000
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
await ctx.clientRuntime.start();
|
|
||||||
ctx.clientRuntime.handleTransportStateChange("connected");
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
await ctx.processClientToServer();
|
|
||||||
await ctx.processServerToClient();
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
await ctx.processClientToServer();
|
|
||||||
await ctx.processServerToClient();
|
|
||||||
|
|
||||||
expect(ctx.clientRuntime.state.phase).toBe("authenticated");
|
|
||||||
|
|
||||||
await ctx.clientRuntime.handleMessage("heartbeat_tick");
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
await ctx.processClientToServer();
|
|
||||||
|
|
||||||
const record = ctx.serverRuntime.state.registry.clients.get("heartbeat-client");
|
|
||||||
expect(record?.lastHeartbeatAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks client unstable then offline after heartbeat timeout", async () => {
|
|
||||||
const now = 1_710_000_000;
|
|
||||||
const keyPair = await generateKeyPair();
|
|
||||||
const ctx = await createIntegrationTestContext({
|
|
||||||
clientIdentifier: "timed-out-client",
|
|
||||||
serverTime: now
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.serverRuntime.state.registry.clients.set("timed-out-client", {
|
|
||||||
identifier: "timed-out-client",
|
|
||||||
pairingStatus: "paired",
|
|
||||||
publicKey: keyPair.publicKey.trim(),
|
|
||||||
secret: "existing-secret",
|
|
||||||
status: "online",
|
|
||||||
recentNonces: [],
|
|
||||||
recentHandshakeAttempts: [],
|
|
||||||
lastAuthenticatedAt: now,
|
|
||||||
lastHeartbeatAt: now,
|
|
||||||
createdAt: now - 100,
|
|
||||||
updatedAt: now
|
|
||||||
});
|
|
||||||
ctx.serverRuntime.state.registry.sessions.set("timed-out-client", {
|
|
||||||
identifier: "timed-out-client",
|
|
||||||
socket: { close: vi.fn() } as unknown as WebSocket,
|
|
||||||
isAuthenticated: true,
|
|
||||||
connectedAt: now,
|
|
||||||
lastActivityAt: now,
|
|
||||||
publicKey: keyPair.publicKey.trim()
|
|
||||||
});
|
|
||||||
ctx.transports.serverTransport.connections.set("timed-out-client", {
|
|
||||||
identifier: "timed-out-client",
|
|
||||||
ws: { close: vi.fn() } as unknown as WebSocket,
|
|
||||||
connectedAt: now,
|
|
||||||
isAuthenticated: true
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.advanceTime(7 * 60);
|
|
||||||
await vi.advanceTimersByTimeAsync(30_100);
|
|
||||||
|
|
||||||
const unstableRecord = ctx.serverRuntime.state.registry.clients.get("timed-out-client");
|
|
||||||
expect(unstableRecord?.status).toBe("unstable");
|
|
||||||
expect(ctx.transports.channel.serverToClient.some((message) => {
|
|
||||||
const envelope = decodeBuiltin(message);
|
|
||||||
return envelope.type === "status_update";
|
|
||||||
})).toBe(true);
|
|
||||||
|
|
||||||
ctx.advanceTime(4 * 60);
|
|
||||||
await vi.advanceTimersByTimeAsync(30_100);
|
|
||||||
|
|
||||||
const offlineRecord = ctx.serverRuntime.state.registry.clients.get("timed-out-client");
|
|
||||||
expect(offlineRecord?.status).toBe("offline");
|
|
||||||
expect(ctx.serverRuntime.state.registry.sessions.has("timed-out-client")).toBe(false);
|
|
||||||
expect(ctx.transports.channel.serverToClient.some((message) => {
|
|
||||||
const envelope = decodeBuiltin(message);
|
|
||||||
return envelope.type === "disconnect_notice";
|
|
||||||
})).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Re-pair Flow", () => {
|
|
||||||
it("forces client back to pair_required after nonce collision", async () => {
|
|
||||||
const now = 1_710_000_000;
|
|
||||||
const keyPair = await generateKeyPair();
|
|
||||||
const collisionNonce = "NONCE1234567890123456789";
|
|
||||||
const ctx = await createIntegrationTestContext({
|
|
||||||
clientIdentifier: "collision-client",
|
|
||||||
serverTime: now,
|
|
||||||
initialClientState: {
|
|
||||||
secret: "existing-secret",
|
|
||||||
publicKey: keyPair.publicKey.trim(),
|
|
||||||
privateKey: keyPair.privateKey,
|
|
||||||
pairedAt: now - 100,
|
|
||||||
updatedAt: now - 100
|
|
||||||
},
|
|
||||||
initialServerClients: [
|
|
||||||
{
|
|
||||||
identifier: "collision-client",
|
|
||||||
pairingStatus: "paired",
|
|
||||||
publicKey: keyPair.publicKey.trim(),
|
|
||||||
secret: "existing-secret",
|
|
||||||
status: "offline",
|
|
||||||
recentNonces: [{ nonce: collisionNonce, timestamp: now - 1 }],
|
|
||||||
recentHandshakeAttempts: [],
|
|
||||||
createdAt: now - 200,
|
|
||||||
updatedAt: now - 100
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.serverRuntime.state.registry.sessions.set("collision-client", {
|
|
||||||
identifier: "collision-client",
|
|
||||||
socket: { close: vi.fn() } as unknown as WebSocket,
|
|
||||||
isAuthenticated: false,
|
|
||||||
connectedAt: now,
|
|
||||||
lastActivityAt: now,
|
|
||||||
publicKey: keyPair.publicKey.trim()
|
|
||||||
});
|
|
||||||
|
|
||||||
const authRequest = buildAuthRequest(
|
|
||||||
{
|
|
||||||
identifier: "collision-client",
|
|
||||||
nonce: collisionNonce,
|
|
||||||
proofTimestamp: now,
|
|
||||||
signature: await signMessage(
|
|
||||||
keyPair.privateKey,
|
|
||||||
createAuthRequestSigningInput({
|
|
||||||
secret: "existing-secret",
|
|
||||||
nonce: collisionNonce,
|
|
||||||
proofTimestamp: now
|
|
||||||
})
|
|
||||||
),
|
|
||||||
publicKey: keyPair.publicKey.trim()
|
|
||||||
},
|
|
||||||
{ requestId: "req-collision", timestamp: now }
|
|
||||||
);
|
|
||||||
|
|
||||||
await ctx.serverRuntime.handleMessage(
|
|
||||||
{
|
|
||||||
identifier: "collision-client",
|
|
||||||
ws: { close: vi.fn() } as unknown as WebSocket,
|
|
||||||
connectedAt: now,
|
|
||||||
isAuthenticated: false
|
|
||||||
},
|
|
||||||
encodeBuiltin(authRequest)
|
|
||||||
);
|
|
||||||
|
|
||||||
const serverEnvelope = decodeBuiltin(ctx.transports.channel.serverToClient.at(-1) ?? "");
|
|
||||||
expect(serverEnvelope.type).toBe("re_pair_required");
|
|
||||||
|
|
||||||
await ctx.clientRuntime.handleMessage(ctx.transports.channel.serverToClient.at(-1)!);
|
|
||||||
expect(ctx.clientRuntime.state.phase).toBe("pair_required");
|
|
||||||
expect(ctx.clientRuntime.state.clientState.secret).toBeUndefined();
|
|
||||||
expect(ctx.clientRuntime.state.lastPairingFailure).toBe("re_pair_required");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
54
tests/smoke.ts
Normal file
54
tests/smoke.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from 'node:assert';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { Yonexus } from '../plugin/index';
|
||||||
|
import { YonexusError } from '../plugin/core/models/errors';
|
||||||
|
|
||||||
|
const root = path.resolve(process.cwd(), 'data/test-openclaw');
|
||||||
|
const dataFile = path.resolve(process.cwd(), 'data/test-org.json');
|
||||||
|
if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile);
|
||||||
|
if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const app = new Yonexus({ dataFile, registrars: ['orion'], openclawDir: root });
|
||||||
|
|
||||||
|
app.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']);
|
||||||
|
app.registerAgent({ agentId: 'orion' }, 'u1', 'U1', ['agent']);
|
||||||
|
const org = app.createOrganization({ agentId: 'orion' }, 'Yonexus');
|
||||||
|
const dept = app.createDepartment({ agentId: 'orion' }, 'Eng', org.id);
|
||||||
|
const team = app.createTeam({ agentId: 'orion' }, 'API', dept.id);
|
||||||
|
app.assignIdentity({ agentId: 'orion' }, 'u1', dept.id, team.id, {
|
||||||
|
git_user_name: 'u1',
|
||||||
|
position: 'dev'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = app.queryAgents(
|
||||||
|
{ agentId: 'orion' },
|
||||||
|
{ deptId: dept.id },
|
||||||
|
{ filters: [{ field: 'git_user_name', op: 'eq', value: 'u1' }] }
|
||||||
|
);
|
||||||
|
assert.equal(result.length, 1);
|
||||||
|
|
||||||
|
const expectedAgentDocsDir = path.join(root, 'yonexus', 'organizations', 'yonexus', 'teams', 'api', 'agents', 'u1', 'docs');
|
||||||
|
assert.equal(fs.existsSync(expectedAgentDocsDir), true);
|
||||||
|
|
||||||
|
let thrown = false;
|
||||||
|
try {
|
||||||
|
app.queryAgents(
|
||||||
|
{ agentId: 'orion' },
|
||||||
|
{ deptId: dept.id },
|
||||||
|
{ filters: [{ field: 'team', op: 'eq', value: 'API' }] }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e instanceof YonexusError && e.code === 'FIELD_NOT_QUERYABLE';
|
||||||
|
}
|
||||||
|
assert.equal(thrown, true);
|
||||||
|
|
||||||
|
let invalidRegexThrown = false;
|
||||||
|
try {
|
||||||
|
app.getDocs('agent', 'docs', '[');
|
||||||
|
} catch (e) {
|
||||||
|
invalidRegexThrown = e instanceof YonexusError && e.code === 'VALIDATION_ERROR';
|
||||||
|
}
|
||||||
|
assert.equal(invalidRegexThrown, true);
|
||||||
|
|
||||||
|
console.log('smoke test passed');
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist/yonexus",
|
||||||
|
"rootDir": "plugin",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["plugin/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user