Compare commits
3 Commits
d8290c0aa7
...
bc1a002a8c
| Author | SHA1 | Date | |
|---|---|---|---|
| bc1a002a8c | |||
| 3ec57ce199 | |||
| ac128d3827 |
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "yonexus-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Yonexus.Server OpenClaw plugin scaffold",
|
||||
"type": "module",
|
||||
"main": "dist/plugin/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"plugin",
|
||||
"scripts",
|
||||
"protocol",
|
||||
"README.md",
|
||||
"PLAN.md",
|
||||
"SCAFFOLD.md",
|
||||
"STRUCTURE.md",
|
||||
"TASKS.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"clean": "rm -rf dist",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
0
plugin/commands/.gitkeep
Normal file
0
plugin/commands/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
104
plugin/core/config.ts
Normal file
104
plugin/core/config.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export interface YonexusServerConfig {
|
||||
followerIdentifiers: string[];
|
||||
notifyBotToken: string;
|
||||
adminUserId: string;
|
||||
listenHost?: string;
|
||||
listenPort: number;
|
||||
publicWsUrl?: string;
|
||||
}
|
||||
|
||||
export class YonexusServerConfigError extends Error {
|
||||
readonly issues: string[];
|
||||
|
||||
constructor(issues: string[]) {
|
||||
super(`Invalid Yonexus.Server config: ${issues.join("; ")}`);
|
||||
this.name = "YonexusServerConfigError";
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isValidPort(value: unknown): value is number {
|
||||
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||
}
|
||||
|
||||
function isValidWsUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "ws:" || url.protocol === "wss:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig {
|
||||
const source = raw as Record<string, unknown> | null;
|
||||
const issues: string[] = [];
|
||||
|
||||
const rawIdentifiers = source?.followerIdentifiers;
|
||||
const followerIdentifiers = Array.isArray(rawIdentifiers)
|
||||
? rawIdentifiers
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
: [];
|
||||
|
||||
if (!Array.isArray(rawIdentifiers) || followerIdentifiers.length === 0) {
|
||||
issues.push("followerIdentifiers must contain at least one non-empty identifier");
|
||||
}
|
||||
|
||||
if (new Set(followerIdentifiers).size !== followerIdentifiers.length) {
|
||||
issues.push("followerIdentifiers must not contain duplicates");
|
||||
}
|
||||
|
||||
const notifyBotToken = source?.notifyBotToken;
|
||||
if (!isNonEmptyString(notifyBotToken)) {
|
||||
issues.push("notifyBotToken is required");
|
||||
}
|
||||
|
||||
const adminUserId = source?.adminUserId;
|
||||
if (!isNonEmptyString(adminUserId)) {
|
||||
issues.push("adminUserId is required");
|
||||
}
|
||||
|
||||
const listenPort = source?.listenPort;
|
||||
if (!isValidPort(listenPort)) {
|
||||
issues.push("listenPort must be an integer between 1 and 65535");
|
||||
}
|
||||
|
||||
const listenHost = normalizeOptionalString(source?.listenHost) ?? "0.0.0.0";
|
||||
const publicWsUrl = normalizeOptionalString(source?.publicWsUrl);
|
||||
|
||||
if (publicWsUrl !== undefined && !isValidWsUrl(publicWsUrl)) {
|
||||
issues.push("publicWsUrl must be a valid ws:// or wss:// URL when provided");
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new YonexusServerConfigError(issues);
|
||||
}
|
||||
|
||||
return {
|
||||
followerIdentifiers,
|
||||
notifyBotToken: notifyBotToken.trim(),
|
||||
adminUserId: adminUserId.trim(),
|
||||
listenHost,
|
||||
listenPort,
|
||||
publicWsUrl
|
||||
};
|
||||
}
|
||||
250
plugin/core/persistence.ts
Normal file
250
plugin/core/persistence.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Yonexus Server - Persistence Types
|
||||
*
|
||||
* Defines the persistent record structures for client registry and state management.
|
||||
* Based on PLAN.md section 6 and 12.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Client pairing status
|
||||
*/
|
||||
export type PairingStatus = "unpaired" | "pending" | "paired" | "revoked";
|
||||
|
||||
/**
|
||||
* Client liveness status
|
||||
*/
|
||||
export type ClientLivenessStatus = "online" | "offline" | "unstable";
|
||||
|
||||
/**
|
||||
* Pairing notification delivery status
|
||||
*/
|
||||
export type PairingNotifyStatus = "pending" | "sent" | "failed";
|
||||
|
||||
/**
|
||||
* Security window entry for nonce tracking
|
||||
*/
|
||||
export interface NonceEntry {
|
||||
/** The nonce value */
|
||||
readonly nonce: string;
|
||||
/** UTC unix timestamp when the nonce was used */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security window entry for handshake attempt tracking
|
||||
*/
|
||||
export interface HandshakeAttemptEntry {
|
||||
/** UTC unix timestamp of the attempt */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent client record stored by Yonexus.Server
|
||||
*
|
||||
* This structure represents the durable trust state for a client.
|
||||
* Rolling security windows (recentNonces, recentHandshakeAttempts) may be
|
||||
* cleared on server restart as per v1 semantics.
|
||||
*/
|
||||
export interface ClientRecord {
|
||||
/** Unique client identifier */
|
||||
readonly identifier: string;
|
||||
|
||||
/** Client's public key (Ed25519 or other) - stored after pairing */
|
||||
publicKey?: string;
|
||||
|
||||
/** Shared secret issued after successful pairing */
|
||||
secret?: string;
|
||||
|
||||
/** Current pairing status */
|
||||
pairingStatus: PairingStatus;
|
||||
|
||||
/** Pairing code (only valid when pairingStatus is "pending") */
|
||||
pairingCode?: string;
|
||||
|
||||
/** Pairing expiration timestamp (UTC unix seconds) */
|
||||
pairingExpiresAt?: number;
|
||||
|
||||
/** When the pairing notification was sent (UTC unix seconds) */
|
||||
pairingNotifiedAt?: number;
|
||||
|
||||
/** Status of the pairing notification delivery */
|
||||
pairingNotifyStatus?: PairingNotifyStatus;
|
||||
|
||||
/** Current liveness status (may be stale on restart) */
|
||||
status: ClientLivenessStatus;
|
||||
|
||||
/** Last heartbeat received timestamp (UTC unix seconds) */
|
||||
lastHeartbeatAt?: number;
|
||||
|
||||
/** Last successful authentication timestamp (UTC unix seconds) */
|
||||
lastAuthenticatedAt?: number;
|
||||
|
||||
/**
|
||||
* Recent nonces used in authentication attempts.
|
||||
* This is a rolling window that may be cleared on restart.
|
||||
*/
|
||||
recentNonces: NonceEntry[];
|
||||
|
||||
/**
|
||||
* Recent handshake attempt timestamps.
|
||||
* This is a rolling window that may be cleared on restart.
|
||||
*/
|
||||
recentHandshakeAttempts: number[];
|
||||
|
||||
/** Record creation timestamp (UTC unix seconds) */
|
||||
readonly createdAt: number;
|
||||
|
||||
/** Record last update timestamp (UTC unix seconds) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session state (not persisted)
|
||||
*
|
||||
* Represents an active or pending WebSocket connection.
|
||||
*/
|
||||
export interface ClientSession {
|
||||
/** Client identifier */
|
||||
readonly identifier: string;
|
||||
|
||||
/** WebSocket connection instance */
|
||||
readonly socket: unknown; // Will be typed as WebSocket when implementing transport
|
||||
|
||||
/** Whether the client is currently authenticated */
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/** Session start timestamp (UTC unix seconds) */
|
||||
readonly connectedAt: number;
|
||||
|
||||
/** Last activity timestamp (UTC unix seconds) */
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server registry state
|
||||
*
|
||||
* Contains both persistent and in-memory state for all clients.
|
||||
*/
|
||||
export interface ServerRegistry {
|
||||
/** Persistent client records keyed by identifier */
|
||||
clients: Map<string, ClientRecord>;
|
||||
|
||||
/** Active WebSocket sessions keyed by identifier */
|
||||
sessions: Map<string, ClientSession>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized form of ClientRecord for JSON persistence
|
||||
*/
|
||||
export interface SerializedClientRecord {
|
||||
identifier: string;
|
||||
publicKey?: string;
|
||||
secret?: string;
|
||||
pairingStatus: PairingStatus;
|
||||
pairingCode?: string;
|
||||
pairingExpiresAt?: number;
|
||||
pairingNotifiedAt?: number;
|
||||
pairingNotifyStatus?: PairingNotifyStatus;
|
||||
status: ClientLivenessStatus;
|
||||
lastHeartbeatAt?: number;
|
||||
lastAuthenticatedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
// Note: recentNonces and recentHandshakeAttempts are intentionally
|
||||
// excluded from persistent serialization - they are cleared on restart
|
||||
}
|
||||
|
||||
/**
|
||||
* Server persistence file format
|
||||
*/
|
||||
export interface ServerPersistenceData {
|
||||
/** Format version for migration support */
|
||||
version: number;
|
||||
|
||||
/** Server-side client records */
|
||||
clients: SerializedClientRecord[];
|
||||
|
||||
/** Persistence timestamp (UTC unix seconds) */
|
||||
persistedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new empty client record
|
||||
*/
|
||||
export function createClientRecord(identifier: string): ClientRecord {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
identifier,
|
||||
pairingStatus: "unpaired",
|
||||
status: "offline",
|
||||
recentNonces: [],
|
||||
recentHandshakeAttempts: [],
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ClientRecord to its serialized form (for persistence)
|
||||
*/
|
||||
export function serializeClientRecord(record: ClientRecord): SerializedClientRecord {
|
||||
return {
|
||||
identifier: record.identifier,
|
||||
publicKey: record.publicKey,
|
||||
secret: record.secret,
|
||||
pairingStatus: record.pairingStatus,
|
||||
pairingCode: record.pairingCode,
|
||||
pairingExpiresAt: record.pairingExpiresAt,
|
||||
pairingNotifiedAt: record.pairingNotifiedAt,
|
||||
pairingNotifyStatus: record.pairingNotifyStatus,
|
||||
status: record.status,
|
||||
lastHeartbeatAt: record.lastHeartbeatAt,
|
||||
lastAuthenticatedAt: record.lastAuthenticatedAt,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a client record and initialize rolling windows
|
||||
*/
|
||||
export function deserializeClientRecord(
|
||||
serialized: SerializedClientRecord
|
||||
): ClientRecord {
|
||||
return {
|
||||
...serialized,
|
||||
recentNonces: [], // Rolling windows cleared on restart
|
||||
recentHandshakeAttempts: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client record is in a pairable state
|
||||
*/
|
||||
export function isPairable(record: ClientRecord): boolean {
|
||||
return record.pairingStatus === "unpaired" || record.pairingStatus === "revoked";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client record has a pending pairing that may have expired
|
||||
*/
|
||||
export function hasPendingPairing(record: ClientRecord): boolean {
|
||||
return record.pairingStatus === "pending" && record.pairingCode !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pending pairing has expired
|
||||
*/
|
||||
export function isPairingExpired(record: ClientRecord, now: number = Date.now() / 1000): boolean {
|
||||
if (!hasPendingPairing(record) || record.pairingExpiresAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
return now > record.pairingExpiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client is ready for authentication (has secret and is paired)
|
||||
*/
|
||||
export function canAuthenticate(record: ClientRecord): boolean {
|
||||
return record.pairingStatus === "paired" && record.secret !== undefined && record.publicKey !== undefined;
|
||||
}
|
||||
0
plugin/hooks/.gitkeep
Normal file
0
plugin/hooks/.gitkeep
Normal file
@@ -0,0 +1,50 @@
|
||||
export { validateYonexusServerConfig, YonexusServerConfigError } from "./core/config.js";
|
||||
export type { YonexusServerConfig } from "./core/config.js";
|
||||
export {
|
||||
createClientRecord,
|
||||
serializeClientRecord,
|
||||
deserializeClientRecord,
|
||||
isPairable,
|
||||
hasPendingPairing,
|
||||
isPairingExpired,
|
||||
canAuthenticate,
|
||||
type PairingStatus,
|
||||
type ClientLivenessStatus,
|
||||
type PairingNotifyStatus,
|
||||
type NonceEntry,
|
||||
type HandshakeAttemptEntry,
|
||||
type ClientRecord,
|
||||
type ClientSession,
|
||||
type ServerRegistry,
|
||||
type SerializedClientRecord,
|
||||
type ServerPersistenceData
|
||||
} from "./core/persistence.js";
|
||||
|
||||
export interface YonexusServerPluginManifest {
|
||||
readonly name: "Yonexus.Server";
|
||||
readonly version: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface YonexusServerPluginRuntime {
|
||||
readonly hooks: readonly [];
|
||||
readonly commands: readonly [];
|
||||
readonly tools: readonly [];
|
||||
}
|
||||
|
||||
const manifest: YonexusServerPluginManifest = {
|
||||
name: "Yonexus.Server",
|
||||
version: "0.1.0",
|
||||
description: "Yonexus central hub plugin for cross-instance OpenClaw communication"
|
||||
};
|
||||
|
||||
export function createYonexusServerPlugin(): YonexusServerPluginRuntime {
|
||||
return {
|
||||
hooks: [],
|
||||
commands: [],
|
||||
tools: []
|
||||
};
|
||||
}
|
||||
|
||||
export default createYonexusServerPlugin;
|
||||
export { manifest };
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Yonexus.Server",
|
||||
"version": "0.1.0",
|
||||
"description": "Yonexus central hub plugin for cross-instance OpenClaw communication",
|
||||
"entry": "dist/plugin/index.js",
|
||||
"permissions": [],
|
||||
"config": {
|
||||
"followerIdentifiers": [],
|
||||
"notifyBotToken": "",
|
||||
"adminUserId": "",
|
||||
"listenHost": "0.0.0.0",
|
||||
"listenPort": 8787,
|
||||
"publicWsUrl": ""
|
||||
}
|
||||
}
|
||||
|
||||
0
plugin/tools/.gitkeep
Normal file
0
plugin/tools/.gitkeep
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const mode = args.includes("--install") ? "install" : args.includes("--uninstall") ? "uninstall" : null;
|
||||
const profileIndex = args.indexOf("--openclaw-profile-path");
|
||||
const profilePath = profileIndex >= 0 ? args[profileIndex + 1] : path.join(os.homedir(), ".openclaw");
|
||||
|
||||
if (!mode) {
|
||||
console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path <path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const pluginName = "Yonexus.Server";
|
||||
const sourceDist = path.join(repoRoot, "dist");
|
||||
const targetDir = path.join(profilePath, "plugins", pluginName);
|
||||
|
||||
if (mode === "install") {
|
||||
if (!fs.existsSync(sourceDist)) {
|
||||
console.error(`Build output not found: ${sourceDist}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
fs.cpSync(sourceDist, path.join(targetDir, "dist"), { recursive: true });
|
||||
fs.copyFileSync(path.join(repoRoot, "plugin", "openclaw.plugin.json"), path.join(targetDir, "openclaw.plugin.json"));
|
||||
console.log(`Installed ${pluginName} to ${targetDir}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
console.log(`Removed ${pluginName} from ${targetDir}`);
|
||||
|
||||
0
servers/.gitkeep
Normal file
0
servers/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"plugin/**/*.ts",
|
||||
"servers/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user