bom-pi 0.0.4 → 0.0.5

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.
@@ -169,6 +169,17 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
169
169
  };
170
170
  }
171
171
 
172
+ // title conversation = message user đầu (cùng logic sessionsPayload) — cho targetTitle webhook
173
+ async function convTitle(convId) {
174
+ try {
175
+ const raw = await rpc('transcript.raw', { conversationId: convId });
176
+ const first = (raw.entries ?? []).find((e) => e.kind === 'pi.user');
177
+ const m0 = first?.model?.[0];
178
+ const text = typeof m0?.content === 'string' ? m0.content : Array.isArray(m0?.content) ? m0.content.filter((p) => p?.type === 'text').map((p) => p.text).join(' ') : '';
179
+ return text ? text.split('\n')[0].slice(0, 60) : `session ${convId}`;
180
+ } catch { return `session ${convId}`; }
181
+ }
182
+
172
183
  const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }); res.end(JSON.stringify(obj)); };
173
184
  const readBody = (req) => new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => { try { done(JSON.parse(b || '{}')); } catch { done({}); } }); });
174
185
 
@@ -334,10 +345,28 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
334
345
  return true;
335
346
  }
336
347
 
348
+ // ---- webhooks (vòng 9 — port pi-pocket): list/add/rotate/remove; dispatch ở route public /hook/<id>/<secret> ----
349
+ if (first === 'hooks') {
350
+ if (second === undefined && method === 'GET') {
351
+ const r = await rpc('hooks.list');
352
+ const out = [];
353
+ for (const h of r.hooks ?? []) out.push({ ...h, targetTitle: await convTitle(h.target) });
354
+ return json(res, 200, out), true;
355
+ }
356
+ if (second === undefined && isPost) {
357
+ const body = await readBody(req);
358
+ return json(res, 200, await rpc('hooks.add', {
359
+ target: body.target, name: body.name, instructions: body.instructions,
360
+ ...(body.signed === true ? { signed: true } : {}),
361
+ })), true;
362
+ }
363
+ if (second !== undefined && third === 'rotate' && isPost) { await readBody(req); return json(res, 200, await rpc('hooks.rotate', { id: second })), true; }
364
+ if (second !== undefined && third === 'remove' && isPost) { await readBody(req); return json(res, 200, await rpc('hooks.remove', { id: second })), true; }
365
+ }
366
+
337
367
  // ---- stub rỗng: tính năng pocket chưa có ở bom-pi ----
338
368
  if (first === 'users' && method === 'GET') return json(res, 200, [OWNER]), true;
339
369
  if (first === 'subagents' && method === 'GET') return json(res, 200, []), true;
340
- if (first === 'hooks' && method === 'GET') return json(res, 200, []), true;
341
370
  if (first === 'folder-favorites' && method === 'GET') return json(res, 200, []), true;
342
371
  if (first === 'pi-sessions' && method === 'GET') return json(res, 200, []), true;
343
372
  if (first === 'fs') return json(res, 200, { entries: [] }), true; // PORT(bom-pi): file tree
@@ -726,6 +726,30 @@ const http = createHttpServer(async (req, res) => {
726
726
  try { return json2(res, 200, await rpc('pair.poll', { enrollId: p.enrollId })); }
727
727
  catch (e) { return json2(res, 404, { error: e.message.replace(/^\d+: /, '') }); }
728
728
  }
729
+ // ---- /hook/<id>[/<secret>]: webhook public (vòng 9 — port pi-pocket hooks, bản đơn).
730
+ // Dịch vụ ngoài (GitHub/CI/monitor/cron) POST ⇒ message vào conversation. KHÔNG token:
731
+ // secret nằm trong path (unsigned) hoặc chữ ký X-Hub-Signature-256 (signed) — daemon verify. ----
732
+ if (req.method === 'POST' && url.pathname.startsWith('/hook/')) {
733
+ const parts = url.pathname.slice('/hook/'.length).split('/').filter(Boolean);
734
+ if (parts.length < 1 || parts.length > 2 || parts.some((p) => p === '')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"Not found"}'); }
735
+ if (Number(req.headers['content-length'] ?? 0) > 5 * 1024 * 1024) { res.writeHead(413, { 'content-type': 'application/json' }); return res.end('{"error":"Request too large"}'); }
736
+ const body = await new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => done(b)); });
737
+ try {
738
+ const r = await rpc('hooks.dispatch', {
739
+ id: parts[0],
740
+ ...(parts[1] !== undefined ? { secret: parts[1] } : {}),
741
+ body,
742
+ signature: req.headers['x-hub-signature-256'],
743
+ delivery: req.headers['x-github-delivery'] ?? req.headers['idempotency-key'],
744
+ });
745
+ return json2(res, 200, r);
746
+ } catch (e) {
747
+ const msg = String(e?.message ?? e).replace(/^-?\d+: /, '');
748
+ if (/not found/.test(msg)) return json2(res, 404, { error: 'Not found' }); // không tiết lộ hook tồn tại与否
749
+ if (/rate-limited/.test(msg)) { res.writeHead(429, { 'content-type': 'application/json', 'retry-after': '60', ...CORS }); return res.end('{"error":"Too many posts this hour"}'); }
750
+ return json2(res, 502, { error: msg });
751
+ }
752
+ }
729
753
  // ---- UI pocket (public/): shell + module + vendor tĩnh, KHÔNG cần token ----
730
754
  // serveStatic phục vụ cả sw.js/manifest/icon của pocket (public/) — không còn hằng
731
755
  // SW cũ: sw.js pocket có push handler (notification từ push service).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bom-pi",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "description": "bom × pi — hệ sinh thái durable cho pi trên nền pi-durable: core daemon + web UI pocket + CLI",
6
6
  "engines": {
@@ -32,6 +32,13 @@ const cmd = process.argv[2] ?? 'status';
32
32
  const REMOTE_URL = arg('url', process.env.BOM_PI_URL || null);
33
33
  const REMOTE_TOKEN = arg('token', process.env.BOM_PI_TOKEN || null);
34
34
 
35
+ // -v/--version trước mọi thứ — user kiểm bản nào đang chạy (bài học: máy Windows kẹt bản cũ)
36
+ const VERSION = (await import('../../../package.json', { with: { type: 'json' } })).default.version;
37
+ if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
38
+ console.log(`bom-pi ${VERSION}${REMOTE_URL ? ` (remote ${REMOTE_URL})` : ''}`);
39
+ process.exit(0);
40
+ }
41
+
35
42
  // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
36
43
  // thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
37
44
  function unreachable(e) {
@@ -488,7 +495,7 @@ WantedBy=default.target
488
495
  } else if (cmd === 'shutdown') {
489
496
  pretty(await rpc('shutdown'));
490
497
  } else {
491
- console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|shutdown> [--socket PATH] [--cid N]');
498
+ console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|shutdown|version> [--socket PATH] [--cid N] [--url http://host:8791] [--token bom_…]\n từ thiết bị khác: BOM_PI_URL + BOM_PI_TOKEN (xem bom-pi version)');
492
499
  process.exit(64);
493
500
  }
494
501
  if (cmd !== 'watch') process.exit(0);
@@ -31,7 +31,7 @@ import { homedir, userInfo } from 'node:os';
31
31
  import process from 'node:process';
32
32
  import { execFile as execFileCb } from 'node:child_process';
33
33
  import { promisify } from 'node:util';
34
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes } from 'node:crypto';
34
+ import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
35
35
  import { createLimitGate } from './limit-gate.mjs';
36
36
 
37
37
  const VERSION = '0.0.4';
@@ -990,6 +990,37 @@ async function saveDevices() {
990
990
  await writeFile(tmp, JSON.stringify(webDevices, null, 2) + '\n');
991
991
  await rename(tmp, devicesPath);
992
992
  }
993
+
994
+ // ---- Webhooks (vòng 9 — port pi-pocket hooks.ts, bản đơn có chủ ý): địa chỉ mà dịch vụ
995
+ // ngoài (GitHub/CI/monitor/cron) POST đến ⇒ message vào conversation. Secret trong path
996
+ // (unsigned) hoặc chữ ký X-Hub-Signature-256 (signed, khuôn GitHub). PORT(bom-pi): lọc
997
+ // "người ngoài repo" của pocket (cần gh query writers) — vòng này post hợp lệ đều qua. ----
998
+ const hooksPath = join(dataDir, 'hooks.json');
999
+ let hooks = []; // [{id, secret, name, instructions, target, signed?, by, createdAt, count, lastAt?}]
1000
+ const hookSeen = new Map(); // idempotency: key → ts (delivery id / Idempotency-Key, 1h)
1001
+ const hookHits = new Map(); // rate-limit: hook id → [ts] (30 post/h, cửa sổ trượt)
1002
+ const HOOK_LIMITS = { max: 20, nameLen: 60, instructionsLen: 2000, perHour: 30, payloadChars: 8000 };
1003
+ const HOOK_DEFAULT_INSTRUCTIONS = 'Something happened that you were set up to hear about. Say in a line what it is, and whether anything needs doing.';
1004
+ async function loadHooks() {
1005
+ try { hooks = JSON.parse(await readFile(hooksPath, 'utf8')); } catch { hooks = []; }
1006
+ }
1007
+ async function saveHooks() {
1008
+ const tmp = hooksPath + '.tmp';
1009
+ await writeFile(tmp, JSON.stringify(hooks, null, 2) + '\n');
1010
+ await rename(tmp, hooksPath);
1011
+ }
1012
+ const hookLabel = (name) => String(name ?? '').replace(/[[\]]/g, '').replace(/\s+/g, ' ').trim();
1013
+ const sameSecret = (a, b) => {
1014
+ const l = createHash('sha256').update(String(a)).digest();
1015
+ const r = createHash('sha256').update(String(b)).digest();
1016
+ return timingSafeEqual(l, r) && String(a).length === String(b).length;
1017
+ };
1018
+ function hookView(h) {
1019
+ const { secret, ...rest } = h;
1020
+ // unsigned: secret nằm trong path (địa chỉ tự chịu); signed: path không secret —
1021
+ // secret hiển thị riêng để paste vào GitHub Webhook secret
1022
+ return h.signed === true ? { ...rest, path: `/hook/${h.id}`, secret } : { ...rest, path: `/hook/${h.id}/${secret}` };
1023
+ }
993
1024
  const sha256hex = (s) => createHash('sha256').update(s).digest('hex');
994
1025
  // Daemon keypair Ed25519 — sinh tại lần boot đầu (khuôn ssh host key / spin daemon):
995
1026
  // MÁY NÀO cài bom-pi cũng tự có cặp key riêng. fp = 8 hex đầu sha256(publicKey DER) —
@@ -1325,6 +1356,7 @@ try {
1325
1356
  await loadFactories();
1326
1357
  await loadMetrics(); // M6c: ledger số đo trust ladder
1327
1358
  await loadDevices(); // M8: thiết bị đã pair (web-devices.json)
1359
+ await loadHooks(); // vòng 9: webhook ngoài → conversation (hooks.json)
1328
1360
  await loadDaemonKeys(); // offer pair: keypair danh tính — fp + pubkey cho device đối chiếu
1329
1361
  if (factories.length) log('factories nạp lại:', factories.length, '—', factories.map((f) => f.id).join(', '));
1330
1362
  await loadConfigWatches(); // M5b: đăng lại watch config (scan-timer là M5d)
@@ -1570,6 +1602,97 @@ const methods = {
1570
1602
  await conv.configure(change, BACKGROUND_CONTEXT);
1571
1603
  return { ok: true, conversationId: conv.id };
1572
1604
  },
1605
+ // ---- Webhooks (vòng 9): quản lý qua CLI/web, dispatch từ route public /hook/<id>/<secret> ----
1606
+ async hooksList() {
1607
+ return { hooks: hooks.map(hookView) };
1608
+ },
1609
+ async hooksAdd(params) {
1610
+ const name = hookLabel(params?.name);
1611
+ const instructions = typeof params?.instructions === 'string' ? params.instructions.trim() : '';
1612
+ if (!name || name.length > HOOK_LIMITS.nameLen) throw new Error(`name: 1–${HOOK_LIMITS.nameLen} ký tự, một dòng`);
1613
+ if (instructions.length > HOOK_LIMITS.instructionsLen) throw new Error(`instructions ≤ ${HOOK_LIMITS.instructionsLen} ký tự`);
1614
+ const target = Number(params?.target);
1615
+ if (!Number.isSafeInteger(target)) throw new Error('target: conversation id (xem session.list)');
1616
+ await convFor({ conversationId: target }); // phải là conversation thật
1617
+ if (hooks.length >= HOOK_LIMITS.max) throw new Error(`tối đa ${HOOK_LIMITS.max} webhook — bỏ cái cũ trước (hooks.remove)`);
1618
+ // PORT(bom-pi): 'anyone' của pocket (lọc người ngoài repo qua gh) chưa có — bỏ qua
1619
+ const hook = {
1620
+ id: randomBytes(6).toString('base64url'),
1621
+ secret: randomBytes(24).toString('base64url'),
1622
+ name, instructions, target,
1623
+ ...(params?.signed === true ? { signed: true } : {}),
1624
+ by: 'bom-owner', createdAt: Date.now(), count: 0,
1625
+ };
1626
+ hooks.push(hook);
1627
+ await saveHooks();
1628
+ log('webhook tạo:', hook.id, '→ conv', target, hook.signed ? '(GitHub signed)' : '');
1629
+ return hookView(hook);
1630
+ },
1631
+ async hooksRemove({ id }) {
1632
+ const before = hooks.length;
1633
+ hooks = hooks.filter((h) => h.id !== String(id ?? ''));
1634
+ if (hooks.length === before) throw new Error('không có webhook đó — hooks.list');
1635
+ hookHits.delete(String(id));
1636
+ await saveHooks();
1637
+ return { ok: true };
1638
+ },
1639
+ async hooksRotate({ id }) {
1640
+ const h = hooks.find((x) => x.id === String(id ?? ''));
1641
+ if (!h) throw new Error('không có webhook đó — hooks.list');
1642
+ h.secret = randomBytes(24).toString('base64url');
1643
+ await saveHooks();
1644
+ log('webhook xoay secret:', h.id);
1645
+ return hookView(h);
1646
+ },
1647
+ async hooksDispatch({ id, secret, body, signature, delivery }) {
1648
+ const h = hooks.find((x) => x.id === String(id ?? ''));
1649
+ // Không tiết lộ hook có tồn tại hay không — 404 chung chung như pocket
1650
+ if (!h) throw new Error('not found');
1651
+ const text = String(body ?? '');
1652
+ if (secret !== undefined) {
1653
+ if (h.signed === true || !sameSecret(secret, h.secret)) throw new Error('not found');
1654
+ } else {
1655
+ // Khuôn GitHub: X-Hub-Signature-256 = sha256=HMAC_SHA256(secret, body)
1656
+ const sig = String(signature ?? '');
1657
+ const expect = `sha256=${createHmac('sha256', h.secret).update(text).digest('hex')}`;
1658
+ if (h.signed !== true || !sameSecret(sig, expect)) throw new Error('not found');
1659
+ }
1660
+ // Idempotency (GitHub delivery id / Idempotency-Key): post lại không thành message thứ hai
1661
+ const key = delivery !== undefined && delivery !== '' ? String(delivery) : null;
1662
+ if (key) {
1663
+ const seen = hookSeen.get(`${h.id}:${key}`);
1664
+ if (seen && Date.now() - seen < 3_600_000) return { ok: true, duplicate: true, conversationId: h.target };
1665
+ hookSeen.set(`${h.id}:${key}`, Date.now());
1666
+ if (hookSeen.size > 500) { // prune — giữ map nhỏ
1667
+ const cut = Date.now() - 3_600_000;
1668
+ for (const [k, ts] of hookSeen) if (ts < cut) hookSeen.delete(k);
1669
+ }
1670
+ }
1671
+ // Rate-limit 30 post/h mỗi hook (cửa sổ trượt)
1672
+ const now = Date.now();
1673
+ const hits = (hookHits.get(h.id) ?? []).filter((t) => now - t < 3_600_000);
1674
+ if (hits.length >= HOOK_LIMITS.perHour) throw new Error('rate-limited: too many posts this hour');
1675
+ hits.push(now);
1676
+ hookHits.set(h.id, hits);
1677
+ // Message khuôn pocket: nhãn + instructions + payload fence (đọc như DATA, không phải lệnh)
1678
+ const payload = text.length > HOOK_LIMITS.payloadChars ? `${text.slice(0, HOOK_LIMITS.payloadChars)}\n… (clipped)` : text;
1679
+ const message = `[via webhook ${h.name}]\n${h.instructions || HOOK_DEFAULT_INSTRUCTIONS}\n\n<webhook-payload>\n${payload}\n</webhook-payload>`;
1680
+ try {
1681
+ await methods.sessionSubmit({
1682
+ conversationId: h.target,
1683
+ text: message,
1684
+ ...(key ? { requestId: `hook-${h.id}-${key}` } : {}), // durable idempotent khi có delivery id
1685
+ });
1686
+ } catch (e) {
1687
+ hookSeen.delete(`${h.id}:${key}`); // không vào transcript ⇒ lần sau thử lại được
1688
+ throw new Error(`không submit được: ${e.message}`);
1689
+ }
1690
+ h.count += 1;
1691
+ h.lastAt = now;
1692
+ await saveHooks();
1693
+ return { ok: true, conversationId: h.target };
1694
+ },
1695
+
1573
1696
  async sessionSubmit(params) {
1574
1697
  const conv = await convFor(params);
1575
1698
  const text = typeof params.text === 'string' ? params.text.trim() : '';