echoclaw-relay-agent 0.1.0
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/dist/main.js +475 -0
- package/package.json +39 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/relay/client.ts
|
|
27
|
+
var import_ws = __toESM(require("ws"));
|
|
28
|
+
|
|
29
|
+
// ../../packages/crypto/src/x25519.ts
|
|
30
|
+
var subtle = globalThis.crypto.subtle;
|
|
31
|
+
async function generateKeyPair() {
|
|
32
|
+
const keyPair2 = await subtle.generateKey(
|
|
33
|
+
{ name: "X25519" },
|
|
34
|
+
false,
|
|
35
|
+
// non-extractable: private key cannot be exported
|
|
36
|
+
["deriveBits"]
|
|
37
|
+
);
|
|
38
|
+
const rawPub = await subtle.exportKey("raw", keyPair2.publicKey);
|
|
39
|
+
return {
|
|
40
|
+
publicKey: new Uint8Array(rawPub),
|
|
41
|
+
privateKey: keyPair2.privateKey
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function computeSharedSecret(myPrivateKey, theirPublicKeyRaw) {
|
|
45
|
+
const theirPublicKey = await subtle.importKey(
|
|
46
|
+
"raw",
|
|
47
|
+
theirPublicKeyRaw,
|
|
48
|
+
{ name: "X25519" },
|
|
49
|
+
false,
|
|
50
|
+
[]
|
|
51
|
+
);
|
|
52
|
+
const sharedBits = await subtle.deriveBits(
|
|
53
|
+
{ name: "X25519", public: theirPublicKey },
|
|
54
|
+
myPrivateKey,
|
|
55
|
+
256
|
|
56
|
+
// 32 bytes
|
|
57
|
+
);
|
|
58
|
+
return new Uint8Array(sharedBits);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ../../packages/crypto/src/hkdf.ts
|
|
62
|
+
var subtle2 = globalThis.crypto.subtle;
|
|
63
|
+
var PROTOCOL_SALT = new TextEncoder().encode("echoclaw-relay-v1");
|
|
64
|
+
var INFO_PREFIX = "echoclaw-e2e-";
|
|
65
|
+
async function deriveSessionKey(sharedSecret, pairingCode2) {
|
|
66
|
+
const hkdfKey = await subtle2.importKey(
|
|
67
|
+
"raw",
|
|
68
|
+
sharedSecret,
|
|
69
|
+
"HKDF",
|
|
70
|
+
false,
|
|
71
|
+
["deriveKey"]
|
|
72
|
+
);
|
|
73
|
+
const info = new TextEncoder().encode(
|
|
74
|
+
pairingCode2 ? `${INFO_PREFIX}${pairingCode2}` : INFO_PREFIX
|
|
75
|
+
);
|
|
76
|
+
return subtle2.deriveKey(
|
|
77
|
+
{
|
|
78
|
+
name: "HKDF",
|
|
79
|
+
hash: "SHA-256",
|
|
80
|
+
salt: PROTOCOL_SALT,
|
|
81
|
+
info
|
|
82
|
+
},
|
|
83
|
+
hkdfKey,
|
|
84
|
+
{ name: "AES-GCM", length: 256 },
|
|
85
|
+
false,
|
|
86
|
+
// non-extractable
|
|
87
|
+
["encrypt", "decrypt"]
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ../../packages/crypto/src/aes-gcm.ts
|
|
92
|
+
var subtle3 = globalThis.crypto.subtle;
|
|
93
|
+
var IV_LENGTH = 12;
|
|
94
|
+
async function encrypt(key, plaintext) {
|
|
95
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
96
|
+
const ciphertext = await subtle3.encrypt(
|
|
97
|
+
{ name: "AES-GCM", iv },
|
|
98
|
+
key,
|
|
99
|
+
plaintext
|
|
100
|
+
);
|
|
101
|
+
return {
|
|
102
|
+
iv,
|
|
103
|
+
ciphertext: new Uint8Array(ciphertext)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function decrypt(key, iv, ciphertext) {
|
|
107
|
+
const plaintext = await subtle3.decrypt(
|
|
108
|
+
{ name: "AES-GCM", iv },
|
|
109
|
+
key,
|
|
110
|
+
ciphertext
|
|
111
|
+
);
|
|
112
|
+
return new Uint8Array(plaintext);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ../../packages/crypto/src/index.ts
|
|
116
|
+
async function completeHandshake(myPrivateKey, theirPublicKey, pairingCode2) {
|
|
117
|
+
const shared = await computeSharedSecret(myPrivateKey, theirPublicKey);
|
|
118
|
+
return deriveSessionKey(shared, pairingCode2);
|
|
119
|
+
}
|
|
120
|
+
function toBase64(bytes) {
|
|
121
|
+
let binary = "";
|
|
122
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
123
|
+
binary += String.fromCharCode(bytes[i]);
|
|
124
|
+
}
|
|
125
|
+
return btoa(binary);
|
|
126
|
+
}
|
|
127
|
+
function fromBase64(b64) {
|
|
128
|
+
const binary = atob(b64);
|
|
129
|
+
const bytes = new Uint8Array(binary.length);
|
|
130
|
+
for (let i = 0; i < binary.length; i++) {
|
|
131
|
+
bytes[i] = binary.charCodeAt(i);
|
|
132
|
+
}
|
|
133
|
+
return bytes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/relay/reconnect.ts
|
|
137
|
+
var MIN_DELAY_MS = 1e3;
|
|
138
|
+
var MAX_DELAY_MS = 3e4;
|
|
139
|
+
var JITTER_FACTOR = 0.3;
|
|
140
|
+
function createReconnectController() {
|
|
141
|
+
const ctrl = {
|
|
142
|
+
state: "disconnected",
|
|
143
|
+
attempt: 0,
|
|
144
|
+
setState(s) {
|
|
145
|
+
ctrl.state = s;
|
|
146
|
+
if (s === "connected" || s === "paired") {
|
|
147
|
+
ctrl.attempt = 0;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
nextDelay() {
|
|
151
|
+
const base = Math.min(MIN_DELAY_MS * 2 ** ctrl.attempt, MAX_DELAY_MS);
|
|
152
|
+
const jitter = base * JITTER_FACTOR * (Math.random() * 2 - 1);
|
|
153
|
+
ctrl.attempt++;
|
|
154
|
+
return Math.max(MIN_DELAY_MS, Math.round(base + jitter));
|
|
155
|
+
},
|
|
156
|
+
reset() {
|
|
157
|
+
ctrl.state = "disconnected";
|
|
158
|
+
ctrl.attempt = 0;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
return ctrl;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/tunnel/http.ts
|
|
165
|
+
async function forwardToBridge(bridgeUrl, request) {
|
|
166
|
+
const url = `${bridgeUrl}${request.path ?? "/"}`;
|
|
167
|
+
const method = request.method ?? "GET";
|
|
168
|
+
try {
|
|
169
|
+
const headers = {
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
...request.headers
|
|
172
|
+
};
|
|
173
|
+
const fetchOpts = {
|
|
174
|
+
method,
|
|
175
|
+
headers
|
|
176
|
+
};
|
|
177
|
+
if (request.body && ["POST", "PUT", "PATCH"].includes(method)) {
|
|
178
|
+
fetchOpts.body = Buffer.from(request.body, "base64").toString("utf-8");
|
|
179
|
+
}
|
|
180
|
+
const res = await fetch(url, fetchOpts);
|
|
181
|
+
const bodyText = await res.text();
|
|
182
|
+
const responseHeaders = {};
|
|
183
|
+
const ct = res.headers.get("content-type");
|
|
184
|
+
if (ct) responseHeaders["content-type"] = ct;
|
|
185
|
+
return {
|
|
186
|
+
request_id: request.request_id,
|
|
187
|
+
direction: "response",
|
|
188
|
+
status: res.status,
|
|
189
|
+
headers: responseHeaders,
|
|
190
|
+
body: Buffer.from(bodyText, "utf-8").toString("base64"),
|
|
191
|
+
final: true
|
|
192
|
+
};
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const errMsg = err instanceof Error ? err.message : "Bridge unreachable";
|
|
195
|
+
console.error(`[tunnel] Failed to reach ${url}: ${errMsg}`);
|
|
196
|
+
return {
|
|
197
|
+
request_id: request.request_id,
|
|
198
|
+
direction: "response",
|
|
199
|
+
status: 502,
|
|
200
|
+
body: Buffer.from(JSON.stringify({ error: errMsg }), "utf-8").toString("base64"),
|
|
201
|
+
final: true
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/relay/client.ts
|
|
207
|
+
var ws = null;
|
|
208
|
+
var keyPair = null;
|
|
209
|
+
var sessionKey = null;
|
|
210
|
+
var sessionId = null;
|
|
211
|
+
var pairingCode = null;
|
|
212
|
+
var reconnect;
|
|
213
|
+
var config;
|
|
214
|
+
var _stopped = false;
|
|
215
|
+
async function startRelayClient(cfg) {
|
|
216
|
+
config = cfg;
|
|
217
|
+
_stopped = false;
|
|
218
|
+
reconnect = createReconnectController();
|
|
219
|
+
keyPair = await generateKeyPair();
|
|
220
|
+
console.log("[relay-agent] X25519 key pair generated");
|
|
221
|
+
connect();
|
|
222
|
+
}
|
|
223
|
+
function stopRelayClient() {
|
|
224
|
+
_stopped = true;
|
|
225
|
+
if (ws) {
|
|
226
|
+
ws.close(1e3, "agent shutdown");
|
|
227
|
+
ws = null;
|
|
228
|
+
}
|
|
229
|
+
sessionKey = null;
|
|
230
|
+
keyPair = null;
|
|
231
|
+
reconnect?.reset();
|
|
232
|
+
}
|
|
233
|
+
function connect() {
|
|
234
|
+
if (_stopped) return;
|
|
235
|
+
reconnect.setState("connecting");
|
|
236
|
+
const url = config.sessionId ? `${config.relayUrl}?resume=${config.sessionId}` : config.relayUrl;
|
|
237
|
+
console.log(`[relay-agent] Connecting to ${url}...`);
|
|
238
|
+
ws = new import_ws.default(url);
|
|
239
|
+
ws.on("open", () => {
|
|
240
|
+
reconnect.setState("connected");
|
|
241
|
+
console.log("[relay-agent] Connected to relay server");
|
|
242
|
+
const registerMsg = {
|
|
243
|
+
version: 1,
|
|
244
|
+
session_id: sessionId ?? "",
|
|
245
|
+
msg_id: makeId(),
|
|
246
|
+
type: "HELLO",
|
|
247
|
+
pubkey: toBase64(keyPair.publicKey),
|
|
248
|
+
sender_role: "agent",
|
|
249
|
+
timestamp: Date.now()
|
|
250
|
+
};
|
|
251
|
+
ws.send(JSON.stringify(registerMsg));
|
|
252
|
+
});
|
|
253
|
+
ws.on("message", (data) => {
|
|
254
|
+
handleMessage(data.toString());
|
|
255
|
+
});
|
|
256
|
+
ws.on("close", (code, reason) => {
|
|
257
|
+
console.log(`[relay-agent] Disconnected (code=${code}, reason=${reason.toString()})`);
|
|
258
|
+
sessionKey = null;
|
|
259
|
+
scheduleReconnect();
|
|
260
|
+
});
|
|
261
|
+
ws.on("error", (err) => {
|
|
262
|
+
console.error(`[relay-agent] WebSocket error: ${err.message}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
function scheduleReconnect() {
|
|
266
|
+
if (_stopped) return;
|
|
267
|
+
reconnect.setState("disconnected");
|
|
268
|
+
const delay = reconnect.nextDelay();
|
|
269
|
+
console.log(`[relay-agent] Reconnecting in ${delay}ms (attempt ${reconnect.attempt})...`);
|
|
270
|
+
setTimeout(connect, delay);
|
|
271
|
+
}
|
|
272
|
+
async function handleMessage(raw) {
|
|
273
|
+
let msg;
|
|
274
|
+
try {
|
|
275
|
+
msg = JSON.parse(raw);
|
|
276
|
+
} catch {
|
|
277
|
+
console.warn("[relay-agent] Invalid JSON from relay server");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
switch (msg.type) {
|
|
281
|
+
case "HELLO":
|
|
282
|
+
await handleHello(msg);
|
|
283
|
+
break;
|
|
284
|
+
case "DATA":
|
|
285
|
+
await handleData(msg);
|
|
286
|
+
break;
|
|
287
|
+
case "PING":
|
|
288
|
+
sendRaw({ version: 1, session_id: msg.session_id, msg_id: makeId(), type: "PING", sender_role: "agent" });
|
|
289
|
+
break;
|
|
290
|
+
case "CLOSE":
|
|
291
|
+
console.log("[relay-agent] Session closed by peer");
|
|
292
|
+
sessionKey = null;
|
|
293
|
+
reconnect.setState("connected");
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function handleHello(msg) {
|
|
298
|
+
if (msg.session_id && !sessionId) {
|
|
299
|
+
sessionId = msg.session_id;
|
|
300
|
+
if (msg.payload) {
|
|
301
|
+
pairingCode = msg.payload;
|
|
302
|
+
console.log("");
|
|
303
|
+
console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
|
|
304
|
+
console.log(` \u2551 Pairing Code: ${pairingCode.padEnd(24)}\u2551`);
|
|
305
|
+
console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
|
|
306
|
+
console.log("");
|
|
307
|
+
console.log(" Enter this code in your EchoClaw desktop client to connect.");
|
|
308
|
+
console.log("");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (msg.pubkey && msg.sender_role === "desktop") {
|
|
312
|
+
console.log("[relay-agent] Received desktop public key, completing handshake...");
|
|
313
|
+
const theirPubKey = fromBase64(msg.pubkey);
|
|
314
|
+
sessionKey = await completeHandshake(
|
|
315
|
+
keyPair.privateKey,
|
|
316
|
+
theirPubKey,
|
|
317
|
+
pairingCode ?? void 0
|
|
318
|
+
);
|
|
319
|
+
reconnect.setState("paired");
|
|
320
|
+
console.log("[relay-agent] \u2705 E2E encryption established \u2014 session paired");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function handleData(msg) {
|
|
324
|
+
if (!sessionKey || !msg.iv || !msg.payload) {
|
|
325
|
+
console.warn("[relay-agent] Received DATA but no session key or missing fields");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const plainBytes = await decrypt(
|
|
330
|
+
sessionKey,
|
|
331
|
+
fromBase64(msg.iv),
|
|
332
|
+
fromBase64(msg.payload)
|
|
333
|
+
);
|
|
334
|
+
const tunnelPayload = JSON.parse(
|
|
335
|
+
new TextDecoder().decode(plainBytes)
|
|
336
|
+
);
|
|
337
|
+
if (tunnelPayload.direction !== "request") {
|
|
338
|
+
console.warn("[relay-agent] Unexpected payload direction:", tunnelPayload.direction);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const response = await forwardToBridge(config.bridgeUrl, tunnelPayload);
|
|
342
|
+
const responseBytes = new TextEncoder().encode(JSON.stringify(response));
|
|
343
|
+
const encrypted = await encrypt(sessionKey, responseBytes);
|
|
344
|
+
const responseMsg = {
|
|
345
|
+
version: 1,
|
|
346
|
+
session_id: sessionId,
|
|
347
|
+
msg_id: makeId(),
|
|
348
|
+
type: "DATA",
|
|
349
|
+
iv: toBase64(encrypted.iv),
|
|
350
|
+
payload: toBase64(encrypted.ciphertext),
|
|
351
|
+
sender_role: "agent",
|
|
352
|
+
timestamp: Date.now()
|
|
353
|
+
};
|
|
354
|
+
sendRaw(responseMsg);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error("[relay-agent] Failed to process DATA message:", err.message);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
var _counter = 0;
|
|
360
|
+
function makeId() {
|
|
361
|
+
return `ra-${++_counter}-${Date.now().toString(36)}`;
|
|
362
|
+
}
|
|
363
|
+
function sendRaw(msg) {
|
|
364
|
+
if (ws?.readyState === import_ws.default.OPEN) {
|
|
365
|
+
ws.send(JSON.stringify(msg));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/main.ts
|
|
370
|
+
function parseArgs() {
|
|
371
|
+
const args = process.argv.slice(2);
|
|
372
|
+
const opts = {
|
|
373
|
+
relayUrl: "wss://relay.echoclaw.me/agent/connect",
|
|
374
|
+
bridgeUrl: "http://localhost:8013"
|
|
375
|
+
};
|
|
376
|
+
for (let i = 0; i < args.length; i++) {
|
|
377
|
+
switch (args[i]) {
|
|
378
|
+
case "--relay":
|
|
379
|
+
case "-r":
|
|
380
|
+
opts.relayUrl = args[++i] || opts.relayUrl;
|
|
381
|
+
break;
|
|
382
|
+
case "--bridge":
|
|
383
|
+
case "-b":
|
|
384
|
+
opts.bridgeUrl = args[++i] || opts.bridgeUrl;
|
|
385
|
+
break;
|
|
386
|
+
case "--help":
|
|
387
|
+
case "-h":
|
|
388
|
+
printHelp();
|
|
389
|
+
process.exit(0);
|
|
390
|
+
case "--version":
|
|
391
|
+
case "-v":
|
|
392
|
+
console.log("echoclaw-relay-agent 0.1.0");
|
|
393
|
+
process.exit(0);
|
|
394
|
+
default:
|
|
395
|
+
if (args[i].startsWith("-")) {
|
|
396
|
+
console.error(`Unknown option: ${args[i]}`);
|
|
397
|
+
printHelp();
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return opts;
|
|
403
|
+
}
|
|
404
|
+
function printHelp() {
|
|
405
|
+
console.log(`
|
|
406
|
+
EchoClaw Relay Agent \u2014 Connect OpenClaw to the cloud relay
|
|
407
|
+
|
|
408
|
+
USAGE
|
|
409
|
+
echoclaw-relay [options]
|
|
410
|
+
|
|
411
|
+
OPTIONS
|
|
412
|
+
--relay, -r <url> Relay server WebSocket URL
|
|
413
|
+
(default: wss://relay.echoclaw.me/agent/connect)
|
|
414
|
+
|
|
415
|
+
--bridge, -b <url> Local OpenClaw bridge HTTP URL
|
|
416
|
+
(default: http://localhost:8013)
|
|
417
|
+
|
|
418
|
+
--help, -h Show this help message
|
|
419
|
+
--version, -v Show version
|
|
420
|
+
|
|
421
|
+
EXAMPLES
|
|
422
|
+
echoclaw-relay
|
|
423
|
+
echoclaw-relay --bridge http://localhost:9000
|
|
424
|
+
echoclaw-relay --relay wss://my-relay.example.com/agent/connect
|
|
425
|
+
|
|
426
|
+
SECURITY
|
|
427
|
+
All communication is end-to-end encrypted (X25519 + AES-256-GCM).
|
|
428
|
+
The relay server cannot read your messages \u2014 it only forwards ciphertext.
|
|
429
|
+
Verify: https://github.com/echoclaw/relay-agent/tree/main/packages/crypto
|
|
430
|
+
`);
|
|
431
|
+
}
|
|
432
|
+
async function main() {
|
|
433
|
+
const opts = parseArgs();
|
|
434
|
+
console.log("");
|
|
435
|
+
console.log(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
|
|
436
|
+
console.log(" \u2502 EchoClaw Relay Agent v0.1.0 \u2502");
|
|
437
|
+
console.log(" \u2502 Open Source \xB7 Apache License 2.0 \u2502");
|
|
438
|
+
console.log(" \u2502 github.com/echoclaw/relay-agent \u2502");
|
|
439
|
+
console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
|
|
440
|
+
console.log("");
|
|
441
|
+
console.log(` Relay: ${opts.relayUrl}`);
|
|
442
|
+
console.log(` Bridge: ${opts.bridgeUrl}`);
|
|
443
|
+
console.log("");
|
|
444
|
+
try {
|
|
445
|
+
const healthRes = await fetch(`${opts.bridgeUrl}/health`, {
|
|
446
|
+
signal: AbortSignal.timeout(3e3)
|
|
447
|
+
});
|
|
448
|
+
if (healthRes.ok) {
|
|
449
|
+
console.log(" \u2705 Local bridge is reachable");
|
|
450
|
+
} else {
|
|
451
|
+
console.warn(` \u26A0\uFE0F Bridge returned HTTP ${healthRes.status} \u2014 starting anyway`);
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
console.warn(" \u26A0\uFE0F Cannot reach local bridge \u2014 will retry when messages arrive");
|
|
455
|
+
}
|
|
456
|
+
console.log("");
|
|
457
|
+
await startRelayClient({
|
|
458
|
+
relayUrl: opts.relayUrl,
|
|
459
|
+
bridgeUrl: opts.bridgeUrl
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
process.on("SIGINT", () => {
|
|
463
|
+
console.log("\n[relay-agent] Shutting down...");
|
|
464
|
+
stopRelayClient();
|
|
465
|
+
process.exit(0);
|
|
466
|
+
});
|
|
467
|
+
process.on("SIGTERM", () => {
|
|
468
|
+
console.log("\n[relay-agent] Terminated");
|
|
469
|
+
stopRelayClient();
|
|
470
|
+
process.exit(0);
|
|
471
|
+
});
|
|
472
|
+
main().catch((err) => {
|
|
473
|
+
console.error("[relay-agent] Fatal error:", err);
|
|
474
|
+
process.exit(1);
|
|
475
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "echoclaw-relay-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "EchoClaw Relay Agent — connects OpenClaw bridge to the EchoClaw Relay Server with E2E encryption",
|
|
5
|
+
"main": "./dist/main.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"echoclaw-relay-agent": "dist/main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx src/main.ts",
|
|
14
|
+
"build": "node build.mjs",
|
|
15
|
+
"start": "node dist/main.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"ws": "^8.18.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@echoclaw/crypto": "workspace:*",
|
|
22
|
+
"@types/ws": "^8.5.13",
|
|
23
|
+
"esbuild": "^0.27.3",
|
|
24
|
+
"tsx": "^4.19.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"echoclaw",
|
|
29
|
+
"relay",
|
|
30
|
+
"openclaw",
|
|
31
|
+
"e2e",
|
|
32
|
+
"encryption"
|
|
33
|
+
],
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/nicekwell/EchoClaw.git"
|
|
38
|
+
}
|
|
39
|
+
}
|