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.
- package/AGENTS.md +66 -0
- package/CLAUDE.md +52 -0
- package/README.md +307 -0
- package/SKILL.md +122 -0
- package/bin/cli.js +908 -0
- package/docs/protocol.md +241 -0
- package/package.json +44 -0
- package/scripts/install-openclaw.js +291 -0
- package/src/index.js +61 -0
- package/src/lib/call-monitor.js +143 -0
- package/src/lib/client.js +208 -0
- package/src/lib/config.js +173 -0
- package/src/lib/conversations.js +470 -0
- package/src/lib/openclaw-integration.js +329 -0
- package/src/lib/summarizer.js +137 -0
- package/src/lib/tokens.js +448 -0
- package/src/routes/federation.js +463 -0
- package/src/server.js +56 -0
|
@@ -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 };
|