a2acalling 0.6.1 → 0.6.3

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.
@@ -26,19 +26,26 @@
26
26
  <h2>Contacts</h2>
27
27
  <button id="refresh-contacts">Refresh</button>
28
28
  </div>
29
- <table id="contacts-table">
30
- <thead>
31
- <tr>
32
- <th>Name</th>
33
- <th>Owner</th>
34
- <th>Status</th>
35
- <th>Calls</th>
36
- <th>Last Summary</th>
37
- </tr>
38
- </thead>
39
- <tbody></tbody>
40
- </table>
41
- <div id="contact-calls"></div>
29
+
30
+ <div class="card">
31
+ <h3>Add Contact</h3>
32
+ <form id="add-contact-form">
33
+ <label>Invite URL <input id="add-contact-url" type="text" placeholder="a2a://host/token" required></label>
34
+ <label>Agent name <input id="add-contact-name" type="text" placeholder="Agent name (optional)"></label>
35
+ <label>Owner name <input id="add-contact-owner" type="text" placeholder="Human owner (optional)"></label>
36
+ <label><input id="add-contact-mine" type="checkbox"> Mark as mine (personal agent)</label>
37
+ <label>Server name (my agents only) <input id="add-contact-server-name" type="text" placeholder="Server label (optional)"></label>
38
+ <label>Tags <input id="add-contact-tags" type="text" placeholder="comma,separated (optional)"></label>
39
+ <label>Notes <textarea id="add-contact-notes" rows="3" placeholder="Notes (optional)"></textarea></label>
40
+ <label>Fields (JSON) <textarea id="add-contact-fields" rows="4" placeholder='{"email":"...","company":"..."} (optional)'></textarea></label>
41
+ <div class="row">
42
+ <button type="submit">Add</button>
43
+ </div>
44
+ </form>
45
+ </div>
46
+
47
+ <div id="contacts-sections"></div>
48
+ <div id="contact-detail" class="card"></div>
42
49
  </section>
43
50
 
44
51
  <section id="tab-calls" class="panel">
@@ -160,6 +167,43 @@
160
167
  <button type="submit">Create Tier</button>
161
168
  </div>
162
169
  </form>
170
+
171
+ <h3>Remote Callbook</h3>
172
+ <div id="callbook-status" class="card"></div>
173
+
174
+ <form id="callbook-provision-form" class="card">
175
+ <div class="row">
176
+ <button type="submit">Create Install Link (24h)</button>
177
+ <button id="callbook-refresh" type="button">Refresh</button>
178
+ <button id="callbook-logout" type="button">Logout This Browser</button>
179
+ </div>
180
+ <label>Device label <input id="callbook-label" type="text" value="Callbook Remote"></label>
181
+ <label>Install URL<textarea id="callbook-install-url" rows="3" readonly></textarea></label>
182
+ <div class="row">
183
+ <button id="callbook-copy-url" type="button">Copy Link</button>
184
+ </div>
185
+ <div id="callbook-warnings" class="mono"></div>
186
+ </form>
187
+
188
+ <div class="card">
189
+ <div class="row">
190
+ <strong>Paired Devices</strong>
191
+ <button id="callbook-refresh-devices" type="button">Refresh Devices</button>
192
+ </div>
193
+ <table id="callbook-devices-table">
194
+ <thead>
195
+ <tr>
196
+ <th>Label</th>
197
+ <th>Created</th>
198
+ <th>Last Used</th>
199
+ <th>Sessions</th>
200
+ <th>Revoked</th>
201
+ <th>Action</th>
202
+ </tr>
203
+ </thead>
204
+ <tbody></tbody>
205
+ </table>
206
+ </div>
163
207
  </section>
164
208
 
165
209
  <section id="tab-invites" class="panel">
@@ -140,6 +140,18 @@ button:hover {
140
140
  color: var(--accent);
141
141
  }
142
142
 
143
+ .btn-link {
144
+ border: none;
145
+ background: none;
146
+ padding: 0;
147
+ color: var(--accent);
148
+ cursor: pointer;
149
+ }
150
+
151
+ .btn-link:hover {
152
+ text-decoration: underline;
153
+ }
154
+
143
155
  table {
144
156
  width: 100%;
145
157
  border-collapse: collapse;
@@ -159,6 +171,10 @@ th {
159
171
  background: #f8fafc;
160
172
  }
161
173
 
174
+ tr[data-selected="1"] td {
175
+ background: #f0f7ff;
176
+ }
177
+
162
178
  .summary {
163
179
  max-width: 500px;
164
180
  white-space: pre-wrap;
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Callbook Remote Auth
3
+ *
4
+ * Purpose:
5
+ * - Allow a remote owner UI ("Callbook Remote") to manage the dashboard safely.
6
+ * - Provisioning codes are short-lived and one-time use (safe-ish to share as an install link).
7
+ * - Sessions are long-lived (no expiration by default) and revocable.
8
+ *
9
+ * Storage:
10
+ * - SQLite DB at ~/.config/openclaw/a2a-callbook.db (or $A2A_CONFIG_DIR).
11
+ *
12
+ * Notes:
13
+ * - Secrets are never stored plaintext: only SHA-256 hashes are persisted.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
21
+ process.env.OPENCLAW_CONFIG_DIR ||
22
+ path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
23
+
24
+ const DB_FILENAME = 'a2a-callbook.db';
25
+
26
+ function nowIso() {
27
+ return new Date().toISOString();
28
+ }
29
+
30
+ function sha256Hex(value) {
31
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex');
32
+ }
33
+
34
+ function randomToken(prefix = '') {
35
+ const token = crypto.randomBytes(32).toString('base64url');
36
+ return prefix ? `${prefix}${token}` : token;
37
+ }
38
+
39
+ class CallbookStore {
40
+ constructor(configDir = DEFAULT_CONFIG_DIR, options = {}) {
41
+ this.configDir = configDir;
42
+ this.dbPath = options.dbPath || path.join(configDir, DB_FILENAME);
43
+ this.db = null;
44
+ this._dbError = null;
45
+ this._stmts = null;
46
+ this._ensureDir();
47
+ }
48
+
49
+ _ensureDir() {
50
+ if (!fs.existsSync(this.configDir)) {
51
+ fs.mkdirSync(this.configDir, { recursive: true });
52
+ }
53
+ }
54
+
55
+ _initDb() {
56
+ if (this.db) return this.db;
57
+ if (this._dbError) return null;
58
+ try {
59
+ const Database = require('better-sqlite3');
60
+ this.db = new Database(this.dbPath);
61
+ try {
62
+ fs.chmodSync(this.dbPath, 0o600);
63
+ } catch (err) {
64
+ // best effort
65
+ }
66
+ this._migrate();
67
+ this._prepareStatements();
68
+ return this.db;
69
+ } catch (err) {
70
+ this._dbError = err && err.message ? err.message : 'failed_to_initialize_callbook_db';
71
+ return null;
72
+ }
73
+ }
74
+
75
+ _migrate() {
76
+ this.db.exec(`
77
+ PRAGMA journal_mode = WAL;
78
+
79
+ CREATE TABLE IF NOT EXISTS provision_codes (
80
+ id TEXT PRIMARY KEY,
81
+ code_hash TEXT NOT NULL UNIQUE,
82
+ label TEXT,
83
+ created_at TEXT NOT NULL,
84
+ expires_at TEXT NOT NULL,
85
+ used_at TEXT
86
+ );
87
+
88
+ CREATE INDEX IF NOT EXISTS idx_provision_codes_expires ON provision_codes(expires_at);
89
+ CREATE INDEX IF NOT EXISTS idx_provision_codes_used ON provision_codes(used_at);
90
+
91
+ CREATE TABLE IF NOT EXISTS devices (
92
+ id TEXT PRIMARY KEY,
93
+ label TEXT,
94
+ created_at TEXT NOT NULL,
95
+ revoked_at TEXT,
96
+ last_used_at TEXT
97
+ );
98
+
99
+ CREATE INDEX IF NOT EXISTS idx_devices_revoked ON devices(revoked_at);
100
+
101
+ CREATE TABLE IF NOT EXISTS sessions (
102
+ id TEXT PRIMARY KEY,
103
+ device_id TEXT NOT NULL,
104
+ token_hash TEXT NOT NULL UNIQUE,
105
+ created_at TEXT NOT NULL,
106
+ revoked_at TEXT,
107
+ last_used_at TEXT,
108
+ FOREIGN KEY(device_id) REFERENCES devices(id)
109
+ );
110
+
111
+ CREATE INDEX IF NOT EXISTS idx_sessions_device_id ON sessions(device_id);
112
+ CREATE INDEX IF NOT EXISTS idx_sessions_revoked ON sessions(revoked_at);
113
+ `);
114
+ }
115
+
116
+ _prepareStatements() {
117
+ this._stmts = {
118
+ insertProvision: this.db.prepare(
119
+ `INSERT INTO provision_codes (id, code_hash, label, created_at, expires_at, used_at)
120
+ VALUES (@id, @code_hash, @label, @created_at, @expires_at, NULL)`
121
+ ),
122
+ findProvisionByHash: this.db.prepare(
123
+ `SELECT * FROM provision_codes WHERE code_hash = ?`
124
+ ),
125
+ markProvisionUsed: this.db.prepare(
126
+ `UPDATE provision_codes SET used_at = @used_at WHERE id = @id AND used_at IS NULL`
127
+ ),
128
+
129
+ insertDevice: this.db.prepare(
130
+ `INSERT INTO devices (id, label, created_at, revoked_at, last_used_at)
131
+ VALUES (@id, @label, @created_at, NULL, NULL)`
132
+ ),
133
+ getDevice: this.db.prepare(
134
+ `SELECT * FROM devices WHERE id = ?`
135
+ ),
136
+ listDevices: this.db.prepare(
137
+ `SELECT
138
+ d.id,
139
+ d.label,
140
+ d.created_at,
141
+ d.revoked_at,
142
+ d.last_used_at,
143
+ (SELECT COUNT(*) FROM sessions s WHERE s.device_id = d.id AND s.revoked_at IS NULL) AS active_sessions
144
+ FROM devices d
145
+ ORDER BY d.created_at DESC
146
+ LIMIT ?`
147
+ ),
148
+ updateDeviceLastUsed: this.db.prepare(
149
+ `UPDATE devices SET last_used_at = ? WHERE id = ?`
150
+ ),
151
+ revokeDevice: this.db.prepare(
152
+ `UPDATE devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`
153
+ ),
154
+
155
+ insertSession: this.db.prepare(
156
+ `INSERT INTO sessions (id, device_id, token_hash, created_at, revoked_at, last_used_at)
157
+ VALUES (@id, @device_id, @token_hash, @created_at, NULL, NULL)`
158
+ ),
159
+ findSessionByHash: this.db.prepare(
160
+ `SELECT * FROM sessions WHERE token_hash = ?`
161
+ ),
162
+ updateSessionLastUsed: this.db.prepare(
163
+ `UPDATE sessions SET last_used_at = ? WHERE id = ?`
164
+ ),
165
+ revokeSessionsByDevice: this.db.prepare(
166
+ `UPDATE sessions SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL`
167
+ )
168
+ };
169
+ }
170
+
171
+ isAvailable() {
172
+ return Boolean(this._initDb());
173
+ }
174
+
175
+ getDbError() {
176
+ this._initDb();
177
+ return this._dbError;
178
+ }
179
+
180
+ /**
181
+ * Create a one-time provisioning code (default TTL: 24 hours).
182
+ * Returns { code, record } where code is plaintext (show once).
183
+ */
184
+ createProvisionCode(options = {}) {
185
+ const db = this._initDb();
186
+ if (!db) {
187
+ return { success: false, error: 'callbook_storage_unavailable', message: this._dbError };
188
+ }
189
+
190
+ const label = options.label ? String(options.label).trim().slice(0, 120) : null;
191
+ const ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : (24 * 60 * 60 * 1000);
192
+ const createdAt = nowIso();
193
+ const expiresAt = new Date(Date.now() + Math.max(1, ttlMs)).toISOString();
194
+
195
+ const code = randomToken('cbk_');
196
+ const record = {
197
+ id: `cbkprov_${crypto.randomBytes(10).toString('hex')}`,
198
+ code_hash: sha256Hex(code),
199
+ label,
200
+ created_at: createdAt,
201
+ expires_at: expiresAt
202
+ };
203
+
204
+ this._stmts.insertProvision.run(record);
205
+
206
+ return {
207
+ success: true,
208
+ code,
209
+ record: {
210
+ id: record.id,
211
+ label: record.label,
212
+ created_at: record.created_at,
213
+ expires_at: record.expires_at,
214
+ used_at: null
215
+ }
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Exchange a provisioning code for a long-lived session token.
221
+ * Returns { sessionToken, device } on success.
222
+ */
223
+ exchangeProvisionCode(code, options = {}) {
224
+ const db = this._initDb();
225
+ if (!db) {
226
+ return { success: false, error: 'callbook_storage_unavailable', message: this._dbError };
227
+ }
228
+
229
+ const raw = String(code || '').trim();
230
+ if (!raw) {
231
+ return { success: false, error: 'missing_code' };
232
+ }
233
+
234
+ const codeHash = sha256Hex(raw);
235
+ const found = this._stmts.findProvisionByHash.get(codeHash);
236
+ if (!found) {
237
+ return { success: false, error: 'invalid_code' };
238
+ }
239
+
240
+ const now = Date.now();
241
+ const expiresMs = Date.parse(found.expires_at);
242
+ if (Number.isFinite(expiresMs) && now > expiresMs) {
243
+ return { success: false, error: 'code_expired' };
244
+ }
245
+ if (found.used_at) {
246
+ return { success: false, error: 'code_already_used' };
247
+ }
248
+
249
+ const usedAt = nowIso();
250
+ const tx = db.transaction(() => {
251
+ const marked = this._stmts.markProvisionUsed.run({ id: found.id, used_at: usedAt });
252
+ if (!marked || marked.changes !== 1) {
253
+ // Another request raced us.
254
+ return { success: false, error: 'code_already_used' };
255
+ }
256
+
257
+ const deviceId = `cbkdev_${crypto.randomBytes(10).toString('hex')}`;
258
+ const deviceLabel = (options.label ? String(options.label).trim().slice(0, 120) : null) || found.label || null;
259
+ this._stmts.insertDevice.run({
260
+ id: deviceId,
261
+ label: deviceLabel,
262
+ created_at: usedAt
263
+ });
264
+
265
+ const sessionToken = randomToken('cbksess_');
266
+ const sessionId = `cbks_${crypto.randomBytes(10).toString('hex')}`;
267
+ this._stmts.insertSession.run({
268
+ id: sessionId,
269
+ device_id: deviceId,
270
+ token_hash: sha256Hex(sessionToken),
271
+ created_at: usedAt
272
+ });
273
+
274
+ return {
275
+ success: true,
276
+ sessionToken,
277
+ device: {
278
+ id: deviceId,
279
+ label: deviceLabel,
280
+ created_at: usedAt,
281
+ revoked_at: null,
282
+ last_used_at: null
283
+ }
284
+ };
285
+ });
286
+
287
+ return tx();
288
+ }
289
+
290
+ /**
291
+ * Validate a session token from a cookie.
292
+ * Returns { valid, session, device }.
293
+ */
294
+ validateSession(sessionToken) {
295
+ const db = this._initDb();
296
+ if (!db) {
297
+ return { valid: false, error: 'callbook_storage_unavailable' };
298
+ }
299
+
300
+ const raw = String(sessionToken || '').trim();
301
+ if (!raw) return { valid: false, error: 'missing_session' };
302
+
303
+ const session = this._stmts.findSessionByHash.get(sha256Hex(raw));
304
+ if (!session) return { valid: false, error: 'invalid_session' };
305
+ if (session.revoked_at) return { valid: false, error: 'session_revoked' };
306
+
307
+ const device = this._stmts.getDevice.get(session.device_id);
308
+ if (!device) return { valid: false, error: 'device_not_found' };
309
+ if (device.revoked_at) return { valid: false, error: 'device_revoked' };
310
+
311
+ const usedAt = nowIso();
312
+ try {
313
+ this._stmts.updateSessionLastUsed.run(usedAt, session.id);
314
+ this._stmts.updateDeviceLastUsed.run(usedAt, device.id);
315
+ } catch (err) {
316
+ // best effort
317
+ }
318
+
319
+ return { valid: true, session: { id: session.id, device_id: session.device_id }, device };
320
+ }
321
+
322
+ listDevices(options = {}) {
323
+ const db = this._initDb();
324
+ if (!db) {
325
+ return { success: false, error: 'callbook_storage_unavailable', message: this._dbError, devices: [] };
326
+ }
327
+ const limit = Math.min(500, Math.max(1, Number.parseInt(String(options.limit || '200'), 10) || 200));
328
+ const rows = this._stmts.listDevices.all(limit);
329
+ const includeRevoked = Boolean(options.includeRevoked);
330
+ const devices = includeRevoked ? rows : rows.filter(r => !r.revoked_at);
331
+ return { success: true, devices };
332
+ }
333
+
334
+ revokeDevice(deviceId) {
335
+ const db = this._initDb();
336
+ if (!db) {
337
+ return { success: false, error: 'callbook_storage_unavailable', message: this._dbError };
338
+ }
339
+ const id = String(deviceId || '').trim();
340
+ if (!id) return { success: false, error: 'device_id_required' };
341
+ const revokedAt = nowIso();
342
+ const tx = db.transaction(() => {
343
+ const dev = this._stmts.getDevice.get(id);
344
+ if (!dev) return { success: false, error: 'device_not_found' };
345
+ this._stmts.revokeDevice.run(revokedAt, id);
346
+ this._stmts.revokeSessionsByDevice.run(revokedAt, id);
347
+ return { success: true, device: { ...dev, revoked_at: revokedAt } };
348
+ });
349
+ return tx();
350
+ }
351
+ }
352
+
353
+ module.exports = {
354
+ DEFAULT_CONFIG_DIR,
355
+ DB_FILENAME,
356
+ CallbookStore
357
+ };
358
+
package/src/lib/client.js CHANGED
@@ -60,8 +60,7 @@ class A2AClient {
60
60
  * Parse an a2a:// URL
61
61
  */
62
62
  static parseInvite(inviteUrl) {
63
- // Support both a2a:// and legacy oclaw:// schemes
64
- const match = inviteUrl.match(/^(?:a2a|oclaw):\/\/([^/]+)\/(.+)$/);
63
+ const match = inviteUrl.match(/^a2a:\/\/([^/]+)\/(.+)$/);
65
64
  if (!match) {
66
65
  throw new Error(`Invalid invite URL: ${inviteUrl}`);
67
66
  }