evolclaw-web 1.0.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/dist/server.js ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Watch Web 服务 — 本地浏览器监控面板后端。
3
+ *
4
+ * - HTTP: 静态资源 + 配对 API
5
+ * - WebSocket: 订阅式实时推送(aid / msg / session)
6
+ * - 鉴权: 6 位配对码(5 分钟有效)→ token(24h,有访问自动续期),持久化到磁盘
7
+ * - 安全: 绑定 0.0.0.0(支持远程访问),token 校验,只读
8
+ *
9
+ * 与 evolclaw 的唯一通信:启动时发 ping 检查 protocolVersion(soft 校验)。
10
+ */
11
+ import http from 'http';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import crypto from 'crypto';
15
+ import { fileURLToPath } from 'url';
16
+ import { WebSocketServer } from 'ws';
17
+ import { resolvePaths } from './paths.js';
18
+ import { ipcQuery } from './ipc-client.js';
19
+ import { setDebugLog, dlog } from './debug-log.js';
20
+ import { aidSource } from './sources/aid.js';
21
+ import { msgSource } from './sources/msg.js';
22
+ import { sessionSource } from './sources/session.js';
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const STATIC_DIR = path.join(__dirname, 'static');
25
+ const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24h
26
+ const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
27
+ const DEFAULT_PORT = 20030;
28
+ const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
29
+ const SOURCES = { aid: aidSource, msg: msgSource, session: sessionSource };
30
+ const MIME = {
31
+ '.html': 'text/html; charset=utf-8',
32
+ '.js': 'text/javascript; charset=utf-8',
33
+ '.css': 'text/css; charset=utf-8',
34
+ '.json': 'application/json; charset=utf-8',
35
+ '.svg': 'image/svg+xml',
36
+ };
37
+ function tokenStorePath() {
38
+ return path.join(resolvePaths().instanceDir, 'watch-web-tokens.json');
39
+ }
40
+ function loadTokens() {
41
+ try {
42
+ const store = JSON.parse(fs.readFileSync(tokenStorePath(), 'utf-8'));
43
+ if (Array.isArray(store.tokens))
44
+ return store;
45
+ }
46
+ catch { }
47
+ return { tokens: [] };
48
+ }
49
+ function saveTokens(store) {
50
+ try {
51
+ fs.mkdirSync(resolvePaths().instanceDir, { recursive: true });
52
+ const tmp = tokenStorePath() + '.tmp';
53
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
54
+ fs.renameSync(tmp, tokenStorePath());
55
+ }
56
+ catch { }
57
+ }
58
+ function pruneExpired(store, now) {
59
+ const before = store.tokens.length;
60
+ store.tokens = store.tokens.filter(t => now - t.lastActive < TOKEN_TTL_MS);
61
+ return store.tokens.length !== before;
62
+ }
63
+ function validateAndRenew(token, now) {
64
+ if (!token)
65
+ return false;
66
+ const store = loadTokens();
67
+ const changed = pruneExpired(store, now);
68
+ const rec = store.tokens.find(t => t.token === token);
69
+ if (rec) {
70
+ rec.lastActive = now;
71
+ saveTokens(store);
72
+ return true;
73
+ }
74
+ if (changed)
75
+ saveTokens(store);
76
+ return false;
77
+ }
78
+ // ── Static ──
79
+ function serveStatic(req, res) {
80
+ let urlPath = (req.url || '/').split('?')[0];
81
+ if (urlPath === '/')
82
+ urlPath = '/index.html';
83
+ const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
84
+ const file = path.join(STATIC_DIR, safe);
85
+ if (!file.startsWith(STATIC_DIR)) {
86
+ res.writeHead(403).end('Forbidden');
87
+ return;
88
+ }
89
+ fs.readFile(file, (err, data) => {
90
+ if (err) {
91
+ res.writeHead(404).end('Not Found');
92
+ return;
93
+ }
94
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
95
+ res.end(data);
96
+ });
97
+ }
98
+ // ── Helpers ──
99
+ function parseUrl(rawUrl) {
100
+ const qIdx = rawUrl.indexOf('?');
101
+ if (qIdx === -1)
102
+ return { path: rawUrl, query: {} };
103
+ const query = {};
104
+ for (const pair of rawUrl.slice(qIdx + 1).split('&')) {
105
+ const [k, v] = pair.split('=');
106
+ if (k)
107
+ query[decodeURIComponent(k)] = decodeURIComponent(v || '');
108
+ }
109
+ return { path: rawUrl.slice(0, qIdx), query };
110
+ }
111
+ function clientIp(req) {
112
+ const fwd = req.headers['x-forwarded-for'];
113
+ if (typeof fwd === 'string' && fwd)
114
+ return fwd.split(',')[0].trim();
115
+ return req.socket.remoteAddress || '?';
116
+ }
117
+ function genPairingCode() {
118
+ return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
119
+ }
120
+ // ── Pair handler ──
121
+ function handlePair(req, res, pairingCode, pairingExpiry, log) {
122
+ let body = '';
123
+ req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
124
+ req.destroy(); });
125
+ req.on('end', () => {
126
+ const ip = clientIp(req);
127
+ let code = '';
128
+ try {
129
+ code = String(JSON.parse(body).code || '');
130
+ }
131
+ catch { }
132
+ if (Date.now() > pairingExpiry) {
133
+ res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, reason: '配对码已过期,请重启 watch' }));
134
+ log(`✗ 配对失败(码已过期) from ${ip}`);
135
+ return;
136
+ }
137
+ if (code !== pairingCode) {
138
+ res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, reason: '配对码错误' }));
139
+ log(`✗ 配对失败(码错误: ${code}) from ${ip}`);
140
+ return;
141
+ }
142
+ const now = Date.now();
143
+ const token = crypto.randomBytes(32).toString('hex');
144
+ const store = loadTokens();
145
+ pruneExpired(store, now);
146
+ store.tokens.push({ token, createdAt: now, lastActive: now, label: ip });
147
+ saveTokens(store);
148
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token }));
149
+ log(`✓ 配对成功 from ${ip}(token 缓存 24h)`);
150
+ });
151
+ }
152
+ // ── WebSocket connection ──
153
+ function handleConnection(ws, req, log) {
154
+ const ip = clientIp(req);
155
+ let unsubscribe = null;
156
+ let currentView = null;
157
+ log(`◆ WS 连接 from ${ip}`);
158
+ const send = (obj) => {
159
+ if (ws.readyState === ws.OPEN)
160
+ try {
161
+ ws.send(JSON.stringify(obj));
162
+ }
163
+ catch { }
164
+ };
165
+ const switchSubscription = async (view, params) => {
166
+ if (unsubscribe) {
167
+ unsubscribe();
168
+ unsubscribe = null;
169
+ }
170
+ currentView = view;
171
+ const source = SOURCES[view];
172
+ if (!source) {
173
+ send({ type: 'error', message: `unknown view: ${view}` });
174
+ return;
175
+ }
176
+ try {
177
+ send({ type: 'snapshot', view, data: await source.snapshot(params) });
178
+ }
179
+ catch (e) {
180
+ send({ type: 'error', message: `snapshot failed: ${e?.message || e}` });
181
+ }
182
+ unsubscribe = source.subscribe(params, (data) => {
183
+ if (currentView === view)
184
+ send({ type: 'delta', view, data });
185
+ });
186
+ };
187
+ ws.on('message', async (raw) => {
188
+ let msg;
189
+ try {
190
+ msg = JSON.parse(raw.toString());
191
+ }
192
+ catch {
193
+ return;
194
+ }
195
+ if (msg.type === 'ping') {
196
+ send({ type: 'pong' });
197
+ return;
198
+ }
199
+ if (msg.type === 'subscribe' && msg.view) {
200
+ const params = {};
201
+ if (msg.aid)
202
+ params.aid = msg.aid;
203
+ if (msg.peer)
204
+ params.peer = msg.peer;
205
+ if (msg.sessionId)
206
+ params.sessionId = msg.sessionId;
207
+ if (msg.project)
208
+ params.project = msg.project;
209
+ dlog(`▸ 订阅 ${msg.view}${msg.aid ? ` aid=${String(msg.aid).split('.')[0]}` : ''}${msg.peer ? ` peer=${String(msg.peer).split('.')[0]}` : ''}${msg.sessionId ? ` session=${String(msg.sessionId).slice(0, 8)}` : ''} from ${ip}`);
210
+ await switchSubscription(msg.view, params);
211
+ }
212
+ });
213
+ ws.on('close', () => { if (unsubscribe) {
214
+ unsubscribe();
215
+ unsubscribe = null;
216
+ } log(`◇ WS 断开 from ${ip}`); });
217
+ ws.on('error', () => { });
218
+ }
219
+ // ── Port binding ──
220
+ function bindPort(server, preferred) {
221
+ return new Promise((resolve, reject) => {
222
+ let attempt = 0;
223
+ const tryBind = (port) => {
224
+ server.once('error', (err) => {
225
+ if (err.code === 'EADDRINUSE' && attempt < 10) {
226
+ attempt++;
227
+ tryBind(port + 1);
228
+ }
229
+ else
230
+ reject(err);
231
+ });
232
+ server.listen(port, '0.0.0.0', () => resolve({ port, displaced: port !== preferred }));
233
+ };
234
+ tryBind(preferred);
235
+ });
236
+ }
237
+ export async function startWatchWebServer(opts = {}) {
238
+ const log = opts.log || (() => { });
239
+ setDebugLog(log);
240
+ // soft 版本校验:ping daemon 检查 protocolVersion
241
+ const p = resolvePaths();
242
+ const pingResp = await ipcQuery(p.socket, { type: 'ping' }, 1000);
243
+ if (pingResp && pingResp.protocolVersion !== undefined && pingResp.protocolVersion < PROTOCOL_VERSION) {
244
+ log(`⚠️ evolclaw protocolVersion=${pingResp.protocolVersion},watch 期望 >=${PROTOCOL_VERSION},部分功能可能异常`);
245
+ }
246
+ let pairingCode = genPairingCode();
247
+ let pairingExpiry = Date.now() + PAIRING_TTL_MS;
248
+ function freshPairing() {
249
+ if (Date.now() > pairingExpiry) {
250
+ pairingCode = genPairingCode();
251
+ pairingExpiry = Date.now() + PAIRING_TTL_MS;
252
+ log(`↺ 配对码已刷新:${pairingCode}(5 分钟有效)`);
253
+ }
254
+ return { code: pairingCode, expiresAt: pairingExpiry };
255
+ }
256
+ const server = http.createServer((req, res) => {
257
+ if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
258
+ const { code, expiresAt } = freshPairing();
259
+ res.writeHead(200, { 'Content-Type': 'application/json' });
260
+ res.end(JSON.stringify({ code, expiresAt }));
261
+ }
262
+ else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
263
+ handlePair(req, res, pairingCode, pairingExpiry, log);
264
+ }
265
+ else {
266
+ serveStatic(req, res);
267
+ }
268
+ });
269
+ const wss = new WebSocketServer({ noServer: true });
270
+ server.on('upgrade', (req, socket, head) => {
271
+ const { query } = parseUrl(req.url || '');
272
+ const authed = validateAndRenew(query.token || '', Date.now());
273
+ wss.handleUpgrade(req, socket, head, (ws) => {
274
+ if (!authed) {
275
+ log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
276
+ ws.close(4001, 'invalid-token');
277
+ return;
278
+ }
279
+ handleConnection(ws, req, log);
280
+ });
281
+ });
282
+ const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT);
283
+ return {
284
+ url: `http://0.0.0.0:${port}`,
285
+ port,
286
+ displaced,
287
+ pairingCode,
288
+ close() {
289
+ return new Promise((resolve) => {
290
+ for (const client of wss.clients)
291
+ try {
292
+ client.close();
293
+ }
294
+ catch { }
295
+ wss.close();
296
+ server.close(() => resolve());
297
+ });
298
+ },
299
+ };
300
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * AID 数据源 — 复用 daemon 的 IPC socket(与 evolclaw `watch aid` 同源)。
3
+ *
4
+ * daemon 运行时:拉 aun-aids / aun-aid-stats / status / evolagent.list。
5
+ * daemon 未运行时:降级到读 instance/ 目录的 aid-*.jsonl(精简版 scanInstances)。
6
+ * IPC 无推送能力,故 1s 轮询 + JSON diff,仅在变化时 push。
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { resolvePaths } from '../paths.js';
11
+ import { ipcQuery } from '../ipc-client.js';
12
+ /** 精简版实例扫描:仅在 daemon 离线时降级用,读各 alive main 进程的 aid 活动日志 */
13
+ function scanAidActivity() {
14
+ const result = new Map();
15
+ const dir = resolvePaths().instanceDir;
16
+ let files;
17
+ try {
18
+ files = fs.readdirSync(dir);
19
+ }
20
+ catch {
21
+ return result;
22
+ }
23
+ for (const file of files) {
24
+ if (!file.startsWith('main-') || !file.endsWith('.json'))
25
+ continue;
26
+ let rec;
27
+ try {
28
+ rec = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ if (!rec?.pid)
34
+ continue;
35
+ // 进程存活检测(轻量:signal 0)
36
+ try {
37
+ process.kill(rec.pid, 0);
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ // 读该 pid 的 aid-<pid>.jsonl,按 aid 取最后活动
43
+ const aidLog = path.join(dir, `aid-${rec.pid}.jsonl`);
44
+ let content;
45
+ try {
46
+ content = fs.readFileSync(aidLog, 'utf-8');
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ for (const line of content.trim().split('\n')) {
52
+ if (!line)
53
+ continue;
54
+ try {
55
+ const ev = JSON.parse(line);
56
+ if (ev.aid && ev.ts) {
57
+ const prev = result.get(ev.aid);
58
+ if (!prev || ev.ts > prev.ts)
59
+ result.set(ev.aid, { ts: ev.ts, event: ev.event });
60
+ }
61
+ }
62
+ catch { /* skip */ }
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+ async function buildSnapshot() {
68
+ const p = resolvePaths();
69
+ const [aidsResp, statsResp, statusResp, agentsResp] = await Promise.all([
70
+ ipcQuery(p.socket, { type: 'aun-aids' }),
71
+ ipcQuery(p.socket, { type: 'aun-aid-stats' }),
72
+ ipcQuery(p.socket, { type: 'status' }),
73
+ ipcQuery(p.socket, { type: 'evolagent.list' }),
74
+ ]);
75
+ const daemonRunning = aidsResp !== null || statusResp !== null;
76
+ if (!daemonRunning) {
77
+ const activity = scanAidActivity();
78
+ const aids = Array.from(activity.entries()).map(([aid, info]) => ({
79
+ aid,
80
+ status: info.event === 'disconnected' ? 'disconnected' : 'offline',
81
+ lastActivity: info.ts,
82
+ lastEvent: info.event,
83
+ }));
84
+ return { daemonRunning: false, aids, stats: [], agents: [] };
85
+ }
86
+ return {
87
+ daemonRunning: true,
88
+ aids: aidsResp?.aids ?? [],
89
+ stats: statsResp?.stats ?? [],
90
+ status: statusResp ?? null,
91
+ agents: agentsResp?.agents ?? [],
92
+ };
93
+ }
94
+ export const aidSource = {
95
+ kind: 'aid',
96
+ async snapshot() {
97
+ return buildSnapshot();
98
+ },
99
+ subscribe(_params, push) {
100
+ let lastJson = '';
101
+ let stopped = false;
102
+ const tick = async () => {
103
+ if (stopped)
104
+ return;
105
+ try {
106
+ const snap = await buildSnapshot();
107
+ const json = JSON.stringify(snap);
108
+ if (json !== lastJson) {
109
+ lastJson = json;
110
+ push(snap);
111
+ }
112
+ }
113
+ catch { /* ignore transient IPC errors */ }
114
+ };
115
+ const timer = setInterval(tick, 1000);
116
+ return () => { stopped = true; clearInterval(timer); };
117
+ },
118
+ };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 消息数据源 — fs-utils 的数据层函数 + fs.watch 文件监听。
3
+ *
4
+ * snapshot:
5
+ * - 无 aid: 本地 AID 列表(含收发统计)
6
+ * - 有 aid 无 peer: 该 AID 的对端列表 + 全部消息
7
+ * - 有 aid 有 peer: 该对端的消息
8
+ * subscribe: fs.watch(sessions/aun, recursive),messages.jsonl 变化时防抖 150ms 后重推。
9
+ */
10
+ import fs from 'fs';
11
+ import { getSessionsAunDir, listLocalAids, loadAidInfo, loadPeerInfos, loadAllMessages, readMessages, } from '../fs-utils.js';
12
+ function buildSnapshot(params) {
13
+ const aunDir = getSessionsAunDir();
14
+ if (!fs.existsSync(aunDir)) {
15
+ return { aids: [], peers: [], messages: [], aid: null, peer: null };
16
+ }
17
+ const aid = params.aid || null;
18
+ const peer = params.peer || null;
19
+ const aids = listLocalAids(aunDir)
20
+ .map(a => loadAidInfo(aunDir, a))
21
+ .sort((a, b) => (b.totalIn + b.totalOut) - (a.totalIn + a.totalOut));
22
+ if (!aid) {
23
+ return { aids, peers: [], messages: [], aid: null, peer: null };
24
+ }
25
+ const peers = loadPeerInfos(aunDir, aid);
26
+ let messages;
27
+ if (peer) {
28
+ messages = readMessages(aunDir, aid, peer);
29
+ if (messages.length > 1000)
30
+ messages = messages.slice(-1000);
31
+ }
32
+ else {
33
+ messages = loadAllMessages(aunDir, aid);
34
+ }
35
+ return { aids, peers, messages, aid, peer };
36
+ }
37
+ export const msgSource = {
38
+ kind: 'msg',
39
+ async snapshot(params = {}) {
40
+ return buildSnapshot(params);
41
+ },
42
+ subscribe(params, push) {
43
+ const aunDir = getSessionsAunDir();
44
+ let watcher = null;
45
+ let debounce = null;
46
+ const fire = () => {
47
+ if (debounce)
48
+ clearTimeout(debounce);
49
+ debounce = setTimeout(() => {
50
+ try {
51
+ push(buildSnapshot(params));
52
+ }
53
+ catch { /* ignore */ }
54
+ }, 150);
55
+ };
56
+ try {
57
+ watcher = fs.watch(aunDir, { recursive: true }, (_evt, filename) => {
58
+ if (filename && String(filename).endsWith('messages.jsonl'))
59
+ fire();
60
+ });
61
+ }
62
+ catch { /* aunDir may not exist yet */ }
63
+ return () => {
64
+ if (watcher)
65
+ watcher.close();
66
+ if (debounce)
67
+ clearTimeout(debounce);
68
+ };
69
+ },
70
+ };