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.
Files changed (2) hide show
  1. package/dist/main.js +475 -0
  2. 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
+ }