Compare commits

..

50 Commits

Author SHA1 Message Date
ca64f4eb27 fix: globalThis 2026-04-10 21:58:59 +01:00
7be370bd3e fix: move plugin startup guards and shared state to globalThis; update docs
Both Yonexus.Client and Yonexus.Server used module-level variables as
hot-reload guards, which reset on every hot-reload (new VM context).
Fix submodule pointers to the corrected plugin index.ts commits.

Also add LESSONS_LEARNED.md and OPENCLAW_PLUGIN_DEV.md (copied from
Dirigent) with three new lessons from this session (§11 connection-plugin
hot-reload trap, §12 transport message routing race, §13 re-hello on
session race) and updated plugin dev guide (§2.2 connection plugin entry
pattern, §6 state table, §9 checklist, §10 cross-plugin globalThis API).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:41:40 +01:00
a8b2f5d9ed feat: add rule dispatch, cross-plugin API, and Docker integration test
Wire rule registry and authenticated callbacks into both client and server
runtimes; expose __yonexusClient / __yonexusServer on globalThis for
cross-plugin communication. Add Docker-based integration test with
server-test-plugin (test_ping echo) and client-test-plugin (test_pong
receiver), plus docker-compose setup. Fix transport race condition where
a stale _connections entry caused promoteToAuthenticated to silently fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:15:09 +01:00
nav
17b9cc83f4 chore: tighten yonexus v1 validation flow 2026-04-09 05:03:06 +00:00
nav
3bb0a79057 Add umbrella validation entry point 2026-04-09 04:38:15 +00:00
nav
cdf17bf1c4 chore: close yonexus pairing follow-ups 2026-04-09 04:06:10 +00:00
nav
12224c761f test: expand failure and recovery coverage 2026-04-09 03:33:09 +00:00
nav
8c9307f061 docs: update failure-path coverage status 2026-04-09 03:02:40 +00:00
nav
6f164db7f8 docs: lock v1 scope and terminology 2026-04-09 02:33:21 +00:00
nav
8b5a8efe9a docs: update yonexus failure-path progress 2026-04-09 02:04:11 +00:00
nav
e1835ba489 test: extend failure-path recovery coverage 2026-04-09 01:32:49 +00:00
nav
0a7f895315 docs(test): mark CF-06 and heartbeat failures 2026-04-09 01:19:26 +00:00
nav
25a59adb5d test: extend yonexus integration coverage 2026-04-09 01:13:49 +00:00
nav
477ccc8e5a YNX-1105c: cover auth nonce collision/rate limit failures 2026-04-09 01:04:59 +00:00
nav
685213b3d4 YNX-1104/1105: Add integration test framework and pairing failure path tests
- YNX-1104a: Create Server-Client integration test framework
  - MockTransportPair for simulating network communication
  - createIntegrationTestContext() for test environment setup
  - First-time pairing flow integration tests
  - Reconnection flow tests

- YNX-1105a: Create failure path test matrix documentation
  - MATRIX.md with PF/AF/RP/CF/HF/SR categories
  - Priority marking for critical security paths

- YNX-1105b: Implement pairing failure path tests
  - PF-01: Invalid pairing code with retry
  - PF-02: Expired pairing code cleanup
  - PF-03: Unknown identifier rejection
  - PF-04: Admin notification failure handling
  - PF-05: Empty/whitespace code rejection
  - PF-06: Malformed payload handling
  - PF-07: Double pairing protection
  - Edge cases: concurrent pairing, state cleanup

- Update TASKLIST.md with completion status
2026-04-09 00:59:43 +00:00
nav
1f399c6191 chore: advance yonexus test coverage 2026-04-09 00:42:38 +00:00
nav
a1baa6fd82 docs: update test task progress 2026-04-09 00:36:44 +00:00
nav
733e02025b docs: record yonexus unit test progress 2026-04-09 00:03:41 +00:00
nav
d8af91cc0b docs: add deployment and operations guides 2026-04-08 23:56:06 +00:00
nav
81752e763e docs: add yonexus acceptance baseline 2026-04-08 23:32:37 +00:00
nav
347066240d Update TASKLIST: mark YNX-1003, YNX-1004 complete; update YNX-0603 progress
- YNX-1003: Single-identifier single-active-connection policy (completed)
- YNX-1004: Restart recovery strategy (completed with documentation)
- YNX-0603: Discord DM notification skeleton complete, real DM noted as needing discord.js
2026-04-08 23:24:47 +00:00
nav
cb0fb097de Advance transport hardening tasks 2026-04-08 23:03:59 +00:00
nav
9c72c93b8b feat: YNX-0903/0904/0905 - Implement rule messaging APIs and server rewrite
- YNX-0903: Add sendMessageToServer() and sendRuleMessage() to Client runtime
- YNX-0904: Add sendMessageToClient() and sendRuleMessageToClient() to Server runtime
- YNX-0905: Implement handleRuleMessage() for server-side message rewriting
  with sender identifier injection (::::)
- Update TASKLIST.md to mark all 3 tasks as completed
2026-04-08 22:52:00 +00:00
nav
07b5ffed0c chore: record yonexus runtime progress 2026-04-08 22:39:52 +00:00
nav
479bb5e349 Update heartbeat and re-pair task status 2026-04-08 22:35:09 +00:00
nav
739a1ee094 feat: advance yonexus authentication handshake 2026-04-08 22:04:56 +00:00
nav
02d158eacd feat: wire yonexus pairing request flow 2026-04-08 21:38:43 +00:00
nav
3891eab488 chore: update pairing task progress 2026-04-08 21:34:53 +00:00
nav
34a591dd0c Track runtime and handshake progress 2026-04-08 21:13:26 +00:00
nav
8c475f259a Record transport scaffolding and protocol tests 2026-04-08 21:05:22 +00:00
nav
38f68ca7ab feat: persist Yonexus trust state scaffolding 2026-04-08 20:33:28 +00:00
nav
4ed8287c2a feat: update submodules for YNX-0102 and YNX-0401
- Yonexus.Protocol: add codec module with protocol encode/decode
- Yonexus.Server: add persistence types and ClientRecord structure
- Update TASKLIST.md to mark YNX-0102 and YNX-0401 complete
2026-04-08 20:20:17 +00:00
nav
014a985814 feat: YNX-0102 protocol codec + YNX-0401 server persistence types
- Add Yonexus.Protocol/src/codec.ts with encodeBuiltin/decodeBuiltin,
  rule message parsing, and type-safe envelope builders
- Add Yonexus.Server/plugin/core/persistence.ts with ClientRecord,
  ServerRegistry, and serialization helpers
- Update exports in both modules
2026-04-08 20:19:41 +00:00
nav
3b59e34eb5 feat: add protocol types and config schemas 2026-04-08 20:03:59 +00:00
nav
609563c73e chore: update yonexus submodule pointers 2026-04-08 19:33:39 +00:00
nav
8e99285633 feat: scaffold yonexus server and client plugins 2026-04-08 19:32:46 +00:00
nav
3d01ba40b0 docs: add Yonexus task breakdown 2026-04-08 19:08:23 +00:00
nav
2309425cf6 update submodule refs for initial skeleton 2026-04-01 18:11:11 +00:00
nav
f830012dbb update submodule refs for conventions docs 2026-04-01 01:58:24 +00:00
nav
26135a9659 update submodule refs for task docs 2026-04-01 01:56:37 +00:00
nav
60a11686cc update submodule refs for manifest docs 2026-04-01 01:53:36 +00:00
nav
2a60031ad5 update submodule refs for scaffold docs 2026-04-01 01:38:41 +00:00
nav
eba5f7535d update submodule refs for structure docs 2026-04-01 01:36:13 +00:00
nav
64dac56e37 add Yonexus.Protocol submodule and update architecture docs 2026-04-01 01:21:47 +00:00
nav
95dc06453a add architecture overview 2026-04-01 01:08:44 +00:00
nav
eb7a6248f8 update submodule refs after plans 2026-04-01 01:08:36 +00:00
nav
673d1bcb69 add server and client as submodules 2026-04-01 00:57:47 +00:00
nav
488fc29a89 add yonexus dual-plugin readme and manifests 2026-03-31 23:37:59 +00:00
nav
1d270110b0 define split yonexus server/client architecture 2026-03-31 23:14:05 +00:00
nav
83e02829e7 reset project and add new yonexus communication plan 2026-03-31 13:59:40 +00:00
63 changed files with 6846 additions and 2357 deletions

8
.gitignore vendored
View File

@@ -1,6 +1,2 @@
node_modules/
dist/
*.log
.DS_Store
data/*.json
!data/.gitkeep
.idea/
tests/docker/.env

9
.gitmodules vendored Normal file
View File

@@ -0,0 +1,9 @@
[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 Normal file
View File

@@ -0,0 +1,256 @@
# 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-1102Server 单元测试
- YNX-1103Client 单元测试
- YNX-1104Server-Client 集成测试
- YNX-1105失败路径测试矩阵
- YNX-1205协议测试与验收清单本文件

351
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,351 @@
# 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 Normal file
View File

@@ -0,0 +1,123 @@
# Yonexus 部署指南 (v1)
本指南面向 **单主多从** 拓扑:
- **主节点**:运行 `Yonexus.Server`
- **从节点**:运行 `Yonexus.Client`
> 说明Yonexus 采用三仓库/子模块结构Umbrella + Server + Client + Protocol
---
## 1. 拓扑与前置条件
- **主节点**需要可被从节点访问的稳定地址(域名或固定 IP
- **从节点**只需能 outbound 访问主节点 WebSocket
- 需要一个 Discord Bot Token用于向管理员 DM 配对码
- 需要管理员的 Discord User ID
---
## 2. 仓库结构与同步
在 umbrella 仓库内:
```
Yonexus/
├── Yonexus.Server
├── Yonexus.Client
├── Yonexus.Protocol
```
确保子模块已更新:
```bash
git submodule update --init --recursive
```
---
## 3. 主节点部署Yonexus.Server
### 3.1 安装与构建
```bash
cd Yonexus.Server
npm install
npm run build
```
### 3.2 配置
示例配置OpenClaw 配置中):
```json
{
"followerIdentifiers": ["client-a", "client-b"],
"notifyBotToken": "<discord-bot-token>",
"adminUserId": "123456789012345678",
"listenHost": "0.0.0.0",
"listenPort": 8787,
"publicWsUrl": "wss://example.com/yonexus"
}
```
### 3.3 启动
-`Yonexus.Server` 安装为 OpenClaw 插件
- 启动 OpenClaw Gateway 后Server 会自动启动 WebSocket 服务
---
## 4. 从节点部署Yonexus.Client
### 4.1 安装与构建
```bash
cd Yonexus.Client
npm install
npm run build
```
### 4.2 配置
```json
{
"mainHost": "wss://example.com/yonexus",
"identifier": "client-a",
"notifyBotToken": "<discord-bot-token>",
"adminUserId": "123456789012345678"
}
```
### 4.3 启动
-`Yonexus.Client` 安装为 OpenClaw 插件
- 启动 OpenClaw Gateway 后Client 会自动连接 Server
---
## 5. 首次配对流程
1. Client 连接后发送 `hello`
2. Server 检测未配对,生成配对码
3. Server 通过 Discord DM 将配对码发送给管理员
4. 管理员将配对码转交给 Client 操作员
5. Client 提交 `pair_confirm` 完成配对
6. Server 返回 `pair_success` 并下发 `secret`
7. Client 进入认证流程并开始心跳
---
## 6. 版本与兼容性
- 协议版本:`1`
- 需要确保 `Yonexus.Protocol` 子模块与 Server/Client 使用的协议一致
---
## 7. 快速验证建议
- 主节点启动后确认 WebSocket 监听端口可达
- 从节点能建立连接且收到 `hello_ack`
- 配对完成后收到 `auth_success`
- 5 分钟内可看到心跳日志/状态更新

295
FEAT.md Normal file
View File

@@ -0,0 +1,295 @@
# 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

323
LESSONS_LEARNED.md Normal file
View File

@@ -0,0 +1,323 @@
# 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_endrunId 去重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 是 stringparams 是入参对象
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 有延迟1030s。如果在 bot 完全连接前就发送 schedule trigger`<@bot_id>➡️`bot 会错过该消息WS 不推送历史消息)。
**现象**:发送了 triggerchannel 里能看到消息,但 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 → 进入 tempConnectionsassignedIdentifier = "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` 等待用户干预。

478
OPENCLAW_PLUGIN_DEV.md Normal file
View File

@@ -0,0 +1,478 @@
# 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/ # 构建产物gitignoreinstall 脚本生成
```
**约定**
- 文件名用 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 Schemaopenclaw.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" };
},
});
// 需要 ctxagentId 等)的工具:工厂函数形式
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` | 同上 |
| 全局初始化 flaggateway_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 dedupWeakSet for before_model_resolveSet+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 Normal file
View File

@@ -0,0 +1,113 @@
# Yonexus 运维与排障指南 (v1)
本指南覆盖常见运行状态、错误码与恢复步骤。
---
## 1. 运行状态速览
### Client 侧状态
- `connecting`:正在连接
- `pairing_required`:需要配对
- `waiting_pair_confirm`:等待提交配对码
- `authenticating`:认证中
- `authenticated`:已认证,心跳中
### Server 侧状态
- `online`:已认证且心跳正常
- `unstable`7 分钟未收到心跳
- `offline`11 分钟未收到心跳,已断开连接
---
## 2. 常见问题与处理
### 2.1 Client 无法连接 Server
**可能原因**
- `mainHost` 配置错误
- Server 未启动或端口不可达
**处理**
- 检查 `mainHost` 是否为 `ws://``wss://`
- 验证 Server 监听端口是否对外开放
---
### 2.2 Client 一直停在 `pairing_required`
**可能原因**
- Server 未能发送 Discord DM
- `notifyBotToken``adminUserId` 配置错误
**处理**
- 检查 Server 日志是否出现 `admin_notification_failed`
- 确认 Bot 有向目标用户发送 DM 的权限
---
### 2.3 配对码无效 / 过期
**可能原因**
- 输入错误
- 配对码超过 TTL
**处理**
- 重新触发配对流程(断线后重连)
- 确保管理员转发的配对码最新
---
### 2.4 认证失败 (`auth_failed`)
**可能原因**
- Secret 不匹配
- 时间漂移过大
- Nonce 重放或格式错误
**处理**
- 检查系统时间是否正确
- 清除 Client 本地 secret触发重新配对
---
### 2.5 频繁触发 `re_pair_required`
**可能原因**
- 非法重放或高频认证尝试
- Client 有并发连接/重连异常
**处理**
- 确认同一 `identifier` 只存在一个活跃 Client
- 检查 Client 是否重复启动多个实例
---
## 3. 错误码参考
常见协议错误码:
- `MALFORMED_MESSAGE`
- `UNSUPPORTED_PROTOCOL_VERSION`
- `IDENTIFIER_NOT_ALLOWED`
- `PAIRING_REQUIRED`
- `PAIRING_EXPIRED`
- `ADMIN_NOTIFICATION_FAILED`
- `AUTH_FAILED`
- `NONCE_COLLISION`
- `RATE_LIMITED`
- `RE_PAIR_REQUIRED`
---
## 4. 恢复步骤建议
**场景Client 无法恢复认证**
1. 停止 Client
2. 删除本地 state 中的 secret
3. 重启 Client 触发重新配对
**场景Server 端状态异常**
1. 检查持久化 store 文件是否损坏
2. 必要时备份后清理 store 文件(会导致所有 Client 重新配对)
---
## 5. 日志建议
- Server 日志中应避免输出 secret / 配对码明文
- 建议在生产环境开启结构化日志并保留最小必要字段

609
PLAN.md Normal file
View File

@@ -0,0 +1,609 @@
# 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 clients 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 Normal file
View File

@@ -0,0 +1,866 @@
# 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.

263
README.md
View File

@@ -1,118 +1,205 @@
[English](./README.md) | [中文](./README.zh.md)
# Yonexus
Yonexus is a cross-instance communication system for OpenClaw built as **three separate repositories**:
| 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 |
## Overview
### Yonexus.Server
Installed on the central OpenClaw instance.
Responsibilities:
- run the WebSocket server
- maintain the client registry
- handle pairing and authentication
- track heartbeat and liveness state
- relay messages to connected clients
- rewrite inbound client messages before rule dispatch
- notify a human administrator of pairing requests via Discord DM
### Yonexus.Client
Installed on follower OpenClaw instances.
Responsibilities:
- connect to `Yonexus.Server`
- manage local keypair and shared secret
- complete pairing with out-of-band pairing code
- authenticate on reconnect
- send periodic heartbeat
- send messages to server
- receive messages from server via rule dispatch
### Yonexus.Protocol
Shared protocol specification repository. Both `Yonexus.Server` and `Yonexus.Client` reference this as a submodule at `protocol/`.
---
# Yonexus
## Repository Structure
Yonexus is an OpenClaw plugin for organization hierarchy and agent identity management.
```
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
```
## Features
---
- 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
## Architecture
## Project Layout
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
.
├─ 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
${rule_identifier}::${message_content}
```
## Requirements
Reserved rule: `builtin`
- Node.js 22+
- npm 10+
---
## Quick Start
## Planned Server Config
```bash
npm install
npm run build
npm run test:smoke
npm run demo
```json
{
"followerIdentifiers": ["client-a", "client-b"],
"notifyBotToken": "<discord-bot-token>",
"adminUserId": "123456789012345678",
"listenHost": "0.0.0.0",
"listenPort": 8787,
"publicWsUrl": "wss://example.com/yonexus"
}
```
## Install / Uninstall
## Planned Client Config
```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
```json
{
"mainHost": "wss://example.com/yonexus",
"identifier": "client-a",
"notifyBotToken": "<discord-bot-token>",
"adminUserId": "123456789012345678"
}
```
## Configuration
---
`plugin.json` includes default config:
## Shared Terminology
- `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
To keep the umbrella repo, protocol repo, and both plugin repos aligned, Yonexus uses these terms consistently:
## Implemented APIs
- `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`.
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)`
## v1 Scope Boundaries
Management:
- `renameDepartment(actor, deptId, newName)`
- `renameTeam(actor, teamId, newName, deptId?)`
- `migrateTeam(actor, teamId, newDeptId)`
- `deleteDepartment(actor, deptId)`
- `deleteTeam(actor, teamId, deptId?)`
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
Docs:
- `getDocs(scope, topic, keyword)`
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
Data & audit:
- `exportData(actor)`
- `importData(actor, state)`
- `listAuditLogs(limit?, offset?)`
## Status
## Testing
- 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
```bash
npm run test:smoke
```
---
## Notes
## Repository URLs
- 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.
- [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)

View File

@@ -1,118 +0,0 @@
[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 Normal file

File diff suppressed because it is too large Load Diff

1
Yonexus.Client Submodule

Submodule Yonexus.Client added at 8b26919790

1
Yonexus.Protocol Submodule

Submodule Yonexus.Protocol added at 2611304084

1
Yonexus.Server Submodule

Submodule Yonexus.Server added at a8748f8c55

View File

View File

View File

@@ -1,54 +0,0 @@
# 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] paginationlimit/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 关系不隐含权限

View File

@@ -1,86 +0,0 @@
# 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.

View File

@@ -1,127 +0,0 @@
# 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.

View File

@@ -1,44 +0,0 @@
{
"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" }
]
}

View File

@@ -1,209 +0,0 @@
#!/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
View File

@@ -1,591 +0,0 @@
{
"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"
}
}
}

View File

@@ -1,21 +0,0 @@
{
"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"
}
}

13
plugin.client.json Normal file
View File

@@ -0,0 +1,13 @@
{
"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": ""
}
}

View File

@@ -1,21 +0,0 @@
{
"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 }
}
}
}

15
plugin.server.json Normal file
View File

@@ -0,0 +1,15 @@
{
"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": ""
}
}

View File

@@ -1,18 +0,0 @@
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 }
};

View File

@@ -1,16 +0,0 @@
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 });
}
}

View File

@@ -1,10 +0,0 @@
export interface AuditLogEntry {
id: string;
ts: string;
actorId: string;
action: string;
target?: string;
status: 'ok' | 'error';
message?: string;
meta?: Record<string, unknown>;
}

View File

@@ -1,19 +0,0 @@
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';
}
}

View File

@@ -1,93 +0,0 @@
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;
}

View File

@@ -1,40 +0,0 @@
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
});
}
}

View File

@@ -1,19 +0,0 @@
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);
}
}

View File

@@ -1,156 +0,0 @@
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);
}
}

View File

@@ -1,18 +0,0 @@
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");
}

View File

@@ -1,10 +0,0 @@
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"}`;
}

View File

@@ -1,245 +0,0 @@
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();
}
}

View File

View File

@@ -1,45 +0,0 @@
/**
* 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";

View File

@@ -1,13 +0,0 @@
{
"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 }
}
}
}

View File

@@ -1,75 +0,0 @@
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);
}

View File

@@ -1,111 +0,0 @@
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');
}
}

View File

@@ -1,34 +0,0 @@
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));

47
scripts/validate-v1.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/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'

View File

View File

@@ -0,0 +1,33 @@
// 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');
}

View File

@@ -0,0 +1,13 @@
{
"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": {}
}
}

View File

@@ -0,0 +1,37 @@
# 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"]

View File

@@ -0,0 +1,69 @@
#!/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

View File

@@ -0,0 +1,46 @@
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

View File

@@ -0,0 +1,39 @@
// 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');
}

View File

@@ -0,0 +1,13 @@
{
"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": {}
}
}

View File

@@ -0,0 +1,40 @@
# 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"]

View File

@@ -0,0 +1,71 @@
#!/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

View File

@@ -0,0 +1,167 @@
# 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-04key 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/`

View File

@@ -0,0 +1,666 @@
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();
});
});
});

View File

@@ -0,0 +1,630 @@
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");
});
});
});

View File

@@ -1,54 +0,0 @@
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');

View File

@@ -1,16 +0,0 @@
{
"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"]
}