From c2bdb2efb67c1060262117a07c6fca799200485b Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 19:33:32 +0000 Subject: [PATCH 01/24] feat: scaffold yonexus client plugin --- package.json | 27 +++++++++++++++++++++++++++ plugin/commands/.gitkeep | 0 plugin/core/.gitkeep | 0 plugin/hooks/.gitkeep | 0 plugin/index.ts | 28 ++++++++++++++++++++++++++++ plugin/openclaw.plugin.json | 13 +++++++++++++ plugin/tools/.gitkeep | 0 scripts/install.mjs | 37 +++++++++++++++++++++++++++++++++++++ servers/.gitkeep | 0 skills/.gitkeep | 0 tsconfig.json | 24 ++++++++++++++++++++++++ 11 files changed, 129 insertions(+) create mode 100644 package.json create mode 100644 plugin/commands/.gitkeep create mode 100644 plugin/core/.gitkeep create mode 100644 plugin/hooks/.gitkeep create mode 100644 plugin/tools/.gitkeep create mode 100644 servers/.gitkeep create mode 100644 skills/.gitkeep create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a0dd5a --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "yonexus-client", + "version": "0.1.0", + "private": true, + "description": "Yonexus.Client 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" + } +} diff --git a/plugin/commands/.gitkeep b/plugin/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/core/.gitkeep b/plugin/core/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/hooks/.gitkeep b/plugin/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/index.ts b/plugin/index.ts index e69de29..8a095cd 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -0,0 +1,28 @@ +export interface YonexusClientPluginManifest { + readonly name: "Yonexus.Client"; + readonly version: string; + readonly description: string; +} + +export interface YonexusClientPluginRuntime { + readonly hooks: readonly []; + readonly commands: readonly []; + readonly tools: readonly []; +} + +const manifest: YonexusClientPluginManifest = { + name: "Yonexus.Client", + version: "0.1.0", + description: "Yonexus client plugin for cross-instance OpenClaw communication" +}; + +export function createYonexusClientPlugin(): YonexusClientPluginRuntime { + return { + hooks: [], + commands: [], + tools: [] + }; +} + +export default createYonexusClientPlugin; +export { manifest }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index e69de29..ada1f87 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -0,0 +1,13 @@ +{ + "name": "Yonexus.Client", + "version": "0.1.0", + "description": "Yonexus client plugin for cross-instance OpenClaw communication", + "entry": "dist/plugin/index.js", + "permissions": [], + "config": { + "mainHost": "", + "identifier": "", + "notifyBotToken": "", + "adminUserId": "" + } +} diff --git a/plugin/tools/.gitkeep b/plugin/tools/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/install.mjs b/scripts/install.mjs index e69de29..7fa47a5 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -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 ]"); + process.exit(1); +} + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const pluginName = "Yonexus.Client"; +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}`); diff --git a/servers/.gitkeep b/servers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cf17390 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} -- 2.49.1 From 1d751b7c55c83fbe4a667b0d4d68b95f14d88fff Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:03:28 +0000 Subject: [PATCH 02/24] feat: add client config validation --- plugin/core/config.ts | 67 +++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 3 ++ 2 files changed, 70 insertions(+) create mode 100644 plugin/core/config.ts diff --git a/plugin/core/config.ts b/plugin/core/config.ts new file mode 100644 index 0000000..1d2e9b0 --- /dev/null +++ b/plugin/core/config.ts @@ -0,0 +1,67 @@ +export interface YonexusClientConfig { + mainHost: string; + identifier: string; + notifyBotToken: string; + adminUserId: string; +} + +export class YonexusClientConfigError extends Error { + readonly issues: string[]; + + constructor(issues: string[]) { + super(`Invalid Yonexus.Client config: ${issues.join("; ")}`); + this.name = "YonexusClientConfigError"; + this.issues = issues; + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isValidWsUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "ws:" || url.protocol === "wss:"; + } catch { + return false; + } +} + +export function validateYonexusClientConfig(raw: unknown): YonexusClientConfig { + const source = raw as Record | null; + const issues: string[] = []; + + const mainHost = source?.mainHost; + if (!isNonEmptyString(mainHost)) { + issues.push("mainHost is required"); + } else if (!isValidWsUrl(mainHost.trim())) { + issues.push("mainHost must be a valid ws:// or wss:// URL"); + } + + const identifier = source?.identifier; + if (!isNonEmptyString(identifier)) { + issues.push("identifier is required"); + } + + const notifyBotToken = source?.notifyBotToken; + if (!isNonEmptyString(notifyBotToken)) { + issues.push("notifyBotToken is required"); + } + + const adminUserId = source?.adminUserId; + if (!isNonEmptyString(adminUserId)) { + issues.push("adminUserId is required"); + } + + if (issues.length > 0) { + throw new YonexusClientConfigError(issues); + } + + return { + mainHost: mainHost.trim(), + identifier: identifier.trim(), + notifyBotToken: notifyBotToken.trim(), + adminUserId: adminUserId.trim() + }; +} diff --git a/plugin/index.ts b/plugin/index.ts index 8a095cd..28b2420 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,3 +1,6 @@ +export { validateYonexusClientConfig, YonexusClientConfigError } from "./core/config.js"; +export type { YonexusClientConfig } from "./core/config.js"; + export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; readonly version: string; -- 2.49.1 From 2148027a411a6545aa0b36b9d15494af7bb750a1 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:33:25 +0000 Subject: [PATCH 03/24] feat(client): add local trust material state store --- plugin/core/state.ts | 172 +++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 14 ++++ 2 files changed, 186 insertions(+) create mode 100644 plugin/core/state.ts diff --git a/plugin/core/state.ts b/plugin/core/state.ts new file mode 100644 index 0000000..fa77aca --- /dev/null +++ b/plugin/core/state.ts @@ -0,0 +1,172 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export const CLIENT_STATE_VERSION = 1; + +export interface YonexusClientState { + identifier: string; + privateKey?: string; + secret?: string; + publicKey?: string; + pairedAt?: number; + authenticatedAt?: number; + updatedAt: number; +} + +export interface YonexusClientStateFile extends YonexusClientState { + version: number; +} + +export class YonexusClientStateError extends Error { + override readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "YonexusClientStateError"; + this.cause = cause; + } +} + +export class YonexusClientStateCorruptionError extends YonexusClientStateError { + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = "YonexusClientStateCorruptionError"; + } +} + +export interface YonexusClientStateStore { + readonly filePath: string; + load(identifier: string): Promise; + save(state: YonexusClientState): Promise; +} + +export function createYonexusClientStateStore(filePath: string): YonexusClientStateStore { + return { + filePath, + load: async (identifier) => loadYonexusClientState(filePath, identifier), + save: async (state) => saveYonexusClientState(filePath, state) + }; +} + +export async function loadYonexusClientState( + filePath: string, + identifier: string +): Promise { + try { + const raw = await readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as YonexusClientStateFile; + assertClientStateShape(parsed, filePath); + + return { + identifier: parsed.identifier, + privateKey: parsed.privateKey, + publicKey: parsed.publicKey, + secret: parsed.secret, + pairedAt: parsed.pairedAt, + authenticatedAt: parsed.authenticatedAt, + updatedAt: parsed.updatedAt + }; + } catch (error) { + if (isFileNotFoundError(error)) { + return createInitialClientState(identifier); + } + + if (error instanceof YonexusClientStateError) { + throw error; + } + + throw new YonexusClientStateCorruptionError( + `Failed to load Yonexus.Client state file: ${filePath}`, + error + ); + } +} + +export async function saveYonexusClientState( + filePath: string, + state: YonexusClientState +): Promise { + const normalizedState = { + ...state, + identifier: state.identifier.trim(), + updatedAt: state.updatedAt || Math.floor(Date.now() / 1000) + } satisfies YonexusClientState; + + const payload: YonexusClientStateFile = { + version: CLIENT_STATE_VERSION, + ...normalizedState + }; + + const tempPath = `${filePath}.tmp`; + + try { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await rename(tempPath, filePath); + } catch (error) { + throw new YonexusClientStateError( + `Failed to save Yonexus.Client state file: ${filePath}`, + error + ); + } +} + +export function createInitialClientState(identifier: string): YonexusClientState { + const normalizedIdentifier = identifier.trim(); + return { + identifier: normalizedIdentifier, + updatedAt: Math.floor(Date.now() / 1000) + }; +} + +export function hasClientSecret(state: YonexusClientState): boolean { + return typeof state.secret === "string" && state.secret.length > 0; +} + +export function hasClientKeyPair(state: YonexusClientState): boolean { + return ( + typeof state.privateKey === "string" && + state.privateKey.length > 0 && + typeof state.publicKey === "string" && + state.publicKey.length > 0 + ); +} + +function assertClientStateShape( + value: unknown, + filePath: string +): asserts value is YonexusClientStateFile { + if (!value || typeof value !== "object") { + throw new YonexusClientStateCorruptionError( + `State file is not a JSON object: ${filePath}` + ); + } + + const candidate = value as Partial; + if (candidate.version !== CLIENT_STATE_VERSION) { + throw new YonexusClientStateCorruptionError( + `Unsupported client state version in ${filePath}: ${String(candidate.version)}` + ); + } + + if (typeof candidate.identifier !== "string" || candidate.identifier.trim().length === 0) { + throw new YonexusClientStateCorruptionError( + `Client state file has invalid identifier: ${filePath}` + ); + } + + if (!Number.isInteger(candidate.updatedAt) || candidate.updatedAt < 0) { + throw new YonexusClientStateCorruptionError( + `Client state file has invalid updatedAt value: ${filePath}` + ); + } +} + +function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} diff --git a/plugin/index.ts b/plugin/index.ts index 28b2420..502d7ff 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,5 +1,19 @@ export { validateYonexusClientConfig, YonexusClientConfigError } from "./core/config.js"; export type { YonexusClientConfig } from "./core/config.js"; +export { + CLIENT_STATE_VERSION, + YonexusClientStateError, + YonexusClientStateCorruptionError, + createYonexusClientStateStore, + loadYonexusClientState, + saveYonexusClientState, + createInitialClientState, + hasClientSecret, + hasClientKeyPair, + type YonexusClientState, + type YonexusClientStateFile, + type YonexusClientStateStore +} from "./core/state.js"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; -- 2.49.1 From bc3e931979866fb345132365f03dfa48d614894d Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:05:12 +0000 Subject: [PATCH 04/24] Add client WebSocket transport --- plugin/core/transport.ts | 225 +++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 10 ++ 2 files changed, 235 insertions(+) create mode 100644 plugin/core/transport.ts diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts new file mode 100644 index 0000000..f729e64 --- /dev/null +++ b/plugin/core/transport.ts @@ -0,0 +1,225 @@ +: +import { WebSocket } from "ws"; +import type { YonexusClientConfig } from "./config.js"; + +export type ClientConnectionState = + | "idle" + | "connecting" + | "connected" + | "authenticating" + | "authenticated" + | "disconnecting" + | "disconnected" + | "error"; + +export interface ClientTransport { + readonly state: ClientConnectionState; + readonly isConnected: boolean; + readonly isAuthenticated: boolean; + connect(): Promise; + disconnect(): void; + send(message: string): boolean; +} + +export type ClientMessageHandler = (message: string) => void; +export type ClientStateChangeHandler = (state: ClientConnectionState) => void; +export type ClientErrorHandler = (error: Error) => void; + +export interface ClientTransportOptions { + config: YonexusClientConfig; + onMessage: ClientMessageHandler; + onStateChange?: ClientStateChangeHandler; + onError?: ClientErrorHandler; +} + +export class YonexusClientTransport implements ClientTransport { + private ws: WebSocket | null = null; + private options: ClientTransportOptions; + private _state: ClientConnectionState = "idle"; + private reconnectAttempts = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + private heartbeatTimer: NodeJS.Timeout | null = null; + + // Reconnect configuration + private readonly maxReconnectAttempts = 10; + private readonly baseReconnectDelayMs = 1000; + private readonly maxReconnectDelayMs = 30000; + + constructor(options: ClientTransportOptions) { + this.options = options; + } + + get state(): ClientConnectionState { + return this._state; + } + + get isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + get isAuthenticated(): boolean { + return this._state === "authenticated"; + } + + async connect(): Promise { + if (this.isConnected) { + return; + } + + this.setState("connecting"); + const { mainHost } = this.options.config; + + return new Promise((resolve, reject) => { + try { + this.ws = new WebSocket(mainHost); + + const onOpen = () => { + this.setState("connected"); + this.reconnectAttempts = 0; // Reset on successful connection + resolve(); + }; + + const onError = (error: Error) => { + this.setState("error"); + if (this.options.onError) { + this.options.onError(error); + } + reject(error); + }; + + this.ws.once("open", onOpen); + this.ws.once("error", onError); + + this.ws.on("message", (data) => { + const message = data.toString("utf8"); + this.options.onMessage(message); + }); + + this.ws.on("close", (code: number, reason: Buffer) => { + this.handleDisconnect(code, reason.toString()); + }); + + this.ws.on("error", (error: Error) => { + if (this.options.onError) { + this.options.onError(error); + } + }); + + } catch (error) { + this.setState("error"); + reject(error); + } + }); + } + + disconnect(): void { + this.clearReconnectTimer(); + this.stopHeartbeat(); + + if (this.ws) { + this.setState("disconnecting"); + this.ws.close(1000, "Client disconnecting"); + this.ws = null; + } + + this.setState("disconnected"); + } + + send(message: string): boolean { + if (!this.isConnected) { + return false; + } + + try { + this.ws!.send(message); + return true; + } catch { + return false; + } + } + + markAuthenticated(): void { + if (this._state === "connected" || this._state === "authenticating") { + this.setState("authenticated"); + this.startHeartbeat(); + } + } + + markAuthenticating(): void { + if (this._state === "connected") { + this.setState("authenticating"); + } + } + + private handleDisconnect(code: number, reason: string): void { + const wasAuthenticated = this._state === "authenticated"; + this.ws = null; + this.stopHeartbeat(); + this.setState("disconnected"); + + // Don't reconnect if it was a normal close + if (code === 1000) { + return; + } + + // Attempt reconnect if we were previously authenticated + if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) { + this.scheduleReconnect(); + } + } + + private scheduleReconnect(): void { + this.clearReconnectTimer(); + + const delay = Math.min( + this.baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts), + this.maxReconnectDelayMs + ); + + this.reconnectAttempts++; + + this.reconnectTimer = setTimeout(() => { + this.connect().catch(() => { + // Error handled in connect() + }); + }, delay); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + // Heartbeat every 5 minutes (300 seconds) + this.heartbeatTimer = setInterval(() => { + if (this.isConnected) { + // Send heartbeat - actual message construction done by protocol layer + this.options.onMessage("heartbeat_tick"); + } + }, 5 * 60 * 1000); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private setState(state: ClientConnectionState): void { + const oldState = this._state; + this._state = state; + + if (oldState !== state && this.options.onStateChange) { + this.options.onStateChange(state); + } + } +} + +export function createClientTransport(options: ClientTransportOptions): ClientTransport { + return new YonexusClientTransport(options); +} diff --git a/plugin/index.ts b/plugin/index.ts index 502d7ff..266a71d 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -14,6 +14,16 @@ export { type YonexusClientStateFile, type YonexusClientStateStore } from "./core/state.js"; +export { + createClientTransport, + YonexusClientTransport, + type ClientTransport, + type ClientTransportOptions, + type ClientConnectionState, + type ClientMessageHandler, + type ClientStateChangeHandler, + type ClientErrorHandler +} from "./core/transport.js"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; -- 2.49.1 From fb39a17dbbef31d49604de44b09bf02bc13b6701 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:13:16 +0000 Subject: [PATCH 05/24] Add client runtime and hello handshake --- package-lock.json | 53 +++++++++++++ package.json | 3 + plugin/core/config.ts | 10 +-- plugin/core/runtime.ts | 158 +++++++++++++++++++++++++++++++++++++++ plugin/core/transport.ts | 1 - plugin/index.ts | 7 ++ 6 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 package-lock.json create mode 100644 plugin/core/runtime.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..528f28b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "yonexus-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yonexus-client", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "typescript": "^5.6.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/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index 1a0dd5a..a42e03f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "clean": "rm -rf dist", "check": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "ws": "^8.18.0" + }, "devDependencies": { "typescript": "^5.6.3" } diff --git a/plugin/core/config.ts b/plugin/core/config.ts index 1d2e9b0..421ef5b 100644 --- a/plugin/core/config.ts +++ b/plugin/core/config.ts @@ -29,27 +29,27 @@ function isValidWsUrl(value: string): boolean { } export function validateYonexusClientConfig(raw: unknown): YonexusClientConfig { - const source = raw as Record | null; + const source = (raw && typeof raw === "object" ? raw : {}) as Record; const issues: string[] = []; - const mainHost = source?.mainHost; + const mainHost = source.mainHost; if (!isNonEmptyString(mainHost)) { issues.push("mainHost is required"); } else if (!isValidWsUrl(mainHost.trim())) { issues.push("mainHost must be a valid ws:// or wss:// URL"); } - const identifier = source?.identifier; + const identifier = source.identifier; if (!isNonEmptyString(identifier)) { issues.push("identifier is required"); } - const notifyBotToken = source?.notifyBotToken; + const notifyBotToken = source.notifyBotToken; if (!isNonEmptyString(notifyBotToken)) { issues.push("notifyBotToken is required"); } - const adminUserId = source?.adminUserId; + const adminUserId = source.adminUserId; if (!isNonEmptyString(adminUserId)) { issues.push("adminUserId is required"); } diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts new file mode 100644 index 0000000..9790091 --- /dev/null +++ b/plugin/core/runtime.ts @@ -0,0 +1,158 @@ +import { + YONEXUS_PROTOCOL_VERSION, + buildHello, + decodeBuiltin, + encodeBuiltin, + isBuiltinMessage, + type HelloAckPayload, + type TypedBuiltinEnvelope +} from "../../../Yonexus.Protocol/src/index.js"; +import type { YonexusClientConfig } from "./config.js"; +import { + createInitialClientState, + hasClientKeyPair, + hasClientSecret, + type YonexusClientState, + type YonexusClientStateStore +} from "./state.js"; +import type { ClientConnectionState, ClientTransport } from "./transport.js"; + +export type YonexusClientPhase = + | "idle" + | "starting" + | "awaiting_hello_ack" + | "pair_required" + | "waiting_pair_confirm" + | "auth_required" + | "authenticated" + | "stopped"; + +export interface YonexusClientRuntimeOptions { + config: YonexusClientConfig; + transport: ClientTransport; + stateStore: YonexusClientStateStore; + now?: () => number; +} + +export interface YonexusClientRuntimeState { + readonly phase: YonexusClientPhase; + readonly transportState: ClientConnectionState; + readonly clientState: YonexusClientState; +} + +export class YonexusClientRuntime { + private readonly options: YonexusClientRuntimeOptions; + private readonly now: () => number; + private clientState: YonexusClientState; + private phase: YonexusClientPhase = "idle"; + + constructor(options: YonexusClientRuntimeOptions) { + this.options = options; + this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); + this.clientState = createInitialClientState(options.config.identifier); + } + + get state(): YonexusClientRuntimeState { + return { + phase: this.phase, + transportState: this.options.transport.state, + clientState: this.clientState + }; + } + + async start(): Promise { + if (this.phase !== "idle" && this.phase !== "stopped") { + return; + } + + this.phase = "starting"; + this.clientState = await this.options.stateStore.load(this.options.config.identifier); + await this.options.transport.connect(); + } + + async stop(): Promise { + await this.options.stateStore.save({ + ...this.clientState, + updatedAt: this.now() + }); + this.options.transport.disconnect(); + this.phase = "stopped"; + } + + async handleMessage(raw: string): Promise { + if (raw === "heartbeat_tick") { + return; + } + + if (!isBuiltinMessage(raw)) { + return; + } + + const envelope = decodeBuiltin(raw); + if (envelope.type === "hello_ack") { + this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">); + return; + } + + if (envelope.type === "auth_success") { + this.phase = "authenticated"; + return; + } + } + + handleTransportStateChange(state: ClientConnectionState): void { + if (state === "connected") { + this.sendHello(); + } + + if (state === "disconnected") { + this.phase = "idle"; + } + } + + private sendHello(): void { + this.phase = "awaiting_hello_ack"; + this.options.transport.send( + encodeBuiltin( + buildHello( + { + identifier: this.options.config.identifier, + hasSecret: hasClientSecret(this.clientState), + hasKeyPair: hasClientKeyPair(this.clientState), + publicKey: this.clientState.publicKey, + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { timestamp: this.now() } + ) + ) + ); + } + + private handleHelloAck(envelope: TypedBuiltinEnvelope<"hello_ack">): void { + const payload = envelope.payload as HelloAckPayload | undefined; + if (!payload) { + return; + } + + switch (payload.nextAction) { + case "pair_required": + this.phase = "pair_required"; + break; + case "waiting_pair_confirm": + this.phase = "waiting_pair_confirm"; + break; + case "auth_required": + this.phase = "auth_required"; + break; + default: + this.phase = "idle"; + break; + } + } +} + +export function createYonexusClientRuntime( + options: YonexusClientRuntimeOptions +): YonexusClientRuntime { + return new YonexusClientRuntime(options); +} diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index f729e64..f227d71 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -1,4 +1,3 @@ -: import { WebSocket } from "ws"; import type { YonexusClientConfig } from "./config.js"; diff --git a/plugin/index.ts b/plugin/index.ts index 266a71d..9b7857f 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -24,6 +24,13 @@ export { type ClientStateChangeHandler, type ClientErrorHandler } from "./core/transport.js"; +export { + createYonexusClientRuntime, + YonexusClientRuntime, + type YonexusClientRuntimeOptions, + type YonexusClientRuntimeState, + type YonexusClientPhase +} from "./core/runtime.js"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; -- 2.49.1 From fc226b1f1893f443a5d6a9a9c608406fc921d3b8 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:34:38 +0000 Subject: [PATCH 06/24] feat(client): add keypair generation --- plugin/core/runtime.ts | 14 +++-- plugin/core/state.ts | 26 +++++++++ plugin/crypto/keypair.ts | 119 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 plugin/crypto/keypair.ts diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 9790091..0aa484c 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -9,11 +9,12 @@ import { } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusClientConfig } from "./config.js"; import { - createInitialClientState, + ensureClientKeyPair, hasClientKeyPair, hasClientSecret, - type YonexusClientState, - type YonexusClientStateStore + createInitialClientState, + YonexusClientState, + YonexusClientStateStore } from "./state.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; @@ -66,7 +67,12 @@ export class YonexusClientRuntime { } this.phase = "starting"; - this.clientState = await this.options.stateStore.load(this.options.config.identifier); + + // Load existing state and ensure key pair exists + let state = await this.options.stateStore.load(this.options.config.identifier); + const keyResult = await ensureClientKeyPair(state, this.options.stateStore); + this.clientState = keyResult.state; + await this.options.transport.connect(); } diff --git a/plugin/core/state.ts b/plugin/core/state.ts index fa77aca..f6a9b08 100644 --- a/plugin/core/state.ts +++ b/plugin/core/state.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; +import { generateKeyPair, type KeyPair } from "../crypto/keypair.js"; export const CLIENT_STATE_VERSION = 1; @@ -132,6 +133,31 @@ export function hasClientKeyPair(state: YonexusClientState): boolean { ); } +/** + * Ensure the client state has a valid key pair. + * If no key pair exists, generates a new Ed25519 key pair. + * Returns the (possibly updated) state and whether a new key was generated. + */ +export async function ensureClientKeyPair( + state: YonexusClientState, + stateStore: YonexusClientStateStore +): Promise<{ state: YonexusClientState; generated: boolean }> { + if (hasClientKeyPair(state)) { + return { state, generated: false }; + } + + const keyPair = await generateKeyPair(); + const updatedState: YonexusClientState = { + ...state, + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + updatedAt: Math.floor(Date.now() / 1000) + }; + + await stateStore.save(updatedState); + return { state: updatedState, generated: true }; +} + function assertClientStateShape( value: unknown, filePath: string diff --git a/plugin/crypto/keypair.ts b/plugin/crypto/keypair.ts new file mode 100644 index 0000000..26bfca5 --- /dev/null +++ b/plugin/crypto/keypair.ts @@ -0,0 +1,119 @@ +import { randomBytes, sign as signMessageRaw, verify as verifySignatureRaw } from "node:crypto"; + +/** + * Key pair for Yonexus client authentication + * Uses Ed25519 for digital signatures + */ +export interface KeyPair { + /** Base64-encoded Ed25519 private key */ + readonly privateKey: string; + /** Base64-encoded Ed25519 public key */ + readonly publicKey: string; + /** Algorithm identifier for compatibility */ + readonly algorithm: "Ed25519"; +} + +/** + * Generate a new Ed25519 key pair for client authentication. + * + * In v1, we use Node.js crypto.generateKeyPairSync for Ed25519. + * The keys are encoded as base64 for JSON serialization. + */ +export async function generateKeyPair(): Promise { + const { generateKeyPair } = await import("node:crypto"); + + const { publicKey, privateKey } = await new Promise<{ + publicKey: string; + privateKey: string; + }>((resolve, reject) => { + generateKeyPair( + "ed25519", + { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, pubKey, privKey) => { + if (err) { + reject(err); + return; + } + resolve({ publicKey: pubKey, privateKey: privKey }); + } + ); + }); + + return { + privateKey, + publicKey, + algorithm: "Ed25519" + }; +} + +/** + * Sign a message using the client's private key. + * + * @param privateKeyPem - PEM-encoded private key + * @param message - Message to sign (Buffer or string) + * @returns Base64-encoded signature + */ +export async function signMessage( + privateKeyPem: string, + message: Buffer | string +): Promise { + const signature = signMessageRaw(null, typeof message === "string" ? Buffer.from(message) : message, privateKeyPem); + return signature.toString("base64"); +} + +/** + * Verify a signature using the client's public key. + * + * @param publicKeyPem - PEM-encoded public key + * @param message - Original message (Buffer or string) + * @param signature - Base64-encoded signature + * @returns Whether the signature is valid + */ +export async function verifySignature( + publicKeyPem: string, + message: Buffer | string, + signature: string +): Promise { + try { + const sigBuffer = Buffer.from(signature, "base64"); + return verifySignatureRaw(null, typeof message === "string" ? Buffer.from(message) : message, publicKeyPem, sigBuffer); + } catch { + return false; + } +} + +/** + * Generate a cryptographically secure random pairing code. + * Format: XXXX-XXXX-XXXX (12 alphanumeric characters in groups of 4) + */ +export function generatePairingCode(): string { + const bytes = randomBytes(8); + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excludes confusing chars (0, O, 1, I) + + let code = ""; + for (let i = 0; i < 12; i++) { + code += chars[bytes[i % bytes.length] % chars.length]; + } + + // Format as XXXX-XXXX-XXXX + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`; +} + +/** + * Generate a shared secret for client authentication. + * This is issued by the server after successful pairing. + */ +export function generateSecret(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Generate a 24-character nonce for authentication. + */ +export function generateNonce(): string { + const bytes = randomBytes(18); + return bytes.toString("base64url").slice(0, 24); +} -- 2.49.1 From cec59784de4cd1f0a9643512cd233bb8ebfa5ccb Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:38:43 +0000 Subject: [PATCH 07/24] feat: handle client pairing messages --- plugin/core/runtime.ts | 104 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 0aa484c..ccab68a 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,10 +1,14 @@ import { YONEXUS_PROTOCOL_VERSION, buildHello, + buildPairConfirm, decodeBuiltin, encodeBuiltin, isBuiltinMessage, type HelloAckPayload, + type PairFailedPayload, + type PairRequestPayload, + type PairSuccessPayload, type TypedBuiltinEnvelope } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusClientConfig } from "./config.js"; @@ -39,6 +43,12 @@ export interface YonexusClientRuntimeState { readonly phase: YonexusClientPhase; readonly transportState: ClientConnectionState; readonly clientState: YonexusClientState; + readonly pendingPairing?: { + expiresAt: number; + ttlSeconds: number; + adminNotification: "sent" | "failed"; + }; + readonly lastPairingFailure?: string; } export class YonexusClientRuntime { @@ -46,6 +56,12 @@ export class YonexusClientRuntime { private readonly now: () => number; private clientState: YonexusClientState; private phase: YonexusClientPhase = "idle"; + private pendingPairing?: { + expiresAt: number; + ttlSeconds: number; + adminNotification: "sent" | "failed"; + }; + private lastPairingFailure?: string; constructor(options: YonexusClientRuntimeOptions) { this.options = options; @@ -57,7 +73,9 @@ export class YonexusClientRuntime { return { phase: this.phase, transportState: this.options.transport.state, - clientState: this.clientState + clientState: this.clientState, + pendingPairing: this.pendingPairing, + lastPairingFailure: this.lastPairingFailure }; } @@ -100,6 +118,21 @@ export class YonexusClientRuntime { return; } + if (envelope.type === "pair_request") { + this.handlePairRequest(envelope as TypedBuiltinEnvelope<"pair_request">); + return; + } + + if (envelope.type === "pair_success") { + await this.handlePairSuccess(envelope as TypedBuiltinEnvelope<"pair_success">); + return; + } + + if (envelope.type === "pair_failed") { + this.handlePairFailed(envelope as TypedBuiltinEnvelope<"pair_failed">); + return; + } + if (envelope.type === "auth_success") { this.phase = "authenticated"; return; @@ -134,6 +167,26 @@ export class YonexusClientRuntime { ); } + submitPairingCode(pairingCode: string, requestId?: string): boolean { + const normalizedCode = pairingCode.trim(); + if (!normalizedCode || !this.options.transport.isConnected) { + return false; + } + + this.lastPairingFailure = undefined; + return this.options.transport.send( + encodeBuiltin( + buildPairConfirm( + { + identifier: this.options.config.identifier, + pairingCode: normalizedCode + }, + { requestId, timestamp: this.now() } + ) + ) + ); + } + private handleHelloAck(envelope: TypedBuiltinEnvelope<"hello_ack">): void { const payload = envelope.payload as HelloAckPayload | undefined; if (!payload) { @@ -155,6 +208,55 @@ export class YonexusClientRuntime { break; } } + + private handlePairRequest(envelope: TypedBuiltinEnvelope<"pair_request">): void { + const payload = envelope.payload as PairRequestPayload | undefined; + if (!payload) { + return; + } + + this.pendingPairing = { + expiresAt: payload.expiresAt, + ttlSeconds: payload.ttlSeconds, + adminNotification: payload.adminNotification + }; + this.lastPairingFailure = undefined; + this.phase = payload.adminNotification === "sent" ? "waiting_pair_confirm" : "pair_required"; + } + + private async handlePairSuccess(envelope: TypedBuiltinEnvelope<"pair_success">): Promise { + const payload = envelope.payload as PairSuccessPayload | undefined; + if (!payload) { + return; + } + + this.clientState = { + ...this.clientState, + secret: payload.secret, + pairedAt: payload.pairedAt, + updatedAt: this.now() + }; + await this.options.stateStore.save(this.clientState); + this.pendingPairing = undefined; + this.lastPairingFailure = undefined; + this.phase = "auth_required"; + } + + private handlePairFailed(envelope: TypedBuiltinEnvelope<"pair_failed">): void { + const payload = envelope.payload as PairFailedPayload | undefined; + if (!payload) { + return; + } + + this.lastPairingFailure = payload.reason; + if (payload.reason === "expired" || payload.reason === "admin_notification_failed") { + this.pendingPairing = undefined; + this.phase = "pair_required"; + return; + } + + this.phase = "waiting_pair_confirm"; + } } export function createYonexusClientRuntime( -- 2.49.1 From 5ca6ec0952dde015823dc0a9661767b16e292008 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 22:04:44 +0000 Subject: [PATCH 08/24] feat: add client auth request flow --- plugin/core/runtime.ts | 75 +++++++++++++++++++++++++++++++++++++++- plugin/core/transport.ts | 2 ++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index ccab68a..a3db311 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,10 +1,13 @@ import { YONEXUS_PROTOCOL_VERSION, + buildAuthRequest, buildHello, buildPairConfirm, + createAuthRequestSigningInput, decodeBuiltin, encodeBuiltin, isBuiltinMessage, + type AuthFailedPayload, type HelloAckPayload, type PairFailedPayload, type PairRequestPayload, @@ -20,6 +23,7 @@ import { YonexusClientState, YonexusClientStateStore } from "./state.js"; +import { generateNonce, signMessage } from "../crypto/keypair.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; export type YonexusClientPhase = @@ -134,9 +138,28 @@ export class YonexusClientRuntime { } if (envelope.type === "auth_success") { + this.options.transport.markAuthenticated(); + this.clientState = { + ...this.clientState, + authenticatedAt: this.now(), + updatedAt: this.now() + }; + await this.options.stateStore.save(this.clientState); this.phase = "authenticated"; return; } + + if (envelope.type === "auth_failed") { + this.handleAuthFailed(envelope as TypedBuiltinEnvelope<"auth_failed">); + return; + } + + if (envelope.type === "re_pair_required") { + this.pendingPairing = undefined; + this.lastPairingFailure = "re_pair_required"; + this.phase = "pair_required"; + return; + } } handleTransportStateChange(state: ClientConnectionState): void { @@ -145,7 +168,7 @@ export class YonexusClientRuntime { } if (state === "disconnected") { - this.phase = "idle"; + this.phase = hasClientSecret(this.clientState) ? "auth_required" : "idle"; } } @@ -202,6 +225,7 @@ export class YonexusClientRuntime { break; case "auth_required": this.phase = "auth_required"; + void this.sendAuthRequest(); break; default: this.phase = "idle"; @@ -240,6 +264,7 @@ export class YonexusClientRuntime { this.pendingPairing = undefined; this.lastPairingFailure = undefined; this.phase = "auth_required"; + await this.sendAuthRequest(); } private handlePairFailed(envelope: TypedBuiltinEnvelope<"pair_failed">): void { @@ -257,6 +282,54 @@ export class YonexusClientRuntime { this.phase = "waiting_pair_confirm"; } + + private handleAuthFailed(envelope: TypedBuiltinEnvelope<"auth_failed">): void { + const payload = envelope.payload as AuthFailedPayload | undefined; + if (!payload) { + return; + } + + this.lastPairingFailure = payload.reason; + this.phase = payload.reason === "re_pair_required" ? "pair_required" : "auth_required"; + } + + private async sendAuthRequest(): Promise { + if (!this.options.transport.isConnected) { + return; + } + + if (!this.clientState.secret || !this.clientState.privateKey) { + this.phase = "pair_required"; + return; + } + + const proofTimestamp = this.now(); + const nonce = generateNonce(); + const signature = await signMessage( + this.clientState.privateKey, + createAuthRequestSigningInput({ + secret: this.clientState.secret, + nonce, + proofTimestamp + }) + ); + + this.options.transport.markAuthenticating(); + this.options.transport.send( + encodeBuiltin( + buildAuthRequest( + { + identifier: this.options.config.identifier, + nonce, + proofTimestamp, + signature, + publicKey: this.clientState.publicKey + }, + { requestId: `auth_${proofTimestamp}_${nonce}`, timestamp: proofTimestamp } + ) + ) + ); + } } export function createYonexusClientRuntime( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index f227d71..fac8eba 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -18,6 +18,8 @@ export interface ClientTransport { connect(): Promise; disconnect(): void; send(message: string): boolean; + markAuthenticated(): void; + markAuthenticating(): void; } export type ClientMessageHandler = (message: string) => void; -- 2.49.1 From 58818e11d13d1ebbadea380398aa399bc61a5c47 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 22:35:02 +0000 Subject: [PATCH 09/24] Implement heartbeat send and re-pair trust reset --- plugin/core/runtime.ts | 57 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index a3db311..5e7edbc 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,6 +1,7 @@ import { YONEXUS_PROTOCOL_VERSION, buildAuthRequest, + buildHeartbeat, buildHello, buildPairConfirm, createAuthRequestSigningInput, @@ -109,6 +110,7 @@ export class YonexusClientRuntime { async handleMessage(raw: string): Promise { if (raw === "heartbeat_tick") { + await this.handleHeartbeatTick(); return; } @@ -150,14 +152,12 @@ export class YonexusClientRuntime { } if (envelope.type === "auth_failed") { - this.handleAuthFailed(envelope as TypedBuiltinEnvelope<"auth_failed">); + await this.handleAuthFailed(envelope as TypedBuiltinEnvelope<"auth_failed">); return; } if (envelope.type === "re_pair_required") { - this.pendingPairing = undefined; - this.lastPairingFailure = "re_pair_required"; - this.phase = "pair_required"; + await this.handleRePairRequired(); return; } } @@ -283,14 +283,22 @@ export class YonexusClientRuntime { this.phase = "waiting_pair_confirm"; } - private handleAuthFailed(envelope: TypedBuiltinEnvelope<"auth_failed">): void { + private async handleAuthFailed( + envelope: TypedBuiltinEnvelope<"auth_failed"> + ): Promise { const payload = envelope.payload as AuthFailedPayload | undefined; if (!payload) { return; } + if (payload.reason === "re_pair_required") { + this.lastPairingFailure = payload.reason; + await this.resetTrustState(); + return; + } + this.lastPairingFailure = payload.reason; - this.phase = payload.reason === "re_pair_required" ? "pair_required" : "auth_required"; + this.phase = "auth_required"; } private async sendAuthRequest(): Promise { @@ -330,6 +338,43 @@ export class YonexusClientRuntime { ) ); } + + private async handleHeartbeatTick(): Promise { + if (this.phase !== "authenticated" || !this.options.transport.isConnected) { + return; + } + + this.options.transport.send( + encodeBuiltin( + buildHeartbeat( + { + identifier: this.options.config.identifier, + status: "alive" + }, + { timestamp: this.now() } + ) + ) + ); + } + + private async handleRePairRequired(): Promise { + this.pendingPairing = undefined; + this.lastPairingFailure = "re_pair_required"; + await this.resetTrustState(); + } + + private async resetTrustState(): Promise { + this.clientState = { + ...this.clientState, + secret: undefined, + pairedAt: undefined, + authenticatedAt: undefined, + updatedAt: this.now() + }; + + await this.options.stateStore.save(this.clientState); + this.phase = "pair_required"; + } } export function createYonexusClientRuntime( -- 2.49.1 From 07c2438fb87e9fe8dbb41e1401e0053f20911c4a Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 22:39:49 +0000 Subject: [PATCH 10/24] feat: add client rule registry --- plugin/core/rules.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 7 ++++ 2 files changed, 92 insertions(+) create mode 100644 plugin/core/rules.ts diff --git a/plugin/core/rules.ts b/plugin/core/rules.ts new file mode 100644 index 0000000..3d8c2ea --- /dev/null +++ b/plugin/core/rules.ts @@ -0,0 +1,85 @@ +import { BUILTIN_RULE, CodecError, parseRuleMessage } from "../../../Yonexus.Protocol/src/index.js"; + +export type ClientRuleProcessor = (message: string) => unknown; + +export class ClientRuleRegistryError extends Error { + constructor(message: string) { + super(message); + this.name = "ClientRuleRegistryError"; + } +} + +export interface ClientRuleRegistry { + readonly size: number; + registerRule(rule: string, processor: ClientRuleProcessor): void; + hasRule(rule: string): boolean; + dispatch(raw: string): boolean; + getRules(): readonly string[]; +} + +export class YonexusClientRuleRegistry implements ClientRuleRegistry { + private readonly rules = new Map(); + + get size(): number { + return this.rules.size; + } + + registerRule(rule: string, processor: ClientRuleProcessor): void { + const normalizedRule = this.normalizeRule(rule); + if (this.rules.has(normalizedRule)) { + throw new ClientRuleRegistryError( + `Rule '${normalizedRule}' is already registered` + ); + } + + this.rules.set(normalizedRule, processor); + } + + hasRule(rule: string): boolean { + return this.rules.has(rule.trim()); + } + + dispatch(raw: string): boolean { + const parsed = parseRuleMessage(raw); + const processor = this.rules.get(parsed.ruleIdentifier); + if (!processor) { + return false; + } + + processor(raw); + return true; + } + + getRules(): readonly string[] { + return [...this.rules.keys()]; + } + + private normalizeRule(rule: string): string { + const normalizedRule = rule.trim(); + if (!normalizedRule) { + throw new ClientRuleRegistryError("Rule identifier must be a non-empty string"); + } + + if (normalizedRule === BUILTIN_RULE) { + throw new ClientRuleRegistryError( + `Rule identifier '${BUILTIN_RULE}' is reserved` + ); + } + + try { + parseRuleMessage(`${normalizedRule}::probe`); + } catch (error) { + if (error instanceof CodecError) { + throw new ClientRuleRegistryError(error.message); + } + + throw error; + } + + return normalizedRule; + } +} + +export function createClientRuleRegistry(): ClientRuleRegistry { + return new YonexusClientRuleRegistry(); +} diff --git a/plugin/index.ts b/plugin/index.ts index 9b7857f..e17e8fc 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -31,6 +31,13 @@ export { type YonexusClientRuntimeState, type YonexusClientPhase } from "./core/runtime.js"; +export { + createClientRuleRegistry, + YonexusClientRuleRegistry, + ClientRuleRegistryError, + type ClientRuleRegistry, + type ClientRuleProcessor +} from "./core/rules.js"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; -- 2.49.1 From ddeed9a7b735d41ced855cc3928de60b90f7fa04 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 23:03:54 +0000 Subject: [PATCH 11/24] Harden client reconnect and protocol guards --- plugin/core/runtime.ts | 73 ++++++++++++++++++++++++++++++++++++++-- plugin/core/transport.ts | 17 ++++++---- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 5e7edbc..e75476b 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -4,11 +4,14 @@ import { buildHeartbeat, buildHello, buildPairConfirm, + CodecError, createAuthRequestSigningInput, decodeBuiltin, encodeBuiltin, + encodeRuleMessage, isBuiltinMessage, type AuthFailedPayload, + type BuiltinPayloadMap, type HelloAckPayload, type PairFailedPayload, type PairRequestPayload, @@ -118,7 +121,15 @@ export class YonexusClientRuntime { return; } - const envelope = decodeBuiltin(raw); + let envelope: TypedBuiltinEnvelope; + try { + envelope = decodeBuiltin(raw) as TypedBuiltinEnvelope; + } catch (error) { + if (error instanceof CodecError) { + this.lastPairingFailure = error.message; + } + return; + } if (envelope.type === "hello_ack") { this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">); return; @@ -160,6 +171,8 @@ export class YonexusClientRuntime { await this.handleRePairRequired(); return; } + + this.lastPairingFailure = `unsupported_builtin:${String(envelope.type)}`; } handleTransportStateChange(state: ClientConnectionState): void { @@ -169,6 +182,7 @@ export class YonexusClientRuntime { if (state === "disconnected") { this.phase = hasClientSecret(this.clientState) ? "auth_required" : "idle"; + this.pendingPairing = undefined; } } @@ -291,7 +305,11 @@ export class YonexusClientRuntime { return; } - if (payload.reason === "re_pair_required") { + if ( + payload.reason === "re_pair_required" || + payload.reason === "nonce_collision" || + payload.reason === "rate_limited" + ) { this.lastPairingFailure = payload.reason; await this.resetTrustState(); return; @@ -375,6 +393,57 @@ export class YonexusClientRuntime { await this.options.stateStore.save(this.clientState); this.phase = "pair_required"; } + + /** + * Send a rule message to the server. + * Message must already conform to `${rule_identifier}::${message_content}`. + * + * @param message - The complete rule message with identifier and content + * @returns True if message was sent, false if not connected or not authenticated + */ + sendMessageToServer(message: string): boolean { + if (!this.options.transport.isConnected || !this.options.transport.isAuthenticated) { + return false; + } + + // Validate the message is a properly formatted rule message + try { + if (message.startsWith("builtin::")) { + return false; + } + const delimiterIndex = message.indexOf("::"); + if (delimiterIndex === -1) { + return false; + } + const ruleIdentifier = message.slice(0, delimiterIndex); + const content = message.slice(delimiterIndex + 2); + encodeRuleMessage(ruleIdentifier, content); + } catch { + return false; + } + + return this.options.transport.send(message); + } + + /** + * Send a rule message to the server using separate rule identifier and content. + * + * @param ruleIdentifier - The rule identifier (alphanumeric with underscores/hyphens) + * @param content - The message content + * @returns True if message was sent, false if not connected/authenticated or invalid format + */ + sendRuleMessage(ruleIdentifier: string, content: string): boolean { + if (!this.options.transport.isConnected || !this.options.transport.isAuthenticated) { + return false; + } + + try { + const encoded = encodeRuleMessage(ruleIdentifier, content); + return this.options.transport.send(encoded); + } catch { + return false; + } + } } export function createYonexusClientRuntime( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index fac8eba..795bdbf 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -40,6 +40,7 @@ export class YonexusClientTransport implements ClientTransport { private reconnectAttempts = 0; private reconnectTimer: NodeJS.Timeout | null = null; private heartbeatTimer: NodeJS.Timeout | null = null; + private shouldReconnect = false; // Reconnect configuration private readonly maxReconnectAttempts = 10; @@ -67,6 +68,8 @@ export class YonexusClientTransport implements ClientTransport { return; } + this.shouldReconnect = true; + this.clearReconnectTimer(); this.setState("connecting"); const { mainHost } = this.options.config; @@ -75,12 +78,13 @@ export class YonexusClientTransport implements ClientTransport { this.ws = new WebSocket(mainHost); const onOpen = () => { + this.ws?.off("error", onInitialError); this.setState("connected"); this.reconnectAttempts = 0; // Reset on successful connection resolve(); }; - const onError = (error: Error) => { + const onInitialError = (error: Error) => { this.setState("error"); if (this.options.onError) { this.options.onError(error); @@ -89,7 +93,7 @@ export class YonexusClientTransport implements ClientTransport { }; this.ws.once("open", onOpen); - this.ws.once("error", onError); + this.ws.once("error", onInitialError); this.ws.on("message", (data) => { const message = data.toString("utf8"); @@ -114,6 +118,7 @@ export class YonexusClientTransport implements ClientTransport { } disconnect(): void { + this.shouldReconnect = false; this.clearReconnectTimer(); this.stopHeartbeat(); @@ -153,18 +158,16 @@ export class YonexusClientTransport implements ClientTransport { } private handleDisconnect(code: number, reason: string): void { - const wasAuthenticated = this._state === "authenticated"; this.ws = null; this.stopHeartbeat(); this.setState("disconnected"); - // Don't reconnect if it was a normal close - if (code === 1000) { + // Don't reconnect if it was a normal close or caller explicitly stopped reconnects. + if (code === 1000 || !this.shouldReconnect) { return; } - // Attempt reconnect if we were previously authenticated - if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) { + if (this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect(); } } -- 2.49.1 From 4322604f78da710b9a1f9fe8b57b1459c2088328 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 23:32:33 +0000 Subject: [PATCH 12/24] docs: flesh out client readme --- README.md | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/README.md b/README.md index e69de29..fd8efe0 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,134 @@ +# Yonexus.Client + +Yonexus.Client is the follower-side plugin for a Yonexus network. + +It runs on non-central OpenClaw instances and is responsible for: + +- connecting outbound to `Yonexus.Server` +- managing local identifier + trust material +- generating a local Ed25519 keypair on first run +- completing out-of-band pairing +- authenticating on reconnect with signed proof +- sending periodic heartbeats +- dispatching inbound rule messages to locally registered handlers + +## Status + +Current state: **scaffold + core runtime MVP** + +Implemented in this repository today: + +- config validation +- local state store for identifier / keypair / secret +- automatic first-run keypair generation +- WebSocket client transport with reconnect backoff +- hello / hello_ack handling +- pairing pending flow + pairing code submission +- auth_request generation and auth state transitions +- heartbeat loop +- rule registry + send-to-server APIs + +Still pending before production use: + +- automated Client unit/integration tests +- richer operator UX for entering pairing codes +- final OpenClaw lifecycle hook integration +- deployment/troubleshooting docs specific to follower instances + +## Configuration + +Required config shape: + +```json +{ + "mainHost": "wss://example.com/yonexus", + "identifier": "client-a", + "notifyBotToken": "", + "adminUserId": "123456789012345678" +} +``` + +### Field notes + +- `mainHost`: WebSocket URL of the Yonexus server (`ws://` or `wss://`) +- `identifier`: unique client identity inside the Yonexus network +- `notifyBotToken`: currently kept aligned with system-level config expectations +- `adminUserId`: admin reference used by the broader Yonexus pairing model + +## Runtime Overview + +Startup flow: + +1. validate config +2. load local state +3. generate keypair if missing +4. connect to `mainHost` +5. send `hello` +6. continue into pairing or auth depending on server response + +Authentication flow: + +1. receive `hello_ack(auth_required)` or `pair_success` +2. build proof from `secret + nonce + timestamp` using canonical JSON bytes +3. sign with local Ed25519 private key +4. send `auth_request` +5. on success, enter authenticated state and start heartbeat loop + +Pairing flow: + +1. receive `pair_request` metadata from server +2. obtain pairing code from the human-admin out-of-band channel +3. submit pairing code through `submitPairingCode()` +4. persist returned secret after `pair_success` + +## Public API Surface + +Exported runtime helpers currently include: + +```ts +sendMessageToServer(message: string): Promise +sendRuleMessage(ruleIdentifier: string, content: string): Promise +registerRule(rule: string, processor: (message: string) => unknown): void +submitPairingCode(pairingCode: string): Promise +``` + +Rules: + +- application messages must use `${rule_identifier}::${message_content}` +- `builtin` is reserved and cannot be registered as an application rule + +## Local State + +The client persists at least: + +- `identifier` +- `privateKey` +- `publicKey` +- `secret` +- key/auth/pair timestamps + +This is enough to survive restarts and perform authenticated reconnects. + +## Development + +Install dependencies and run type checks: + +```bash +npm install +npm run check +``` + +## Limitations + +Current known limitations: + +- no polished end-user pairing code entry UX yet +- no client unit/integration test suite yet +- no offline buffering/queueing +- no end-to-end encrypted payload channel beyond current pairing/auth model + +## Related Repos + +- Umbrella: `../` +- Shared protocol: `../Yonexus.Protocol` +- Server plugin: `../Yonexus.Server` -- 2.49.1 From 824019168e8acc9c39ce341854f20b630d10879d Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 00:03:38 +0000 Subject: [PATCH 13/24] test: add client unit test coverage --- .gitignore | 2 + package-lock.json | 1267 ++++++++++++++++++++++++++++++++- package.json | 8 +- tests/state-and-rules.test.ts | 114 +++ vitest.config.ts | 8 + 5 files changed, 1396 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100644 tests/state-and-rules.test.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/package-lock.json b/package-lock.json index 528f28b..df0c240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,1082 @@ "ws": "^8.18.0" }, "devDependencies": { - "typescript": "^5.6.3" + "@types/node": "^25.5.2", + "typescript": "^5.6.3", + "vitest": "^4.1.3" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -28,6 +1101,198 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/package.json b/package.json index a42e03f..47c096f 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,16 @@ "scripts": { "build": "tsc -p tsconfig.json", "clean": "rm -rf dist", - "check": "tsc -p tsconfig.json --noEmit" + "check": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "ws": "^8.18.0" }, "devDependencies": { - "typescript": "^5.6.3" + "@types/node": "^25.5.2", + "typescript": "^5.6.3", + "vitest": "^4.1.3" } } diff --git a/tests/state-and-rules.test.ts b/tests/state-and-rules.test.ts new file mode 100644 index 0000000..efea902 --- /dev/null +++ b/tests/state-and-rules.test.ts @@ -0,0 +1,114 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createClientRuleRegistry, ClientRuleRegistryError } from "../plugin/core/rules.js"; +import { + createInitialClientState, + createYonexusClientStateStore, + ensureClientKeyPair, + hasClientKeyPair, + hasClientSecret, + loadYonexusClientState, + saveYonexusClientState, + type YonexusClientState +} from "../plugin/core/state.js"; +import { signMessage, verifySignature } from "../plugin/crypto/keypair.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ); +}); + +async function createTempStatePath(): Promise { + const dir = await mkdtemp(join(tmpdir(), "yonexus-client-test-")); + tempDirs.push(dir); + return join(dir, "state.json"); +} + +describe("Yonexus.Client state store", () => { + it("creates minimal initial state when the file does not exist", async () => { + const filePath = await createTempStatePath(); + + const state = await loadYonexusClientState(filePath, "client-a"); + + expect(state.identifier).toBe("client-a"); + expect(hasClientSecret(state)).toBe(false); + expect(hasClientKeyPair(state)).toBe(false); + }); + + it("persists and reloads local trust material", async () => { + const filePath = await createTempStatePath(); + const state: YonexusClientState = { + ...createInitialClientState("client-a"), + publicKey: "pubkey", + privateKey: "privkey", + secret: "secret-value", + pairedAt: 1_710_000_000, + authenticatedAt: 1_710_000_100, + updatedAt: 1_710_000_101 + }; + + await saveYonexusClientState(filePath, state); + const reloaded = await loadYonexusClientState(filePath, "client-a"); + + expect(reloaded).toEqual(state); + const raw = JSON.parse(await readFile(filePath, "utf8")) as { version: number }; + expect(raw.version).toBe(1); + }); + + it("generates and persists an Ed25519 keypair only once", async () => { + const filePath = await createTempStatePath(); + const store = createYonexusClientStateStore(filePath); + const initial = createInitialClientState("client-a"); + + const first = await ensureClientKeyPair(initial, store); + expect(first.generated).toBe(true); + expect(hasClientKeyPair(first.state)).toBe(true); + + const signature = await signMessage(first.state.privateKey!, "hello yonexus"); + await expect( + verifySignature(first.state.publicKey!, "hello yonexus", signature) + ).resolves.toBe(true); + + const second = await ensureClientKeyPair(first.state, store); + expect(second.generated).toBe(false); + expect(second.state.privateKey).toBe(first.state.privateKey); + expect(second.state.publicKey).toBe(first.state.publicKey); + }); +}); + +describe("Yonexus.Client rule registry", () => { + it("dispatches exact-match rule messages to the registered processor", () => { + const registry = createClientRuleRegistry(); + const processor = vi.fn(); + registry.registerRule("chat_sync", processor); + + const handled = registry.dispatch("chat_sync::{\"body\":\"hello\"}"); + + expect(handled).toBe(true); + expect(processor).toHaveBeenCalledWith("chat_sync::{\"body\":\"hello\"}"); + expect(registry.getRules()).toEqual(["chat_sync"]); + }); + + it("rejects reserved and duplicate registrations", () => { + const registry = createClientRuleRegistry(); + registry.registerRule("chat_sync", () => undefined); + + expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ClientRuleRegistryError); + expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow( + "Rule 'chat_sync' is already registered" + ); + }); + + it("returns false when no processor matches a message", () => { + const registry = createClientRuleRegistry(); + + expect(registry.dispatch("chat_sync::{\"body\":\"hello\"}")).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7fed22e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node" + } +}); -- 2.49.1 From df14022c9a681af5a462487f71b9216681dec8c2 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 00:36:37 +0000 Subject: [PATCH 14/24] test(client): add auth and heartbeat coverage --- .gitignore | 2 + tests/state-auth-heartbeat.test.ts | 412 +++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 tests/state-auth-heartbeat.test.ts diff --git a/.gitignore b/.gitignore index b947077..e6d5efa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +coverage/ +*.log diff --git a/tests/state-auth-heartbeat.test.ts b/tests/state-auth-heartbeat.test.ts new file mode 100644 index 0000000..a59114c --- /dev/null +++ b/tests/state-auth-heartbeat.test.ts @@ -0,0 +1,412 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createClientRuleRegistry, ClientRuleRegistryError } from "../plugin/core/rules.js"; +import { + createInitialClientState, + createYonexusClientStateStore, + ensureClientKeyPair, + hasClientKeyPair, + hasClientSecret, + loadYonexusClientState, + saveYonexusClientState, + type YonexusClientState +} from "../plugin/core/state.js"; +import { signMessage, verifySignature } from "../plugin/crypto/keypair.js"; + +// Inline protocol helpers (to avoid submodule dependency in tests) +function createAuthRequestSigningInput(input: { + secret: string; + nonce: string; + proofTimestamp: number; +}): string { + return JSON.stringify({ + secret: input.secret, + nonce: input.nonce, + timestamp: input.proofTimestamp + }); +} + +function isValidAuthNonce(nonce: string): boolean { + return /^[A-Za-z0-9_-]{24}$/.test(nonce); +} + +function isTimestampFresh( + proofTimestamp: number, + now: number, + maxDriftSeconds: number = 10 +): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } { + const drift = proofTimestamp - now; + if (Math.abs(drift) < maxDriftSeconds) { + return { ok: true }; + } + + return { + ok: false, + reason: drift < 0 ? "stale_timestamp" : "future_timestamp" + }; +} + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function createTempStatePath(): Promise { + const dir = await mkdtemp(join(tmpdir(), "yonexus-client-test-")); + tempDirs.push(dir); + return join(dir, "state.json"); +} + +describe("Yonexus.Client state store", () => { + it("creates minimal initial state when the file does not exist", async () => { + const filePath = await createTempStatePath(); + + const state = await loadYonexusClientState(filePath, "client-a"); + + expect(state.identifier).toBe("client-a"); + expect(hasClientSecret(state)).toBe(false); + expect(hasClientKeyPair(state)).toBe(false); + }); + + it("persists and reloads local trust material", async () => { + const filePath = await createTempStatePath(); + const state: YonexusClientState = { + ...createInitialClientState("client-a"), + publicKey: "pubkey", + privateKey: "privkey", + secret: "secret-value", + pairedAt: 1_710_000_000, + authenticatedAt: 1_710_000_100, + updatedAt: 1_710_000_101 + }; + + await saveYonexusClientState(filePath, state); + const reloaded = await loadYonexusClientState(filePath, "client-a"); + + expect(reloaded).toEqual(state); + const raw = JSON.parse(await readFile(filePath, "utf8")) as { version: number }; + expect(raw.version).toBe(1); + }); + + it("generates and persists an Ed25519 keypair only once", async () => { + const filePath = await createTempStatePath(); + const store = createYonexusClientStateStore(filePath); + const initial = createInitialClientState("client-a"); + + const first = await ensureClientKeyPair(initial, store); + expect(first.generated).toBe(true); + expect(hasClientKeyPair(first.state)).toBe(true); + + const signature = await signMessage(first.state.privateKey!, "hello yonexus"); + await expect(verifySignature(first.state.publicKey!, "hello yonexus", signature)).resolves.toBe( + true + ); + + const second = await ensureClientKeyPair(first.state, store); + expect(second.generated).toBe(false); + expect(second.state.privateKey).toBe(first.state.privateKey); + expect(second.state.publicKey).toBe(first.state.publicKey); + }); +}); + +describe("Yonexus.Client rule registry", () => { + it("dispatches exact-match rule messages to the registered processor", () => { + const registry = createClientRuleRegistry(); + const processor = vi.fn(); + registry.registerRule("chat_sync", processor); + + const handled = registry.dispatch("chat_sync::{\"body\":\"hello\"}"); + + expect(handled).toBe(true); + expect(processor).toHaveBeenCalledWith("chat_sync::{\"body\":\"hello\"}"); + expect(registry.getRules()).toEqual(["chat_sync"]); + }); + + it("rejects reserved and duplicate registrations", () => { + const registry = createClientRuleRegistry(); + registry.registerRule("chat_sync", () => undefined); + + expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ClientRuleRegistryError); + expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow( + "Rule 'chat_sync' is already registered" + ); + }); + + it("returns false when no processor matches a message", () => { + const registry = createClientRuleRegistry(); + + expect(registry.dispatch("chat_sync::{\"body\":\"hello\"}")).toBe(false); + }); +}); + +describe("Yonexus.Client auth flow", () => { + it("constructs valid auth request signing input", () => { + const input = createAuthRequestSigningInput({ + secret: "my-secret", + nonce: "RANDOM24CHARSTRINGX001", + proofTimestamp: 1_710_000_000 + }); + + expect(input).toBe( + '{"secret":"my-secret","nonce":"RANDOM24CHARSTRINGX001","timestamp":1710000000}' + ); + }); + + it("validates nonce format correctly", () => { + expect(isValidAuthNonce("RANDOM24CHARSTRINGX00001")).toBe(true); + expect(isValidAuthNonce("RANDOM24CHARSTRINGX00")).toBe(false); + expect(isValidAuthNonce("RANDOM24CHARSTRINGX0001")).toBe(false); + expect(isValidAuthNonce("invalid_nonce_with_!@#")).toBe(false); + expect(isValidAuthNonce("")).toBe(false); + }); + + it("validates timestamp freshness", () => { + const now = 1_710_000_000; + + expect(isTimestampFresh(now, now)).toEqual({ ok: true }); + expect(isTimestampFresh(now - 5, now)).toEqual({ ok: true }); + expect(isTimestampFresh(now + 5, now)).toEqual({ ok: true }); + + expect(isTimestampFresh(now - 15, now)).toEqual({ ok: false, reason: "stale_timestamp" }); + expect(isTimestampFresh(now + 15, now)).toEqual({ ok: false, reason: "future_timestamp" }); + }); +}); + +describe("Yonexus.Client phase state machine", () => { + it("transitions from idle to connecting to connected", () => { + const sm = createClientStateMachine(); + + expect(sm.getState()).toBe("idle"); + + sm.transition("connect"); + expect(sm.getState()).toBe("connecting"); + + sm.transition("connected"); + expect(sm.getState()).toBe("connected"); + }); + + it("handles pairing required flow", () => { + const sm = createClientStateMachine(); + + sm.transition("connect"); + sm.transition("connected"); + sm.transition("pair_required"); + expect(sm.getState()).toBe("pairing_required"); + + sm.transition("pairing_started"); + expect(sm.getState()).toBe("pairing_pending"); + + sm.transition("pair_success"); + expect(sm.getState()).toBe("authenticating"); + + sm.transition("auth_success"); + expect(sm.getState()).toBe("authenticated"); + }); + + it("handles re-pair required from authenticated", () => { + const sm = createClientStateMachine(); + + sm.transition("connect"); + sm.transition("connected"); + sm.transition("pair_required"); + sm.transition("pairing_started"); + sm.transition("pair_success"); + sm.transition("auth_success"); + expect(sm.getState()).toBe("authenticated"); + + sm.transition("re_pair_required"); + expect(sm.getState()).toBe("pairing_required"); + }); + + it("handles disconnect and reconnect", () => { + const sm = createClientStateMachine(); + + sm.transition("connect"); + sm.transition("connected"); + sm.transition("auth_required"); + sm.transition("auth_success"); + expect(sm.getState()).toBe("authenticated"); + + sm.transition("disconnect"); + expect(sm.getState()).toBe("reconnecting"); + + sm.transition("connect"); + expect(sm.getState()).toBe("connecting"); + }); + + it("emits state change events", () => { + const sm = createClientStateMachine(); + const listener = vi.fn(); + + sm.on("stateChange", listener); + sm.transition("connect"); + + expect(listener).toHaveBeenCalledWith({ from: "idle", to: "connecting" }); + }); +}); + +describe("Yonexus.Client heartbeat scheduling", () => { + it("schedules heartbeat only when authenticated", () => { + const heartbeat = createHeartbeatScheduler({ intervalMs: 300_000 }); + + expect(heartbeat.isRunning()).toBe(false); + + heartbeat.start(); + expect(heartbeat.isRunning()).toBe(true); + + heartbeat.stop(); + expect(heartbeat.isRunning()).toBe(false); + }); + + it("emits heartbeat tick at configured interval", () => { + vi.useFakeTimers(); + const onTick = vi.fn(); + const heartbeat = createHeartbeatScheduler({ intervalMs: 300_000, onTick }); + + heartbeat.start(); + expect(onTick).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(300_000); + expect(onTick).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(300_000); + expect(onTick).toHaveBeenCalledTimes(2); + + heartbeat.stop(); + vi.useRealTimers(); + }); + + it("resets interval on restart", () => { + vi.useFakeTimers(); + const onTick = vi.fn(); + const heartbeat = createHeartbeatScheduler({ intervalMs: 300_000, onTick }); + + heartbeat.start(); + vi.advanceTimersByTime(150_000); + + heartbeat.stop(); + heartbeat.start(); + + vi.advanceTimersByTime(150_000); + expect(onTick).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(150_000); + expect(onTick).toHaveBeenCalledTimes(1); + + heartbeat.stop(); + vi.useRealTimers(); + }); +}); + +type ClientState = + | "idle" + | "connecting" + | "connected" + | "pairing_required" + | "pairing_pending" + | "authenticating" + | "authenticated" + | "reconnecting" + | "error"; + +type ClientEvent = + | "connect" + | "connected" + | "disconnected" + | "disconnect" + | "pair_required" + | "pairing_started" + | "pair_success" + | "pair_failed" + | "auth_required" + | "auth_success" + | "auth_failed" + | "re_pair_required" + | "error"; + +interface StateMachine { + getState(): ClientState; + transition(event: ClientEvent): void; + on(event: "stateChange", handler: (change: { from: ClientState; to: ClientState }) => void): void; +} + +function createClientStateMachine(): StateMachine { + let state: ClientState = "idle"; + const listeners: Array<(change: { from: ClientState; to: ClientState }) => void> = []; + + const transitions: Record>> = { + idle: { connect: "connecting" }, + connecting: { connected: "connected", error: "error", disconnect: "reconnecting" }, + connected: { + pair_required: "pairing_required", + auth_required: "authenticating", + disconnect: "reconnecting" + }, + pairing_required: { pairing_started: "pairing_pending", disconnect: "reconnecting" }, + pairing_pending: { + pair_success: "authenticating", + pair_failed: "pairing_required", + disconnect: "reconnecting" + }, + authenticating: { + auth_success: "authenticated", + auth_failed: "pairing_required", + re_pair_required: "pairing_required", + disconnect: "reconnecting" + }, + authenticated: { re_pair_required: "pairing_required", disconnect: "reconnecting" }, + reconnecting: { connect: "connecting" }, + error: { connect: "connecting" } + }; + + return { + getState: () => state, + transition: (event: ClientEvent) => { + const nextState = transitions[state]?.[event]; + if (nextState) { + const from = state; + state = nextState; + listeners.forEach((l) => l({ from, to: nextState })); + } + }, + on: (event, handler) => { + if (event === "stateChange") { + listeners.push(handler); + } + } + }; +} + +interface HeartbeatScheduler { + start(): void; + stop(): void; + isRunning(): boolean; +} + +function createHeartbeatScheduler(options: { + intervalMs: number; + onTick?: () => void; +}): HeartbeatScheduler { + let timer: ReturnType | null = null; + + return { + start: () => { + if (timer) clearInterval(timer); + timer = setInterval(() => { + options.onTick?.(); + }, options.intervalMs); + }, + stop: () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }, + isRunning: () => timer !== null + }; +} -- 2.49.1 From 65c1f92cc13a45f8cd3c03abd46a9d35ec83add0 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 00:42:32 +0000 Subject: [PATCH 15/24] test: cover client runtime flow --- tests/runtime-flow.test.ts | 378 +++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 tests/runtime-flow.test.ts diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts new file mode 100644 index 0000000..196487b --- /dev/null +++ b/tests/runtime-flow.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildAuthFailed, + buildAuthSuccess, + buildHelloAck, + buildPairFailed, + buildPairRequest, + buildPairSuccess, + buildRePairRequired, + decodeBuiltin, + encodeBuiltin, + type HelloEnvelopePayloadMap, + type PairConfirmPayload, + type TypedBuiltinEnvelope +} from "../../Yonexus.Protocol/src/index.js"; +import { createYonexusClientRuntime } from "../plugin/core/runtime.js"; +import type { YonexusClientState, YonexusClientStateStore } from "../plugin/core/state.js"; +import type { ClientConnectionState, ClientTransport } from "../plugin/core/transport.js"; + +type SavedState = YonexusClientState; + +function createInitialState(): YonexusClientState { + return { + identifier: "client-a", + updatedAt: 1_710_000_000 + }; +} + +function createMockStateStore(initialState: YonexusClientState = createInitialState()) { + let state = { ...initialState }; + const saved: SavedState[] = []; + + const store: YonexusClientStateStore = { + filePath: "/tmp/yonexus-client-test.json", + load: vi.fn(async () => ({ ...state })), + save: vi.fn(async (next) => { + state = { ...next }; + saved.push({ ...next }); + }) + }; + + return { + store, + saved, + getState: () => ({ ...state }) + }; +} + +function createMockTransport() { + let currentState: ClientConnectionState = "idle"; + const sent: string[] = []; + + const transport: ClientTransport = { + get state() { + return currentState; + }, + get isConnected() { + return currentState !== "idle" && currentState !== "disconnected" && currentState !== "error"; + }, + get isAuthenticated() { + return currentState === "authenticated"; + }, + connect: vi.fn(async () => { + currentState = "connected"; + }), + disconnect: vi.fn(() => { + currentState = "disconnected"; + }), + send: vi.fn((message: string) => { + sent.push(message); + return true; + }), + markAuthenticated: vi.fn(() => { + currentState = "authenticated"; + }), + markAuthenticating: vi.fn(() => { + currentState = "authenticating"; + }) + }; + + return { + transport, + sent, + setState: (state: ClientConnectionState) => { + currentState = state; + } + }; +} + +describe("Yonexus.Client runtime flow", () => { + it("starts by loading state, ensuring keypair, and sending hello on connect", async () => { + const storeState = createMockStateStore(); + const transportState = createMockTransport(); + const runtime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: transportState.transport, + stateStore: storeState.store, + now: () => 1_710_000_000 + }); + + await runtime.start(); + runtime.handleTransportStateChange("connected"); + + expect(transportState.transport.connect).toHaveBeenCalled(); + expect(storeState.saved.length).toBeGreaterThan(0); + expect(runtime.state.clientState.publicKey).toBeTypeOf("string"); + expect(runtime.state.phase).toBe("awaiting_hello_ack"); + + const hello = decodeBuiltin(transportState.sent[0]); + expect(hello.type).toBe("hello"); + expect(hello.payload).toMatchObject({ + identifier: "client-a", + hasSecret: false, + hasKeyPair: true + }); + }); + + it("handles pair request, submits code, stores secret, and authenticates", async () => { + let now = 1_710_000_000; + const storeState = createMockStateStore(); + const transportState = createMockTransport(); + const runtime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: transportState.transport, + stateStore: storeState.store, + now: () => now + }); + + await runtime.start(); + runtime.handleTransportStateChange("connected"); + await runtime.handleMessage( + encodeBuiltin( + buildHelloAck( + { + identifier: "client-a", + nextAction: "pair_required" + }, + { requestId: "req-hello", timestamp: now } + ) + ) + ); + + expect(runtime.state.phase).toBe("pair_required"); + + await runtime.handleMessage( + encodeBuiltin( + buildPairRequest( + { + identifier: "client-a", + expiresAt: now + 300, + ttlSeconds: 300, + adminNotification: "sent", + codeDelivery: "out_of_band" + }, + { requestId: "req-pair", timestamp: now } + ) + ) + ); + + expect(runtime.state.phase).toBe("waiting_pair_confirm"); + expect(runtime.state.pendingPairing).toMatchObject({ + ttlSeconds: 300, + adminNotification: "sent" + }); + + expect(runtime.submitPairingCode("PAIR-CODE-123", "req-pair-confirm")).toBe(true); + const pairConfirm = decodeBuiltin(transportState.sent.at(-1)!); + expect(pairConfirm.type).toBe("pair_confirm"); + expect((pairConfirm.payload as PairConfirmPayload).pairingCode).toBe("PAIR-CODE-123"); + + now += 1; + await runtime.handleMessage( + encodeBuiltin( + buildPairSuccess( + { + identifier: "client-a", + secret: "issued-secret", + pairedAt: now + }, + { requestId: "req-pair-confirm", timestamp: now } + ) + ) + ); + + expect(runtime.state.clientState.secret).toBe("issued-secret"); + expect(runtime.state.phase).toBe("auth_required"); + expect(transportState.transport.markAuthenticating).toHaveBeenCalled(); + + const authRequest = decodeBuiltin(transportState.sent.at(-1)!); + expect(authRequest.type).toBe("auth_request"); + expect(authRequest.payload).toMatchObject({ identifier: "client-a" }); + + now += 1; + await runtime.handleMessage( + encodeBuiltin( + buildAuthSuccess( + { + identifier: "client-a", + authenticatedAt: now, + status: "online" + }, + { requestId: "req-auth", timestamp: now } + ) + ) + ); + + expect(runtime.state.phase).toBe("authenticated"); + expect(transportState.transport.markAuthenticated).toHaveBeenCalled(); + expect(runtime.state.clientState.authenticatedAt).toBe(now); + }); + + it("resets trust state on re-pair-required auth failures", async () => { + let now = 1_710_000_000; + const storeState = createMockStateStore({ + identifier: "client-a", + publicKey: "pubkey", + privateKey: "privkey", + secret: "old-secret", + pairedAt: now - 10, + authenticatedAt: now - 5, + updatedAt: now - 5 + }); + const transportState = createMockTransport(); + transportState.setState("connected"); + const runtime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: transportState.transport, + stateStore: storeState.store, + now: () => now + }); + + await runtime.start(); + await runtime.handleMessage( + encodeBuiltin( + buildAuthFailed( + { + identifier: "client-a", + reason: "nonce_collision" + }, + { requestId: "req-auth", timestamp: now } + ) + ) + ); + + expect(runtime.state.phase).toBe("pair_required"); + expect(runtime.state.lastPairingFailure).toBe("nonce_collision"); + expect(runtime.state.clientState.secret).toBeUndefined(); + + now += 1; + await runtime.handleMessage( + encodeBuiltin( + buildRePairRequired( + { + identifier: "client-a", + reason: "rate_limited" + }, + { requestId: "req-repair", timestamp: now } + ) + ) + ); + + expect(runtime.state.phase).toBe("pair_required"); + expect(runtime.state.lastPairingFailure).toBe("re_pair_required"); + }); + + it("sends heartbeat only when authenticated and connected", async () => { + const storeState = createMockStateStore({ + identifier: "client-a", + publicKey: "pubkey", + privateKey: "privkey", + secret: "secret", + pairedAt: 1_709_999_990, + authenticatedAt: 1_709_999_995, + updatedAt: 1_709_999_995 + }); + const transportState = createMockTransport(); + transportState.setState("authenticated"); + const runtime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: transportState.transport, + stateStore: storeState.store, + now: () => 1_710_000_000 + }); + + await runtime.start(); + + await runtime.handleMessage("heartbeat_tick"); + expect(transportState.sent).toHaveLength(0); + + await runtime.handleMessage( + encodeBuiltin( + buildAuthSuccess( + { + identifier: "client-a", + authenticatedAt: 1_710_000_000, + status: "online" + }, + { timestamp: 1_710_000_000 } + ) + ) + ); + + await runtime.handleMessage("heartbeat_tick"); + const heartbeat = decodeBuiltin(transportState.sent.at(-1)!); + expect(heartbeat.type).toBe("heartbeat"); + expect(heartbeat.payload).toMatchObject({ identifier: "client-a", status: "alive" }); + }); + + it("tracks pairing failures without wiping pending session for retryable reasons", async () => { + const storeState = createMockStateStore(); + const transportState = createMockTransport(); + const runtime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: transportState.transport, + stateStore: storeState.store, + now: () => 1_710_000_000 + }); + + await runtime.start(); + runtime.handleTransportStateChange("connected"); + await runtime.handleMessage( + encodeBuiltin( + buildPairRequest( + { + identifier: "client-a", + expiresAt: 1_710_000_300, + ttlSeconds: 300, + adminNotification: "sent", + codeDelivery: "out_of_band" + }, + { timestamp: 1_710_000_000 } + ) + ) + ); + + await runtime.handleMessage( + encodeBuiltin( + buildPairFailed( + { + identifier: "client-a", + reason: "invalid_code" + }, + { timestamp: 1_710_000_001 } + ) + ) + ); + + expect(runtime.state.phase).toBe("waiting_pair_confirm"); + expect(runtime.state.pendingPairing).toBeDefined(); + expect(runtime.state.lastPairingFailure).toBe("invalid_code"); + }); +}); -- 2.49.1 From 93e09875ecad2a1b9fae9b11ca4fd569710f81af Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 01:32:49 +0000 Subject: [PATCH 16/24] test: cover corrupted client state --- tests/state-and-rules.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/state-and-rules.test.ts b/tests/state-and-rules.test.ts index efea902..e0b1bc2 100644 --- a/tests/state-and-rules.test.ts +++ b/tests/state-and-rules.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,6 +13,7 @@ import { hasClientSecret, loadYonexusClientState, saveYonexusClientState, + YonexusClientStateCorruptionError, type YonexusClientState } from "../plugin/core/state.js"; import { signMessage, verifySignature } from "../plugin/crypto/keypair.js"; @@ -81,6 +82,23 @@ describe("Yonexus.Client state store", () => { expect(second.state.privateKey).toBe(first.state.privateKey); expect(second.state.publicKey).toBe(first.state.publicKey); }); + + it("SR-06: raises a corruption error for malformed client state files", async () => { + const filePath = await createTempStatePath(); + await saveYonexusClientState(filePath, { + ...createInitialClientState("client-a"), + updatedAt: 1_710_000_000 + }); + + await writeFile(filePath, '{"version":1,"identifier":"client-a","updatedAt":"bad"}\n', "utf8"); + + await expect(loadYonexusClientState(filePath, "client-a")).rejects.toBeInstanceOf( + YonexusClientStateCorruptionError + ); + await expect(loadYonexusClientState(filePath, "client-a")).rejects.toThrow( + "invalid updatedAt" + ); + }); }); describe("Yonexus.Client rule registry", () => { -- 2.49.1 From 5fbbdd199c70ec0f96f700d9218e2e957514b823 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 02:04:06 +0000 Subject: [PATCH 17/24] test: cover client restart auth recovery --- tests/runtime-flow.test.ts | 57 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts index 196487b..1c83e07 100644 --- a/tests/runtime-flow.test.ts +++ b/tests/runtime-flow.test.ts @@ -279,6 +279,63 @@ describe("Yonexus.Client runtime flow", () => { expect(runtime.state.lastPairingFailure).toBe("re_pair_required"); }); + it("SR-03: restarts with stored credentials and resumes at auth flow without re-pairing", async () => { + const now = 1_710_000_000; + const { generateKeyPair } = await import("../plugin/crypto/keypair.js"); + const keyPair = await generateKeyPair(); + const storeState = createMockStateStore({ + identifier: "client-a", + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + secret: "stored-secret", + pairedAt: now - 20, + authenticatedAt: now - 10, + updatedAt: now - 10 + }); + const transportState = createMockTransport(); + const runtime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: transportState.transport, + stateStore: storeState.store, + now: () => now + }); + + await runtime.start(); + runtime.handleTransportStateChange("connected"); + + const hello = decodeBuiltin(transportState.sent[0]); + expect(hello.type).toBe("hello"); + expect(hello.payload).toMatchObject({ + identifier: "client-a", + hasSecret: true, + hasKeyPair: true, + publicKey: keyPair.publicKey + }); + + await runtime.handleMessage( + encodeBuiltin( + buildHelloAck( + { + identifier: "client-a", + nextAction: "auth_required" + }, + { requestId: "req-restart-hello", timestamp: now } + ) + ) + ); + + expect(runtime.state.phase).toBe("auth_required"); + + const authRequest = decodeBuiltin(transportState.sent.at(-1)!); + expect(authRequest.type).toBe("auth_request"); + expect(authRequest.payload).toMatchObject({ identifier: "client-a" }); + }); + it("sends heartbeat only when authenticated and connected", async () => { const storeState = createMockStateStore({ identifier: "client-a", -- 2.49.1 From 9fd9b50842080550b93874a8dd2d61afeea49e2a Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 03:02:36 +0000 Subject: [PATCH 18/24] test: cover first-run pair bootstrap --- tests/runtime-flow.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts index 1c83e07..0e6324d 100644 --- a/tests/runtime-flow.test.ts +++ b/tests/runtime-flow.test.ts @@ -121,6 +121,53 @@ describe("Yonexus.Client runtime flow", () => { }); }); + it("SR-04: first run without credentials enters pair flow and does not require manual state bootstrap", async () => { + const storeState = createMockStateStore({ + identifier: "client-a", + updatedAt: 1_710_000_000 + }); + const transportState = createMockTransport(); + const runtime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: transportState.transport, + stateStore: storeState.store, + now: () => 1_710_000_000 + }); + + await runtime.start(); + runtime.handleTransportStateChange("connected"); + + const hello = decodeBuiltin(transportState.sent[0]); + expect(hello.type).toBe("hello"); + expect(hello.payload).toMatchObject({ + identifier: "client-a", + hasSecret: false, + hasKeyPair: true + }); + + await runtime.handleMessage( + encodeBuiltin( + buildHelloAck( + { + identifier: "client-a", + nextAction: "pair_required" + }, + { requestId: "req-first-run", timestamp: 1_710_000_000 } + ) + ) + ); + + expect(runtime.state.phase).toBe("pair_required"); + expect(runtime.state.clientState.secret).toBeUndefined(); + expect(runtime.state.clientState.privateKey).toBeTypeOf("string"); + expect(runtime.state.clientState.publicKey).toBeTypeOf("string"); + }); + it("handles pair request, submits code, stores secret, and authenticates", async () => { let now = 1_710_000_000; const storeState = createMockStateStore(); -- 2.49.1 From b10ebc541e874306ec05575ce100c53f017634e1 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 03:33:09 +0000 Subject: [PATCH 19/24] test: cover client reconnect failures --- tests/transport-reconnect.test.ts | 163 ++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/transport-reconnect.test.ts diff --git a/tests/transport-reconnect.test.ts b/tests/transport-reconnect.test.ts new file mode 100644 index 0000000..e03f3d0 --- /dev/null +++ b/tests/transport-reconnect.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type EventName = "open" | "close" | "error" | "message"; +type EventHandler = (...args: any[]) => void; + +const socketInstances: MockWebSocket[] = []; +const pendingBehaviors: Array<"open" | "error"> = []; + +class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + + readyState = 0; + sent: string[] = []; + private readonly handlers = new Map(); + + constructor(public readonly url: string) { + socketInstances.push(this); + const behavior = pendingBehaviors.shift() ?? "open"; + + queueMicrotask(() => { + if (behavior === "open") { + this.readyState = MockWebSocket.OPEN; + this.emit("open"); + return; + } + + const error = new Error(`mock connect failure for ${url}`); + this.emit("error", error); + this.emit("close", 1006, Buffer.from("connect failed")); + this.readyState = MockWebSocket.CLOSED; + }); + } + + once(event: EventName, handler: EventHandler): this { + const onceHandler: EventHandler = (...args: any[]) => { + this.off(event, onceHandler); + handler(...args); + }; + return this.on(event, onceHandler); + } + + on(event: EventName, handler: EventHandler): this { + const existing = this.handlers.get(event) ?? []; + existing.push(handler); + this.handlers.set(event, existing); + return this; + } + + off(event: EventName, handler: EventHandler): this { + const existing = this.handlers.get(event) ?? []; + this.handlers.set( + event, + existing.filter((candidate) => candidate !== handler) + ); + return this; + } + + send(message: string): void { + this.sent.push(message); + } + + close(code = 1000, reason = ""): + void { + this.readyState = MockWebSocket.CLOSED; + this.emit("close", code, Buffer.from(reason)); + } + + emit(event: EventName, ...args: any[]): void { + for (const handler of [...(this.handlers.get(event) ?? [])]) { + handler(...args); + } + } +} + +vi.mock("ws", () => ({ + WebSocket: MockWebSocket +})); + +describe("Yonexus.Client transport reconnect behavior", () => { + beforeEach(() => { + vi.useFakeTimers(); + socketInstances.length = 0; + pendingBehaviors.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("CF-02: retries initial connect with exponential backoff when server is unreachable", async () => { + const { createClientTransport } = await import("../plugin/core/transport.js"); + const onStateChange = vi.fn(); + const onError = vi.fn(); + + pendingBehaviors.push("error", "error", "open"); + + const transport = createClientTransport({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + onMessage: vi.fn(), + onStateChange, + onError + }); + + await expect(transport.connect()).rejects.toThrow("mock connect failure"); + expect(socketInstances).toHaveLength(1); + expect(transport.state).toBe("disconnected"); + + await vi.advanceTimersByTimeAsync(999); + expect(socketInstances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + expect(socketInstances).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(1_999); + expect(socketInstances).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(1); + expect(socketInstances).toHaveLength(3); + expect(transport.state).toBe("connected"); + expect(onError.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(onStateChange).toHaveBeenCalledWith("connecting"); + expect(onStateChange).toHaveBeenCalledWith("connected"); + }); + + it("CF-01: reconnects with backoff after network partition closes an established connection", async () => { + const { createClientTransport } = await import("../plugin/core/transport.js"); + + pendingBehaviors.push("open", "open"); + + const transport = createClientTransport({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + onMessage: vi.fn(), + onStateChange: vi.fn(), + onError: vi.fn() + }); + + await transport.connect(); + expect(socketInstances).toHaveLength(1); + expect(transport.state).toBe("connected"); + + socketInstances[0].emit("close", 1006, Buffer.from("network partition")); + expect(transport.state).toBe("disconnected"); + + await vi.advanceTimersByTimeAsync(999); + expect(socketInstances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + expect(socketInstances).toHaveLength(2); + expect(transport.state).toBe("connected"); + }); +}); -- 2.49.1 From 7cdda2e3357cb84afb9c65e0b9a687b06f4fd945 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 04:06:06 +0000 Subject: [PATCH 20/24] test(client): cover pairing restart resume flow --- tests/runtime-flow.test.ts | 110 +++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts index 0e6324d..3dc2a3e 100644 --- a/tests/runtime-flow.test.ts +++ b/tests/runtime-flow.test.ts @@ -479,4 +479,114 @@ describe("Yonexus.Client runtime flow", () => { expect(runtime.state.pendingPairing).toBeDefined(); expect(runtime.state.lastPairingFailure).toBe("invalid_code"); }); + + it("PF-10: restart during pending pairing resumes waiting for out-of-band code", async () => { + const now = 1_710_000_000; + const storeState = createMockStateStore({ + identifier: "client-a", + updatedAt: now + }); + + const firstTransportState = createMockTransport(); + const firstRuntime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: firstTransportState.transport, + stateStore: storeState.store, + now: () => now + }); + + await firstRuntime.start(); + firstRuntime.handleTransportStateChange("connected"); + await firstRuntime.handleMessage( + encodeBuiltin( + buildPairRequest( + { + identifier: "client-a", + expiresAt: now + 300, + ttlSeconds: 300, + adminNotification: "sent", + codeDelivery: "out_of_band" + }, + { requestId: "req-pair", timestamp: now } + ) + ) + ); + + expect(firstRuntime.state.phase).toBe("waiting_pair_confirm"); + expect(firstRuntime.state.pendingPairing).toMatchObject({ + expiresAt: now + 300, + ttlSeconds: 300, + adminNotification: "sent" + }); + + await firstRuntime.stop(); + + const secondTransportState = createMockTransport(); + const secondRuntime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: secondTransportState.transport, + stateStore: storeState.store, + now: () => now + 5 + }); + + await secondRuntime.start(); + secondRuntime.handleTransportStateChange("connected"); + + const hello = decodeBuiltin(secondTransportState.sent[0]); + expect(hello.type).toBe("hello"); + expect(hello.payload).toMatchObject({ + identifier: "client-a", + hasSecret: false, + hasKeyPair: true + }); + + await secondRuntime.handleMessage( + encodeBuiltin( + buildHelloAck( + { + identifier: "client-a", + nextAction: "waiting_pair_confirm" + }, + { requestId: "req-hello-resume", timestamp: now + 5 } + ) + ) + ); + + await secondRuntime.handleMessage( + encodeBuiltin( + buildPairRequest( + { + identifier: "client-a", + expiresAt: now + 300, + ttlSeconds: 295, + adminNotification: "sent", + codeDelivery: "out_of_band" + }, + { requestId: "req-pair-resume", timestamp: now + 5 } + ) + ) + ); + + expect(secondRuntime.state.phase).toBe("waiting_pair_confirm"); + expect(secondRuntime.state.pendingPairing).toMatchObject({ + expiresAt: now + 300, + ttlSeconds: 295, + adminNotification: "sent" + }); + expect(secondRuntime.submitPairingCode("PAIR-CODE-123", "req-pair-resume")).toBe(true); + + const pairConfirm = decodeBuiltin(secondTransportState.sent.at(-1)!); + expect(pairConfirm.type).toBe("pair_confirm"); + expect((pairConfirm.payload as PairConfirmPayload).pairingCode).toBe("PAIR-CODE-123"); + }); }); -- 2.49.1 From 57b53fc122f88b442781101fa2f848ec78c86c76 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 04:38:03 +0000 Subject: [PATCH 21/24] Fix strict TypeScript checks for client --- plugin/core/config.ts | 31 ++++++++++++++++++------------- plugin/core/state.ts | 3 ++- plugin/types/ws.d.ts | 24 ++++++++++++++++++++++++ tsconfig.json | 6 ++++-- 4 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 plugin/types/ws.d.ts diff --git a/plugin/core/config.ts b/plugin/core/config.ts index 421ef5b..c8eada7 100644 --- a/plugin/core/config.ts +++ b/plugin/core/config.ts @@ -32,25 +32,25 @@ export function validateYonexusClientConfig(raw: unknown): YonexusClientConfig { const source = (raw && typeof raw === "object" ? raw : {}) as Record; const issues: string[] = []; - const mainHost = source.mainHost; - if (!isNonEmptyString(mainHost)) { + const rawMainHost = source.mainHost; + if (!isNonEmptyString(rawMainHost)) { issues.push("mainHost is required"); - } else if (!isValidWsUrl(mainHost.trim())) { + } else if (!isValidWsUrl(rawMainHost.trim())) { issues.push("mainHost must be a valid ws:// or wss:// URL"); } - const identifier = source.identifier; - if (!isNonEmptyString(identifier)) { + const rawIdentifier = source.identifier; + if (!isNonEmptyString(rawIdentifier)) { issues.push("identifier is required"); } - const notifyBotToken = source.notifyBotToken; - if (!isNonEmptyString(notifyBotToken)) { + const rawNotifyBotToken = source.notifyBotToken; + if (!isNonEmptyString(rawNotifyBotToken)) { issues.push("notifyBotToken is required"); } - const adminUserId = source.adminUserId; - if (!isNonEmptyString(adminUserId)) { + const rawAdminUserId = source.adminUserId; + if (!isNonEmptyString(rawAdminUserId)) { issues.push("adminUserId is required"); } @@ -58,10 +58,15 @@ export function validateYonexusClientConfig(raw: unknown): YonexusClientConfig { throw new YonexusClientConfigError(issues); } + const mainHost = (rawMainHost as string).trim(); + const identifier = (rawIdentifier as string).trim(); + const notifyBotToken = (rawNotifyBotToken as string).trim(); + const adminUserId = (rawAdminUserId as string).trim(); + return { - mainHost: mainHost.trim(), - identifier: identifier.trim(), - notifyBotToken: notifyBotToken.trim(), - adminUserId: adminUserId.trim() + mainHost, + identifier, + notifyBotToken, + adminUserId }; } diff --git a/plugin/core/state.ts b/plugin/core/state.ts index f6a9b08..fc45866 100644 --- a/plugin/core/state.ts +++ b/plugin/core/state.ts @@ -181,7 +181,8 @@ function assertClientStateShape( ); } - if (!Number.isInteger(candidate.updatedAt) || candidate.updatedAt < 0) { + const updatedAt = candidate.updatedAt; + if (typeof updatedAt !== "number" || !Number.isInteger(updatedAt) || updatedAt < 0) { throw new YonexusClientStateCorruptionError( `Client state file has invalid updatedAt value: ${filePath}` ); diff --git a/plugin/types/ws.d.ts b/plugin/types/ws.d.ts new file mode 100644 index 0000000..d8b7f2f --- /dev/null +++ b/plugin/types/ws.d.ts @@ -0,0 +1,24 @@ +declare module "ws" { + export type RawData = Buffer | ArrayBuffer | Buffer[] | string; + + export class WebSocket { + static readonly OPEN: number; + constructor(url: string); + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + terminate(): void; + on(event: "open", listener: () => void): this; + on(event: "message", listener: (data: RawData) => void): this; + on(event: "close", listener: (code: number, reason: Buffer) => void): this; + on(event: "error", listener: (error: Error) => void): this; + once(event: "open", listener: () => void): this; + once(event: "error", listener: (error: Error) => void): this; + off(event: "error", listener: (error: Error) => void): this; + removeAllListeners?(event?: string): this; + } + + export class WebSocketServer { + constructor(options: { host?: string; port: number }); + } +} diff --git a/tsconfig.json b/tsconfig.json index cf17390..658f108 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", - "rootDir": ".", + "rootDir": "..", "strict": true, "skipLibCheck": true, "esModuleInterop": true, @@ -15,7 +15,9 @@ }, "include": [ "plugin/**/*.ts", - "servers/**/*.ts" + "plugin/**/*.d.ts", + "servers/**/*.ts", + "../Yonexus.Protocol/src/**/*.ts" ], "exclude": [ "dist", -- 2.49.1 From 8824e768fb511508888c0a96071f9f25d11df58f Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 20:14:57 +0100 Subject: [PATCH 22/24] feat: wire rule registry and authenticated callback into client runtime - Add ruleRegistry and onAuthenticated options to YonexusClientRuntime - Dispatch non-builtin messages to rule registry - Fire onAuthenticated callback on auth_success - Reload persisted state on reconnect so externally-written secrets are picked up - Re-send hello on auth_failed("not_paired") when client has a valid secret - Always enter waiting_pair_confirm after pair_request regardless of notification status - Expose __yonexusClient on globalThis for cross-plugin communication - Wire onStateChange in transport creation (previously missing, prevented connection) Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++ plugin/core/runtime.ts | 28 +++++++++++++- plugin/index.ts | 74 +++++++++++++++++++++++++++++++------ plugin/openclaw.plugin.json | 18 ++++++--- scripts/install.mjs | 1 + 5 files changed, 105 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 47c096f..67609a0 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "description": "Yonexus.Client OpenClaw plugin scaffold", "type": "module", "main": "dist/plugin/index.js", + "openclaw": { + "extensions": ["./dist/Yonexus.Client/plugin/index.js"] + }, "files": [ "dist", "plugin", diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index e75476b..774aa74 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -29,6 +29,7 @@ import { } from "./state.js"; import { generateNonce, signMessage } from "../crypto/keypair.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; +import type { ClientRuleRegistry } from "./rules.js"; export type YonexusClientPhase = | "idle" @@ -44,6 +45,8 @@ export interface YonexusClientRuntimeOptions { config: YonexusClientConfig; transport: ClientTransport; stateStore: YonexusClientStateStore; + ruleRegistry?: ClientRuleRegistry; + onAuthenticated?: () => void; now?: () => number; } @@ -118,6 +121,7 @@ export class YonexusClientRuntime { } if (!isBuiltinMessage(raw)) { + this.options.ruleRegistry?.dispatch(raw); return; } @@ -159,6 +163,7 @@ export class YonexusClientRuntime { }; await this.options.stateStore.save(this.clientState); this.phase = "authenticated"; + this.options.onAuthenticated?.(); return; } @@ -177,7 +182,17 @@ export class YonexusClientRuntime { handleTransportStateChange(state: ClientConnectionState): void { if (state === "connected") { - this.sendHello(); + // Reload state from disk before hello so that any secret written by an + // external process (e.g. a pairing script) is picked up on reconnect. + this.options.stateStore.load(this.options.config.identifier).then((fresh) => { + if (fresh) { + this.clientState = { ...this.clientState, ...fresh }; + } + this.sendHello(); + }).catch(() => { + // If reload fails, proceed with in-memory state + this.sendHello(); + }); } if (state === "disconnected") { @@ -259,7 +274,10 @@ export class YonexusClientRuntime { adminNotification: payload.adminNotification }; this.lastPairingFailure = undefined; - this.phase = payload.adminNotification === "sent" ? "waiting_pair_confirm" : "pair_required"; + // Always wait for the pairing code regardless of notification status. + // When adminNotification is "failed", the admin can retrieve the code + // via the server CLI command and deliver it through an alternate channel. + this.phase = "waiting_pair_confirm"; } private async handlePairSuccess(envelope: TypedBuiltinEnvelope<"pair_success">): Promise { @@ -316,6 +334,12 @@ export class YonexusClientRuntime { } this.lastPairingFailure = payload.reason; + // If the server lost our session (race condition), re-announce via hello + // so the server creates a new session and we can retry auth. + if (payload.reason === "not_paired" && hasClientSecret(this.clientState)) { + this.sendHello(); + return; + } this.phase = "auth_required"; } diff --git a/plugin/index.ts b/plugin/index.ts index e17e8fc..b006bd1 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -39,30 +39,82 @@ export { type ClientRuleProcessor } from "./core/rules.js"; +import path from "node:path"; +import { validateYonexusClientConfig } from "./core/config.js"; +import { createYonexusClientStateStore } from "./core/state.js"; +import { createClientTransport } from "./core/transport.js"; +import { createYonexusClientRuntime } from "./core/runtime.js"; +import { createClientRuleRegistry } from "./core/rules.js"; + export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; readonly version: string; readonly description: string; } -export interface YonexusClientPluginRuntime { - readonly hooks: readonly []; - readonly commands: readonly []; - readonly tools: readonly []; -} - const manifest: YonexusClientPluginManifest = { name: "Yonexus.Client", version: "0.1.0", description: "Yonexus client plugin for cross-instance OpenClaw communication" }; -export function createYonexusClientPlugin(): YonexusClientPluginRuntime { - return { - hooks: [], - commands: [], - tools: [] +let _clientStarted = false; + +export function createYonexusClientPlugin(api: { rootDir: string; pluginConfig: unknown }): void { + if (_clientStarted) return; + _clientStarted = true; + + const config = validateYonexusClientConfig(api.pluginConfig); + const stateStore = createYonexusClientStateStore(path.join(api.rootDir, "state.json")); + + const ruleRegistry = createClientRuleRegistry(); + const onAuthenticatedCallbacks: Array<() => void> = []; + + let runtimeRef: ReturnType | null = null; + const transport = createClientTransport({ + config, + onMessage: (msg) => { + runtimeRef?.handleMessage(msg).catch((err: unknown) => { + console.error("[yonexus-client] message handler error:", err); + }); + }, + onStateChange: (state) => { + runtimeRef?.handleTransportStateChange(state); + } + }); + + // Expose registry and helpers for other plugins loaded in the same process + (globalThis as Record)["__yonexusClient"] = { + ruleRegistry, + sendRule: (ruleId: string, content: string): boolean => + runtimeRef?.sendRuleMessage(ruleId, content) ?? false, + submitPairingCode: (code: string): boolean => + runtimeRef?.submitPairingCode(code) ?? false, + onAuthenticated: onAuthenticatedCallbacks }; + + const runtime = createYonexusClientRuntime({ + config, + transport, + stateStore, + ruleRegistry, + onAuthenticated: () => { + for (const cb of onAuthenticatedCallbacks) cb(); + } + }); + runtimeRef = runtime; + + const shutdown = (): void => { + runtime.stop().catch((err: unknown) => { + console.error("[yonexus-client] shutdown error:", err); + }); + }; + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); + + runtime.start().catch((err: unknown) => { + console.error("[yonexus-client] failed to start:", err); + }); } export default createYonexusClientPlugin; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index ada1f87..ec65507 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,13 +1,19 @@ { + "id": "yonexus-client", "name": "Yonexus.Client", "version": "0.1.0", "description": "Yonexus client plugin for cross-instance OpenClaw communication", - "entry": "dist/plugin/index.js", + "entry": "./dist/Yonexus.Client/plugin/index.js", "permissions": [], - "config": { - "mainHost": "", - "identifier": "", - "notifyBotToken": "", - "adminUserId": "" + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "mainHost": { "type": "string" }, + "identifier": { "type": "string" }, + "notifyBotToken": { "type": "string" }, + "adminUserId": { "type": "string" } + }, + "required": ["mainHost", "identifier", "notifyBotToken", "adminUserId"] } } diff --git a/scripts/install.mjs b/scripts/install.mjs index 7fa47a5..f6abaa2 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -29,6 +29,7 @@ if (mode === "install") { 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")); + fs.copyFileSync(path.join(repoRoot, "package.json"), path.join(targetDir, "package.json")); console.log(`Installed ${pluginName} to ${targetDir}`); process.exit(0); } -- 2.49.1 From 4adb1873311a15ccf74c1789cc2b8e51273e8e14 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 20:41:27 +0100 Subject: [PATCH 23/24] fix: migrate startup guard and shared state to globalThis Module-level _clientStarted / ruleRegistry / onAuthenticatedCallbacks reset on hot-reload (new VM context), causing a second runtime to start and the exposed __yonexusClient API to point at orphaned objects. - Replace let _clientStarted with _G["_yonexusClientStarted"] - Store ruleRegistry and onAuthenticatedCallbacks under globalThis keys, initialising only when absent (survives hot-reload) - Store runtime under _G["_yonexusClientRuntime"]; sendRule / submitPairingCode closures read it from globalThis instead of capturing a module-local ref - Re-write __yonexusClient every register() call so closures stay current, but skip runtime.start() when the globalThis flag is already set Co-Authored-By: Claude Sonnet 4.6 --- plugin/index.ts | 59 ++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index b006bd1..7054776 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -43,8 +43,14 @@ import path from "node:path"; import { validateYonexusClientConfig } from "./core/config.js"; import { createYonexusClientStateStore } from "./core/state.js"; import { createClientTransport } from "./core/transport.js"; -import { createYonexusClientRuntime } from "./core/runtime.js"; -import { createClientRuleRegistry } from "./core/rules.js"; +import { createYonexusClientRuntime, type YonexusClientRuntime } from "./core/runtime.js"; +import { createClientRuleRegistry, YonexusClientRuleRegistry } from "./core/rules.js"; + +const _G = globalThis as Record; +const _STARTED_KEY = "_yonexusClientStarted"; +const _RUNTIME_KEY = "_yonexusClientRuntime"; +const _REGISTRY_KEY = "_yonexusClientRegistry"; +const _CALLBACKS_KEY = "_yonexusClientOnAuthCallbacks"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; @@ -58,41 +64,48 @@ const manifest: YonexusClientPluginManifest = { description: "Yonexus client plugin for cross-instance OpenClaw communication" }; -let _clientStarted = false; - export function createYonexusClientPlugin(api: { rootDir: string; pluginConfig: unknown }): void { - if (_clientStarted) return; - _clientStarted = true; + // 1. Ensure shared state survives hot-reload — only initialise when absent + if (!(_G[_REGISTRY_KEY] instanceof YonexusClientRuleRegistry)) { + _G[_REGISTRY_KEY] = createClientRuleRegistry(); + } + if (!Array.isArray(_G[_CALLBACKS_KEY])) { + _G[_CALLBACKS_KEY] = []; + } + + const ruleRegistry = _G[_REGISTRY_KEY] as YonexusClientRuleRegistry; + const onAuthenticatedCallbacks = _G[_CALLBACKS_KEY] as Array<() => void>; + + // 2. Refresh the cross-plugin API object every call so that sendRule / submitPairingCode + // closures always read the live runtime from globalThis. + _G["__yonexusClient"] = { + ruleRegistry, + sendRule: (ruleId: string, content: string): boolean => + (_G[_RUNTIME_KEY] as YonexusClientRuntime | undefined)?.sendRuleMessage(ruleId, content) ?? false, + submitPairingCode: (code: string): boolean => + (_G[_RUNTIME_KEY] as YonexusClientRuntime | undefined)?.submitPairingCode(code) ?? false, + onAuthenticated: onAuthenticatedCallbacks + }; + + // 3. Start the runtime only once — the globalThis flag survives hot-reload + if (_G[_STARTED_KEY]) return; + _G[_STARTED_KEY] = true; const config = validateYonexusClientConfig(api.pluginConfig); const stateStore = createYonexusClientStateStore(path.join(api.rootDir, "state.json")); - const ruleRegistry = createClientRuleRegistry(); - const onAuthenticatedCallbacks: Array<() => void> = []; - - let runtimeRef: ReturnType | null = null; const transport = createClientTransport({ config, onMessage: (msg) => { - runtimeRef?.handleMessage(msg).catch((err: unknown) => { + (_G[_RUNTIME_KEY] as YonexusClientRuntime | undefined)?.handleMessage(msg).catch((err: unknown) => { console.error("[yonexus-client] message handler error:", err); }); }, onStateChange: (state) => { - runtimeRef?.handleTransportStateChange(state); + (_G[_RUNTIME_KEY] as YonexusClientRuntime | undefined)?.handleTransportStateChange(state); } }); - // Expose registry and helpers for other plugins loaded in the same process - (globalThis as Record)["__yonexusClient"] = { - ruleRegistry, - sendRule: (ruleId: string, content: string): boolean => - runtimeRef?.sendRuleMessage(ruleId, content) ?? false, - submitPairingCode: (code: string): boolean => - runtimeRef?.submitPairingCode(code) ?? false, - onAuthenticated: onAuthenticatedCallbacks - }; - const runtime = createYonexusClientRuntime({ config, transport, @@ -102,7 +115,7 @@ export function createYonexusClientPlugin(api: { rootDir: string; pluginConfig: for (const cb of onAuthenticatedCallbacks) cb(); } }); - runtimeRef = runtime; + _G[_RUNTIME_KEY] = runtime; const shutdown = (): void => { runtime.stop().catch((err: unknown) => { -- 2.49.1 From 8b2691979091a0e2b4c5d04fde6a0a455e87126f Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 21:58:59 +0100 Subject: [PATCH 24/24] fix: globalThis --- protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocol b/protocol index 9232aa7..2611304 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 9232aa7c1755adda6990a5a2f6c7c1a114285a73 +Subproject commit 26113040844cc6804e6a2b617d0c9ce1cbdb92df -- 2.49.1