From a8b2f5d9ed51ad1621f4e4e36447905f74695c62 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 20:15:09 +0100 Subject: [PATCH] feat: add rule dispatch, cross-plugin API, and Docker integration test Wire rule registry and authenticated callbacks into both client and server runtimes; expose __yonexusClient / __yonexusServer on globalThis for cross-plugin communication. Add Docker-based integration test with server-test-plugin (test_ping echo) and client-test-plugin (test_pong receiver), plus docker-compose setup. Fix transport race condition where a stale _connections entry caused promoteToAuthenticated to silently fail. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + Yonexus.Client | 2 +- Yonexus.Server | 2 +- tests/docker/client-test-plugin/index.mjs | 33 +++++++++ .../client-test-plugin/openclaw.plugin.json | 13 ++++ tests/docker/client/Dockerfile | 37 ++++++++++ tests/docker/client/entrypoint.sh | 69 ++++++++++++++++++ tests/docker/docker-compose.yml | 46 ++++++++++++ tests/docker/server-test-plugin/index.mjs | 39 ++++++++++ .../server-test-plugin/openclaw.plugin.json | 13 ++++ tests/docker/server/Dockerfile | 40 +++++++++++ tests/docker/server/entrypoint.sh | 71 +++++++++++++++++++ 12 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 tests/docker/client-test-plugin/index.mjs create mode 100644 tests/docker/client-test-plugin/openclaw.plugin.json create mode 100644 tests/docker/client/Dockerfile create mode 100644 tests/docker/client/entrypoint.sh create mode 100644 tests/docker/docker-compose.yml create mode 100644 tests/docker/server-test-plugin/index.mjs create mode 100644 tests/docker/server-test-plugin/openclaw.plugin.json create mode 100644 tests/docker/server/Dockerfile create mode 100644 tests/docker/server/entrypoint.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..575d489 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +tests/docker/.env diff --git a/Yonexus.Client b/Yonexus.Client index 57b53fc..8824e76 160000 --- a/Yonexus.Client +++ b/Yonexus.Client @@ -1 +1 @@ -Subproject commit 57b53fc122f88b442781101fa2f848ec78c86c76 +Subproject commit 8824e768fb511508888c0a96071f9f25d11df58f diff --git a/Yonexus.Server b/Yonexus.Server index 31f41cb..59d5b26 160000 --- a/Yonexus.Server +++ b/Yonexus.Server @@ -1 +1 @@ -Subproject commit 31f41cb49bb2d4ac22059b296a996115a1d822dd +Subproject commit 59d5b26aff7cd5659c665991d3fc243dd8e4e324 diff --git a/tests/docker/client-test-plugin/index.mjs b/tests/docker/client-test-plugin/index.mjs new file mode 100644 index 0000000..b1cab47 --- /dev/null +++ b/tests/docker/client-test-plugin/index.mjs @@ -0,0 +1,33 @@ +// Singleton guard — openclaw calls register() twice per process +let _registered = false; + +export default function register(_api) { + if (_registered) return; + _registered = true; + + const client = globalThis.__yonexusClient; + if (!client) { + console.error('[client-test] __yonexusClient not on globalThis — ensure Yonexus.Client loads first'); + return; + } + + console.log('[client-test] __yonexusClient available, keys:', Object.keys(client)); + + // Register test_pong rule + // Received format (plain rule message from server): test_pong:: + client.ruleRegistry.registerRule('test_pong', (raw) => { + const sep = raw.indexOf('::'); + const content = raw.slice(sep + 2); + console.log(`[client-test] MATCH test_pong content="${content}"`); + }); + + // When authenticated, send one matching and one non-matching rule message to server + client.onAuthenticated.push(() => { + console.log('[client-test] Authenticated — sending test_ping + other_rule to server'); + const s1 = client.sendRule('test_ping', 'hello-from-client'); + const s2 = client.sendRule('other_rule', 'other-from-client'); + console.log(`[client-test] sendRule results: test_ping=${s1} other_rule=${s2}`); + }); + + console.log('[client-test] registered test_pong rule and onAuthenticated callback'); +} diff --git a/tests/docker/client-test-plugin/openclaw.plugin.json b/tests/docker/client-test-plugin/openclaw.plugin.json new file mode 100644 index 0000000..6eac772 --- /dev/null +++ b/tests/docker/client-test-plugin/openclaw.plugin.json @@ -0,0 +1,13 @@ +{ + "id": "yonexus-client-test", + "name": "Yonexus Client Test Plugin", + "version": "0.1.0", + "description": "Test plugin for Yonexus.Client rule routing", + "entry": "./index.mjs", + "permissions": [], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/tests/docker/client/Dockerfile b/tests/docker/client/Dockerfile new file mode 100644 index 0000000..5dc4bfd --- /dev/null +++ b/tests/docker/client/Dockerfile @@ -0,0 +1,37 @@ +# Build context: repo root (Yonexus/) +# ── Stage 1: compile ────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder + +WORKDIR /build + +# Client imports Yonexus.Protocol only +COPY Yonexus.Protocol/src ./Yonexus.Protocol/src + +COPY Yonexus.Client/package.json ./Yonexus.Client/ +COPY Yonexus.Client/package-lock.json ./Yonexus.Client/ +COPY Yonexus.Client/tsconfig.json ./Yonexus.Client/ +COPY Yonexus.Client/plugin ./Yonexus.Client/plugin + +WORKDIR /build/Yonexus.Client +RUN npm ci +RUN npm run build + +# ── Stage 2: runtime ───────────────────────────────────────────────────────── +FROM node:22-alpine AS runtime + +RUN npm install -g openclaw@2026.4.9 + +WORKDIR /app + +# Layout expected by install.mjs: repoRoot = /app, sourceDist = /app/dist +COPY --from=builder /build/Yonexus.Client/dist ./dist +COPY --from=builder /build/Yonexus.Client/node_modules ./node_modules +COPY Yonexus.Client/package.json ./package.json +COPY Yonexus.Client/plugin/openclaw.plugin.json ./plugin/openclaw.plugin.json +COPY Yonexus.Client/scripts/install.mjs ./scripts/install.mjs + +COPY tests/docker/client-test-plugin /app/client-test-plugin +COPY tests/docker/client/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/docker/client/entrypoint.sh b/tests/docker/client/entrypoint.sh new file mode 100644 index 0000000..591985f --- /dev/null +++ b/tests/docker/client/entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -e + +: "${IDENTIFIER:?IDENTIFIER is required}" +: "${NOTIFY_BOT_TOKEN:?NOTIFY_BOT_TOKEN is required}" +: "${ADMIN_USER_ID:?ADMIN_USER_ID is required}" +: "${YONEXUS_SERVER_URL:?YONEXUS_SERVER_URL is required}" + +STATE_DIR=/app/.openclaw-state +PLUGIN_DIR="$STATE_DIR/plugins/Yonexus.Client" +TEST_PLUGIN_DIR="$STATE_DIR/plugins/yonexus-client-test" + +# Install plugin dist + manifest into isolated state directory +node /app/scripts/install.mjs --install --openclaw-profile-path "$STATE_DIR" +# Symlink node_modules so bare-module imports (e.g. ws) resolve from plugin dir +ln -sf /app/node_modules "$PLUGIN_DIR/node_modules" + +# Install test plugin (plain .mjs, no compilation needed) +mkdir -p "$TEST_PLUGIN_DIR" +cp /app/client-test-plugin/index.mjs "$TEST_PLUGIN_DIR/" +cp /app/client-test-plugin/openclaw.plugin.json "$TEST_PLUGIN_DIR/" + +# Write openclaw config — plugin id is "yonexus-client" per openclaw.plugin.json +mkdir -p "$STATE_DIR" +cat > "$STATE_DIR/openclaw.json" << EOF +{ + "meta": { "lastTouchedVersion": "2026.4.9" }, + "gateway": { "bind": "loopback" }, + "agents": { "defaults": { "workspace": "$STATE_DIR/workspace" } }, + "plugins": { + "allow": ["yonexus-client", "yonexus-client-test"], + "load": { "paths": ["$PLUGIN_DIR", "$TEST_PLUGIN_DIR"] }, + "installs": { + "yonexus-client": { + "source": "path", + "sourcePath": "$PLUGIN_DIR", + "installPath": "$PLUGIN_DIR", + "version": "0.1.0", + "installedAt": "2026-04-10T00:00:00.000Z" + }, + "yonexus-client-test": { + "source": "path", + "sourcePath": "$TEST_PLUGIN_DIR", + "installPath": "$TEST_PLUGIN_DIR", + "version": "0.1.0", + "installedAt": "2026-04-10T00:00:00.000Z" + } + }, + "entries": { + "yonexus-client": { + "enabled": true, + "config": { + "mainHost": "$YONEXUS_SERVER_URL", + "identifier": "$IDENTIFIER", + "notifyBotToken": "$NOTIFY_BOT_TOKEN", + "adminUserId": "$ADMIN_USER_ID" + } + }, + "yonexus-client-test": { + "enabled": true, + "config": {} + } + } + } +} +EOF + +export OPENCLAW_STATE_DIR="$STATE_DIR" +exec openclaw gateway run --allow-unconfigured diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml new file mode 100644 index 0000000..5124a63 --- /dev/null +++ b/tests/docker/docker-compose.yml @@ -0,0 +1,46 @@ +services: + yonexus-server: + build: + context: ../.. + dockerfile: tests/docker/server/Dockerfile + environment: + # Identifier the client will use — must match IDENTIFIER on the client side + CLIENT_IDENTIFIER: test-client + # Required: Discord bot token and admin user ID for pairing notifications + NOTIFY_BOT_TOKEN: ${NOTIFY_BOT_TOKEN} + ADMIN_USER_ID: ${ADMIN_USER_ID} + # Optional: override the publicWsUrl advertised to clients + # PUBLIC_WS_URL: ws://yonexus-server:8787 + networks: + - yonexus-net + healthcheck: + # Wait until the Yonexus WebSocket port is accepting connections + test: + - CMD + - node + - -e + - "require('net').createConnection({port:8787,host:'127.0.0.1'}).on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))" + interval: 5s + timeout: 3s + retries: 12 + start_period: 15s + + yonexus-client: + build: + context: ../.. + dockerfile: tests/docker/client/Dockerfile + environment: + # Must match CLIENT_IDENTIFIER on the server side + IDENTIFIER: test-client + NOTIFY_BOT_TOKEN: ${NOTIFY_BOT_TOKEN} + ADMIN_USER_ID: ${ADMIN_USER_ID} + YONEXUS_SERVER_URL: ws://yonexus-server:8787 + networks: + - yonexus-net + depends_on: + yonexus-server: + condition: service_healthy + +networks: + yonexus-net: + driver: bridge diff --git a/tests/docker/server-test-plugin/index.mjs b/tests/docker/server-test-plugin/index.mjs new file mode 100644 index 0000000..1df9ad8 --- /dev/null +++ b/tests/docker/server-test-plugin/index.mjs @@ -0,0 +1,39 @@ +// Singleton guard — openclaw calls register() twice per process +let _registered = false; + +export default function register(_api) { + if (_registered) return; + _registered = true; + + const server = globalThis.__yonexusServer; + if (!server) { + console.error('[server-test] __yonexusServer not on globalThis — ensure Yonexus.Server loads first'); + return; + } + + console.log('[server-test] __yonexusServer available, keys:', Object.keys(server)); + + // Register test_ping rule + // Received format (rewritten by server): test_ping:::: + server.ruleRegistry.registerRule('test_ping', (raw) => { + const firstSep = raw.indexOf('::'); + const rest = raw.slice(firstSep + 2); + const secondSep = rest.indexOf('::'); + const sender = rest.slice(0, secondSep); + const content = rest.slice(secondSep + 2); + console.log(`[server-test] MATCH test_ping from="${sender}" content="${content}"`); + // Echo back to sender via test_pong + const sent = server.sendRule(sender, 'test_pong', `echo-${content}`); + console.log(`[server-test] echo sent=${sent}`); + }); + + // When a client authenticates, send one matching and one non-matching rule message + server.onClientAuthenticated.push((identifier) => { + console.log(`[server-test] Client "${identifier}" authenticated — sending test_pong + other_rule`); + const s1 = server.sendRule(identifier, 'test_pong', 'welcome-from-server'); + const s2 = server.sendRule(identifier, 'other_rule', 'other-from-server'); + console.log(`[server-test] sendRule results: test_pong=${s1} other_rule=${s2}`); + }); + + console.log('[server-test] registered test_ping rule and onClientAuthenticated callback'); +} diff --git a/tests/docker/server-test-plugin/openclaw.plugin.json b/tests/docker/server-test-plugin/openclaw.plugin.json new file mode 100644 index 0000000..70f4e85 --- /dev/null +++ b/tests/docker/server-test-plugin/openclaw.plugin.json @@ -0,0 +1,13 @@ +{ + "id": "yonexus-server-test", + "name": "Yonexus Server Test Plugin", + "version": "0.1.0", + "description": "Test plugin for Yonexus.Server rule routing", + "entry": "./index.mjs", + "permissions": [], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/tests/docker/server/Dockerfile b/tests/docker/server/Dockerfile new file mode 100644 index 0000000..54e0d56 --- /dev/null +++ b/tests/docker/server/Dockerfile @@ -0,0 +1,40 @@ +# Build context: repo root (Yonexus/) +# ── Stage 1: compile ────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder + +WORKDIR /build + +# Server imports Yonexus.Protocol and Yonexus.Client/crypto — all needed for tsc +COPY Yonexus.Protocol/src ./Yonexus.Protocol/src +COPY Yonexus.Client/plugin/crypto ./Yonexus.Client/plugin/crypto + +COPY Yonexus.Server/package.json ./Yonexus.Server/ +COPY Yonexus.Server/package-lock.json ./Yonexus.Server/ +COPY Yonexus.Server/tsconfig.json ./Yonexus.Server/ +COPY Yonexus.Server/plugin ./Yonexus.Server/plugin + +WORKDIR /build/Yonexus.Server +RUN npm ci +RUN npm run build + +# ── Stage 2: runtime ───────────────────────────────────────────────────────── +FROM node:22-alpine AS runtime + +RUN npm install -g openclaw@2026.4.9 + +WORKDIR /app + +# Layout expected by install.mjs: repoRoot = /app, sourceDist = /app/dist +COPY --from=builder /build/Yonexus.Server/dist ./dist +COPY --from=builder /build/Yonexus.Server/node_modules ./node_modules +COPY Yonexus.Server/package.json ./package.json +COPY Yonexus.Server/plugin/openclaw.plugin.json ./plugin/openclaw.plugin.json +COPY Yonexus.Server/scripts/install.mjs ./scripts/install.mjs + +COPY tests/docker/server-test-plugin /app/server-test-plugin +COPY tests/docker/server/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8787 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/docker/server/entrypoint.sh b/tests/docker/server/entrypoint.sh new file mode 100644 index 0000000..e0a58a6 --- /dev/null +++ b/tests/docker/server/entrypoint.sh @@ -0,0 +1,71 @@ +#!/bin/sh +set -e + +: "${CLIENT_IDENTIFIER:?CLIENT_IDENTIFIER is required}" +: "${NOTIFY_BOT_TOKEN:?NOTIFY_BOT_TOKEN is required}" +: "${ADMIN_USER_ID:?ADMIN_USER_ID is required}" + +STATE_DIR=/app/.openclaw-state +PLUGIN_DIR="$STATE_DIR/plugins/Yonexus.Server" +TEST_PLUGIN_DIR="$STATE_DIR/plugins/yonexus-server-test" +SERVER_WS_URL="${PUBLIC_WS_URL:-ws://yonexus-server:8787}" + +# Install plugin dist + manifest into isolated state directory +node /app/scripts/install.mjs --install --openclaw-profile-path "$STATE_DIR" +# Symlink node_modules so bare-module imports (e.g. ws) resolve from plugin dir +ln -sf /app/node_modules "$PLUGIN_DIR/node_modules" + +# Install test plugin (plain .mjs, no compilation needed) +mkdir -p "$TEST_PLUGIN_DIR" +cp /app/server-test-plugin/index.mjs "$TEST_PLUGIN_DIR/" +cp /app/server-test-plugin/openclaw.plugin.json "$TEST_PLUGIN_DIR/" + +# Write openclaw config — plugin id is "yonexus-server" per openclaw.plugin.json +mkdir -p "$STATE_DIR" +cat > "$STATE_DIR/openclaw.json" << EOF +{ + "meta": { "lastTouchedVersion": "2026.4.9" }, + "gateway": { "bind": "loopback" }, + "agents": { "defaults": { "workspace": "$STATE_DIR/workspace" } }, + "plugins": { + "allow": ["yonexus-server", "yonexus-server-test"], + "load": { "paths": ["$PLUGIN_DIR", "$TEST_PLUGIN_DIR"] }, + "installs": { + "yonexus-server": { + "source": "path", + "sourcePath": "$PLUGIN_DIR", + "installPath": "$PLUGIN_DIR", + "version": "0.1.0", + "installedAt": "2026-04-10T00:00:00.000Z" + }, + "yonexus-server-test": { + "source": "path", + "sourcePath": "$TEST_PLUGIN_DIR", + "installPath": "$TEST_PLUGIN_DIR", + "version": "0.1.0", + "installedAt": "2026-04-10T00:00:00.000Z" + } + }, + "entries": { + "yonexus-server": { + "enabled": true, + "config": { + "followerIdentifiers": ["$CLIENT_IDENTIFIER"], + "notifyBotToken": "$NOTIFY_BOT_TOKEN", + "adminUserId": "$ADMIN_USER_ID", + "listenHost": "0.0.0.0", + "listenPort": 8787, + "publicWsUrl": "$SERVER_WS_URL" + } + }, + "yonexus-server-test": { + "enabled": true, + "config": {} + } + } + } +} +EOF + +export OPENCLAW_STATE_DIR="$STATE_DIR" +exec openclaw gateway run --allow-unconfigured