ec-web 0.0.1 → 0.0.2

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,820 @@
1
+ /**
2
+ * Watch Web 服务 — 本地浏览器监控面板后端。
3
+ *
4
+ * - HTTP: 静态资源 + 配对 API
5
+ * - WebSocket: 订阅式实时推送(aid / msg / session)
6
+ * - 鉴权: 6 位配对码(5 分钟有效)→ token(30 天,有访问自动续期),持久化到磁盘
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 { spawnSync } from 'child_process';
16
+ import { fileURLToPath } from 'url';
17
+ import { WebSocketServer } from 'ws';
18
+ import { resolvePaths } from './paths.js';
19
+ import { ipcQuery } from './ipc-client.js';
20
+ import { setDebugLog, dlog } from './debug-log.js';
21
+ import { aidSource } from './sources/aid.js';
22
+ import { msgSource } from './sources/msg.js';
23
+ import { sessionSource, buildBindMap } from './sources/session.js';
24
+ import { cacheSource } from './sources/cache.js';
25
+ import { systemSource } from './sources/system.js';
26
+ import { triggersSource } from './sources/triggers.js';
27
+ import { monitorSource } from './sources/monitor.js';
28
+ import { logsSource } from './sources/logs.js';
29
+ import { gatewaySource } from './sources/gateway.js';
30
+ import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsOverview, queryUsageDetail, queryUsedModels } from './sources/stats.js';
31
+ import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
32
+ import { ccProjectsDir } from './paths.js';
33
+ import { relationRolesSource, handleRelationRolesApi, handlePeerRoleApi } from './sources/relation-roles.js';
34
+ import { roleDefinitionsSource, handleRoleDefinitionsApi } from './sources/role-definitions.js';
35
+ import { handleModelsApi } from './sources/models.js';
36
+ import { detectBaseAgents } from './sources/baseagent-detector.js';
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const STATIC_DIR = path.join(__dirname, 'static');
39
+ const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
40
+ const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
41
+ const DEFAULT_PORT = 42705;
42
+ const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
43
+ function daemonOwnerMenuAuth() {
44
+ return { localDirect: true, actorAid: null };
45
+ }
46
+ function daemonTriggerControlToken() {
47
+ try {
48
+ return fs.readFileSync(path.join(resolvePaths().instanceDir, 'control.token'), 'utf8').trim() || undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource, logs: logsSource, gateway: gatewaySource, roles: relationRolesSource, roleDefinitions: roleDefinitionsSource };
55
+ // ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
56
+ function readEcwebVersion() {
57
+ try {
58
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
59
+ return pkg?.version ?? '0.0.0';
60
+ }
61
+ catch {
62
+ return '0.0.0';
63
+ }
64
+ }
65
+ const ECWEB_VERSION = readEcwebVersion();
66
+ const MIME = {
67
+ '.html': 'text/html; charset=utf-8',
68
+ '.js': 'text/javascript; charset=utf-8',
69
+ '.css': 'text/css; charset=utf-8',
70
+ '.json': 'application/json; charset=utf-8',
71
+ '.svg': 'image/svg+xml',
72
+ };
73
+ function tokenStorePath() {
74
+ const dir = resolvePaths().instanceDir;
75
+ const current = path.join(dir, 'ecweb-tokens.json');
76
+ const legacy = path.join(dir, 'watch-web-tokens.json');
77
+ if (!fs.existsSync(current) && fs.existsSync(legacy)) {
78
+ try {
79
+ fs.renameSync(legacy, current);
80
+ }
81
+ catch { }
82
+ }
83
+ return current;
84
+ }
85
+ function loadTokens() {
86
+ try {
87
+ const store = JSON.parse(fs.readFileSync(tokenStorePath(), 'utf-8'));
88
+ if (Array.isArray(store.tokens))
89
+ return store;
90
+ }
91
+ catch { }
92
+ return { tokens: [] };
93
+ }
94
+ function saveTokens(store) {
95
+ try {
96
+ fs.mkdirSync(resolvePaths().instanceDir, { recursive: true });
97
+ const tmp = tokenStorePath() + '.tmp';
98
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
99
+ fs.renameSync(tmp, tokenStorePath());
100
+ }
101
+ catch { }
102
+ }
103
+ function pruneExpired(store, now) {
104
+ const before = store.tokens.length;
105
+ store.tokens = store.tokens.filter(t => now - t.lastActive < TOKEN_TTL_MS);
106
+ return store.tokens.length !== before;
107
+ }
108
+ function validateAndRenew(token, now) {
109
+ if (!token)
110
+ return false;
111
+ const store = loadTokens();
112
+ const changed = pruneExpired(store, now);
113
+ const rec = store.tokens.find(t => t.token === token);
114
+ if (rec) {
115
+ rec.lastActive = now;
116
+ saveTokens(store);
117
+ return true;
118
+ }
119
+ if (changed)
120
+ saveTokens(store);
121
+ return false;
122
+ }
123
+ // ── Static ──
124
+ // 经 AUN Service Proxy 转发时,proxy-server 注入 x-forwarded-prefix 头,
125
+ // 值为外部前缀(如 /evolai/ecweb)。把它作为 <base href> 注入 index.html,
126
+ // 使相对路径资源(style.css / app.js / api / ws)在带不带尾斜杠时都正确解析。
127
+ // 本地直连无此头,<base> 注入为 "/",行为不变。
128
+ function forwardedPrefix(req) {
129
+ const raw = req.headers['x-forwarded-prefix'];
130
+ const value = (Array.isArray(raw) ? raw[0] : raw || '').trim();
131
+ // 安全:只接受形如 /a/b 的简单路径,拒绝含协议、双斜杠、引号、尖括号等的值
132
+ if (!value || !/^\/[A-Za-z0-9._~%/-]*$/.test(value))
133
+ return '';
134
+ return value.replace(/\/+$/, ''); // 去尾斜杠,注入时统一补
135
+ }
136
+ function injectBaseHref(html, prefix) {
137
+ const base = prefix ? `${prefix}/` : '/';
138
+ const tag = `<base href="${base}">`;
139
+ // 若已有 <base> 则替换,否则插在 <head> 之后
140
+ if (/<base\b[^>]*>/i.test(html))
141
+ return html.replace(/<base\b[^>]*>/i, tag);
142
+ return html.replace(/<head[^>]*>/i, (m) => `${m}\n ${tag}`);
143
+ }
144
+ function serveStatic(req, res) {
145
+ let urlPath = (req.url || '/').split('?')[0];
146
+ if (urlPath === '/')
147
+ urlPath = '/index.html';
148
+ const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
149
+ const file = path.join(STATIC_DIR, safe);
150
+ if (!file.startsWith(STATIC_DIR)) {
151
+ res.writeHead(403).end('Forbidden');
152
+ return;
153
+ }
154
+ fs.readFile(file, (err, data) => {
155
+ if (err) {
156
+ res.writeHead(404).end('Not Found');
157
+ return;
158
+ }
159
+ const ext = path.extname(file);
160
+ // index.html 注入 <base href>,适配 AUN Service Proxy 前缀
161
+ if (ext === '.html') {
162
+ const prefix = forwardedPrefix(req);
163
+ const html = injectBaseHref(data.toString('utf-8'), prefix);
164
+ res.writeHead(200, { 'Content-Type': MIME[ext] });
165
+ res.end(html);
166
+ return;
167
+ }
168
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
169
+ res.end(data);
170
+ });
171
+ }
172
+ // ── Helpers ──
173
+ function parseUrl(rawUrl) {
174
+ const qIdx = rawUrl.indexOf('?');
175
+ if (qIdx === -1)
176
+ return { path: rawUrl, query: {} };
177
+ const query = {};
178
+ for (const pair of rawUrl.slice(qIdx + 1).split('&')) {
179
+ const [k, v] = pair.split('=');
180
+ if (k)
181
+ query[decodeURIComponent(k)] = decodeURIComponent(v || '');
182
+ }
183
+ return { path: rawUrl.slice(0, qIdx), query };
184
+ }
185
+ function handleStatsApi(req, res) {
186
+ const { path: urlPath, query } = parseUrl(req.url || '');
187
+ res.setHeader('Content-Type', 'application/json');
188
+ if (urlPath === '/api/stats/dashboard') {
189
+ const data = queryStatsForDashboard();
190
+ if (!data) {
191
+ res.writeHead(503);
192
+ res.end(JSON.stringify({ error: 'stats unavailable (node:sqlite missing or no data)' }));
193
+ }
194
+ else {
195
+ res.writeHead(200);
196
+ res.end(JSON.stringify(data));
197
+ }
198
+ }
199
+ else if (urlPath === '/api/stats/explorer') {
200
+ const params = {};
201
+ if (query.from)
202
+ params.from_ts = Number(query.from);
203
+ if (query.to)
204
+ params.to_ts = Number(query.to);
205
+ if (query.agent)
206
+ params.agent_aid = query.agent;
207
+ if (query.peer)
208
+ params.peer_key = query.peer;
209
+ if (query.model)
210
+ params.model = query.model;
211
+ if (query.granularity)
212
+ params.granularity = query.granularity;
213
+ const data = queryStatsExplorer(params);
214
+ res.writeHead(200);
215
+ res.end(JSON.stringify(data));
216
+ }
217
+ else if (urlPath === '/api/stats/peers') {
218
+ const params = {};
219
+ if (query.from)
220
+ params.from_ts = Number(query.from);
221
+ if (query.to)
222
+ params.to_ts = Number(query.to);
223
+ if (query.agent)
224
+ params.agent_aid = query.agent;
225
+ if (query.limit)
226
+ params.limit = Number(query.limit);
227
+ const data = queryStatsByPeer(params);
228
+ res.writeHead(200);
229
+ res.end(JSON.stringify(data));
230
+ }
231
+ else if (urlPath === '/api/stats/agents') {
232
+ // 返回所有agent列表(与智能体管理页面一致,从aidSource获取)
233
+ aidSource.snapshot().then(snapshot => {
234
+ const agents = (snapshot?.agents || []).map((ag) => ({
235
+ agent_aid: ag.aid,
236
+ agent_name: ag.displayName || null
237
+ }));
238
+ res.writeHead(200);
239
+ res.end(JSON.stringify(agents));
240
+ }).catch(err => {
241
+ res.writeHead(500);
242
+ res.end(JSON.stringify({ error: 'Failed to fetch agents' }));
243
+ });
244
+ return;
245
+ }
246
+ else if (urlPath === '/api/stats/overview') {
247
+ // 从查询参数中获取时间范围和筛选条件
248
+ const params = {};
249
+ if (query.from)
250
+ params.from_ts = Number(query.from);
251
+ if (query.to)
252
+ params.to_ts = Number(query.to);
253
+ if (query.agent)
254
+ params.agent_aid = String(query.agent);
255
+ if (query.peer)
256
+ params.peer_key = String(query.peer);
257
+ const tokenStats = queryStatsOverview(params);
258
+ // session count: 使用 bindMap 按 agent 和 peer 筛选
259
+ let sessionCount = 0;
260
+ try {
261
+ // 构建 agentSessionId → agent_aid 的映射
262
+ const bindMap = buildBindMap();
263
+ // 如果指定了 peer_key,提取并解码 channelId
264
+ let targetChannelId;
265
+ if (params.peer_key) {
266
+ const parts = params.peer_key.split('#');
267
+ if (parts.length >= 4) {
268
+ // aun#{agent}#main#{channelId},需要解码 URL 编码
269
+ targetChannelId = decodeURIComponent(parts[3]);
270
+ }
271
+ }
272
+ const base = ccProjectsDir();
273
+ for (const d of fs.readdirSync(base, { withFileTypes: true })) {
274
+ if (!d.isDirectory())
275
+ continue;
276
+ const projectDir = path.join(base, d.name);
277
+ try {
278
+ const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
279
+ for (const sessionFile of sessionFiles) {
280
+ const sessionId = sessionFile.replace('.jsonl', '');
281
+ // 如果指定了 agent 或 peer,检查该会话是否匹配
282
+ if (params.agent_aid || targetChannelId) {
283
+ const bindInfo = bindMap.get(sessionId);
284
+ if (!bindInfo)
285
+ continue;
286
+ // 检查 agent
287
+ if (params.agent_aid && bindInfo.selfAID !== params.agent_aid)
288
+ continue;
289
+ // 检查 peer
290
+ if (targetChannelId && bindInfo.channelId !== targetChannelId)
291
+ continue;
292
+ }
293
+ // 如果有时间范围限制,检查会话文件的修改时间
294
+ if (params.from_ts || params.to_ts) {
295
+ const stat = fs.statSync(path.join(projectDir, sessionFile));
296
+ const mtime = stat.mtimeMs;
297
+ if (params.from_ts && mtime < params.from_ts)
298
+ continue;
299
+ if (params.to_ts && mtime > params.to_ts)
300
+ continue;
301
+ }
302
+ sessionCount++;
303
+ }
304
+ }
305
+ catch { }
306
+ }
307
+ }
308
+ catch { }
309
+ // message counts: scan aun dir, 根据时间范围和 agent/peer 过滤
310
+ let msgIn = 0, msgOut = 0;
311
+ try {
312
+ const aunDir = getSessionsAunDir();
313
+ const aids = params.agent_aid ? [params.agent_aid] : listLocalAids(aunDir);
314
+ for (const aid of aids) {
315
+ const peers = params.peer_key ? [params.peer_key] : listPeers(aunDir, aid);
316
+ for (const peer of peers) {
317
+ for (const m of readMessages(aunDir, aid, peer)) {
318
+ // 根据消息时间戳过滤
319
+ if (params.from_ts && m.ts < params.from_ts)
320
+ continue;
321
+ if (params.to_ts && m.ts > params.to_ts)
322
+ continue;
323
+ if (m.dir === 'in')
324
+ msgIn++;
325
+ else
326
+ msgOut++;
327
+ }
328
+ }
329
+ }
330
+ }
331
+ catch { }
332
+ res.writeHead(200);
333
+ res.end(JSON.stringify({ token_stats: tokenStats, session_count: sessionCount, msg_in: msgIn, msg_out: msgOut }));
334
+ }
335
+ else if (urlPath === '/api/stats/detail') {
336
+ // 模型访问明细查询
337
+ const params = {};
338
+ if (query.from)
339
+ params.from_ts = Number(query.from);
340
+ if (query.to)
341
+ params.to_ts = Number(query.to);
342
+ if (query.agent)
343
+ params.agent_aid = query.agent;
344
+ if (query.model)
345
+ params.model = String(query.model);
346
+ params.limit = Number(query.limit) || 50; // 默认限制50条
347
+ params.offset = Number(query.offset) || 0; // 默认从0开始
348
+ const result = queryUsageDetail(params);
349
+ res.writeHead(200);
350
+ res.end(JSON.stringify(result));
351
+ }
352
+ else if (urlPath === '/api/stats/models') {
353
+ // 获取指定时间范围内使用过的模型列表
354
+ const params = {};
355
+ if (query.from)
356
+ params.from_ts = Number(query.from);
357
+ if (query.to)
358
+ params.to_ts = Number(query.to);
359
+ const models = queryUsedModels(params);
360
+ res.writeHead(200);
361
+ res.end(JSON.stringify(models));
362
+ }
363
+ else {
364
+ res.writeHead(404);
365
+ res.end(JSON.stringify({ error: 'not found' }));
366
+ }
367
+ }
368
+ function clientIp(req) {
369
+ const fwd = req.headers['x-forwarded-for'];
370
+ if (typeof fwd === 'string' && fwd)
371
+ return fwd.split(',')[0].trim();
372
+ return req.socket.remoteAddress || '?';
373
+ }
374
+ // 仅 localhost 可访问(取配对码):直连 socket 地址必须是回环。
375
+ // 不信任 x-forwarded-for(代理头可伪造),只看真实 TCP 来源。
376
+ function isLocalhost(req) {
377
+ const addr = req.socket.remoteAddress || '';
378
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
379
+ }
380
+ /**
381
+ * 判定「真本地直连」:socket 地址是回环 AND 无 x-aun-provider-aid 头。
382
+ * proxy-server 回连本地时 remoteAddress 也是 127.0.0.1,但会注入 x-aun-provider-aid
383
+ * 可信头(访客伪造的 x-aun-* 会被 proxy-server 剥掉)。只有真本地直连时两条件同时满足。
384
+ * 真本地直连免配对(自动发 token),隧道/远程需配对码。
385
+ */
386
+ function isLocalDirect(req) {
387
+ if (!isLocalhost(req))
388
+ return false;
389
+ const providerAid = req.headers['x-aun-provider-aid'];
390
+ return !providerAid || (Array.isArray(providerAid) ? providerAid.length === 0 : !providerAid.trim());
391
+ }
392
+ function genPairingCode() {
393
+ return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
394
+ }
395
+ // ── Pair handler ──
396
+ function handlePair(req, res, pairingCode, pairingExpiry, log) {
397
+ let body = '';
398
+ req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
399
+ req.destroy(); });
400
+ req.on('end', () => {
401
+ const ip = clientIp(req);
402
+ let code = '';
403
+ try {
404
+ code = String(JSON.parse(body).code || '');
405
+ }
406
+ catch { }
407
+ if (Date.now() > pairingExpiry) {
408
+ res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, reason: '配对码已过期,请重启 watch' }));
409
+ log(`✗ 配对失败(码已过期) from ${ip}`);
410
+ return;
411
+ }
412
+ if (code !== pairingCode) {
413
+ res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, reason: '配对码错误' }));
414
+ log(`✗ 配对失败(码错误: ${code}) from ${ip}`);
415
+ return;
416
+ }
417
+ const now = Date.now();
418
+ const token = crypto.randomBytes(32).toString('hex');
419
+ const store = loadTokens();
420
+ pruneExpired(store, now);
421
+ store.tokens.push({ token, createdAt: now, lastActive: now, label: ip });
422
+ saveTokens(store);
423
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token }));
424
+ log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
425
+ });
426
+ }
427
+ /**
428
+ * 本地直连免配对:自动发 token。远程(隧道或真远程)需配对码。
429
+ * 本地直连判定:socket 来源是回环 AND 无 x-aun-provider-aid 头(非隧道)。
430
+ */
431
+ function issueLocalDirectToken(req, log) {
432
+ if (!isLocalDirect(req))
433
+ return null;
434
+ const now = Date.now();
435
+ const token = crypto.randomBytes(32).toString('hex');
436
+ const store = loadTokens();
437
+ pruneExpired(store, now);
438
+ const ip = clientIp(req);
439
+ store.tokens.push({ token, createdAt: now, lastActive: now, label: `local-direct:${ip}` });
440
+ saveTokens(store);
441
+ log(`✓ 本地直连自动授权 from ${ip}`);
442
+ return token;
443
+ }
444
+ // ── WebSocket connection ──
445
+ function handleConnection(ws, req, log) {
446
+ const ip = clientIp(req);
447
+ let unsubscribe = null;
448
+ let currentView = null;
449
+ log(`◆ WS 连接 from ${ip}`);
450
+ const send = (obj) => {
451
+ if (ws.readyState === ws.OPEN)
452
+ try {
453
+ ws.send(JSON.stringify(obj));
454
+ }
455
+ catch { }
456
+ };
457
+ const switchSubscription = async (view, params) => {
458
+ if (unsubscribe) {
459
+ unsubscribe();
460
+ unsubscribe = null;
461
+ }
462
+ currentView = view;
463
+ const source = SOURCES[view];
464
+ if (!source) {
465
+ send({ type: 'error', message: `unknown view: ${view}` });
466
+ return;
467
+ }
468
+ const sourceParams = { ...params, __menuAuth: daemonOwnerMenuAuth() };
469
+ // System 视图:把 ECWeb 自身版本合并进快照(前端版本卡用)
470
+ const decorate = (data) => (view === 'system' && data ? { ...data, ecwebVersion: ECWEB_VERSION } : data);
471
+ try {
472
+ send({ type: 'snapshot', view, data: decorate(await source.snapshot(sourceParams)) });
473
+ }
474
+ catch (e) {
475
+ send({ type: 'error', message: `snapshot failed: ${e?.message || e}` });
476
+ }
477
+ unsubscribe = source.subscribe(sourceParams, (data) => {
478
+ if (currentView === view)
479
+ send({ type: 'delta', view, data: decorate(data) });
480
+ });
481
+ };
482
+ ws.on('message', async (raw) => {
483
+ let msg;
484
+ try {
485
+ msg = JSON.parse(raw.toString());
486
+ }
487
+ catch {
488
+ return;
489
+ }
490
+ if (msg.type === 'ping') {
491
+ send({ type: 'pong' });
492
+ return;
493
+ }
494
+ if (msg.type === 'subscribe' && msg.view) {
495
+ const params = {};
496
+ if (msg.aid)
497
+ params.aid = msg.aid;
498
+ if (msg.peer)
499
+ params.peer = msg.peer;
500
+ if (msg.sessionId)
501
+ params.sessionId = msg.sessionId;
502
+ if (msg.project)
503
+ params.project = msg.project;
504
+ if (msg.agent)
505
+ params.agent = msg.agent;
506
+ if (msg.baseagent)
507
+ params.baseagent = msg.baseagent;
508
+ if (msg.source)
509
+ params.source = msg.source;
510
+ if (msg.limit)
511
+ params.limit = msg.limit;
512
+ 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)}` : ''}${msg.baseagent ? ` baseagent=${msg.baseagent}` : ''} from ${ip}`);
513
+ await switchSubscription(msg.view, params);
514
+ return;
515
+ }
516
+ if (msg.type === 'menu' && msg.payload) {
517
+ const p = resolvePaths();
518
+ const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
519
+ ? Math.min(Number(msg.timeoutMs), 120_000)
520
+ : 5000;
521
+ const menuAuth = daemonOwnerMenuAuth();
522
+ const resp = await ipcQuery(p.socket, { type: 'menu.exec', payload: msg.payload, auth: { source: 'ecweb', ...menuAuth } }, timeoutMs);
523
+ if (resp?.ok) {
524
+ send({ type: 'menu.response', requestId: msg.requestId, data: resp.response });
525
+ }
526
+ else {
527
+ send({ type: 'menu.response', requestId: msg.requestId, data: {
528
+ type: 'menu.response', id: msg.payload?.id ?? '',
529
+ error: { code: 'INTERNAL', message: resp?.error ?? 'daemon unreachable' },
530
+ } });
531
+ }
532
+ return;
533
+ }
534
+ if (msg.type === 'ipc' && msg.payload) {
535
+ if (typeof msg.payload.type !== 'string' || !msg.payload.type.startsWith('trigger.')) {
536
+ send({ type: 'ipc.response', requestId: msg.requestId, data: { ok: false, error: 'unsupported ipc payload' } });
537
+ return;
538
+ }
539
+ const p = resolvePaths();
540
+ const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
541
+ ? Math.min(Number(msg.timeoutMs), 120_000)
542
+ : 10000;
543
+ const payload = msg.payload.type === 'trigger.history' || msg.payload.type === 'trigger.eventCatalog'
544
+ ? { ...msg.payload, controlToken: daemonTriggerControlToken() }
545
+ : msg.payload;
546
+ const resp = await ipcQuery(p.socket, payload, timeoutMs);
547
+ send({ type: 'ipc.response', requestId: msg.requestId, data: resp ?? { ok: false, error: 'daemon unreachable' } });
548
+ return;
549
+ }
550
+ });
551
+ // NAT keepalive: ping every 25s to prevent middlebox from cutting the connection
552
+ let alive = true;
553
+ const heartbeat = setInterval(() => {
554
+ if (ws.readyState !== ws.OPEN) {
555
+ clearInterval(heartbeat);
556
+ return;
557
+ }
558
+ if (!alive) {
559
+ ws.terminate();
560
+ return;
561
+ }
562
+ alive = false;
563
+ ws.ping();
564
+ }, 25000);
565
+ ws.on('pong', () => { alive = true; });
566
+ ws.on('close', () => { clearInterval(heartbeat); if (unsubscribe) {
567
+ unsubscribe();
568
+ unsubscribe = null;
569
+ } log(`◇ WS 断开 from ${ip}`); });
570
+ ws.on('error', () => { });
571
+ }
572
+ // ── Port binding ──
573
+ /** 跨平台:查端口上的 LISTENING 进程 PID(清理被占端口用)。 */
574
+ function findPidsOnPort(port) {
575
+ const pids = new Set();
576
+ try {
577
+ if (process.platform === 'win32') {
578
+ const out = spawnSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8', windowsHide: true }).stdout || '';
579
+ for (const line of out.split('\n')) {
580
+ if (!/LISTENING/i.test(line))
581
+ continue;
582
+ const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
583
+ if (m && Number(m[1]) === port) {
584
+ const pid = Number(m[2]);
585
+ if (pid && pid !== process.pid)
586
+ pids.add(pid);
587
+ }
588
+ }
589
+ }
590
+ else {
591
+ const out = spawnSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8' }).stdout || '';
592
+ for (const l of out.split('\n')) {
593
+ const pid = parseInt(l.trim(), 10);
594
+ if (pid && pid !== process.pid)
595
+ pids.add(pid);
596
+ }
597
+ }
598
+ }
599
+ catch { /* netstat/lsof 缺失或无匹配 */ }
600
+ return [...pids];
601
+ }
602
+ /** 杀掉占用目标端口的旧进程(强制)。 */
603
+ function killPidsOnPort(port, log) {
604
+ let killed = false;
605
+ for (const pid of findPidsOnPort(port)) {
606
+ try {
607
+ if (process.platform === 'win32')
608
+ spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true });
609
+ else
610
+ process.kill(pid, 'SIGKILL');
611
+ log(`⚠ 端口 ${port} 被旧进程 PID ${pid} 占用,已终止`);
612
+ killed = true;
613
+ }
614
+ catch { /* 已退出或无权限 */ }
615
+ }
616
+ return killed;
617
+ }
618
+ function sleepMs(ms) { return new Promise(r => setTimeout(r, ms)); }
619
+ function bindPort(server, preferred, log) {
620
+ return new Promise((resolve, reject) => {
621
+ let killedOnce = false;
622
+ const tryBind = (port) => {
623
+ server.once('error', async (err) => {
624
+ if (err.code === 'EADDRINUSE' && !killedOnce) {
625
+ // 默认行为:杀掉占用端口的旧进程并重试本端口(而非 +1 漂移),避免端口被占。
626
+ killedOnce = true;
627
+ killPidsOnPort(port, log);
628
+ await sleepMs(600);
629
+ tryBind(port);
630
+ }
631
+ else {
632
+ reject(err);
633
+ }
634
+ });
635
+ server.listen(port, '0.0.0.0', () => resolve({ port, displaced: false }));
636
+ };
637
+ tryBind(preferred);
638
+ });
639
+ }
640
+ export async function startWatchWebServer(opts = {}) {
641
+ const log = opts.log || (() => { });
642
+ setDebugLog(log);
643
+ // soft 版本校验:ping daemon 检查 protocolVersion
644
+ const p = resolvePaths();
645
+ const pingResp = await ipcQuery(p.socket, { type: 'ping' }, 1000);
646
+ if (pingResp && pingResp.protocolVersion !== undefined && pingResp.protocolVersion < PROTOCOL_VERSION) {
647
+ log(`⚠️ evolclaw protocolVersion=${pingResp.protocolVersion},watch 期望 >=${PROTOCOL_VERSION},部分功能可能异常`);
648
+ }
649
+ let pairingCode = genPairingCode();
650
+ let pairingExpiry = Date.now() + PAIRING_TTL_MS;
651
+ function freshPairing() {
652
+ if (Date.now() > pairingExpiry) {
653
+ pairingCode = genPairingCode();
654
+ pairingExpiry = Date.now() + PAIRING_TTL_MS;
655
+ log(`↺ 配对码已刷新:${pairingCode}(5 分钟有效)`);
656
+ }
657
+ return { code: pairingCode, expiresAt: pairingExpiry };
658
+ }
659
+ const server = http.createServer((req, res) => {
660
+ if (req.method === 'GET' && (req.url || '') === '/api/available-baseagents') {
661
+ // Base agent 可用性检测 API
662
+ const available = detectBaseAgents();
663
+ res.writeHead(200, { 'Content-Type': 'application/json' });
664
+ res.end(JSON.stringify(available));
665
+ }
666
+ else if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
667
+ // 取码 API:仅真本地直连可用(socket 回环 + 无 x-aun-provider-aid)。
668
+ // 隧道回连虽然 socket 也是 127.0.0.1,但有 x-aun-provider-aid 头 → 拒绝,
669
+ // 防止远程访客通过隧道拿到配对码(安全漏洞)。
670
+ if (!isLocalDirect(req)) {
671
+ res.writeHead(403, { 'Content-Type': 'application/json' });
672
+ res.end(JSON.stringify({ error: 'forbidden' }));
673
+ return;
674
+ }
675
+ const { code, expiresAt } = freshPairing();
676
+ res.writeHead(200, { 'Content-Type': 'application/json' });
677
+ res.end(JSON.stringify({ code, expiresAt }));
678
+ }
679
+ else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
680
+ // 配对 API:远程(隧道/真远程)需要配对码;本地直连自动发 token 跳过配对。
681
+ const autoToken = issueLocalDirectToken(req, log);
682
+ if (autoToken) {
683
+ res.writeHead(200, { 'Content-Type': 'application/json' });
684
+ res.end(JSON.stringify({ ok: true, token: autoToken }));
685
+ return;
686
+ }
687
+ handlePair(req, res, pairingCode, pairingExpiry, log);
688
+ }
689
+ else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
690
+ // Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
691
+ const authHeader = req.headers.authorization || '';
692
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
693
+ const { query } = parseUrl(req.url || '');
694
+ let token = bearerToken || query.token || '';
695
+ if (!token) {
696
+ const autoToken = issueLocalDirectToken(req, log);
697
+ if (autoToken)
698
+ token = autoToken;
699
+ }
700
+ if (!token || !validateAndRenew(token, Date.now())) {
701
+ res.writeHead(401, { 'Content-Type': 'application/json' });
702
+ res.end(JSON.stringify({ error: 'unauthorized' }));
703
+ return;
704
+ }
705
+ handleStatsApi(req, res);
706
+ }
707
+ else if ((req.url || '').startsWith('/api/models/')) {
708
+ const authHeader = req.headers.authorization || '';
709
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
710
+ const { query } = parseUrl(req.url || '');
711
+ let token = bearerToken || query.token || '';
712
+ if (!token) {
713
+ const autoToken = issueLocalDirectToken(req, log);
714
+ if (autoToken)
715
+ token = autoToken;
716
+ }
717
+ if (!token || !validateAndRenew(token, Date.now())) {
718
+ res.writeHead(401, { 'Content-Type': 'application/json' });
719
+ res.end(JSON.stringify({ error: 'unauthorized' }));
720
+ return;
721
+ }
722
+ handleModelsApi(req, res);
723
+ }
724
+ else if ((req.url || '').startsWith('/api/roles/')) {
725
+ // Roles API — 与 Stats API 相同鉴权逻辑
726
+ const authHeader = req.headers.authorization || '';
727
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
728
+ const { query } = parseUrl(req.url || '');
729
+ let token = bearerToken || query.token || '';
730
+ if (!token) {
731
+ const autoToken = issueLocalDirectToken(req, log);
732
+ if (autoToken)
733
+ token = autoToken;
734
+ }
735
+ if (!token || !validateAndRenew(token, Date.now())) {
736
+ res.writeHead(401, { 'Content-Type': 'application/json' });
737
+ res.end(JSON.stringify({ error: 'unauthorized' }));
738
+ return;
739
+ }
740
+ handleRelationRolesApi(req, res, daemonOwnerMenuAuth());
741
+ }
742
+ else if ((req.url || '') === '/api/role-definitions' || (req.url || '').startsWith('/api/role-definitions/') || (req.url || '').startsWith('/api/role-definitions?')) {
743
+ // Role Definitions API(含集合端点 /api/role-definitions 与 /api/role-definitions/:role)
744
+ const authHeader = req.headers.authorization || '';
745
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
746
+ const { query } = parseUrl(req.url || '');
747
+ let token = bearerToken || query.token || '';
748
+ if (!token) {
749
+ const autoToken = issueLocalDirectToken(req, log);
750
+ if (autoToken)
751
+ token = autoToken;
752
+ }
753
+ if (!token || !validateAndRenew(token, Date.now())) {
754
+ res.writeHead(401, { 'Content-Type': 'application/json' });
755
+ res.end(JSON.stringify({ error: 'unauthorized' }));
756
+ return;
757
+ }
758
+ handleRoleDefinitionsApi(req, res, daemonOwnerMenuAuth());
759
+ }
760
+ else if ((req.url || '').startsWith('/api/assignments/peer/')) {
761
+ const authHeader = req.headers.authorization || '';
762
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
763
+ const { query } = parseUrl(req.url || '');
764
+ let token = bearerToken || query.token || '';
765
+ if (!token) {
766
+ const autoToken = issueLocalDirectToken(req, log);
767
+ if (autoToken)
768
+ token = autoToken;
769
+ }
770
+ if (!token || !validateAndRenew(token, Date.now())) {
771
+ res.writeHead(401, { 'Content-Type': 'application/json' });
772
+ res.end(JSON.stringify({ error: 'unauthorized' }));
773
+ return;
774
+ }
775
+ handlePeerRoleApi(req, res, daemonOwnerMenuAuth());
776
+ }
777
+ else {
778
+ serveStatic(req, res);
779
+ }
780
+ });
781
+ const wss = new WebSocketServer({ noServer: true });
782
+ server.on('upgrade', (req, socket, head) => {
783
+ const { query } = parseUrl(req.url || '');
784
+ // 本地直连免 token;远程需 token。
785
+ const authed = isLocalDirect(req) || validateAndRenew(query.token || '', Date.now());
786
+ wss.handleUpgrade(req, socket, head, (ws) => {
787
+ if (!authed) {
788
+ log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
789
+ ws.close(4001, 'invalid-token');
790
+ return;
791
+ }
792
+ handleConnection(ws, req, log);
793
+ });
794
+ });
795
+ const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
796
+ return {
797
+ url: `http://0.0.0.0:${port}`,
798
+ port,
799
+ displaced,
800
+ pairingCode,
801
+ close() {
802
+ return new Promise((resolve) => {
803
+ // 强制断开所有 WS 客户端(graceful close 握手可能永不完成)
804
+ for (const client of wss.clients)
805
+ try {
806
+ client.terminate();
807
+ }
808
+ catch { }
809
+ wss.close();
810
+ // server.close() 仅停止接受新连接,会等待存量连接(含 HTTP keep-alive、已升级的 WS)排空,
811
+ // 否则回调永不触发 → 进程挂起。Node 18.2+ 用 closeAllConnections() 强制关闭。
812
+ server.close(() => resolve());
813
+ try {
814
+ server.closeAllConnections();
815
+ }
816
+ catch { }
817
+ });
818
+ },
819
+ };
820
+ }