rol-websocket-channel 1.4.9 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,28 +69,55 @@ async function exchangePairKey(key, endpoint, authOverride, existingMqttUrl) {
69
69
  }
70
70
  function normalizePairingPayload(raw, endpoint, existingMqttUrl) {
71
71
  const root = unwrapPayload(raw);
72
- const pluginId = pickString(root.pluginId) ?? DEFAULT_PLUGIN_ID;
73
- const apiCoreBotValue = isRecord(root.apiCoreBot) ? root.apiCoreBot : {};
74
- const channelValue = isRecord(root.channel) ? root.channel : {};
72
+ const pluginId = pickString(root.pluginId) ?? pickString(root.plugin_id) ?? DEFAULT_PLUGIN_ID;
73
+ const apiCoreBotValue = pickRecord(root.apiCoreBot, root.api_core_bot);
74
+ const channelValue = pickRecord(root.channel);
75
+ const channelConfigValue = pickRecord(channelValue.config);
75
76
  const apiCoreBotAuthToken = pickString(apiCoreBotValue.authToken)
77
+ ?? pickString(apiCoreBotValue.auth_token)
76
78
  ?? pickString(root.authToken)
79
+ ?? pickString(root.auth_token)
77
80
  ?? pickString(root.token);
78
- const rawValue = pickString(root.value);
81
+ const rawValue = pickString(root.value)
82
+ ?? pickString(root.rawValue)
83
+ ?? pickString(root.raw_value);
79
84
  const mqttUrl = pickString(channelValue.mqttUrl)
85
+ ?? pickString(channelValue.mqtt_url)
86
+ ?? pickString(channelConfigValue.mqttUrl)
87
+ ?? pickString(channelConfigValue.mqtt_url)
80
88
  ?? pickString(root.mqttUrl)
89
+ ?? pickString(root.mqtt_url)
81
90
  ?? existingMqttUrl
82
91
  ?? null;
83
92
  if (!mqttUrl) {
84
- throwPairingError('PAIR_CHANNEL_CONFIG_INVALID', 'mqttUrl is missing from pairing payload');
93
+ throwPairingError('PAIR_CHANNEL_CONFIG_INVALID', 'mqttUrl is missing from pairing payload', {
94
+ endpoint,
95
+ rootKeys: Object.keys(root),
96
+ channelKeys: Object.keys(channelValue),
97
+ channelConfigKeys: Object.keys(channelConfigValue),
98
+ hasExistingMqttUrl: Boolean(existingMqttUrl)
99
+ });
85
100
  }
86
101
  const mqttTopic = pickString(channelValue.mqttTopic)
102
+ ?? pickString(channelValue.mqtt_topic)
103
+ ?? pickString(channelConfigValue.mqttTopic)
104
+ ?? pickString(channelConfigValue.mqtt_topic)
87
105
  ?? pickString(root.mqttTopic)
106
+ ?? pickString(root.mqtt_topic)
88
107
  ?? rawValue
89
108
  ?? 'announcement/tester';
90
109
  const groupPolicy = normalizeGroupPolicy(pickString(channelValue.groupPolicy)
110
+ ?? pickString(channelValue.group_policy)
91
111
  ?? pickString(channelValue.dmPolicy)
112
+ ?? pickString(channelValue.dm_policy)
113
+ ?? pickString(channelConfigValue.groupPolicy)
114
+ ?? pickString(channelConfigValue.group_policy)
115
+ ?? pickString(channelConfigValue.dmPolicy)
116
+ ?? pickString(channelConfigValue.dm_policy)
92
117
  ?? pickString(root.groupPolicy)
118
+ ?? pickString(root.group_policy)
93
119
  ?? pickString(root.dmPolicy)
120
+ ?? pickString(root.dm_policy)
94
121
  ?? 'pairing');
95
122
  return {
96
123
  pluginId,
@@ -98,7 +125,7 @@ function normalizePairingPayload(raw, endpoint, existingMqttUrl) {
98
125
  ...(rawValue ? { rawValue } : {})
99
126
  },
100
127
  apiCoreBot: {
101
- baseUrl: pickString(apiCoreBotValue.baseUrl) ?? inferBaseUrl(endpoint),
128
+ baseUrl: pickString(apiCoreBotValue.baseUrl) ?? pickString(apiCoreBotValue.base_url) ?? inferBaseUrl(endpoint),
102
129
  ...(apiCoreBotAuthToken ? { authToken: apiCoreBotAuthToken } : {})
103
130
  },
104
131
  channel: {
@@ -260,9 +287,20 @@ function pickString(value) {
260
287
  const trimmed = value.trim();
261
288
  return trimmed.length > 0 ? trimmed : null;
262
289
  }
290
+ function pickRecord(...values) {
291
+ for (const value of values) {
292
+ if (isRecord(value)) {
293
+ return value;
294
+ }
295
+ }
296
+ return {};
297
+ }
263
298
  function isRecord(value) {
264
299
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
265
300
  }
266
- function throwPairingError(code, message) {
267
- throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, message, { code });
301
+ function throwPairingError(code, message, debug) {
302
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, message, {
303
+ code,
304
+ ...(debug ? { debug } : {})
305
+ });
268
306
  }
@@ -0,0 +1,114 @@
1
+ import { afterEach, describe, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import http from 'node:http';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { pairWithKey } from './pairing.js';
8
+ import { JsonRpcException } from '../jsonrpc.js';
9
+ const tempDirs = [];
10
+ const servers = [];
11
+ afterEach(async () => {
12
+ while (servers.length > 0) {
13
+ const server = servers.pop();
14
+ if (server) {
15
+ await new Promise((resolve, reject) => {
16
+ server.close((error) => error ? reject(error) : resolve());
17
+ });
18
+ }
19
+ }
20
+ while (tempDirs.length > 0) {
21
+ const dir = tempDirs.pop();
22
+ if (dir) {
23
+ await fs.rm(dir, { recursive: true, force: true });
24
+ }
25
+ }
26
+ });
27
+ describe('pairWithKey payload compatibility', () => {
28
+ test('accepts snake_case channel config from the pairing endpoint', async () => {
29
+ const context = await createMethodContext();
30
+ const endpoint = await createPairingEndpoint({
31
+ data: {
32
+ plugin_id: 'rol-websocket-channel',
33
+ api_core_bot: {
34
+ base_url: 'https://api.example.test',
35
+ auth_token: 'secret-token'
36
+ },
37
+ value: 'announcement/user/agent/#',
38
+ channel: {
39
+ config: {
40
+ mqtt_url: 'ws://mqtt.example.test:8083/mqtt',
41
+ mqtt_topic: 'announcement/user/agent/#',
42
+ group_policy: 'pairing'
43
+ }
44
+ }
45
+ }
46
+ });
47
+ const result = await pairWithKey({ key: 'abc123456', endpoint }, context);
48
+ const savedConfig = JSON.parse(await fs.readFile(path.join(context.openclawRoot, 'openclaw.json'), 'utf8'));
49
+ assert.equal(result.ok, true);
50
+ assert.equal(result.channel.mqttUrl, 'ws://mqtt.example.test:8083/mqtt');
51
+ assert.equal(result.channel.mqttTopic, 'announcement/user/agent/#');
52
+ assert.equal(result.channel.groupPolicy, 'pairing');
53
+ assert.deepEqual(savedConfig.plugins.allow, ['rol-websocket-channel']);
54
+ assert.equal(savedConfig.channels['rol-websocket-channel'].config.mqttUrl, 'ws://mqtt.example.test:8083/mqtt');
55
+ assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.authToken, 'secret-token');
56
+ });
57
+ test('reports payload keys when mqttUrl is missing', async () => {
58
+ const context = await createMethodContext();
59
+ const endpoint = await createPairingEndpoint({
60
+ data: {
61
+ plugin_id: 'rol-websocket-channel',
62
+ channel: {
63
+ config: {
64
+ mqtt_topic: 'announcement/user/agent/#'
65
+ }
66
+ }
67
+ }
68
+ });
69
+ await assert.rejects(() => pairWithKey({ key: 'abc123456', endpoint }, context), (error) => {
70
+ assert.ok(error instanceof JsonRpcException);
71
+ assert.equal(error.message, 'mqttUrl is missing from pairing payload');
72
+ assert.deepEqual(error.data, {
73
+ code: 'PAIR_CHANNEL_CONFIG_INVALID',
74
+ debug: {
75
+ endpoint,
76
+ rootKeys: ['plugin_id', 'channel'],
77
+ channelKeys: ['config'],
78
+ channelConfigKeys: ['mqtt_topic'],
79
+ hasExistingMqttUrl: false
80
+ }
81
+ });
82
+ return true;
83
+ });
84
+ });
85
+ });
86
+ async function createMethodContext() {
87
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rol-pairing-'));
88
+ tempDirs.push(dir);
89
+ await fs.writeFile(path.join(dir, 'openclaw.json'), '{}\n', 'utf8');
90
+ return {
91
+ projectRoot: dir,
92
+ openclawRoot: dir
93
+ };
94
+ }
95
+ async function createPairingEndpoint(payload) {
96
+ const server = http.createServer(async (request, response) => {
97
+ if (request.method !== 'POST') {
98
+ response.writeHead(405).end();
99
+ return;
100
+ }
101
+ for await (const _chunk of request) {
102
+ // Drain request body so Node can close the connection cleanly.
103
+ }
104
+ response.writeHead(200, { 'Content-Type': 'application/json' });
105
+ response.end(JSON.stringify(payload));
106
+ });
107
+ await new Promise((resolve) => {
108
+ server.listen(0, '127.0.0.1', resolve);
109
+ });
110
+ servers.push(server);
111
+ const address = server.address();
112
+ assert.ok(address && typeof address === 'object');
113
+ return `http://127.0.0.1:${address.port}/pair`;
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rol-websocket-channel",
3
- "version": "1.4.9",
3
+ "version": "1.5.1",
4
4
  "description": "Unified OpenClaw plugin: MQTT Channel + Admin Bridge for remote management",
5
5
  "license": "MIT",
6
6
  "author": "nixgnehc",
@@ -0,0 +1,149 @@
1
+ import { afterEach, describe, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import http from 'node:http';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ import { pairWithKey } from './pairing.js';
9
+ import { JsonRpcException } from '../jsonrpc.js';
10
+ import type { MethodContext } from '../types.js';
11
+
12
+ const tempDirs: string[] = [];
13
+ const servers: http.Server[] = [];
14
+
15
+ afterEach(async () => {
16
+ while (servers.length > 0) {
17
+ const server = servers.pop();
18
+ if (server) {
19
+ await new Promise<void>((resolve, reject) => {
20
+ server.close((error) => error ? reject(error) : resolve());
21
+ });
22
+ }
23
+ }
24
+
25
+ while (tempDirs.length > 0) {
26
+ const dir = tempDirs.pop();
27
+ if (dir) {
28
+ await fs.rm(dir, { recursive: true, force: true });
29
+ }
30
+ }
31
+ });
32
+
33
+ describe('pairWithKey payload compatibility', () => {
34
+ test('accepts snake_case channel config from the pairing endpoint', async () => {
35
+ const context = await createMethodContext();
36
+ const endpoint = await createPairingEndpoint({
37
+ data: {
38
+ plugin_id: 'rol-websocket-channel',
39
+ api_core_bot: {
40
+ base_url: 'https://api.example.test',
41
+ auth_token: 'secret-token'
42
+ },
43
+ value: 'announcement/user/agent/#',
44
+ channel: {
45
+ config: {
46
+ mqtt_url: 'ws://mqtt.example.test:8083/mqtt',
47
+ mqtt_topic: 'announcement/user/agent/#',
48
+ group_policy: 'pairing'
49
+ }
50
+ }
51
+ }
52
+ });
53
+
54
+ const result = await pairWithKey({ key: 'abc123456', endpoint }, context) as {
55
+ ok: boolean;
56
+ channel: {
57
+ mqttUrl: string;
58
+ mqttTopic: string;
59
+ groupPolicy: string;
60
+ };
61
+ };
62
+ const savedConfig = JSON.parse(
63
+ await fs.readFile(path.join(context.openclawRoot, 'openclaw.json'), 'utf8')
64
+ );
65
+
66
+ assert.equal(result.ok, true);
67
+ assert.equal(result.channel.mqttUrl, 'ws://mqtt.example.test:8083/mqtt');
68
+ assert.equal(result.channel.mqttTopic, 'announcement/user/agent/#');
69
+ assert.equal(result.channel.groupPolicy, 'pairing');
70
+ assert.deepEqual(savedConfig.plugins.allow, ['rol-websocket-channel']);
71
+ assert.equal(
72
+ savedConfig.channels['rol-websocket-channel'].config.mqttUrl,
73
+ 'ws://mqtt.example.test:8083/mqtt'
74
+ );
75
+ assert.equal(
76
+ savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.authToken,
77
+ 'secret-token'
78
+ );
79
+ });
80
+
81
+ test('reports payload keys when mqttUrl is missing', async () => {
82
+ const context = await createMethodContext();
83
+ const endpoint = await createPairingEndpoint({
84
+ data: {
85
+ plugin_id: 'rol-websocket-channel',
86
+ channel: {
87
+ config: {
88
+ mqtt_topic: 'announcement/user/agent/#'
89
+ }
90
+ }
91
+ }
92
+ });
93
+
94
+ await assert.rejects(
95
+ () => pairWithKey({ key: 'abc123456', endpoint }, context),
96
+ (error: unknown) => {
97
+ assert.ok(error instanceof JsonRpcException);
98
+ assert.equal(error.message, 'mqttUrl is missing from pairing payload');
99
+ assert.deepEqual(error.data, {
100
+ code: 'PAIR_CHANNEL_CONFIG_INVALID',
101
+ debug: {
102
+ endpoint,
103
+ rootKeys: ['plugin_id', 'channel'],
104
+ channelKeys: ['config'],
105
+ channelConfigKeys: ['mqtt_topic'],
106
+ hasExistingMqttUrl: false
107
+ }
108
+ });
109
+ return true;
110
+ }
111
+ );
112
+ });
113
+ });
114
+
115
+ async function createMethodContext(): Promise<MethodContext> {
116
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rol-pairing-'));
117
+ tempDirs.push(dir);
118
+ await fs.writeFile(path.join(dir, 'openclaw.json'), '{}\n', 'utf8');
119
+ return {
120
+ projectRoot: dir,
121
+ openclawRoot: dir
122
+ };
123
+ }
124
+
125
+ async function createPairingEndpoint(payload: unknown): Promise<string> {
126
+ const server = http.createServer(async (request, response) => {
127
+ if (request.method !== 'POST') {
128
+ response.writeHead(405).end();
129
+ return;
130
+ }
131
+
132
+ for await (const _chunk of request) {
133
+ // Drain request body so Node can close the connection cleanly.
134
+ }
135
+
136
+ response.writeHead(200, { 'Content-Type': 'application/json' });
137
+ response.end(JSON.stringify(payload));
138
+ });
139
+
140
+ await new Promise<void>((resolve) => {
141
+ server.listen(0, '127.0.0.1', resolve);
142
+ });
143
+ servers.push(server);
144
+
145
+ const address = server.address();
146
+ assert.ok(address && typeof address === 'object');
147
+
148
+ return `http://127.0.0.1:${address.port}/pair`;
149
+ }
@@ -46,6 +46,14 @@ interface PairingPayload {
46
46
  };
47
47
  }
48
48
 
49
+ interface PairingPayloadDebug {
50
+ endpoint: string;
51
+ rootKeys: string[];
52
+ channelKeys: string[];
53
+ channelConfigKeys: string[];
54
+ hasExistingMqttUrl: boolean;
55
+ }
56
+
49
57
  export async function pairWithKey(
50
58
  options: PairingCommandOptions,
51
59
  context: MethodContext
@@ -134,33 +142,60 @@ function normalizePairingPayload(
134
142
  existingMqttUrl?: string | null
135
143
  ): PairingPayload {
136
144
  const root = unwrapPayload(raw);
137
- const pluginId = pickString(root.pluginId) ?? DEFAULT_PLUGIN_ID;
138
- const apiCoreBotValue = isRecord(root.apiCoreBot) ? root.apiCoreBot : {};
139
- const channelValue = isRecord(root.channel) ? root.channel : {};
145
+ const pluginId = pickString(root.pluginId) ?? pickString(root.plugin_id) ?? DEFAULT_PLUGIN_ID;
146
+ const apiCoreBotValue = pickRecord(root.apiCoreBot, root.api_core_bot);
147
+ const channelValue = pickRecord(root.channel);
148
+ const channelConfigValue = pickRecord(channelValue.config);
140
149
 
141
150
  const apiCoreBotAuthToken = pickString(apiCoreBotValue.authToken)
151
+ ?? pickString(apiCoreBotValue.auth_token)
142
152
  ?? pickString(root.authToken)
153
+ ?? pickString(root.auth_token)
143
154
  ?? pickString(root.token);
144
- const rawValue = pickString(root.value);
155
+ const rawValue = pickString(root.value)
156
+ ?? pickString(root.rawValue)
157
+ ?? pickString(root.raw_value);
145
158
 
146
159
  const mqttUrl = pickString(channelValue.mqttUrl)
160
+ ?? pickString(channelValue.mqtt_url)
161
+ ?? pickString(channelConfigValue.mqttUrl)
162
+ ?? pickString(channelConfigValue.mqtt_url)
147
163
  ?? pickString(root.mqttUrl)
164
+ ?? pickString(root.mqtt_url)
148
165
  ?? existingMqttUrl
149
166
  ?? null;
150
167
  if (!mqttUrl) {
151
- throwPairingError('PAIR_CHANNEL_CONFIG_INVALID', 'mqttUrl is missing from pairing payload');
168
+ throwPairingError('PAIR_CHANNEL_CONFIG_INVALID', 'mqttUrl is missing from pairing payload', {
169
+ endpoint,
170
+ rootKeys: Object.keys(root),
171
+ channelKeys: Object.keys(channelValue),
172
+ channelConfigKeys: Object.keys(channelConfigValue),
173
+ hasExistingMqttUrl: Boolean(existingMqttUrl)
174
+ });
152
175
  }
153
176
 
154
177
  const mqttTopic = pickString(channelValue.mqttTopic)
178
+ ?? pickString(channelValue.mqtt_topic)
179
+ ?? pickString(channelConfigValue.mqttTopic)
180
+ ?? pickString(channelConfigValue.mqtt_topic)
155
181
  ?? pickString(root.mqttTopic)
182
+ ?? pickString(root.mqtt_topic)
156
183
  ?? rawValue
157
184
  ?? 'announcement/tester';
158
185
 
159
186
  const groupPolicy = normalizeGroupPolicy(
160
187
  pickString(channelValue.groupPolicy)
188
+ ?? pickString(channelValue.group_policy)
161
189
  ?? pickString(channelValue.dmPolicy)
190
+ ?? pickString(channelValue.dm_policy)
191
+ ?? pickString(channelConfigValue.groupPolicy)
192
+ ?? pickString(channelConfigValue.group_policy)
193
+ ?? pickString(channelConfigValue.dmPolicy)
194
+ ?? pickString(channelConfigValue.dm_policy)
162
195
  ?? pickString(root.groupPolicy)
196
+ ?? pickString(root.group_policy)
163
197
  ?? pickString(root.dmPolicy)
198
+ ?? pickString(root.dm_policy)
164
199
  ?? 'pairing'
165
200
  );
166
201
 
@@ -170,7 +205,7 @@ function normalizePairingPayload(
170
205
  ...(rawValue ? { rawValue } : {})
171
206
  },
172
207
  apiCoreBot: {
173
- baseUrl: pickString(apiCoreBotValue.baseUrl) ?? inferBaseUrl(endpoint),
208
+ baseUrl: pickString(apiCoreBotValue.baseUrl) ?? pickString(apiCoreBotValue.base_url) ?? inferBaseUrl(endpoint),
174
209
  ...(apiCoreBotAuthToken ? { authToken: apiCoreBotAuthToken } : {})
175
210
  },
176
211
  channel: {
@@ -352,10 +387,23 @@ function pickString(value: unknown): string | null {
352
387
  return trimmed.length > 0 ? trimmed : null;
353
388
  }
354
389
 
390
+ function pickRecord(...values: unknown[]): Record<string, any> {
391
+ for (const value of values) {
392
+ if (isRecord(value)) {
393
+ return value;
394
+ }
395
+ }
396
+
397
+ return {};
398
+ }
399
+
355
400
  function isRecord(value: unknown): value is Record<string, any> {
356
401
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
357
402
  }
358
403
 
359
- function throwPairingError(code: string, message: string): never {
360
- throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, message, { code });
404
+ function throwPairingError(code: string, message: string, debug?: PairingPayloadDebug): never {
405
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, message, {
406
+ code,
407
+ ...(debug ? { debug } : {})
408
+ });
361
409
  }