Compare commits
4 Commits
dev/2026-0
...
220ec85e6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 220ec85e6f | |||
| 00108c357b | |||
| 3b26f3d083 | |||
| a0e926594f |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
.idea/
|
||||
tests/docker/.env
|
||||
node_modules/
|
||||
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 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 |
|
||||
|---|---|
|
||||
| `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 |
|
||||
## Features
|
||||
|
||||
## 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
|
||||
Installed on the central OpenClaw instance.
|
||||
## Project Layout
|
||||
|
||||
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
|
||||
${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
|
||||
{
|
||||
"followerIdentifiers": ["client-a", "client-b"],
|
||||
"notifyBotToken": "<discord-bot-token>",
|
||||
"adminUserId": "123456789012345678",
|
||||
"listenHost": "0.0.0.0",
|
||||
"listenPort": 8787,
|
||||
"publicWsUrl": "wss://example.com/yonexus"
|
||||
}
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run test:smoke
|
||||
npm run demo
|
||||
```
|
||||
|
||||
## Planned Client Config
|
||||
## Install / Uninstall
|
||||
|
||||
```json
|
||||
{
|
||||
"mainHost": "wss://example.com/yonexus",
|
||||
"identifier": "client-a",
|
||||
"notifyBotToken": "<discord-bot-token>",
|
||||
"adminUserId": "123456789012345678"
|
||||
}
|
||||
```bash
|
||||
# Install (builds and copies to ~/.openclaw/plugins/yonexus)
|
||||
node install.mjs --install
|
||||
|
||||
# Install to custom openclaw profile path
|
||||
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.
|
||||
- `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`.
|
||||
## Implemented APIs
|
||||
|
||||
## 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:
|
||||
- WebSocket transport between one server and one or more clients
|
||||
- out-of-band pairing via Discord DM to a human administrator
|
||||
- signed reconnect authentication using `secret + nonce + timestamp`
|
||||
- heartbeat/liveness tracking (`online | unstable | offline`)
|
||||
- exact-match rule dispatch
|
||||
- lightweight persistence for trust/state material
|
||||
- optional `heartbeat_ack`
|
||||
- exponential reconnect backoff on the client side
|
||||
Management:
|
||||
- `renameDepartment(actor, deptId, newName)`
|
||||
- `renameTeam(actor, teamId, newName, deptId?)`
|
||||
- `migrateTeam(actor, teamId, newDeptId)`
|
||||
- `deleteDepartment(actor, deptId)`
|
||||
- `deleteTeam(actor, teamId, deptId?)`
|
||||
|
||||
Explicitly out of scope for v1:
|
||||
- multi-server topology
|
||||
- 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
|
||||
Docs:
|
||||
- `getDocs(scope, topic, keyword)`
|
||||
|
||||
## Status
|
||||
Data & audit:
|
||||
- `exportData(actor)`
|
||||
- `importData(actor, state)`
|
||||
- `listAuditLogs(limit?, offset?)`
|
||||
|
||||
- umbrella/specification repo is aligned with the split architecture
|
||||
- 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
|
||||
## Testing
|
||||
|
||||
---
|
||||
```bash
|
||||
npm run test:smoke
|
||||
```
|
||||
|
||||
## Repository URLs
|
||||
## Notes
|
||||
|
||||
- [Yonexus (umbrella)](https://git.hangman-lab.top/nav/Yonexus)
|
||||
- [Yonexus.Server](https://git.hangman-lab.top/nav/Yonexus.Server)
|
||||
- [Yonexus.Client](https://git.hangman-lab.top/nav/Yonexus.Client)
|
||||
- [Yonexus.Protocol](https://git.hangman-lab.top/nav/Yonexus.Protocol)
|
||||
- Structure data is persisted in JSON, not memory_store.
|
||||
- Shared scope memory is handled via the scope memory adapter.
|
||||
- Unknown metadata fields are dropped during identity assignment.
|
||||
- `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" }
|
||||
]
|
||||
}
|
||||
209
install.mjs
Normal file
209
install.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/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/4] 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/4] 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. Configure OpenClaw
|
||||
log('[3/4] Configuring OpenClaw...', 'cyan');
|
||||
try {
|
||||
const pluginPath = destDir;
|
||||
const allow = ensureArray(getConfigValue('plugins.allow'));
|
||||
const loadPaths = ensureArray(getConfigValue('plugins.load.paths'));
|
||||
|
||||
if (!allow.includes(PLUGIN_NAME)) allow.push(PLUGIN_NAME);
|
||||
if (!loadPaths.includes(pluginPath)) loadPaths.push(pluginPath);
|
||||
|
||||
execSync(`openclaw config set plugins.allow '${JSON.stringify(allow)}'`);
|
||||
execSync(`openclaw config set plugins.load.paths '${JSON.stringify(loadPaths)}'`);
|
||||
execSync(`openclaw config set plugins.entries.${PLUGIN_NAME}.enabled true`);
|
||||
execSync(`openclaw config set plugins.entries.${PLUGIN_NAME}.config '{"enabled": true}'`);
|
||||
|
||||
logOk('OpenClaw config updated');
|
||||
} catch (err) {
|
||||
logErr('Failed to update OpenClaw config via `openclaw config set`');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 4. Summary
|
||||
log('[4/4] Done!', 'cyan');
|
||||
console.log('');
|
||||
log('✓ Yonexus installed successfully!', 'green');
|
||||
console.log('');
|
||||
log('Next steps:', 'blue');
|
||||
log(' openclaw gateway restart', 'cyan');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function getConfigValue(path) {
|
||||
try {
|
||||
const out = execSync(`openclaw config get ${path}`, { encoding: 'utf8' }).trim();
|
||||
if (!out || out === 'undefined' || out === 'null') return undefined;
|
||||
try { return JSON.parse(out); } catch { return out; }
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value === undefined || value === null || value === '') return [];
|
||||
return [value];
|
||||
}
|
||||
|
||||
|
||||
// ── 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`);
|
||||
}
|
||||
|
||||
// Clean OpenClaw config
|
||||
log('Cleaning OpenClaw config...', 'cyan');
|
||||
try {
|
||||
const allow = ensureArray(getConfigValue('plugins.allow')).filter((id) => id !== PLUGIN_NAME);
|
||||
const loadPaths = ensureArray(getConfigValue('plugins.load.paths')).filter((p) => p !== destDir);
|
||||
|
||||
if (allow.length > 0) {
|
||||
execSync(`openclaw config set plugins.allow '${JSON.stringify(allow)}'`);
|
||||
} else {
|
||||
execSync('openclaw config unset plugins.allow');
|
||||
}
|
||||
|
||||
if (loadPaths.length > 0) {
|
||||
execSync(`openclaw config set plugins.load.paths '${JSON.stringify(loadPaths)}'`);
|
||||
} else {
|
||||
execSync('openclaw config unset plugins.load.paths');
|
||||
}
|
||||
|
||||
execSync(`openclaw config unset plugins.entries.${PLUGIN_NAME}`);
|
||||
|
||||
logOk('OpenClaw config cleaned');
|
||||
} catch (err) {
|
||||
logErr('Failed to clean OpenClaw config via `openclaw config`');
|
||||
throw err;
|
||||
}
|
||||
|
||||
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 && cp plugin/openclaw.plugin.json dist/yonexus/openclaw.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";
|
||||
13
plugin/openclaw.plugin.json
Normal file
13
plugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "yonexus",
|
||||
"name": "Yonexus",
|
||||
"version": "0.2.0",
|
||||
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
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