evolclaw-web 1.1.0 → 1.2.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/index.js CHANGED
@@ -67,14 +67,14 @@ const fileLog = (line) => {
67
67
  };
68
68
  const log = (line) => { logLine(line); fileLog(line); };
69
69
  // 单实例保护:
70
- // 1) 按 instance 文件杀掉登记在册的旧 watch-web 进程
71
- const { writeWatchWeb, removeWatchWeb, cleanupWatchWebs, cleanupWatchWebByPort } = await import('./process-utils.js');
72
- const killedWebs = cleanupWatchWebs();
70
+ // 1) 按 instance 文件杀掉登记在册的旧 ecweb 进程
71
+ const { writeEcweb, removeEcweb, cleanupEcwebs, cleanupEcwebByPort } = await import('./process-utils.js');
72
+ const killedWebs = cleanupEcwebs();
73
73
  for (const r of killedWebs)
74
- logLine(`${YELLOW}↺ 已清理旧 watch 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
74
+ logLine(`${YELLOW}↺ 已清理旧 ecweb 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
75
75
  // 2) 兜底:按端口杀掉 instance 文件已丢失的孤儿进程(杀不掉的僵尸)
76
76
  const WATCH_WEB_PORT = port ?? 42705;
77
- const killedByPort = cleanupWatchWebByPort(WATCH_WEB_PORT);
77
+ const killedByPort = cleanupEcwebByPort(WATCH_WEB_PORT);
78
78
  for (const pid of killedByPort)
79
79
  logLine(`${YELLOW}↺ 已强占端口 ${WATCH_WEB_PORT}:杀掉占用进程 PID ${pid}${RST}`);
80
80
  if (killedWebs.length > 0 || killedByPort.length > 0) {
@@ -90,7 +90,7 @@ catch (e) {
90
90
  process.exit(1);
91
91
  }
92
92
  // 注册 instance 文件
93
- writeWatchWeb(handle.port);
93
+ writeEcweb(handle.port);
94
94
  // 列出访问地址
95
95
  const ifaces = os.networkInterfaces();
96
96
  const lanIps = [];
@@ -101,7 +101,7 @@ for (const list of Object.values(ifaces)) {
101
101
  }
102
102
  }
103
103
  process.stdout.write(`\n${BOLD}${CYAN}🔭 EvolClaw Watch${RST} ${DIM}(home: ${p.root})${RST}\n\n`);
104
- process.stdout.write(` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(5 分钟内有效,配对后 token 缓存 24h 自动续期)${RST}\n\n`);
104
+ process.stdout.write(` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(5 分钟内有效,配对后 token 缓存 30 天,有访问自动续期)${RST}\n\n`);
105
105
  process.stdout.write(` ${BOLD}本机:${RST} http://localhost:${handle.port}\n`);
106
106
  for (const ip of lanIps)
107
107
  process.stdout.write(` ${BOLD}局域网:${RST} http://${ip}:${handle.port}\n`);
@@ -116,13 +116,13 @@ const cleanup = () => {
116
116
  return; // 幂等:raw 模式下连按 Ctrl-C/q 不应重复触发
117
117
  cleaningUp = true;
118
118
  logLine(`${YELLOW}退出中…${RST}`);
119
- removeWatchWeb();
119
+ removeEcweb();
120
120
  // 兜底:close() 万一卡住也强制退出,避免进程挂死
121
121
  const force = setTimeout(() => process.exit(0), 2000);
122
122
  force.unref();
123
123
  handle.close().finally(() => process.exit(0));
124
124
  };
125
- process.on('exit', () => removeWatchWeb());
125
+ process.on('exit', () => removeEcweb());
126
126
  process.on('SIGINT', cleanup);
127
127
  process.on('SIGTERM', cleanup);
128
128
  if (process.stdin.isTTY) {
@@ -151,25 +151,32 @@ function readCmdline(pid) {
151
151
  function instanceDir() {
152
152
  return resolvePaths().instanceDir;
153
153
  }
154
- function watchWebFile(pid) {
155
- return path.join(instanceDir(), `watch-web-${pid}.json`);
154
+ function isEcwebInstanceFile(file) {
155
+ return /^(ecweb|watch-web)-\d+\.json$/.test(file);
156
156
  }
157
- export function writeWatchWeb(port) {
157
+ function ecwebFile(pid) {
158
+ return path.join(instanceDir(), `ecweb-${pid}.json`);
159
+ }
160
+ export function writeEcweb(port) {
158
161
  const dir = instanceDir();
159
162
  fs.mkdirSync(dir, { recursive: true });
160
163
  const startedAt = getProcessStartTime(process.pid) ?? Date.now();
161
164
  const record = { pid: process.pid, startedAt, startedAtIso: new Date(startedAt).toISOString(), port };
162
- const filePath = watchWebFile(process.pid);
165
+ const filePath = ecwebFile(process.pid);
163
166
  const tmp = filePath + '.tmp';
164
167
  fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
165
168
  fs.renameSync(tmp, filePath);
166
169
  return filePath;
167
170
  }
168
- export function removeWatchWeb(pid) {
169
- try {
170
- fs.unlinkSync(watchWebFile(pid ?? process.pid));
171
+ export function removeEcweb(pid) {
172
+ const target = pid ?? process.pid;
173
+ const dir = instanceDir();
174
+ for (const prefix of ['ecweb', 'watch-web']) {
175
+ try {
176
+ fs.unlinkSync(path.join(dir, `${prefix}-${target}.json`));
177
+ }
178
+ catch { }
171
179
  }
172
- catch { }
173
180
  }
174
181
  function safeParseJson(filePath) {
175
182
  try {
@@ -180,10 +187,11 @@ function safeParseJson(filePath) {
180
187
  }
181
188
  }
182
189
  /**
183
- * 杀掉所有非自己 PID 的存活 watch-web 进程并清理文件。
190
+ * 杀掉所有非自己 PID 的存活 ecweb 进程并清理文件。
191
+ * 兼容清理迁移前遗留的 watch-web-*.json。
184
192
  * 用启动时间比对防 PID 复用。返回被杀的记录列表。
185
193
  */
186
- export function cleanupWatchWebs() {
194
+ export function cleanupEcwebs() {
187
195
  const dir = instanceDir();
188
196
  if (!fs.existsSync(dir))
189
197
  return [];
@@ -196,7 +204,7 @@ export function cleanupWatchWebs() {
196
204
  }
197
205
  const killed = [];
198
206
  for (const file of files) {
199
- if (!file.startsWith('watch-web-') || !file.endsWith('.json'))
207
+ if (!isEcwebInstanceFile(file))
200
208
  continue;
201
209
  const filePath = path.join(dir, file);
202
210
  const record = safeParseJson(filePath);
@@ -217,7 +225,7 @@ export function cleanupWatchWebs() {
217
225
  * 兜底:按端口找占用进程,确认是 ecweb 进程后 SIGKILL。
218
226
  * 用于清理 instance 文件已丢失的孤儿进程(杀不掉的僵尸)。返回被杀的 PID 列表。
219
227
  */
220
- export function cleanupWatchWebByPort(port) {
228
+ export function cleanupEcwebByPort(port) {
221
229
  const killed = [];
222
230
  for (const pid of findPidByPort(port)) {
223
231
  if (pid === process.pid || !isProcessRunning(pid))
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * - HTTP: 静态资源 + 配对 API
5
5
  * - WebSocket: 订阅式实时推送(aid / msg / session)
6
- * - 鉴权: 6 位配对码(5 分钟有效)→ token(24h,有访问自动续期),持久化到磁盘
6
+ * - 鉴权: 6 位配对码(5 分钟有效)→ token(30 天,有访问自动续期),持久化到磁盘
7
7
  * - 安全: 绑定 0.0.0.0(支持远程访问),token 校验,只读
8
8
  *
9
9
  * 与 evolclaw 的唯一通信:启动时发 ping 检查 protocolVersion(soft 校验)。
@@ -12,6 +12,7 @@ import http from 'http';
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
14
  import crypto from 'crypto';
15
+ import { spawnSync } from 'child_process';
15
16
  import { fileURLToPath } from 'url';
16
17
  import { WebSocketServer } from 'ws';
17
18
  import { resolvePaths } from './paths.js';
@@ -19,20 +20,22 @@ import { ipcQuery } from './ipc-client.js';
19
20
  import { setDebugLog, dlog } from './debug-log.js';
20
21
  import { aidSource } from './sources/aid.js';
21
22
  import { msgSource } from './sources/msg.js';
22
- import { sessionSource } from './sources/session.js';
23
+ import { sessionSource, buildBindMap } from './sources/session.js';
23
24
  import { cacheSource } from './sources/cache.js';
24
25
  import { systemSource } from './sources/system.js';
25
26
  import { triggersSource } from './sources/triggers.js';
26
- import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsByAgent, queryStatsOverview } from './sources/stats.js';
27
+ import { monitorSource } from './sources/monitor.js';
28
+ import { gatewaySource } from './sources/gateway.js';
29
+ import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsOverview, queryUsageDetail, queryUsedModels } from './sources/stats.js';
27
30
  import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
28
31
  import { ccProjectsDir } from './paths.js';
29
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
33
  const STATIC_DIR = path.join(__dirname, 'static');
31
- const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24h
34
+ const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
32
35
  const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
33
36
  const DEFAULT_PORT = 42705;
34
37
  const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
35
- const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource };
38
+ const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource, gateway: gatewaySource };
36
39
  // ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
37
40
  function readEcwebVersion() {
38
41
  try {
@@ -52,7 +55,16 @@ const MIME = {
52
55
  '.svg': 'image/svg+xml',
53
56
  };
54
57
  function tokenStorePath() {
55
- return path.join(resolvePaths().instanceDir, 'watch-web-tokens.json');
58
+ const dir = resolvePaths().instanceDir;
59
+ const current = path.join(dir, 'ecweb-tokens.json');
60
+ const legacy = path.join(dir, 'watch-web-tokens.json');
61
+ if (!fs.existsSync(current) && fs.existsSync(legacy)) {
62
+ try {
63
+ fs.renameSync(legacy, current);
64
+ }
65
+ catch { }
66
+ }
67
+ return current;
56
68
  }
57
69
  function loadTokens() {
58
70
  try {
@@ -93,6 +105,26 @@ function validateAndRenew(token, now) {
93
105
  return false;
94
106
  }
95
107
  // ── Static ──
108
+ // 经 AUN Service Proxy 转发时,proxy-server 注入 x-forwarded-prefix 头,
109
+ // 值为外部前缀(如 /evolai/ecweb)。把它作为 <base href> 注入 index.html,
110
+ // 使相对路径资源(style.css / app.js / api / ws)在带不带尾斜杠时都正确解析。
111
+ // 本地直连无此头,<base> 注入为 "/",行为不变。
112
+ function forwardedPrefix(req) {
113
+ const raw = req.headers['x-forwarded-prefix'];
114
+ const value = (Array.isArray(raw) ? raw[0] : raw || '').trim();
115
+ // 安全:只接受形如 /a/b 的简单路径,拒绝含协议、双斜杠、引号、尖括号等的值
116
+ if (!value || !/^\/[A-Za-z0-9._~%/-]*$/.test(value))
117
+ return '';
118
+ return value.replace(/\/+$/, ''); // 去尾斜杠,注入时统一补
119
+ }
120
+ function injectBaseHref(html, prefix) {
121
+ const base = prefix ? `${prefix}/` : '/';
122
+ const tag = `<base href="${base}">`;
123
+ // 若已有 <base> 则替换,否则插在 <head> 之后
124
+ if (/<base\b[^>]*>/i.test(html))
125
+ return html.replace(/<base\b[^>]*>/i, tag);
126
+ return html.replace(/<head[^>]*>/i, (m) => `${m}\n ${tag}`);
127
+ }
96
128
  function serveStatic(req, res) {
97
129
  let urlPath = (req.url || '/').split('?')[0];
98
130
  if (urlPath === '/')
@@ -108,7 +140,16 @@ function serveStatic(req, res) {
108
140
  res.writeHead(404).end('Not Found');
109
141
  return;
110
142
  }
111
- res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
143
+ const ext = path.extname(file);
144
+ // index.html 注入 <base href>,适配 AUN Service Proxy 前缀
145
+ if (ext === '.html') {
146
+ const prefix = forwardedPrefix(req);
147
+ const html = injectBaseHref(data.toString('utf-8'), prefix);
148
+ res.writeHead(200, { 'Content-Type': MIME[ext] });
149
+ res.end(html);
150
+ return;
151
+ }
152
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
112
153
  res.end(data);
113
154
  });
114
155
  }
@@ -172,40 +213,97 @@ function handleStatsApi(req, res) {
172
213
  res.end(JSON.stringify(data));
173
214
  }
174
215
  else if (urlPath === '/api/stats/agents') {
216
+ // 返回所有agent列表(与智能体管理页面一致,从aidSource获取)
217
+ aidSource.snapshot().then(snapshot => {
218
+ const agents = (snapshot?.agents || []).map((ag) => ({
219
+ agent_aid: ag.aid,
220
+ agent_name: ag.displayName || null
221
+ }));
222
+ res.writeHead(200);
223
+ res.end(JSON.stringify(agents));
224
+ }).catch(err => {
225
+ res.writeHead(500);
226
+ res.end(JSON.stringify({ error: 'Failed to fetch agents' }));
227
+ });
228
+ return;
229
+ }
230
+ else if (urlPath === '/api/stats/overview') {
231
+ // 从查询参数中获取时间范围和筛选条件
175
232
  const params = {};
176
233
  if (query.from)
177
234
  params.from_ts = Number(query.from);
178
235
  if (query.to)
179
236
  params.to_ts = Number(query.to);
180
- if (query.limit)
181
- params.limit = Number(query.limit);
182
- const data = queryStatsByAgent(params);
183
- res.writeHead(200);
184
- res.end(JSON.stringify(data));
185
- }
186
- else if (urlPath === '/api/stats/overview') {
187
- const tokenStats = queryStatsOverview();
188
- // session count: scan all CC project dirs
237
+ if (query.agent)
238
+ params.agent_aid = String(query.agent);
239
+ if (query.peer)
240
+ params.peer_key = String(query.peer);
241
+ const tokenStats = queryStatsOverview(params);
242
+ // session count: 使用 bindMap 按 agent 和 peer 筛选
189
243
  let sessionCount = 0;
190
244
  try {
245
+ // 构建 agentSessionId → agent_aid 的映射
246
+ const bindMap = buildBindMap();
247
+ // 如果指定了 peer_key,提取并解码 channelId
248
+ let targetChannelId;
249
+ if (params.peer_key) {
250
+ const parts = params.peer_key.split('#');
251
+ if (parts.length >= 4) {
252
+ // aun#{agent}#main#{channelId},需要解码 URL 编码
253
+ targetChannelId = decodeURIComponent(parts[3]);
254
+ }
255
+ }
191
256
  const base = ccProjectsDir();
192
257
  for (const d of fs.readdirSync(base, { withFileTypes: true })) {
193
258
  if (!d.isDirectory())
194
259
  continue;
260
+ const projectDir = path.join(base, d.name);
195
261
  try {
196
- sessionCount += fs.readdirSync(path.join(base, d.name)).filter(f => f.endsWith('.jsonl')).length;
262
+ const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
263
+ for (const sessionFile of sessionFiles) {
264
+ const sessionId = sessionFile.replace('.jsonl', '');
265
+ // 如果指定了 agent 或 peer,检查该会话是否匹配
266
+ if (params.agent_aid || targetChannelId) {
267
+ const bindInfo = bindMap.get(sessionId);
268
+ if (!bindInfo)
269
+ continue;
270
+ // 检查 agent
271
+ if (params.agent_aid && bindInfo.selfAID !== params.agent_aid)
272
+ continue;
273
+ // 检查 peer
274
+ if (targetChannelId && bindInfo.channelId !== targetChannelId)
275
+ continue;
276
+ }
277
+ // 如果有时间范围限制,检查会话文件的修改时间
278
+ if (params.from_ts || params.to_ts) {
279
+ const stat = fs.statSync(path.join(projectDir, sessionFile));
280
+ const mtime = stat.mtimeMs;
281
+ if (params.from_ts && mtime < params.from_ts)
282
+ continue;
283
+ if (params.to_ts && mtime > params.to_ts)
284
+ continue;
285
+ }
286
+ sessionCount++;
287
+ }
197
288
  }
198
289
  catch { }
199
290
  }
200
291
  }
201
292
  catch { }
202
- // message counts: scan aun dir
293
+ // message counts: scan aun dir, 根据时间范围和 agent/peer 过滤
203
294
  let msgIn = 0, msgOut = 0;
204
295
  try {
205
296
  const aunDir = getSessionsAunDir();
206
- for (const aid of listLocalAids(aunDir)) {
207
- for (const peer of listPeers(aunDir, aid)) {
297
+ const aids = params.agent_aid ? [params.agent_aid] : listLocalAids(aunDir);
298
+ for (const aid of aids) {
299
+ const peers = params.peer_key ? [params.peer_key] : listPeers(aunDir, aid);
300
+ for (const peer of peers) {
208
301
  for (const m of readMessages(aunDir, aid, peer)) {
302
+ // 根据消息时间戳过滤
303
+ if (params.from_ts && m.ts < params.from_ts)
304
+ continue;
305
+ if (params.to_ts && m.ts > params.to_ts)
306
+ continue;
209
307
  if (m.dir === 'in')
210
308
  msgIn++;
211
309
  else
@@ -218,6 +316,34 @@ function handleStatsApi(req, res) {
218
316
  res.writeHead(200);
219
317
  res.end(JSON.stringify({ token_stats: tokenStats, session_count: sessionCount, msg_in: msgIn, msg_out: msgOut }));
220
318
  }
319
+ else if (urlPath === '/api/stats/detail') {
320
+ // 模型访问明细查询
321
+ const params = {};
322
+ if (query.from)
323
+ params.from_ts = Number(query.from);
324
+ if (query.to)
325
+ params.to_ts = Number(query.to);
326
+ if (query.agent)
327
+ params.agent_aid = query.agent;
328
+ if (query.model)
329
+ params.model = String(query.model);
330
+ params.limit = Number(query.limit) || 50; // 默认限制50条
331
+ params.offset = Number(query.offset) || 0; // 默认从0开始
332
+ const result = queryUsageDetail(params);
333
+ res.writeHead(200);
334
+ res.end(JSON.stringify(result));
335
+ }
336
+ else if (urlPath === '/api/stats/models') {
337
+ // 获取指定时间范围内使用过的模型列表
338
+ const params = {};
339
+ if (query.from)
340
+ params.from_ts = Number(query.from);
341
+ if (query.to)
342
+ params.to_ts = Number(query.to);
343
+ const models = queryUsedModels(params);
344
+ res.writeHead(200);
345
+ res.end(JSON.stringify(models));
346
+ }
221
347
  else {
222
348
  res.writeHead(404);
223
349
  res.end(JSON.stringify({ error: 'not found' }));
@@ -235,6 +361,18 @@ function isLocalhost(req) {
235
361
  const addr = req.socket.remoteAddress || '';
236
362
  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
237
363
  }
364
+ /**
365
+ * 判定「真本地直连」:socket 地址是回环 AND 无 x-aun-provider-aid 头。
366
+ * proxy-server 回连本地时 remoteAddress 也是 127.0.0.1,但会注入 x-aun-provider-aid
367
+ * 可信头(访客伪造的 x-aun-* 会被 proxy-server 剥掉)。只有真本地直连时两条件同时满足。
368
+ * 真本地直连免配对(自动发 token),隧道/远程需配对码。
369
+ */
370
+ function isLocalDirect(req) {
371
+ if (!isLocalhost(req))
372
+ return false;
373
+ const providerAid = req.headers['x-aun-provider-aid'];
374
+ return !providerAid || (Array.isArray(providerAid) ? providerAid.length === 0 : !providerAid.trim());
375
+ }
238
376
  function genPairingCode() {
239
377
  return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
240
378
  }
@@ -267,9 +405,26 @@ function handlePair(req, res, pairingCode, pairingExpiry, log) {
267
405
  store.tokens.push({ token, createdAt: now, lastActive: now, label: ip });
268
406
  saveTokens(store);
269
407
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token }));
270
- log(`✓ 配对成功 from ${ip}(token 缓存 24h)`);
408
+ log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
271
409
  });
272
410
  }
411
+ /**
412
+ * 本地直连免配对:自动发 token。远程(隧道或真远程)需配对码。
413
+ * 本地直连判定:socket 来源是回环 AND 无 x-aun-provider-aid 头(非隧道)。
414
+ */
415
+ function issueLocalDirectToken(req, log) {
416
+ if (!isLocalDirect(req))
417
+ return null;
418
+ const now = Date.now();
419
+ const token = crypto.randomBytes(32).toString('hex');
420
+ const store = loadTokens();
421
+ pruneExpired(store, now);
422
+ const ip = clientIp(req);
423
+ store.tokens.push({ token, createdAt: now, lastActive: now, label: `local-direct:${ip}` });
424
+ saveTokens(store);
425
+ log(`✓ 本地直连自动授权 from ${ip}`);
426
+ return token;
427
+ }
273
428
  // ── WebSocket connection ──
274
429
  function handleConnection(ws, req, log) {
275
430
  const ip = clientIp(req);
@@ -372,19 +527,69 @@ function handleConnection(ws, req, log) {
372
527
  ws.on('error', () => { });
373
528
  }
374
529
  // ── Port binding ──
375
- function bindPort(server, preferred) {
530
+ /** 跨平台:查端口上的 LISTENING 进程 PID(清理被占端口用)。 */
531
+ function findPidsOnPort(port) {
532
+ const pids = new Set();
533
+ try {
534
+ if (process.platform === 'win32') {
535
+ const out = spawnSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8', windowsHide: true }).stdout || '';
536
+ for (const line of out.split('\n')) {
537
+ if (!/LISTENING/i.test(line))
538
+ continue;
539
+ const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
540
+ if (m && Number(m[1]) === port) {
541
+ const pid = Number(m[2]);
542
+ if (pid && pid !== process.pid)
543
+ pids.add(pid);
544
+ }
545
+ }
546
+ }
547
+ else {
548
+ const out = spawnSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8' }).stdout || '';
549
+ for (const l of out.split('\n')) {
550
+ const pid = parseInt(l.trim(), 10);
551
+ if (pid && pid !== process.pid)
552
+ pids.add(pid);
553
+ }
554
+ }
555
+ }
556
+ catch { /* netstat/lsof 缺失或无匹配 */ }
557
+ return [...pids];
558
+ }
559
+ /** 杀掉占用目标端口的旧进程(强制)。 */
560
+ function killPidsOnPort(port, log) {
561
+ let killed = false;
562
+ for (const pid of findPidsOnPort(port)) {
563
+ try {
564
+ if (process.platform === 'win32')
565
+ spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true });
566
+ else
567
+ process.kill(pid, 'SIGKILL');
568
+ log(`⚠ 端口 ${port} 被旧进程 PID ${pid} 占用,已终止`);
569
+ killed = true;
570
+ }
571
+ catch { /* 已退出或无权限 */ }
572
+ }
573
+ return killed;
574
+ }
575
+ function sleepMs(ms) { return new Promise(r => setTimeout(r, ms)); }
576
+ function bindPort(server, preferred, log) {
376
577
  return new Promise((resolve, reject) => {
377
- let attempt = 0;
578
+ let killedOnce = false;
378
579
  const tryBind = (port) => {
379
- server.once('error', (err) => {
380
- if (err.code === 'EADDRINUSE' && attempt < 10) {
381
- attempt++;
382
- tryBind(port + 1);
580
+ server.once('error', async (err) => {
581
+ if (err.code === 'EADDRINUSE' && !killedOnce) {
582
+ // 默认行为:杀掉占用端口的旧进程并重试本端口(而非 +1 漂移),避免端口被占。
583
+ killedOnce = true;
584
+ killPidsOnPort(port, log);
585
+ await sleepMs(600);
586
+ tryBind(port);
383
587
  }
384
- else
588
+ else {
385
589
  reject(err);
590
+ }
386
591
  });
387
- server.listen(port, '0.0.0.0', () => resolve({ port, displaced: port !== preferred }));
592
+ server.listen(port, '0.0.0.0', () => resolve({ port, displaced: false }));
388
593
  };
389
594
  tryBind(preferred);
390
595
  });
@@ -410,8 +615,10 @@ export async function startWatchWebServer(opts = {}) {
410
615
  }
411
616
  const server = http.createServer((req, res) => {
412
617
  if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
413
- // localhost 可取码:远程浏览器拿不到,必须由同机的 `ec watch web` 显示给用户
414
- if (!isLocalhost(req)) {
618
+ // 取码 API:仅真本地直连可用(socket 回环 + x-aun-provider-aid)。
619
+ // 隧道回连虽然 socket 也是 127.0.0.1,但有 x-aun-provider-aid 头 → 拒绝,
620
+ // 防止远程访客通过隧道拿到配对码(安全漏洞)。
621
+ if (!isLocalDirect(req)) {
415
622
  res.writeHead(403, { 'Content-Type': 'application/json' });
416
623
  res.end(JSON.stringify({ error: 'forbidden' }));
417
624
  return;
@@ -421,15 +628,27 @@ export async function startWatchWebServer(opts = {}) {
421
628
  res.end(JSON.stringify({ code, expiresAt }));
422
629
  }
423
630
  else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
631
+ // 配对 API:远程(隧道/真远程)需要配对码;本地直连自动发 token 跳过配对。
632
+ const autoToken = issueLocalDirectToken(req, log);
633
+ if (autoToken) {
634
+ res.writeHead(200, { 'Content-Type': 'application/json' });
635
+ res.end(JSON.stringify({ ok: true, token: autoToken }));
636
+ return;
637
+ }
424
638
  handlePair(req, res, pairingCode, pairingExpiry, log);
425
639
  }
426
640
  else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
427
- // Stats API — requires auth
641
+ // Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
428
642
  const authHeader = req.headers.authorization || '';
429
643
  const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
430
644
  const { query } = parseUrl(req.url || '');
431
- const token = bearerToken || query.token || '';
432
- if (!validateAndRenew(token, Date.now())) {
645
+ let token = bearerToken || query.token || '';
646
+ if (!token) {
647
+ const autoToken = issueLocalDirectToken(req, log);
648
+ if (autoToken)
649
+ token = autoToken;
650
+ }
651
+ if (!token || !validateAndRenew(token, Date.now())) {
433
652
  res.writeHead(401, { 'Content-Type': 'application/json' });
434
653
  res.end(JSON.stringify({ error: 'unauthorized' }));
435
654
  return;
@@ -443,7 +662,8 @@ export async function startWatchWebServer(opts = {}) {
443
662
  const wss = new WebSocketServer({ noServer: true });
444
663
  server.on('upgrade', (req, socket, head) => {
445
664
  const { query } = parseUrl(req.url || '');
446
- const authed = validateAndRenew(query.token || '', Date.now());
665
+ // 本地直连免 token;远程需 token
666
+ const authed = isLocalDirect(req) || validateAndRenew(query.token || '', Date.now());
447
667
  wss.handleUpgrade(req, socket, head, (ws) => {
448
668
  if (!authed) {
449
669
  log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
@@ -453,7 +673,7 @@ export async function startWatchWebServer(opts = {}) {
453
673
  handleConnection(ws, req, log);
454
674
  });
455
675
  });
456
- const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT);
676
+ const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
457
677
  return {
458
678
  url: `http://0.0.0.0:${port}`,
459
679
  port,
@@ -64,13 +64,31 @@ function scanAidActivity() {
64
64
  }
65
65
  return result;
66
66
  }
67
+ // evolclaw 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
68
+ // 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
69
+ let _cachedVersion = null;
70
+ async function fetchVersion(socket) {
71
+ if (_cachedVersion)
72
+ return _cachedVersion;
73
+ try {
74
+ const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' } }, 4000);
75
+ const v = r?.ok ? r.response?.data?.version : null;
76
+ if (v)
77
+ _cachedVersion = v;
78
+ return v ?? null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
67
84
  async function buildSnapshot() {
68
85
  const p = resolvePaths();
69
- const [aidsResp, statsResp, statusResp, agentsResp] = await Promise.all([
86
+ const [aidsResp, statsResp, statusResp, agentsResp, version] = await Promise.all([
70
87
  ipcQuery(p.socket, { type: 'aun-aids' }),
71
88
  ipcQuery(p.socket, { type: 'aun-aid-stats' }),
72
89
  ipcQuery(p.socket, { type: 'status' }),
73
90
  ipcQuery(p.socket, { type: 'evolagent.list' }),
91
+ fetchVersion(p.socket),
74
92
  ]);
75
93
  const daemonRunning = aidsResp !== null || statusResp !== null;
76
94
  if (!daemonRunning) {
@@ -89,6 +107,7 @@ async function buildSnapshot() {
89
107
  stats: statsResp?.stats ?? [],
90
108
  status: statusResp ?? null,
91
109
  agents: agentsResp?.agents ?? [],
110
+ version: version ?? null,
92
111
  };
93
112
  }
94
113
  export const aidSource = {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * gateway.ts — 网关(baseagent 后端)配置数据源(只读代理)。
3
+ *
4
+ * ECWeb 不直接读写配置文件。snapshot 通过 daemon 的 menu.exec IPC 调
5
+ * menu.query name=gateway,由 daemon 端的 command-handler-gateway-control 统一
6
+ * 出数据(apiKey 已掩码)。写操作(update/test/delete)走前端 menuSend → menu IPC,
7
+ * 不经过本 source。
8
+ *
9
+ * 连不上 daemon 时返回 { gateways: [], error } 让前端提示。
10
+ */
11
+ import { resolvePaths } from '../paths.js';
12
+ import { ipcQuery } from '../ipc-client.js';
13
+ async function getSnapshot() {
14
+ const sock = resolvePaths().socket;
15
+ const resp = await ipcQuery(sock, { type: 'menu.exec', payload: { type: 'menu.query', name: 'gateway', id: 'ecw-gateway-snap' } }, 5000);
16
+ if (!resp) {
17
+ return { gateways: [], scopes: [], types: [], error: 'evolclaw 未运行或 socket 不可达' };
18
+ }
19
+ if (!resp.ok) {
20
+ return { gateways: [], scopes: [], types: [], error: resp.error || 'daemon 返回失败' };
21
+ }
22
+ const inner = resp.response;
23
+ if (inner?.error) {
24
+ return { gateways: [], scopes: [], types: [], error: inner.error.message };
25
+ }
26
+ const data = inner?.data ?? {};
27
+ return {
28
+ gateways: Array.isArray(data.gateways) ? data.gateways : [],
29
+ scopes: Array.isArray(data.scopes) ? data.scopes : [],
30
+ types: Array.isArray(data.types) ? data.types : [],
31
+ effective: Array.isArray(data.effective) ? data.effective : [],
32
+ envMismatch: data.envMismatch || { hasMismatch: false, mismatches: [] },
33
+ };
34
+ }
35
+ export const gatewaySource = {
36
+ kind: 'gateway',
37
+ async snapshot() {
38
+ return getSnapshot();
39
+ },
40
+ subscribe(_params, _push) {
41
+ // 网关配置变更不频繁,无文件监听;前端通过操作后主动 re-subscribe 刷新。
42
+ return () => { };
43
+ },
44
+ };