evolclaw-web 1.2.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/server.js CHANGED
@@ -20,12 +20,13 @@ import { ipcQuery } from './ipc-client.js';
20
20
  import { setDebugLog, dlog } from './debug-log.js';
21
21
  import { aidSource } from './sources/aid.js';
22
22
  import { msgSource } from './sources/msg.js';
23
- import { sessionSource } from './sources/session.js';
23
+ import { sessionSource, buildBindMap } from './sources/session.js';
24
24
  import { cacheSource } from './sources/cache.js';
25
25
  import { systemSource } from './sources/system.js';
26
26
  import { triggersSource } from './sources/triggers.js';
27
27
  import { monitorSource } from './sources/monitor.js';
28
- import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsByAgent, queryStatsOverview } from './sources/stats.js';
28
+ import { gatewaySource } from './sources/gateway.js';
29
+ import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsOverview, queryUsageDetail, queryUsedModels } from './sources/stats.js';
29
30
  import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
30
31
  import { ccProjectsDir } from './paths.js';
31
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -34,7 +35,7 @@ const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次
34
35
  const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
35
36
  const DEFAULT_PORT = 42705;
36
37
  const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
37
- const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource };
38
+ const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource, gateway: gatewaySource };
38
39
  // ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
39
40
  function readEcwebVersion() {
40
41
  try {
@@ -104,6 +105,26 @@ function validateAndRenew(token, now) {
104
105
  return false;
105
106
  }
106
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
+ }
107
128
  function serveStatic(req, res) {
108
129
  let urlPath = (req.url || '/').split('?')[0];
109
130
  if (urlPath === '/')
@@ -119,7 +140,16 @@ function serveStatic(req, res) {
119
140
  res.writeHead(404).end('Not Found');
120
141
  return;
121
142
  }
122
- 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' });
123
153
  res.end(data);
124
154
  });
125
155
  }
@@ -183,40 +213,97 @@ function handleStatsApi(req, res) {
183
213
  res.end(JSON.stringify(data));
184
214
  }
185
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
+ // 从查询参数中获取时间范围和筛选条件
186
232
  const params = {};
187
233
  if (query.from)
188
234
  params.from_ts = Number(query.from);
189
235
  if (query.to)
190
236
  params.to_ts = Number(query.to);
191
- if (query.limit)
192
- params.limit = Number(query.limit);
193
- const data = queryStatsByAgent(params);
194
- res.writeHead(200);
195
- res.end(JSON.stringify(data));
196
- }
197
- else if (urlPath === '/api/stats/overview') {
198
- const tokenStats = queryStatsOverview();
199
- // 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 筛选
200
243
  let sessionCount = 0;
201
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
+ }
202
256
  const base = ccProjectsDir();
203
257
  for (const d of fs.readdirSync(base, { withFileTypes: true })) {
204
258
  if (!d.isDirectory())
205
259
  continue;
260
+ const projectDir = path.join(base, d.name);
206
261
  try {
207
- 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
+ }
208
288
  }
209
289
  catch { }
210
290
  }
211
291
  }
212
292
  catch { }
213
- // message counts: scan aun dir
293
+ // message counts: scan aun dir, 根据时间范围和 agent/peer 过滤
214
294
  let msgIn = 0, msgOut = 0;
215
295
  try {
216
296
  const aunDir = getSessionsAunDir();
217
- for (const aid of listLocalAids(aunDir)) {
218
- 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) {
219
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;
220
307
  if (m.dir === 'in')
221
308
  msgIn++;
222
309
  else
@@ -229,6 +316,34 @@ function handleStatsApi(req, res) {
229
316
  res.writeHead(200);
230
317
  res.end(JSON.stringify({ token_stats: tokenStats, session_count: sessionCount, msg_in: msgIn, msg_out: msgOut }));
231
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
+ }
232
347
  else {
233
348
  res.writeHead(404);
234
349
  res.end(JSON.stringify({ error: 'not found' }));
@@ -246,6 +361,18 @@ function isLocalhost(req) {
246
361
  const addr = req.socket.remoteAddress || '';
247
362
  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
248
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
+ }
249
376
  function genPairingCode() {
250
377
  return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
251
378
  }
@@ -281,6 +408,23 @@ function handlePair(req, res, pairingCode, pairingExpiry, log) {
281
408
  log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
282
409
  });
283
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
+ }
284
428
  // ── WebSocket connection ──
285
429
  function handleConnection(ws, req, log) {
286
430
  const ip = clientIp(req);
@@ -471,8 +615,10 @@ export async function startWatchWebServer(opts = {}) {
471
615
  }
472
616
  const server = http.createServer((req, res) => {
473
617
  if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
474
- // localhost 可取码:远程浏览器拿不到,必须由同机的 `ec watch web` 显示给用户
475
- 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)) {
476
622
  res.writeHead(403, { 'Content-Type': 'application/json' });
477
623
  res.end(JSON.stringify({ error: 'forbidden' }));
478
624
  return;
@@ -482,15 +628,27 @@ export async function startWatchWebServer(opts = {}) {
482
628
  res.end(JSON.stringify({ code, expiresAt }));
483
629
  }
484
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
+ }
485
638
  handlePair(req, res, pairingCode, pairingExpiry, log);
486
639
  }
487
640
  else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
488
- // Stats API — requires auth
641
+ // Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
489
642
  const authHeader = req.headers.authorization || '';
490
643
  const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
491
644
  const { query } = parseUrl(req.url || '');
492
- const token = bearerToken || query.token || '';
493
- 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())) {
494
652
  res.writeHead(401, { 'Content-Type': 'application/json' });
495
653
  res.end(JSON.stringify({ error: 'unauthorized' }));
496
654
  return;
@@ -504,7 +662,8 @@ export async function startWatchWebServer(opts = {}) {
504
662
  const wss = new WebSocketServer({ noServer: true });
505
663
  server.on('upgrade', (req, socket, head) => {
506
664
  const { query } = parseUrl(req.url || '');
507
- const authed = validateAndRenew(query.token || '', Date.now());
665
+ // 本地直连免 token;远程需 token
666
+ const authed = isLocalDirect(req) || validateAndRenew(query.token || '', Date.now());
508
667
  wss.handleUpgrade(req, socket, head, (ws) => {
509
668
  if (!authed) {
510
669
  log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
@@ -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
+ };
@@ -221,7 +221,7 @@ function resolveProject(params, projects) {
221
221
  dlog(`[session] resolveProject: cwd project=${curEncoded.slice(-24)} not found → using first=${projects[0]?.encoded.slice(-24)}`);
222
222
  return projects[0] || null;
223
223
  }
224
- function buildBindMap() {
224
+ export function buildBindMap() {
225
225
  const map = new Map();
226
226
  try {
227
227
  const p = resolvePaths();