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.
- package/dist/RelayClient.d.ts +20 -11
- package/dist/RelayClient.js +33 -24
- package/dist/relay/PairingProtocol.d.ts +14 -10
- package/dist/relay/PairingProtocol.js +51 -19
- package/package.json +8 -8
package/dist/RelayClient.d.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RelayClient — Desktop client entry point.
|
|
3
3
|
*
|
|
4
|
-
* Connects to
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* await client.connect();
|
|
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
|
|
41
|
-
*
|
|
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(
|
|
53
|
+
connect(): Promise<void>;
|
|
45
54
|
/**
|
|
46
55
|
* Send an encrypted payload to the connected agent.
|
|
47
56
|
*/
|
package/dist/RelayClient.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RelayClient — Desktop client entry point.
|
|
3
3
|
*
|
|
4
|
-
* Connects to
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* await client.connect();
|
|
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
|
|
110
|
-
*
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
132
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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:
|
|
37
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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:
|
|
97
|
-
*
|
|
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(
|
|
119
|
+
reject(new Error(timeoutMsg));
|
|
109
120
|
}
|
|
110
|
-
},
|
|
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
|
|
128
|
+
const sendPubkey = () => {
|
|
118
129
|
if (sentHello)
|
|
119
130
|
return;
|
|
120
131
|
sentHello = true;
|
|
121
|
-
|
|
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
|
-
//
|
|
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,
|
|
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,
|
|
158
|
-
if (this.transport.isConnected
|
|
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.
|
|
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
|
-
"
|
|
22
|
-
"
|
|
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
|
+
}
|