echoclaw-relay-agent 0.4.1 → 0.5.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 (44) hide show
  1. package/dist/RelayAgent.d.ts +60 -0
  2. package/dist/RelayAgent.js +370 -0
  3. package/dist/RelayClient.d.ts +64 -0
  4. package/dist/RelayClient.js +321 -0
  5. package/dist/cli.d.ts +12 -0
  6. package/dist/cli.js +381 -0
  7. package/dist/crypto/FrameCrypto.d.ts +37 -0
  8. package/dist/crypto/FrameCrypto.js +104 -0
  9. package/dist/gateway/DeviceIdentity.d.ts +24 -0
  10. package/dist/gateway/DeviceIdentity.js +113 -0
  11. package/dist/gateway/GatewayBridge.d.ts +78 -0
  12. package/dist/gateway/GatewayBridge.js +425 -0
  13. package/dist/gateway/GatewayManager.d.ts +53 -0
  14. package/dist/gateway/GatewayManager.js +242 -0
  15. package/dist/gateway/GatewayWatchdog.d.ts +37 -0
  16. package/dist/gateway/GatewayWatchdog.js +159 -0
  17. package/dist/gateway/TokenDiscovery.d.ts +29 -0
  18. package/dist/gateway/TokenDiscovery.js +91 -0
  19. package/dist/gateway/index.d.ts +10 -0
  20. package/dist/gateway/index.js +10 -0
  21. package/dist/gateway/types.d.ts +76 -0
  22. package/dist/gateway/types.js +43 -0
  23. package/dist/index.d.ts +25 -0
  24. package/dist/index.js +27 -0
  25. package/dist/relay/PairingProtocol.d.ts +40 -0
  26. package/dist/relay/PairingProtocol.js +163 -0
  27. package/dist/relay/ReconnectPolicy.d.ts +37 -0
  28. package/dist/relay/ReconnectPolicy.js +75 -0
  29. package/dist/relay/RelayTransport.d.ts +60 -0
  30. package/dist/relay/RelayTransport.js +262 -0
  31. package/dist/relay/SessionStore.d.ts +25 -0
  32. package/dist/relay/SessionStore.js +82 -0
  33. package/dist/service/launchd.d.ts +12 -0
  34. package/dist/service/launchd.js +99 -0
  35. package/dist/service/platform.d.ts +21 -0
  36. package/dist/service/platform.js +30 -0
  37. package/dist/service/systemd.d.ts +12 -0
  38. package/dist/service/systemd.js +93 -0
  39. package/dist/service/windows.d.ts +12 -0
  40. package/dist/service/windows.js +79 -0
  41. package/dist/types.d.ts +66 -0
  42. package/dist/types.js +11 -0
  43. package/package.json +18 -29
  44. package/dist/main.js +0 -4227
@@ -0,0 +1,60 @@
1
+ /**
2
+ * RelayAgent — Agent-side entry point.
3
+ *
4
+ * Runs on the OpenClaw machine. Connects to relay.echoclaw.me,
5
+ * waits for client pairing, then enters E2E encrypted communication.
6
+ *
7
+ * Pair once, connect forever:
8
+ * - First run: `agent.start('1234')` — pairs with code, persists session
9
+ * - Subsequent runs: `agent.start()` — auto-resumes from saved session
10
+ * - New pairing: `agent.start('5678')` — overwrites old session
11
+ *
12
+ * Usage:
13
+ * const agent = new RelayAgent({ relayServer: 'wss://relay.echoclaw.me' });
14
+ * await agent.start('1234');
15
+ * agent.on('message', (payload) => console.log(payload));
16
+ * await agent.send({ type: 'greeting', text: 'Hello from OpenClaw' });
17
+ */
18
+ import { EventEmitter } from 'node:events';
19
+ import type { RelayAgentConfig, ClientStatus } from './types.js';
20
+ export declare class RelayAgent extends EventEmitter {
21
+ private transport;
22
+ private pairing;
23
+ private sessionStore;
24
+ private frameCrypto;
25
+ private sessionKey;
26
+ private sessionId;
27
+ private pairingCode;
28
+ private _status;
29
+ private _dataHandler;
30
+ private _stopped;
31
+ private _starting;
32
+ private readonly config;
33
+ private readonly subscribers;
34
+ private gatewayManager;
35
+ constructor(config: RelayAgentConfig);
36
+ /** Current connection status. */
37
+ get status(): ClientStatus;
38
+ /**
39
+ * Start the relay agent.
40
+ * @param pairingCode - 4-digit code for new pairing. Omit to resume saved session.
41
+ */
42
+ start(pairingCode?: string): Promise<void>;
43
+ /**
44
+ * Send an encrypted payload to the connected client.
45
+ */
46
+ send(payload: object): Promise<void>;
47
+ /**
48
+ * Subscribe to decrypted messages from the client.
49
+ * Returns an unsubscribe function.
50
+ */
51
+ subscribe(event: string, cb: (payload: unknown) => void): () => void;
52
+ /** Gracefully stop the agent. */
53
+ stop(): Promise<void>;
54
+ private freshPairing;
55
+ private resumeSession;
56
+ private onPaired;
57
+ private setupDataHandler;
58
+ private setupTransportEvents;
59
+ private setStatus;
60
+ }
@@ -0,0 +1,370 @@
1
+ /**
2
+ * RelayAgent — Agent-side entry point.
3
+ *
4
+ * Runs on the OpenClaw machine. Connects to relay.echoclaw.me,
5
+ * waits for client pairing, then enters E2E encrypted communication.
6
+ *
7
+ * Pair once, connect forever:
8
+ * - First run: `agent.start('1234')` — pairs with code, persists session
9
+ * - Subsequent runs: `agent.start()` — auto-resumes from saved session
10
+ * - New pairing: `agent.start('5678')` — overwrites old session
11
+ *
12
+ * Usage:
13
+ * const agent = new RelayAgent({ relayServer: 'wss://relay.echoclaw.me' });
14
+ * await agent.start('1234');
15
+ * agent.on('message', (payload) => console.log(payload));
16
+ * await agent.send({ type: 'greeting', text: 'Hello from OpenClaw' });
17
+ */
18
+ import { EventEmitter } from 'node:events';
19
+ // @echoclaw/crypto used via FrameCrypto and PairingProtocol
20
+ import { RelayTransport } from './relay/RelayTransport.js';
21
+ import { PairingProtocol } from './relay/PairingProtocol.js';
22
+ import { SessionStore } from './relay/SessionStore.js';
23
+ import { FrameCrypto } from './crypto/FrameCrypto.js';
24
+ import { GatewayManager } from './gateway/GatewayManager.js';
25
+ export class RelayAgent extends EventEmitter {
26
+ constructor(config) {
27
+ super();
28
+ Object.defineProperty(this, "transport", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: null
33
+ });
34
+ Object.defineProperty(this, "pairing", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: null
39
+ });
40
+ Object.defineProperty(this, "sessionStore", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: void 0
45
+ });
46
+ Object.defineProperty(this, "frameCrypto", {
47
+ enumerable: true,
48
+ configurable: true,
49
+ writable: true,
50
+ value: null
51
+ });
52
+ Object.defineProperty(this, "sessionKey", {
53
+ enumerable: true,
54
+ configurable: true,
55
+ writable: true,
56
+ value: null
57
+ });
58
+ Object.defineProperty(this, "sessionId", {
59
+ enumerable: true,
60
+ configurable: true,
61
+ writable: true,
62
+ value: ''
63
+ });
64
+ Object.defineProperty(this, "pairingCode", {
65
+ enumerable: true,
66
+ configurable: true,
67
+ writable: true,
68
+ value: ''
69
+ });
70
+ Object.defineProperty(this, "_status", {
71
+ enumerable: true,
72
+ configurable: true,
73
+ writable: true,
74
+ value: 'disconnected'
75
+ });
76
+ Object.defineProperty(this, "_dataHandler", {
77
+ enumerable: true,
78
+ configurable: true,
79
+ writable: true,
80
+ value: null
81
+ });
82
+ Object.defineProperty(this, "_stopped", {
83
+ enumerable: true,
84
+ configurable: true,
85
+ writable: true,
86
+ value: false
87
+ });
88
+ Object.defineProperty(this, "_starting", {
89
+ enumerable: true,
90
+ configurable: true,
91
+ writable: true,
92
+ value: false
93
+ });
94
+ Object.defineProperty(this, "config", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: void 0
99
+ });
100
+ Object.defineProperty(this, "subscribers", {
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true,
104
+ value: new Map()
105
+ });
106
+ Object.defineProperty(this, "gatewayManager", {
107
+ enumerable: true,
108
+ configurable: true,
109
+ writable: true,
110
+ value: null
111
+ });
112
+ this.config = config;
113
+ this.sessionStore = new SessionStore(config.sessionPath);
114
+ // V2: Create gateway manager if enabled
115
+ if (config.gateway?.enabled) {
116
+ this.gatewayManager = new GatewayManager(config.gateway, config.sessionPath);
117
+ // Forward gateway events
118
+ this.gatewayManager.on('bridge_up', (info) => this.emit('bridge_up', info));
119
+ this.gatewayManager.on('bridge_down', (info) => this.emit('bridge_down', info));
120
+ this.gatewayManager.on('bridge_recovered', (info) => this.emit('bridge_recovered', info));
121
+ this.gatewayManager.on('tunnel_request', (info) => this.emit('tunnel_request', info));
122
+ this.gatewayManager.on('tunnel_response', (info) => this.emit('tunnel_response', info));
123
+ this.gatewayManager.on('tunnel_error', (info) => this.emit('tunnel_error', info));
124
+ }
125
+ }
126
+ /** Current connection status. */
127
+ get status() {
128
+ return this._status;
129
+ }
130
+ /**
131
+ * Start the relay agent.
132
+ * @param pairingCode - 4-digit code for new pairing. Omit to resume saved session.
133
+ */
134
+ async start(pairingCode) {
135
+ if (this._starting)
136
+ return;
137
+ this._starting = true;
138
+ this._stopped = false;
139
+ // Teardown previous transport before starting new
140
+ if (this.transport) {
141
+ this.transport.disconnect();
142
+ this.transport = null;
143
+ }
144
+ this.frameCrypto = null;
145
+ try {
146
+ if (pairingCode) {
147
+ // New pairing — overwrite any existing session
148
+ await this.freshPairing(pairingCode);
149
+ }
150
+ else {
151
+ // Try to resume saved session
152
+ const session = await this.sessionStore.load();
153
+ if (session) {
154
+ await this.resumeSession(session);
155
+ }
156
+ else {
157
+ throw new Error('No saved session found. Provide a pairing code: agent.start("1234")');
158
+ }
159
+ }
160
+ }
161
+ finally {
162
+ this._starting = false;
163
+ }
164
+ }
165
+ /**
166
+ * Send an encrypted payload to the connected client.
167
+ */
168
+ async send(payload) {
169
+ if (!this.sessionKey || !this.transport?.isConnected) {
170
+ throw new Error('Not connected. Call start() first.');
171
+ }
172
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
173
+ const frame = await this.frameCrypto.encrypt(plaintext);
174
+ const wire = FrameCrypto.frameToWire(frame);
175
+ this.transport.send(this.transport.buildMessage('DATA', this.sessionId, 'agent', {
176
+ iv: wire.iv,
177
+ payload: wire.payload,
178
+ seq: wire.seq,
179
+ }));
180
+ }
181
+ /**
182
+ * Subscribe to decrypted messages from the client.
183
+ * Returns an unsubscribe function.
184
+ */
185
+ subscribe(event, cb) {
186
+ if (!this.subscribers.has(event)) {
187
+ this.subscribers.set(event, new Set());
188
+ }
189
+ this.subscribers.get(event).add(cb);
190
+ return () => {
191
+ this.subscribers.get(event)?.delete(cb);
192
+ };
193
+ }
194
+ /** Gracefully stop the agent. */
195
+ async stop() {
196
+ this._stopped = true;
197
+ this.gatewayManager?.stop();
198
+ this.transport?.disconnect();
199
+ this.transport = null;
200
+ this.sessionKey = null;
201
+ this.frameCrypto = null;
202
+ this.setStatus('disconnected');
203
+ }
204
+ // ── Private ────────────────────────────────────────────────
205
+ async freshPairing(code) {
206
+ this.setStatus('connecting');
207
+ // Connect to relay server as agent
208
+ const agentUrl = `${this.config.relayServer}/agent/connect`;
209
+ this.transport = new RelayTransport({
210
+ url: agentUrl,
211
+ reconnect: this.config.reconnect,
212
+ pingIntervalMs: this.config.pingIntervalMs,
213
+ });
214
+ this.setupTransportEvents();
215
+ this.transport.connect();
216
+ // Run pairing protocol
217
+ this.pairing = new PairingProtocol(this.transport);
218
+ this.pairing.on('pairing_code', (receivedCode) => {
219
+ this.pairingCode = receivedCode;
220
+ this.emit('pairing_code', receivedCode);
221
+ });
222
+ const result = await this.pairing.pairAsAgent();
223
+ await this.onPaired(result);
224
+ }
225
+ async resumeSession(session) {
226
+ this.setStatus('connecting');
227
+ this.pairingCode = session.pairingCode;
228
+ this.sessionId = session.relaySessionId;
229
+ // Reconnect with session resume
230
+ const resumeUrl = `${this.config.relayServer}/agent/connect?resume=${session.relaySessionId}`;
231
+ this.transport = new RelayTransport({
232
+ url: resumeUrl,
233
+ reconnect: this.config.reconnect,
234
+ pingIntervalMs: this.config.pingIntervalMs,
235
+ });
236
+ this.setupTransportEvents();
237
+ // V1: Re-do key exchange on resume since session keys are ephemeral (not persisted).
238
+ // The relay server uses the session ID to reconnect the same agent-client pair,
239
+ // but a fresh ECDH handshake establishes new forward-secret session keys.
240
+ // V2 may add session key persistence for instant resume without re-keying.
241
+ this.pairing = new PairingProtocol(this.transport);
242
+ this.transport.connect();
243
+ try {
244
+ const result = await this.pairing.pairAsAgent();
245
+ await this.onPaired(result);
246
+ }
247
+ catch (err) {
248
+ // Resume failed — clear stale session to prevent infinite retry loop
249
+ this.transport?.disconnect();
250
+ this.transport = null;
251
+ this.frameCrypto = null;
252
+ this.setStatus('disconnected');
253
+ await this.sessionStore.clear();
254
+ throw err;
255
+ }
256
+ }
257
+ async onPaired(result) {
258
+ this.sessionKey = result.sessionKey;
259
+ this.sessionId = result.sessionId;
260
+ this.pairingCode = result.pairingCode;
261
+ this.frameCrypto = new FrameCrypto(result.sessionKey);
262
+ // Persist session for future auto-resume
263
+ await this.sessionStore.save({
264
+ pairingCode: result.pairingCode,
265
+ relaySessionId: result.sessionId,
266
+ lastConnected: new Date().toISOString(),
267
+ });
268
+ this.setupDataHandler();
269
+ this.setStatus('connected');
270
+ this.emit('paired', {
271
+ pairingCode: result.pairingCode,
272
+ sessionId: result.sessionId,
273
+ });
274
+ this.emit('connected');
275
+ // V2: Start gateway (non-blocking — failure doesn't affect connection)
276
+ if (this.gatewayManager) {
277
+ this.gatewayManager.start().catch((err) => {
278
+ this.emit('error', new Error(`Gateway start failed: ${err instanceof Error ? err.message : err}`));
279
+ });
280
+ }
281
+ }
282
+ setupDataHandler() {
283
+ if (!this.transport)
284
+ return;
285
+ // Remove previous data handler to prevent listener accumulation on reconnect
286
+ if (this._dataHandler) {
287
+ this.transport.removeListener('message', this._dataHandler);
288
+ }
289
+ this._dataHandler = async (msg) => {
290
+ if (msg.type !== 'DATA' || !msg.iv || !msg.payload || !this.frameCrypto)
291
+ return;
292
+ try {
293
+ if (typeof msg.seq !== 'number') {
294
+ this.emit('error', new Error('DATA message missing seq field'));
295
+ return;
296
+ }
297
+ const frame = FrameCrypto.wireToFrame({
298
+ iv: msg.iv,
299
+ payload: msg.payload,
300
+ seq: msg.seq,
301
+ });
302
+ const plainBytes = await this.frameCrypto.decrypt(frame);
303
+ const payload = JSON.parse(new TextDecoder().decode(plainBytes));
304
+ // V2: Route TunnelPayload requests to gateway if enabled
305
+ if (this.gatewayManager && payload.request_id && payload.direction === 'request') {
306
+ if (payload.method === 'CANCEL') {
307
+ this.gatewayManager.handleCancel(payload.request_id);
308
+ return;
309
+ }
310
+ this.gatewayManager.handleRequest(payload, async (response) => {
311
+ try {
312
+ await this.send(response);
313
+ }
314
+ catch { /* send may fail if disconnected */ }
315
+ });
316
+ return;
317
+ }
318
+ this.emit('message', payload);
319
+ // Notify typed subscribers
320
+ const eventType = payload?.type ?? 'message';
321
+ this.subscribers.get(eventType)?.forEach(cb => cb(payload));
322
+ this.subscribers.get('*')?.forEach(cb => cb(payload));
323
+ }
324
+ catch (err) {
325
+ this.emit('error', err);
326
+ }
327
+ };
328
+ this.transport.on('message', this._dataHandler);
329
+ }
330
+ setupTransportEvents() {
331
+ if (!this.transport)
332
+ return;
333
+ this.transport.on('close', () => {
334
+ if (!this._stopped) {
335
+ this.setStatus('reconnecting');
336
+ this.emit('disconnected', { code: 0, reason: 'transport closed' });
337
+ // V2: Notify gateway of disconnect (starts grace period)
338
+ this.gatewayManager?.onDisconnected();
339
+ }
340
+ });
341
+ this.transport.on('reconnecting', (info) => {
342
+ this.setStatus('reconnecting');
343
+ this.emit('reconnecting', info);
344
+ });
345
+ this.transport.on('error', (err) => {
346
+ this.emit('error', err);
347
+ });
348
+ this.transport.on('open', () => {
349
+ if (this.sessionKey) {
350
+ this.setStatus('connected');
351
+ this.emit('connected');
352
+ // V2: Reattach gateway callbacks after reconnect
353
+ if (this.gatewayManager) {
354
+ this.gatewayManager.onReconnected(async (r) => {
355
+ try {
356
+ await this.send(r);
357
+ }
358
+ catch { /* send may fail */ }
359
+ });
360
+ }
361
+ }
362
+ });
363
+ }
364
+ setStatus(status) {
365
+ if (this._status !== status) {
366
+ this._status = status;
367
+ this.emit('status', status);
368
+ }
369
+ }
370
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * RelayClient — Desktop client entry point.
3
+ *
4
+ * Connects to an existing RelayAgent via 4-digit pairing code.
5
+ * All communication is E2E encrypted through relay.echoclaw.me.
6
+ *
7
+ * Pair once, connect forever:
8
+ * - First run: `client.connect('1234')` — pairs with code, persists session
9
+ * - Subsequent runs: `client.connect()` — auto-resumes from saved session
10
+ * - New pairing: `client.connect('5678')` — overwrites old session
11
+ *
12
+ * Usage:
13
+ * const client = new RelayClient({ relayServer: 'wss://relay.echoclaw.me' });
14
+ * await client.connect('1234'); // first time: pair with code
15
+ * // ... later, on app restart:
16
+ * await client.connect(); // auto-resume saved session
17
+ * client.on('message', (payload) => console.log(payload));
18
+ * await client.send({ type: 'chat', text: 'Hello' });
19
+ * await client.disconnect();
20
+ */
21
+ import { EventEmitter } from 'node:events';
22
+ import type { RelayClientConfig, ClientStatus } from './types.js';
23
+ export declare class RelayClient extends EventEmitter {
24
+ private transport;
25
+ private frameCrypto;
26
+ private sessionKey;
27
+ private sessionId;
28
+ private pairingCode;
29
+ private _status;
30
+ private _connecting;
31
+ private _stopped;
32
+ private readonly config;
33
+ private readonly sessionStore;
34
+ private readonly subscribers;
35
+ private _dataHandler;
36
+ constructor(config: RelayClientConfig);
37
+ /** Current connection status. */
38
+ get status(): ClientStatus;
39
+ /**
40
+ * Connect to a relay agent.
41
+ * @param pairingCode - 4-digit code for new pairing. Omit to resume saved session.
42
+ * Idempotent: calling while already connecting/connected is a no-op.
43
+ */
44
+ connect(pairingCode?: string): Promise<void>;
45
+ /**
46
+ * Send an encrypted payload to the connected agent.
47
+ */
48
+ send(payload: object): Promise<void>;
49
+ /**
50
+ * Subscribe to decrypted messages from the agent.
51
+ * Returns an unsubscribe function.
52
+ */
53
+ subscribe(event: string, cb: (payload: unknown) => void): () => void;
54
+ /** Disconnect from the relay. */
55
+ disconnect(): Promise<void>;
56
+ /** Clear saved session, forcing fresh pairing on next connect(). */
57
+ clearSession(): Promise<void>;
58
+ private freshPairing;
59
+ private resumeSession;
60
+ private onPaired;
61
+ private setupDataHandler;
62
+ private setupTransportEvents;
63
+ private setStatus;
64
+ }