@omnixal/openclaw-nats-plugin 0.2.11 → 0.2.12
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 +2 -0
- package/cli/bun-setup.ts +3 -3
- package/docker/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/sidecar/bun.lock +1 -0
- package/sidecar/package.json +2 -1
- package/sidecar/src/config.ts +2 -2
- package/sidecar/src/consumer/consumer.controller.ts +0 -1
- package/sidecar/src/gateway/gateway-client.service.ts +30 -286
- package/sidecar/src/health/health.service.ts +1 -1
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
|
@@ -54,18 +54,18 @@ export async function bunSetup(): Promise<void> {
|
|
|
54
54
|
NATS_SIDECAR_URL: 'http://127.0.0.1:3104',
|
|
55
55
|
NATS_PLUGIN_API_KEY: apiKey,
|
|
56
56
|
NATS_SERVERS: 'nats://127.0.0.1:4222',
|
|
57
|
-
|
|
57
|
+
OPENCLAW_GATEWAY_URL: 'http://127.0.0.1:18789',
|
|
58
58
|
};
|
|
59
59
|
writeEnvVariables(envVars);
|
|
60
60
|
|
|
61
61
|
// Write .env into sidecar dir so loadDotEnv picks it up
|
|
62
|
-
// Explicit localhost values override any container-level env
|
|
62
|
+
// Explicit localhost values override any container-level env
|
|
63
63
|
const sidecarEnv = [
|
|
64
64
|
`PORT=3104`,
|
|
65
65
|
`DB_PATH=${join(DATA_DIR, 'nats-sidecar.db')}`,
|
|
66
66
|
`NATS_SERVERS=nats://127.0.0.1:4222`,
|
|
67
67
|
`NATS_PLUGIN_API_KEY=${apiKey}`,
|
|
68
|
-
`
|
|
68
|
+
`OPENCLAW_GATEWAY_URL=http://127.0.0.1:18789`,
|
|
69
69
|
].join('\n');
|
|
70
70
|
writeFileSync(join(SIDECAR_DIR, '.env'), sidecarEnv, 'utf-8');
|
|
71
71
|
|
|
@@ -29,7 +29,7 @@ services:
|
|
|
29
29
|
- PORT=3104
|
|
30
30
|
- DB_PATH=/app/data/nats-sidecar.db
|
|
31
31
|
- NATS_SERVERS=nats://nats:4222
|
|
32
|
-
-
|
|
32
|
+
- OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789
|
|
33
33
|
- NATS_PLUGIN_API_KEY=${NATS_PLUGIN_API_KEY}
|
|
34
34
|
volumes:
|
|
35
35
|
- sidecar-data:/app/data
|
package/package.json
CHANGED
package/sidecar/bun.lock
CHANGED
package/sidecar/package.json
CHANGED
|
@@ -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
|
},
|
package/sidecar/src/config.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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' }),
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import { Service, BaseService, type OnModuleInit
|
|
2
|
-
import
|
|
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
|
|
34
|
-
private
|
|
35
|
-
private
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
227
|
-
this.logger.
|
|
228
|
-
|
|
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.
|
|
257
|
-
throw new Error('Gateway
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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.
|
|
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.
|
|
47
|
+
url: this.config.get('gateway.url'),
|
|
48
48
|
},
|
|
49
49
|
pendingCount,
|
|
50
50
|
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
|