ec-web 0.0.19 → 0.0.21

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
@@ -33,6 +33,11 @@ if (home)
33
33
  const { resolvePaths } = await import('./paths.js');
34
34
  const { startWatchWebServer } = await import('./server.js');
35
35
  const p = resolvePaths();
36
+ // 统一行为:无论是 supervisor 启动(已设 env)还是手动启动(未设 env),
37
+ // 都确保 EVOLCORE_HOME 环境变量存在,让所有依赖 env 的模块(如 ipc-client)行为一致
38
+ if (!process.env.EVOLCORE_HOME) {
39
+ process.env.EVOLCORE_HOME = p.root;
40
+ }
36
41
  // 校验 home 目录存在
37
42
  if (!fs.existsSync(p.root)) {
38
43
  process.stderr.write(`❌ EVOLCORE_HOME 不存在: ${p.root}\n 用 --home 指定正确路径,或设置 EVOLCORE_HOME 环境变量。\n`);
@@ -17,10 +17,23 @@ export function ipcQuery(socketPath, cmd, timeoutMs = 3000) {
17
17
  let request = cmd;
18
18
  if (LOCAL_CONTROL_COMMANDS.has(cmd.type) && typeof cmd.controlToken !== 'string') {
19
19
  try {
20
- const tokenPath = process.platform === 'win32'
21
- ? path.join(process.env.EVOLCORE_HOME || '', 'data', 'instance', 'control.token')
22
- : path.join(path.dirname(socketPath), 'control.token');
23
- const controlToken = fs.readFileSync(tokenPath, 'utf8').trim();
20
+ // 入口会设置 EVOLCORE_HOME;保留 socket 旁路回退,便于独立调用和
21
+ // 旧版本地布局继续完成受保护 IPC 查询。
22
+ const tokenPaths = [
23
+ ...(process.env.EVOLCORE_HOME
24
+ ? [path.join(process.env.EVOLCORE_HOME, 'data', 'instance', 'control.token')]
25
+ : []),
26
+ path.join(path.dirname(socketPath), 'control.token'),
27
+ ];
28
+ let controlToken = '';
29
+ for (const tokenPath of tokenPaths) {
30
+ try {
31
+ controlToken = fs.readFileSync(tokenPath, 'utf8').trim();
32
+ if (controlToken)
33
+ break;
34
+ }
35
+ catch { }
36
+ }
24
37
  if (controlToken)
25
38
  request = { ...cmd, controlToken };
26
39
  }
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * - HTTP: 静态资源 + 配对 API
5
5
  * - WebSocket: 订阅式实时推送(aid / msg / session)
6
6
  * - 鉴权: 6 位配对码(5 分钟有效)→ token(30 天,有访问自动续期),持久化到磁盘
7
- * - 安全: 绑定 0.0.0.0(支持远程访问),token 校验,只读
7
+ * - 安全: 绑定 0.0.0.0(支持远程访问),token 校验;远程连接可执行受控管理操作,不能修改 daemon 配置或读取 secret
8
8
  *
9
9
  * 与 evolcore 的唯一通信:启动时发 ping 检查 protocolVersion(soft 校验)。
10
10
  */
@@ -35,14 +35,23 @@ import { roleDefinitionsSource, handleRoleDefinitionsApi } from './sources/role-
35
35
  import { handleModelsApi } from './sources/models.js';
36
36
  import { detectBaseAgents } from './sources/baseagent-detector.js';
37
37
  import { readCodexSessionResult } from './sources/session-codex.js';
38
+ import { handleBasicSettingsApi } from './sources/basic-settings.js';
38
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
40
  const STATIC_DIR = path.join(__dirname, 'static');
40
41
  const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
41
42
  const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
42
43
  const DEFAULT_PORT = 42705;
43
44
  const PROTOCOL_VERSION = 1; // 与 evolcore ping response 对齐的软校验版本
44
- function daemonOwnerMenuAuth() {
45
- return { localDirect: true, actorAid: null };
45
+ function daemonOwnerMenuAuth(localDirect, actorAid) {
46
+ return {
47
+ localDirect,
48
+ actorAid: actorAid ?? null,
49
+ // Every request reaching this helper has already passed the ECWeb
50
+ // authentication gate (local-direct exemption or a valid bearer token).
51
+ // ECWeb is the daemon-owner management surface, so remote tokens receive
52
+ // the same persistent fullaccess approval capability as local requests.
53
+ canApprovePersistentFullAccess: true,
54
+ };
46
55
  }
47
56
  function daemonTriggerControlToken() {
48
57
  try {
@@ -52,6 +61,14 @@ function daemonTriggerControlToken() {
52
61
  return undefined;
53
62
  }
54
63
  }
64
+ function matchesDaemonControlToken(value) {
65
+ const expected = daemonTriggerControlToken();
66
+ if (typeof value !== 'string' || !expected)
67
+ return false;
68
+ const provided = Buffer.from(value);
69
+ const target = Buffer.from(expected);
70
+ return provided.length === target.length && crypto.timingSafeEqual(provided, target);
71
+ }
55
72
  const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource, logs: logsSource, gateway: gatewaySource, roles: relationRolesSource, roleDefinitions: roleDefinitionsSource };
56
73
  // ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
57
74
  function readEcwebVersion() {
@@ -122,20 +139,31 @@ function pruneExpired(store, now) {
122
139
  store.tokens = store.tokens.filter(t => now - t.lastActive < TOKEN_TTL_MS);
123
140
  return store.tokens.length !== before;
124
141
  }
125
- function validateAndRenew(token, now) {
142
+ function validAuthorizedBy(value) {
143
+ if (typeof value !== 'string')
144
+ return undefined;
145
+ const normalized = value.trim();
146
+ if (!normalized || normalized.length > 512 || /[\r\n]/.test(normalized))
147
+ return undefined;
148
+ return normalized;
149
+ }
150
+ function getValidTokenRecord(token, now) {
126
151
  if (!token)
127
- return false;
152
+ return null;
128
153
  const store = loadTokens();
129
154
  const changed = pruneExpired(store, now);
130
155
  const rec = store.tokens.find(t => t.token === token);
131
156
  if (rec) {
132
157
  rec.lastActive = now;
133
158
  saveTokens(store);
134
- return true;
159
+ return rec;
135
160
  }
136
161
  if (changed)
137
162
  saveTokens(store);
138
- return false;
163
+ return null;
164
+ }
165
+ function validateAndRenew(token, now) {
166
+ return getValidTokenRecord(token, now) !== null;
139
167
  }
140
168
  // ── Static ──
141
169
  // 经 AUN Service Proxy 转发时,proxy-server 注入 x-forwarded-prefix 头,
@@ -225,6 +253,15 @@ function parseUrl(rawUrl) {
225
253
  }
226
254
  return { path: rawUrl.slice(0, qIdx), query };
227
255
  }
256
+ function requestPath(rawUrl) {
257
+ try {
258
+ const pathname = new URL(rawUrl || '/', 'http://localhost').pathname;
259
+ return pathname.replace(/\/+$/, '') || '/';
260
+ }
261
+ catch {
262
+ return rawUrl.split('?')[0].replace(/\/+$/, '') || '/';
263
+ }
264
+ }
228
265
  function handleStatsApi(req, res) {
229
266
  const { path: urlPath, query } = parseUrl(req.url || '');
230
267
  res.setHeader('Content-Type', 'application/json');
@@ -436,7 +473,7 @@ function genPairingCode() {
436
473
  return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
437
474
  }
438
475
  // ── Pair handler ──
439
- function handlePair(req, res, pairingCode, pairingExpiry, log) {
476
+ function handlePair(req, res, pairingCode, pairingExpiry, authorizedBy, log) {
440
477
  let body = '';
441
478
  req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
442
479
  req.destroy(); });
@@ -461,10 +498,16 @@ function handlePair(req, res, pairingCode, pairingExpiry, log) {
461
498
  const token = crypto.randomBytes(32).toString('hex');
462
499
  const store = loadTokens();
463
500
  pruneExpired(store, now);
464
- store.tokens.push({ token, createdAt: now, lastActive: now, label: ip });
501
+ store.tokens.push({
502
+ token,
503
+ createdAt: now,
504
+ lastActive: now,
505
+ label: ip,
506
+ ...(authorizedBy ? { authorizedBy } : {}),
507
+ });
465
508
  saveTokens(store);
466
509
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token }));
467
- log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
510
+ log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期${authorizedBy ? `,批准者 ${authorizedBy}` : ''})`);
468
511
  });
469
512
  }
470
513
  /**
@@ -485,8 +528,10 @@ function issueLocalDirectToken(req, log) {
485
528
  return token;
486
529
  }
487
530
  // ── WebSocket connection ──
488
- function handleConnection(ws, req, log) {
531
+ function handleConnection(ws, req, log, tokenRecord) {
489
532
  const ip = clientIp(req);
533
+ const localDirect = isLocalDirect(req);
534
+ const actorAid = tokenRecord?.authorizedBy ?? null;
490
535
  let unsubscribe = null;
491
536
  let currentView = null;
492
537
  let subscriptionGeneration = 0;
@@ -510,7 +555,7 @@ function handleConnection(ws, req, log) {
510
555
  send({ type: 'error', message: `unknown view: ${view}` });
511
556
  return;
512
557
  }
513
- const sourceParams = { ...params, __menuAuth: daemonOwnerMenuAuth() };
558
+ const sourceParams = { ...params, __menuAuth: daemonOwnerMenuAuth(localDirect, actorAid) };
514
559
  // System 视图:把 ECWeb 自身版本合并进快照(前端版本卡用)
515
560
  const decorate = (data) => (view === 'system' && data ? { ...data, ecwebVersion: ECWEB_VERSION } : data);
516
561
  let initialSnapshot;
@@ -589,7 +634,7 @@ function handleConnection(ws, req, log) {
589
634
  const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
590
635
  ? Math.min(Number(msg.timeoutMs), 120_000)
591
636
  : 5000;
592
- const menuAuth = daemonOwnerMenuAuth();
637
+ const menuAuth = daemonOwnerMenuAuth(localDirect, actorAid);
593
638
  const resp = await ipcQuery(p.socket, { type: 'menu.exec', payload: msg.payload, auth: { source: 'ecweb', ...menuAuth } }, timeoutMs);
594
639
  if (resp?.ok) {
595
640
  send({ type: 'menu.response', requestId: msg.requestId, data: resp.response });
@@ -708,7 +753,11 @@ function bindPort(server, preferred, log) {
708
753
  reject(err);
709
754
  }
710
755
  });
711
- server.listen(port, '0.0.0.0', () => resolve({ port, displaced: false }));
756
+ server.listen(port, '0.0.0.0', () => {
757
+ const address = server.address();
758
+ const boundPort = address && typeof address !== 'string' ? address.port : port;
759
+ resolve({ port: boundPort, displaced: false });
760
+ });
712
761
  };
713
762
  tryBind(preferred);
714
763
  });
@@ -724,15 +773,19 @@ export async function startWatchWebServer(opts = {}) {
724
773
  }
725
774
  let pairingCode = genPairingCode();
726
775
  let pairingExpiry = Date.now() + PAIRING_TTL_MS;
776
+ let pairingAuthorizedBy;
727
777
  function freshPairing() {
728
778
  if (Date.now() > pairingExpiry) {
729
779
  pairingCode = genPairingCode();
730
780
  pairingExpiry = Date.now() + PAIRING_TTL_MS;
781
+ pairingAuthorizedBy = undefined;
731
782
  log(`↺ 配对码已刷新:${pairingCode}(5 分钟有效)`);
732
783
  }
733
784
  return { code: pairingCode, expiresAt: pairingExpiry };
734
785
  }
735
786
  const server = http.createServer((req, res) => {
787
+ const pathname = requestPath(req.url || '');
788
+ const localDirect = isLocalDirect(req);
736
789
  if (req.method === 'GET' && (req.url || '') === '/api/available-baseagents') {
737
790
  // Base agent 可用性检测 API
738
791
  const available = detectBaseAgents();
@@ -749,6 +802,15 @@ export async function startWatchWebServer(opts = {}) {
749
802
  return;
750
803
  }
751
804
  const { code, expiresAt } = freshPairing();
805
+ // daemon 通过 localhost 取码时带上 AUN 已认证的 owner AID。只接受
806
+ // 第一次绑定,避免同一配对码在不同 owner 间被重新归属。
807
+ const ownerHeader = req.headers['x-evolcore-pair-authorized-by'];
808
+ const controlHeader = req.headers['x-evolcore-control-token'];
809
+ const requestedOwner = validAuthorizedBy(Array.isArray(ownerHeader) ? ownerHeader[0] : ownerHeader);
810
+ const suppliedControlToken = Array.isArray(controlHeader) ? controlHeader[0] : controlHeader;
811
+ if (!pairingAuthorizedBy && requestedOwner && matchesDaemonControlToken(suppliedControlToken)) {
812
+ pairingAuthorizedBy = requestedOwner;
813
+ }
752
814
  res.writeHead(200, { 'Content-Type': 'application/json' });
753
815
  res.end(JSON.stringify({ code, expiresAt }));
754
816
  }
@@ -760,7 +822,34 @@ export async function startWatchWebServer(opts = {}) {
760
822
  res.end(JSON.stringify({ ok: true, token: autoToken }));
761
823
  return;
762
824
  }
763
- handlePair(req, res, pairingCode, pairingExpiry, log);
825
+ handlePair(req, res, pairingCode, pairingExpiry, pairingAuthorizedBy, log);
826
+ }
827
+ else if (pathname === '/api/basic-settings' || pathname === '/api/basic-settings/secret') {
828
+ const authHeader = req.headers.authorization || '';
829
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
830
+ const { query } = parseUrl(req.url || '');
831
+ let token = bearerToken || query.token || '';
832
+ if (!token) {
833
+ const autoToken = issueLocalDirectToken(req, log);
834
+ if (autoToken)
835
+ token = autoToken;
836
+ }
837
+ if (!token || !validateAndRenew(token, Date.now())) {
838
+ res.writeHead(401, { 'Content-Type': 'application/json' });
839
+ res.end(JSON.stringify({ success: false, error: 'unauthorized' }));
840
+ return;
841
+ }
842
+ if (!localDirect && pathname === '/api/basic-settings/secret') {
843
+ res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
844
+ res.end(JSON.stringify({ success: false, error: 'remote ECWeb connections cannot reveal API keys' }));
845
+ return;
846
+ }
847
+ if (!localDirect && req.method !== 'GET') {
848
+ res.writeHead(403, { 'Content-Type': 'application/json' });
849
+ res.end(JSON.stringify({ success: false, error: 'remote ECWeb connections cannot modify daemon settings' }));
850
+ return;
851
+ }
852
+ void handleBasicSettingsApi(req, res, { redactConnectionMaps: !localDirect });
764
853
  }
765
854
  else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
766
855
  // Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
@@ -808,12 +897,13 @@ export async function startWatchWebServer(opts = {}) {
808
897
  if (autoToken)
809
898
  token = autoToken;
810
899
  }
811
- if (!token || !validateAndRenew(token, Date.now())) {
900
+ const tokenRecord = token ? getValidTokenRecord(token, Date.now()) : null;
901
+ if (!token || !tokenRecord) {
812
902
  res.writeHead(401, { 'Content-Type': 'application/json' });
813
903
  res.end(JSON.stringify({ error: 'unauthorized' }));
814
904
  return;
815
905
  }
816
- handleRelationRolesApi(req, res, daemonOwnerMenuAuth());
906
+ handleRelationRolesApi(req, res, daemonOwnerMenuAuth(localDirect, tokenRecord.authorizedBy));
817
907
  }
818
908
  else if ((req.url || '') === '/api/role-definitions' || (req.url || '').startsWith('/api/role-definitions/') || (req.url || '').startsWith('/api/role-definitions?')) {
819
909
  // Role Definitions API(含集合端点 /api/role-definitions 与 /api/role-definitions/:role)
@@ -826,12 +916,13 @@ export async function startWatchWebServer(opts = {}) {
826
916
  if (autoToken)
827
917
  token = autoToken;
828
918
  }
829
- if (!token || !validateAndRenew(token, Date.now())) {
919
+ const tokenRecord = token ? getValidTokenRecord(token, Date.now()) : null;
920
+ if (!token || !tokenRecord) {
830
921
  res.writeHead(401, { 'Content-Type': 'application/json' });
831
922
  res.end(JSON.stringify({ error: 'unauthorized' }));
832
923
  return;
833
924
  }
834
- handleRoleDefinitionsApi(req, res, daemonOwnerMenuAuth());
925
+ handleRoleDefinitionsApi(req, res, daemonOwnerMenuAuth(localDirect, tokenRecord.authorizedBy));
835
926
  }
836
927
  else if ((req.url || '').startsWith('/api/assignments/peer/')) {
837
928
  const authHeader = req.headers.authorization || '';
@@ -843,12 +934,13 @@ export async function startWatchWebServer(opts = {}) {
843
934
  if (autoToken)
844
935
  token = autoToken;
845
936
  }
846
- if (!token || !validateAndRenew(token, Date.now())) {
937
+ const tokenRecord = token ? getValidTokenRecord(token, Date.now()) : null;
938
+ if (!token || !tokenRecord) {
847
939
  res.writeHead(401, { 'Content-Type': 'application/json' });
848
940
  res.end(JSON.stringify({ error: 'unauthorized' }));
849
941
  return;
850
942
  }
851
- handlePeerRoleApi(req, res, daemonOwnerMenuAuth());
943
+ handlePeerRoleApi(req, res, daemonOwnerMenuAuth(localDirect, tokenRecord.authorizedBy));
852
944
  }
853
945
  else {
854
946
  serveStatic(req, res);
@@ -858,14 +950,16 @@ export async function startWatchWebServer(opts = {}) {
858
950
  server.on('upgrade', (req, socket, head) => {
859
951
  const { query } = parseUrl(req.url || '');
860
952
  // 本地直连免 token;远程需 token。
861
- const authed = isLocalDirect(req) || validateAndRenew(query.token || '', Date.now());
953
+ const localDirect = isLocalDirect(req);
954
+ const tokenRecord = localDirect ? null : getValidTokenRecord(query.token || '', Date.now());
955
+ const authed = localDirect || tokenRecord !== null;
862
956
  wss.handleUpgrade(req, socket, head, (ws) => {
863
957
  if (!authed) {
864
958
  log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
865
959
  ws.close(4001, 'invalid-token');
866
960
  return;
867
961
  }
868
- handleConnection(ws, req, log);
962
+ handleConnection(ws, req, log, tokenRecord);
869
963
  });
870
964
  });
871
965
  const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
@@ -2,14 +2,15 @@
2
2
  * AID 数据源 — 复用 daemon 的 IPC socket(与 evolcore `watch aid` 同源)。
3
3
  *
4
4
  * daemon 运行时:拉 aun-aids / aun-aid-stats / agent-stats / status / evolagent.list。
5
- * daemon 未运行时:降级到读 instance/ 目录的 aid-*.jsonl(精简版 scanInstances)。
5
+ * daemon IPC 连续不可达时:降级到读 instance/ 目录的 aid-*.jsonl(精简版 scanInstances)。
6
6
  * IPC 无推送能力,故 1s 轮询 + JSON diff,仅在变化时 push。
7
7
  */
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { resolvePaths } from '../paths.js';
11
11
  import { ipcQuery } from '../ipc-client.js';
12
- /** 精简版实例扫描:仅在 daemon 离线时降级用,读各 alive main 进程的 aid 活动日志 */
12
+ import { watchMenuRequestAuth } from './types.js';
13
+ /** 精简版实例扫描:仅在 daemon IPC 不可达时降级用,读各 alive main 进程的 aid 活动日志 */
13
14
  function scanAidActivity() {
14
15
  const result = new Map();
15
16
  const dir = resolvePaths().instanceDir;
@@ -66,22 +67,28 @@ function scanAidActivity() {
66
67
  }
67
68
  // evolcore 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
68
69
  // 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
69
- let _cachedVersion = null;
70
- async function fetchVersion(socket) {
71
- if (_cachedVersion)
72
- return _cachedVersion;
70
+ const versionCache = new Map();
71
+ function authContextKey(params = {}) {
72
+ const auth = watchMenuRequestAuth(params);
73
+ return `${auth.localDirect ? 'local' : 'remote'}:${auth.actorAid || ''}`;
74
+ }
75
+ async function fetchVersion(socket, params = {}) {
76
+ const key = authContextKey(params);
77
+ const cached = versionCache.get(key);
78
+ if (cached)
79
+ return cached;
73
80
  try {
74
- const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' }, auth: { source: 'ecweb', localDirect: true } }, 4000);
81
+ const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' }, auth: watchMenuRequestAuth(params) }, 4000);
75
82
  const v = r?.ok ? r.response?.data?.version : null;
76
83
  if (v)
77
- _cachedVersion = v;
84
+ versionCache.set(key, v);
78
85
  return v ?? null;
79
86
  }
80
87
  catch {
81
88
  return null;
82
89
  }
83
90
  }
84
- async function buildSnapshot() {
91
+ async function buildSnapshot(fallback = null, params = {}) {
85
92
  const p = resolvePaths();
86
93
  const [aidsResp, statsResp, agentStatsResp, statusResp, agentsResp, version] = await Promise.all([
87
94
  ipcQuery(p.socket, { type: 'aun-aids' }),
@@ -89,9 +96,12 @@ async function buildSnapshot() {
89
96
  ipcQuery(p.socket, { type: 'agent-stats' }),
90
97
  ipcQuery(p.socket, { type: 'status' }),
91
98
  ipcQuery(p.socket, { type: 'evolagent.list' }),
92
- fetchVersion(p.socket),
99
+ fetchVersion(p.socket, params),
93
100
  ]);
94
- const daemonRunning = aidsResp !== null || statusResp !== null;
101
+ // 任一只读命令能返回就说明 IPC/daemon 仍可达。慢主机上某个较重的
102
+ // 统计命令单独超时,不应把整个 Agent 页面判成离线。
103
+ const daemonRunning = [aidsResp, statsResp, agentStatsResp, statusResp, agentsResp]
104
+ .some((response) => response !== null);
95
105
  if (!daemonRunning) {
96
106
  const activity = scanAidActivity();
97
107
  const aids = Array.from(activity.entries()).map(([aid, info]) => ({
@@ -102,38 +112,118 @@ async function buildSnapshot() {
102
112
  }));
103
113
  return { daemonRunning: false, aids, stats: [], agents: [] };
104
114
  }
115
+ const previous = fallback?.daemonRunning ? fallback : null;
105
116
  return {
106
117
  daemonRunning: true,
107
- aids: aidsResp?.aids ?? [],
108
- stats: statsResp?.stats ?? [],
109
- agentStats: agentStatsResp?.stats ?? [],
110
- status: statusResp ?? null,
111
- agents: agentsResp?.agents ?? [],
112
- version: version ?? null,
118
+ aids: Array.isArray(aidsResp?.aids) ? aidsResp.aids : (previous?.aids ?? []),
119
+ stats: Array.isArray(statsResp?.stats) ? statsResp.stats : (previous?.stats ?? []),
120
+ agentStats: Array.isArray(agentStatsResp?.stats) ? agentStatsResp.stats : (previous?.agentStats ?? []),
121
+ status: statusResp !== null ? statusResp : (previous?.status ?? null),
122
+ agents: Array.isArray(agentsResp?.agents) ? agentsResp.agents : (previous?.agents ?? []),
123
+ version: version ?? previous?.version ?? null,
113
124
  };
114
125
  }
126
+ const AID_POLL_INTERVAL_MS = 1000;
127
+ const DAEMON_FAILURE_THRESHOLD = 3;
128
+ // 同一认证上下文的浏览器标签页共享轮询;本地直连与远程 token 必须隔离,
129
+ // 避免本地 owner 查询的快照或 in-flight promise 被远程连接复用。
130
+ const pollStates = new Map();
131
+ function pollState(params = {}) {
132
+ const key = authContextKey(params);
133
+ let state = pollStates.get(key);
134
+ if (!state) {
135
+ state = {
136
+ params,
137
+ subscribers: new Set(),
138
+ pollTimer: null,
139
+ pollInFlight: null,
140
+ lastSuccessfulSnapshot: null,
141
+ lastResolvedSnapshot: null,
142
+ consecutiveDaemonFailures: 0,
143
+ };
144
+ pollStates.set(key, state);
145
+ }
146
+ return state;
147
+ }
148
+ async function readStableSnapshot(state) {
149
+ if (!state.pollInFlight) {
150
+ state.pollInFlight = (async () => {
151
+ let snapshot;
152
+ try {
153
+ snapshot = await buildSnapshot(state.lastSuccessfulSnapshot, state.params);
154
+ }
155
+ catch {
156
+ snapshot = { daemonRunning: false, aids: [], stats: [], agentStats: [], agents: [] };
157
+ }
158
+ if (snapshot?.daemonRunning) {
159
+ state.consecutiveDaemonFailures = 0;
160
+ state.lastSuccessfulSnapshot = snapshot;
161
+ state.lastResolvedSnapshot = snapshot;
162
+ return snapshot;
163
+ }
164
+ state.consecutiveDaemonFailures += 1;
165
+ // 单轮 IPC 超时不代表 daemon 已停止。保留最近一次成功数据,直到
166
+ // 连续多轮都失败,避免页面在慢主机上反复清空和恢复。
167
+ if (state.lastSuccessfulSnapshot && state.consecutiveDaemonFailures < DAEMON_FAILURE_THRESHOLD) {
168
+ state.lastResolvedSnapshot = state.lastSuccessfulSnapshot;
169
+ return state.lastSuccessfulSnapshot;
170
+ }
171
+ state.lastResolvedSnapshot = snapshot;
172
+ return snapshot;
173
+ })().finally(() => {
174
+ state.pollInFlight = null;
175
+ });
176
+ }
177
+ return state.pollInFlight;
178
+ }
179
+ function notifySubscribers(state, snapshot) {
180
+ const json = JSON.stringify(snapshot);
181
+ for (const subscriber of state.subscribers) {
182
+ if (json === subscriber.lastJson)
183
+ continue;
184
+ subscriber.lastJson = json;
185
+ try {
186
+ subscriber.push(snapshot);
187
+ }
188
+ catch { /* subscriber disconnected */ }
189
+ }
190
+ }
191
+ function schedulePoll(state, delayMs = AID_POLL_INTERVAL_MS) {
192
+ if (state.pollTimer !== null || state.subscribers.size === 0)
193
+ return;
194
+ state.pollTimer = setTimeout(async () => {
195
+ state.pollTimer = null;
196
+ if (state.subscribers.size === 0)
197
+ return;
198
+ try {
199
+ notifySubscribers(state, await readStableSnapshot(state));
200
+ }
201
+ catch { /* ignore transient IPC errors */ }
202
+ // 下一轮从本轮完成后开始计时,绝不与上一轮重叠。
203
+ schedulePoll(state);
204
+ }, delayMs);
205
+ }
115
206
  export const aidSource = {
116
207
  kind: 'agents',
117
- async snapshot() {
118
- return buildSnapshot();
208
+ async snapshot(params = {}) {
209
+ return readStableSnapshot(pollState(params));
119
210
  },
120
- subscribe(_params, push) {
121
- let lastJson = '';
122
- let stopped = false;
123
- const tick = async () => {
124
- if (stopped)
125
- return;
126
- try {
127
- const snap = await buildSnapshot();
128
- const json = JSON.stringify(snap);
129
- if (json !== lastJson) {
130
- lastJson = json;
131
- push(snap);
132
- }
211
+ subscribe(params, push) {
212
+ const state = pollState(params);
213
+ // server 会在 subscribe 前先发送 snapshot;以最近成功值作为基线,避免
214
+ // 第一轮轮询把同一份数据再推送一次。
215
+ const subscriber = {
216
+ push,
217
+ lastJson: state.lastResolvedSnapshot ? JSON.stringify(state.lastResolvedSnapshot) : '',
218
+ };
219
+ state.subscribers.add(subscriber);
220
+ schedulePoll(state);
221
+ return () => {
222
+ state.subscribers.delete(subscriber);
223
+ if (state.subscribers.size === 0 && state.pollTimer !== null) {
224
+ clearTimeout(state.pollTimer);
225
+ state.pollTimer = null;
133
226
  }
134
- catch { /* ignore transient IPC errors */ }
135
227
  };
136
- const timer = setInterval(tick, 1000);
137
- return () => { stopped = true; clearInterval(timer); };
138
228
  },
139
229
  };
@@ -1,14 +1,16 @@
1
1
  /**
2
- * Base agent 环境检测 — 检测 Claude 和 Codex 的可用性。
2
+ * Base agent 环境检测 — 检测本机可用的 Claude、Codex、Gemini 和内置 EC Agent。
3
3
  *
4
4
  * 检测条件:
5
- * - Claude: ~/.claude/projects/ 目录存在
6
- * - Codex: ~/.codex/sessions/ 和 ~/.codex/state_*.sqlite 存在,且 Node 版本 >= 22.5
5
+ * 命令行工具优先按 PATH 检测;同时保留会话目录检测,兼容 CLI 不在当前 shell
6
+ * PATH 但已经在本机初始化过的情况。ecagent 随 EvolCore 一起提供,始终作为可选
7
+ * 基座展示,实际凭据和网关配置由配置校验负责。
7
8
  */
8
9
  import fs from 'fs';
9
10
  import path from 'path';
10
11
  import os from 'os';
11
12
  import { createRequire } from 'module';
13
+ import { spawnSync } from 'child_process';
12
14
  const requireFromHere = createRequire(import.meta.url);
13
15
  let _cached = null;
14
16
  /**
@@ -45,6 +47,40 @@ function checkClaudeProjects() {
45
47
  const claudeProjects = path.join(os.homedir(), '.claude', 'projects');
46
48
  return fs.existsSync(claudeProjects);
47
49
  }
50
+ function commandExists(command) {
51
+ try {
52
+ const result = process.platform === 'win32'
53
+ ? spawnSync('where', [command], { stdio: 'ignore', windowsHide: true })
54
+ : spawnSync('sh', ['-lc', `command -v ${JSON.stringify(command)}`], { stdio: 'ignore' });
55
+ return result.status === 0;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ function checkGeminiHome() {
62
+ return fs.existsSync(path.join(os.homedir(), '.gemini'));
63
+ }
64
+ function detectSources(id) {
65
+ if (id === 'ecagent')
66
+ return ['内置'];
67
+ const sources = [];
68
+ if (commandExists(id))
69
+ sources.push('PATH');
70
+ if (id === 'claude' && checkClaudeProjects())
71
+ sources.push('~/.claude/projects');
72
+ if (id === 'codex') {
73
+ if (checkCodexSessions())
74
+ sources.push('~/.codex/sessions');
75
+ if (checkCodexStateDb())
76
+ sources.push('~/.codex/state_*.sqlite');
77
+ if (checkSqliteAvailable())
78
+ sources.push('node:sqlite');
79
+ }
80
+ if (id === 'gemini' && checkGeminiHome())
81
+ sources.push('~/.gemini');
82
+ return sources;
83
+ }
48
84
  /**
49
85
  * 检测 Codex sessions 目录是否存在
50
86
  */
@@ -59,11 +95,29 @@ function checkCodexSessions() {
59
95
  export function detectBaseAgents() {
60
96
  if (_cached)
61
97
  return _cached;
62
- const claude = checkClaudeProjects();
63
- const codex = checkCodexSessions() && checkCodexStateDb() && checkSqliteAvailable();
64
- _cached = { claude, codex };
98
+ const claude = detectSources('claude').length > 0;
99
+ const codex = detectSources('codex').length > 0;
100
+ const gemini = detectSources('gemini').length > 0;
101
+ const ecagent = true;
102
+ _cached = { claude, codex, gemini, ecagent };
65
103
  return _cached;
66
104
  }
105
+ /** Return display-oriented detection details for the Basic Settings API. */
106
+ export function detectBaseAgentDetails(defaults = {}) {
107
+ const configured = isRecord(defaults?.baseagents) ? defaults.baseagents : {};
108
+ return ['claude', 'codex', 'gemini', 'ecagent'].map(id => {
109
+ const detectionSource = detectSources(id);
110
+ return {
111
+ id,
112
+ detected: id === 'ecagent' || detectionSource.length > 0,
113
+ configured: Object.prototype.hasOwnProperty.call(configured, id),
114
+ detectionSource,
115
+ };
116
+ });
117
+ }
118
+ function isRecord(value) {
119
+ return !!value && typeof value === 'object' && !Array.isArray(value);
120
+ }
67
121
  /**
68
122
  * 重置缓存(测试用)
69
123
  */