@omnixal/openclaw-nats-plugin 0.2.11 → 0.2.13

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.
package/PLUGIN.md CHANGED
@@ -94,6 +94,8 @@ Environment variables (auto-configured by setup):
94
94
  - `NATS_SIDECAR_URL` — Sidecar URL (default: `http://127.0.0.1:3104`)
95
95
  - `NATS_PLUGIN_API_KEY` — API key for sidecar auth (auto-generated)
96
96
  - `NATS_SERVERS` — NATS server URL (default: `nats://127.0.0.1:4222`)
97
+ - `OPENCLAW_GATEWAY_URL` — Gateway HTTP URL (default: `http://127.0.0.1:18789`)
98
+ - `OPENCLAW_HOOK_TOKEN` — Webhook token for event delivery to agent session (from `hooks.token` in gateway config)
97
99
 
98
100
  ## Requirements
99
101
 
package/cli/bun-setup.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  } from './paths';
8
8
  import { downloadNatsServer, NATS_VERSION } from './download-nats';
9
9
  import { writeNatsConfig } from './nats-config';
10
- import { generateApiKey, getExistingApiKey, writeEnvVariables } from './env-writer';
10
+ import { generateApiKey, getExistingApiKey, getExistingHookToken, writeEnvVariables } from './env-writer';
11
11
  import {
12
12
  getServiceManager, generateSystemdUnit, generateLaunchdPlist,
13
13
  installSystemdUnit, installLaunchdPlist, startService, stopService,
@@ -48,24 +48,27 @@ export async function bunSetup(): Promise<void> {
48
48
 
49
49
  // 6. Reuse existing API key or generate new one
50
50
  const apiKey = getExistingApiKey() ?? generateApiKey();
51
+ const hookToken = getExistingHookToken() ?? generateApiKey();
51
52
 
52
53
  // 7. Write env variables (to OpenClaw .env for hooks, and sidecar .env for the service)
53
54
  const envVars: Record<string, string> = {
54
55
  NATS_SIDECAR_URL: 'http://127.0.0.1:3104',
55
56
  NATS_PLUGIN_API_KEY: apiKey,
56
57
  NATS_SERVERS: 'nats://127.0.0.1:4222',
57
- OPENCLAW_WS_URL: 'ws://127.0.0.1:18789',
58
+ OPENCLAW_GATEWAY_URL: 'http://127.0.0.1:18789',
59
+ OPENCLAW_HOOK_TOKEN: hookToken,
58
60
  };
59
61
  writeEnvVariables(envVars);
60
62
 
61
63
  // Write .env into sidecar dir so loadDotEnv picks it up
62
- // Explicit localhost values override any container-level env (e.g. OPENCLAW_WS_URL=ws://openclaw:...)
64
+ // Explicit localhost values override any container-level env
63
65
  const sidecarEnv = [
64
66
  `PORT=3104`,
65
67
  `DB_PATH=${join(DATA_DIR, 'nats-sidecar.db')}`,
66
68
  `NATS_SERVERS=nats://127.0.0.1:4222`,
67
69
  `NATS_PLUGIN_API_KEY=${apiKey}`,
68
- `OPENCLAW_WS_URL=ws://127.0.0.1:18789`,
70
+ `OPENCLAW_GATEWAY_URL=http://127.0.0.1:18789`,
71
+ `OPENCLAW_HOOK_TOKEN=${hookToken}`,
69
72
  ].join('\n');
70
73
  writeFileSync(join(SIDECAR_DIR, '.env'), sidecarEnv, 'utf-8');
71
74
 
@@ -2,7 +2,7 @@ import { mkdirSync, cpSync, writeFileSync, existsSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { PLUGIN_DIR, DOCKER_DIR, DASHBOARD_DIR, STATE_FILE, type PluginState } from './paths';
5
- import { generateApiKey, getExistingApiKey, writeEnvVariables } from './env-writer';
5
+ import { generateApiKey, getExistingApiKey, getExistingHookToken, writeEnvVariables } from './env-writer';
6
6
 
7
7
  /**
8
8
  * Try to copy dashboard dist into a running OpenClaw container's volume.
@@ -58,9 +58,10 @@ export async function dockerSetup(): Promise<void> {
58
58
  cpSync(templateDir, DOCKER_DIR, { recursive: true });
59
59
  cpSync(sidecarSrc, join(DOCKER_DIR, 'sidecar'), { recursive: true });
60
60
 
61
- // 2. Reuse existing API key or generate new one
61
+ // 2. Reuse existing API key and hook token or generate new ones
62
62
  const apiKey = getExistingApiKey() ?? generateApiKey();
63
- writeFileSync(join(DOCKER_DIR, '.env'), `NATS_PLUGIN_API_KEY=${apiKey}\n`);
63
+ const hookToken = getExistingHookToken() ?? generateApiKey();
64
+ writeFileSync(join(DOCKER_DIR, '.env'), `NATS_PLUGIN_API_KEY=${apiKey}\nOPENCLAW_HOOK_TOKEN=${hookToken}\n`);
64
65
 
65
66
  // 3. Build and start
66
67
  console.log('Building and starting containers...');
@@ -71,6 +72,8 @@ export async function dockerSetup(): Promise<void> {
71
72
  NATS_SIDECAR_URL: 'http://127.0.0.1:3104',
72
73
  NATS_PLUGIN_API_KEY: apiKey,
73
74
  NATS_SERVERS: 'nats://127.0.0.1:4222',
75
+ OPENCLAW_GATEWAY_URL: 'http://127.0.0.1:18789',
76
+ OPENCLAW_HOOK_TOKEN: hookToken,
74
77
  });
75
78
 
76
79
  // 5. Copy dashboard dist into OpenClaw container (host→container bridge)
package/cli/env-writer.ts CHANGED
@@ -14,6 +14,13 @@ export function getExistingApiKey(): string | null {
14
14
  return match?.[1]?.trim() || null;
15
15
  }
16
16
 
17
+ export function getExistingHookToken(): string | null {
18
+ if (!existsSync(OPENCLAW_ENV)) return null;
19
+ const content = readFileSync(OPENCLAW_ENV, 'utf-8');
20
+ const match = content.match(/^OPENCLAW_HOOK_TOKEN=(.+)$/m);
21
+ return match?.[1]?.trim() || null;
22
+ }
23
+
17
24
  export function mergeEnvContent(
18
25
  existingContent: string,
19
26
  variables: Record<string, string>,
@@ -29,8 +29,9 @@ services:
29
29
  - PORT=3104
30
30
  - DB_PATH=/app/data/nats-sidecar.db
31
31
  - NATS_SERVERS=nats://nats:4222
32
- - OPENCLAW_WS_URL=ws://host.docker.internal:18789
32
+ - OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789
33
33
  - NATS_PLUGIN_API_KEY=${NATS_PLUGIN_API_KEY}
34
+ - OPENCLAW_HOOK_TOKEN=${OPENCLAW_HOOK_TOKEN}
34
35
  volumes:
35
36
  - sidecar-data:/app/data
36
37
  restart: unless-stopped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnixal/openclaw-nats-plugin",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "NATS JetStream event-driven plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/sidecar/bun.lock CHANGED
@@ -10,6 +10,7 @@
10
10
  "@onebun/envs": "^0.2.2",
11
11
  "@onebun/logger": "^0.2.1",
12
12
  "@onebun/nats": "^0.2.6",
13
+ "@onebun/requests": "^0.2.1",
13
14
  "arktype": "^2.2.0",
14
15
  "ulid": "^2.3.0",
15
16
  },
@@ -11,7 +11,7 @@
11
11
  "typecheck": "bunx tsc --noEmit",
12
12
  "db:generate": "bunx onebun-drizzle generate",
13
13
  "db:push": "bunx onebun-drizzle push",
14
- "db:studio": "bunx onebun-drizzle studio",
14
+ "db:studio": "bunx onebun-drizzle studio"
15
15
  },
16
16
  "dependencies": {
17
17
  "@onebun/core": "^0.2.15",
@@ -19,6 +19,7 @@
19
19
  "@onebun/envs": "^0.2.2",
20
20
  "@onebun/logger": "^0.2.1",
21
21
  "@onebun/nats": "^0.2.6",
22
+ "@onebun/requests": "^0.2.1",
22
23
  "arktype": "^2.2.0",
23
24
  "ulid": "^2.3.0"
24
25
  },
@@ -16,8 +16,8 @@ export const envSchema = {
16
16
  maxReconnectAttempts: Env.number({ default: -1, env: 'NATS_MAX_RECONNECT_ATTEMPTS' }),
17
17
  },
18
18
  gateway: {
19
- wsUrl: Env.string({ default: 'ws://localhost:18789', env: 'OPENCLAW_WS_URL' }),
20
- token: Env.string({ default: '', env: 'OPENCLAW_DEVICE_TOKEN' }),
19
+ url: Env.string({ default: 'http://localhost:18789', env: 'OPENCLAW_GATEWAY_URL' }),
20
+ hookToken: Env.string({ default: '', env: 'OPENCLAW_HOOK_TOKEN' }),
21
21
  },
22
22
  consumer: {
23
23
  name: Env.string({ default: 'openclaw-main', env: 'NATS_CONSUMER_NAME' }),
@@ -58,7 +58,6 @@ export class ConsumerController extends BaseController {
58
58
  try {
59
59
  const injectStart = performance.now();
60
60
  await this.gatewayClient.inject({
61
- to: route.target,
62
61
  message: this.formatMessage(envelope),
63
62
  eventId: envelope.id,
64
63
  });
@@ -1,12 +1,8 @@
1
- import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
2
- import path from 'node:path';
3
- import { loadOrCreateIdentity, publicKeyToBase64Url, signChallenge, type DeviceIdentity } from './device-identity';
1
+ import { Service, BaseService, type OnModuleInit } from '@onebun/core';
2
+ import { HttpClient, isErrorResponse } from '@onebun/requests';
4
3
 
5
4
  export interface GatewayInjectPayload {
6
- /** OpenClaw session key or recipient address (maps to `to` in the send frame) */
7
- to: string;
8
5
  message: string;
9
- /** Internal tracking metadata — NOT sent to gateway (additionalProperties: false) */
10
6
  eventId?: string;
11
7
  }
12
8
 
@@ -21,304 +17,52 @@ export class GatewayRpcError extends Error {
21
17
  }
22
18
  }
23
19
 
24
- interface PendingRequest {
25
- resolve: () => void;
26
- reject: (err: Error) => void;
27
- timer: Timer;
28
- }
29
-
30
- const RPC_TIMEOUT_MS = 10_000;
31
-
32
20
  @Service()
33
- export class GatewayClientService extends BaseService implements OnModuleInit, OnModuleDestroy {
34
- private ws: WebSocket | null = null;
35
- private connected = false;
36
- private connectSent = false;
37
- private reconnectAttempt = 0;
38
- private reconnectTimer: Timer | null = null;
21
+ export class GatewayClientService extends BaseService implements OnModuleInit {
22
+ private client!: HttpClient;
23
+ private configured = false;
39
24
  private requestId = 0;
40
- private wsUrl!: string;
41
- private token!: string;
42
- private identity!: DeviceIdentity;
43
- private publicKeyBase64Url!: string;
44
- private challengeNonce = '';
45
- private pendingRequests = new Map<string, PendingRequest>();
46
25
 
47
26
  async onModuleInit(): Promise<void> {
48
- this.wsUrl = this.config.get('gateway.wsUrl');
49
- this.token = this.config.get('gateway.token');
50
- if (this.wsUrl && this.token) {
51
- const dbPath = this.config.get('database.url');
52
- const identityPath = path.join(path.dirname(dbPath), 'device-identity.json');
53
- this.identity = loadOrCreateIdentity(identityPath);
54
- this.publicKeyBase64Url = publicKeyToBase64Url(this.identity.publicKeyPem);
55
- this.logger.info('Device identity loaded', { deviceId: this.identity.deviceId });
56
- this.connect();
57
- } else {
58
- this.logger.warn('Gateway WebSocket not configured — skipping connection (need wsUrl + deviceToken)');
59
- }
60
- }
61
-
62
- private connect(): void {
63
- try {
64
- this.connectSent = false;
65
- this.ws = new WebSocket(this.wsUrl);
66
-
67
- this.ws.onopen = () => {
68
- this.reconnectAttempt = 0;
69
- this.logger.info('Gateway WebSocket opened, waiting for connect.challenge');
70
- };
71
-
72
- this.ws.onmessage = (event) => {
73
- this.handleMessage(String(event.data));
74
- };
75
-
76
- this.ws.onclose = () => {
77
- this.connected = false;
78
- this.connectSent = false;
79
- // Reject all in-flight requests immediately — don't make callers wait for timeout
80
- for (const [id, pending] of this.pendingRequests) {
81
- clearTimeout(pending.timer);
82
- pending.reject(new Error('Gateway WebSocket closed'));
83
- }
84
- this.pendingRequests.clear();
85
- this.scheduleReconnect();
86
- };
87
-
88
- this.ws.onerror = () => {
89
- this.logger.warn('Gateway WebSocket error');
90
- this.connected = false;
91
- };
92
- } catch (err) {
93
- this.logger.warn('Failed to connect to Gateway WebSocket', err);
94
- this.scheduleReconnect();
95
- }
96
- }
97
-
98
- private handleMessage(data: string): void {
99
- let frame: any;
100
- try {
101
- frame = JSON.parse(data);
102
- } catch {
103
- this.logger.warn(`Failed to parse WebSocket message: ${data.slice(0, 200)}`);
104
- return;
105
- }
106
-
107
- // Server challenge — respond with connect frame
108
- if (frame.type === 'event' && frame.event === 'connect.challenge') {
109
- this.challengeNonce = frame.payload?.nonce ?? '';
110
- this.logger.debug('Received connect.challenge from server', { hasNonce: !!this.challengeNonce });
111
- this.sendConnectFrame();
112
- return;
113
- }
114
-
115
- // Some gateway versions send an event before challenge; treat any pre-connect event as trigger
116
- if (!this.connectSent && frame.type === 'event') {
117
- this.logger.debug('Received event before connect sent, sending connect frame');
118
- this.sendConnectFrame();
119
- return;
120
- }
121
-
122
- // Successful connect response — must be hello-ok
123
- if (frame.type === 'res' && frame.ok === true) {
124
- const payload = frame.payload;
125
- if (payload?.type === 'hello-ok') {
126
- if (!this.connected) {
127
- this.connected = true;
128
- const grantedScopes = payload.auth?.scopes ?? [];
129
- const serverVersion = payload.server?.version ?? 'unknown';
130
- this.logger.info('OpenClaw handshake complete', {
131
- protocol: payload.protocol,
132
- serverVersion,
133
- grantedScopes,
134
- connId: payload.server?.connId,
135
- });
136
- if (grantedScopes.length > 0 && !grantedScopes.includes('operator.write')) {
137
- this.logger.error(
138
- `Gateway did NOT grant operator.write scope! Granted: [${grantedScopes.join(', ')}]. ` +
139
- 'Message delivery will fail. Rotate the device token with --scope operator.write',
140
- );
141
- }
142
- }
143
- return;
144
- }
145
- // Regular RPC ok response (e.g. for inject calls)
146
- this.logger.debug('Received RPC ok response', { id: frame.id });
147
- this.resolvePending(frame.id);
148
- return;
149
- }
150
-
151
- // Error response
152
- if (frame.type === 'res' && frame.ok === false) {
153
- const errorCode = frame.error?.code ?? frame.error?.errorCode ?? 'UNKNOWN';
154
- const errorMessage = frame.error?.message ?? frame.error?.errorMessage ?? 'Unknown gateway error';
155
- this.logger.error('Gateway RPC error', { id: frame.id, errorCode, errorMessage });
156
-
157
- // If this is a connect error, close and reconnect
158
- if (frame.id?.startsWith('connect-')) {
159
- this.logger.error(`Gateway rejected connection: ${errorCode} — ${errorMessage}`);
160
- this.connected = false;
161
- this.connectSent = false;
162
- this.ws?.close();
163
- return;
164
- }
165
-
166
- this.rejectPending(frame.id, new GatewayRpcError(frame.id, String(errorCode), String(errorMessage)));
167
- }
168
- }
169
-
170
- private sendConnectFrame(): void {
171
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connectSent) return;
172
- this.connectSent = true;
173
-
174
- const signedAt = Date.now();
175
- const scopes = ['operator.read', 'operator.write'];
176
- const client = {
177
- id: 'gateway-client' as const,
178
- displayName: 'nats-sidecar',
179
- version: '1.0.0',
180
- platform: 'linux',
181
- mode: 'backend' as const,
182
- };
183
-
184
- const signature = signChallenge(this.identity.privateKeyPem, {
185
- deviceId: this.identity.deviceId,
186
- clientId: client.id,
187
- clientMode: client.mode,
188
- role: 'operator',
189
- scopes,
190
- signedAtMs: signedAt,
191
- token: this.token,
192
- nonce: this.challengeNonce,
193
- platform: client.platform,
194
- });
195
-
196
- this.logger.info('Sending connect frame with device identity', {
197
- deviceId: this.identity.deviceId,
198
- });
199
-
200
- try {
201
- this.send({
202
- type: 'req',
203
- id: `connect-${++this.requestId}`,
204
- method: 'connect',
205
- params: {
206
- minProtocol: 3,
207
- maxProtocol: 3,
208
- client,
209
- role: 'operator',
210
- scopes,
211
- caps: [],
212
- commands: [],
213
- permissions: {},
214
- auth: { token: this.token },
215
- device: {
216
- id: this.identity.deviceId,
217
- publicKey: this.publicKeyBase64Url,
218
- signature,
219
- signedAt,
220
- nonce: this.challengeNonce,
221
- },
222
- locale: 'en-US',
223
- userAgent: 'nats-sidecar/1.0.0',
27
+ const gatewayUrl = this.config.get('gateway.url');
28
+ const hookToken = this.config.get('gateway.hookToken');
29
+
30
+ if (gatewayUrl && hookToken) {
31
+ this.client = new HttpClient({
32
+ baseUrl: gatewayUrl,
33
+ timeout: 10_000,
34
+ auth: { type: 'bearer', token: hookToken },
35
+ retries: {
36
+ max: 2,
37
+ backoff: 'exponential',
38
+ delay: 500,
39
+ retryOn: [502, 503, 504],
224
40
  },
225
41
  });
226
- } catch (err) {
227
- this.logger.error('Failed to send connect frame', err);
228
- this.connectSent = false;
229
- }
230
- }
231
-
232
- private send(frame: unknown): void {
233
- if (this.ws?.readyState !== WebSocket.OPEN) {
234
- throw new Error('WebSocket is not open');
235
- }
236
- try {
237
- this.ws.send(JSON.stringify(frame));
238
- } catch (err) {
239
- this.connected = false;
240
- throw new Error(`WebSocket send failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ this.configured = true;
43
+ this.logger.info('Gateway webhook configured', { url: gatewayUrl });
44
+ } else {
45
+ this.logger.warn('Gateway webhook not configured — need url + hookToken');
241
46
  }
242
47
  }
243
48
 
244
- private scheduleReconnect(): void {
245
- if (this.reconnectTimer) return;
246
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30_000);
247
- this.reconnectAttempt++;
248
- this.logger.debug(`Reconnecting to Gateway in ${delay}ms (attempt ${this.reconnectAttempt})`);
249
- this.reconnectTimer = setTimeout(() => {
250
- this.reconnectTimer = null;
251
- this.connect();
252
- }, delay);
253
- }
254
-
255
49
  async inject(payload: GatewayInjectPayload): Promise<void> {
256
- if (!this.isAlive()) {
257
- throw new Error('Gateway WebSocket not connected');
50
+ if (!this.configured) {
51
+ throw new Error('Gateway webhook not configured');
258
52
  }
259
53
  const id = `rpc-${++this.requestId}`;
260
- const promise = this.trackRequest(id);
261
- this.send({
262
- type: 'req',
263
- id,
264
- method: 'send',
265
- params: {
266
- to: payload.to,
267
- message: payload.message,
268
- idempotencyKey: payload.eventId ?? String(this.requestId),
269
- },
270
- });
271
- return promise;
272
- }
273
54
 
274
- private trackRequest(id: string): Promise<void> {
275
- return new Promise<void>((resolve, reject) => {
276
- const timer = setTimeout(() => {
277
- this.pendingRequests.delete(id);
278
- reject(new Error(`Gateway RPC timeout after ${RPC_TIMEOUT_MS}ms [${id}]`));
279
- }, RPC_TIMEOUT_MS);
280
- this.pendingRequests.set(id, { resolve, reject, timer });
55
+ const response = await this.client.post('/hooks/wake', {
56
+ text: payload.message,
57
+ mode: 'now',
281
58
  });
282
- }
283
-
284
- private resolvePending(id: string): void {
285
- const pending = this.pendingRequests.get(id);
286
- if (pending) {
287
- clearTimeout(pending.timer);
288
- this.pendingRequests.delete(id);
289
- pending.resolve();
290
- }
291
- }
292
59
 
293
- private rejectPending(id: string, err: Error): void {
294
- const pending = this.pendingRequests.get(id);
295
- if (pending) {
296
- clearTimeout(pending.timer);
297
- this.pendingRequests.delete(id);
298
- pending.reject(err);
60
+ if (isErrorResponse(response)) {
61
+ throw new GatewayRpcError(id, String(response.code), response.message ?? response.error);
299
62
  }
300
63
  }
301
64
 
302
65
  isAlive(): boolean {
303
- return this.connected && this.ws?.readyState === WebSocket.OPEN;
304
- }
305
-
306
- async onModuleDestroy(): Promise<void> {
307
- if (this.reconnectTimer) {
308
- clearTimeout(this.reconnectTimer);
309
- this.reconnectTimer = null;
310
- }
311
- // Reject all pending requests
312
- for (const [id, pending] of this.pendingRequests) {
313
- clearTimeout(pending.timer);
314
- pending.reject(new Error('Gateway client shutting down'));
315
- }
316
- this.pendingRequests.clear();
317
- if (this.ws) {
318
- this.ws.close();
319
- this.ws = null;
320
- }
321
- this.connected = false;
322
- this.connectSent = false;
66
+ return this.configured;
323
67
  }
324
68
  }
@@ -44,7 +44,7 @@ export class HealthService extends BaseService {
44
44
  },
45
45
  gateway: {
46
46
  connected: this.gateway.isAlive(),
47
- url: this.config.get('gateway.wsUrl'),
47
+ url: this.config.get('gateway.url'),
48
48
  },
49
49
  pendingCount,
50
50
  uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),