bom-pi 0.0.6 → 0.0.7
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/apps/web/server.mjs +1 -1
- package/package.json +1 -1
- package/packages/cli/src/bom-pi.mjs +117 -26
- package/packages/core/src/daemon.mjs +129 -22
package/apps/web/server.mjs
CHANGED
|
@@ -657,7 +657,7 @@ button{width:100%;background:#1f6feb;color:#fff;border:0;border-radius:8px;paddi
|
|
|
657
657
|
function guessName(){
|
|
658
658
|
const u=navigator.userAgent;
|
|
659
659
|
const os=/iPad/.test(u)?'iPad':/iPhone/.test(u)?'iPhone':/Android/.test(u)?'Android':/Windows/.test(u)?'Windows':/Mac/.test(u)?'Mac':'Device';
|
|
660
|
-
const br=/Edg
|
|
660
|
+
const br=/Edg\\//.test(u)?'Edge':/CriOS|Chrome\\//.test(u)?'Chrome':/FxiOS|Firefox\\//.test(u)?'Firefox':/Safari\\//.test(u)?'Safari':'Browser';
|
|
661
661
|
return os+' · '+br;
|
|
662
662
|
}
|
|
663
663
|
if(!code)return;
|
package/package.json
CHANGED
|
@@ -47,23 +47,23 @@ if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
|
|
|
47
47
|
process.exit(0);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// Không thấy daemon: hướng dẫn theo nơi đang chạy — máy
|
|
51
|
-
// thiết bị khác (
|
|
50
|
+
// Không thấy daemon: hướng dẫn theo nơi đang chạy — máy này (chưa start?) hay
|
|
51
|
+
// thiết bị khác (pair remote một lần).
|
|
52
52
|
function unreachable(e) {
|
|
53
|
-
const head = `cannot reach bom-core at ${socketPath}: ${e.message}`;
|
|
53
|
+
const head = `cannot reach bom-core at ${socketPath} (và http://127.0.0.1:8790): ${e.message}`;
|
|
54
54
|
if (process.platform === 'win32') {
|
|
55
55
|
return `${head}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
(hoặc dùng WEB đầy đủ: mở http://<ip-máy-chủ>:8791 — pair bằng cùng code đó)`;
|
|
57
|
+
daemon chưa chạy trên máy này. Mở terminal khác và chạy: bom-core
|
|
58
|
+
(giữ cửa sổ mở — daemon HTTP loopback 127.0.0.1:8790; sau này sẽ có service tự khởi động)
|
|
59
|
+
hoặc điều khiển MÁY KHÁC: pair một lần bằng
|
|
60
|
+
bom-pi pair <CODE> --url http://<ip-máy-khác>:8791`;
|
|
62
61
|
}
|
|
63
62
|
return `${head}
|
|
64
63
|
|
|
65
64
|
daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
|
|
66
65
|
chưa cài daemon?: bom-pi setup
|
|
66
|
+
chạy tay không systemd?: bom-core
|
|
67
67
|
ở THIẾT BỊ KHÁC?: pair một lần rồi mọi lệnh chạy remote:
|
|
68
68
|
bom-pi pair <CODE> --url http://<ip-máy-chạy-daemon>:8791`;
|
|
69
69
|
}
|
|
@@ -98,10 +98,57 @@ function rpcSocket(method, params) {
|
|
|
98
98
|
});
|
|
99
99
|
// Daemon chết giữa chừng / đóng connection không trả lời
|
|
100
100
|
conn.on('close', () => settle(fail, new Error('bom-core closed the connection without a reply')));
|
|
101
|
-
conn.on('error', (e) =>
|
|
101
|
+
conn.on('error', (e) => {
|
|
102
|
+
const err = new Error(unreachable(e));
|
|
103
|
+
err.code = e.code; // giữ code (ENOENT/ECONNREFUSED) để dispatcher fallback HTTP
|
|
104
|
+
settle(fail, err);
|
|
105
|
+
});
|
|
102
106
|
});
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
// HTTP loopback (Windows / nơi không có unix socket): cùng /rpc, bearer http.token
|
|
110
|
+
// của daemon trên MÁY NÀY — token tự sinh lúc daemon khởi động, mode 0600.
|
|
111
|
+
const LOCAL_HTTP = `http://127.0.0.1:${arg('port', process.env.BOM_PI_PORT || 8790)}`;
|
|
112
|
+
const dataDir = process.env.BOM_PI_DATA_DIR || join(homedir(), '.local/state/bom-pi');
|
|
113
|
+
const httpTokenPath = join(dataDir, 'http.token');
|
|
114
|
+
let httpToken = null;
|
|
115
|
+
async function localHttpToken() {
|
|
116
|
+
if (httpToken !== null) return httpToken;
|
|
117
|
+
httpToken = (await readFile(httpTokenPath, 'utf8')).trim();
|
|
118
|
+
return httpToken;
|
|
119
|
+
}
|
|
120
|
+
async function rpcHttp(method, params) {
|
|
121
|
+
const token = await localHttpToken(); // ENOENT ⇒ không có daemon HTTP ⇒ lỗi unreachable gốc
|
|
122
|
+
let r;
|
|
123
|
+
try {
|
|
124
|
+
r = await fetch(`${LOCAL_HTTP}/rpc`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'content-type': 'application/json', Authorization: `Bearer ${token}` },
|
|
127
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const err = new Error(`cannot reach bom-core at ${LOCAL_HTTP}: ${e.message}`);
|
|
131
|
+
err.code = e.code;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
const j = await r.json().catch(() => ({}));
|
|
135
|
+
if (!r.ok) throw new Error(`bom-core ${r.status}: ${j?.error?.message ?? r.statusText}`);
|
|
136
|
+
if (j.error) throw new Error(`bom-core error ${j.error.code}: ${j.error.message}`);
|
|
137
|
+
return j.result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Local: ưu tiên unix socket; socket không có (Windows / chạy không socket) → HTTP loopback
|
|
141
|
+
async function rpcLocal(method, params) {
|
|
142
|
+
try {
|
|
143
|
+
return await rpcSocket(method, params);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e?.code === 'ENOENT' || e?.code === 'ECONNREFUSED') {
|
|
146
|
+
try { return await rpcHttp(method, params); } catch { /* rơi xuống lỗi socket gốc cho đúng thông điệp */ }
|
|
147
|
+
}
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
105
152
|
const pretty = (o) => console.log(JSON.stringify(o, null, 2));
|
|
106
153
|
|
|
107
154
|
// Dispatcher: remote (/rpc qua HTTP) khi BOM_PI_URL đặt — thiết bị khác không có unix socket
|
|
@@ -120,7 +167,7 @@ async function rpcRemote(method, params) {
|
|
|
120
167
|
if (!r.ok) throw new Error(`bom-web ${REMOTE_URL} ${r.status}: ${j?.error?.message ?? r.statusText}`);
|
|
121
168
|
return j;
|
|
122
169
|
}
|
|
123
|
-
const rpc = REMOTE_URL ? rpcRemote :
|
|
170
|
+
const rpc = REMOTE_URL ? rpcRemote : rpcLocal;
|
|
124
171
|
|
|
125
172
|
// Kiểm protocol sau hello — mọi lệnh đều chạy qua đây
|
|
126
173
|
function checkProtocol(hello) {
|
|
@@ -134,11 +181,32 @@ function checkProtocol(hello) {
|
|
|
134
181
|
// ---- watch: mở 1 connection, subscribe, in transcript live đến khi Ctrl+C ----
|
|
135
182
|
function watch() {
|
|
136
183
|
if (REMOTE_URL) {
|
|
137
|
-
console.error('watch cần kết nối
|
|
184
|
+
console.error('watch cần kết nối local — từ thiết bị khác hãy mở web (transcript live có sẵn trong UI)');
|
|
138
185
|
process.exit(64);
|
|
139
186
|
}
|
|
140
|
-
const conn = connect(socketPath);
|
|
141
187
|
let gotReply = false;
|
|
188
|
+
const onMsg = (msg) => {
|
|
189
|
+
if (msg.id === 1 && !gotReply) {
|
|
190
|
+
gotReply = true;
|
|
191
|
+
if (msg.error) { console.error(`bom-core error ${msg.error.code}: ${msg.error.message}`); process.exit(1); }
|
|
192
|
+
const { conversationId, seq, snapshot } = msg.result;
|
|
193
|
+
console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
|
|
194
|
+
for (const e of snapshot) printEntry(e);
|
|
195
|
+
} else if (msg.id === null && msg.result && !gotReply) {
|
|
196
|
+
// dòng result đầu của stream /events (cùng shape reply events.subscribe)
|
|
197
|
+
gotReply = true;
|
|
198
|
+
const { conversationId, seq, snapshot } = msg.result;
|
|
199
|
+
console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
|
|
200
|
+
for (const e of snapshot) printEntry(e);
|
|
201
|
+
} else if (msg.method === 'events.update') {
|
|
202
|
+
const { conversationId, seq, entries } = msg.params;
|
|
203
|
+
console.error(`--- ${conversationId} seq ${seq}`);
|
|
204
|
+
for (const e of entries) printEntry(e);
|
|
205
|
+
} else if (msg.method === 'events.heartbeat') {
|
|
206
|
+
console.error(`· heartbeat seq ${msg.params.seq}`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const conn = connect(socketPath);
|
|
142
210
|
conn.on('connect', () => {
|
|
143
211
|
conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: cidParams() }) + '\n');
|
|
144
212
|
});
|
|
@@ -147,24 +215,39 @@ function watch() {
|
|
|
147
215
|
if (!raw.trim()) continue;
|
|
148
216
|
let msg;
|
|
149
217
|
try { msg = JSON.parse(raw); } catch { continue; }
|
|
150
|
-
|
|
151
|
-
gotReply = true;
|
|
152
|
-
if (msg.error) { console.error(`bom-core error ${msg.error.code}: ${msg.error.message}`); process.exit(1); }
|
|
153
|
-
const { conversationId, seq, snapshot } = msg.result;
|
|
154
|
-
console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
|
|
155
|
-
for (const e of snapshot) printEntry(e);
|
|
156
|
-
} else if (msg.method === 'events.update') {
|
|
157
|
-
const { conversationId, seq, entries } = msg.params;
|
|
158
|
-
console.error(`--- ${conversationId} seq ${seq}`);
|
|
159
|
-
for (const e of entries) printEntry(e);
|
|
160
|
-
} else if (msg.method === 'events.heartbeat') {
|
|
161
|
-
console.error(`· heartbeat seq ${msg.params.seq}`);
|
|
162
|
-
}
|
|
218
|
+
onMsg(msg);
|
|
163
219
|
}
|
|
164
220
|
});
|
|
165
|
-
conn.on('error', (e) => {
|
|
221
|
+
conn.on('error', (e) => {
|
|
222
|
+
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') { watchHttp(onMsg, () => gotReply); return; }
|
|
223
|
+
console.error(unreachable(e)); process.exit(1);
|
|
224
|
+
});
|
|
166
225
|
conn.on('close', () => { if (!gotReply) { console.error('bom-core closed the connection without a reply'); process.exit(1); } process.exit(0); });
|
|
167
226
|
}
|
|
227
|
+
|
|
228
|
+
// Windows / không unix socket: stream /events của HTTP loopback (NDJSON, cùng grammar socket)
|
|
229
|
+
async function watchHttp(onMsg, gotReply) {
|
|
230
|
+
let token;
|
|
231
|
+
try { token = await localHttpToken(); } catch { console.error(unreachable(new Error('no socket, no http.token'))); process.exit(1); }
|
|
232
|
+
const q = cid ? `?conversationId=${encodeURIComponent(cid)}` : '';
|
|
233
|
+
let res;
|
|
234
|
+
try { res = await fetch(`${LOCAL_HTTP}/events${q}`, { headers: { Authorization: `Bearer ${token}` } }); }
|
|
235
|
+
catch (e) { console.error(unreachable(e)); process.exit(1); }
|
|
236
|
+
if (!res.ok) { console.error(`bom-core ${res.status}: watch qua ${LOCAL_HTTP}/events thất bại`); process.exit(1); }
|
|
237
|
+
let buf = '';
|
|
238
|
+
for await (const chunk of res.body) {
|
|
239
|
+
buf += chunk.toString('utf8');
|
|
240
|
+
let nl;
|
|
241
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
242
|
+
const line = buf.slice(0, nl).trim();
|
|
243
|
+
buf = buf.slice(nl + 1);
|
|
244
|
+
if (!line) continue;
|
|
245
|
+
try { onMsg(JSON.parse(line)); } catch { /* dòng lệch — bỏ */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!gotReply()) { console.error('stream /events đóng không có reply'); process.exit(1); }
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
168
251
|
const printEntry = (e) => console.log(`[${e.kind}]${e.role ? ` ${e.role}:` : ''} ${e.text ?? ''}`);
|
|
169
252
|
|
|
170
253
|
const t0 = Date.now();
|
|
@@ -419,6 +502,14 @@ try {
|
|
|
419
502
|
await saveCliConfig({});
|
|
420
503
|
console.log(`đã xoá ${CONFIG_PATH} — CLI quay về local socket.`);
|
|
421
504
|
} else if (cmd === 'setup') {
|
|
505
|
+
if (process.platform === 'win32') {
|
|
506
|
+
// Windows không có systemd — daemon chạy foreground; service tự khởi động là việc sau
|
|
507
|
+
console.log('Windows: mở terminal riêng và chạy bom-core (daemon HTTP 127.0.0.1:8790, giữ mở)');
|
|
508
|
+
console.log('CLI máy này dùng được ngay sau đó: bom-pi status');
|
|
509
|
+
console.log('Web UI máy này (tuỳ chọn): bom-web --port 8791 rồi mở http://127.0.0.1:8791');
|
|
510
|
+
console.log('Điều khiển máy KHÁC: bom-pi pair <CODE> --url http://<ip-máy-khác>:8791');
|
|
511
|
+
process.exit(0);
|
|
512
|
+
}
|
|
422
513
|
// Sinh systemd user units đúng đường dẫn cài (npm global HOẶC working tree) + enable.
|
|
423
514
|
// root = 3 cấp trên file CLI này: packages/cli/src → gốc package (cả hai kiểu cài đều đúng)
|
|
424
515
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
// kill giữa turn sẽ chạy lại (restartLimit 5). Tools: CodingTools (read/write/edit/bash).
|
|
25
25
|
|
|
26
26
|
import { connect, createServer } from 'node:net';
|
|
27
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
27
28
|
import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
28
29
|
import { chmodSync } from 'node:fs';
|
|
29
30
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
30
|
-
import { homedir, userInfo } from 'node:os';
|
|
31
|
+
import { homedir, platform, userInfo } from 'node:os';
|
|
31
32
|
import process from 'node:process';
|
|
32
33
|
import { execFile as execFileCb } from 'node:child_process';
|
|
33
34
|
import { promisify } from 'node:util';
|
|
@@ -44,7 +45,11 @@ function arg(name, fallback) {
|
|
|
44
45
|
|
|
45
46
|
const runtimeDir = process.env.XDG_RUNTIME_DIR || `/tmp/run-${userInfo().uid}`;
|
|
46
47
|
const socketPath = arg('socket', resolve(runtimeDir, 'bom-pi.sock'));
|
|
47
|
-
const dataDir = arg('data', resolve(homedir(), '.local/state/bom-pi'));
|
|
48
|
+
const dataDir = arg('data', process.env.BOM_PI_DATA_DIR || resolve(homedir(), '.local/state/bom-pi'));
|
|
49
|
+
// HTTP loopback (bước mesh hóa): nền tảng không có unix socket (Windows) vẫn chạy daemon
|
|
50
|
+
// đầy đủ; Linux chạy song song cả hai. Token tự sinh trên chính máy này (http.token 0600).
|
|
51
|
+
const httpPort = Number(arg('port', process.env.BOM_PI_PORT || 8790));
|
|
52
|
+
const httpTokenPath = join(dataDir, 'http.token');
|
|
48
53
|
const defaultCwd = arg('cwd', homedir()); // thư mục làm việc mặc định của agent (đổi qua --cwd)
|
|
49
54
|
const dbPath = resolve(dataDir, 'pi.sqlite');
|
|
50
55
|
const lockPath = join(dataDir, 'bom-core.lock');
|
|
@@ -57,6 +62,7 @@ let models = null; // ModelRuntime (pi-coding-agent) — credentials pi thật
|
|
|
57
62
|
let harness = null;
|
|
58
63
|
let storage = null;
|
|
59
64
|
let server = null;
|
|
65
|
+
let httpServer = null;
|
|
60
66
|
let listening = false;
|
|
61
67
|
let closing = false;
|
|
62
68
|
let signalWhileOpening = false;
|
|
@@ -80,6 +86,10 @@ async function shutdown(reason) {
|
|
|
80
86
|
let failed = false;
|
|
81
87
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
82
88
|
if (scheduleTimer) clearInterval(scheduleTimer);
|
|
89
|
+
if (httpServer) {
|
|
90
|
+
httpServer.close(); // stream /events đang mở không được giữ chân exit
|
|
91
|
+
httpServer.closeAllConnections?.();
|
|
92
|
+
}
|
|
83
93
|
for (const [, w] of watches) {
|
|
84
94
|
try { await w.handle.stop(); } catch {}
|
|
85
95
|
}
|
|
@@ -2099,6 +2109,12 @@ const methods = {
|
|
|
2099
2109
|
const writeMsg = (conn, obj) => conn.write(JSON.stringify(obj) + '\n');
|
|
2100
2110
|
|
|
2101
2111
|
async function handleOne(conn, msg) {
|
|
2112
|
+
// Method đẩy notification phải chạy trên connection dài (socket hoặc /events stream),
|
|
2113
|
+
// không phải POST /rpc một-shot: nhận snapshot rồi mất mọi events sau đó.
|
|
2114
|
+
if (conn.__httpRpc && (String(msg?.method ?? '').startsWith('events.') || msg?.method === 'approvals.subscribe')) {
|
|
2115
|
+
if (msg?.id !== undefined) writeMsg(conn, { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `${msg.method} cần stream dài — dùng GET /events (bearer http.token) hoặc unix socket` } });
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2102
2118
|
// -32600 invalid request: null / không phải object / thiếu jsonrpc 2.0 / method không phải chuỗi
|
|
2103
2119
|
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
2104
2120
|
writeMsg(conn, { jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32600, message: 'invalid request' } });
|
|
@@ -2156,26 +2172,117 @@ server = createServer((conn) => {
|
|
|
2156
2172
|
handle(conn, line).catch((e) => log('lỗi xử lý request:', e?.message ?? e)); // không để reject giết daemon
|
|
2157
2173
|
}
|
|
2158
2174
|
});
|
|
2159
|
-
conn.on('close', () =>
|
|
2160
|
-
approvalConns.delete(conn);
|
|
2161
|
-
// bỏ mọi subscribe của connection này; conversation hết subscriber → stop watch
|
|
2162
|
-
for (const convId of connConvs.get(conn) ?? []) {
|
|
2163
|
-
const w = watches.get(convId);
|
|
2164
|
-
if (!w) continue;
|
|
2165
|
-
w.conns.delete(conn);
|
|
2166
|
-
if (!w.conns.size) {
|
|
2167
|
-
w.handle.stop().catch(() => {});
|
|
2168
|
-
watches.delete(convId);
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
connConvs.delete(conn);
|
|
2172
|
-
});
|
|
2175
|
+
conn.on('close', () => dropConn(conn));
|
|
2173
2176
|
conn.on('error', (e) => log('conn lỗi:', e.message));
|
|
2174
2177
|
});
|
|
2175
2178
|
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2179
|
+
// Dọn subscribe của một connection đã đi: dùng cho cả socket thật và conn ảo HTTP
|
|
2180
|
+
function dropConn(conn) {
|
|
2181
|
+
approvalConns.delete(conn);
|
|
2182
|
+
// bỏ mọi subscribe của connection này; conversation hết subscriber → stop watch
|
|
2183
|
+
for (const convId of connConvs.get(conn) ?? []) {
|
|
2184
|
+
const w = watches.get(convId);
|
|
2185
|
+
if (!w) continue;
|
|
2186
|
+
w.conns.delete(conn);
|
|
2187
|
+
if (!w.conns.size) {
|
|
2188
|
+
w.handle.stop().catch(() => {});
|
|
2189
|
+
watches.delete(convId);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
connConvs.delete(conn);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
if (platform() === 'win32') {
|
|
2196
|
+
// Windows không có unix socket — daemon vẫn đầy đủ qua HTTP loopback bên dưới
|
|
2197
|
+
log('windows: bỏ unix socket, chỉ HTTP loopback 127.0.0.1:' + httpPort);
|
|
2198
|
+
} else {
|
|
2199
|
+
await rm(socketPath, { force: true }); // an toàn: probe ở trên đã xác nhận không daemon sống
|
|
2200
|
+
await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
2201
|
+
await new Promise((res) => server.listen(socketPath, res));
|
|
2202
|
+
chmodSync(socketPath, 0o600); // socket chỉ owner nối được
|
|
2203
|
+
listening = true;
|
|
2204
|
+
log('nghe tại', socketPath, '— protocol', PROTOCOL, '— pid', process.pid);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// ---- HTTP loopback: /healthz public, /rpc + /events cần bearer http.token ----
|
|
2208
|
+
// Cùng bảng methods và cùng grammar NDJSON với socket; /events là stream dài
|
|
2209
|
+
// (dòng result của events.subscribe rồi các notification events.update/heartbeat).
|
|
2210
|
+
let httpToken = '';
|
|
2211
|
+
try { httpToken = (await readFile(httpTokenPath, 'utf8')).trim(); } catch { /* sinh mới ngay dưới */ }
|
|
2212
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(httpToken)) {
|
|
2213
|
+
httpToken = randomBytes(32).toString('base64url');
|
|
2214
|
+
await writeFile(httpTokenPath, httpToken + '\n', { mode: 0o600 });
|
|
2215
|
+
chmodSync(httpTokenPath, 0o600);
|
|
2216
|
+
}
|
|
2217
|
+
const httpAuthed = (req) => {
|
|
2218
|
+
const got = Buffer.from(String(req.headers.authorization ?? ''));
|
|
2219
|
+
const want = Buffer.from(`Bearer ${httpToken}`);
|
|
2220
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
2221
|
+
};
|
|
2222
|
+
httpServer = createHttpServer(async (req, res) => {
|
|
2223
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
2224
|
+
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
2225
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2226
|
+
res.end(JSON.stringify({ ok: true, name: 'bom-core', version: VERSION, protocol: PROTOCOL }));
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
if (!httpAuthed(req)) {
|
|
2230
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
2231
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'unauthorized: Authorization: Bearer <token từ http.token>' } }));
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
if (req.method === 'POST' && url.pathname === '/rpc') {
|
|
2235
|
+
const chunks = []; let size = 0;
|
|
2236
|
+
for await (const c of req) {
|
|
2237
|
+
size += c.length;
|
|
2238
|
+
if (size > 1_048_576) { res.writeHead(413); res.end(); return; } // 1 MB — như ws.maxPayload spec tunnel
|
|
2239
|
+
chunks.push(c);
|
|
2240
|
+
}
|
|
2241
|
+
let msg;
|
|
2242
|
+
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
|
|
2243
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2244
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
if (Array.isArray(msg) && msg.length === 0) {
|
|
2248
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2249
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'invalid request: empty batch' } }));
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
const replies = [];
|
|
2253
|
+
const vconn = { __httpRpc: true, write: (s) => { const line = String(s).trim(); if (line) replies.push(JSON.parse(line)); }, on: () => {} };
|
|
2254
|
+
const items = Array.isArray(msg) ? msg : [msg];
|
|
2255
|
+
for (const item of items) await handleOne(vconn, item);
|
|
2256
|
+
dropConn(vconn);
|
|
2257
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
2258
|
+
res.end(JSON.stringify(Array.isArray(msg) ? replies : replies[0] ?? null));
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
if (req.method === 'GET' && url.pathname === '/events') {
|
|
2262
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson', 'cache-control': 'no-store' });
|
|
2263
|
+
const vconn = { __httpStream: true, write: (s) => res.write(s), on: () => {} };
|
|
2264
|
+
res.on('close', () => dropConn(vconn));
|
|
2265
|
+
try {
|
|
2266
|
+
const convId = url.searchParams.get('conversationId') ?? undefined;
|
|
2267
|
+
const result = await methods.eventsSubscribe({ conversationId: convId }, { conn: vconn, push: (m, p) => writeMsg(vconn, { jsonrpc: '2.0', method: m, params: p ?? {} }) });
|
|
2268
|
+
res.write(JSON.stringify({ jsonrpc: '2.0', id: null, result }) + '\n');
|
|
2269
|
+
} catch (e) {
|
|
2270
|
+
res.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32000, message: e?.message ?? String(e) } }) + '\n');
|
|
2271
|
+
res.end();
|
|
2272
|
+
}
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
2276
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32601, message: `not found: ${req.method} ${url.pathname}` } }));
|
|
2277
|
+
});
|
|
2278
|
+
try {
|
|
2279
|
+
await new Promise((res, rej) => {
|
|
2280
|
+
httpServer.once('error', rej);
|
|
2281
|
+
httpServer.listen(httpPort, '127.0.0.1', () => { httpServer.off('error', rej); res(); });
|
|
2282
|
+
});
|
|
2283
|
+
} catch (e) {
|
|
2284
|
+
log(`không nghe được HTTP 127.0.0.1:${httpPort}: ${e.code ?? e.message} — cổng bị chiếm? chạy với --port <port khác>`);
|
|
2285
|
+
if (listening) await rm(socketPath, { force: true }); // dọn socket đã mở, không để lại mồ
|
|
2286
|
+
process.exit(1);
|
|
2287
|
+
}
|
|
2288
|
+
log('http loopback: 127.0.0.1:' + httpPort, '(/rpc /events /healthz) — token:', httpTokenPath);
|