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
|
package/apps/web/server.mjs
CHANGED
|
@@ -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
|
@@ -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() : '';
|