echoclaw-relay-agent 0.6.1 → 0.7.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.
@@ -1,19 +1,24 @@
1
1
  /**
2
2
  * RelayClient — Desktop client entry point.
3
3
  *
4
- * Connects to an existing RelayAgent via 4-digit pairing code.
5
- * All communication is E2E encrypted through relay.echoclaw.me.
4
+ * Connects to relay.echoclaw.me for E2E encrypted communication with agent.
6
5
  *
7
- * Pair once, connect forever:
8
- * - First run: `client.connect('1234')` — pairs with code, persists session
6
+ * Pairing flow (desktop initiates):
7
+ * 1. `client.connect()` — connects to relay, server assigns 4-digit code
8
+ * 2. Client emits 'pairing_code' event — UI displays code for user
9
+ * 3. User copies code to agent machine → agent connects with code
10
+ * 4. ECDH key exchange completes → client emits 'paired' + 'connected'
11
+ *
12
+ * Session lifecycle:
13
+ * - First run: `client.connect()` — new pairing, gets code, waits for agent
9
14
  * - Subsequent runs: `client.connect()` — auto-resumes from saved session
10
- * - New pairing: `client.connect('5678')` overwrites old session
15
+ * - Force new pairing: `client.clearSession()` then `client.connect()`
11
16
  *
12
17
  * Usage:
13
18
  * 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
19
+ * client.on('pairing_code', (code) => showCodeInUI(code));
20
+ * client.on('paired', () => console.log('Agent connected!'));
21
+ * await client.connect();
17
22
  * client.on('message', (payload) => console.log(payload));
18
23
  * await client.send({ type: 'chat', text: 'Hello' });
19
24
  * await client.disconnect();
@@ -37,11 +42,15 @@ export declare class RelayClient extends EventEmitter {
37
42
  /** Current connection status. */
38
43
  get status(): ClientStatus;
39
44
  /**
40
- * Connect to a relay agent.
41
- * @param pairingCode - 4-digit code for new pairing. Omit to resume saved session.
45
+ * Connect to relay server.
46
+ *
47
+ * - No saved session: initiates new pairing. Emits 'pairing_code' with 4-digit code
48
+ * for user to give to agent. Resolves when agent connects and ECDH completes.
49
+ * - Saved session exists: auto-resumes with forward-secret re-keying.
50
+ *
42
51
  * Idempotent: calling while already connecting/connected is a no-op.
43
52
  */
44
- connect(pairingCode?: string): Promise<void>;
53
+ connect(): Promise<void>;
45
54
  /**
46
55
  * Send an encrypted payload to the connected agent.
47
56
  */
@@ -1,19 +1,24 @@
1
1
  /**
2
2
  * RelayClient — Desktop client entry point.
3
3
  *
4
- * Connects to an existing RelayAgent via 4-digit pairing code.
5
- * All communication is E2E encrypted through relay.echoclaw.me.
4
+ * Connects to relay.echoclaw.me for E2E encrypted communication with agent.
6
5
  *
7
- * Pair once, connect forever:
8
- * - First run: `client.connect('1234')` — pairs with code, persists session
6
+ * Pairing flow (desktop initiates):
7
+ * 1. `client.connect()` — connects to relay, server assigns 4-digit code
8
+ * 2. Client emits 'pairing_code' event — UI displays code for user
9
+ * 3. User copies code to agent machine → agent connects with code
10
+ * 4. ECDH key exchange completes → client emits 'paired' + 'connected'
11
+ *
12
+ * Session lifecycle:
13
+ * - First run: `client.connect()` — new pairing, gets code, waits for agent
9
14
  * - Subsequent runs: `client.connect()` — auto-resumes from saved session
10
- * - New pairing: `client.connect('5678')` overwrites old session
15
+ * - Force new pairing: `client.clearSession()` then `client.connect()`
11
16
  *
12
17
  * Usage:
13
18
  * 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
19
+ * client.on('pairing_code', (code) => showCodeInUI(code));
20
+ * client.on('paired', () => console.log('Agent connected!'));
21
+ * await client.connect();
17
22
  * client.on('message', (payload) => console.log(payload));
18
23
  * await client.send({ type: 'chat', text: 'Hello' });
19
24
  * await client.disconnect();
@@ -106,11 +111,15 @@ export class RelayClient extends EventEmitter {
106
111
  return this._status;
107
112
  }
108
113
  /**
109
- * Connect to a relay agent.
110
- * @param pairingCode - 4-digit code for new pairing. Omit to resume saved session.
114
+ * Connect to relay server.
115
+ *
116
+ * - No saved session: initiates new pairing. Emits 'pairing_code' with 4-digit code
117
+ * for user to give to agent. Resolves when agent connects and ECDH completes.
118
+ * - Saved session exists: auto-resumes with forward-secret re-keying.
119
+ *
111
120
  * Idempotent: calling while already connecting/connected is a no-op.
112
121
  */
113
- async connect(pairingCode) {
122
+ async connect() {
114
123
  // Idempotent guard
115
124
  if (this._connecting || this._status === 'connected')
116
125
  return;
@@ -123,19 +132,14 @@ export class RelayClient extends EventEmitter {
123
132
  }
124
133
  this.frameCrypto = null;
125
134
  try {
126
- if (pairingCode) {
127
- // New pairing overwrite any existing session
128
- await this.freshPairing(pairingCode);
135
+ // Try to resume saved session first
136
+ const session = await this.sessionStore.load();
137
+ if (session) {
138
+ await this.resumeSession(session);
129
139
  }
130
140
  else {
131
- // Try to resume saved session
132
- const session = await this.sessionStore.load();
133
- if (session) {
134
- await this.resumeSession(session);
135
- }
136
- else {
137
- throw new Error('No saved session found. Provide a pairing code: client.connect("1234")');
138
- }
141
+ // No saved session initiate new pairing (desktop gets code from server)
142
+ await this.freshPairing();
139
143
  }
140
144
  }
141
145
  finally {
@@ -186,7 +190,7 @@ export class RelayClient extends EventEmitter {
186
190
  await this.sessionStore.clear();
187
191
  }
188
192
  // ── Private ────────────────────────────────────────────────
189
- async freshPairing(code) {
193
+ async freshPairing() {
190
194
  this.setStatus('connecting');
191
195
  const clientUrl = `${this.config.relayServer}/client/connect`;
192
196
  this.transport = new RelayTransport({
@@ -196,8 +200,13 @@ export class RelayClient extends EventEmitter {
196
200
  });
197
201
  this.setupTransportEvents();
198
202
  this.transport.connect();
203
+ // Initiator mode: no code — PairingProtocol gets code from server HELLO
199
204
  const pairing = new PairingProtocol(this.transport);
200
- const result = await pairing.pairAsClient(code);
205
+ pairing.on('pairing_code', (code) => {
206
+ this.pairingCode = code;
207
+ this.emit('pairing_code', code);
208
+ });
209
+ const result = await pairing.pairAsClient(); // no code = initiator mode
201
210
  await this.onPaired(result, 'paired');
202
211
  }
203
212
  async resumeSession(session) {
@@ -2,17 +2,15 @@
2
2
  * PairingProtocol — E2E key exchange via pairing code.
3
3
  *
4
4
  * Agent side:
5
- * 1. Register with relay server → receive session_id + pairing_code (4 digits)
5
+ * 1. Connect with ?code=XXXX → receive HELLO with session_id
6
6
  * 2. Send HELLO with agent's X25519 public key
7
7
  * 3. Wait for client's HELLO with their public key
8
8
  * 4. ECDH + HKDF (with pairing code as salt) → sessionKey
9
- * 5. Persist session for future auto-resume
10
9
  *
11
- * Client side:
12
- * 1. Connect with ?code=<4-digit-code>
13
- * 2. Send HELLO with client's X25519 public key
14
- * 3. Receive agent's HELLO with their public key
15
- * 4. ECDH + HKDF → sessionKey
10
+ * Client (desktop) side — two modes:
11
+ * A. Initiator (no code): connect /client/connect → server HELLO gives code
12
+ * emit 'pairing_code' send pubkey wait for agent HELLO → ECDH
13
+ * B. With code (resume): connect with known code → send pubkey → ECDH
16
14
  *
17
15
  * Pairing code is mixed into HKDF salt for MITM protection.
18
16
  */
@@ -33,8 +31,14 @@ export declare class PairingProtocol extends EventEmitter {
33
31
  */
34
32
  pairAsAgent(inputCode?: string): Promise<PairingResult>;
35
33
  /**
36
- * Client-side pairing: connect with pairing code, exchange keys with agent.
37
- * Returns the E2E session key.
34
+ * Client-side pairing: exchange keys with agent via relay server.
35
+ *
36
+ * Two modes:
37
+ * - **Initiator** (no code): Desktop connects first, server assigns code.
38
+ * Emits 'pairing_code' so UI can display it. Waits for agent to join.
39
+ * - **With code** (resume): Desktop already knows the code, sends pubkey immediately.
40
+ *
41
+ * @param pairingCode - Known code for HKDF. Omit for initiator mode.
38
42
  */
39
- pairAsClient(pairingCode: string): Promise<PairingResult>;
43
+ pairAsClient(pairingCode?: string): Promise<PairingResult>;
40
44
  }
@@ -2,17 +2,15 @@
2
2
  * PairingProtocol — E2E key exchange via pairing code.
3
3
  *
4
4
  * Agent side:
5
- * 1. Register with relay server → receive session_id + pairing_code (4 digits)
5
+ * 1. Connect with ?code=XXXX → receive HELLO with session_id
6
6
  * 2. Send HELLO with agent's X25519 public key
7
7
  * 3. Wait for client's HELLO with their public key
8
8
  * 4. ECDH + HKDF (with pairing code as salt) → sessionKey
9
- * 5. Persist session for future auto-resume
10
9
  *
11
- * Client side:
12
- * 1. Connect with ?code=<4-digit-code>
13
- * 2. Send HELLO with client's X25519 public key
14
- * 3. Receive agent's HELLO with their public key
15
- * 4. ECDH + HKDF → sessionKey
10
+ * Client (desktop) side — two modes:
11
+ * A. Initiator (no code): connect /client/connect → server HELLO gives code
12
+ * emit 'pairing_code' send pubkey wait for agent HELLO → ECDH
13
+ * B. With code (resume): connect with known code → send pubkey → ECDH
16
14
  *
17
15
  * Pairing code is mixed into HKDF salt for MITM protection.
18
16
  */
@@ -93,40 +91,72 @@ export class PairingProtocol extends EventEmitter {
93
91
  });
94
92
  }
95
93
  /**
96
- * Client-side pairing: connect with pairing code, exchange keys with agent.
97
- * Returns the E2E session key.
94
+ * Client-side pairing: exchange keys with agent via relay server.
95
+ *
96
+ * Two modes:
97
+ * - **Initiator** (no code): Desktop connects first, server assigns code.
98
+ * Emits 'pairing_code' so UI can display it. Waits for agent to join.
99
+ * - **With code** (resume): Desktop already knows the code, sends pubkey immediately.
100
+ *
101
+ * @param pairingCode - Known code for HKDF. Omit for initiator mode.
98
102
  */
99
103
  async pairAsClient(pairingCode) {
100
104
  this.keyPair = await generateKeyPair();
105
+ const isInitiator = !pairingCode;
101
106
  return new Promise((resolve, reject) => {
102
107
  let sessionId = '';
108
+ let code = pairingCode || '';
103
109
  let resolved = false;
104
110
  let sentHello = false;
111
+ // Initiator waits up to 10 min for agent; with-code waits 30s
112
+ const timeoutMs = isInitiator ? 10 * 60 * 1000 : 30000;
113
+ const timeoutMsg = isInitiator
114
+ ? 'Pairing timeout: no agent connected within 10 minutes'
115
+ : 'Pairing timeout: agent not responding within 30 seconds';
105
116
  const timeout = setTimeout(() => {
106
117
  if (!resolved) {
107
118
  cleanup();
108
- reject(new Error('Pairing timeout: agent not responding within 30 seconds'));
119
+ reject(new Error(timeoutMsg));
109
120
  }
110
- }, 30000);
121
+ }, timeoutMs);
111
122
  const cleanup = () => {
112
123
  resolved = true;
113
124
  clearTimeout(timeout);
114
125
  this.transport.removeListener('message', onMessage);
115
126
  this.transport.removeListener('open', onOpen);
116
127
  };
117
- const onOpen = () => {
128
+ const sendPubkey = () => {
118
129
  if (sentHello)
119
130
  return;
120
131
  sentHello = true;
121
- // Send our public key immediately
122
- this.transport.send(this.transport.buildMessage('HELLO', '', 'desktop', {
132
+ this.transport.send(this.transport.buildMessage('HELLO', sessionId, 'desktop', {
123
133
  pubkey: toBase64(this.keyPair.publicKey),
124
134
  }));
125
135
  };
136
+ const onOpen = () => {
137
+ // With known code: send pubkey immediately on connect
138
+ if (!isInitiator) {
139
+ sendPubkey();
140
+ }
141
+ // Initiator mode: wait for server HELLO to get code first
142
+ };
126
143
  const onMessage = async (msg) => {
127
144
  if (resolved)
128
145
  return;
129
- // Track session_id from server
146
+ // Server HELLO — provides session_id (and code in initiator mode)
147
+ if (msg.type === 'HELLO' && msg.sender_role === 'server') {
148
+ if (msg.session_id)
149
+ sessionId = msg.session_id;
150
+ if (msg.payload && isInitiator) {
151
+ code = msg.payload;
152
+ this.emit('pairing_code', code);
153
+ }
154
+ // In initiator mode, send pubkey after receiving server HELLO
155
+ if (isInitiator) {
156
+ sendPubkey();
157
+ }
158
+ }
159
+ // Track session_id from any message
130
160
  if (msg.session_id && !sessionId) {
131
161
  sessionId = msg.session_id;
132
162
  }
@@ -134,15 +164,17 @@ export class PairingProtocol extends EventEmitter {
134
164
  if (msg.type === 'HELLO' && msg.pubkey && msg.sender_role === 'agent') {
135
165
  try {
136
166
  const theirPub = fromBase64(msg.pubkey);
137
- const sessionKey = await completeHandshake(this.keyPair.privateKey, theirPub, pairingCode);
167
+ const sessionKey = await completeHandshake(this.keyPair.privateKey, theirPub, code || undefined);
138
168
  cleanup();
139
- resolve({ sessionKey, sessionId, pairingCode });
169
+ resolve({ sessionKey, sessionId, pairingCode: code });
140
170
  }
141
171
  catch (err) {
142
172
  cleanup();
143
173
  reject(err);
144
174
  }
145
175
  }
176
+ // PAIRED notification from server — agent has joined (initiator mode)
177
+ // No action needed, just wait for agent's HELLO with pubkey
146
178
  // Error from server
147
179
  if (msg.type === 'CLOSE') {
148
180
  cleanup();
@@ -154,8 +186,8 @@ export class PairingProtocol extends EventEmitter {
154
186
  };
155
187
  this.transport.on('message', onMessage);
156
188
  this.transport.on('open', onOpen);
157
- // If already connected, send HELLO now
158
- if (this.transport.isConnected && !sentHello) {
189
+ // If already connected, trigger onOpen
190
+ if (this.transport.isConnected) {
159
191
  onOpen();
160
192
  }
161
193
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,17 +17,17 @@
17
17
  "files": [
18
18
  "dist"
19
19
  ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsx src/cli.ts"
23
+ },
20
24
  "dependencies": {
21
- "ws": "^8.18.0",
22
- "echoclaw-crypto": "0.2.0"
25
+ "echoclaw-crypto": "workspace:*",
26
+ "ws": "^8.18.0"
23
27
  },
24
28
  "devDependencies": {
25
29
  "@types/ws": "^8.5.10",
26
30
  "tsx": "^4.7.0",
27
31
  "typescript": "^5.7.0"
28
- },
29
- "scripts": {
30
- "build": "tsc",
31
- "dev": "tsx src/cli.ts"
32
32
  }
33
- }
33
+ }