a2acalling 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.
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Token management for A2A federation
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+
9
+ // Default config path
10
+ const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
11
+ process.env.OPENCLAW_CONFIG_DIR ||
12
+ path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
13
+
14
+ const DB_FILENAME = 'a2a-federation.json';
15
+
16
+ class TokenStore {
17
+ constructor(configDir = DEFAULT_CONFIG_DIR) {
18
+ this.configDir = configDir;
19
+ this.dbPath = path.join(configDir, DB_FILENAME);
20
+ this._ensureDir();
21
+ }
22
+
23
+ _ensureDir() {
24
+ if (!fs.existsSync(this.configDir)) {
25
+ fs.mkdirSync(this.configDir, { recursive: true });
26
+ }
27
+ }
28
+
29
+ _load() {
30
+ if (fs.existsSync(this.dbPath)) {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
33
+ } catch (e) {
34
+ // Corrupted file - backup and start fresh
35
+ const backupPath = `${this.dbPath}.corrupt.${Date.now()}`;
36
+ fs.renameSync(this.dbPath, backupPath);
37
+ console.error(`[a2a] Corrupted DB backed up to ${backupPath}`);
38
+ return { tokens: [], remotes: [], calls: [] };
39
+ }
40
+ }
41
+ return { tokens: [], remotes: [], calls: [] };
42
+ }
43
+
44
+ _save(db) {
45
+ // Atomic write: write to temp file, then rename
46
+ const tmpPath = `${this.dbPath}.tmp.${process.pid}`;
47
+ fs.writeFileSync(tmpPath, JSON.stringify(db, null, 2));
48
+ fs.renameSync(tmpPath, this.dbPath);
49
+ }
50
+
51
+ /**
52
+ * Generate a secure federation token
53
+ */
54
+ static generateToken() {
55
+ const bytes = crypto.randomBytes(24);
56
+ return 'fed_' + bytes.toString('base64url');
57
+ }
58
+
59
+ /**
60
+ * Hash a token for storage
61
+ */
62
+ static hashToken(token) {
63
+ return crypto.createHash('sha256').update(token).digest('hex');
64
+ }
65
+
66
+ /**
67
+ * Parse duration string (1h, 1d, 7d, 30d, never) to milliseconds
68
+ */
69
+ static parseDuration(str) {
70
+ if (!str || str === 'never') return null;
71
+ const match = str.match(/^(\d+)(h|d)$/);
72
+ if (!match) throw new Error(`Invalid duration: ${str}`);
73
+ const [, num, unit] = match;
74
+ return unit === 'h'
75
+ ? parseInt(num) * 60 * 60 * 1000
76
+ : parseInt(num) * 24 * 60 * 60 * 1000;
77
+ }
78
+
79
+ /**
80
+ * Create a new federation token
81
+ *
82
+ * Default limits (anti-abuse):
83
+ * - Expires in 1 day
84
+ * - Max 100 calls total
85
+ * - Rate limited: 10/min, 100/hr, 1000/day (enforced server-side)
86
+ * - Timeout: 5-300 seconds (enforced server-side)
87
+ */
88
+ create(options = {}) {
89
+ const {
90
+ name = 'unnamed',
91
+ owner = null,
92
+ expires = '1d',
93
+ permissions = 'chat-only',
94
+ disclosure = 'minimal',
95
+ notify = 'all',
96
+ maxCalls = 100, // Default limit, not unlimited
97
+ // Snapshot of actual capabilities at creation time
98
+ allowedTopics = null, // Array of topic strings, e.g. ['chat', 'calendar.read']
99
+ tierSettings = null // Object with tier-specific settings
100
+ } = options;
101
+
102
+ const token = TokenStore.generateToken();
103
+ const tokenHash = TokenStore.hashToken(token);
104
+ const durationMs = TokenStore.parseDuration(expires);
105
+ const expiresAt = durationMs ? new Date(Date.now() + durationMs).toISOString() : null;
106
+
107
+ // Load tier definitions from config (if available)
108
+ let configTiers = {};
109
+ try {
110
+ const configPath = path.join(this.configDir, 'a2a-config.json');
111
+ if (fs.existsSync(configPath)) {
112
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
113
+ if (config.tiers) {
114
+ configTiers = config.tiers;
115
+ }
116
+ }
117
+ } catch (e) {
118
+ // Config not available, use defaults
119
+ }
120
+
121
+ // Default topics based on permissions tier (snapshot at creation)
122
+ // User config overrides these defaults
123
+ const defaultTopics = {
124
+ 'chat-only': ['chat'],
125
+ 'public': configTiers.public?.topics || ['chat'],
126
+ 'tools-read': ['chat', 'calendar.read', 'email.read', 'search'],
127
+ 'friends': configTiers.friends?.topics || ['chat', 'calendar.read', 'email.read', 'search'],
128
+ 'tools-write': ['chat', 'calendar', 'email', 'search', 'tools'],
129
+ 'family': configTiers.family?.topics || ['chat', 'calendar', 'email', 'search', 'tools']
130
+ };
131
+
132
+ // Normalize tier name
133
+ const tierAliases = {
134
+ 'public': 'chat-only',
135
+ 'friends': 'tools-read',
136
+ 'family': 'tools-write'
137
+ };
138
+ const normalizedTier = tierAliases[permissions] || permissions;
139
+
140
+ // Use separate random ID (not derived from token) to prevent prefix attacks
141
+ const record = {
142
+ id: 'tok_' + crypto.randomBytes(8).toString('hex'),
143
+ token_hash: tokenHash,
144
+ name,
145
+ owner,
146
+ tier: normalizedTier, // Normalized tier (chat-only, tools-read, tools-write)
147
+ tier_label: permissions, // Original label (public, friends, family)
148
+ allowed_topics: allowedTopics || defaultTopics[permissions] || ['chat'],
149
+ tier_settings: tierSettings || {}, // Snapshot of settings at creation
150
+ disclosure,
151
+ notify,
152
+ max_calls: maxCalls,
153
+ calls_made: 0,
154
+ created_at: new Date().toISOString(),
155
+ expires_at: expiresAt,
156
+ revoked: false
157
+ };
158
+
159
+ const db = this._load();
160
+ db.tokens.push(record);
161
+ this._save(db);
162
+
163
+ return { token, record };
164
+ }
165
+
166
+ /**
167
+ * List all tokens (optionally including revoked)
168
+ */
169
+ list(includeRevoked = false) {
170
+ const db = this._load();
171
+ return includeRevoked ? db.tokens : db.tokens.filter(t => !t.revoked);
172
+ }
173
+
174
+ /**
175
+ * Find a token by ID prefix
176
+ */
177
+ findById(idPrefix) {
178
+ const db = this._load();
179
+ return db.tokens.find(t => t.id === idPrefix || t.id.startsWith(idPrefix));
180
+ }
181
+
182
+ /**
183
+ * Validate an incoming token, returns validation result
184
+ */
185
+ validate(token) {
186
+ const db = this._load();
187
+ const tokenHash = TokenStore.hashToken(token);
188
+ const record = db.tokens.find(t => t.token_hash === tokenHash);
189
+
190
+ if (!record) {
191
+ return { valid: false, error: 'token_not_found' };
192
+ }
193
+
194
+ if (record.revoked) {
195
+ return { valid: false, error: 'token_revoked' };
196
+ }
197
+
198
+ if (record.expires_at && new Date(record.expires_at) < new Date()) {
199
+ return { valid: false, error: 'token_expired' };
200
+ }
201
+
202
+ if (record.max_calls && record.calls_made >= record.max_calls) {
203
+ return { valid: false, error: 'max_calls_exceeded' };
204
+ }
205
+
206
+ // Increment call count
207
+ record.calls_made++;
208
+ record.last_used = new Date().toISOString();
209
+ this._save(db);
210
+
211
+ return {
212
+ valid: true,
213
+ id: record.id,
214
+ name: record.name,
215
+ tier: record.tier || record.permissions, // backward compat
216
+ allowed_topics: record.allowed_topics || ['chat'],
217
+ tier_settings: record.tier_settings || {},
218
+ disclosure: record.disclosure,
219
+ notify: record.notify,
220
+ calls_remaining: record.max_calls ? record.max_calls - record.calls_made : null
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Revoke a token by ID
226
+ */
227
+ revoke(idPrefix) {
228
+ const db = this._load();
229
+ const record = db.tokens.find(t => t.id === idPrefix || t.id.startsWith(idPrefix));
230
+
231
+ if (!record) {
232
+ return { success: false, error: 'not_found' };
233
+ }
234
+
235
+ record.revoked = true;
236
+ record.revoked_at = new Date().toISOString();
237
+ this._save(db);
238
+
239
+ return { success: true, record };
240
+ }
241
+
242
+ /**
243
+ * Add a remote agent endpoint (contact)
244
+ * Note: Token is encrypted at rest using a derived key
245
+ *
246
+ * @param {string} inviteUrl - a2a://host/token format
247
+ * @param {object} options - Contact metadata
248
+ * @param {string} options.name - Agent name
249
+ * @param {string} options.owner - Human owner name
250
+ * @param {string} options.notes - Freeform notes
251
+ * @param {string[]} options.tags - Grouping tags
252
+ * @param {string} options.trust - Trust level (trusted, verified, unknown)
253
+ */
254
+ addRemote(inviteUrl, options = {}) {
255
+ // Handle legacy signature: addRemote(url, name)
256
+ if (typeof options === 'string') {
257
+ options = { name: options };
258
+ }
259
+
260
+ const match = inviteUrl.match(/^oclaw:\/\/([^/]+)\/(.+)$/);
261
+ if (!match) {
262
+ throw new Error(`Invalid invite URL: ${inviteUrl}`);
263
+ }
264
+
265
+ const [, host, token] = match;
266
+ const remoteName = options.name || host;
267
+
268
+ const db = this._load();
269
+
270
+ // Check for duplicate by host + token hash
271
+ const tokenHash = TokenStore.hashToken(token);
272
+ const existing = db.remotes.find(r => r.host === host && r.token_hash === tokenHash);
273
+ if (existing) {
274
+ return { success: false, error: 'duplicate', existing };
275
+ }
276
+
277
+ // Encrypt token for storage (simple XOR with derived key - not production-grade but better than plaintext)
278
+ const encryptionKey = crypto.createHash('sha256').update(this.dbPath + 'remote-key').digest();
279
+ const tokenBuffer = Buffer.from(token, 'utf8');
280
+ const encrypted = Buffer.alloc(tokenBuffer.length);
281
+ for (let i = 0; i < tokenBuffer.length; i++) {
282
+ encrypted[i] = tokenBuffer[i] ^ encryptionKey[i % encryptionKey.length];
283
+ }
284
+
285
+ const remote = {
286
+ id: crypto.randomBytes(8).toString('hex'),
287
+ name: remoteName,
288
+ owner: options.owner || null,
289
+ host,
290
+ token_hash: tokenHash,
291
+ token_enc: encrypted.toString('base64'),
292
+ notes: options.notes || null,
293
+ tags: options.tags || [],
294
+ linked_token_id: options.linkedTokenId || null, // Token you gave them
295
+ status: 'unknown',
296
+ last_seen: null,
297
+ added_at: new Date().toISOString()
298
+ };
299
+
300
+ db.remotes.push(remote);
301
+ this._save(db);
302
+
303
+ return { success: true, remote: { ...remote, token: undefined, token_enc: undefined } };
304
+ }
305
+
306
+ /**
307
+ * Decrypt a remote token
308
+ */
309
+ _decryptRemoteToken(remote) {
310
+ if (remote.token) return remote.token; // Legacy plaintext
311
+ if (!remote.token_enc) return null;
312
+
313
+ const encryptionKey = crypto.createHash('sha256').update(this.dbPath + 'remote-key').digest();
314
+ const encrypted = Buffer.from(remote.token_enc, 'base64');
315
+ const decrypted = Buffer.alloc(encrypted.length);
316
+ for (let i = 0; i < encrypted.length; i++) {
317
+ decrypted[i] = encrypted[i] ^ encryptionKey[i % encryptionKey.length];
318
+ }
319
+ return decrypted.toString('utf8');
320
+ }
321
+
322
+ /**
323
+ * List remote agents with linked token info
324
+ */
325
+ listRemotes() {
326
+ const db = this._load();
327
+ return db.remotes.map(r => {
328
+ if (r.linked_token_id) {
329
+ const token = db.tokens.find(t => t.id === r.linked_token_id);
330
+ if (token) {
331
+ return { ...r, linked_token: token };
332
+ }
333
+ }
334
+ return r;
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Link a token to a contact
340
+ */
341
+ linkTokenToContact(contactNameOrId, tokenId) {
342
+ const db = this._load();
343
+ const remote = db.remotes.find(r =>
344
+ r.name === contactNameOrId || r.id === contactNameOrId
345
+ );
346
+ const token = db.tokens.find(t =>
347
+ t.id === tokenId || t.id.startsWith(tokenId)
348
+ );
349
+
350
+ if (!remote) return { success: false, error: 'contact_not_found' };
351
+ if (!token) return { success: false, error: 'token_not_found' };
352
+
353
+ remote.linked_token_id = token.id;
354
+ this._save(db);
355
+ return { success: true, remote, token };
356
+ }
357
+
358
+ /**
359
+ * Get a remote by name or host (with decrypted token)
360
+ */
361
+ getRemote(nameOrHost) {
362
+ const db = this._load();
363
+ const remote = db.remotes.find(r =>
364
+ r.name === nameOrHost ||
365
+ r.host === nameOrHost ||
366
+ r.id === nameOrHost
367
+ );
368
+ if (!remote) return null;
369
+
370
+ // Return with decrypted token
371
+ return {
372
+ ...remote,
373
+ token: this._decryptRemoteToken(remote),
374
+ token_enc: undefined
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Update a remote agent's metadata
380
+ */
381
+ updateRemote(nameOrHost, updates) {
382
+ const db = this._load();
383
+ const remote = db.remotes.find(r =>
384
+ r.name === nameOrHost ||
385
+ r.host === nameOrHost ||
386
+ r.id === nameOrHost
387
+ );
388
+
389
+ if (!remote) {
390
+ return { success: false, error: 'not_found' };
391
+ }
392
+
393
+ // Only allow updating specific fields
394
+ const allowed = ['name', 'owner', 'notes', 'tags', 'linked_token_id'];
395
+ for (const key of allowed) {
396
+ if (updates[key] !== undefined) {
397
+ remote[key] = updates[key];
398
+ }
399
+ }
400
+ remote.updated_at = new Date().toISOString();
401
+
402
+ this._save(db);
403
+ return { success: true, remote };
404
+ }
405
+
406
+ /**
407
+ * Update contact status after ping/call
408
+ */
409
+ updateRemoteStatus(nameOrHost, status, error = null) {
410
+ const db = this._load();
411
+ const remote = db.remotes.find(r =>
412
+ r.name === nameOrHost ||
413
+ r.host === nameOrHost ||
414
+ r.id === nameOrHost
415
+ );
416
+
417
+ if (!remote) return;
418
+
419
+ remote.status = status; // 'online', 'offline', 'error'
420
+ remote.last_seen = status === 'online' ? new Date().toISOString() : remote.last_seen;
421
+ remote.last_error = error;
422
+ remote.last_check = new Date().toISOString();
423
+
424
+ this._save(db);
425
+ }
426
+
427
+ /**
428
+ * Remove a remote agent
429
+ */
430
+ removeRemote(nameOrHost) {
431
+ const db = this._load();
432
+ const idx = db.remotes.findIndex(r =>
433
+ r.name === nameOrHost ||
434
+ r.host === nameOrHost ||
435
+ r.id === nameOrHost
436
+ );
437
+
438
+ if (idx === -1) {
439
+ return { success: false, error: 'not_found' };
440
+ }
441
+
442
+ const [removed] = db.remotes.splice(idx, 1);
443
+ this._save(db);
444
+ return { success: true, remote: removed };
445
+ }
446
+ }
447
+
448
+ module.exports = { TokenStore };