Compare commits
4 Commits
main
...
220ec85e6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 220ec85e6f | |||
| 00108c357b | |||
| 3b26f3d083 | |||
| a0e926594f |
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/*.json
|
||||||
|
!data/.gitkeep
|
||||||
9
.gitmodules
vendored
9
.gitmodules
vendored
@@ -1,9 +0,0 @@
|
|||||||
[submodule "Yonexus.Server"]
|
|
||||||
path = Yonexus.Server
|
|
||||||
url = https://git.hangman-lab.top/nav/Yonexus.Server.git
|
|
||||||
[submodule "Yonexus.Client"]
|
|
||||||
path = Yonexus.Client
|
|
||||||
url = https://git.hangman-lab.top/nav/Yonexus.Client.git
|
|
||||||
[submodule "Yonexus.Protocol"]
|
|
||||||
path = Yonexus.Protocol
|
|
||||||
url = https://git.hangman-lab.top/nav/Yonexus.Protocol.git
|
|
||||||
351
ARCHITECTURE.md
351
ARCHITECTURE.md
@@ -1,351 +0,0 @@
|
|||||||
# Yonexus — Architecture Overview
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
Yonexus is a cross-instance communication system for OpenClaw.
|
|
||||||
|
|
||||||
The repository `Yonexus` is the **umbrella/specification repository** for the system. It contains:
|
|
||||||
- high-level planning
|
|
||||||
- architecture documents
|
|
||||||
- references to implementation repositories as git submodules
|
|
||||||
|
|
||||||
Yonexus is implemented as three repositories:
|
|
||||||
- `Yonexus.Server` — central hub plugin
|
|
||||||
- `Yonexus.Client` — client plugin
|
|
||||||
- `Yonexus.Protocol` — shared protocol specification, referenced as a submodule by both
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Repository Roles
|
|
||||||
|
|
||||||
## 2.1 `Yonexus` (umbrella repo)
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- system-level planning
|
|
||||||
- architecture documents
|
|
||||||
- cross-cutting decisions that apply to both server and client
|
|
||||||
- coordination of sub-repositories via git submodules
|
|
||||||
|
|
||||||
This repository should contain:
|
|
||||||
- top-level planning docs
|
|
||||||
- architecture overview
|
|
||||||
- feature checklists
|
|
||||||
- cross-cutting design rationale
|
|
||||||
|
|
||||||
It references:
|
|
||||||
- `Yonexus.Server` (submodule)
|
|
||||||
- `Yonexus.Client` (submodule)
|
|
||||||
- `Yonexus.Protocol` (submodule)
|
|
||||||
|
|
||||||
## 2.2 `Yonexus.Protocol`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- protocol specification (PROTOCOL.md)
|
|
||||||
- canonical JSON shape references
|
|
||||||
- shared type definitions (planned)
|
|
||||||
|
|
||||||
Referenced as a submodule by:
|
|
||||||
- `Yonexus.Server/protocol`
|
|
||||||
- `Yonexus.Client/protocol`
|
|
||||||
|
|
||||||
This is the **single source of truth** for the Yonexus protocol. Both server and client implementations must conform to the protocol defined here.
|
|
||||||
|
|
||||||
## 2.3 `Yonexus.Server`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- implementation of the central hub/server plugin
|
|
||||||
- server-side connection management
|
|
||||||
- server-side pairing/authentication/state tracking
|
|
||||||
- server-side dispatch and routing behavior
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
- `PLAN.md`
|
|
||||||
- implementation code
|
|
||||||
|
|
||||||
## 2.4 `Yonexus.Client`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- implementation of the client plugin
|
|
||||||
- outbound connection to `Yonexus.Server`
|
|
||||||
- local identity/keypair/secret management
|
|
||||||
- client-side pairing confirmation and authenticated reconnect
|
|
||||||
- client-side heartbeat and message sending
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
- `PLAN.md`
|
|
||||||
- implementation code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Repository Graph
|
|
||||||
|
|
||||||
```
|
|
||||||
Yonexus (umbrella)
|
|
||||||
├── Yonexus.Protocol (submodule)
|
|
||||||
├── Yonexus.Server (submodule)
|
|
||||||
│ └── protocol/ (nested submodule -> Yonexus.Protocol)
|
|
||||||
└── Yonexus.Client (submodule)
|
|
||||||
└── protocol/ (nested submodule -> Yonexus.Protocol)
|
|
||||||
```
|
|
||||||
|
|
||||||
Policy:
|
|
||||||
- protocol changes are always committed to `Yonexus.Protocol` first
|
|
||||||
- `Yonexus.Server` and `Yonexus.Client` update their `protocol/` submodule ref after protocol version is stable
|
|
||||||
- umbrella `Yonexus` updates its submodule refs after server/client have stable versions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. System Topology
|
|
||||||
|
|
||||||
A Yonexus deployment contains:
|
|
||||||
- exactly one `Yonexus.Server` instance
|
|
||||||
- one or more `Yonexus.Client` instances
|
|
||||||
|
|
||||||
Topology assumptions:
|
|
||||||
- `Yonexus.Server` runs on an OpenClaw instance reachable at a stable address
|
|
||||||
- each `Yonexus.Client` connects outbound to the server
|
|
||||||
- clients do not directly connect to each other in v1
|
|
||||||
- cross-client coordination is relayed through the server
|
|
||||||
|
|
||||||
Visual model:
|
|
||||||
|
|
||||||
```
|
|
||||||
Yonexus.Client A --->
|
|
||||||
\
|
|
||||||
Yonexus.Client B ----> Yonexus.Server
|
|
||||||
/
|
|
||||||
Yonexus.Client C --->
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Shared vs Split Responsibilities
|
|
||||||
|
|
||||||
## 5.1 Yonexus.Protocol — Shared
|
|
||||||
|
|
||||||
These belong to the protocol repo and apply to both plugins:
|
|
||||||
- protocol format and message categories
|
|
||||||
- builtin message types and their semantics
|
|
||||||
- pairing security model
|
|
||||||
- nonce/timestamp validation rules
|
|
||||||
- heartbeat timing rules
|
|
||||||
- message rewrite rules
|
|
||||||
- reserved rule namespace (`builtin`)
|
|
||||||
- canonical JSON shapes
|
|
||||||
- naming and terminology
|
|
||||||
|
|
||||||
## 5.2 Server-Only Concerns (Yonexus.Server)
|
|
||||||
|
|
||||||
These belong in `Yonexus.Server`:
|
|
||||||
- WebSocket server startup
|
|
||||||
- listen host/port config
|
|
||||||
- client registry persistence
|
|
||||||
- public key / secret storage
|
|
||||||
- pairing code generation
|
|
||||||
- Discord DM notification to admin
|
|
||||||
- auth proof verification
|
|
||||||
- liveness status tracking
|
|
||||||
- client message rewriting and dispatch on server side
|
|
||||||
- sending messages to connected clients
|
|
||||||
|
|
||||||
## 5.3 Client-Only Concerns (Yonexus.Client)
|
|
||||||
|
|
||||||
These belong in `Yonexus.Client`:
|
|
||||||
- WebSocket client connection management
|
|
||||||
- reconnect/backoff logic
|
|
||||||
- local keypair generation
|
|
||||||
- local secret persistence
|
|
||||||
- pairing code submission
|
|
||||||
- auth proof construction/signing
|
|
||||||
- heartbeat sending
|
|
||||||
- sending messages to server
|
|
||||||
- receiving server messages and local dispatch
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Communication Model
|
|
||||||
|
|
||||||
## 6.1 Transport
|
|
||||||
|
|
||||||
Transport is WebSocket.
|
|
||||||
|
|
||||||
- `Yonexus.Server` acts as server
|
|
||||||
- `Yonexus.Client` acts as client
|
|
||||||
|
|
||||||
## 6.2 Message Categories
|
|
||||||
|
|
||||||
Two message categories exist on the same transport:
|
|
||||||
|
|
||||||
### Builtin protocol messages
|
|
||||||
Used for:
|
|
||||||
- hello/session setup
|
|
||||||
- pairing
|
|
||||||
- authentication
|
|
||||||
- heartbeat
|
|
||||||
- lifecycle/status
|
|
||||||
- protocol errors
|
|
||||||
|
|
||||||
Format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin::{json}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Application rule messages
|
|
||||||
Used for higher-level cross-instance communication.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
Server rewrite rule:
|
|
||||||
|
|
||||||
When server receives a message from a client, before dispatch it rewrites:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${sender_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Security Architecture
|
|
||||||
|
|
||||||
## 7.1 Pairing Model
|
|
||||||
|
|
||||||
Pairing is intentionally out-of-band.
|
|
||||||
|
|
||||||
When a new client needs pairing:
|
|
||||||
- server generates a pairing code
|
|
||||||
- server sends that code to a human administrator via Discord DM
|
|
||||||
- server does **not** send the code over the Yonexus WebSocket channel
|
|
||||||
- human relays the code to the client side manually
|
|
||||||
- client submits the code back to the server
|
|
||||||
|
|
||||||
This preserves a basic human-mediated trust step.
|
|
||||||
|
|
||||||
## 7.2 Post-Pairing Authentication
|
|
||||||
|
|
||||||
After pairing:
|
|
||||||
- server issues a shared secret
|
|
||||||
- client stores secret locally
|
|
||||||
- client already has a private key
|
|
||||||
- reconnect auth uses signed proof derived from:
|
|
||||||
- secret
|
|
||||||
- nonce
|
|
||||||
- timestamp
|
|
||||||
|
|
||||||
## 7.3 Replay Protection
|
|
||||||
|
|
||||||
Server enforces:
|
|
||||||
- timestamp freshness (`< 10s` drift)
|
|
||||||
- nonce collision detection
|
|
||||||
- handshake rate threshold (`>10 attempts in 10s` is unsafe)
|
|
||||||
- re-pair requirement after unsafe conditions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. State Ownership
|
|
||||||
|
|
||||||
## 8.1 Server-Owned State
|
|
||||||
|
|
||||||
Canonical server-owned state includes:
|
|
||||||
- allowed client identifiers
|
|
||||||
- trust state for each client
|
|
||||||
- client public key
|
|
||||||
- client secret
|
|
||||||
- pairing state
|
|
||||||
- pairing notification state
|
|
||||||
- recent nonce window
|
|
||||||
- recent handshake attempt window
|
|
||||||
- client liveness state
|
|
||||||
|
|
||||||
## 8.2 Client-Owned State
|
|
||||||
|
|
||||||
Canonical client-owned state includes:
|
|
||||||
- client identifier
|
|
||||||
- client private key
|
|
||||||
- client public key
|
|
||||||
- current shared secret
|
|
||||||
- last successful local trust metadata if needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Plugin API Boundaries
|
|
||||||
|
|
||||||
## 9.1 Yonexus.Server API
|
|
||||||
|
|
||||||
Planned public API:
|
|
||||||
- `sendMessageToClient(identifier, message)`
|
|
||||||
- `registerRule(rule, processor)`
|
|
||||||
|
|
||||||
## 9.2 Yonexus.Client API
|
|
||||||
|
|
||||||
Planned public API:
|
|
||||||
- `sendMessageToServer(message)`
|
|
||||||
- `registerRule(rule, processor)`
|
|
||||||
|
|
||||||
The protocol defines semantics; implementation details belong in each submodule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Documentation Ownership
|
|
||||||
|
|
||||||
## 10.1 Umbrella Repo Docs
|
|
||||||
|
|
||||||
Should contain:
|
|
||||||
- system architecture
|
|
||||||
- cross-cutting feature list
|
|
||||||
- global design rationale
|
|
||||||
- cross-repo coordination notes
|
|
||||||
|
|
||||||
## 10.2 Protocol Repo Docs
|
|
||||||
|
|
||||||
Must contain:
|
|
||||||
- protocol specification (PROTOCOL.md)
|
|
||||||
- canonical message shapes
|
|
||||||
- protocol versioning notes
|
|
||||||
|
|
||||||
## 10.3 Server Repo Docs
|
|
||||||
|
|
||||||
Should contain:
|
|
||||||
- server setup
|
|
||||||
- server config reference
|
|
||||||
- server persistence model
|
|
||||||
- server operational behavior
|
|
||||||
- implementation tasks
|
|
||||||
|
|
||||||
## 10.4 Client Repo Docs
|
|
||||||
|
|
||||||
Should contain:
|
|
||||||
- client setup
|
|
||||||
- client config reference
|
|
||||||
- client local storage model
|
|
||||||
- client reconnect/heartbeat behavior
|
|
||||||
- implementation tasks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Development Flow
|
|
||||||
|
|
||||||
Recommended flow:
|
|
||||||
1. define cross-cutting behavior in `Yonexus` umbrella
|
|
||||||
2. finalize protocol in `Yonexus.Protocol`
|
|
||||||
3. update submodule refs in `Yonexus.Server` and `Yonexus.Client`
|
|
||||||
4. implement server-side protocol handling in `Yonexus.Server`
|
|
||||||
5. implement client-side protocol handling in `Yonexus.Client`
|
|
||||||
6. keep protocol changes synchronized back into umbrella docs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Non-Goals of the Umbrella Repo
|
|
||||||
|
|
||||||
The umbrella repo should avoid becoming:
|
|
||||||
- the place where all implementation code lives
|
|
||||||
- a dumping ground for server-only or client-only details
|
|
||||||
- a duplicate of submodule READMEs without system-level value
|
|
||||||
|
|
||||||
Its job is coordination, not code concentration.
|
|
||||||
268
FEAT.md
268
FEAT.md
@@ -1,268 +0,0 @@
|
|||||||
# Yonexus — Feature Checklist
|
|
||||||
|
|
||||||
## Project Direction
|
|
||||||
|
|
||||||
Yonexus is a **two-plugin** cross-instance communication system for OpenClaw:
|
|
||||||
- `Yonexus.Server`
|
|
||||||
- `Yonexus.Client`
|
|
||||||
|
|
||||||
This repository now targets the split-plugin architecture only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
579
PLAN.md
579
PLAN.md
@@ -1,579 +0,0 @@
|
|||||||
# Yonexus — Project Plan
|
|
||||||
|
|
||||||
## 1. Goal
|
|
||||||
|
|
||||||
Yonexus is a cross-instance communication system for OpenClaw, implemented as **two separate plugins**:
|
|
||||||
|
|
||||||
- `Yonexus.Server`
|
|
||||||
- `Yonexus.Client`
|
|
||||||
|
|
||||||
Together they provide:
|
|
||||||
- communication between multiple OpenClaw instances
|
|
||||||
- a central WebSocket hub model
|
|
||||||
- client pairing and authentication
|
|
||||||
- heartbeat-based client liveness tracking
|
|
||||||
- rule-based message dispatch
|
|
||||||
- out-of-band pairing notification to a human administrator via Discord DM
|
|
||||||
- TypeScript interfaces for higher-level plugin/runtime integrations
|
|
||||||
|
|
||||||
This project is no longer a role-switched single plugin. It is now explicitly split into two installable plugins with distinct responsibilities.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Plugin Split
|
|
||||||
|
|
||||||
## 2.1 Yonexus.Server
|
|
||||||
|
|
||||||
`Yonexus.Server` is installed only on the main OpenClaw instance.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- start and maintain the WebSocket server
|
|
||||||
- accept incoming client connections
|
|
||||||
- maintain the client registry
|
|
||||||
- handle pairing flow
|
|
||||||
- verify authentication proofs
|
|
||||||
- track heartbeat and connection state
|
|
||||||
- route or relay messages to connected clients
|
|
||||||
- rewrite inbound client messages before rule dispatch
|
|
||||||
- send Discord DM pairing notifications to the human administrator
|
|
||||||
|
|
||||||
## 2.2 Yonexus.Client
|
|
||||||
|
|
||||||
`Yonexus.Client` is installed on follower OpenClaw instances.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- connect to the configured Yonexus server
|
|
||||||
- generate and persist local keypair on first use
|
|
||||||
- persist local client identity and secret
|
|
||||||
- perform pairing confirmation
|
|
||||||
- perform authenticated reconnect
|
|
||||||
- send periodic heartbeats
|
|
||||||
- expose client-side messaging and rule registration APIs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Deployment Model
|
|
||||||
|
|
||||||
A Yonexus network contains:
|
|
||||||
- exactly one OpenClaw instance running `Yonexus.Server`
|
|
||||||
- one or more OpenClaw instances running `Yonexus.Client`
|
|
||||||
|
|
||||||
Topology rules:
|
|
||||||
- `Yonexus.Server` must be reachable via fixed IP/domain or otherwise stable addressable endpoint
|
|
||||||
- `Yonexus.Client` instances do not need stable public IP/domain
|
|
||||||
- all `Yonexus.Client` instances connect outbound to the `Yonexus.Server` WebSocket endpoint
|
|
||||||
- no direct client-to-client communication is required in v1
|
|
||||||
- inter-client communication, if needed, is relayed by `Yonexus.Server`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Configuration Model
|
|
||||||
|
|
||||||
## 4.1 Yonexus.Server Config
|
|
||||||
|
|
||||||
```ts
|
|
||||||
followerIdentifiers: string[]
|
|
||||||
notifyBotToken: string
|
|
||||||
adminUserId: string
|
|
||||||
listenHost?: string
|
|
||||||
listenPort: number
|
|
||||||
publicWsUrl?: string
|
|
||||||
```
|
|
||||||
|
|
||||||
Semantics:
|
|
||||||
- `followerIdentifiers`: allowlist of client identifiers permitted to pair/connect
|
|
||||||
- `notifyBotToken`: Discord bot token used to send pairing notifications
|
|
||||||
- `adminUserId`: Discord user id of the human administrator who receives pairing codes by DM
|
|
||||||
- `listenHost`: local bind host for WebSocket server
|
|
||||||
- `listenPort`: local bind port for WebSocket server
|
|
||||||
- `publicWsUrl`: optional canonical external URL advertised/documented for clients
|
|
||||||
|
|
||||||
## 4.2 Yonexus.Client Config
|
|
||||||
|
|
||||||
```ts
|
|
||||||
mainHost: string
|
|
||||||
identifier: string
|
|
||||||
notifyBotToken: string
|
|
||||||
adminUserId: string
|
|
||||||
```
|
|
||||||
|
|
||||||
Semantics:
|
|
||||||
- `mainHost`: WebSocket endpoint of `Yonexus.Server`
|
|
||||||
- `identifier`: unique identity of this client inside the Yonexus network
|
|
||||||
- `notifyBotToken`: kept aligned with shared config expectations if future client-side notification behaviors are needed
|
|
||||||
- `adminUserId`: human administrator identity reference shared with the Yonexus system
|
|
||||||
|
|
||||||
## 4.3 Validation Rules
|
|
||||||
|
|
||||||
### Yonexus.Server
|
|
||||||
- must provide `followerIdentifiers`
|
|
||||||
- must provide `notifyBotToken`
|
|
||||||
- must provide `adminUserId`
|
|
||||||
- must provide `listenPort`
|
|
||||||
- must be deployed on a reachable/stable endpoint
|
|
||||||
|
|
||||||
### Yonexus.Client
|
|
||||||
- must provide `mainHost`
|
|
||||||
- must provide `identifier`
|
|
||||||
- must provide `notifyBotToken`
|
|
||||||
- must provide `adminUserId`
|
|
||||||
|
|
||||||
### Shared
|
|
||||||
- invalid or missing required fields must fail plugin initialization
|
|
||||||
- unknown client identifiers must be rejected by `Yonexus.Server`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Runtime Lifecycle
|
|
||||||
|
|
||||||
## 5.1 Yonexus.Server Startup
|
|
||||||
|
|
||||||
On OpenClaw gateway startup:
|
|
||||||
- initialize persistent client registry
|
|
||||||
- start WebSocket server
|
|
||||||
- register builtin protocol handlers
|
|
||||||
- register application rule registry
|
|
||||||
- start heartbeat/status sweep timer
|
|
||||||
|
|
||||||
## 5.2 Yonexus.Client Startup
|
|
||||||
|
|
||||||
On OpenClaw gateway startup:
|
|
||||||
- load local persisted identity, private key, and secret state
|
|
||||||
- generate keypair if absent
|
|
||||||
- connect to `mainHost`
|
|
||||||
- perform pairing or authentication flow depending on local state
|
|
||||||
- start heartbeat schedule after successful authentication
|
|
||||||
- attempt reconnect when disconnected
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Server Registry and Persistence
|
|
||||||
|
|
||||||
`Yonexus.Server` must maintain a registry keyed by client `identifier`.
|
|
||||||
|
|
||||||
Each client record contains at minimum:
|
|
||||||
- `identifier`
|
|
||||||
- `publicKey`
|
|
||||||
- `secret`
|
|
||||||
- pairing state
|
|
||||||
- pairing expiration data
|
|
||||||
- pairing notification metadata
|
|
||||||
- connection status
|
|
||||||
- security counters/window data
|
|
||||||
- heartbeat timestamps
|
|
||||||
- last known session metadata
|
|
||||||
|
|
||||||
The registry must use:
|
|
||||||
- in-memory runtime state for active connections and recent security windows
|
|
||||||
- persistent on-disk storage for durable trust state
|
|
||||||
|
|
||||||
### 6.1 Proposed Server Record Shape
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ClientRecord {
|
|
||||||
identifier: string;
|
|
||||||
publicKey?: string;
|
|
||||||
secret?: string;
|
|
||||||
pairingStatus: "unpaired" | "pending" | "paired" | "revoked";
|
|
||||||
pairingCode?: string;
|
|
||||||
pairingExpiresAt?: number;
|
|
||||||
pairingNotifiedAt?: number;
|
|
||||||
pairingNotifyStatus?: "pending" | "sent" | "failed";
|
|
||||||
status: "online" | "offline" | "unstable";
|
|
||||||
lastHeartbeatAt?: number;
|
|
||||||
lastAuthenticatedAt?: number;
|
|
||||||
recentNonces: Array<{
|
|
||||||
nonce: string;
|
|
||||||
timestamp: number;
|
|
||||||
}>;
|
|
||||||
recentHandshakeAttempts: number[];
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Pairing and Authentication
|
|
||||||
|
|
||||||
## 7.1 First Connection and Key Generation
|
|
||||||
|
|
||||||
When a client connects to the server for the first time:
|
|
||||||
- `Yonexus.Client` generates a public/private key pair locally
|
|
||||||
- the private key remains only on the client instance
|
|
||||||
- the public key is sent to `Yonexus.Server` during handshake
|
|
||||||
|
|
||||||
If the server sees that:
|
|
||||||
- the client identifier is allowed, and
|
|
||||||
- there is no valid `secret` currently associated with that identifier
|
|
||||||
|
|
||||||
then the server must enter pairing flow.
|
|
||||||
|
|
||||||
## 7.2 Pairing Flow
|
|
||||||
|
|
||||||
### Step A: Pairing Request Creation
|
|
||||||
`Yonexus.Server` generates:
|
|
||||||
- a random pairing string
|
|
||||||
- an expiration time
|
|
||||||
|
|
||||||
The pairing string must **not** be sent to the client over WebSocket.
|
|
||||||
|
|
||||||
Instead, `Yonexus.Server` uses `notifyBotToken` to send a Discord DM to `adminUserId` containing:
|
|
||||||
- the client `identifier`
|
|
||||||
- the generated `pairingCode`
|
|
||||||
- the expiration time
|
|
||||||
|
|
||||||
### Step B: Pairing Confirmation
|
|
||||||
The client must provide the pairing code back to the server before expiration.
|
|
||||||
|
|
||||||
How the client operator obtains the pairing code is intentionally out-of-band from the Yonexus WebSocket channel. The server only trusts that the code came through some human-mediated path.
|
|
||||||
|
|
||||||
If the client sends the correct pairing code before expiration:
|
|
||||||
- pairing succeeds
|
|
||||||
|
|
||||||
### Step C: Secret Issuance
|
|
||||||
After successful pairing:
|
|
||||||
- `Yonexus.Server` generates a random `secret`
|
|
||||||
- `Yonexus.Server` returns that `secret` to the client
|
|
||||||
- `Yonexus.Server` stores client `publicKey` + `secret`
|
|
||||||
- `Yonexus.Client` stores private key + secret locally
|
|
||||||
|
|
||||||
If Discord DM delivery fails:
|
|
||||||
- pairing must not proceed
|
|
||||||
- server should mark the pairing attempt as failed or pending-error
|
|
||||||
- client must not receive a usable pairing code through the protocol channel
|
|
||||||
|
|
||||||
If pairing expires before confirmation:
|
|
||||||
- pairing fails
|
|
||||||
- the client must restart the pairing process
|
|
||||||
|
|
||||||
## 7.3 Reconnect Authentication Flow
|
|
||||||
|
|
||||||
After pairing is complete, future client authentication must use:
|
|
||||||
- the stored `secret`
|
|
||||||
- a 24-character random nonce
|
|
||||||
- current UTC Unix timestamp
|
|
||||||
|
|
||||||
The client builds a proof payload from:
|
|
||||||
- `secret`
|
|
||||||
- `nonce`
|
|
||||||
- `timestamp`
|
|
||||||
|
|
||||||
Logical concatenation order:
|
|
||||||
|
|
||||||
```text
|
|
||||||
secret + nonce + timestamp
|
|
||||||
```
|
|
||||||
|
|
||||||
Implementation recommendation:
|
|
||||||
- use a canonical serialized object and sign its bytes rather than naive string concatenation in code
|
|
||||||
|
|
||||||
The client signs the proof using its private key and sends it to the server.
|
|
||||||
|
|
||||||
The server verifies:
|
|
||||||
1. identifier is known and paired
|
|
||||||
2. public key matches stored state
|
|
||||||
3. proof contains the correct `secret`
|
|
||||||
4. timestamp difference from current time is less than 10 seconds
|
|
||||||
5. nonce does not collide with the recent nonce window
|
|
||||||
6. handshake attempts in the last 10 seconds do not exceed 10
|
|
||||||
|
|
||||||
If all checks pass:
|
|
||||||
- authentication succeeds
|
|
||||||
- the client is considered authenticated for the session
|
|
||||||
|
|
||||||
If any check fails:
|
|
||||||
- authentication fails
|
|
||||||
- server may downgrade or revoke trust state
|
|
||||||
|
|
||||||
## 7.4 Unsafe Condition Handling
|
|
||||||
|
|
||||||
The connection is considered unsafe and must return to pairing flow if either is true:
|
|
||||||
- more than 10 handshake attempts occur within 10 seconds
|
|
||||||
- the presented nonce collides with one of the last 10 nonces observed within the recent window
|
|
||||||
|
|
||||||
When unsafe:
|
|
||||||
- existing trust state must no longer be accepted for authentication
|
|
||||||
- the client must re-pair
|
|
||||||
- server should clear or rotate the stored `secret`
|
|
||||||
- server should reset security windows as part of re-pairing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Heartbeat and Client Status
|
|
||||||
|
|
||||||
The server must track each client’s liveness state:
|
|
||||||
- `online`
|
|
||||||
- `unstable`
|
|
||||||
- `offline`
|
|
||||||
|
|
||||||
## 8.1 Heartbeat Rules
|
|
||||||
|
|
||||||
Each client must send a heartbeat to the server every 5 minutes.
|
|
||||||
|
|
||||||
## 8.2 Status Transitions
|
|
||||||
|
|
||||||
### online
|
|
||||||
A client is `online` when:
|
|
||||||
- it has an active authenticated WebSocket connection, and
|
|
||||||
- the server has received a recent heartbeat
|
|
||||||
|
|
||||||
### unstable
|
|
||||||
A client becomes `unstable` when:
|
|
||||||
- no heartbeat has been received for 7 minutes
|
|
||||||
|
|
||||||
### offline
|
|
||||||
A client becomes `offline` when:
|
|
||||||
- no heartbeat has been received for 11 minutes
|
|
||||||
|
|
||||||
When a client becomes `offline`:
|
|
||||||
- the server must close/terminate the WebSocket connection for that client
|
|
||||||
|
|
||||||
## 8.3 Status Evaluation Strategy
|
|
||||||
|
|
||||||
The server should run a periodic status sweep timer.
|
|
||||||
|
|
||||||
Recommended interval:
|
|
||||||
- every 30 to 60 seconds
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Messaging Model
|
|
||||||
|
|
||||||
Yonexus provides rule-based message dispatch over WebSocket.
|
|
||||||
|
|
||||||
## 9.1 Base Message Format
|
|
||||||
|
|
||||||
All application messages must use the format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9.2 Server-Side Rewriting
|
|
||||||
|
|
||||||
When `Yonexus.Server` receives a message from a client, before rule matching it must rewrite the message into:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${rule_identifier}::${sender_identifier}::${message_content}
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures server-side processors can identify which client sent the message.
|
|
||||||
|
|
||||||
## 9.3 Builtin Rule Namespace
|
|
||||||
|
|
||||||
The reserved rule identifier is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
builtin
|
|
||||||
```
|
|
||||||
|
|
||||||
It is used internally for:
|
|
||||||
- handshake
|
|
||||||
- pairing
|
|
||||||
- heartbeat
|
|
||||||
- protocol/system messages
|
|
||||||
|
|
||||||
User code must not be allowed to register handlers for `builtin`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. TypeScript API Surface
|
|
||||||
|
|
||||||
## 10.1 Yonexus.Client API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
sendMessageToServer(message: string): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- sends message to connected `Yonexus.Server`
|
|
||||||
- message must already conform to `${rule_identifier}::${message_content}`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- rejects `builtin`
|
|
||||||
- rejects duplicate rule registration unless explicit override support is added later
|
|
||||||
|
|
||||||
## 10.2 Yonexus.Server API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
sendMessageToClient(identifier: string, message: string): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- target client must be known and currently connected/authenticated
|
|
||||||
- message must already conform to `${rule_identifier}::${message_content}`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- rejects `builtin`
|
|
||||||
- rejects duplicate rule registration unless explicit override support is added later
|
|
||||||
- processors are invoked with the final received string after any server-side rewrite
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Hooks and Integration
|
|
||||||
|
|
||||||
## 11.1 Yonexus.Server Hooking
|
|
||||||
|
|
||||||
`Yonexus.Server` must register hooks so that when OpenClaw gateway starts:
|
|
||||||
- the WebSocket server is started
|
|
||||||
- the server registry is initialized
|
|
||||||
- builtin protocol handling is enabled
|
|
||||||
- heartbeat sweep begins
|
|
||||||
|
|
||||||
## 11.2 Yonexus.Client Behavior
|
|
||||||
|
|
||||||
`Yonexus.Client` must:
|
|
||||||
- connect outbound to `mainHost`
|
|
||||||
- manage local trust material
|
|
||||||
- handle pairing/authentication transitions
|
|
||||||
- emit heartbeats after authentication
|
|
||||||
- reconnect after disconnect with retry/backoff behavior
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Storage Strategy
|
|
||||||
|
|
||||||
## 12.1 Yonexus.Server Storage
|
|
||||||
|
|
||||||
Server persists at minimum:
|
|
||||||
- identifier
|
|
||||||
- public key
|
|
||||||
- secret
|
|
||||||
- trust state
|
|
||||||
- pairing code + expiry if pairing is pending
|
|
||||||
- pairing notification metadata
|
|
||||||
- last known status
|
|
||||||
- metadata timestamps
|
|
||||||
|
|
||||||
May persist or reset on restart:
|
|
||||||
- recent nonces
|
|
||||||
- recent handshake attempts
|
|
||||||
|
|
||||||
Recommended v1:
|
|
||||||
- clear rolling security windows on restart
|
|
||||||
- keep long-lived trust records
|
|
||||||
|
|
||||||
## 12.2 Yonexus.Client Storage
|
|
||||||
|
|
||||||
Client persists at minimum:
|
|
||||||
- identifier
|
|
||||||
- private key
|
|
||||||
- secret
|
|
||||||
- optional last successful pair/auth metadata
|
|
||||||
|
|
||||||
Security notes:
|
|
||||||
- private key must never be sent to the server
|
|
||||||
- secret must be treated as sensitive material
|
|
||||||
- encryption-at-rest can be a future enhancement, but any plaintext local storage must be documented as a limitation if used initially
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Error Handling
|
|
||||||
|
|
||||||
Structured errors should exist for at least:
|
|
||||||
- invalid configuration
|
|
||||||
- unauthorized identifier
|
|
||||||
- pairing required
|
|
||||||
- pairing expired
|
|
||||||
- pairing notification failure
|
|
||||||
- handshake verification failure
|
|
||||||
- replay/nonce collision detected
|
|
||||||
- unsafe handshake rate detected
|
|
||||||
- target client not connected
|
|
||||||
- duplicate rule registration
|
|
||||||
- reserved rule registration
|
|
||||||
- malformed message
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Initial Implementation Phases
|
|
||||||
|
|
||||||
## Phase 0 — Protocol and Skeleton
|
|
||||||
- finalize split-plugin configuration schema
|
|
||||||
- define persistent data models
|
|
||||||
- define builtin protocol messages
|
|
||||||
- define startup hooks for both plugins
|
|
||||||
- define rule registry behavior
|
|
||||||
- define Discord DM notification flow
|
|
||||||
|
|
||||||
## Phase 1 — Transport MVP
|
|
||||||
- Yonexus.Server WebSocket server startup
|
|
||||||
- Yonexus.Client WebSocket client startup
|
|
||||||
- reconnect logic
|
|
||||||
- builtin protocol channel
|
|
||||||
- persistent registry/state scaffolding
|
|
||||||
|
|
||||||
## Phase 2 — Pairing and Authentication
|
|
||||||
- client keypair generation
|
|
||||||
- pairing request creation
|
|
||||||
- Discord DM notification to admin user
|
|
||||||
- pairing confirmation flow
|
|
||||||
- secret issuance and persistence
|
|
||||||
- signed proof verification
|
|
||||||
- nonce/replay protection
|
|
||||||
- unsafe-condition reset to pairing
|
|
||||||
|
|
||||||
## Phase 3 — Heartbeat and Status Tracking
|
|
||||||
- client heartbeat sender
|
|
||||||
- server heartbeat receiver
|
|
||||||
- periodic sweep
|
|
||||||
- status transitions: online / unstable / offline
|
|
||||||
- forced disconnect on offline
|
|
||||||
|
|
||||||
## Phase 4 — Public APIs and Dispatch
|
|
||||||
- `sendMessageToServer`
|
|
||||||
- `sendMessageToClient`
|
|
||||||
- `registerRule`
|
|
||||||
- first-match dispatch
|
|
||||||
- server-side sender rewrite behavior
|
|
||||||
|
|
||||||
## Phase 5 — Hardening and Docs
|
|
||||||
- integration tests
|
|
||||||
- failure-path coverage
|
|
||||||
- restart recovery checks
|
|
||||||
- protocol docs
|
|
||||||
- operator setup docs for server/client deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Non-Goals for Initial Version
|
|
||||||
|
|
||||||
Not required in the first version unless explicitly added later:
|
|
||||||
- direct client-to-client sockets
|
|
||||||
- multi-server clustering
|
|
||||||
- distributed consensus
|
|
||||||
- message ordering guarantees across reconnects
|
|
||||||
- end-to-end payload encryption beyond the pairing/authentication requirements
|
|
||||||
- management UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Open Questions To Confirm Later
|
|
||||||
|
|
||||||
1. Exact signing algorithm:
|
|
||||||
- Ed25519 is a strong default candidate
|
|
||||||
2. Should `mainHost` accept only full WebSocket URLs or also raw `ip:port` strings?
|
|
||||||
3. Is human code relay sufficient for v1 pairing, or should admin approve/deny controls be added later?
|
|
||||||
4. On unsafe condition, should the old public key be retained or should the client generate a new keypair?
|
|
||||||
5. Should offline clients support queued outbound messages from server, or should sends fail immediately?
|
|
||||||
6. Are rule identifiers exact strings only, or should regex/prefix matching exist later?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. 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
|
|
||||||
845
PROTOCOL.md
845
PROTOCOL.md
@@ -1,845 +0,0 @@
|
|||||||
# Yonexus Protocol Specification
|
|
||||||
|
|
||||||
Version: draft v0.3
|
|
||||||
Status: planning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
This document defines the built-in Yonexus communication protocol used between:
|
|
||||||
- `Yonexus.Server`
|
|
||||||
- `Yonexus.Client`
|
|
||||||
|
|
||||||
The protocol covers:
|
|
||||||
- connection setup
|
|
||||||
- pairing
|
|
||||||
- authentication
|
|
||||||
- heartbeat
|
|
||||||
- status/lifecycle events
|
|
||||||
- protocol-level errors
|
|
||||||
- transport of application rule messages over the same WebSocket channel
|
|
||||||
|
|
||||||
Important security rule:
|
|
||||||
- pairing codes must **not** be delivered to `Yonexus.Client` over the Yonexus WebSocket channel
|
|
||||||
- pairing codes must be delivered out-of-band to a human administrator via Discord DM
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 may be:
|
|
||||||
- `ws://host:port/path`
|
|
||||||
- `wss://host:port/path`
|
|
||||||
- or raw `host:port` if normalized by implementation
|
|
||||||
|
|
||||||
Recommended canonical config:
|
|
||||||
- prefer full WebSocket URL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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.
|
|
||||||
245
README.md
245
README.md
@@ -1,171 +1,118 @@
|
|||||||
|
[English](./README.md) | [中文](./README.zh.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Yonexus
|
# Yonexus
|
||||||
|
|
||||||
Yonexus is a cross-instance communication system for OpenClaw built as **three separate repositories**:
|
Yonexus is an OpenClaw plugin for organization hierarchy and agent identity management.
|
||||||
|
|
||||||
| Repository | Role |
|
## Features
|
||||||
|---|---|
|
|
||||||
| `Yonexus` | Umbrella — architecture, planning, and coordination |
|
|
||||||
| `Yonexus.Server` | Central hub plugin — accepts client connections, handles pairing/authentication |
|
|
||||||
| `Yonexus.Client` | Client plugin — connects to server, manages local identity |
|
|
||||||
| `Yonexus.Protocol` | Shared protocol specification — referenced as a submodule by both Server and Client |
|
|
||||||
|
|
||||||
## Overview
|
- Organization hierarchy: `Organization -> Department -> Team -> Agent`
|
||||||
|
- Filesystem-backed resource layout under `${openclaw dir}/yonexus`
|
||||||
|
- Agent registration and multi-identity assignment
|
||||||
|
- Supervisor relationship mapping (does **not** imply permissions)
|
||||||
|
- Role-based authorization
|
||||||
|
- Query DSL: `eq | contains | regex`
|
||||||
|
- Queryable field whitelist via schema (`queryable: true`)
|
||||||
|
- Scope shared memory adapter (`org/dept/team`)
|
||||||
|
- JSON persistence for structure data
|
||||||
|
- Audit logs and structured error codes
|
||||||
|
- Import / export support
|
||||||
|
|
||||||
### Yonexus.Server
|
## Project Layout
|
||||||
Installed on the central OpenClaw instance.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- run the WebSocket server
|
|
||||||
- maintain the client registry
|
|
||||||
- handle pairing and authentication
|
|
||||||
- track heartbeat and liveness state
|
|
||||||
- relay messages to connected clients
|
|
||||||
- rewrite inbound client messages before rule dispatch
|
|
||||||
- notify a human administrator of pairing requests via Discord DM
|
|
||||||
|
|
||||||
### Yonexus.Client
|
|
||||||
Installed on follower OpenClaw instances.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- connect to `Yonexus.Server`
|
|
||||||
- manage local keypair and shared secret
|
|
||||||
- complete pairing with out-of-band pairing code
|
|
||||||
- authenticate on reconnect
|
|
||||||
- send periodic heartbeat
|
|
||||||
- send messages to server
|
|
||||||
- receive messages from server via rule dispatch
|
|
||||||
|
|
||||||
### Yonexus.Protocol
|
|
||||||
Shared protocol specification repository. Both `Yonexus.Server` and `Yonexus.Client` reference this as a submodule at `protocol/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Yonexus (umbrella)
|
|
||||||
├── Yonexus.Protocol ← shared protocol submodule
|
|
||||||
├── Yonexus.Server ← server plugin submodule
|
|
||||||
│ └── protocol/ ← points to Yonexus.Protocol
|
|
||||||
└── Yonexus.Client ← client plugin submodule
|
|
||||||
└── protocol/ ← points to Yonexus.Protocol
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
A Yonexus network contains:
|
|
||||||
- exactly one OpenClaw instance running `Yonexus.Server`
|
|
||||||
- one or more OpenClaw instances running `Yonexus.Client`
|
|
||||||
|
|
||||||
Topology rules:
|
|
||||||
- `Yonexus.Server` must be reachable via a stable address
|
|
||||||
- `Yonexus.Client` instances connect outbound to the server
|
|
||||||
- direct client-to-client sockets are not required in v1
|
|
||||||
- client-to-client communication, if needed, is relayed by the server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
Pairing is intentionally **out-of-band**.
|
|
||||||
|
|
||||||
When a new client needs pairing:
|
|
||||||
- the server generates a pairing code
|
|
||||||
- the server sends that pairing code by Discord DM to a configured human admin
|
|
||||||
- the pairing code is **not** sent over the Yonexus WebSocket connection
|
|
||||||
- the human relays the code to the client side manually
|
|
||||||
- the client submits the code back through the protocol
|
|
||||||
|
|
||||||
After pairing:
|
|
||||||
- the server issues a shared secret
|
|
||||||
- the client stores its private key and secret locally
|
|
||||||
- reconnect authentication uses signed proof derived from `secret + nonce + timestamp`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Repository Spec Files
|
|
||||||
|
|
||||||
### Umbrella (`Yonexus`)
|
|
||||||
- `PLAN.md` — project plan and architecture
|
|
||||||
- `ARCHITECTURE.md` — architecture overview and repository graph
|
|
||||||
- `FEAT.md` — implementation feature checklist
|
|
||||||
|
|
||||||
### Protocol (`Yonexus.Protocol`)
|
|
||||||
- `PROTOCOL.md` — shared communication protocol specification
|
|
||||||
|
|
||||||
### Server (`Yonexus.Server`)
|
|
||||||
- `PLAN.md` — server-specific implementation plan
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
|
|
||||||
### Client (`Yonexus.Client`)
|
|
||||||
- `PLAN.md` — client-specific implementation plan
|
|
||||||
- `protocol/` — submodule pointing to `Yonexus.Protocol`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned TypeScript APIs
|
|
||||||
|
|
||||||
### Yonexus.Server
|
|
||||||
```ts
|
|
||||||
sendMessageToClient(identifier: string, message: string): Promise<void>
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
### Yonexus.Client
|
|
||||||
```ts
|
|
||||||
sendMessageToServer(message: string): Promise<void>
|
|
||||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
|
||||||
```
|
|
||||||
|
|
||||||
Message format:
|
|
||||||
```text
|
```text
|
||||||
${rule_identifier}::${message_content}
|
.
|
||||||
|
├─ plugin/
|
||||||
|
│ ├─ index.ts # wiring: init, register commands/hooks/tools
|
||||||
|
│ ├─ commands/ # slash commands
|
||||||
|
│ ├─ tools/ # query & resource tools
|
||||||
|
│ ├─ hooks/ # lifecycle hooks
|
||||||
|
│ └─ core/ # business logic, models, store, permissions
|
||||||
|
├─ skills/ # skill definitions
|
||||||
|
├─ docs/ # project documentation
|
||||||
|
├─ scripts/ # demo & utility scripts
|
||||||
|
├─ tests/ # tests
|
||||||
|
├─ install.mjs # install/uninstall script
|
||||||
|
├─ plugin.json # plugin manifest
|
||||||
|
├─ README.md
|
||||||
|
└─ README.zh.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Reserved rule: `builtin`
|
## Requirements
|
||||||
|
|
||||||
---
|
- Node.js 22+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
## Planned Server Config
|
## Quick Start
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
npm install
|
||||||
"followerIdentifiers": ["client-a", "client-b"],
|
npm run build
|
||||||
"notifyBotToken": "<discord-bot-token>",
|
npm run test:smoke
|
||||||
"adminUserId": "123456789012345678",
|
npm run demo
|
||||||
"listenHost": "0.0.0.0",
|
|
||||||
"listenPort": 8787,
|
|
||||||
"publicWsUrl": "wss://example.com/yonexus"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Planned Client Config
|
## Install / Uninstall
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
# Install (builds and copies to ~/.openclaw/plugins/yonexus)
|
||||||
"mainHost": "wss://example.com/yonexus",
|
node install.mjs --install
|
||||||
"identifier": "client-a",
|
|
||||||
"notifyBotToken": "<discord-bot-token>",
|
# Install to custom openclaw profile path
|
||||||
"adminUserId": "123456789012345678"
|
node install.mjs --install --openclaw-profile-path /path/to/.openclaw
|
||||||
}
|
|
||||||
|
# Uninstall
|
||||||
|
node install.mjs --uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Configuration
|
||||||
|
|
||||||
## Status
|
`plugin.json` includes default config:
|
||||||
|
|
||||||
- planning/specification stage
|
- `name`: `yonexus`
|
||||||
- split-plugin architecture defined
|
- `entry`: `dist/yonexus/index.js`
|
||||||
- protocol draft defined in `Yonexus.Protocol`
|
- `config.dataFile`: `./data/org.json`
|
||||||
- implementation not started yet
|
- `config.registrars`: whitelist for registrar agents
|
||||||
|
- `config.schema`: metadata field schema and queryability
|
||||||
|
|
||||||
---
|
## Implemented APIs
|
||||||
|
|
||||||
## Repository URLs
|
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)`
|
||||||
|
|
||||||
- [Yonexus (umbrella)](https://git.hangman-lab.top/nav/Yonexus)
|
Management:
|
||||||
- [Yonexus.Server](https://git.hangman-lab.top/nav/Yonexus.Server)
|
- `renameDepartment(actor, deptId, newName)`
|
||||||
- [Yonexus.Client](https://git.hangman-lab.top/nav/Yonexus.Client)
|
- `renameTeam(actor, teamId, newName, deptId?)`
|
||||||
- [Yonexus.Protocol](https://git.hangman-lab.top/nav/Yonexus.Protocol)
|
- `migrateTeam(actor, teamId, newDeptId)`
|
||||||
|
- `deleteDepartment(actor, deptId)`
|
||||||
|
- `deleteTeam(actor, teamId, deptId?)`
|
||||||
|
|
||||||
|
Docs:
|
||||||
|
- `getDocs(scope, topic, keyword)`
|
||||||
|
|
||||||
|
Data & audit:
|
||||||
|
- `exportData(actor)`
|
||||||
|
- `importData(actor, state)`
|
||||||
|
- `listAuditLogs(limit?, offset?)`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Structure data is persisted in JSON, not memory_store.
|
||||||
|
- Shared scope memory is handled via the scope memory adapter.
|
||||||
|
- Unknown metadata fields are dropped during identity assignment.
|
||||||
|
- `queryAgents` enforces schema queryable constraints.
|
||||||
|
|||||||
118
README.zh.md
Normal file
118
README.zh.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
[English](./README.md) | [中文](./README.zh.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Yonexus
|
||||||
|
|
||||||
|
Yonexus 是一个用于 OpenClaw 的组织结构与 Agent 身份管理插件。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 组织层级:`Organization -> Department -> Team -> Agent`
|
||||||
|
- 基于文件系统的资源目录:`${openclaw dir}/yonexus`
|
||||||
|
- Agent 注册与多身份(Identity)管理
|
||||||
|
- 上下级关系(Supervisor,**不自动赋权**)
|
||||||
|
- 基于角色的权限控制
|
||||||
|
- Query DSL:`eq | contains | regex`
|
||||||
|
- 基于 schema 的可查询字段白名单(`queryable: true`)
|
||||||
|
- scope 共享记忆适配(org/dept/team)
|
||||||
|
- 结构化数据 JSON 持久化
|
||||||
|
- 审计日志与结构化错误码
|
||||||
|
- 导入 / 导出能力
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├─ plugin/
|
||||||
|
│ ├─ index.ts # 接线层:初始化、注册命令/hooks/tools
|
||||||
|
│ ├─ commands/ # slash commands
|
||||||
|
│ ├─ tools/ # 查询与资源工具
|
||||||
|
│ ├─ hooks/ # 生命周期钩子
|
||||||
|
│ └─ core/ # 业务逻辑、模型、存储、权限
|
||||||
|
├─ skills/ # 技能定义
|
||||||
|
├─ docs/ # 项目文档
|
||||||
|
├─ scripts/ # 演示与工具脚本
|
||||||
|
├─ tests/ # 测试
|
||||||
|
├─ install.mjs # 安装/卸载脚本
|
||||||
|
├─ plugin.json # 插件清单
|
||||||
|
├─ README.md
|
||||||
|
└─ README.zh.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run test:smoke
|
||||||
|
npm run demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装 / 卸载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装(构建并复制到 ~/.openclaw/plugins/yonexus)
|
||||||
|
node install.mjs --install
|
||||||
|
|
||||||
|
# 安装到自定义 openclaw profile 路径
|
||||||
|
node install.mjs --install --openclaw-profile-path /path/to/.openclaw
|
||||||
|
|
||||||
|
# 卸载
|
||||||
|
node install.mjs --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
`plugin.json` 默认包含以下配置:
|
||||||
|
|
||||||
|
- `name`: `yonexus`
|
||||||
|
- `entry`: `dist/yonexus/index.js`
|
||||||
|
- `config.dataFile`: `./data/org.json`
|
||||||
|
- `config.registrars`: 注册人白名单
|
||||||
|
- `config.schema`: 元数据字段定义与可查询性
|
||||||
|
|
||||||
|
## 已实现 API
|
||||||
|
|
||||||
|
核心 API:
|
||||||
|
- `createOrganization(actor, name)`
|
||||||
|
- `createDepartment(actor, name, orgId)`
|
||||||
|
- `createTeam(actor, name, deptId)`
|
||||||
|
- `registerAgent(actor, agentId, name, roles?)`
|
||||||
|
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
|
||||||
|
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
|
||||||
|
- `whoami(agentId)`
|
||||||
|
- `queryAgents(actor, scope, query)`
|
||||||
|
|
||||||
|
管理 API:
|
||||||
|
- `renameDepartment(actor, deptId, newName)`
|
||||||
|
- `renameTeam(actor, teamId, newName, deptId?)`
|
||||||
|
- `migrateTeam(actor, teamId, newDeptId)`
|
||||||
|
- `deleteDepartment(actor, deptId)`
|
||||||
|
- `deleteTeam(actor, teamId, deptId?)`
|
||||||
|
|
||||||
|
文档检索:
|
||||||
|
- `getDocs(scope, topic, keyword)`
|
||||||
|
|
||||||
|
数据与审计:
|
||||||
|
- `exportData(actor)`
|
||||||
|
- `importData(actor, state)`
|
||||||
|
- `listAuditLogs(limit?, offset?)`
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 结构数据保存在 JSON 文件,不进入 memory_store。
|
||||||
|
- 共享记忆通过 scope memory 适配器处理。
|
||||||
|
- 分配 identity 时,未知 meta 字段会被丢弃。
|
||||||
|
- `queryAgents` 会严格校验字段是否在 schema 中标记为可查询。
|
||||||
811
TASKLIST.md
811
TASKLIST.md
@@ -1,811 +0,0 @@
|
|||||||
# Yonexus — TASKLIST
|
|
||||||
|
|
||||||
基于 `FEAT.md`、`PLAN.md`、`PROTOCOL.md`、`README.md` 整理。
|
|
||||||
|
|
||||||
目标:把 Yonexus 从“规划完成、实现未开始”的状态,拆成可逐项交付、可验收、尽量低耦合的小任务列表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 任务拆分原则
|
|
||||||
|
|
||||||
- 先把**协议、边界、脚手架**固定,再做运行时逻辑
|
|
||||||
- 先做**可启动、可连接**,再做**配对/认证**
|
|
||||||
- 先做**核心 happy path**,再做**失败路径和安全硬化**
|
|
||||||
- 先做**Server / Client 各自最小闭环**,再做联调
|
|
||||||
- 每个任务尽量满足:
|
|
||||||
- 范围明确
|
|
||||||
- 可以单独提交
|
|
||||||
- 有清晰验收标准
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — 仓库与规范对齐
|
|
||||||
|
|
||||||
### YNX-0001 统一仓库定位与术语
|
|
||||||
**目标**
|
|
||||||
- 统一文档中对 Yonexus、Yonexus.Server、Yonexus.Client、Yonexus.Protocol 的描述
|
|
||||||
- 明确这是“umbrella + 三个独立仓库/子模块”的模型
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 检查 `README.md`、`PLAN.md`、`FEAT.md`、`PROTOCOL.md` 术语是否一致
|
|
||||||
- 明确 `builtin`、`rule_identifier`、`identifier`、`secret`、`pairingCode` 等词汇定义
|
|
||||||
- 记录 v1 非目标,避免实现漂移
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 核心文档不存在角色冲突或架构冲突描述
|
|
||||||
- 协议字段名称在文档中保持一致
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0002 定义 v1 实现边界
|
|
||||||
**目标**
|
|
||||||
- 把“必须做”和“以后再说”彻底分开
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 固化 v1 必做项:WebSocket、pairing、auth、heartbeat、rule dispatch、持久化
|
|
||||||
- 固化 v1 不做项:多服务器、离线消息队列、复杂规则匹配、管理 UI
|
|
||||||
- 明确心跳 ack 是否默认开启
|
|
||||||
- 明确断线重连采用指数退避还是固定退避
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 有一份可执行的 v1 scope 列表
|
|
||||||
- 实现阶段不再反复讨论边界
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — 协议落地与共享契约
|
|
||||||
|
|
||||||
### YNX-0101 固化 builtin 协议信封与类型枚举
|
|
||||||
**目标**
|
|
||||||
- 把 `PROTOCOL.md` 里的 builtin envelope 转成共享 TypeScript 类型
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 定义 `BuiltinEnvelope`
|
|
||||||
- 定义所有 builtin `type` 的联合类型
|
|
||||||
- 定义 payload 接口:`HelloPayload`、`PairRequestPayload`、`PairConfirmPayload`、`PairSuccessPayload`、`AuthRequestPayload`、`HeartbeatPayload` 等
|
|
||||||
- 统一 `timestamp` 使用 UTC unix seconds
|
|
||||||
|
|
||||||
**产出**
|
|
||||||
- `Yonexus.Protocol` 中的共享类型文件
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- Server / Client 都能直接复用类型
|
|
||||||
- 所有 builtin 消息都可被类型系统约束
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0102 实现协议编解码工具
|
|
||||||
**目标**
|
|
||||||
- 提供统一的字符串协议解析与序列化能力
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 实现 `encodeBuiltin(envelope)`
|
|
||||||
- 实现 `decodeBuiltin(raw)`
|
|
||||||
- 实现 rule message 的首段分隔解析
|
|
||||||
- 确保 message content 中包含 `::` 时不会被错误切碎
|
|
||||||
- 为 malformed message 提供标准错误
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- `builtin::{json}` 可稳定双向转换
|
|
||||||
- `rule::content` 和 `rule::sender::content` 都能正确解析
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0103 定义协议错误码与错误对象
|
|
||||||
**目标**
|
|
||||||
- 统一错误语义,避免 Server / Client 各自发明错误格式
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 定义协议错误码枚举
|
|
||||||
- 定义错误 envelope payload 结构
|
|
||||||
- 区分:配置错误、协议错误、认证错误、配对错误、运行时错误
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 所有失败路径都能落到有限集合的错误码
|
|
||||||
- 文档与代码错误码一致
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0104 编写协议级测试样例
|
|
||||||
**目标**
|
|
||||||
- 在实现运行时前,先锁定协议行为
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 为 hello / pair / auth / heartbeat / error 编写样例用例
|
|
||||||
- 为 malformed、缺字段、错误 rule、保留字冲突编写反例
|
|
||||||
- 为 `::` 分隔边界编写测试
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 协议测试能独立运行
|
|
||||||
- 后续实现可直接拿这些样例做回归
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Server 插件脚手架
|
|
||||||
|
|
||||||
### YNX-0201 创建 Yonexus.Server 最小插件骨架
|
|
||||||
**目标**
|
|
||||||
- 让 Server 插件可被 OpenClaw 加载
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 创建插件目录结构
|
|
||||||
- 创建 `package.json`
|
|
||||||
- 创建 `openclaw.plugin.json`
|
|
||||||
- 创建入口文件 `index.ts`
|
|
||||||
- 添加基础构建配置
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 插件能被识别
|
|
||||||
- 启动时至少能加载但不报致命错误
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0202 定义 Yonexus.Server 配置 schema
|
|
||||||
**目标**
|
|
||||||
- 让 Server 配置在启动前就能被校验
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 校验 `followerIdentifiers`
|
|
||||||
- 校验 `notifyBotToken`
|
|
||||||
- 校验 `adminUserId`
|
|
||||||
- 校验 `listenPort`
|
|
||||||
- 校验 `listenHost` / `publicWsUrl` 可选字段
|
|
||||||
- 启动失败时返回明确错误
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 缺字段和非法字段会 fail fast
|
|
||||||
- 错误信息足够定位问题
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0203 实现 Server 生命周期 wiring
|
|
||||||
**目标**
|
|
||||||
- 在 gateway 启动时初始化 Server 内部组件
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 初始化配置
|
|
||||||
- 初始化 registry
|
|
||||||
- 初始化持久化层
|
|
||||||
- 初始化 rule registry
|
|
||||||
- 注册 shutdown 清理逻辑
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 启停流程完整
|
|
||||||
- 不会留下悬挂 timer / socket
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — Client 插件脚手架
|
|
||||||
|
|
||||||
### YNX-0301 创建 Yonexus.Client 最小插件骨架
|
|
||||||
**目标**
|
|
||||||
- 让 Client 插件可被 OpenClaw 加载
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 创建插件目录结构
|
|
||||||
- 创建 `package.json`
|
|
||||||
- 创建 `openclaw.plugin.json`
|
|
||||||
- 创建入口文件 `index.ts`
|
|
||||||
- 添加基础构建配置
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 插件能被识别并加载
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0302 定义 Yonexus.Client 配置 schema
|
|
||||||
**目标**
|
|
||||||
- 保证 Client 配置可启动前校验
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 校验 `mainHost`
|
|
||||||
- 校验 `identifier`
|
|
||||||
- 校验 `notifyBotToken`
|
|
||||||
- 校验 `adminUserId`
|
|
||||||
- 规范 `mainHost` 是否允许裸 `host:port`
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 配置错误可在启动时直接发现
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0303 实现 Client 生命周期 wiring
|
|
||||||
**目标**
|
|
||||||
- 在 gateway 启动时初始化 Client 运行时
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 加载本地状态
|
|
||||||
- 初始化连接管理器
|
|
||||||
- 初始化规则注册器
|
|
||||||
- 注册 shutdown 清理逻辑
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- Client 可启动并在未连接状态下稳定运行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — 持久化与状态模型
|
|
||||||
|
|
||||||
### YNX-0401 定义 Server 持久化记录结构
|
|
||||||
**目标**
|
|
||||||
- 把 `PLAN.md` 中的 `ClientRecord` 落到代码
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 定义 paired/unpaired/revoked/pending 状态
|
|
||||||
- 定义 heartbeat/liveness 字段
|
|
||||||
- 定义 pairing 通知字段
|
|
||||||
- 定义 recent nonce / recent attempts 窗口结构
|
|
||||||
- 定义 `createdAt` / `updatedAt`
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 持久化结构足以支撑 pairing/auth/heartbeat
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0402 实现 Server 状态存储
|
|
||||||
**目标**
|
|
||||||
- 让信任状态在重启后仍可恢复
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 选择 JSON 文件或等价轻量持久化形式
|
|
||||||
- 实现加载、保存、原子写入
|
|
||||||
- 区分持久数据与内存态数据
|
|
||||||
- 明确 restart 后是否清空 rolling windows
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 重启后 paired client 不丢失
|
|
||||||
- 损坏文件时有可恢复行为
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0403 实现 Client 本地信任材料存储
|
|
||||||
**目标**
|
|
||||||
- 持久化 Client 的 `identifier`、私钥、secret
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 定义本地 state 文件结构
|
|
||||||
- 实现加载 / 初始化 / 保存
|
|
||||||
- 缺失文件时自动初始化最小状态
|
|
||||||
- 标记敏感字段
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- Client 重启后可恢复身份与 secret
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 — Transport 最小闭环
|
|
||||||
|
|
||||||
### YNX-0501 实现 Server WebSocket 启动与连接接入
|
|
||||||
**目标**
|
|
||||||
- Server 能监听并接受连接
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 启动 WebSocket server
|
|
||||||
- 处理 connect / message / close / error
|
|
||||||
- 记录连接与 identifier 绑定前的临时会话
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 可看到客户端连接进入
|
|
||||||
- 无协议时也不会崩
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0502 实现 Client WebSocket 连接器
|
|
||||||
**目标**
|
|
||||||
- Client 能主动连到 Server
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 建立 outbound 连接
|
|
||||||
- 处理 open / message / close / error
|
|
||||||
- 提供连接状态机
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- Client 能连上可用的 Server
|
|
||||||
- Server 不可用时不会死循环刷日志
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0503 实现 hello / hello_ack
|
|
||||||
**目标**
|
|
||||||
- 完成连接后的第一段协议握手
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- Client 连接后发送 `hello`
|
|
||||||
- Server 验证 `identifier`、协议版本、payload 形状
|
|
||||||
- Server 返回 `hello_ack`
|
|
||||||
- 根据状态决定 `nextAction`
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 未配对客户端收到 `pair_required`
|
|
||||||
- 已配对客户端收到 `auth_required`
|
|
||||||
- 非 allowlist 客户端被拒绝
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0504 实现基础重连策略
|
|
||||||
**目标**
|
|
||||||
- Client 断线后可恢复连接
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 设计退避策略
|
|
||||||
- 避免并发多次重连
|
|
||||||
- 在成功连接后重置退避计数
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 断线可自动恢复
|
|
||||||
- 不会形成高频重连风暴
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6 — Pairing 主流程
|
|
||||||
|
|
||||||
### YNX-0601 实现 Client 首次密钥生成
|
|
||||||
**目标**
|
|
||||||
- Client 首次运行自动生成本地公私钥
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 选定签名算法(建议 Ed25519)
|
|
||||||
- 生成 keypair
|
|
||||||
- 保存私钥
|
|
||||||
- 导出公钥供握手使用
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 首次启动可生成并持久化 keypair
|
|
||||||
- 重启不会重复生成
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0602 实现 Server pairing request 创建
|
|
||||||
**目标**
|
|
||||||
- Server 可为待配对客户端创建 pairing 会话
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 生成 pairing code
|
|
||||||
- 生成 expiresAt / ttl
|
|
||||||
- 将状态写入 registry
|
|
||||||
- 更新 pairing 状态为 pending
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 每次 pairing 会话有可验证的过期时间
|
|
||||||
- pairing code 不会通过 Yonexus WS 下发
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0603 实现 Discord DM 配对通知
|
|
||||||
**目标**
|
|
||||||
- Server 通过 `notifyBotToken` 向 `adminUserId` 发送 pairing code
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 封装 Discord DM 发送逻辑
|
|
||||||
- DM 内容包含 identifier、pairingCode、expiresAt/TTL
|
|
||||||
- 处理发送失败
|
|
||||||
- 记录 pairing notification metadata
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 通知成功时 Client 才能进入可确认状态
|
|
||||||
- 通知失败时不会继续配对成功路径
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0604 实现 pair_request / pair_confirm / pair_success
|
|
||||||
**目标**
|
|
||||||
- 打通完整配对流程
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- Server 下发不含 code 的 `pair_request`
|
|
||||||
- Client 接收并进入 pairing pending
|
|
||||||
- Client 提交 `pair_confirm`
|
|
||||||
- Server 校验 code 和 expiry
|
|
||||||
- 成功后生成 secret
|
|
||||||
- Server 返回 `pair_success`
|
|
||||||
- Client 保存 secret
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 正确 code 可完成配对
|
|
||||||
- 错误 code / 过期 code 会失败
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0605 实现配对失败路径
|
|
||||||
**目标**
|
|
||||||
- 补齐 pairing 相关失败逻辑
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- `invalid_code`
|
|
||||||
- `expired`
|
|
||||||
- `identifier_not_allowed`
|
|
||||||
- `admin_notification_failed`
|
|
||||||
- `internal_error`
|
|
||||||
- 配对失败后的状态恢复策略
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 失败后不会留下脏状态导致后续无法重试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7 — Authentication 主流程
|
|
||||||
|
|
||||||
### YNX-0701 固化 proof 构造与签名规范
|
|
||||||
**目标**
|
|
||||||
- 明确签名输入,避免 Server / Client 不一致
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 决定 canonical serialization 方案
|
|
||||||
- 明确签名字段:secret、nonce、timestamp
|
|
||||||
- 定义签名编码格式(如 base64)
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 同一输入在 Client 与 Server 上验证一致
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0702 实现 Client auth_request
|
|
||||||
**目标**
|
|
||||||
- Client 能基于本地 secret 与私钥发起认证
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 生成 24 字符 nonce
|
|
||||||
- 生成 proofTimestamp
|
|
||||||
- 构造签名
|
|
||||||
- 发送 `auth_request`
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 报文字段完整
|
|
||||||
- nonce 格式符合协议要求
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0703 实现 Server 认证校验
|
|
||||||
**目标**
|
|
||||||
- Server 能验证 auth_request 真伪
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 校验 identifier allowlist
|
|
||||||
- 校验 paired 状态
|
|
||||||
- 校验 publicKey 匹配
|
|
||||||
- 校验 signature
|
|
||||||
- 校验 secret
|
|
||||||
- 校验 timestamp 漂移
|
|
||||||
- 校验 nonce 重放
|
|
||||||
- 校验 attempts 频率
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 正确认证返回 `auth_success`
|
|
||||||
- 各类失败返回对应 `auth_failed`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0704 实现 re_pair_required 机制
|
|
||||||
**目标**
|
|
||||||
- 在不安全条件下强制重新配对
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 检测 nonce collision
|
|
||||||
- 检测 `>10 attempts / 10s`
|
|
||||||
- 失效旧 secret 或标记 trust revoked
|
|
||||||
- 返回 `re_pair_required`
|
|
||||||
- Client 收到后切回 pairing_required
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 不安全状态下不会继续接受旧信任材料
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8 — Heartbeat 与在线状态
|
|
||||||
|
|
||||||
### YNX-0801 实现 Client heartbeat loop
|
|
||||||
**目标**
|
|
||||||
- Client 在认证后按周期发心跳
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 认证成功后启动 heartbeat timer
|
|
||||||
- 断线或未认证时停止 timer
|
|
||||||
- 发送 `heartbeat`
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 默认每 5 分钟发送一次
|
|
||||||
- 状态切换时 timer 无泄漏
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0802 实现 Server heartbeat 接收与记录
|
|
||||||
**目标**
|
|
||||||
- Server 能更新客户端最近存活时间
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 接收 heartbeat
|
|
||||||
- 校验来源已认证
|
|
||||||
- 更新 `lastHeartbeatAt`
|
|
||||||
- 可选返回 `heartbeat_ack`
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 收到心跳后客户端状态可维持在线
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0803 实现 Server liveness sweep
|
|
||||||
**目标**
|
|
||||||
- Server 能周期性评估 online / unstable / offline
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 增加 30~60s sweep timer
|
|
||||||
- 7 分钟无心跳标记 unstable
|
|
||||||
- 11 分钟无心跳标记 offline
|
|
||||||
- offline 时发送 `disconnect_notice` 并断开连接
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 状态转移符合文档定义
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9 — 规则消息与 API
|
|
||||||
|
|
||||||
### YNX-0901 实现 Client rule registry
|
|
||||||
**目标**
|
|
||||||
- Client 侧支持应用层规则分发
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 实现 `registerRule(rule, processor)`
|
|
||||||
- 禁止 `builtin`
|
|
||||||
- 默认禁止重复注册
|
|
||||||
- 按 exact match 分发
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 命中规则时正确调用处理器
|
|
||||||
- 非法注册被拒绝
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0902 实现 Server rule registry
|
|
||||||
**目标**
|
|
||||||
- Server 侧支持应用层规则分发
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 实现 `registerRule(rule, processor)`
|
|
||||||
- 禁止 `builtin`
|
|
||||||
- 默认禁止重复注册
|
|
||||||
- 按 exact match 分发
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 规则注册与调用行为一致
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0903 实现 `sendMessageToServer(message)`
|
|
||||||
**目标**
|
|
||||||
- 暴露 Client 到 Server 的发送 API
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 校验连接/认证状态
|
|
||||||
- 发送 `${rule_identifier}::${message_content}`
|
|
||||||
- 未连接时返回明确错误
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 上层插件可直接调用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0904 实现 `sendMessageToClient(identifier, message)`
|
|
||||||
**目标**
|
|
||||||
- 暴露 Server 到指定 Client 的发送 API
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 校验目标 client 已知且在线
|
|
||||||
- 发送 `${rule_identifier}::${message_content}`
|
|
||||||
- 离线时返回明确错误
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 上层插件可向指定客户端投递消息
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-0905 实现 Server 入站消息重写
|
|
||||||
**目标**
|
|
||||||
- 把客户端来的消息重写成带 sender 标识的形式
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 解析 `${rule_identifier}::${message_content}`
|
|
||||||
- 重写为 `${rule_identifier}::${sender_identifier}::${message_content}`
|
|
||||||
- 再进入 rule dispatch
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- Server 侧处理器能可靠识别消息来源
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10 — 安全与鲁棒性
|
|
||||||
|
|
||||||
### YNX-1001 实现敏感信息脱敏日志
|
|
||||||
**目标**
|
|
||||||
- 避免 secret、私钥、proof 原文进入日志
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 定义 redaction 工具
|
|
||||||
- 覆盖 pairing、auth、error 日志路径
|
|
||||||
- 对外只保留必要上下文
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 关键敏感值不会明文打印
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-1002 实现 malformed / unsupported / unauthorized 防御
|
|
||||||
**目标**
|
|
||||||
- 所有非法输入都能被可控拒绝
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- malformed json
|
|
||||||
- 缺字段 payload
|
|
||||||
- unsupported protocol version
|
|
||||||
- unauthorized identifier
|
|
||||||
- 非法 builtin type
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 错误不会导致进程崩溃
|
|
||||||
- 客户端能收到明确错误反馈或断开
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-1003 实现单 identifier 单活跃连接策略
|
|
||||||
**目标**
|
|
||||||
- 同一 client identifier 只允许一个活跃认证连接
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 定义旧连接替换策略
|
|
||||||
- 新连接成功认证后踢掉旧连接
|
|
||||||
- 避免竞态导致双在线
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 任意时刻同一 identifier 只有一个有效 session
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YNX-1004 实现重启恢复策略
|
|
||||||
**目标**
|
|
||||||
- 重启后行为可预期且文档一致
|
|
||||||
|
|
||||||
**子任务**
|
|
||||||
- 恢复 durable trust records
|
|
||||||
- 清理或重建 rolling windows
|
|
||||||
- 明确 pending pairing 如何恢复
|
|
||||||
- 明确 active connection 状态如何重建
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 重启后的认证/配对行为稳定可解释
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 11 — 测试与联调
|
|
||||||
|
|
||||||
### YNX-1101 编写协议单元测试
|
|
||||||
**目标**
|
|
||||||
- 覆盖编解码、字段校验、错误码
|
|
||||||
|
|
||||||
### YNX-1102 编写 Server 单元测试
|
|
||||||
**目标**
|
|
||||||
- 覆盖 registry、pairing、auth、heartbeat sweep
|
|
||||||
|
|
||||||
### YNX-1103 编写 Client 单元测试
|
|
||||||
**目标**
|
|
||||||
- 覆盖状态机、keypair、auth 构造、heartbeat timer
|
|
||||||
|
|
||||||
### YNX-1104 编写 Server-Client 集成测试
|
|
||||||
**目标**
|
|
||||||
- 覆盖首次配对、正常重连、认证失败、心跳超时、re-pair
|
|
||||||
|
|
||||||
### YNX-1105 编写失败路径测试矩阵
|
|
||||||
**目标**
|
|
||||||
- 系统性覆盖 pairing/auth 失败路径
|
|
||||||
|
|
||||||
**重点场景**
|
|
||||||
- pairing code 错误
|
|
||||||
- pairing 过期
|
|
||||||
- DM 发送失败
|
|
||||||
- stale timestamp
|
|
||||||
- future timestamp
|
|
||||||
- nonce collision
|
|
||||||
- handshake rate limit
|
|
||||||
- unknown identifier
|
|
||||||
- duplicate connection
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 核心安全路径都有自动化测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 12 — 文档与交付
|
|
||||||
|
|
||||||
### YNX-1201 补齐 Server README
|
|
||||||
**目标**
|
|
||||||
- Server 仓库可独立被安装与使用
|
|
||||||
|
|
||||||
### YNX-1202 补齐 Client README
|
|
||||||
**目标**
|
|
||||||
- Client 仓库可独立被安装与使用
|
|
||||||
|
|
||||||
### YNX-1203 输出部署文档
|
|
||||||
**目标**
|
|
||||||
- 写清楚单主多从部署方式、配置示例、配对流程
|
|
||||||
|
|
||||||
### YNX-1204 输出运维排障文档
|
|
||||||
**目标**
|
|
||||||
- 写清楚常见报错、状态含义、恢复步骤
|
|
||||||
|
|
||||||
### YNX-1205 输出协议测试与验收清单
|
|
||||||
**目标**
|
|
||||||
- 让后续改动有统一回归基线
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 推荐执行顺序(最小闭环)
|
|
||||||
|
|
||||||
### Milestone A — 能启动
|
|
||||||
- YNX-0101
|
|
||||||
- YNX-0102
|
|
||||||
- YNX-0201
|
|
||||||
- YNX-0202
|
|
||||||
- YNX-0301
|
|
||||||
- YNX-0302
|
|
||||||
- YNX-0401
|
|
||||||
- YNX-0402
|
|
||||||
- YNX-0403
|
|
||||||
|
|
||||||
### Milestone B — 能连接
|
|
||||||
- YNX-0203
|
|
||||||
- YNX-0303
|
|
||||||
- YNX-0501
|
|
||||||
- YNX-0502
|
|
||||||
- YNX-0503
|
|
||||||
- YNX-0504
|
|
||||||
|
|
||||||
### Milestone C — 能配对认证
|
|
||||||
- YNX-0601
|
|
||||||
- YNX-0602
|
|
||||||
- YNX-0603
|
|
||||||
- YNX-0604
|
|
||||||
- YNX-0605
|
|
||||||
- YNX-0701
|
|
||||||
- YNX-0702
|
|
||||||
- YNX-0703
|
|
||||||
- YNX-0704
|
|
||||||
|
|
||||||
### Milestone D — 能稳定通信
|
|
||||||
- YNX-0801
|
|
||||||
- YNX-0802
|
|
||||||
- YNX-0803
|
|
||||||
- YNX-0901
|
|
||||||
- YNX-0902
|
|
||||||
- YNX-0903
|
|
||||||
- YNX-0904
|
|
||||||
- YNX-0905
|
|
||||||
|
|
||||||
### Milestone E — 能交付
|
|
||||||
- YNX-1001
|
|
||||||
- YNX-1002
|
|
||||||
- YNX-1003
|
|
||||||
- YNX-1004
|
|
||||||
- YNX-1101
|
|
||||||
- YNX-1102
|
|
||||||
- YNX-1103
|
|
||||||
- YNX-1104
|
|
||||||
- YNX-1105
|
|
||||||
- YNX-1201
|
|
||||||
- YNX-1202
|
|
||||||
- YNX-1203
|
|
||||||
- YNX-1204
|
|
||||||
- YNX-1205
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 可直接开工的首批任务
|
|
||||||
|
|
||||||
如果要我给出“今天就能开始做”的第一批,我建议是:
|
|
||||||
|
|
||||||
1. YNX-0101 固化共享协议类型
|
|
||||||
2. YNX-0102 实现协议编解码工具
|
|
||||||
3. YNX-0201 创建 Yonexus.Server 插件骨架
|
|
||||||
4. YNX-0301 创建 Yonexus.Client 插件骨架
|
|
||||||
5. YNX-0401 定义 Server / Client 状态模型
|
|
||||||
6. YNX-0503 实现 hello / hello_ack 最小握手
|
|
||||||
|
|
||||||
这样最快能拿到一个“能启动、能连接、能判断下一步动作”的基础闭环。
|
|
||||||
Submodule Yonexus.Client deleted from 5234358cac
Submodule Yonexus.Protocol deleted from 9232aa7c17
Submodule Yonexus.Server deleted from d8290c0aa7
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
54
docs/AGENT_TASKS.md
Normal file
54
docs/AGENT_TASKS.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Yonexus — AGENT_TASKS
|
||||||
|
|
||||||
|
> 目标:将插件拆解为可执行任务(按阶段/优先级)。
|
||||||
|
|
||||||
|
## Phase 0 — 基础准备(P0)
|
||||||
|
- [x] 明确插件运行环境/依赖(OpenClaw 版本、Node 版本)
|
||||||
|
- [x] 定义最终配置文件格式(schema + permissions + registrars)
|
||||||
|
- [x] 统一 ID 规则(org/dept/team/agent)
|
||||||
|
|
||||||
|
## Phase 1 — MVP 核心(P0)
|
||||||
|
### 数据与存储
|
||||||
|
- [x] 设计数据模型(Org/Dept/Team/Agent/Identity/Supervisor)
|
||||||
|
- [x] 实现 in-memory store + JSON 持久化
|
||||||
|
- [x] 定义 CRUD API
|
||||||
|
|
||||||
|
### 权限系统
|
||||||
|
- [x] 实现权限角色(Org Admin / Dept Admin / Team Lead / Agent)
|
||||||
|
- [x] 实现权限校验函数 authorize(action, actor, scope)
|
||||||
|
- [x] 实现 registrars 白名单(禁止自注册)
|
||||||
|
|
||||||
|
### 工具/API
|
||||||
|
- [x] create_department
|
||||||
|
- [x] create_team
|
||||||
|
- [x] register_agent
|
||||||
|
- [x] assign_identity
|
||||||
|
- [x] set_supervisor
|
||||||
|
- [x] whoami
|
||||||
|
- [x] query_agents
|
||||||
|
|
||||||
|
### Query DSL
|
||||||
|
- [x] filters/op 解析(eq / contains / regex)
|
||||||
|
- [x] schema queryable 字段约束
|
||||||
|
- [x] pagination(limit/offset)
|
||||||
|
|
||||||
|
### Scope Memory
|
||||||
|
- [x] scope_memory.put(scopeId, text, metadata)
|
||||||
|
- [x] scope_memory.search(scopeId, query, limit)
|
||||||
|
- [x] 兼容 memory-lancedb-pro
|
||||||
|
|
||||||
|
## Phase 2 — v1 增强(P1)
|
||||||
|
- [x] 模糊/正则性能优化(索引/缓存)
|
||||||
|
- [x] 管理命令与校验(重命名/删除/迁移)
|
||||||
|
- [x] 完善错误码与审计日志
|
||||||
|
- [x] 增加导入/导出工具
|
||||||
|
|
||||||
|
## Phase 3 — 体验与文档(P1)
|
||||||
|
- [x] README(安装/配置/示例)
|
||||||
|
- [x] 示例数据集与演示脚本
|
||||||
|
- [x] 安装脚本完善(build + copy 到 dist/yonexus)
|
||||||
|
|
||||||
|
## Risk & Notes
|
||||||
|
- 结构数据不进 memory_store(只做 scope 共享记忆)
|
||||||
|
- queryable 字段必须严格按 schema 控制
|
||||||
|
- supervisor 关系不隐含权限
|
||||||
86
docs/FEAT.md
Normal file
86
docs/FEAT.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# FEAT — Yonexus Feature List
|
||||||
|
|
||||||
|
## Existing Features
|
||||||
|
|
||||||
|
### Core Model & Storage
|
||||||
|
- Organization / Department / Team / Agent / Identity / Supervisor data model
|
||||||
|
- In-memory runtime with JSON persistence (`data/org.json`)
|
||||||
|
- Import/export of structure data
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
- Role model: `org_admin`, `dept_admin`, `team_lead`, `agent`
|
||||||
|
- `authorize(action, actor, scope)` permission check
|
||||||
|
- Registrar whitelist (`registrars`) and bootstrap registration support
|
||||||
|
|
||||||
|
### Core APIs
|
||||||
|
- `createOrganization(actor, name)`
|
||||||
|
- `createDepartment(actor, name, orgId)`
|
||||||
|
- `createTeam(actor, name, deptId)`
|
||||||
|
- `registerAgent(actor, agentId, name, roles?)`
|
||||||
|
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
|
||||||
|
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
|
||||||
|
- `whoami(agentId)`
|
||||||
|
- `queryAgents(actor, scope, query)`
|
||||||
|
|
||||||
|
### Query & Search
|
||||||
|
- Query ops: `eq`, `contains`, `regex`
|
||||||
|
- Schema `queryable` whitelist enforcement
|
||||||
|
- Pagination (`limit`, `offset`)
|
||||||
|
- Basic query performance optimization (regex cache + ordered filter eval)
|
||||||
|
|
||||||
|
### Management & Audit
|
||||||
|
- `renameDepartment`, `renameTeam`, `migrateTeam`, `deleteDepartment`, `deleteTeam`
|
||||||
|
- Structured errors via `YonexusError`
|
||||||
|
- In-memory audit log (`listAuditLogs`)
|
||||||
|
|
||||||
|
### Scope Memory
|
||||||
|
- Scope memory adapter:
|
||||||
|
- `scope_memory.put(scopeId, text, metadata)`
|
||||||
|
- `scope_memory.search(scopeId, query, limit)`
|
||||||
|
|
||||||
|
### Developer Experience
|
||||||
|
- `README.md` + `README.zh.md`
|
||||||
|
- Example data (`examples/sample-data.json`)
|
||||||
|
- Demo script (`scripts/demo.ts`)
|
||||||
|
- Smoke test (`tests/smoke.ts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Features (from NEW_FEAT)
|
||||||
|
|
||||||
|
### 1) Filesystem Resource Layout
|
||||||
|
Data-only filesystem tree under:
|
||||||
|
- `${openclaw dir}/yonexus/organizations/<org-name>/...`
|
||||||
|
|
||||||
|
Auto-create (idempotent):
|
||||||
|
- On `createOrganization`:
|
||||||
|
- `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On `createTeam`:
|
||||||
|
- `teams/<team-name>/agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On `assignIdentity`:
|
||||||
|
- `teams/<team-name>/agents/<agent-id>/docs|notes|knowledge|rules|lessons|workflows`
|
||||||
|
|
||||||
|
### 2) Document Query Tool
|
||||||
|
New API:
|
||||||
|
- `getDocs(scope, topic, keyword)`
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `scope`: `organization | department | team | agent`
|
||||||
|
- `topic`: `docs | notes | knowledge | rules | lessons | workflows`
|
||||||
|
- `keyword`: regex string
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Read-only search by filename regex under filesystem resources
|
||||||
|
- Structured output:
|
||||||
|
- `----ORG`
|
||||||
|
- `----DEPT`
|
||||||
|
- `----TEAM`
|
||||||
|
- `----AGENT`
|
||||||
|
- Invalid regex returns structured error (`YonexusError: VALIDATION_ERROR`)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `${openclaw dir}` resolution order:
|
||||||
|
1. `YonexusOptions.openclawDir`
|
||||||
|
2. `OPENCLAW_DIR` env
|
||||||
|
3. `${HOME}/.openclaw`
|
||||||
|
- Plugin code is not written into `${openclaw dir}/yonexus`; only data folders/files are used there.
|
||||||
127
docs/PLAN.md
Normal file
127
docs/PLAN.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Yonexus — Project Plan
|
||||||
|
|
||||||
|
## 1) Goal
|
||||||
|
Build an OpenClaw plugin that models organization hierarchy and agent identities, supports supervisor relationships, provides query tools for agents, and uses shared memory per scope (org/department/team).
|
||||||
|
|
||||||
|
## 2) Core Concepts
|
||||||
|
- **Hierarchy**: Organization → Department → Team → Agent
|
||||||
|
- **Supervisor**: each agent may have exactly one supervisor
|
||||||
|
- **Identity**: an agent can hold multiple identities across teams/departments
|
||||||
|
- **Schema-driven metadata**: configurable fields with per-field queryability
|
||||||
|
- **Scope memory**: shared memory for org/department/team (using `memory_store`, compatible with memory-lancedb-pro)
|
||||||
|
|
||||||
|
## 3) Storage Strategy
|
||||||
|
- **Structure & identity data**: in-memory + JSON persistence (no memory_store)
|
||||||
|
- **Shared memory**: memory_store keyed by scope (`org:{id}`, `dept:{id}`, `team:{id}`)
|
||||||
|
- **Filesystem resources** (OpenClaw install dir `${openclaw dir}`):
|
||||||
|
- Create a data-only folder at `${openclaw dir}/yonexus` (no plugin code here)
|
||||||
|
- `yonexus/organizations/<org-name>/` contains: `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On **create_organization**: create `<org-name>` folder and its subfolders
|
||||||
|
- On **create_team**: create `organizations/<org-name>/teams/<team-name>/` with `agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
- On **assign_identity**: create `organizations/<org-name>/teams/<team-name>/agents/<agent-id>/` with `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||||
|
|
||||||
|
## 4) Permissions Model (B)
|
||||||
|
Roles:
|
||||||
|
- Org Admin
|
||||||
|
- Dept Admin
|
||||||
|
- Team Lead
|
||||||
|
- Agent
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Supervisor is **not** a role (no inherent permissions)
|
||||||
|
- Registration **not** self-service
|
||||||
|
- only configured agent list or human via slash command
|
||||||
|
|
||||||
|
Permission matrix (recommended):
|
||||||
|
- create_department → Org Admin
|
||||||
|
- create_team → Org Admin, Dept Admin (same dept)
|
||||||
|
- assign_identity → Org Admin, Dept Admin (same dept), Team Lead (same team)
|
||||||
|
- register_agent → Org Admin, Dept Admin, Team Lead (scope-limited)
|
||||||
|
- set_supervisor → Org Admin, Dept Admin (same dept)
|
||||||
|
- query → all roles, but only schema fields with `queryable: true`
|
||||||
|
|
||||||
|
## 5) Schema Configuration (example)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"position": { "type": "string", "queryable": true },
|
||||||
|
"discord_user_id": { "type": "string", "queryable": true },
|
||||||
|
"git_user_name": { "type": "string", "queryable": true },
|
||||||
|
"department": { "type": "string", "queryable": false },
|
||||||
|
"team": { "type": "string", "queryable": false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6) Tool/API Surface (MVP)
|
||||||
|
- `create_organization(name)`
|
||||||
|
- `create_department(name, orgId)`
|
||||||
|
- `create_team(name, deptId)`
|
||||||
|
- `register_agent(agentId, name)`
|
||||||
|
- `assign_identity(agentId, deptId, teamId, meta)`
|
||||||
|
- `set_supervisor(actor, agentId, supervisorId)`
|
||||||
|
- `whoami(agentId)` → identities + supervisor + roles
|
||||||
|
- `query_agents(filters, options)` → list; supports `eq | contains | regex`
|
||||||
|
|
||||||
|
Query example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filters": [
|
||||||
|
{"field":"discord_user_id","op":"eq","value":"123"},
|
||||||
|
{"field":"git_user_name","op":"regex","value":"^hang"}
|
||||||
|
],
|
||||||
|
"options": {"limit": 20, "offset": 0}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7) Data Model (MVP)
|
||||||
|
- Organization { id, name }
|
||||||
|
- Department { id, name, orgId }
|
||||||
|
- Team { id, name, deptId }
|
||||||
|
- Agent { id, name, roles[] }
|
||||||
|
- Identity { id, agentId, deptId, teamId, meta }
|
||||||
|
- Supervisor { agentId, supervisorId }
|
||||||
|
|
||||||
|
## 8) Milestones
|
||||||
|
**Phase 0 (Design)**
|
||||||
|
- finalize schema
|
||||||
|
- confirm permission rules
|
||||||
|
|
||||||
|
**Phase 1 (MVP)**
|
||||||
|
- storage + JSON persistence
|
||||||
|
- core models + tools
|
||||||
|
- query DSL
|
||||||
|
- scope memory adapter
|
||||||
|
|
||||||
|
**Phase 2 (v1)**
|
||||||
|
- policy refinements
|
||||||
|
- better query pagination & filtering
|
||||||
|
- management commands & validation
|
||||||
|
|
||||||
|
## 9) Project Structure (recommended)
|
||||||
|
```
|
||||||
|
openclaw-plugin-yonexus/
|
||||||
|
├─ plugin.json
|
||||||
|
├─ src/
|
||||||
|
│ ├─ index.ts
|
||||||
|
│ ├─ store/ # in-memory + JSON persistence
|
||||||
|
│ ├─ models/
|
||||||
|
│ ├─ permissions/
|
||||||
|
│ ├─ tools/
|
||||||
|
│ ├─ memory/
|
||||||
|
│ └─ utils/
|
||||||
|
├─ scripts/
|
||||||
|
│ └─ install.sh
|
||||||
|
├─ dist/
|
||||||
|
│ └─ yonexus/ # build output target
|
||||||
|
└─ data/
|
||||||
|
└─ org.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10) Install Script Requirement
|
||||||
|
- Provide `scripts/install.sh`
|
||||||
|
- It should register the OpenClaw plugin name as **`yonexus`**
|
||||||
|
- Build artifacts must be placed into **`dist/yonexus`**
|
||||||
|
|
||||||
|
## 11) Notes & Decisions
|
||||||
|
- Structure data is not stored in memory_store.
|
||||||
|
- Shared memory uses memory_store (compatible with memory-lancedb-pro).
|
||||||
|
- Queryable fields are whitelisted via schema.
|
||||||
44
examples/sample-data.json
Normal file
44
examples/sample-data.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"organizations": [
|
||||||
|
{ "id": "org:yonexus", "name": "Yonexus" }
|
||||||
|
],
|
||||||
|
"departments": [
|
||||||
|
{ "id": "dept:platform", "name": "Platform", "orgId": "org:yonexus" },
|
||||||
|
{ "id": "dept:ai", "name": "AI", "orgId": "org:yonexus" }
|
||||||
|
],
|
||||||
|
"teams": [
|
||||||
|
{ "id": "team:platform-core", "name": "Core", "deptId": "dept:platform" },
|
||||||
|
{ "id": "team:ai-agent", "name": "Agent", "deptId": "dept:ai" }
|
||||||
|
],
|
||||||
|
"agents": [
|
||||||
|
{ "id": "orion", "name": "Orion", "roles": ["org_admin", "agent"] },
|
||||||
|
{ "id": "hangman", "name": "Hangman", "roles": ["agent"] }
|
||||||
|
],
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"id": "identity:orion-platform",
|
||||||
|
"agentId": "orion",
|
||||||
|
"deptId": "dept:platform",
|
||||||
|
"teamId": "team:platform-core",
|
||||||
|
"meta": {
|
||||||
|
"position": "assistant",
|
||||||
|
"discord_user_id": "1474088632750047324",
|
||||||
|
"git_user_name": "orion"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "identity:hangman-ai",
|
||||||
|
"agentId": "hangman",
|
||||||
|
"deptId": "dept:ai",
|
||||||
|
"teamId": "team:ai-agent",
|
||||||
|
"meta": {
|
||||||
|
"position": "owner",
|
||||||
|
"discord_user_id": "561921120408698910",
|
||||||
|
"git_user_name": "hangman"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supervisors": [
|
||||||
|
{ "agentId": "orion", "supervisorId": "hangman" }
|
||||||
|
]
|
||||||
|
}
|
||||||
209
install.mjs
Normal file
209
install.mjs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yonexus Plugin Installer v0.2.0
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node install.mjs --install
|
||||||
|
* node install.mjs --install --openclaw-profile-path /path/to/.openclaw
|
||||||
|
* node install.mjs --uninstall
|
||||||
|
* node install.mjs --uninstall --openclaw-profile-path /path/to/.openclaw
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs';
|
||||||
|
import { dirname, join, resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = resolve(dirname(__filename));
|
||||||
|
|
||||||
|
const PLUGIN_NAME = 'yonexus';
|
||||||
|
const SRC_DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME);
|
||||||
|
|
||||||
|
// ── Parse arguments ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const isInstall = args.includes('--install');
|
||||||
|
const isUninstall = args.includes('--uninstall');
|
||||||
|
|
||||||
|
const profileIdx = args.indexOf('--openclaw-profile-path');
|
||||||
|
let openclawProfilePath = null;
|
||||||
|
if (profileIdx !== -1 && args[profileIdx + 1]) {
|
||||||
|
openclawProfilePath = resolve(args[profileIdx + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenclawPath() {
|
||||||
|
if (openclawProfilePath) return openclawProfilePath;
|
||||||
|
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
|
||||||
|
return join(homedir(), '.openclaw');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
function log(msg, color = 'reset') { console.log(`${c[color]}${msg}${c.reset}`); }
|
||||||
|
function logOk(msg) { log(` ✓ ${msg}`, 'green'); }
|
||||||
|
function logWarn(msg) { log(` ⚠ ${msg}`, 'yellow'); }
|
||||||
|
function logErr(msg) { log(` ✗ ${msg}`, 'red'); }
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function copyDir(src, dest) {
|
||||||
|
mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = join(src, entry.name);
|
||||||
|
const d = join(dest, entry.name);
|
||||||
|
if (entry.name === 'node_modules') continue;
|
||||||
|
entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Install ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
console.log('');
|
||||||
|
log('╔══════════════════════════════════════════════╗', 'cyan');
|
||||||
|
log('║ Yonexus Plugin Installer v0.2.0 ║', 'cyan');
|
||||||
|
log('╚══════════════════════════════════════════════╝', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 1. Build
|
||||||
|
log('[1/4] Building...', 'cyan');
|
||||||
|
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
|
||||||
|
execSync('npm run build', { cwd: __dirname, stdio: 'inherit' });
|
||||||
|
|
||||||
|
if (!existsSync(SRC_DIST_DIR)) {
|
||||||
|
logErr(`Build output not found at ${SRC_DIST_DIR}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
logOk('Build complete');
|
||||||
|
|
||||||
|
// 2. Copy to plugins dir
|
||||||
|
log('[2/4] Installing...', 'cyan');
|
||||||
|
const openclawPath = resolveOpenclawPath();
|
||||||
|
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
|
||||||
|
|
||||||
|
log(` OpenClaw path: ${openclawPath}`, 'blue');
|
||||||
|
|
||||||
|
if (existsSync(destDir)) rmSync(destDir, { recursive: true, force: true });
|
||||||
|
copyDir(SRC_DIST_DIR, destDir);
|
||||||
|
logOk(`Plugin files → ${destDir}`);
|
||||||
|
|
||||||
|
// 3. Configure OpenClaw
|
||||||
|
log('[3/4] Configuring OpenClaw...', 'cyan');
|
||||||
|
try {
|
||||||
|
const pluginPath = destDir;
|
||||||
|
const allow = ensureArray(getConfigValue('plugins.allow'));
|
||||||
|
const loadPaths = ensureArray(getConfigValue('plugins.load.paths'));
|
||||||
|
|
||||||
|
if (!allow.includes(PLUGIN_NAME)) allow.push(PLUGIN_NAME);
|
||||||
|
if (!loadPaths.includes(pluginPath)) loadPaths.push(pluginPath);
|
||||||
|
|
||||||
|
execSync(`openclaw config set plugins.allow '${JSON.stringify(allow)}'`);
|
||||||
|
execSync(`openclaw config set plugins.load.paths '${JSON.stringify(loadPaths)}'`);
|
||||||
|
execSync(`openclaw config set plugins.entries.${PLUGIN_NAME}.enabled true`);
|
||||||
|
execSync(`openclaw config set plugins.entries.${PLUGIN_NAME}.config '{"enabled": true}'`);
|
||||||
|
|
||||||
|
logOk('OpenClaw config updated');
|
||||||
|
} catch (err) {
|
||||||
|
logErr('Failed to update OpenClaw config via `openclaw config set`');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Summary
|
||||||
|
log('[4/4] Done!', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
log('✓ Yonexus installed successfully!', 'green');
|
||||||
|
console.log('');
|
||||||
|
log('Next steps:', 'blue');
|
||||||
|
log(' openclaw gateway restart', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigValue(path) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`openclaw config get ${path}`, { encoding: 'utf8' }).trim();
|
||||||
|
if (!out || out === 'undefined' || out === 'null') return undefined;
|
||||||
|
try { return JSON.parse(out); } catch { return out; }
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureArray(value) {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (value === undefined || value === null || value === '') return [];
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Uninstall ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function uninstall() {
|
||||||
|
console.log('');
|
||||||
|
log('Uninstalling Yonexus...', 'cyan');
|
||||||
|
|
||||||
|
const openclawPath = resolveOpenclawPath();
|
||||||
|
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
|
||||||
|
|
||||||
|
if (existsSync(destDir)) {
|
||||||
|
rmSync(destDir, { recursive: true, force: true });
|
||||||
|
logOk(`Removed ${destDir}`);
|
||||||
|
} else {
|
||||||
|
logWarn(`${destDir} not found, nothing to remove`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean OpenClaw config
|
||||||
|
log('Cleaning OpenClaw config...', 'cyan');
|
||||||
|
try {
|
||||||
|
const allow = ensureArray(getConfigValue('plugins.allow')).filter((id) => id !== PLUGIN_NAME);
|
||||||
|
const loadPaths = ensureArray(getConfigValue('plugins.load.paths')).filter((p) => p !== destDir);
|
||||||
|
|
||||||
|
if (allow.length > 0) {
|
||||||
|
execSync(`openclaw config set plugins.allow '${JSON.stringify(allow)}'`);
|
||||||
|
} else {
|
||||||
|
execSync('openclaw config unset plugins.allow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadPaths.length > 0) {
|
||||||
|
execSync(`openclaw config set plugins.load.paths '${JSON.stringify(loadPaths)}'`);
|
||||||
|
} else {
|
||||||
|
execSync('openclaw config unset plugins.load.paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
execSync(`openclaw config unset plugins.entries.${PLUGIN_NAME}`);
|
||||||
|
|
||||||
|
logOk('OpenClaw config cleaned');
|
||||||
|
} catch (err) {
|
||||||
|
logErr('Failed to clean OpenClaw config via `openclaw config`');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
log('✓ Yonexus uninstalled.', 'green');
|
||||||
|
log('\nNext: openclaw gateway restart', 'yellow');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!isInstall && !isUninstall) {
|
||||||
|
console.log('');
|
||||||
|
log('Yonexus Plugin Installer', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
log('Usage:', 'blue');
|
||||||
|
log(' node install.mjs --install Install plugin', 'reset');
|
||||||
|
log(' node install.mjs --install --openclaw-profile-path <path> Install to custom path', 'reset');
|
||||||
|
log(' node install.mjs --uninstall Uninstall plugin', 'reset');
|
||||||
|
log(' node install.mjs --uninstall --openclaw-profile-path <path> Uninstall from custom path', 'reset');
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstall) install();
|
||||||
|
if (isUninstall) uninstall();
|
||||||
591
package-lock.json
generated
Normal file
591
package-lock.json
generated
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.3",
|
||||||
|
"@esbuild/android-arm": "0.27.3",
|
||||||
|
"@esbuild/android-arm64": "0.27.3",
|
||||||
|
"@esbuild/android-x64": "0.27.3",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.3",
|
||||||
|
"@esbuild/darwin-x64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.3",
|
||||||
|
"@esbuild/linux-arm": "0.27.3",
|
||||||
|
"@esbuild/linux-arm64": "0.27.3",
|
||||||
|
"@esbuild/linux-ia32": "0.27.3",
|
||||||
|
"@esbuild/linux-loong64": "0.27.3",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.3",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.3",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.3",
|
||||||
|
"@esbuild/linux-s390x": "0.27.3",
|
||||||
|
"@esbuild/linux-x64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.3",
|
||||||
|
"@esbuild/sunos-x64": "0.27.3",
|
||||||
|
"@esbuild/win32-arm64": "0.27.3",
|
||||||
|
"@esbuild/win32-ia32": "0.27.3",
|
||||||
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||||
|
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-plugin-yonexus",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "Yonexus OpenClaw plugin: hierarchy, identities, permissions, and scoped memory",
|
||||||
|
"main": "dist/yonexus/index.js",
|
||||||
|
"types": "dist/yonexus/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json && cp plugin.json dist/yonexus/plugin.json && cp plugin/openclaw.plugin.json dist/yonexus/openclaw.plugin.json",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"prepare": "npm run clean && npm run build",
|
||||||
|
"test:smoke": "tsx tests/smoke.ts",
|
||||||
|
"demo": "tsx scripts/demo.ts"
|
||||||
|
},
|
||||||
|
"keywords": ["openclaw", "plugin", "organization", "agents"],
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"tsx": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Yonexus.Client",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Yonexus client plugin for cross-instance OpenClaw communication",
|
|
||||||
"entry": "dist/client/index.js",
|
|
||||||
"permissions": [],
|
|
||||||
"config": {
|
|
||||||
"mainHost": "",
|
|
||||||
"identifier": "",
|
|
||||||
"notifyBotToken": "",
|
|
||||||
"adminUserId": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
plugin.json
Normal file
21
plugin.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "yonexus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"entry": "dist/yonexus/index.js",
|
||||||
|
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
|
||||||
|
"permissions": [
|
||||||
|
"memory_store",
|
||||||
|
"memory_recall"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"dataFile": "./data/org.json",
|
||||||
|
"registrars": [],
|
||||||
|
"schema": {
|
||||||
|
"position": { "type": "string", "queryable": true },
|
||||||
|
"discord_user_id": { "type": "string", "queryable": true },
|
||||||
|
"git_user_name": { "type": "string", "queryable": true },
|
||||||
|
"department": { "type": "string", "queryable": false },
|
||||||
|
"team": { "type": "string", "queryable": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Yonexus.Server",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Yonexus central server plugin for cross-instance OpenClaw communication",
|
|
||||||
"entry": "dist/server/index.js",
|
|
||||||
"permissions": [],
|
|
||||||
"config": {
|
|
||||||
"followerIdentifiers": [],
|
|
||||||
"notifyBotToken": "",
|
|
||||||
"adminUserId": "",
|
|
||||||
"listenHost": "0.0.0.0",
|
|
||||||
"listenPort": 8787,
|
|
||||||
"publicWsUrl": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
plugin/commands/.gitkeep
Normal file
0
plugin/commands/.gitkeep
Normal file
18
plugin/core/config/defaults.ts
Normal file
18
plugin/core/config/defaults.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { StoreState, YonexusSchema } from "../models/types";
|
||||||
|
|
||||||
|
export const DEFAULT_STATE: StoreState = {
|
||||||
|
organizations: [],
|
||||||
|
departments: [],
|
||||||
|
teams: [],
|
||||||
|
agents: [],
|
||||||
|
identities: [],
|
||||||
|
supervisors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEMA: YonexusSchema = {
|
||||||
|
position: { type: "string", queryable: true },
|
||||||
|
discord_user_id: { type: "string", queryable: true },
|
||||||
|
git_user_name: { type: "string", queryable: true },
|
||||||
|
department: { type: "string", queryable: false },
|
||||||
|
team: { type: "string", queryable: false }
|
||||||
|
};
|
||||||
16
plugin/core/memory/scopeMemory.ts
Normal file
16
plugin/core/memory/scopeMemory.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface MemoryPort {
|
||||||
|
store(input: { text: string; scope: string; metadata?: Record<string, string> }): Promise<unknown>;
|
||||||
|
recall(input: { query: string; scope: string; limit?: number }): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScopeMemory {
|
||||||
|
constructor(private readonly memory: MemoryPort) {}
|
||||||
|
|
||||||
|
async put(scopeId: string, text: string, metadata?: Record<string, string>): Promise<unknown> {
|
||||||
|
return this.memory.store({ text, scope: scopeId, metadata });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(scopeId: string, query: string, limit = 5): Promise<unknown> {
|
||||||
|
return this.memory.recall({ query, scope: scopeId, limit });
|
||||||
|
}
|
||||||
|
}
|
||||||
10
plugin/core/models/audit.ts
Normal file
10
plugin/core/models/audit.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
ts: string;
|
||||||
|
actorId: string;
|
||||||
|
action: string;
|
||||||
|
target?: string;
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
message?: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
19
plugin/core/models/errors.ts
Normal file
19
plugin/core/models/errors.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type ErrorCode =
|
||||||
|
| 'PERMISSION_DENIED'
|
||||||
|
| 'NOT_FOUND'
|
||||||
|
| 'ALREADY_EXISTS'
|
||||||
|
| 'VALIDATION_ERROR'
|
||||||
|
| 'FIELD_NOT_QUERYABLE'
|
||||||
|
| 'INVALID_SUPERVISOR'
|
||||||
|
| 'REGISTRAR_DENIED';
|
||||||
|
|
||||||
|
export class YonexusError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'YonexusError';
|
||||||
|
}
|
||||||
|
}
|
||||||
93
plugin/core/models/types.ts
Normal file
93
plugin/core/models/types.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
export type Role = "org_admin" | "dept_admin" | "team_lead" | "agent";
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
deptId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Identity {
|
||||||
|
id: string;
|
||||||
|
agentId: string;
|
||||||
|
deptId: string;
|
||||||
|
teamId: string;
|
||||||
|
meta: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Supervisor {
|
||||||
|
agentId: string;
|
||||||
|
supervisorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaField {
|
||||||
|
type: "string";
|
||||||
|
queryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YonexusSchema {
|
||||||
|
[field: string]: SchemaField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreState {
|
||||||
|
organizations: Organization[];
|
||||||
|
departments: Department[];
|
||||||
|
teams: Team[];
|
||||||
|
agents: Agent[];
|
||||||
|
identities: Identity[];
|
||||||
|
supervisors: Supervisor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Actor {
|
||||||
|
agentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| "create_organization"
|
||||||
|
| "create_department"
|
||||||
|
| "create_team"
|
||||||
|
| "register_agent"
|
||||||
|
| "assign_identity"
|
||||||
|
| "set_supervisor"
|
||||||
|
| "query_agents";
|
||||||
|
|
||||||
|
export type DocsScope = "organization" | "department" | "team" | "agent";
|
||||||
|
export type DocsTopic = "docs" | "notes" | "knowledge" | "rules" | "lessons" | "workflows";
|
||||||
|
|
||||||
|
export interface Scope {
|
||||||
|
orgId?: string;
|
||||||
|
deptId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryFilter {
|
||||||
|
field: string;
|
||||||
|
op: "eq" | "contains" | "regex";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
filters: QueryFilter[];
|
||||||
|
options?: QueryOptions;
|
||||||
|
}
|
||||||
40
plugin/core/permissions/authorize.ts
Normal file
40
plugin/core/permissions/authorize.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { YonexusError } from '../models/errors';
|
||||||
|
import type { Action, Actor, Scope } from "../models/types";
|
||||||
|
import { JsonStore } from "../store/jsonStore";
|
||||||
|
|
||||||
|
function hasRole(store: JsonStore, actor: Actor, role: string): boolean {
|
||||||
|
const me = store.findAgent(actor.agentId);
|
||||||
|
return Boolean(me?.roles.includes(role as never));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inDeptScope(scope: Scope): boolean {
|
||||||
|
return Boolean(scope.deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inTeamScope(scope: Scope): boolean {
|
||||||
|
return Boolean(scope.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorize(action: Action, actor: Actor, scope: Scope, store: JsonStore): void {
|
||||||
|
const orgAdmin = hasRole(store, actor, "org_admin");
|
||||||
|
const deptAdmin = hasRole(store, actor, "dept_admin") && inDeptScope(scope);
|
||||||
|
const teamLead = hasRole(store, actor, "team_lead") && inTeamScope(scope);
|
||||||
|
const agent = hasRole(store, actor, "agent");
|
||||||
|
|
||||||
|
const allowed =
|
||||||
|
(action === "create_organization" && orgAdmin) ||
|
||||||
|
(action === "create_department" && orgAdmin) ||
|
||||||
|
(action === "create_team" && (orgAdmin || deptAdmin)) ||
|
||||||
|
(action === "assign_identity" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||||
|
(action === "register_agent" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||||
|
(action === "set_supervisor" && (orgAdmin || deptAdmin)) ||
|
||||||
|
(action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent));
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
throw new YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, {
|
||||||
|
action,
|
||||||
|
actorId: actor.agentId,
|
||||||
|
scope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
19
plugin/core/store/auditStore.ts
Normal file
19
plugin/core/store/auditStore.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AuditLogEntry } from '../models/audit';
|
||||||
|
|
||||||
|
const MAX_AUDIT = 1000;
|
||||||
|
|
||||||
|
export class AuditStore {
|
||||||
|
private logs: AuditLogEntry[] = [];
|
||||||
|
|
||||||
|
append(entry: AuditLogEntry): AuditLogEntry {
|
||||||
|
this.logs.push(entry);
|
||||||
|
if (this.logs.length > MAX_AUDIT) this.logs.shift();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(limit = 100, offset = 0): AuditLogEntry[] {
|
||||||
|
const safeLimit = Math.min(Math.max(1, limit), 500);
|
||||||
|
const safeOffset = Math.max(0, offset);
|
||||||
|
return this.logs.slice(safeOffset, safeOffset + safeLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
plugin/core/store/jsonStore.ts
Normal file
156
plugin/core/store/jsonStore.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { DEFAULT_STATE } from "../config/defaults";
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
Department,
|
||||||
|
Identity,
|
||||||
|
Organization,
|
||||||
|
StoreState,
|
||||||
|
Supervisor,
|
||||||
|
Team
|
||||||
|
} from "../models/types";
|
||||||
|
import { readJsonFile, writeJsonFile } from "../utils/fs";
|
||||||
|
|
||||||
|
export class JsonStore {
|
||||||
|
private state: StoreState;
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
this.state = readJsonFile<StoreState>(filePath, DEFAULT_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
writeJsonFile(this.filePath, this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): StoreState {
|
||||||
|
return JSON.parse(JSON.stringify(this.state)) as StoreState;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(state: StoreState): void {
|
||||||
|
this.state = JSON.parse(JSON.stringify(state)) as StoreState;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrganization(org: Organization): Organization {
|
||||||
|
this.state.organizations.push(org);
|
||||||
|
this.save();
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDepartment(dept: Department): Department {
|
||||||
|
this.state.departments.push(dept);
|
||||||
|
this.save();
|
||||||
|
return dept;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameDepartment(deptId: string, name: string): Department | undefined {
|
||||||
|
const dept = this.findDepartment(deptId);
|
||||||
|
if (!dept) return undefined;
|
||||||
|
dept.name = name;
|
||||||
|
this.save();
|
||||||
|
return dept;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDepartment(deptId: string): boolean {
|
||||||
|
const before = this.state.departments.length;
|
||||||
|
this.state.departments = this.state.departments.filter((d) => d.id !== deptId);
|
||||||
|
this.state.teams = this.state.teams.filter((t) => t.deptId !== deptId);
|
||||||
|
this.state.identities = this.state.identities.filter((i) => i.deptId !== deptId);
|
||||||
|
const changed = this.state.departments.length !== before;
|
||||||
|
if (changed) this.save();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
addTeam(team: Team): Team {
|
||||||
|
this.state.teams.push(team);
|
||||||
|
this.save();
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameTeam(teamId: string, name: string): Team | undefined {
|
||||||
|
const team = this.findTeam(teamId);
|
||||||
|
if (!team) return undefined;
|
||||||
|
team.name = name;
|
||||||
|
this.save();
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateTeam(teamId: string, newDeptId: string): Team | undefined {
|
||||||
|
const team = this.findTeam(teamId);
|
||||||
|
if (!team) return undefined;
|
||||||
|
team.deptId = newDeptId;
|
||||||
|
for (const identity of this.state.identities) {
|
||||||
|
if (identity.teamId === teamId) identity.deptId = newDeptId;
|
||||||
|
}
|
||||||
|
this.save();
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTeam(teamId: string): boolean {
|
||||||
|
const before = this.state.teams.length;
|
||||||
|
this.state.teams = this.state.teams.filter((t) => t.id !== teamId);
|
||||||
|
this.state.identities = this.state.identities.filter((i) => i.teamId !== teamId);
|
||||||
|
const changed = before !== this.state.teams.length;
|
||||||
|
if (changed) this.save();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
addAgent(agent: Agent): Agent {
|
||||||
|
this.state.agents.push(agent);
|
||||||
|
this.save();
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
addIdentity(identity: Identity): Identity {
|
||||||
|
this.state.identities.push(identity);
|
||||||
|
this.save();
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertSupervisor(rel: Supervisor): Supervisor {
|
||||||
|
const idx = this.state.supervisors.findIndex((x) => x.agentId === rel.agentId);
|
||||||
|
if (idx >= 0) this.state.supervisors[idx] = rel;
|
||||||
|
else this.state.supervisors.push(rel);
|
||||||
|
this.save();
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
findAgent(agentId: string): Agent | undefined {
|
||||||
|
return this.state.agents.find((a) => a.id === agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findOrganization(orgId: string): Organization | undefined {
|
||||||
|
return this.state.organizations.find((o) => o.id === orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listOrganizations(): Organization[] {
|
||||||
|
return this.state.organizations;
|
||||||
|
}
|
||||||
|
|
||||||
|
findDepartment(deptId: string): Department | undefined {
|
||||||
|
return this.state.departments.find((d) => d.id === deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listDepartments(): Department[] {
|
||||||
|
return this.state.departments;
|
||||||
|
}
|
||||||
|
|
||||||
|
findTeam(teamId: string): Team | undefined {
|
||||||
|
return this.state.teams.find((t) => t.id === teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listTeams(): Team[] {
|
||||||
|
return this.state.teams;
|
||||||
|
}
|
||||||
|
|
||||||
|
listAgents(): Agent[] {
|
||||||
|
return this.state.agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
listIdentities(): Identity[] {
|
||||||
|
return this.state.identities;
|
||||||
|
}
|
||||||
|
|
||||||
|
findSupervisor(agentId: string): Supervisor | undefined {
|
||||||
|
return this.state.supervisors.find((s) => s.agentId === agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
plugin/core/utils/fs.ts
Normal file
18
plugin/core/utils/fs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function ensureDirForFile(filePath: string): void {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsonFile<T>(filePath: string, fallback: T): T {
|
||||||
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJsonFile(filePath: string, data: unknown): void {
|
||||||
|
ensureDirForFile(filePath);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||||
|
}
|
||||||
10
plugin/core/utils/id.ts
Normal file
10
plugin/core/utils/id.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const SAFE = /[^a-z0-9]+/g;
|
||||||
|
|
||||||
|
export function slug(input: string): string {
|
||||||
|
return input.trim().toLowerCase().replace(SAFE, "-").replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeId(prefix: string, input: string): string {
|
||||||
|
const s = slug(input);
|
||||||
|
return `${prefix}:${s || "unknown"}`;
|
||||||
|
}
|
||||||
245
plugin/core/yonexus.ts
Normal file
245
plugin/core/yonexus.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { DEFAULT_SCHEMA } from "./config/defaults";
|
||||||
|
import type { AuditLogEntry } from "./models/audit";
|
||||||
|
import { YonexusError } from "./models/errors";
|
||||||
|
import type {
|
||||||
|
Actor,
|
||||||
|
Agent,
|
||||||
|
DocsScope,
|
||||||
|
DocsTopic,
|
||||||
|
Identity,
|
||||||
|
QueryInput,
|
||||||
|
Scope,
|
||||||
|
StoreState,
|
||||||
|
YonexusSchema
|
||||||
|
} from "./models/types";
|
||||||
|
import { authorize } from "./permissions/authorize";
|
||||||
|
import { AuditStore } from "./store/auditStore";
|
||||||
|
import { JsonStore } from "./store/jsonStore";
|
||||||
|
import { queryIdentities } from "../tools/query";
|
||||||
|
import { ResourceLayout } from "../tools/resources";
|
||||||
|
import { makeId } from "./utils/id";
|
||||||
|
|
||||||
|
export interface YonexusOptions {
|
||||||
|
dataFile?: string;
|
||||||
|
schema?: YonexusSchema;
|
||||||
|
registrars?: string[];
|
||||||
|
openclawDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Yonexus {
|
||||||
|
private readonly schema: YonexusSchema;
|
||||||
|
private readonly registrars: Set<string>;
|
||||||
|
private readonly store: JsonStore;
|
||||||
|
private readonly audit = new AuditStore();
|
||||||
|
private readonly resources: ResourceLayout;
|
||||||
|
|
||||||
|
constructor(options: YonexusOptions = {}) {
|
||||||
|
const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json");
|
||||||
|
this.store = new JsonStore(dataFile);
|
||||||
|
this.schema = options.schema ?? DEFAULT_SCHEMA;
|
||||||
|
this.registrars = new Set(options.registrars ?? []);
|
||||||
|
|
||||||
|
const openclawDir =
|
||||||
|
options.openclawDir ??
|
||||||
|
process.env.OPENCLAW_DIR ??
|
||||||
|
path.resolve(process.env.HOME ?? process.cwd(), ".openclaw");
|
||||||
|
this.resources = new ResourceLayout(path.join(openclawDir, "yonexus"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(entry: Omit<AuditLogEntry, "id" | "ts">): void {
|
||||||
|
this.audit.append({
|
||||||
|
id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`),
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
...entry
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createOrganization(actor: Actor, name: string) {
|
||||||
|
authorize("create_organization", actor, {}, this.store);
|
||||||
|
const orgId = makeId("org", name);
|
||||||
|
if (this.store.findOrganization(orgId)) {
|
||||||
|
throw new YonexusError("ALREADY_EXISTS", `organization_exists: ${orgId}`);
|
||||||
|
}
|
||||||
|
const org = this.store.addOrganization({ id: orgId, name });
|
||||||
|
this.resources.ensureOrganization(name);
|
||||||
|
this.log({ actorId: actor.agentId, action: "create_organization", target: org.id, status: "ok" });
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDepartment(actor: Actor, name: string, orgId: string) {
|
||||||
|
try {
|
||||||
|
authorize("create_department", actor, { orgId }, this.store);
|
||||||
|
if (!this.store.findOrganization(orgId)) {
|
||||||
|
throw new YonexusError("NOT_FOUND", `organization_not_found: ${orgId}`, { orgId });
|
||||||
|
}
|
||||||
|
const dept = { id: makeId("dept", name), name, orgId };
|
||||||
|
const result = this.store.addDepartment(dept);
|
||||||
|
this.log({ actorId: actor.agentId, action: "create_department", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.log({
|
||||||
|
actorId: actor.agentId,
|
||||||
|
action: "create_department",
|
||||||
|
target: name,
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createTeam(actor: Actor, name: string, deptId: string) {
|
||||||
|
authorize("create_team", actor, { deptId }, this.store);
|
||||||
|
const dept = this.store.findDepartment(deptId);
|
||||||
|
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId });
|
||||||
|
|
||||||
|
const org = this.store.findOrganization(dept.orgId);
|
||||||
|
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
|
||||||
|
|
||||||
|
const team = { id: makeId("team", `${deptId}-${name}`), name, deptId };
|
||||||
|
const result = this.store.addTeam(team);
|
||||||
|
this.resources.ensureTeam(org.name, name);
|
||||||
|
this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) {
|
||||||
|
if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) {
|
||||||
|
throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBootstrap = this.store.listAgents().length === 0 && actor.agentId === agentId;
|
||||||
|
if (!isBootstrap) authorize("register_agent", actor, {}, this.store);
|
||||||
|
|
||||||
|
if (this.store.findAgent(agentId)) {
|
||||||
|
throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId });
|
||||||
|
}
|
||||||
|
const result = this.store.addAgent({ id: agentId, name, roles });
|
||||||
|
this.log({ actorId: actor.agentId, action: "register_agent", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record<string, string>): Identity {
|
||||||
|
authorize("assign_identity", actor, { deptId, teamId }, this.store);
|
||||||
|
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||||
|
|
||||||
|
const dept = this.store.findDepartment(deptId);
|
||||||
|
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||||
|
|
||||||
|
const team = this.store.findTeam(teamId);
|
||||||
|
if (!team) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
|
||||||
|
const org = this.store.findOrganization(dept.orgId);
|
||||||
|
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
|
||||||
|
|
||||||
|
const validatedMeta: Record<string, string> = {};
|
||||||
|
for (const [field, value] of Object.entries(meta)) {
|
||||||
|
if (!this.schema[field]) continue;
|
||||||
|
validatedMeta[field] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.store.addIdentity({
|
||||||
|
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
|
||||||
|
agentId,
|
||||||
|
deptId,
|
||||||
|
teamId,
|
||||||
|
meta: validatedMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resources.ensureAgent(org.name, team.name, agentId);
|
||||||
|
this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) {
|
||||||
|
authorize("set_supervisor", actor, { deptId }, this.store);
|
||||||
|
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||||
|
if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`);
|
||||||
|
if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference");
|
||||||
|
const result = this.store.upsertSupervisor({ agentId, supervisorId });
|
||||||
|
this.log({ actorId: actor.agentId, action: "set_supervisor", target: `${agentId}->${supervisorId}`, status: "ok" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
whoami(agentId: string) {
|
||||||
|
const agent = this.store.findAgent(agentId);
|
||||||
|
if (!agent) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||||
|
|
||||||
|
const identities = this.store.listIdentities().filter((x) => x.agentId === agentId);
|
||||||
|
const supervisor = this.store.findSupervisor(agentId);
|
||||||
|
return { agent, identities, supervisor };
|
||||||
|
}
|
||||||
|
|
||||||
|
queryAgents(actor: Actor, scope: Scope, query: QueryInput) {
|
||||||
|
authorize("query_agents", actor, scope, this.store);
|
||||||
|
const identities = this.store.listIdentities();
|
||||||
|
return queryIdentities(identities, query, this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string {
|
||||||
|
return this.resources.getDocs(scope, topic, keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
renameDepartment(actor: Actor, deptId: string, newName: string) {
|
||||||
|
authorize("create_department", actor, {}, this.store);
|
||||||
|
const updated = this.store.renameDepartment(deptId, newName);
|
||||||
|
if (!updated) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "rename_department", target: deptId, status: "ok" });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameTeam(actor: Actor, teamId: string, newName: string, deptId?: string) {
|
||||||
|
authorize("create_team", actor, { deptId }, this.store);
|
||||||
|
const updated = this.store.renameTeam(teamId, newName);
|
||||||
|
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "rename_team", target: teamId, status: "ok" });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateTeam(actor: Actor, teamId: string, newDeptId: string) {
|
||||||
|
authorize("create_team", actor, { deptId: newDeptId }, this.store);
|
||||||
|
if (!this.store.findDepartment(newDeptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${newDeptId}`);
|
||||||
|
const updated = this.store.migrateTeam(teamId, newDeptId);
|
||||||
|
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "migrate_team", target: `${teamId}->${newDeptId}`, status: "ok" });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDepartment(actor: Actor, deptId: string) {
|
||||||
|
authorize("create_department", actor, {}, this.store);
|
||||||
|
const ok = this.store.deleteDepartment(deptId);
|
||||||
|
if (!ok) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "delete_department", target: deptId, status: "ok" });
|
||||||
|
return { ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTeam(actor: Actor, teamId: string, deptId?: string) {
|
||||||
|
authorize("create_team", actor, { deptId }, this.store);
|
||||||
|
const ok = this.store.deleteTeam(teamId);
|
||||||
|
if (!ok) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||||
|
this.log({ actorId: actor.agentId, action: "delete_team", target: teamId, status: "ok" });
|
||||||
|
return { ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData(actor: Actor): StoreState {
|
||||||
|
authorize("query_agents", actor, {}, this.store);
|
||||||
|
this.log({ actorId: actor.agentId, action: "export_data", status: "ok" });
|
||||||
|
return this.store.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
importData(actor: Actor, state: StoreState): { ok: true } {
|
||||||
|
authorize("create_department", actor, {}, this.store);
|
||||||
|
this.store.replace(state);
|
||||||
|
this.log({ actorId: actor.agentId, action: "import_data", status: "ok" });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
listAuditLogs(limit = 100, offset = 0): AuditLogEntry[] {
|
||||||
|
return this.audit.list(limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugSnapshot(): StoreState {
|
||||||
|
return this.store.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
0
plugin/hooks/.gitkeep
Normal file
0
plugin/hooks/.gitkeep
Normal file
45
plugin/index.ts
Normal file
45
plugin/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Yonexus Plugin Entry
|
||||||
|
*
|
||||||
|
* This file is the wiring layer: it initializes and re-exports the core
|
||||||
|
* Yonexus class along with models, tools, and memory adapters.
|
||||||
|
* No business logic lives here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Core ────────────────────────────────────────────────────────────────
|
||||||
|
export { Yonexus, type YonexusOptions } from "./core/yonexus";
|
||||||
|
|
||||||
|
// ── Models ──────────────────────────────────────────────────────────────
|
||||||
|
export { YonexusError } from "./core/models/errors";
|
||||||
|
export type { ErrorCode } from "./core/models/errors";
|
||||||
|
export type { AuditLogEntry } from "./core/models/audit";
|
||||||
|
export type {
|
||||||
|
Action,
|
||||||
|
Actor,
|
||||||
|
Agent,
|
||||||
|
Department,
|
||||||
|
DocsScope,
|
||||||
|
DocsTopic,
|
||||||
|
Identity,
|
||||||
|
Organization,
|
||||||
|
QueryFilter,
|
||||||
|
QueryInput,
|
||||||
|
QueryOptions,
|
||||||
|
Role,
|
||||||
|
SchemaField,
|
||||||
|
Scope,
|
||||||
|
StoreState,
|
||||||
|
Supervisor,
|
||||||
|
Team,
|
||||||
|
YonexusSchema,
|
||||||
|
} from "./core/models/types";
|
||||||
|
|
||||||
|
// ── Tools ───────────────────────────────────────────────────────────────
|
||||||
|
export { queryIdentities } from "./tools/query";
|
||||||
|
export { ResourceLayout } from "./tools/resources";
|
||||||
|
|
||||||
|
// ── Memory ──────────────────────────────────────────────────────────────
|
||||||
|
export { ScopeMemory, type MemoryPort } from "./core/memory/scopeMemory";
|
||||||
|
|
||||||
|
// ── Default export ──────────────────────────────────────────────────────
|
||||||
|
export { Yonexus as default } from "./core/yonexus";
|
||||||
13
plugin/openclaw.plugin.json
Normal file
13
plugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "yonexus",
|
||||||
|
"name": "Yonexus",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean", "default": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
plugin/tools/query.ts
Normal file
75
plugin/tools/query.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { YonexusError } from '../core/models/errors';
|
||||||
|
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../core/models/types";
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 20;
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
const regexCache = new Map<string, RegExp>();
|
||||||
|
const containsCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function getRegex(pattern: string): RegExp {
|
||||||
|
const cached = regexCache.get(pattern);
|
||||||
|
if (cached) return cached;
|
||||||
|
const created = new RegExp(pattern);
|
||||||
|
regexCache.set(pattern, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNeedle(value: string): string {
|
||||||
|
const cached = containsCache.get(value);
|
||||||
|
if (cached) return cached;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
containsCache.set(value, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQueryable(field: string, schema: YonexusSchema): boolean {
|
||||||
|
return Boolean(schema[field]?.queryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchFilter(identity: Identity, filter: QueryFilter): boolean {
|
||||||
|
const raw = identity.meta[filter.field] ?? "";
|
||||||
|
|
||||||
|
switch (filter.op) {
|
||||||
|
case "eq":
|
||||||
|
return raw === filter.value;
|
||||||
|
case "contains":
|
||||||
|
return raw.toLowerCase().includes(normalizeNeedle(filter.value));
|
||||||
|
case "regex": {
|
||||||
|
const re = getRegex(filter.value);
|
||||||
|
return re.test(raw);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(options?: QueryOptions): Required<QueryOptions> {
|
||||||
|
const limit = Math.min(Math.max(1, options?.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
||||||
|
const offset = Math.max(0, options?.offset ?? 0);
|
||||||
|
return { limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFilters(filters: QueryFilter[]): QueryFilter[] {
|
||||||
|
const weight = (f: QueryFilter): number => {
|
||||||
|
if (f.op === 'eq') return 1;
|
||||||
|
if (f.op === 'contains') return 2;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
return [...filters].sort((a, b) => weight(a) - weight(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] {
|
||||||
|
for (const filter of input.filters) {
|
||||||
|
if (!isQueryable(filter.field, schema)) {
|
||||||
|
throw new YonexusError('FIELD_NOT_QUERYABLE', `field_not_queryable: ${filter.field}`, {
|
||||||
|
field: filter.field
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedFilters = sortFilters(input.filters);
|
||||||
|
const filtered = identities.filter((identity) => orderedFilters.every((f) => matchFilter(identity, f)));
|
||||||
|
const { limit, offset } = normalizeOptions(input.options);
|
||||||
|
return filtered.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
111
plugin/tools/resources.ts
Normal file
111
plugin/tools/resources.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { YonexusError } from '../core/models/errors';
|
||||||
|
import type { DocsScope, DocsTopic } from '../core/models/types';
|
||||||
|
import { slug } from '../core/utils/id';
|
||||||
|
|
||||||
|
const TOPICS: DocsTopic[] = ['docs', 'notes', 'knowledge', 'rules', 'lessons', 'workflows'];
|
||||||
|
|
||||||
|
function ensureDirs(base: string, dirs: string[]): void {
|
||||||
|
for (const d of dirs) fs.mkdirSync(path.join(base, d), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResourceLayout {
|
||||||
|
constructor(private readonly rootDir: string) {}
|
||||||
|
|
||||||
|
get organizationsRoot(): string {
|
||||||
|
return path.join(this.rootDir, 'organizations');
|
||||||
|
}
|
||||||
|
|
||||||
|
orgPath(orgName: string): string {
|
||||||
|
return path.join(this.organizationsRoot, slug(orgName));
|
||||||
|
}
|
||||||
|
|
||||||
|
teamPath(orgName: string, teamName: string): string {
|
||||||
|
return path.join(this.orgPath(orgName), 'teams', slug(teamName));
|
||||||
|
}
|
||||||
|
|
||||||
|
agentPath(orgName: string, teamName: string, agentId: string): string {
|
||||||
|
return path.join(this.teamPath(orgName, teamName), 'agents', slug(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureOrganization(orgName: string): void {
|
||||||
|
const root = this.orgPath(orgName);
|
||||||
|
ensureDirs(root, ['teams', ...TOPICS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTeam(orgName: string, teamName: string): void {
|
||||||
|
const root = this.teamPath(orgName, teamName);
|
||||||
|
ensureDirs(root, ['agents', ...TOPICS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAgent(orgName: string, teamName: string, agentId: string): void {
|
||||||
|
const root = this.agentPath(orgName, teamName, agentId);
|
||||||
|
ensureDirs(root, TOPICS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readTopicFiles(topicRoot: string, keyword: string): string[] {
|
||||||
|
if (!fs.existsSync(topicRoot)) return [];
|
||||||
|
let re: RegExp;
|
||||||
|
try {
|
||||||
|
re = new RegExp(keyword);
|
||||||
|
} catch {
|
||||||
|
throw new YonexusError('VALIDATION_ERROR', 'invalid_regex', { keyword });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(topicRoot, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.isFile() && re.test(e.name))
|
||||||
|
.map((e) => path.join(topicRoot, e.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string {
|
||||||
|
const groups: Record<'ORG' | 'DEPT' | 'TEAM' | 'AGENT', string[]> = {
|
||||||
|
ORG: [],
|
||||||
|
DEPT: [],
|
||||||
|
TEAM: [],
|
||||||
|
AGENT: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgsRoot = this.organizationsRoot;
|
||||||
|
if (!fs.existsSync(orgsRoot)) {
|
||||||
|
return '----ORG\n----DEPT\n----TEAM\n----AGENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgs = fs.readdirSync(orgsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
||||||
|
|
||||||
|
for (const orgName of orgs) {
|
||||||
|
const orgPath = path.join(orgsRoot, orgName);
|
||||||
|
if (scope === 'organization') {
|
||||||
|
groups.ORG.push(...this.readTopicFiles(path.join(orgPath, topic), keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamsRoot = path.join(orgPath, 'teams');
|
||||||
|
if (!fs.existsSync(teamsRoot)) continue;
|
||||||
|
|
||||||
|
const teams = fs.readdirSync(teamsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
||||||
|
for (const teamName of teams) {
|
||||||
|
const teamPath = path.join(teamsRoot, teamName);
|
||||||
|
if (scope === 'team') {
|
||||||
|
groups.TEAM.push(...this.readTopicFiles(path.join(teamPath, topic), keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentsRoot = path.join(teamPath, 'agents');
|
||||||
|
if (!fs.existsSync(agentsRoot)) continue;
|
||||||
|
|
||||||
|
const agents = fs.readdirSync(agentsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
||||||
|
for (const agentId of agents) {
|
||||||
|
if (scope === 'agent') {
|
||||||
|
groups.AGENT.push(...this.readTopicFiles(path.join(agentsRoot, agentId, topic), keyword));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// department folders are not defined in this layout; reserved empty group for compatible output.
|
||||||
|
const printGroup = (k: 'ORG' | 'DEPT' | 'TEAM' | 'AGENT'): string =>
|
||||||
|
[`----${k}`, ...groups[k]].join('\n');
|
||||||
|
|
||||||
|
return [printGroup('ORG'), printGroup('DEPT'), printGroup('TEAM'), printGroup('AGENT')].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
scripts/demo.ts
Normal file
34
scripts/demo.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { Yonexus } from '../plugin/index';
|
||||||
|
|
||||||
|
const dataFile = path.resolve(process.cwd(), 'data/demo-org.json');
|
||||||
|
if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile);
|
||||||
|
|
||||||
|
const yx = new Yonexus({ dataFile, registrars: ['orion'] });
|
||||||
|
|
||||||
|
yx.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']);
|
||||||
|
yx.registerAgent({ agentId: 'orion' }, 'hangman', 'Hangman', ['agent']);
|
||||||
|
|
||||||
|
const org = yx.createOrganization({ agentId: 'orion' }, 'Yonexus');
|
||||||
|
const dept = yx.createDepartment({ agentId: 'orion' }, 'Platform', org.id);
|
||||||
|
const team = yx.createTeam({ agentId: 'orion' }, 'Core', dept.id);
|
||||||
|
|
||||||
|
yx.assignIdentity({ agentId: 'orion' }, 'orion', dept.id, team.id, {
|
||||||
|
position: 'assistant',
|
||||||
|
discord_user_id: '1474088632750047324',
|
||||||
|
git_user_name: 'orion'
|
||||||
|
});
|
||||||
|
|
||||||
|
yx.setSupervisor({ agentId: 'orion' }, 'orion', 'hangman', dept.id);
|
||||||
|
|
||||||
|
const query = yx.queryAgents(
|
||||||
|
{ agentId: 'orion' },
|
||||||
|
{ deptId: dept.id },
|
||||||
|
{
|
||||||
|
filters: [{ field: 'git_user_name', op: 'eq', value: 'orion' }],
|
||||||
|
options: { limit: 10, offset: 0 }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ dept, team, query, audit: yx.listAuditLogs(20, 0) }, null, 2));
|
||||||
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
54
tests/smoke.ts
Normal file
54
tests/smoke.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from 'node:assert';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { Yonexus } from '../plugin/index';
|
||||||
|
import { YonexusError } from '../plugin/core/models/errors';
|
||||||
|
|
||||||
|
const root = path.resolve(process.cwd(), 'data/test-openclaw');
|
||||||
|
const dataFile = path.resolve(process.cwd(), 'data/test-org.json');
|
||||||
|
if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile);
|
||||||
|
if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const app = new Yonexus({ dataFile, registrars: ['orion'], openclawDir: root });
|
||||||
|
|
||||||
|
app.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']);
|
||||||
|
app.registerAgent({ agentId: 'orion' }, 'u1', 'U1', ['agent']);
|
||||||
|
const org = app.createOrganization({ agentId: 'orion' }, 'Yonexus');
|
||||||
|
const dept = app.createDepartment({ agentId: 'orion' }, 'Eng', org.id);
|
||||||
|
const team = app.createTeam({ agentId: 'orion' }, 'API', dept.id);
|
||||||
|
app.assignIdentity({ agentId: 'orion' }, 'u1', dept.id, team.id, {
|
||||||
|
git_user_name: 'u1',
|
||||||
|
position: 'dev'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = app.queryAgents(
|
||||||
|
{ agentId: 'orion' },
|
||||||
|
{ deptId: dept.id },
|
||||||
|
{ filters: [{ field: 'git_user_name', op: 'eq', value: 'u1' }] }
|
||||||
|
);
|
||||||
|
assert.equal(result.length, 1);
|
||||||
|
|
||||||
|
const expectedAgentDocsDir = path.join(root, 'yonexus', 'organizations', 'yonexus', 'teams', 'api', 'agents', 'u1', 'docs');
|
||||||
|
assert.equal(fs.existsSync(expectedAgentDocsDir), true);
|
||||||
|
|
||||||
|
let thrown = false;
|
||||||
|
try {
|
||||||
|
app.queryAgents(
|
||||||
|
{ agentId: 'orion' },
|
||||||
|
{ deptId: dept.id },
|
||||||
|
{ filters: [{ field: 'team', op: 'eq', value: 'API' }] }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e instanceof YonexusError && e.code === 'FIELD_NOT_QUERYABLE';
|
||||||
|
}
|
||||||
|
assert.equal(thrown, true);
|
||||||
|
|
||||||
|
let invalidRegexThrown = false;
|
||||||
|
try {
|
||||||
|
app.getDocs('agent', 'docs', '[');
|
||||||
|
} catch (e) {
|
||||||
|
invalidRegexThrown = e instanceof YonexusError && e.code === 'VALIDATION_ERROR';
|
||||||
|
}
|
||||||
|
assert.equal(invalidRegexThrown, true);
|
||||||
|
|
||||||
|
console.log('smoke test passed');
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist/yonexus",
|
||||||
|
"rootDir": "plugin",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["plugin/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user