@tjamescouch/agentchat 0.11.0 → 0.13.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/bin/agentchat.js +172 -0
- package/lib/chat.py +66 -0
- package/lib/client.js +131 -1
- package/lib/elo_swarm.py +569 -0
- package/lib/escrow-hooks.js +237 -0
- package/lib/identity.js +134 -2
- package/lib/protocol.js +89 -3
- package/lib/server-directory.js +181 -0
- package/lib/server.js +304 -11
- package/package.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EscrowHooks - Event system for external escrow integration
|
|
3
|
+
*
|
|
4
|
+
* Allows external systems (blockchain, multi-sig, compliance) to hook into
|
|
5
|
+
* escrow lifecycle events without modifying core AgentChat code.
|
|
6
|
+
*
|
|
7
|
+
* Events:
|
|
8
|
+
* escrow:created - Escrow created when proposal accepted with stakes
|
|
9
|
+
* escrow:released - Escrow released (expired, cancelled)
|
|
10
|
+
* settlement:completion - Proposal completed, stakes returned
|
|
11
|
+
* settlement:dispute - Proposal disputed, stakes transferred/burned
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const EscrowEvent = {
|
|
15
|
+
CREATED: 'escrow:created',
|
|
16
|
+
RELEASED: 'escrow:released',
|
|
17
|
+
COMPLETION_SETTLED: 'settlement:completion',
|
|
18
|
+
DISPUTE_SETTLED: 'settlement:dispute'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class EscrowHooks {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.handlers = new Map(); // event -> Set of handlers
|
|
24
|
+
this.logger = options.logger || console;
|
|
25
|
+
this.continueOnError = options.continueOnError !== false; // default true
|
|
26
|
+
|
|
27
|
+
// Initialize event handler sets
|
|
28
|
+
for (const event of Object.values(EscrowEvent)) {
|
|
29
|
+
this.handlers.set(event, new Set());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register a handler for an escrow event
|
|
35
|
+
* @param {string} event - Event name from EscrowEvent
|
|
36
|
+
* @param {Function} handler - Async function(payload) to call
|
|
37
|
+
* @returns {Function} Unsubscribe function
|
|
38
|
+
*/
|
|
39
|
+
on(event, handler) {
|
|
40
|
+
if (!this.handlers.has(event)) {
|
|
41
|
+
throw new Error(`Unknown escrow event: ${event}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof handler !== 'function') {
|
|
45
|
+
throw new Error('Handler must be a function');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.handlers.get(event).add(handler);
|
|
49
|
+
|
|
50
|
+
// Return unsubscribe function
|
|
51
|
+
return () => this.off(event, handler);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Remove a handler for an escrow event
|
|
56
|
+
* @param {string} event - Event name
|
|
57
|
+
* @param {Function} handler - Handler to remove
|
|
58
|
+
*/
|
|
59
|
+
off(event, handler) {
|
|
60
|
+
if (this.handlers.has(event)) {
|
|
61
|
+
this.handlers.get(event).delete(handler);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove all handlers for an event (or all events)
|
|
67
|
+
* @param {string} [event] - Optional event name
|
|
68
|
+
*/
|
|
69
|
+
clear(event) {
|
|
70
|
+
if (event) {
|
|
71
|
+
if (this.handlers.has(event)) {
|
|
72
|
+
this.handlers.get(event).clear();
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
for (const handlers of this.handlers.values()) {
|
|
76
|
+
handlers.clear();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Emit an escrow event to all registered handlers
|
|
83
|
+
* @param {string} event - Event name
|
|
84
|
+
* @param {Object} payload - Event payload
|
|
85
|
+
* @returns {Promise<Object>} Results from all handlers
|
|
86
|
+
*/
|
|
87
|
+
async emit(event, payload) {
|
|
88
|
+
if (!this.handlers.has(event)) {
|
|
89
|
+
throw new Error(`Unknown escrow event: ${event}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handlers = this.handlers.get(event);
|
|
93
|
+
if (handlers.size === 0) {
|
|
94
|
+
return { event, handled: false, results: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const results = [];
|
|
98
|
+
const errors = [];
|
|
99
|
+
|
|
100
|
+
for (const handler of handlers) {
|
|
101
|
+
try {
|
|
102
|
+
const result = await handler(payload);
|
|
103
|
+
results.push({ success: true, result });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const errorInfo = {
|
|
106
|
+
success: false,
|
|
107
|
+
error: err.message,
|
|
108
|
+
stack: err.stack
|
|
109
|
+
};
|
|
110
|
+
errors.push(errorInfo);
|
|
111
|
+
results.push(errorInfo);
|
|
112
|
+
|
|
113
|
+
this.logger.error?.(`[EscrowHooks] Error in ${event} handler:`, err.message);
|
|
114
|
+
|
|
115
|
+
if (!this.continueOnError) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
event,
|
|
123
|
+
handled: true,
|
|
124
|
+
results,
|
|
125
|
+
errors: errors.length > 0 ? errors : undefined
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if any handlers are registered for an event
|
|
131
|
+
* @param {string} event - Event name
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
hasHandlers(event) {
|
|
135
|
+
return this.handlers.has(event) && this.handlers.get(event).size > 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get count of handlers for an event
|
|
140
|
+
* @param {string} event - Event name
|
|
141
|
+
* @returns {number}
|
|
142
|
+
*/
|
|
143
|
+
handlerCount(event) {
|
|
144
|
+
return this.handlers.has(event) ? this.handlers.get(event).size : 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create payload for escrow:created event
|
|
150
|
+
*/
|
|
151
|
+
export function createEscrowCreatedPayload(proposal, escrowResult) {
|
|
152
|
+
return {
|
|
153
|
+
event: EscrowEvent.CREATED,
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
proposal_id: proposal.id,
|
|
156
|
+
from_agent: proposal.from,
|
|
157
|
+
to_agent: proposal.to,
|
|
158
|
+
proposer_stake: proposal.proposer_stake || 0,
|
|
159
|
+
acceptor_stake: proposal.acceptor_stake || 0,
|
|
160
|
+
total_stake: (proposal.proposer_stake || 0) + (proposal.acceptor_stake || 0),
|
|
161
|
+
task: proposal.task,
|
|
162
|
+
amount: proposal.amount,
|
|
163
|
+
currency: proposal.currency,
|
|
164
|
+
expires: proposal.expires,
|
|
165
|
+
escrow_id: escrowResult.escrow?.proposal_id || proposal.id
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create payload for settlement:completion event
|
|
171
|
+
*/
|
|
172
|
+
export function createCompletionPayload(proposal, ratingChanges) {
|
|
173
|
+
const escrowInfo = ratingChanges?._escrow || {};
|
|
174
|
+
return {
|
|
175
|
+
event: EscrowEvent.COMPLETION_SETTLED,
|
|
176
|
+
timestamp: Date.now(),
|
|
177
|
+
proposal_id: proposal.id,
|
|
178
|
+
from_agent: proposal.from,
|
|
179
|
+
to_agent: proposal.to,
|
|
180
|
+
completed_by: proposal.completed_by,
|
|
181
|
+
completion_proof: proposal.completion_proof,
|
|
182
|
+
settlement: 'returned',
|
|
183
|
+
stakes_returned: {
|
|
184
|
+
proposer: escrowInfo.proposer_stake || 0,
|
|
185
|
+
acceptor: escrowInfo.acceptor_stake || 0
|
|
186
|
+
},
|
|
187
|
+
rating_changes: {
|
|
188
|
+
[proposal.from]: ratingChanges?.[proposal.from],
|
|
189
|
+
[proposal.to]: ratingChanges?.[proposal.to]
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Create payload for settlement:dispute event
|
|
196
|
+
*/
|
|
197
|
+
export function createDisputePayload(proposal, ratingChanges) {
|
|
198
|
+
const escrowInfo = ratingChanges?._escrow || {};
|
|
199
|
+
return {
|
|
200
|
+
event: EscrowEvent.DISPUTE_SETTLED,
|
|
201
|
+
timestamp: Date.now(),
|
|
202
|
+
proposal_id: proposal.id,
|
|
203
|
+
from_agent: proposal.from,
|
|
204
|
+
to_agent: proposal.to,
|
|
205
|
+
disputed_by: proposal.disputed_by,
|
|
206
|
+
dispute_reason: proposal.dispute_reason,
|
|
207
|
+
settlement: escrowInfo.settlement || 'settled',
|
|
208
|
+
settlement_reason: escrowInfo.settlement_reason,
|
|
209
|
+
fault_determination: escrowInfo.fault_party,
|
|
210
|
+
stakes_transferred: escrowInfo.transferred,
|
|
211
|
+
stakes_burned: escrowInfo.burned,
|
|
212
|
+
rating_changes: {
|
|
213
|
+
[proposal.from]: ratingChanges?.[proposal.from],
|
|
214
|
+
[proposal.to]: ratingChanges?.[proposal.to]
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create payload for escrow:released event
|
|
221
|
+
*/
|
|
222
|
+
export function createEscrowReleasedPayload(proposalId, escrow, reason) {
|
|
223
|
+
return {
|
|
224
|
+
event: EscrowEvent.RELEASED,
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
proposal_id: proposalId,
|
|
227
|
+
from_agent: escrow.from?.agent_id,
|
|
228
|
+
to_agent: escrow.to?.agent_id,
|
|
229
|
+
stakes_released: {
|
|
230
|
+
proposer: escrow.from?.stake || 0,
|
|
231
|
+
acceptor: escrow.to?.stake || 0
|
|
232
|
+
},
|
|
233
|
+
reason: reason || 'expired'
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default EscrowHooks;
|
package/lib/identity.js
CHANGED
|
@@ -44,6 +44,7 @@ export class Identity {
|
|
|
44
44
|
this.pubkey = data.pubkey; // PEM format
|
|
45
45
|
this.privkey = data.privkey; // PEM format (null if loaded from export)
|
|
46
46
|
this.created = data.created;
|
|
47
|
+
this.rotations = data.rotations || []; // Array of rotation records
|
|
47
48
|
|
|
48
49
|
// Lazy-load crypto key objects
|
|
49
50
|
this._publicKey = null;
|
|
@@ -85,7 +86,8 @@ export class Identity {
|
|
|
85
86
|
name: this.name,
|
|
86
87
|
pubkey: this.pubkey,
|
|
87
88
|
privkey: this.privkey,
|
|
88
|
-
created: this.created
|
|
89
|
+
created: this.created,
|
|
90
|
+
rotations: this.rotations
|
|
89
91
|
};
|
|
90
92
|
|
|
91
93
|
await fs.writeFile(filePath, JSON.stringify(data, null, 2), {
|
|
@@ -160,7 +162,137 @@ export class Identity {
|
|
|
160
162
|
return {
|
|
161
163
|
name: this.name,
|
|
162
164
|
pubkey: this.pubkey,
|
|
163
|
-
created: this.created
|
|
165
|
+
created: this.created,
|
|
166
|
+
rotations: this.rotations
|
|
164
167
|
};
|
|
165
168
|
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rotate to a new keypair
|
|
172
|
+
* Signs the new public key with the old private key for chain of custody
|
|
173
|
+
* @returns {object} Rotation record with old_pubkey, new_pubkey, signature, timestamp
|
|
174
|
+
*/
|
|
175
|
+
rotate() {
|
|
176
|
+
if (!this.privkey) {
|
|
177
|
+
throw new Error('Private key not available - cannot rotate');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Generate new keypair
|
|
181
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
182
|
+
const newPubkey = publicKey.export({ type: 'spki', format: 'pem' });
|
|
183
|
+
const newPrivkey = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
184
|
+
|
|
185
|
+
// Use same timestamp for both signing and record
|
|
186
|
+
const timestamp = new Date().toISOString();
|
|
187
|
+
|
|
188
|
+
// Create rotation record content to sign
|
|
189
|
+
const rotationContent = JSON.stringify({
|
|
190
|
+
old_pubkey: this.pubkey,
|
|
191
|
+
new_pubkey: newPubkey,
|
|
192
|
+
timestamp
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Sign with old private key
|
|
196
|
+
const signature = this.sign(rotationContent);
|
|
197
|
+
|
|
198
|
+
// Create rotation record
|
|
199
|
+
const rotationRecord = {
|
|
200
|
+
old_pubkey: this.pubkey,
|
|
201
|
+
old_agent_id: this.getAgentId(),
|
|
202
|
+
new_pubkey: newPubkey,
|
|
203
|
+
new_agent_id: pubkeyToAgentId(newPubkey),
|
|
204
|
+
signature,
|
|
205
|
+
timestamp
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Update identity with new keys
|
|
209
|
+
this.rotations.push(rotationRecord);
|
|
210
|
+
this.pubkey = newPubkey;
|
|
211
|
+
this.privkey = newPrivkey;
|
|
212
|
+
this._publicKey = null;
|
|
213
|
+
this._privateKey = null;
|
|
214
|
+
|
|
215
|
+
return rotationRecord;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Verify a rotation record
|
|
220
|
+
* Checks that the signature is valid using the old public key
|
|
221
|
+
* @param {object} record - Rotation record to verify
|
|
222
|
+
* @returns {boolean} True if signature is valid
|
|
223
|
+
*/
|
|
224
|
+
static verifyRotation(record) {
|
|
225
|
+
try {
|
|
226
|
+
const rotationContent = JSON.stringify({
|
|
227
|
+
old_pubkey: record.old_pubkey,
|
|
228
|
+
new_pubkey: record.new_pubkey,
|
|
229
|
+
timestamp: record.timestamp
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return Identity.verify(rotationContent, record.signature, record.old_pubkey);
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Verify the entire rotation chain
|
|
240
|
+
* @returns {object} { valid: boolean, errors: string[] }
|
|
241
|
+
*/
|
|
242
|
+
verifyRotationChain() {
|
|
243
|
+
const errors = [];
|
|
244
|
+
|
|
245
|
+
if (this.rotations.length === 0) {
|
|
246
|
+
return { valid: true, errors: [] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Verify each rotation in sequence
|
|
250
|
+
for (let i = 0; i < this.rotations.length; i++) {
|
|
251
|
+
const record = this.rotations[i];
|
|
252
|
+
|
|
253
|
+
// Verify signature
|
|
254
|
+
if (!Identity.verifyRotation(record)) {
|
|
255
|
+
errors.push(`Rotation ${i + 1}: Invalid signature`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Verify chain continuity (each new_pubkey should match next old_pubkey)
|
|
260
|
+
if (i < this.rotations.length - 1) {
|
|
261
|
+
const nextRecord = this.rotations[i + 1];
|
|
262
|
+
if (record.new_pubkey !== nextRecord.old_pubkey) {
|
|
263
|
+
errors.push(`Rotation ${i + 1}: Chain break - new_pubkey doesn't match next old_pubkey`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Verify final pubkey matches current identity
|
|
269
|
+
const lastRotation = this.rotations[this.rotations.length - 1];
|
|
270
|
+
if (lastRotation.new_pubkey !== this.pubkey) {
|
|
271
|
+
errors.push('Final rotation new_pubkey does not match current identity pubkey');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
valid: errors.length === 0,
|
|
276
|
+
errors
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get the original (genesis) public key before any rotations
|
|
282
|
+
* @returns {string} Original public key in PEM format
|
|
283
|
+
*/
|
|
284
|
+
getOriginalPubkey() {
|
|
285
|
+
if (this.rotations.length === 0) {
|
|
286
|
+
return this.pubkey;
|
|
287
|
+
}
|
|
288
|
+
return this.rotations[0].old_pubkey;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get the original (genesis) agent ID
|
|
293
|
+
* @returns {string} Original agent ID
|
|
294
|
+
*/
|
|
295
|
+
getOriginalAgentId() {
|
|
296
|
+
return pubkeyToAgentId(this.getOriginalPubkey());
|
|
297
|
+
}
|
|
166
298
|
}
|
package/lib/protocol.js
CHANGED
|
@@ -24,7 +24,12 @@ export const ClientMessageType = {
|
|
|
24
24
|
DISPUTE: 'DISPUTE',
|
|
25
25
|
// Skill discovery message types
|
|
26
26
|
REGISTER_SKILLS: 'REGISTER_SKILLS',
|
|
27
|
-
SEARCH_SKILLS: 'SEARCH_SKILLS'
|
|
27
|
+
SEARCH_SKILLS: 'SEARCH_SKILLS',
|
|
28
|
+
// Presence message types
|
|
29
|
+
SET_PRESENCE: 'SET_PRESENCE',
|
|
30
|
+
// Identity verification message types
|
|
31
|
+
VERIFY_REQUEST: 'VERIFY_REQUEST',
|
|
32
|
+
VERIFY_RESPONSE: 'VERIFY_RESPONSE'
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
// Server -> Client message types
|
|
@@ -47,7 +52,14 @@ export const ServerMessageType = {
|
|
|
47
52
|
DISPUTE: 'DISPUTE',
|
|
48
53
|
// Skill discovery message types
|
|
49
54
|
SKILLS_REGISTERED: 'SKILLS_REGISTERED',
|
|
50
|
-
SEARCH_RESULTS: 'SEARCH_RESULTS'
|
|
55
|
+
SEARCH_RESULTS: 'SEARCH_RESULTS',
|
|
56
|
+
// Presence message types
|
|
57
|
+
PRESENCE_CHANGED: 'PRESENCE_CHANGED',
|
|
58
|
+
// Identity verification message types
|
|
59
|
+
VERIFY_REQUEST: 'VERIFY_REQUEST',
|
|
60
|
+
VERIFY_RESPONSE: 'VERIFY_RESPONSE',
|
|
61
|
+
VERIFY_SUCCESS: 'VERIFY_SUCCESS',
|
|
62
|
+
VERIFY_FAILED: 'VERIFY_FAILED'
|
|
51
63
|
};
|
|
52
64
|
|
|
53
65
|
// Error codes
|
|
@@ -68,7 +80,19 @@ export const ErrorCode = {
|
|
|
68
80
|
NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY',
|
|
69
81
|
// Staking errors
|
|
70
82
|
INSUFFICIENT_REPUTATION: 'INSUFFICIENT_REPUTATION',
|
|
71
|
-
INVALID_STAKE: 'INVALID_STAKE'
|
|
83
|
+
INVALID_STAKE: 'INVALID_STAKE',
|
|
84
|
+
// Verification errors
|
|
85
|
+
VERIFICATION_FAILED: 'VERIFICATION_FAILED',
|
|
86
|
+
VERIFICATION_EXPIRED: 'VERIFICATION_EXPIRED',
|
|
87
|
+
NO_PUBKEY: 'NO_PUBKEY'
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Presence status
|
|
91
|
+
export const PresenceStatus = {
|
|
92
|
+
ONLINE: 'online',
|
|
93
|
+
AWAY: 'away',
|
|
94
|
+
BUSY: 'busy',
|
|
95
|
+
OFFLINE: 'offline'
|
|
72
96
|
};
|
|
73
97
|
|
|
74
98
|
// Proposal status
|
|
@@ -340,6 +364,50 @@ export function validateClientMessage(raw) {
|
|
|
340
364
|
// query_id is optional but useful for tracking responses
|
|
341
365
|
break;
|
|
342
366
|
|
|
367
|
+
case ClientMessageType.SET_PRESENCE:
|
|
368
|
+
// Set presence requires: status (online, away, busy, offline)
|
|
369
|
+
const validStatuses = ['online', 'away', 'busy', 'offline'];
|
|
370
|
+
if (!msg.status || !validStatuses.includes(msg.status)) {
|
|
371
|
+
return { valid: false, error: `Invalid presence status. Must be one of: ${validStatuses.join(', ')}` };
|
|
372
|
+
}
|
|
373
|
+
// Optional: status_text for custom message
|
|
374
|
+
if (msg.status_text !== undefined && typeof msg.status_text !== 'string') {
|
|
375
|
+
return { valid: false, error: 'status_text must be a string' };
|
|
376
|
+
}
|
|
377
|
+
if (msg.status_text && msg.status_text.length > 100) {
|
|
378
|
+
return { valid: false, error: 'status_text too long (max 100 chars)' };
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
|
|
382
|
+
case ClientMessageType.VERIFY_REQUEST:
|
|
383
|
+
// Verify request requires: target agent and nonce
|
|
384
|
+
if (!msg.target) {
|
|
385
|
+
return { valid: false, error: 'Missing target agent' };
|
|
386
|
+
}
|
|
387
|
+
if (!isAgent(msg.target)) {
|
|
388
|
+
return { valid: false, error: 'Target must be an agent (@id)' };
|
|
389
|
+
}
|
|
390
|
+
if (!msg.nonce || typeof msg.nonce !== 'string') {
|
|
391
|
+
return { valid: false, error: 'Missing or invalid nonce' };
|
|
392
|
+
}
|
|
393
|
+
if (msg.nonce.length < 16 || msg.nonce.length > 128) {
|
|
394
|
+
return { valid: false, error: 'Nonce must be 16-128 characters' };
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
|
|
398
|
+
case ClientMessageType.VERIFY_RESPONSE:
|
|
399
|
+
// Verify response requires: request_id, nonce, and signature
|
|
400
|
+
if (!msg.request_id) {
|
|
401
|
+
return { valid: false, error: 'Missing request_id' };
|
|
402
|
+
}
|
|
403
|
+
if (!msg.nonce || typeof msg.nonce !== 'string') {
|
|
404
|
+
return { valid: false, error: 'Missing or invalid nonce' };
|
|
405
|
+
}
|
|
406
|
+
if (!msg.sig || typeof msg.sig !== 'string') {
|
|
407
|
+
return { valid: false, error: 'Missing or invalid signature' };
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
|
|
343
411
|
default:
|
|
344
412
|
return { valid: false, error: `Unknown message type: ${msg.type}` };
|
|
345
413
|
}
|
|
@@ -395,3 +463,21 @@ export function isProposalMessage(type) {
|
|
|
395
463
|
ClientMessageType.DISPUTE
|
|
396
464
|
].includes(type);
|
|
397
465
|
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Generate a unique verification request ID
|
|
469
|
+
* Format: verify_<timestamp>_<random>
|
|
470
|
+
*/
|
|
471
|
+
export function generateVerifyId() {
|
|
472
|
+
const timestamp = Date.now().toString(36);
|
|
473
|
+
const random = crypto.randomBytes(4).toString('hex');
|
|
474
|
+
return `verify_${timestamp}_${random}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Generate a random nonce for identity verification
|
|
479
|
+
* Returns a 32-character hex string
|
|
480
|
+
*/
|
|
481
|
+
export function generateNonce() {
|
|
482
|
+
return crypto.randomBytes(16).toString('hex');
|
|
483
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Directory
|
|
3
|
+
* Registry of known AgentChat servers for discovery
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import http from 'http';
|
|
7
|
+
import https from 'https';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
// Default public servers (can be extended)
|
|
12
|
+
export const DEFAULT_SERVERS = [
|
|
13
|
+
{
|
|
14
|
+
name: 'AgentChat Public',
|
|
15
|
+
url: 'wss://agentchat-server.fly.dev',
|
|
16
|
+
description: 'Official public AgentChat server',
|
|
17
|
+
region: 'global'
|
|
18
|
+
}
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Default directory file path
|
|
22
|
+
export const DEFAULT_DIRECTORY_PATH = path.join(
|
|
23
|
+
process.env.HOME || process.env.USERPROFILE || '.',
|
|
24
|
+
'.agentchat',
|
|
25
|
+
'servers.json'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Server Directory for discovering AgentChat servers
|
|
30
|
+
*/
|
|
31
|
+
export class ServerDirectory {
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.directoryPath = options.directoryPath || DEFAULT_DIRECTORY_PATH;
|
|
34
|
+
this.servers = [...DEFAULT_SERVERS];
|
|
35
|
+
this.timeout = options.timeout || 5000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load servers from directory file
|
|
40
|
+
*/
|
|
41
|
+
async load() {
|
|
42
|
+
try {
|
|
43
|
+
const data = await fs.readFile(this.directoryPath, 'utf8');
|
|
44
|
+
const loaded = JSON.parse(data);
|
|
45
|
+
if (Array.isArray(loaded.servers)) {
|
|
46
|
+
// Merge with defaults, avoiding duplicates by URL
|
|
47
|
+
const urls = new Set(this.servers.map(s => s.url));
|
|
48
|
+
for (const server of loaded.servers) {
|
|
49
|
+
if (!urls.has(server.url)) {
|
|
50
|
+
this.servers.push(server);
|
|
51
|
+
urls.add(server.url);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// File doesn't exist or is invalid, use defaults
|
|
57
|
+
}
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Save servers to directory file
|
|
63
|
+
*/
|
|
64
|
+
async save() {
|
|
65
|
+
const dir = path.dirname(this.directoryPath);
|
|
66
|
+
await fs.mkdir(dir, { recursive: true });
|
|
67
|
+
await fs.writeFile(this.directoryPath, JSON.stringify({
|
|
68
|
+
version: 1,
|
|
69
|
+
updated_at: new Date().toISOString(),
|
|
70
|
+
servers: this.servers
|
|
71
|
+
}, null, 2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Add a server to the directory
|
|
76
|
+
*/
|
|
77
|
+
async addServer(server) {
|
|
78
|
+
const existing = this.servers.find(s => s.url === server.url);
|
|
79
|
+
if (existing) {
|
|
80
|
+
Object.assign(existing, server);
|
|
81
|
+
} else {
|
|
82
|
+
this.servers.push(server);
|
|
83
|
+
}
|
|
84
|
+
await this.save();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Remove a server from the directory
|
|
89
|
+
*/
|
|
90
|
+
async removeServer(url) {
|
|
91
|
+
this.servers = this.servers.filter(s => s.url !== url);
|
|
92
|
+
await this.save();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check health of a single server
|
|
97
|
+
* @param {Object} server - Server object with url
|
|
98
|
+
* @returns {Object} Server with health status
|
|
99
|
+
*/
|
|
100
|
+
async checkHealth(server) {
|
|
101
|
+
const wsUrl = server.url;
|
|
102
|
+
// Convert ws:// or wss:// to http:// or https://
|
|
103
|
+
const httpUrl = wsUrl
|
|
104
|
+
.replace('wss://', 'https://')
|
|
105
|
+
.replace('ws://', 'http://');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const health = await this._fetchHealth(httpUrl + '/health');
|
|
109
|
+
return {
|
|
110
|
+
...server,
|
|
111
|
+
status: 'online',
|
|
112
|
+
health,
|
|
113
|
+
checked_at: new Date().toISOString()
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return {
|
|
117
|
+
...server,
|
|
118
|
+
status: 'offline',
|
|
119
|
+
error: err.message || err.code || 'Unknown error',
|
|
120
|
+
checked_at: new Date().toISOString()
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fetch health endpoint
|
|
127
|
+
*/
|
|
128
|
+
_fetchHealth(url) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const protocol = url.startsWith('https') ? https : http;
|
|
131
|
+
const req = protocol.get(url, { timeout: this.timeout }, (res) => {
|
|
132
|
+
let data = '';
|
|
133
|
+
res.on('data', chunk => data += chunk);
|
|
134
|
+
res.on('end', () => {
|
|
135
|
+
if (res.statusCode === 200) {
|
|
136
|
+
try {
|
|
137
|
+
resolve(JSON.parse(data));
|
|
138
|
+
} catch {
|
|
139
|
+
reject(new Error('Invalid health response'));
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
req.on('error', reject);
|
|
148
|
+
req.on('timeout', () => {
|
|
149
|
+
req.destroy();
|
|
150
|
+
reject(new Error('Timeout'));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Discover available servers (check health of all known servers)
|
|
157
|
+
* @param {Object} options
|
|
158
|
+
* @param {boolean} options.onlineOnly - Only return online servers
|
|
159
|
+
* @returns {Array} List of servers with status
|
|
160
|
+
*/
|
|
161
|
+
async discover(options = {}) {
|
|
162
|
+
const results = await Promise.all(
|
|
163
|
+
this.servers.map(server => this.checkHealth(server))
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (options.onlineOnly) {
|
|
167
|
+
return results.filter(s => s.status === 'online');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return results;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get list of known servers without health check
|
|
175
|
+
*/
|
|
176
|
+
list() {
|
|
177
|
+
return [...this.servers];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default ServerDirectory;
|