reset project and add new yonexus communication plan
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
data/*.json
|
||||
!data/.gitkeep
|
||||
@@ -1,54 +0,0 @@
|
||||
# Yonexus — AGENT_TASKS
|
||||
|
||||
> 目标:将插件拆解为可执行任务(按阶段/优先级)。
|
||||
|
||||
## Phase 0 — 基础准备(P0)
|
||||
- [x] 明确插件运行环境/依赖(OpenClaw 版本、Node 版本)
|
||||
- [x] 定义最终配置文件格式(schema + permissions + registrars)
|
||||
- [x] 统一 ID 规则(org/dept/team/agent)
|
||||
|
||||
## Phase 1 — MVP 核心(P0)
|
||||
### 数据与存储
|
||||
- [x] 设计数据模型(Org/Dept/Team/Agent/Identity/Supervisor)
|
||||
- [x] 实现 in-memory store + JSON 持久化
|
||||
- [x] 定义 CRUD API
|
||||
|
||||
### 权限系统
|
||||
- [x] 实现权限角色(Org Admin / Dept Admin / Team Lead / Agent)
|
||||
- [x] 实现权限校验函数 authorize(action, actor, scope)
|
||||
- [x] 实现 registrars 白名单(禁止自注册)
|
||||
|
||||
### 工具/API
|
||||
- [x] create_department
|
||||
- [x] create_team
|
||||
- [x] register_agent
|
||||
- [x] assign_identity
|
||||
- [x] set_supervisor
|
||||
- [x] whoami
|
||||
- [x] query_agents
|
||||
|
||||
### Query DSL
|
||||
- [x] filters/op 解析(eq / contains / regex)
|
||||
- [x] schema queryable 字段约束
|
||||
- [x] 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
FEAT.md
86
FEAT.md
@@ -1,86 +0,0 @@
|
||||
# FEAT — Yonexus Feature List
|
||||
|
||||
## Existing Features
|
||||
|
||||
### Core Model & Storage
|
||||
- Organization / Department / Team / Agent / Identity / Supervisor data model
|
||||
- In-memory runtime with JSON persistence (`data/org.json`)
|
||||
- Import/export of structure data
|
||||
|
||||
### Authorization
|
||||
- Role model: `org_admin`, `dept_admin`, `team_lead`, `agent`
|
||||
- `authorize(action, actor, scope)` permission check
|
||||
- Registrar whitelist (`registrars`) and bootstrap registration support
|
||||
|
||||
### Core APIs
|
||||
- `createOrganization(actor, name)`
|
||||
- `createDepartment(actor, name, orgId)`
|
||||
- `createTeam(actor, name, deptId)`
|
||||
- `registerAgent(actor, agentId, name, roles?)`
|
||||
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
|
||||
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
|
||||
- `whoami(agentId)`
|
||||
- `queryAgents(actor, scope, query)`
|
||||
|
||||
### Query & Search
|
||||
- Query ops: `eq`, `contains`, `regex`
|
||||
- Schema `queryable` whitelist enforcement
|
||||
- Pagination (`limit`, `offset`)
|
||||
- Basic query performance optimization (regex cache + ordered filter eval)
|
||||
|
||||
### Management & Audit
|
||||
- `renameDepartment`, `renameTeam`, `migrateTeam`, `deleteDepartment`, `deleteTeam`
|
||||
- Structured errors via `YonexusError`
|
||||
- In-memory audit log (`listAuditLogs`)
|
||||
|
||||
### Scope Memory
|
||||
- Scope memory adapter:
|
||||
- `scope_memory.put(scopeId, text, metadata)`
|
||||
- `scope_memory.search(scopeId, query, limit)`
|
||||
|
||||
### Developer Experience
|
||||
- `README.md` + `README.zh.md`
|
||||
- Example data (`examples/sample-data.json`)
|
||||
- Demo script (`scripts/demo.ts`)
|
||||
- Smoke test (`tests/smoke.ts`)
|
||||
|
||||
---
|
||||
|
||||
## New Features (from NEW_FEAT)
|
||||
|
||||
### 1) Filesystem Resource Layout
|
||||
Data-only filesystem tree under:
|
||||
- `${openclaw dir}/yonexus/organizations/<org-name>/...`
|
||||
|
||||
Auto-create (idempotent):
|
||||
- On `createOrganization`:
|
||||
- `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||
- On `createTeam`:
|
||||
- `teams/<team-name>/agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
|
||||
- On `assignIdentity`:
|
||||
- `teams/<team-name>/agents/<agent-id>/docs|notes|knowledge|rules|lessons|workflows`
|
||||
|
||||
### 2) Document Query Tool
|
||||
New API:
|
||||
- `getDocs(scope, topic, keyword)`
|
||||
|
||||
Parameters:
|
||||
- `scope`: `organization | department | team | agent`
|
||||
- `topic`: `docs | notes | knowledge | rules | lessons | workflows`
|
||||
- `keyword`: regex string
|
||||
|
||||
Behavior:
|
||||
- Read-only search by filename regex under filesystem resources
|
||||
- Structured output:
|
||||
- `----ORG`
|
||||
- `----DEPT`
|
||||
- `----TEAM`
|
||||
- `----AGENT`
|
||||
- Invalid regex returns structured error (`YonexusError: VALIDATION_ERROR`)
|
||||
|
||||
## Notes
|
||||
- `${openclaw dir}` resolution order:
|
||||
1. `YonexusOptions.openclawDir`
|
||||
2. `OPENCLAW_DIR` env
|
||||
3. `${HOME}/.openclaw`
|
||||
- Plugin code is not written into `${openclaw dir}/yonexus`; only data folders/files are used there.
|
||||
654
PLAN.md
654
PLAN.md
@@ -1,127 +1,567 @@
|
||||
# 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).
|
||||
## 1. Goal
|
||||
|
||||
## 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)
|
||||
Yonexus is an OpenClaw plugin for **cross-instance communication** between multiple OpenClaw deployments.
|
||||
|
||||
## 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/`
|
||||
A Yonexus network contains:
|
||||
- exactly one instance with role `main`
|
||||
- one or more instances with role `follower`
|
||||
|
||||
## 4) Permissions Model (B)
|
||||
Roles:
|
||||
- Org Admin
|
||||
- Dept Admin
|
||||
- Team Lead
|
||||
- Agent
|
||||
The plugin provides:
|
||||
- a WebSocket-based communication layer between OpenClaw instances
|
||||
- pairing and identity verification for followers
|
||||
- persistent follower registry and trust state on the main node
|
||||
- heartbeat-based follower status tracking
|
||||
- a rule-based message dispatch mechanism
|
||||
- TypeScript function interfaces for other plugin/runtime code
|
||||
|
||||
This project is **not** an organization/identity management plugin anymore. All prior goals are discarded.
|
||||
|
||||
---
|
||||
|
||||
## 2. High-Level Architecture
|
||||
|
||||
### 2.1 Roles
|
||||
|
||||
Each OpenClaw instance running Yonexus must be configured with a `role`:
|
||||
- `main`
|
||||
- `follower`
|
||||
|
||||
Role semantics:
|
||||
- `main` is the hub/server for all Yonexus communication
|
||||
- `follower` connects outbound to the `main` instance
|
||||
|
||||
### 2.2 Network Topology
|
||||
|
||||
- The `main` instance must expose a fixed reachable IP/domain and run a WebSocket service.
|
||||
- `follower` instances do not need fixed IP/domain.
|
||||
- All `follower` instances connect to the `main` WebSocket endpoint.
|
||||
- No direct follower-to-follower communication is required in v1.
|
||||
- Messages between followers, if needed, are relayed by `main`.
|
||||
|
||||
### 2.3 Runtime Lifecycle
|
||||
|
||||
- On OpenClaw gateway startup:
|
||||
- if role is `main`, Yonexus starts a WebSocket server through a hook
|
||||
- if role is `follower`, Yonexus starts a WebSocket client and attempts to connect to `mainHost`
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration Model
|
||||
|
||||
## 3.1 Common Config
|
||||
|
||||
```ts
|
||||
role: "main" | "follower"
|
||||
```
|
||||
|
||||
## 3.2 Follower Config
|
||||
|
||||
Required when `role === "follower"`:
|
||||
|
||||
```ts
|
||||
mainHost: string
|
||||
identifier: string
|
||||
```
|
||||
|
||||
Semantics:
|
||||
- `mainHost`: WebSocket endpoint of the main instance (`ip:port` or full URL)
|
||||
- `identifier`: unique follower identity inside the Yonexus network
|
||||
|
||||
## 3.3 Main Config
|
||||
|
||||
Required when `role === "main"`:
|
||||
|
||||
```ts
|
||||
followerIdentifiers: string[]
|
||||
```
|
||||
|
||||
Semantics:
|
||||
- `followerIdentifiers`: allowlist of follower identifiers that are permitted to pair/connect
|
||||
|
||||
## 3.4 Validation Rules
|
||||
|
||||
### Main
|
||||
- must have `role = main`
|
||||
- must provide `followerIdentifiers`
|
||||
- must expose a stable/reachable IP/domain outside the plugin itself
|
||||
|
||||
### Follower
|
||||
- must have `role = follower`
|
||||
- must provide `mainHost`
|
||||
- must provide `identifier`
|
||||
|
||||
### Shared
|
||||
- invalid or missing role-specific fields must fail plugin initialization
|
||||
- unknown follower identifiers must be rejected by `main`
|
||||
|
||||
---
|
||||
|
||||
## 4. Main Responsibilities
|
||||
|
||||
The `main` instance must maintain a registry keyed by follower `identifier`.
|
||||
|
||||
Each follower record contains at minimum:
|
||||
- `identifier`
|
||||
- `publicKey`
|
||||
- `secret`
|
||||
- pairing state
|
||||
- pairing expiration data
|
||||
- connection status
|
||||
- security counters/window data
|
||||
- heartbeat timestamps
|
||||
- last known connection/session metadata
|
||||
|
||||
The registry must use:
|
||||
- in-memory runtime state for active operations
|
||||
- persistent on-disk storage for restart survival
|
||||
|
||||
### 4.1 Persistent Main Registry Model
|
||||
|
||||
Proposed shape:
|
||||
|
||||
```ts
|
||||
interface FollowerRecord {
|
||||
identifier: string;
|
||||
publicKey?: string;
|
||||
secret?: string;
|
||||
pairingStatus: "unpaired" | "pending" | "paired" | "revoked";
|
||||
pairingCode?: string;
|
||||
pairingExpiresAt?: number;
|
||||
status: "online" | "offline" | "unstable";
|
||||
lastHeartbeatAt?: number;
|
||||
lastAuthenticatedAt?: number;
|
||||
recentNonces: Array<{
|
||||
nonce: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
recentHandshakeAttempts: number[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `recentNonces` stores only the recent nonce window needed for replay detection
|
||||
- `recentHandshakeAttempts` stores timestamps for rate-limiting / unsafe reconnect detection
|
||||
- actual field names can change during implementation, but these semantics must remain
|
||||
|
||||
---
|
||||
|
||||
## 5. Pairing and Authentication Flow
|
||||
|
||||
## 5.1 First Connection: Key Generation
|
||||
|
||||
When a follower connects to main for the first time:
|
||||
- the follower generates a public/private key pair locally
|
||||
- the private key remains only on the follower
|
||||
- the public key is sent to `main` during handshake
|
||||
|
||||
If `main` sees that:
|
||||
- the follower identifier is allowed, and
|
||||
- no valid `secret` is currently associated with that identifier
|
||||
|
||||
then `main` must enter pairing flow.
|
||||
|
||||
## 5.2 Pairing Flow
|
||||
|
||||
### Step A: Pairing Request
|
||||
`main` responds with a pairing request containing:
|
||||
- a random pairing string
|
||||
- an expiration time
|
||||
|
||||
### Step B: Pairing Confirmation
|
||||
If the follower sends that random pairing string back to `main` before expiration:
|
||||
- pairing succeeds
|
||||
|
||||
### Step C: Secret Issuance
|
||||
After successful pairing:
|
||||
- `main` generates a random `secret`
|
||||
- `main` returns that `secret` to the follower
|
||||
- `main` stores follower `publicKey` + `secret`
|
||||
- `follower` stores private key + secret locally
|
||||
|
||||
If pairing expires before confirmation:
|
||||
- pairing fails
|
||||
- follower must restart the pairing process
|
||||
|
||||
## 5.3 Reconnection Authentication Flow
|
||||
|
||||
After pairing is complete, future follower authentication must use:
|
||||
- the stored `secret`
|
||||
- a 24-character random nonce
|
||||
- current UTC Unix timestamp
|
||||
|
||||
The follower builds a plaintext proof payload from:
|
||||
- `secret`
|
||||
- `nonce`
|
||||
- `timestamp`
|
||||
|
||||
Concatenation order:
|
||||
|
||||
```text
|
||||
secret + nonce + timestamp
|
||||
```
|
||||
|
||||
The follower encrypts/signs this payload using its private key and sends it to `main`.
|
||||
|
||||
`main` verifies:
|
||||
1. the follower identifier is known and paired
|
||||
2. the public key matches stored state
|
||||
3. decrypted/verified payload contains the correct `secret`
|
||||
4. timestamp difference from current UTC 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
|
||||
- follower is considered authenticated for the connection/session
|
||||
|
||||
If any check fails:
|
||||
- authentication fails
|
||||
- main may downgrade/revoke trust state
|
||||
|
||||
## 5.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 follower must re-pair
|
||||
- main should clear or rotate the stored `secret`
|
||||
- main should reset security windows as part of re-pairing
|
||||
|
||||
---
|
||||
|
||||
## 6. Heartbeat and Follower Status
|
||||
|
||||
The main instance must track each follower’s liveness state:
|
||||
- `online`
|
||||
- `unstable`
|
||||
- `offline`
|
||||
|
||||
## 6.1 Heartbeat Rules
|
||||
|
||||
Each follower must send a heartbeat to main every 5 minutes.
|
||||
|
||||
## 6.2 Status Transitions
|
||||
|
||||
### online
|
||||
A follower is `online` when:
|
||||
- it has an active authenticated WebSocket connection, and
|
||||
- main has received a recent heartbeat
|
||||
|
||||
### unstable
|
||||
A follower becomes `unstable` when:
|
||||
- no heartbeat has been received for 7 minutes
|
||||
|
||||
### offline
|
||||
A follower becomes `offline` when:
|
||||
- no heartbeat has been received for 11 minutes
|
||||
|
||||
When follower becomes `offline`:
|
||||
- main must close/terminate the WebSocket connection for that follower
|
||||
|
||||
## 6.3 Status Evaluation Strategy
|
||||
|
||||
Main should run a periodic status sweep timer to evaluate heartbeat freshness.
|
||||
|
||||
Recommended initial interval:
|
||||
- every 30 to 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## 7. Messaging Model
|
||||
|
||||
Yonexus provides rule-based message dispatch over WebSocket.
|
||||
|
||||
## 7.1 Base Message Format
|
||||
|
||||
All application messages must use the format:
|
||||
|
||||
```text
|
||||
${rule_identifier}::${message_content}
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- `rule_identifier` is a string token
|
||||
- `message_content` is the remainder payload as string
|
||||
|
||||
## 7.2 Main-Side Rewriting
|
||||
|
||||
When `main` receives a message from a follower, before rule matching it must rewrite the message into:
|
||||
|
||||
```text
|
||||
${rule_identifier}::${sender_identifier}::${message_content}
|
||||
```
|
||||
|
||||
This ensures rule processors on `main` can identify which follower sent the message.
|
||||
|
||||
## 7.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`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rule Registration and Dispatch
|
||||
|
||||
## 8.1 Public API
|
||||
|
||||
```ts
|
||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
||||
```
|
||||
|
||||
## 8.2 Rule Format
|
||||
|
||||
`rule` must use the format:
|
||||
|
||||
```text
|
||||
${rule_identifier}
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
- must be non-empty
|
||||
- must not contain the message delimiter sequence in invalid ways
|
||||
- must not equal `builtin`
|
||||
|
||||
## 8.3 Dispatch Rules
|
||||
|
||||
When Yonexus receives a message over WebSocket:
|
||||
- it iterates registered rules in registration order
|
||||
- it finds the first matching rule
|
||||
- it invokes the corresponding processor
|
||||
- only the first match is used
|
||||
|
||||
Clarification for implementation:
|
||||
- matching should initially be exact match on `rule_identifier`
|
||||
- if pattern-based matching is desired later, that must be explicitly added in a future phase
|
||||
|
||||
If no rule matches:
|
||||
- the message is ignored or logged as unhandled, depending on runtime policy
|
||||
|
||||
---
|
||||
|
||||
## 9. TypeScript API Surface
|
||||
|
||||
## 9.1 sendMessageToMain
|
||||
|
||||
```ts
|
||||
sendMessageToMain(message: string): Promise<void>
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Supervisor is **not** a role (no inherent permissions)
|
||||
- Registration **not** self-service
|
||||
- only configured agent list or human via slash command
|
||||
- allowed only on `follower`
|
||||
- calling from `main` must throw an error
|
||||
- sends message to connected `main`
|
||||
- message must already conform to `${rule_identifier}::${message_content}`
|
||||
|
||||
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`
|
||||
## 9.2 sendMessageToFollower
|
||||
|
||||
## 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 }
|
||||
}
|
||||
```ts
|
||||
sendMessageToFollower(identifier: string, message: string): Promise<void>
|
||||
```
|
||||
|
||||
## 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`
|
||||
Rules:
|
||||
- allowed only on `main`
|
||||
- calling from `follower` must throw an error
|
||||
- target follower must be known and currently connected/authenticated
|
||||
- message must already conform to `${rule_identifier}::${message_content}`
|
||||
|
||||
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}
|
||||
}
|
||||
## 9.3 registerRule
|
||||
|
||||
```ts
|
||||
registerRule(rule: string, processor: (message: string) => unknown): void
|
||||
```
|
||||
|
||||
## 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 }
|
||||
Rules:
|
||||
- rejects `builtin`
|
||||
- rejects duplicate rule registration unless an explicit override mode is added later
|
||||
- processors are invoked with the final received string after any main-side rewrite
|
||||
|
||||
## 8) Milestones
|
||||
**Phase 0 (Design)**
|
||||
- finalize schema
|
||||
- confirm permission rules
|
||||
---
|
||||
|
||||
**Phase 1 (MVP)**
|
||||
- storage + JSON persistence
|
||||
- core models + tools
|
||||
- query DSL
|
||||
- scope memory adapter
|
||||
## 10. Hooks and Runtime Integration
|
||||
|
||||
**Phase 2 (v1)**
|
||||
- policy refinements
|
||||
- better query pagination & filtering
|
||||
- management commands & validation
|
||||
## 10.1 Main Hook
|
||||
|
||||
## 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
|
||||
```
|
||||
The plugin must register a hook so that when OpenClaw gateway starts:
|
||||
- Yonexus initializes internal state
|
||||
- Yonexus starts a WebSocket server
|
||||
- Yonexus begins follower status sweep tasks
|
||||
|
||||
## 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`**
|
||||
## 10.2 Follower Runtime Behavior
|
||||
|
||||
## 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.
|
||||
On startup, follower should:
|
||||
- load local identity/secret/private key state
|
||||
- connect to `mainHost`
|
||||
- perform pairing or authentication flow
|
||||
- start periodic heartbeats when authenticated
|
||||
- attempt reconnect when disconnected
|
||||
|
||||
## 10.3 Persistence Requirements
|
||||
|
||||
### Main persists:
|
||||
- follower registry
|
||||
- public keys
|
||||
- secrets
|
||||
- pairing state
|
||||
- security/rate-limit windows if needed across restart, or resets them safely
|
||||
|
||||
### Follower persists:
|
||||
- identifier
|
||||
- private key
|
||||
- current secret
|
||||
- minimal pairing/auth state needed for reconnect
|
||||
|
||||
---
|
||||
|
||||
## 11. Storage Strategy
|
||||
|
||||
## 11.1 Main Storage
|
||||
|
||||
Main needs a local data file for follower registry persistence.
|
||||
|
||||
Suggested persisted sections:
|
||||
- trusted followers
|
||||
- pairing pending records
|
||||
- last known status metadata
|
||||
- security-related rolling records when persistence is desirable
|
||||
|
||||
## 11.2 Follower Storage
|
||||
|
||||
Follower needs a local secure data file for:
|
||||
- private key
|
||||
- secret
|
||||
- identifier
|
||||
- optional last successful connection metadata
|
||||
|
||||
## 11.3 Security Notes
|
||||
|
||||
- private key must never be sent to main
|
||||
- secret must be treated as sensitive material
|
||||
- storage format should support future encryption-at-rest, but plaintext local file may be acceptable in initial implementation if clearly documented as a limitation
|
||||
|
||||
---
|
||||
|
||||
## 12. Error Handling
|
||||
|
||||
The plugin should define structured errors for at least:
|
||||
- invalid configuration
|
||||
- invalid role usage
|
||||
- unauthorized identifier
|
||||
- pairing required
|
||||
- pairing expired
|
||||
- handshake verification failed
|
||||
- replay/nonce collision detected
|
||||
- rate limit / unsafe handshake detected
|
||||
- follower not connected
|
||||
- duplicate rule registration
|
||||
- reserved rule registration
|
||||
- malformed message
|
||||
|
||||
---
|
||||
|
||||
## 13. Initial Implementation Phases
|
||||
|
||||
## Phase 0 — Protocol and Skeleton
|
||||
- finalize config schema
|
||||
- define persisted data models
|
||||
- define protocol message types for builtin traffic
|
||||
- define hook startup behavior
|
||||
- define rule registry behavior
|
||||
|
||||
## Phase 1 — Main/Follower Transport MVP
|
||||
- main WebSocket server startup
|
||||
- follower WebSocket client startup
|
||||
- reconnect logic
|
||||
- basic builtin protocol channel
|
||||
- persistent registry scaffolding
|
||||
|
||||
## Phase 2 — Pairing and Authentication
|
||||
- follower keypair generation
|
||||
- pairing request/confirmation flow
|
||||
- secret issuance and persistence
|
||||
- signed/encrypted handshake proof verification
|
||||
- nonce/replay protection
|
||||
- unsafe-condition reset to pairing
|
||||
|
||||
## Phase 3 — Heartbeat and Status Tracking
|
||||
- follower heartbeat sender
|
||||
- main heartbeat receiver
|
||||
- periodic sweep
|
||||
- status transitions: online / unstable / offline
|
||||
- forced disconnect on offline
|
||||
|
||||
## Phase 4 — Public APIs and Message Dispatch
|
||||
- `sendMessageToMain`
|
||||
- `sendMessageToFollower`
|
||||
- `registerRule`
|
||||
- first-match dispatch
|
||||
- main-side sender rewrite behavior
|
||||
|
||||
## Phase 5 — Hardening and Docs
|
||||
- integration tests
|
||||
- failure-path coverage
|
||||
- restart recovery checks
|
||||
- protocol docs
|
||||
- operator setup docs for main/follower deployment
|
||||
|
||||
---
|
||||
|
||||
## 14. Non-Goals for Initial Version
|
||||
|
||||
Not required in the first version unless explicitly added later:
|
||||
- direct follower-to-follower sockets
|
||||
- multi-main clustering
|
||||
- distributed consensus
|
||||
- message ordering guarantees across reconnects
|
||||
- end-to-end application payload encryption beyond the handshake/authentication requirements
|
||||
- UI management panel
|
||||
|
||||
---
|
||||
|
||||
## 15. Open Questions To Confirm Later
|
||||
|
||||
These should be resolved before implementation starts:
|
||||
|
||||
1. Is the handshake primitive meant to be:
|
||||
- asymmetric encryption with private/public key, or
|
||||
- digital signature with verification by public key?
|
||||
|
||||
Recommended: **signature**, not “private-key encryption” wording.
|
||||
|
||||
2. Should `mainHost` accept only full WebSocket URLs (`ws://` / `wss://`) or also raw `ip:port` strings?
|
||||
|
||||
3. Should pairing require explicit operator approval on main, or is allowlist membership enough for automatic pairing?
|
||||
|
||||
4. On unsafe condition, should the old public key be retained or must the follower generate a brand-new keypair?
|
||||
|
||||
5. Should offline followers be allowed queued outbound messages from main, or should send fail immediately?
|
||||
|
||||
6. Are rule identifiers exact strings only, or should regex/prefix matching exist in future?
|
||||
|
||||
---
|
||||
|
||||
## 16. Immediate Next Deliverables
|
||||
|
||||
After this plan, the next files to create should be:
|
||||
- `FEAT.md` — feature checklist derived from this plan
|
||||
- `README.md` — concise operator/developer overview
|
||||
- `plugin.json` — plugin config schema and entry declaration
|
||||
- protocol notes for builtin messages
|
||||
- implementation task breakdown
|
||||
918
PROTOCOL.md
Normal file
918
PROTOCOL.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# Yonexus Protocol Specification
|
||||
|
||||
Version: draft v0.1
|
||||
Status: planning
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines the **Yonexus built-in communication protocol** used between OpenClaw instances.
|
||||
|
||||
Yonexus has two roles:
|
||||
- `main`: the central WebSocket server/hub
|
||||
- `follower`: an outbound WebSocket client connected to `main`
|
||||
|
||||
This protocol covers only the **built-in/system channel** required for:
|
||||
- connection setup
|
||||
- pairing
|
||||
- authentication
|
||||
- heartbeat
|
||||
- status/lifecycle events
|
||||
- protocol-level errors
|
||||
|
||||
Application-level business messages are transported separately through the Yonexus rule dispatch layer, but they still use the same WebSocket connection.
|
||||
|
||||
---
|
||||
|
||||
## 2. Transport
|
||||
|
||||
## 2.1 Protocol Transport
|
||||
|
||||
Transport is WebSocket.
|
||||
|
||||
- `main` listens as WebSocket server
|
||||
- `follower` connects as WebSocket client
|
||||
- all protocol frames are UTF-8 text messages in the first version
|
||||
|
||||
Binary frames are not required in v1.
|
||||
|
||||
## 2.2 Endpoint
|
||||
|
||||
The `follower` connects to `mainHost`, which may be configured as:
|
||||
- full URL: `ws://host:port/path` or `wss://host:port/path`
|
||||
- or raw `host:port` if implementation normalizes it
|
||||
|
||||
Recommended canonical configuration for docs and production:
|
||||
- prefer full WebSocket URL
|
||||
|
||||
---
|
||||
|
||||
## 3. Message Categories
|
||||
|
||||
Yonexus messages over WebSocket are split into two categories:
|
||||
|
||||
### 3.1 Builtin Protocol Messages
|
||||
|
||||
Builtin/system messages always use the reserved rule identifier:
|
||||
|
||||
```text
|
||||
builtin::${json_payload}
|
||||
```
|
||||
|
||||
User code must never register a handler for `builtin`.
|
||||
|
||||
### 3.2 Application Rule Messages
|
||||
|
||||
Application messages use the normal format:
|
||||
|
||||
```text
|
||||
${rule_identifier}::${message_content}
|
||||
```
|
||||
|
||||
When received by `main` from a follower, before dispatch they are rewritten to:
|
||||
|
||||
```text
|
||||
${rule_identifier}::${sender_identifier}::${message_content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Builtin Message Envelope
|
||||
|
||||
Builtin messages use this wire format:
|
||||
|
||||
```text
|
||||
builtin::{JSON}
|
||||
```
|
||||
|
||||
Where the JSON payload has this envelope shape:
|
||||
|
||||
```ts
|
||||
interface BuiltinEnvelope {
|
||||
type: string;
|
||||
requestId?: string;
|
||||
timestamp?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
Field semantics:
|
||||
- `type`: builtin message type
|
||||
- `requestId`: optional correlation id for request/response pairs
|
||||
- `timestamp`: sender-side UTC unix timestamp in seconds or milliseconds (must be standardized in implementation)
|
||||
- `payload`: type-specific object
|
||||
|
||||
## 4.1 Timestamp Unit
|
||||
|
||||
To avoid ambiguity, protocol implementation should standardize on:
|
||||
|
||||
```text
|
||||
UTC Unix timestamp in seconds
|
||||
```
|
||||
|
||||
If milliseconds are ever used internally, they must not leak into protocol payloads without explicit versioning.
|
||||
|
||||
---
|
||||
|
||||
## 5. Builtin Message Types
|
||||
|
||||
The first protocol version should define these builtin message types.
|
||||
|
||||
## 5.1 Session / Connection
|
||||
|
||||
### `hello`
|
||||
Sent by follower immediately after WebSocket connection opens.
|
||||
|
||||
Purpose:
|
||||
- identify follower intent
|
||||
- declare identifier
|
||||
- advertise available auth material/state
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"hello",
|
||||
"requestId":"req_001",
|
||||
"timestamp":1711886400,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"hasSecret":true,
|
||||
"hasKeyPair":true,
|
||||
"publicKey":"<optional-public-key>",
|
||||
"protocolVersion":"1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `publicKey` may be included during first connection and may also be re-sent during re-pair
|
||||
- `hasSecret` indicates whether follower believes it already holds a valid secret
|
||||
- `hasKeyPair` indicates whether follower has generated a key pair locally
|
||||
|
||||
### `hello_ack`
|
||||
Sent by main in response to `hello`.
|
||||
|
||||
Purpose:
|
||||
- acknowledge identifier
|
||||
- indicate required next step
|
||||
|
||||
Possible next actions:
|
||||
- `pair_required`
|
||||
- `auth_required`
|
||||
- `rejected`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"hello_ack",
|
||||
"requestId":"req_001",
|
||||
"timestamp":1711886401,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"nextAction":"pair_required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.2 Pairing Flow
|
||||
|
||||
### `pair_request`
|
||||
Sent by main when follower needs pairing.
|
||||
|
||||
Purpose:
|
||||
- start pairing challenge
|
||||
- deliver pairing code and expiry
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"pair_request",
|
||||
"requestId":"req_002",
|
||||
"timestamp":1711886402,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"pairingCode":"ABCD-1234-XYZ",
|
||||
"expiresAt":1711886702,
|
||||
"ttlSeconds":300,
|
||||
"publicKeyAccepted":true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `pair_confirm`
|
||||
Sent by follower to confirm pairing.
|
||||
|
||||
Purpose:
|
||||
- prove receipt of pairing code before expiry
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"pair_confirm",
|
||||
"requestId":"req_002",
|
||||
"timestamp":1711886410,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"pairingCode":"ABCD-1234-XYZ"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `pair_success`
|
||||
Sent by main after successful pairing.
|
||||
|
||||
Purpose:
|
||||
- return generated secret
|
||||
- confirm trusted pairing state
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"pair_success",
|
||||
"requestId":"req_002",
|
||||
"timestamp":1711886411,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"secret":"<random-secret>",
|
||||
"pairedAt":1711886411
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `pair_failed`
|
||||
Sent by main if pairing fails.
|
||||
|
||||
Example reasons:
|
||||
- expired
|
||||
- invalid_code
|
||||
- identifier_not_allowed
|
||||
- internal_error
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"pair_failed",
|
||||
"requestId":"req_002",
|
||||
"timestamp":1711886710,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"reason":"expired"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.3 Authentication Flow
|
||||
|
||||
After pairing, reconnect authentication uses the stored `secret`, nonce, timestamp, and follower private key.
|
||||
|
||||
## 5.3.1 Authentication Payload Construction
|
||||
|
||||
Follower constructs plaintext proof data as:
|
||||
|
||||
```text
|
||||
secret + nonce + timestamp
|
||||
```
|
||||
|
||||
Where:
|
||||
- `secret`: the current shared secret issued by main
|
||||
- `nonce`: 24 random characters
|
||||
- `timestamp`: current UTC Unix timestamp in seconds
|
||||
|
||||
Recommended future improvement:
|
||||
- use a canonical delimiter or JSON encoding before signing to avoid ambiguity
|
||||
|
||||
For v1 planning, the required logical content is unchanged, but implementation should prefer a canonical serialized object like:
|
||||
|
||||
```json
|
||||
{
|
||||
"secret":"...",
|
||||
"nonce":"...",
|
||||
"timestamp":1711886500
|
||||
}
|
||||
```
|
||||
|
||||
and then sign the serialized bytes.
|
||||
|
||||
## 5.3.2 Signature Primitive
|
||||
|
||||
The requirement says the follower uses the private key to encrypt/sign the proof.
|
||||
|
||||
For implementation, the recommended primitive is:
|
||||
- **digital signature using the private key**
|
||||
- verified by main using the stored public key
|
||||
|
||||
This should replace any literal “private-key encryption” wording in implementation docs.
|
||||
|
||||
### `auth_request`
|
||||
Sent by follower to authenticate after pairing.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"auth_request",
|
||||
"requestId":"req_003",
|
||||
"timestamp":1711886500,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"nonce":"RANDOM24CHARACTERSTRINGX",
|
||||
"proofTimestamp":1711886500,
|
||||
"signature":"<base64-signature>",
|
||||
"publicKey":"<optional-public-key-if-rotating>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Validation performed by main:
|
||||
1. identifier is allowlisted
|
||||
2. identifier exists in registry
|
||||
3. follower is in paired state
|
||||
4. public key matches expected key if provided
|
||||
5. signature verifies successfully against canonical proof payload
|
||||
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 main on successful authentication.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"auth_success",
|
||||
"requestId":"req_003",
|
||||
"timestamp":1711886501,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"authenticatedAt":1711886501,
|
||||
"status":"online"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `auth_failed`
|
||||
Sent by main if authentication fails.
|
||||
|
||||
Allowed failure reasons include:
|
||||
- unknown_identifier
|
||||
- not_paired
|
||||
- invalid_signature
|
||||
- invalid_secret
|
||||
- stale_timestamp
|
||||
- future_timestamp
|
||||
- nonce_collision
|
||||
- rate_limited
|
||||
- re_pair_required
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"auth_failed",
|
||||
"requestId":"req_003",
|
||||
"timestamp":1711886501,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"reason":"stale_timestamp",
|
||||
"rePairRequired":false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `re_pair_required`
|
||||
Sent by main when unsafe conditions or trust reset require full pairing again.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"re_pair_required",
|
||||
"requestId":"req_004",
|
||||
"timestamp":1711886510,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"reason":"nonce_collision"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.4 Heartbeat
|
||||
|
||||
### `heartbeat`
|
||||
Sent by follower every 5 minutes after authentication.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"heartbeat",
|
||||
"timestamp":1711886800,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"status":"alive"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `heartbeat_ack`
|
||||
Optional response by main.
|
||||
|
||||
Purpose:
|
||||
- confirm receipt
|
||||
- provide server time or status hints if desired
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"heartbeat_ack",
|
||||
"timestamp":1711886801,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"status":"online"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.5 Status / Lifecycle Notifications
|
||||
|
||||
### `status_update`
|
||||
Sent by main when follower state changes.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"status_update",
|
||||
"timestamp":1711887220,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"status":"unstable",
|
||||
"reason":"heartbeat_timeout_7m"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `disconnect_notice`
|
||||
Sent before main deliberately closes a follower connection.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"disconnect_notice",
|
||||
"timestamp":1711887460,
|
||||
"payload":{
|
||||
"identifier":"follower-a",
|
||||
"reason":"heartbeat_timeout_11m"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.6 Errors
|
||||
|
||||
### `error`
|
||||
Generic protocol-level error envelope.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
builtin::{
|
||||
"type":"error",
|
||||
"requestId":"req_999",
|
||||
"timestamp":1711887000,
|
||||
"payload":{
|
||||
"code":"MALFORMED_MESSAGE",
|
||||
"message":"missing type field"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommended builtin error codes:
|
||||
- `MALFORMED_MESSAGE`
|
||||
- `UNSUPPORTED_PROTOCOL_VERSION`
|
||||
- `IDENTIFIER_NOT_ALLOWED`
|
||||
- `PAIRING_REQUIRED`
|
||||
- `PAIRING_EXPIRED`
|
||||
- `AUTH_FAILED`
|
||||
- `NONCE_COLLISION`
|
||||
- `RATE_LIMITED`
|
||||
- `RE_PAIR_REQUIRED`
|
||||
- `FOLLOWER_OFFLINE`
|
||||
- `INTERNAL_ERROR`
|
||||
|
||||
---
|
||||
|
||||
## 6. Builtin State Machines
|
||||
|
||||
## 6.1 Follower State Machine
|
||||
|
||||
Suggested follower 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 (on socket close)
|
||||
-> connecting
|
||||
```
|
||||
|
||||
On `re_pair_required`:
|
||||
|
||||
```text
|
||||
authenticated | authenticating -> pairing_required
|
||||
```
|
||||
|
||||
## 6.2 Main-Side Follower Trust State
|
||||
|
||||
Per follower trust state:
|
||||
- `unpaired`
|
||||
- `pending`
|
||||
- `paired`
|
||||
- `revoked`
|
||||
|
||||
Per follower liveness state:
|
||||
- `online`
|
||||
- `unstable`
|
||||
- `offline`
|
||||
|
||||
These are related but not identical.
|
||||
|
||||
Example:
|
||||
- follower may be `paired` + `offline`
|
||||
- follower may be `pending` + `offline`
|
||||
|
||||
---
|
||||
|
||||
## 7. Security Windows and Replay Protection
|
||||
|
||||
## 7.1 Nonce Requirements
|
||||
|
||||
Nonce rules:
|
||||
- exactly 24 random characters
|
||||
- generated fresh for each auth attempt
|
||||
- must not repeat within the recent security window
|
||||
|
||||
## 7.2 Recent Nonce Window
|
||||
|
||||
Main stores for each follower:
|
||||
- the last 10 nonces seen within the recent validity window
|
||||
|
||||
If a nonce collides with the recent stored set:
|
||||
- authentication must fail
|
||||
- main must mark the situation unsafe
|
||||
- follower must re-pair
|
||||
|
||||
## 7.3 Handshake Attempt Window
|
||||
|
||||
Main stores recent handshake attempt timestamps for each follower.
|
||||
|
||||
If more than 10 handshake attempts occur within 10 seconds:
|
||||
- authentication must fail
|
||||
- main must mark situation unsafe
|
||||
- follower must re-pair
|
||||
|
||||
## 7.4 Time Drift Validation
|
||||
|
||||
Main validates:
|
||||
|
||||
```text
|
||||
abs(current_utc_unix_time - proofTimestamp) < 10
|
||||
```
|
||||
|
||||
If validation fails:
|
||||
- auth fails
|
||||
- no successful session is established
|
||||
|
||||
Implementation note:
|
||||
- use server time only on main as source of truth
|
||||
|
||||
---
|
||||
|
||||
## 8. Rule Message Dispatch Semantics
|
||||
|
||||
## 8.1 Message Format
|
||||
|
||||
All non-builtin messages use:
|
||||
|
||||
```text
|
||||
${rule_identifier}::${message_content}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
Follower to main:
|
||||
|
||||
```text
|
||||
chat_sync::{"conversationId":"abc","body":"hello"}
|
||||
```
|
||||
|
||||
Main rewrites before matching:
|
||||
|
||||
```text
|
||||
chat_sync::follower-a::{"conversationId":"abc","body":"hello"}
|
||||
```
|
||||
|
||||
## 8.2 Rule Matching
|
||||
|
||||
Initial rule matching should be exact string match against `rule_identifier`.
|
||||
|
||||
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
|
||||
|
||||
## 8.3 Processor Input
|
||||
|
||||
Processor receives the final message string exactly as seen after protocol-level rewrite.
|
||||
|
||||
This means:
|
||||
- on follower, processor input remains `${rule_identifier}::${message_content}`
|
||||
- on main for follower-originated messages, processor input becomes `${rule_identifier}::${sender_identifier}::${message_content}`
|
||||
|
||||
---
|
||||
|
||||
## 9. Connection Rules
|
||||
|
||||
## 9.1 Allowed Connection Policy
|
||||
|
||||
Main should reject connection attempts when:
|
||||
- identifier is absent
|
||||
- identifier is not in configured `followerIdentifiers`
|
||||
- protocol version is unsupported
|
||||
- builtin hello/auth payload is malformed
|
||||
|
||||
## 9.2 One Active Connection Per Identifier
|
||||
|
||||
Recommended v1 policy:
|
||||
- only one active authenticated connection per follower identifier
|
||||
|
||||
If a second connection for the same identifier appears:
|
||||
- main may reject the new one, or
|
||||
- terminate the previous one and accept the new one
|
||||
|
||||
This behavior must be chosen explicitly in implementation.
|
||||
|
||||
Recommended default:
|
||||
- terminate old, accept new after successful auth
|
||||
|
||||
---
|
||||
|
||||
## 10. Persistence Semantics
|
||||
|
||||
## 10.1 Main Persists
|
||||
|
||||
At minimum:
|
||||
- identifier
|
||||
- public key
|
||||
- secret
|
||||
- trust state
|
||||
- pairing code + expiry if pairing is pending
|
||||
- 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 Follower Persists
|
||||
|
||||
At minimum:
|
||||
- identifier
|
||||
- private key
|
||||
- secret
|
||||
- protocol version if useful
|
||||
- last successful pair/auth metadata if useful
|
||||
|
||||
---
|
||||
|
||||
## 11. Versioning
|
||||
|
||||
Protocol payloads should include a protocol version during `hello`.
|
||||
|
||||
Initial protocol version:
|
||||
|
||||
```text
|
||||
1
|
||||
```
|
||||
|
||||
If main does not support the provided version, it should respond with:
|
||||
- `error`
|
||||
- code: `UNSUPPORTED_PROTOCOL_VERSION`
|
||||
|
||||
---
|
||||
|
||||
## 12. Recommended Canonical JSON Shapes
|
||||
|
||||
To reduce ambiguity during implementation, these payload models are recommended.
|
||||
|
||||
```ts
|
||||
interface HelloPayload {
|
||||
identifier: string;
|
||||
hasSecret: boolean;
|
||||
hasKeyPair: boolean;
|
||||
publicKey?: string;
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
interface PairRequestPayload {
|
||||
identifier: string;
|
||||
pairingCode: string;
|
||||
expiresAt: number;
|
||||
ttlSeconds: number;
|
||||
publicKeyAccepted: boolean;
|
||||
}
|
||||
|
||||
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 End-to-End Flows
|
||||
|
||||
## 13.1 First-Time Pairing Flow
|
||||
|
||||
```text
|
||||
Follower connects WS
|
||||
Follower -> builtin::hello
|
||||
Main -> builtin::hello_ack(nextAction=pair_required)
|
||||
Main -> builtin::pair_request(pairingCode, expiresAt)
|
||||
Follower -> builtin::pair_confirm(pairingCode)
|
||||
Main -> builtin::pair_success(secret)
|
||||
Follower stores secret
|
||||
Follower -> builtin::auth_request(signature over secret+nonce+timestamp)
|
||||
Main -> builtin::auth_success
|
||||
Follower enters authenticated state
|
||||
```
|
||||
|
||||
## 13.2 Normal Reconnect Flow
|
||||
|
||||
```text
|
||||
Follower connects WS
|
||||
Follower -> builtin::hello(hasSecret=true)
|
||||
Main -> builtin::hello_ack(nextAction=auth_required)
|
||||
Follower -> builtin::auth_request(...)
|
||||
Main -> builtin::auth_success
|
||||
Follower begins heartbeat schedule
|
||||
```
|
||||
|
||||
## 13.3 Unsafe Replay / Collision Flow
|
||||
|
||||
```text
|
||||
Follower -> builtin::auth_request(nonce collision)
|
||||
Main detects unsafe condition
|
||||
Main -> builtin::re_pair_required(reason=nonce_collision)
|
||||
Main invalidates existing secret/session trust
|
||||
Follower returns to pairing_required state
|
||||
```
|
||||
|
||||
## 13.4 Heartbeat Timeout Flow
|
||||
|
||||
```text
|
||||
Follower authenticated
|
||||
No heartbeat for 7 min -> main marks unstable
|
||||
No heartbeat for 11 min -> main marks offline
|
||||
Main -> builtin::disconnect_notice
|
||||
Main closes WS connection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Implementation Notes
|
||||
|
||||
## 14.1 Parsing
|
||||
|
||||
Because the top-level wire format is string-based with `::` delimiters:
|
||||
- only the first delimiter split should determine the `rule_identifier`
|
||||
- for `builtin`, the remainder should be treated as JSON string and parsed once
|
||||
- message content itself may contain `::`, so avoid naive full split logic
|
||||
|
||||
## 14.2 Payload Encoding
|
||||
|
||||
Recommended message content for application rules:
|
||||
- JSON string payloads where applicable
|
||||
- but Yonexus itself only requires string content
|
||||
|
||||
## 14.3 Logging
|
||||
|
||||
Main should log at least:
|
||||
- connection open/close
|
||||
- hello received
|
||||
- pairing created/succeeded/expired/failed
|
||||
- auth success/failure
|
||||
- nonce collisions
|
||||
- handshake rate-limit triggers
|
||||
- status transitions
|
||||
- unhandled rule messages
|
||||
|
||||
Sensitive values must never be logged in plaintext:
|
||||
- `secret`
|
||||
- private key
|
||||
- raw proof material
|
||||
- full signature verification internals unless safely redacted
|
||||
|
||||
---
|
||||
|
||||
## 15. Open Clarifications
|
||||
|
||||
Before implementation, these should be finalized:
|
||||
|
||||
1. Exact signing algorithm:
|
||||
- Ed25519 is a strong default candidate
|
||||
2. Secret length and generation requirements
|
||||
3. Pairing code format and length
|
||||
4. Whether `pair_request` should require operator confirmation or stay automatic for allowlisted identifiers
|
||||
5. Whether `heartbeat_ack` is mandatory or optional
|
||||
6. Whether follower should auto-reconnect with backoff strategy after disconnect
|
||||
7. Whether duplicate active connections should replace old sessions or be rejected
|
||||
|
||||
---
|
||||
|
||||
## 16. Summary of Reserved Builtin Types
|
||||
|
||||
Current 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.
|
||||
112
README.md
112
README.md
@@ -1,112 +0,0 @@
|
||||
[English](./README.md) | [中文](./README.zh.md)
|
||||
|
||||
---
|
||||
|
||||
# Yonexus
|
||||
|
||||
Yonexus is an OpenClaw plugin for organization hierarchy and agent identity management.
|
||||
|
||||
## Features
|
||||
|
||||
- Organization hierarchy: `Organization -> Department -> Team -> Agent`
|
||||
- Filesystem-backed resource layout under `${openclaw dir}/yonexus`
|
||||
- Agent registration and multi-identity assignment
|
||||
- Supervisor relationship mapping (does **not** imply permissions)
|
||||
- Role-based authorization
|
||||
- Query DSL: `eq | contains | regex`
|
||||
- Queryable field whitelist via schema (`queryable: true`)
|
||||
- Scope shared memory adapter (`org/dept/team`)
|
||||
- JSON persistence for structure data
|
||||
- Audit logs and structured error codes
|
||||
- Import / export support
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
.
|
||||
├─ plugin.json
|
||||
├─ src/
|
||||
│ ├─ index.ts
|
||||
│ ├─ models/
|
||||
│ ├─ permissions/
|
||||
│ ├─ store/
|
||||
│ ├─ tools/
|
||||
│ ├─ memory/
|
||||
│ └─ utils/
|
||||
├─ scripts/
|
||||
│ ├─ install.sh
|
||||
│ └─ demo.ts
|
||||
├─ tests/
|
||||
│ └─ smoke.ts
|
||||
├─ examples/
|
||||
│ └─ sample-data.json
|
||||
└─ dist/
|
||||
└─ yonexus/
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22+
|
||||
- npm 10+
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
bash scripts/install.sh
|
||||
npm run test:smoke
|
||||
npm run demo
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`plugin.json` includes default config:
|
||||
|
||||
- `name`: `yonexus`
|
||||
- `entry`: `dist/yonexus/index.js`
|
||||
- `config.dataFile`: `./data/org.json`
|
||||
- `config.registrars`: whitelist for registrar agents
|
||||
- `config.schema`: metadata field schema and queryability
|
||||
|
||||
## Implemented APIs
|
||||
|
||||
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)`
|
||||
|
||||
Management:
|
||||
- `renameDepartment(actor, deptId, newName)`
|
||||
- `renameTeam(actor, teamId, newName, deptId?)`
|
||||
- `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
|
||||
|
||||
Smoke test:
|
||||
|
||||
```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.
|
||||
112
README.zh.md
112
README.zh.md
@@ -1,112 +0,0 @@
|
||||
[English](./README.md) | [中文](./README.zh.md)
|
||||
|
||||
---
|
||||
|
||||
# Yonexus
|
||||
|
||||
Yonexus 是一个用于 OpenClaw 的组织结构与 Agent 身份管理插件。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 组织层级:`Organization -> Department -> Team -> Agent`
|
||||
- 基于文件系统的资源目录:`${openclaw dir}/yonexus`
|
||||
- Agent 注册与多身份(Identity)管理
|
||||
- 上下级关系(Supervisor,**不自动赋权**)
|
||||
- 基于角色的权限控制
|
||||
- Query DSL:`eq | contains | regex`
|
||||
- 基于 schema 的可查询字段白名单(`queryable: true`)
|
||||
- scope 共享记忆适配(org/dept/team)
|
||||
- 结构化数据 JSON 持久化
|
||||
- 审计日志与结构化错误码
|
||||
- 导入 / 导出能力
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├─ plugin.json
|
||||
├─ src/
|
||||
│ ├─ index.ts
|
||||
│ ├─ models/
|
||||
│ ├─ permissions/
|
||||
│ ├─ store/
|
||||
│ ├─ tools/
|
||||
│ ├─ memory/
|
||||
│ └─ utils/
|
||||
├─ scripts/
|
||||
│ ├─ install.sh
|
||||
│ └─ demo.ts
|
||||
├─ tests/
|
||||
│ └─ smoke.ts
|
||||
├─ examples/
|
||||
│ └─ sample-data.json
|
||||
└─ dist/
|
||||
└─ yonexus/
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 22+
|
||||
- npm 10+
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
bash scripts/install.sh
|
||||
npm run test:smoke
|
||||
npm run demo
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
`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 中标记为可查询。
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"organizations": [
|
||||
{ "id": "org:yonexus", "name": "Yonexus" }
|
||||
],
|
||||
"departments": [
|
||||
{ "id": "dept:platform", "name": "Platform", "orgId": "org:yonexus" },
|
||||
{ "id": "dept:ai", "name": "AI", "orgId": "org:yonexus" }
|
||||
],
|
||||
"teams": [
|
||||
{ "id": "team:platform-core", "name": "Core", "deptId": "dept:platform" },
|
||||
{ "id": "team:ai-agent", "name": "Agent", "deptId": "dept:ai" }
|
||||
],
|
||||
"agents": [
|
||||
{ "id": "orion", "name": "Orion", "roles": ["org_admin", "agent"] },
|
||||
{ "id": "hangman", "name": "Hangman", "roles": ["agent"] }
|
||||
],
|
||||
"identities": [
|
||||
{
|
||||
"id": "identity:orion-platform",
|
||||
"agentId": "orion",
|
||||
"deptId": "dept:platform",
|
||||
"teamId": "team:platform-core",
|
||||
"meta": {
|
||||
"position": "assistant",
|
||||
"discord_user_id": "1474088632750047324",
|
||||
"git_user_name": "orion"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "identity:hangman-ai",
|
||||
"agentId": "hangman",
|
||||
"deptId": "dept:ai",
|
||||
"teamId": "team:ai-agent",
|
||||
"meta": {
|
||||
"position": "owner",
|
||||
"discord_user_id": "561921120408698910",
|
||||
"git_user_name": "hangman"
|
||||
}
|
||||
}
|
||||
],
|
||||
"supervisors": [
|
||||
{ "agentId": "orion", "supervisorId": "hangman" }
|
||||
]
|
||||
}
|
||||
591
package-lock.json
generated
591
package-lock.json
generated
@@ -1,591 +0,0 @@
|
||||
{
|
||||
"name": "openclaw-plugin-yonexus",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openclaw-plugin-yonexus",
|
||||
"version": "0.1.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
21
package.json
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "openclaw-plugin-yonexus",
|
||||
"version": "0.1.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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
21
plugin.json
21
plugin.json
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "yonexus",
|
||||
"version": "0.1.0",
|
||||
"entry": "dist/yonexus/index.js",
|
||||
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
|
||||
"permissions": [
|
||||
"memory_store",
|
||||
"memory_recall"
|
||||
],
|
||||
"config": {
|
||||
"dataFile": "./data/org.json",
|
||||
"registrars": [],
|
||||
"schema": {
|
||||
"position": { "type": "string", "queryable": true },
|
||||
"discord_user_id": { "type": "string", "queryable": true },
|
||||
"git_user_name": { "type": "string", "queryable": true },
|
||||
"department": { "type": "string", "queryable": false },
|
||||
"team": { "type": "string", "queryable": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { Yonexus } from '../src/index';
|
||||
|
||||
const dataFile = path.resolve(process.cwd(), 'data/demo-org.json');
|
||||
if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile);
|
||||
|
||||
const yx = new Yonexus({ dataFile, registrars: ['orion'] });
|
||||
|
||||
yx.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']);
|
||||
yx.registerAgent({ agentId: 'orion' }, 'hangman', 'Hangman', ['agent']);
|
||||
|
||||
const org = yx.createOrganization({ agentId: 'orion' }, 'Yonexus');
|
||||
const dept = yx.createDepartment({ agentId: 'orion' }, 'Platform', org.id);
|
||||
const team = yx.createTeam({ agentId: 'orion' }, 'Core', dept.id);
|
||||
|
||||
yx.assignIdentity({ agentId: 'orion' }, 'orion', dept.id, team.id, {
|
||||
position: 'assistant',
|
||||
discord_user_id: '1474088632750047324',
|
||||
git_user_name: 'orion'
|
||||
});
|
||||
|
||||
yx.setSupervisor({ agentId: 'orion' }, 'orion', 'hangman', dept.id);
|
||||
|
||||
const query = yx.queryAgents(
|
||||
{ agentId: 'orion' },
|
||||
{ deptId: dept.id },
|
||||
{
|
||||
filters: [{ field: 'git_user_name', op: 'eq', value: 'orion' }],
|
||||
options: { limit: 10, offset: 0 }
|
||||
}
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ dept, team, query, audit: yx.listAuditLogs(20, 0) }, null, 2));
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Yonexus install script
|
||||
# - Registers plugin name: yonexus
|
||||
# - Places build output in dist/yonexus
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DIST_DIR="$ROOT_DIR/dist/yonexus"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if [ ! -d node_modules ]; then
|
||||
npm install
|
||||
fi
|
||||
|
||||
npm run build
|
||||
mkdir -p "$DIST_DIR"
|
||||
cp -f "$ROOT_DIR/plugin.json" "$DIST_DIR/plugin.json"
|
||||
|
||||
echo "[yonexus] install complete -> $DIST_DIR"
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { StoreState, YonexusSchema } from "../models/types";
|
||||
|
||||
export const DEFAULT_STATE: StoreState = {
|
||||
organizations: [],
|
||||
departments: [],
|
||||
teams: [],
|
||||
agents: [],
|
||||
identities: [],
|
||||
supervisors: []
|
||||
};
|
||||
|
||||
export const DEFAULT_SCHEMA: YonexusSchema = {
|
||||
position: { type: "string", queryable: true },
|
||||
discord_user_id: { type: "string", queryable: true },
|
||||
git_user_name: { type: "string", queryable: true },
|
||||
department: { type: "string", queryable: false },
|
||||
team: { type: "string", queryable: false }
|
||||
};
|
||||
247
src/index.ts
247
src/index.ts
@@ -1,247 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SCHEMA } from "./config/defaults";
|
||||
import type { AuditLogEntry } from "./models/audit";
|
||||
import { YonexusError } from "./models/errors";
|
||||
import type {
|
||||
Actor,
|
||||
Agent,
|
||||
DocsScope,
|
||||
DocsTopic,
|
||||
Identity,
|
||||
QueryInput,
|
||||
Scope,
|
||||
StoreState,
|
||||
YonexusSchema
|
||||
} from "./models/types";
|
||||
import { authorize } from "./permissions/authorize";
|
||||
import { AuditStore } from "./store/auditStore";
|
||||
import { JsonStore } from "./store/jsonStore";
|
||||
import { queryIdentities } from "./tools/query";
|
||||
import { ResourceLayout } from "./tools/resources";
|
||||
import { makeId } from "./utils/id";
|
||||
|
||||
export interface YonexusOptions {
|
||||
dataFile?: string;
|
||||
schema?: YonexusSchema;
|
||||
registrars?: string[];
|
||||
openclawDir?: string;
|
||||
}
|
||||
|
||||
export class Yonexus {
|
||||
private readonly schema: YonexusSchema;
|
||||
private readonly registrars: Set<string>;
|
||||
private readonly store: JsonStore;
|
||||
private readonly audit = new AuditStore();
|
||||
private readonly resources: ResourceLayout;
|
||||
|
||||
constructor(options: YonexusOptions = {}) {
|
||||
const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json");
|
||||
this.store = new JsonStore(dataFile);
|
||||
this.schema = options.schema ?? DEFAULT_SCHEMA;
|
||||
this.registrars = new Set(options.registrars ?? []);
|
||||
|
||||
const openclawDir =
|
||||
options.openclawDir ??
|
||||
process.env.OPENCLAW_DIR ??
|
||||
path.resolve(process.env.HOME ?? process.cwd(), ".openclaw");
|
||||
this.resources = new ResourceLayout(path.join(openclawDir, "yonexus"));
|
||||
}
|
||||
|
||||
private log(entry: Omit<AuditLogEntry, "id" | "ts">): void {
|
||||
this.audit.append({
|
||||
id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`),
|
||||
ts: new Date().toISOString(),
|
||||
...entry
|
||||
});
|
||||
}
|
||||
|
||||
createOrganization(actor: Actor, name: string) {
|
||||
authorize("create_organization", actor, {}, this.store);
|
||||
const orgId = makeId("org", name);
|
||||
if (this.store.findOrganization(orgId)) {
|
||||
throw new YonexusError("ALREADY_EXISTS", `organization_exists: ${orgId}`);
|
||||
}
|
||||
const org = this.store.addOrganization({ id: orgId, name });
|
||||
this.resources.ensureOrganization(name);
|
||||
this.log({ actorId: actor.agentId, action: "create_organization", target: org.id, status: "ok" });
|
||||
return org;
|
||||
}
|
||||
|
||||
createDepartment(actor: Actor, name: string, orgId: string) {
|
||||
try {
|
||||
authorize("create_department", actor, { orgId }, this.store);
|
||||
if (!this.store.findOrganization(orgId)) {
|
||||
throw new YonexusError("NOT_FOUND", `organization_not_found: ${orgId}`, { orgId });
|
||||
}
|
||||
const dept = { id: makeId("dept", name), name, orgId };
|
||||
const result = this.store.addDepartment(dept);
|
||||
this.log({ actorId: actor.agentId, action: "create_department", target: result.id, status: "ok" });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.log({
|
||||
actorId: actor.agentId,
|
||||
action: "create_department",
|
||||
target: name,
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
createTeam(actor: Actor, name: string, deptId: string) {
|
||||
authorize("create_team", actor, { deptId }, this.store);
|
||||
const dept = this.store.findDepartment(deptId);
|
||||
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId });
|
||||
|
||||
const org = this.store.findOrganization(dept.orgId);
|
||||
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
|
||||
|
||||
const team = { id: makeId("team", `${deptId}-${name}`), name, deptId };
|
||||
const result = this.store.addTeam(team);
|
||||
this.resources.ensureTeam(org.name, name);
|
||||
this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" });
|
||||
return result;
|
||||
}
|
||||
|
||||
registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) {
|
||||
if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) {
|
||||
throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`);
|
||||
}
|
||||
|
||||
const isBootstrap = this.store.listAgents().length === 0 && actor.agentId === agentId;
|
||||
if (!isBootstrap) authorize("register_agent", actor, {}, this.store);
|
||||
|
||||
if (this.store.findAgent(agentId)) {
|
||||
throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId });
|
||||
}
|
||||
const result = this.store.addAgent({ id: agentId, name, roles });
|
||||
this.log({ actorId: actor.agentId, action: "register_agent", target: result.id, status: "ok" });
|
||||
return result;
|
||||
}
|
||||
|
||||
assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record<string, string>): Identity {
|
||||
authorize("assign_identity", actor, { deptId, teamId }, this.store);
|
||||
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||
|
||||
const dept = this.store.findDepartment(deptId);
|
||||
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||
|
||||
const team = this.store.findTeam(teamId);
|
||||
if (!team) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||
|
||||
const org = this.store.findOrganization(dept.orgId);
|
||||
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
|
||||
|
||||
const validatedMeta: Record<string, string> = {};
|
||||
for (const [field, value] of Object.entries(meta)) {
|
||||
if (!this.schema[field]) continue;
|
||||
validatedMeta[field] = String(value);
|
||||
}
|
||||
|
||||
const result = this.store.addIdentity({
|
||||
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
|
||||
agentId,
|
||||
deptId,
|
||||
teamId,
|
||||
meta: validatedMeta
|
||||
});
|
||||
|
||||
this.resources.ensureAgent(org.name, team.name, agentId);
|
||||
this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" });
|
||||
return result;
|
||||
}
|
||||
|
||||
setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) {
|
||||
authorize("set_supervisor", actor, { deptId }, this.store);
|
||||
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||
if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`);
|
||||
if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference");
|
||||
const result = this.store.upsertSupervisor({ agentId, supervisorId });
|
||||
this.log({ actorId: actor.agentId, action: "set_supervisor", target: `${agentId}->${supervisorId}`, status: "ok" });
|
||||
return result;
|
||||
}
|
||||
|
||||
whoami(agentId: string) {
|
||||
const agent = this.store.findAgent(agentId);
|
||||
if (!agent) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
|
||||
|
||||
const identities = this.store.listIdentities().filter((x) => x.agentId === agentId);
|
||||
const supervisor = this.store.findSupervisor(agentId);
|
||||
return { agent, identities, supervisor };
|
||||
}
|
||||
|
||||
queryAgents(actor: Actor, scope: Scope, query: QueryInput) {
|
||||
authorize("query_agents", actor, scope, this.store);
|
||||
const identities = this.store.listIdentities();
|
||||
return queryIdentities(identities, query, this.schema);
|
||||
}
|
||||
|
||||
getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string {
|
||||
return this.resources.getDocs(scope, topic, keyword);
|
||||
}
|
||||
|
||||
renameDepartment(actor: Actor, deptId: string, newName: string) {
|
||||
authorize("create_department", actor, {}, this.store);
|
||||
const updated = this.store.renameDepartment(deptId, newName);
|
||||
if (!updated) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||
this.log({ actorId: actor.agentId, action: "rename_department", target: deptId, status: "ok" });
|
||||
return updated;
|
||||
}
|
||||
|
||||
renameTeam(actor: Actor, teamId: string, newName: string, deptId?: string) {
|
||||
authorize("create_team", actor, { deptId }, this.store);
|
||||
const updated = this.store.renameTeam(teamId, newName);
|
||||
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||
this.log({ actorId: actor.agentId, action: "rename_team", target: teamId, status: "ok" });
|
||||
return updated;
|
||||
}
|
||||
|
||||
migrateTeam(actor: Actor, teamId: string, newDeptId: string) {
|
||||
authorize("create_team", actor, { deptId: newDeptId }, this.store);
|
||||
if (!this.store.findDepartment(newDeptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${newDeptId}`);
|
||||
const updated = this.store.migrateTeam(teamId, newDeptId);
|
||||
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||
this.log({ actorId: actor.agentId, action: "migrate_team", target: `${teamId}->${newDeptId}`, status: "ok" });
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteDepartment(actor: Actor, deptId: string) {
|
||||
authorize("create_department", actor, {}, this.store);
|
||||
const ok = this.store.deleteDepartment(deptId);
|
||||
if (!ok) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
|
||||
this.log({ actorId: actor.agentId, action: "delete_department", target: deptId, status: "ok" });
|
||||
return { ok };
|
||||
}
|
||||
|
||||
deleteTeam(actor: Actor, teamId: string, deptId?: string) {
|
||||
authorize("create_team", actor, { deptId }, this.store);
|
||||
const ok = this.store.deleteTeam(teamId);
|
||||
if (!ok) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
|
||||
this.log({ actorId: actor.agentId, action: "delete_team", target: teamId, status: "ok" });
|
||||
return { ok };
|
||||
}
|
||||
|
||||
exportData(actor: Actor): StoreState {
|
||||
authorize("query_agents", actor, {}, this.store);
|
||||
this.log({ actorId: actor.agentId, action: "export_data", status: "ok" });
|
||||
return this.store.snapshot();
|
||||
}
|
||||
|
||||
importData(actor: Actor, state: StoreState): { ok: true } {
|
||||
authorize("create_department", actor, {}, this.store);
|
||||
this.store.replace(state);
|
||||
this.log({ actorId: actor.agentId, action: "import_data", status: "ok" });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
listAuditLogs(limit = 100, offset = 0): AuditLogEntry[] {
|
||||
return this.audit.list(limit, offset);
|
||||
}
|
||||
|
||||
debugSnapshot(): StoreState {
|
||||
return this.store.snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export default Yonexus;
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface MemoryPort {
|
||||
store(input: { text: string; scope: string; metadata?: Record<string, string> }): Promise<unknown>;
|
||||
recall(input: { query: string; scope: string; limit?: number }): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class ScopeMemory {
|
||||
constructor(private readonly memory: MemoryPort) {}
|
||||
|
||||
async put(scopeId: string, text: string, metadata?: Record<string, string>): Promise<unknown> {
|
||||
return this.memory.store({ text, scope: scopeId, metadata });
|
||||
}
|
||||
|
||||
async search(scopeId: string, query: string, limit = 5): Promise<unknown> {
|
||||
return this.memory.recall({ query, scope: scopeId, limit });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
ts: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type ErrorCode =
|
||||
| 'PERMISSION_DENIED'
|
||||
| 'NOT_FOUND'
|
||||
| 'ALREADY_EXISTS'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'FIELD_NOT_QUERYABLE'
|
||||
| 'INVALID_SUPERVISOR'
|
||||
| 'REGISTRAR_DENIED';
|
||||
|
||||
export class YonexusError extends Error {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'YonexusError';
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
export type Role = "org_admin" | "dept_admin" | "team_lead" | "agent";
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
deptId: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
id: string;
|
||||
agentId: string;
|
||||
deptId: string;
|
||||
teamId: string;
|
||||
meta: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Supervisor {
|
||||
agentId: string;
|
||||
supervisorId: string;
|
||||
}
|
||||
|
||||
export interface SchemaField {
|
||||
type: "string";
|
||||
queryable: boolean;
|
||||
}
|
||||
|
||||
export interface YonexusSchema {
|
||||
[field: string]: SchemaField;
|
||||
}
|
||||
|
||||
export interface StoreState {
|
||||
organizations: Organization[];
|
||||
departments: Department[];
|
||||
teams: Team[];
|
||||
agents: Agent[];
|
||||
identities: Identity[];
|
||||
supervisors: Supervisor[];
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| "create_organization"
|
||||
| "create_department"
|
||||
| "create_team"
|
||||
| "register_agent"
|
||||
| "assign_identity"
|
||||
| "set_supervisor"
|
||||
| "query_agents";
|
||||
|
||||
export type DocsScope = "organization" | "department" | "team" | "agent";
|
||||
export type DocsTopic = "docs" | "notes" | "knowledge" | "rules" | "lessons" | "workflows";
|
||||
|
||||
export interface Scope {
|
||||
orgId?: string;
|
||||
deptId?: string;
|
||||
teamId?: string;
|
||||
}
|
||||
|
||||
export interface QueryFilter {
|
||||
field: string;
|
||||
op: "eq" | "contains" | "regex";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface QueryInput {
|
||||
filters: QueryFilter[];
|
||||
options?: QueryOptions;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { YonexusError } from '../models/errors';
|
||||
import type { Action, Actor, Scope } from "../models/types";
|
||||
import { JsonStore } from "../store/jsonStore";
|
||||
|
||||
function hasRole(store: JsonStore, actor: Actor, role: string): boolean {
|
||||
const me = store.findAgent(actor.agentId);
|
||||
return Boolean(me?.roles.includes(role as never));
|
||||
}
|
||||
|
||||
function inDeptScope(scope: Scope): boolean {
|
||||
return Boolean(scope.deptId);
|
||||
}
|
||||
|
||||
function inTeamScope(scope: Scope): boolean {
|
||||
return Boolean(scope.teamId);
|
||||
}
|
||||
|
||||
export function authorize(action: Action, actor: Actor, scope: Scope, store: JsonStore): void {
|
||||
const orgAdmin = hasRole(store, actor, "org_admin");
|
||||
const deptAdmin = hasRole(store, actor, "dept_admin") && inDeptScope(scope);
|
||||
const teamLead = hasRole(store, actor, "team_lead") && inTeamScope(scope);
|
||||
const agent = hasRole(store, actor, "agent");
|
||||
|
||||
const allowed =
|
||||
(action === "create_organization" && orgAdmin) ||
|
||||
(action === "create_department" && orgAdmin) ||
|
||||
(action === "create_team" && (orgAdmin || deptAdmin)) ||
|
||||
(action === "assign_identity" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||
(action === "register_agent" && (orgAdmin || deptAdmin || teamLead)) ||
|
||||
(action === "set_supervisor" && (orgAdmin || deptAdmin)) ||
|
||||
(action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent));
|
||||
|
||||
if (!allowed) {
|
||||
throw new YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, {
|
||||
action,
|
||||
actorId: actor.agentId,
|
||||
scope
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { AuditLogEntry } from '../models/audit';
|
||||
|
||||
const MAX_AUDIT = 1000;
|
||||
|
||||
export class AuditStore {
|
||||
private logs: AuditLogEntry[] = [];
|
||||
|
||||
append(entry: AuditLogEntry): AuditLogEntry {
|
||||
this.logs.push(entry);
|
||||
if (this.logs.length > MAX_AUDIT) this.logs.shift();
|
||||
return entry;
|
||||
}
|
||||
|
||||
list(limit = 100, offset = 0): AuditLogEntry[] {
|
||||
const safeLimit = Math.min(Math.max(1, limit), 500);
|
||||
const safeOffset = Math.max(0, offset);
|
||||
return this.logs.slice(safeOffset, safeOffset + safeLimit);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { DEFAULT_STATE } from "../config/defaults";
|
||||
import type {
|
||||
Agent,
|
||||
Department,
|
||||
Identity,
|
||||
Organization,
|
||||
StoreState,
|
||||
Supervisor,
|
||||
Team
|
||||
} from "../models/types";
|
||||
import { readJsonFile, writeJsonFile } from "../utils/fs";
|
||||
|
||||
export class JsonStore {
|
||||
private state: StoreState;
|
||||
|
||||
constructor(private readonly filePath: string) {
|
||||
this.state = readJsonFile<StoreState>(filePath, DEFAULT_STATE);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
writeJsonFile(this.filePath, this.state);
|
||||
}
|
||||
|
||||
snapshot(): StoreState {
|
||||
return JSON.parse(JSON.stringify(this.state)) as StoreState;
|
||||
}
|
||||
|
||||
replace(state: StoreState): void {
|
||||
this.state = JSON.parse(JSON.stringify(state)) as StoreState;
|
||||
this.save();
|
||||
}
|
||||
|
||||
addOrganization(org: Organization): Organization {
|
||||
this.state.organizations.push(org);
|
||||
this.save();
|
||||
return org;
|
||||
}
|
||||
|
||||
addDepartment(dept: Department): Department {
|
||||
this.state.departments.push(dept);
|
||||
this.save();
|
||||
return dept;
|
||||
}
|
||||
|
||||
renameDepartment(deptId: string, name: string): Department | undefined {
|
||||
const dept = this.findDepartment(deptId);
|
||||
if (!dept) return undefined;
|
||||
dept.name = name;
|
||||
this.save();
|
||||
return dept;
|
||||
}
|
||||
|
||||
deleteDepartment(deptId: string): boolean {
|
||||
const before = this.state.departments.length;
|
||||
this.state.departments = this.state.departments.filter((d) => d.id !== deptId);
|
||||
this.state.teams = this.state.teams.filter((t) => t.deptId !== deptId);
|
||||
this.state.identities = this.state.identities.filter((i) => i.deptId !== deptId);
|
||||
const changed = this.state.departments.length !== before;
|
||||
if (changed) this.save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
addTeam(team: Team): Team {
|
||||
this.state.teams.push(team);
|
||||
this.save();
|
||||
return team;
|
||||
}
|
||||
|
||||
renameTeam(teamId: string, name: string): Team | undefined {
|
||||
const team = this.findTeam(teamId);
|
||||
if (!team) return undefined;
|
||||
team.name = name;
|
||||
this.save();
|
||||
return team;
|
||||
}
|
||||
|
||||
migrateTeam(teamId: string, newDeptId: string): Team | undefined {
|
||||
const team = this.findTeam(teamId);
|
||||
if (!team) return undefined;
|
||||
team.deptId = newDeptId;
|
||||
for (const identity of this.state.identities) {
|
||||
if (identity.teamId === teamId) identity.deptId = newDeptId;
|
||||
}
|
||||
this.save();
|
||||
return team;
|
||||
}
|
||||
|
||||
deleteTeam(teamId: string): boolean {
|
||||
const before = this.state.teams.length;
|
||||
this.state.teams = this.state.teams.filter((t) => t.id !== teamId);
|
||||
this.state.identities = this.state.identities.filter((i) => i.teamId !== teamId);
|
||||
const changed = before !== this.state.teams.length;
|
||||
if (changed) this.save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
addAgent(agent: Agent): Agent {
|
||||
this.state.agents.push(agent);
|
||||
this.save();
|
||||
return agent;
|
||||
}
|
||||
|
||||
addIdentity(identity: Identity): Identity {
|
||||
this.state.identities.push(identity);
|
||||
this.save();
|
||||
return identity;
|
||||
}
|
||||
|
||||
upsertSupervisor(rel: Supervisor): Supervisor {
|
||||
const idx = this.state.supervisors.findIndex((x) => x.agentId === rel.agentId);
|
||||
if (idx >= 0) this.state.supervisors[idx] = rel;
|
||||
else this.state.supervisors.push(rel);
|
||||
this.save();
|
||||
return rel;
|
||||
}
|
||||
|
||||
findAgent(agentId: string): Agent | undefined {
|
||||
return this.state.agents.find((a) => a.id === agentId);
|
||||
}
|
||||
|
||||
findOrganization(orgId: string): Organization | undefined {
|
||||
return this.state.organizations.find((o) => o.id === orgId);
|
||||
}
|
||||
|
||||
listOrganizations(): Organization[] {
|
||||
return this.state.organizations;
|
||||
}
|
||||
|
||||
findDepartment(deptId: string): Department | undefined {
|
||||
return this.state.departments.find((d) => d.id === deptId);
|
||||
}
|
||||
|
||||
listDepartments(): Department[] {
|
||||
return this.state.departments;
|
||||
}
|
||||
|
||||
findTeam(teamId: string): Team | undefined {
|
||||
return this.state.teams.find((t) => t.id === teamId);
|
||||
}
|
||||
|
||||
listTeams(): Team[] {
|
||||
return this.state.teams;
|
||||
}
|
||||
|
||||
listAgents(): Agent[] {
|
||||
return this.state.agents;
|
||||
}
|
||||
|
||||
listIdentities(): Identity[] {
|
||||
return this.state.identities;
|
||||
}
|
||||
|
||||
findSupervisor(agentId: string): Supervisor | undefined {
|
||||
return this.state.supervisors.find((s) => s.agentId === agentId);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { YonexusError } from '../models/errors';
|
||||
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../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);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { YonexusError } from '../models/errors';
|
||||
import type { DocsScope, DocsTopic } from '../models/types';
|
||||
import { slug } from '../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');
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function ensureDirForFile(filePath: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export function readJsonFile<T>(filePath: string, fallback: T): T {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
export function writeJsonFile(filePath: string, data: unknown): void {
|
||||
ensureDirForFile(filePath);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
const SAFE = /[^a-z0-9]+/g;
|
||||
|
||||
export function slug(input: string): string {
|
||||
return input.trim().toLowerCase().replace(SAFE, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function makeId(prefix: string, input: string): string {
|
||||
const s = slug(input);
|
||||
return `${prefix}:${s || "unknown"}`;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { Yonexus } from '../src/index';
|
||||
import { YonexusError } from '../src/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');
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist/yonexus",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user