ec-web 0.0.20 → 0.0.22
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/ipc-client.js +7 -1
- package/dist/paths.js +18 -1
- package/dist/server.js +159 -28
- package/dist/sources/aid.js +69 -49
- package/dist/sources/baseagent-detector.js +60 -6
- package/dist/sources/basic-settings.js +818 -0
- package/dist/sources/gateway.js +5 -4
- package/dist/sources/monitor.js +22 -5
- package/dist/sources/msg.js +4 -96
- package/dist/sources/relation-roles.js +216 -207
- package/dist/sources/role-definitions.js +162 -331
- package/dist/sources/session-codex.js +207 -84
- package/dist/sources/system.js +10 -9
- package/dist/sources/triggers.js +82 -13
- package/dist/sources/types.js +20 -1
- package/dist/static/app.js +175 -15
- package/dist/static/basic-settings.js +1576 -0
- package/dist/static/index.html +43 -1
- package/dist/static/style.css +586 -2
- package/ecweb-wrapper.cjs +5 -1
- package/package.json +1 -1
package/dist/ipc-client.js
CHANGED
|
@@ -12,7 +12,13 @@ import path from 'path';
|
|
|
12
12
|
// Keep this list aligned with evolcore's local-control IPC gate. `evolagent.list`
|
|
13
13
|
// is used by the Agents view and is read-only from EC Web's perspective, but the
|
|
14
14
|
// daemon still requires the local control token before returning its registry.
|
|
15
|
-
const LOCAL_CONTROL_COMMANDS = new Set([
|
|
15
|
+
const LOCAL_CONTROL_COMMANDS = new Set([
|
|
16
|
+
'evolagent.list',
|
|
17
|
+
'menu.exec',
|
|
18
|
+
'monitor-snapshot',
|
|
19
|
+
'monitor-subscribe',
|
|
20
|
+
'monitor-unsubscribe',
|
|
21
|
+
]);
|
|
16
22
|
export function ipcQuery(socketPath, cmd, timeoutMs = 3000) {
|
|
17
23
|
let request = cmd;
|
|
18
24
|
if (LOCAL_CONTROL_COMMANDS.has(cmd.type) && typeof cmd.controlToken !== 'string') {
|
package/dist/paths.js
CHANGED
|
@@ -25,10 +25,27 @@ export function resolveRoot() {
|
|
|
25
25
|
}
|
|
26
26
|
return _root;
|
|
27
27
|
}
|
|
28
|
+
function stripTrailingWindowsSeparators(value) {
|
|
29
|
+
const stripped = value.replace(/[\\/]+$/g, '');
|
|
30
|
+
// Keep a drive-root separator. `C:` is a drive-relative path on Windows.
|
|
31
|
+
if (/^[a-zA-Z]:$/.test(stripped) && /^[a-zA-Z]:[\\/]+$/.test(value)) {
|
|
32
|
+
return `${stripped}\\`;
|
|
33
|
+
}
|
|
34
|
+
return stripped;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Canonicalize EVOLCORE_HOME exactly like the main EvolCore package does.
|
|
38
|
+
* The CLI and EC Web may receive the same Windows directory with different
|
|
39
|
+
* casing, slash styles, or a trailing separator; all must select one pipe.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeWindowsSocketRoot(root) {
|
|
42
|
+
return stripTrailingWindowsSeparators(path.win32.resolve(root)).toLowerCase();
|
|
43
|
+
}
|
|
28
44
|
/** socket 路径派生:与 evolcore resolveInstanceSocketPath 完全一致 */
|
|
29
45
|
function resolveInstanceSocketPath(root) {
|
|
30
46
|
if (isWindows) {
|
|
31
|
-
const
|
|
47
|
+
const canonicalRoot = normalizeWindowsSocketRoot(root);
|
|
48
|
+
const hash = crypto.createHash('sha1').update(canonicalRoot).digest('hex').slice(0, 12);
|
|
32
49
|
return `\\\\.\\pipe\\daemon-${hash}`;
|
|
33
50
|
}
|
|
34
51
|
return path.join(root, 'data', 'instance', 'daemon.sock');
|
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 {
|
|
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
|
|
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
|
|
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
|
|
159
|
+
return rec;
|
|
135
160
|
}
|
|
136
161
|
if (changed)
|
|
137
162
|
saveTokens(store);
|
|
138
|
-
return
|
|
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({
|
|
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,30 @@ function handleConnection(ws, req, log) {
|
|
|
510
555
|
send({ type: 'error', message: `unknown view: ${view}` });
|
|
511
556
|
return;
|
|
512
557
|
}
|
|
513
|
-
const sourceParams = {
|
|
558
|
+
const sourceParams = {
|
|
559
|
+
...params,
|
|
560
|
+
__menuAuth: daemonOwnerMenuAuth(localDirect, actorAid),
|
|
561
|
+
__ecwebRequestId: crypto.randomUUID(),
|
|
562
|
+
__ecwebEndpoint: `WS snapshot:${view}`,
|
|
563
|
+
};
|
|
564
|
+
let deactivate = null;
|
|
565
|
+
if (source.activate) {
|
|
566
|
+
try {
|
|
567
|
+
deactivate = await source.activate(sourceParams);
|
|
568
|
+
}
|
|
569
|
+
catch (e) {
|
|
570
|
+
if (generation === subscriptionGeneration)
|
|
571
|
+
send({ type: 'error', message: `subscription failed: ${e?.message || e}` });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (generation !== subscriptionGeneration) {
|
|
575
|
+
deactivate();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Install cleanup before awaiting the initial snapshot, so a view switch
|
|
579
|
+
// or socket close releases the monitor lease immediately.
|
|
580
|
+
unsubscribe = deactivate;
|
|
581
|
+
}
|
|
514
582
|
// System 视图:把 ECWeb 自身版本合并进快照(前端版本卡用)
|
|
515
583
|
const decorate = (data) => (view === 'system' && data ? { ...data, ecwebVersion: ECWEB_VERSION } : data);
|
|
516
584
|
let initialSnapshot;
|
|
@@ -525,16 +593,30 @@ function handleConnection(ws, req, log) {
|
|
|
525
593
|
return;
|
|
526
594
|
send({ type: 'error', message: `snapshot failed: ${e?.message || e}` });
|
|
527
595
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
596
|
+
let nextUnsubscribe;
|
|
597
|
+
try {
|
|
598
|
+
nextUnsubscribe = source.subscribe(sourceParams, (data) => {
|
|
599
|
+
if (generation === subscriptionGeneration && currentView === view) {
|
|
600
|
+
send({ type: 'delta', view, data: decorate(data) });
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
deactivate?.();
|
|
606
|
+
if (generation === subscriptionGeneration) {
|
|
607
|
+
unsubscribe = null;
|
|
608
|
+
send({ type: 'error', message: `subscription failed: ${e?.message || e}` });
|
|
531
609
|
}
|
|
532
|
-
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
533
612
|
if (generation !== subscriptionGeneration) {
|
|
534
613
|
nextUnsubscribe();
|
|
535
614
|
return;
|
|
536
615
|
}
|
|
537
|
-
unsubscribe =
|
|
616
|
+
unsubscribe = () => {
|
|
617
|
+
nextUnsubscribe();
|
|
618
|
+
deactivate?.();
|
|
619
|
+
};
|
|
538
620
|
};
|
|
539
621
|
ws.on('message', async (raw) => {
|
|
540
622
|
let msg;
|
|
@@ -589,7 +671,7 @@ function handleConnection(ws, req, log) {
|
|
|
589
671
|
const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
|
|
590
672
|
? Math.min(Number(msg.timeoutMs), 120_000)
|
|
591
673
|
: 5000;
|
|
592
|
-
const menuAuth = daemonOwnerMenuAuth();
|
|
674
|
+
const menuAuth = daemonOwnerMenuAuth(localDirect, actorAid);
|
|
593
675
|
const resp = await ipcQuery(p.socket, { type: 'menu.exec', payload: msg.payload, auth: { source: 'ecweb', ...menuAuth } }, timeoutMs);
|
|
594
676
|
if (resp?.ok) {
|
|
595
677
|
send({ type: 'menu.response', requestId: msg.requestId, data: resp.response });
|
|
@@ -708,7 +790,11 @@ function bindPort(server, preferred, log) {
|
|
|
708
790
|
reject(err);
|
|
709
791
|
}
|
|
710
792
|
});
|
|
711
|
-
server.listen(port, '0.0.0.0', () =>
|
|
793
|
+
server.listen(port, '0.0.0.0', () => {
|
|
794
|
+
const address = server.address();
|
|
795
|
+
const boundPort = address && typeof address !== 'string' ? address.port : port;
|
|
796
|
+
resolve({ port: boundPort, displaced: false });
|
|
797
|
+
});
|
|
712
798
|
};
|
|
713
799
|
tryBind(preferred);
|
|
714
800
|
});
|
|
@@ -724,15 +810,19 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
724
810
|
}
|
|
725
811
|
let pairingCode = genPairingCode();
|
|
726
812
|
let pairingExpiry = Date.now() + PAIRING_TTL_MS;
|
|
813
|
+
let pairingAuthorizedBy;
|
|
727
814
|
function freshPairing() {
|
|
728
815
|
if (Date.now() > pairingExpiry) {
|
|
729
816
|
pairingCode = genPairingCode();
|
|
730
817
|
pairingExpiry = Date.now() + PAIRING_TTL_MS;
|
|
818
|
+
pairingAuthorizedBy = undefined;
|
|
731
819
|
log(`↺ 配对码已刷新:${pairingCode}(5 分钟有效)`);
|
|
732
820
|
}
|
|
733
821
|
return { code: pairingCode, expiresAt: pairingExpiry };
|
|
734
822
|
}
|
|
735
823
|
const server = http.createServer((req, res) => {
|
|
824
|
+
const pathname = requestPath(req.url || '');
|
|
825
|
+
const localDirect = isLocalDirect(req);
|
|
736
826
|
if (req.method === 'GET' && (req.url || '') === '/api/available-baseagents') {
|
|
737
827
|
// Base agent 可用性检测 API
|
|
738
828
|
const available = detectBaseAgents();
|
|
@@ -749,6 +839,15 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
749
839
|
return;
|
|
750
840
|
}
|
|
751
841
|
const { code, expiresAt } = freshPairing();
|
|
842
|
+
// daemon 通过 localhost 取码时带上 AUN 已认证的 owner AID。只接受
|
|
843
|
+
// 第一次绑定,避免同一配对码在不同 owner 间被重新归属。
|
|
844
|
+
const ownerHeader = req.headers['x-evolcore-pair-authorized-by'];
|
|
845
|
+
const controlHeader = req.headers['x-evolcore-control-token'];
|
|
846
|
+
const requestedOwner = validAuthorizedBy(Array.isArray(ownerHeader) ? ownerHeader[0] : ownerHeader);
|
|
847
|
+
const suppliedControlToken = Array.isArray(controlHeader) ? controlHeader[0] : controlHeader;
|
|
848
|
+
if (!pairingAuthorizedBy && requestedOwner && matchesDaemonControlToken(suppliedControlToken)) {
|
|
849
|
+
pairingAuthorizedBy = requestedOwner;
|
|
850
|
+
}
|
|
752
851
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
753
852
|
res.end(JSON.stringify({ code, expiresAt }));
|
|
754
853
|
}
|
|
@@ -760,7 +859,34 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
760
859
|
res.end(JSON.stringify({ ok: true, token: autoToken }));
|
|
761
860
|
return;
|
|
762
861
|
}
|
|
763
|
-
handlePair(req, res, pairingCode, pairingExpiry, log);
|
|
862
|
+
handlePair(req, res, pairingCode, pairingExpiry, pairingAuthorizedBy, log);
|
|
863
|
+
}
|
|
864
|
+
else if (pathname === '/api/basic-settings' || pathname === '/api/basic-settings/secret') {
|
|
865
|
+
const authHeader = req.headers.authorization || '';
|
|
866
|
+
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
867
|
+
const { query } = parseUrl(req.url || '');
|
|
868
|
+
let token = bearerToken || query.token || '';
|
|
869
|
+
if (!token) {
|
|
870
|
+
const autoToken = issueLocalDirectToken(req, log);
|
|
871
|
+
if (autoToken)
|
|
872
|
+
token = autoToken;
|
|
873
|
+
}
|
|
874
|
+
if (!token || !validateAndRenew(token, Date.now())) {
|
|
875
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
876
|
+
res.end(JSON.stringify({ success: false, error: 'unauthorized' }));
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (!localDirect && pathname === '/api/basic-settings/secret') {
|
|
880
|
+
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
881
|
+
res.end(JSON.stringify({ success: false, error: 'remote ECWeb connections cannot reveal API keys' }));
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!localDirect && req.method !== 'GET') {
|
|
885
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
886
|
+
res.end(JSON.stringify({ success: false, error: 'remote ECWeb connections cannot modify daemon settings' }));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
void handleBasicSettingsApi(req, res, { redactConnectionMaps: !localDirect });
|
|
764
890
|
}
|
|
765
891
|
else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
|
|
766
892
|
// Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
|
|
@@ -808,12 +934,13 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
808
934
|
if (autoToken)
|
|
809
935
|
token = autoToken;
|
|
810
936
|
}
|
|
811
|
-
|
|
937
|
+
const tokenRecord = token ? getValidTokenRecord(token, Date.now()) : null;
|
|
938
|
+
if (!token || !tokenRecord) {
|
|
812
939
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
813
940
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
814
941
|
return;
|
|
815
942
|
}
|
|
816
|
-
handleRelationRolesApi(req, res, daemonOwnerMenuAuth());
|
|
943
|
+
handleRelationRolesApi(req, res, daemonOwnerMenuAuth(localDirect, tokenRecord.authorizedBy));
|
|
817
944
|
}
|
|
818
945
|
else if ((req.url || '') === '/api/role-definitions' || (req.url || '').startsWith('/api/role-definitions/') || (req.url || '').startsWith('/api/role-definitions?')) {
|
|
819
946
|
// Role Definitions API(含集合端点 /api/role-definitions 与 /api/role-definitions/:role)
|
|
@@ -826,12 +953,13 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
826
953
|
if (autoToken)
|
|
827
954
|
token = autoToken;
|
|
828
955
|
}
|
|
829
|
-
|
|
956
|
+
const tokenRecord = token ? getValidTokenRecord(token, Date.now()) : null;
|
|
957
|
+
if (!token || !tokenRecord) {
|
|
830
958
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
831
959
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
832
960
|
return;
|
|
833
961
|
}
|
|
834
|
-
handleRoleDefinitionsApi(req, res, daemonOwnerMenuAuth());
|
|
962
|
+
handleRoleDefinitionsApi(req, res, daemonOwnerMenuAuth(localDirect, tokenRecord.authorizedBy));
|
|
835
963
|
}
|
|
836
964
|
else if ((req.url || '').startsWith('/api/assignments/peer/')) {
|
|
837
965
|
const authHeader = req.headers.authorization || '';
|
|
@@ -843,12 +971,13 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
843
971
|
if (autoToken)
|
|
844
972
|
token = autoToken;
|
|
845
973
|
}
|
|
846
|
-
|
|
974
|
+
const tokenRecord = token ? getValidTokenRecord(token, Date.now()) : null;
|
|
975
|
+
if (!token || !tokenRecord) {
|
|
847
976
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
848
977
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
849
978
|
return;
|
|
850
979
|
}
|
|
851
|
-
handlePeerRoleApi(req, res, daemonOwnerMenuAuth());
|
|
980
|
+
handlePeerRoleApi(req, res, daemonOwnerMenuAuth(localDirect, tokenRecord.authorizedBy));
|
|
852
981
|
}
|
|
853
982
|
else {
|
|
854
983
|
serveStatic(req, res);
|
|
@@ -858,14 +987,16 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
858
987
|
server.on('upgrade', (req, socket, head) => {
|
|
859
988
|
const { query } = parseUrl(req.url || '');
|
|
860
989
|
// 本地直连免 token;远程需 token。
|
|
861
|
-
const
|
|
990
|
+
const localDirect = isLocalDirect(req);
|
|
991
|
+
const tokenRecord = localDirect ? null : getValidTokenRecord(query.token || '', Date.now());
|
|
992
|
+
const authed = localDirect || tokenRecord !== null;
|
|
862
993
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
863
994
|
if (!authed) {
|
|
864
995
|
log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
|
|
865
996
|
ws.close(4001, 'invalid-token');
|
|
866
997
|
return;
|
|
867
998
|
}
|
|
868
|
-
handleConnection(ws, req, log);
|
|
999
|
+
handleConnection(ws, req, log, tokenRecord);
|
|
869
1000
|
});
|
|
870
1001
|
});
|
|
871
1002
|
const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
|
package/dist/sources/aid.js
CHANGED
|
@@ -9,6 +9,7 @@ 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
|
+
import { watchMenuRequestAuth } from './types.js';
|
|
12
13
|
/** 精简版实例扫描:仅在 daemon IPC 不可达时降级用,读各 alive main 进程的 aid 活动日志 */
|
|
13
14
|
function scanAidActivity() {
|
|
14
15
|
const result = new Map();
|
|
@@ -66,22 +67,28 @@ function scanAidActivity() {
|
|
|
66
67
|
}
|
|
67
68
|
// evolcore 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
|
|
68
69
|
// 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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:
|
|
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
|
-
|
|
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(fallback = null) {
|
|
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,7 +96,7 @@ async function buildSnapshot(fallback = null) {
|
|
|
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
101
|
// 任一只读命令能返回就说明 IPC/daemon 仍可达。慢主机上某个较重的
|
|
95
102
|
// 统计命令单独超时,不应把整个 Agent 页面判成离线。
|
|
@@ -118,48 +125,60 @@ async function buildSnapshot(fallback = null) {
|
|
|
118
125
|
}
|
|
119
126
|
const AID_POLL_INTERVAL_MS = 1000;
|
|
120
127
|
const DAEMON_FAILURE_THRESHOLD = 3;
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
let
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 () => {
|
|
132
151
|
let snapshot;
|
|
133
152
|
try {
|
|
134
|
-
snapshot = await buildSnapshot(lastSuccessfulSnapshot);
|
|
153
|
+
snapshot = await buildSnapshot(state.lastSuccessfulSnapshot, state.params);
|
|
135
154
|
}
|
|
136
155
|
catch {
|
|
137
156
|
snapshot = { daemonRunning: false, aids: [], stats: [], agentStats: [], agents: [] };
|
|
138
157
|
}
|
|
139
158
|
if (snapshot?.daemonRunning) {
|
|
140
|
-
consecutiveDaemonFailures = 0;
|
|
141
|
-
lastSuccessfulSnapshot = snapshot;
|
|
142
|
-
lastResolvedSnapshot = snapshot;
|
|
159
|
+
state.consecutiveDaemonFailures = 0;
|
|
160
|
+
state.lastSuccessfulSnapshot = snapshot;
|
|
161
|
+
state.lastResolvedSnapshot = snapshot;
|
|
143
162
|
return snapshot;
|
|
144
163
|
}
|
|
145
|
-
consecutiveDaemonFailures += 1;
|
|
164
|
+
state.consecutiveDaemonFailures += 1;
|
|
146
165
|
// 单轮 IPC 超时不代表 daemon 已停止。保留最近一次成功数据,直到
|
|
147
166
|
// 连续多轮都失败,避免页面在慢主机上反复清空和恢复。
|
|
148
|
-
if (lastSuccessfulSnapshot && consecutiveDaemonFailures < DAEMON_FAILURE_THRESHOLD) {
|
|
149
|
-
lastResolvedSnapshot = lastSuccessfulSnapshot;
|
|
150
|
-
return lastSuccessfulSnapshot;
|
|
167
|
+
if (state.lastSuccessfulSnapshot && state.consecutiveDaemonFailures < DAEMON_FAILURE_THRESHOLD) {
|
|
168
|
+
state.lastResolvedSnapshot = state.lastSuccessfulSnapshot;
|
|
169
|
+
return state.lastSuccessfulSnapshot;
|
|
151
170
|
}
|
|
152
|
-
lastResolvedSnapshot = snapshot;
|
|
171
|
+
state.lastResolvedSnapshot = snapshot;
|
|
153
172
|
return snapshot;
|
|
154
173
|
})().finally(() => {
|
|
155
|
-
pollInFlight = null;
|
|
174
|
+
state.pollInFlight = null;
|
|
156
175
|
});
|
|
157
176
|
}
|
|
158
|
-
return pollInFlight;
|
|
177
|
+
return state.pollInFlight;
|
|
159
178
|
}
|
|
160
|
-
function notifySubscribers(snapshot) {
|
|
179
|
+
function notifySubscribers(state, snapshot) {
|
|
161
180
|
const json = JSON.stringify(snapshot);
|
|
162
|
-
for (const subscriber of subscribers) {
|
|
181
|
+
for (const subscriber of state.subscribers) {
|
|
163
182
|
if (json === subscriber.lastJson)
|
|
164
183
|
continue;
|
|
165
184
|
subscriber.lastJson = json;
|
|
@@ -169,40 +188,41 @@ function notifySubscribers(snapshot) {
|
|
|
169
188
|
catch { /* subscriber disconnected */ }
|
|
170
189
|
}
|
|
171
190
|
}
|
|
172
|
-
function schedulePoll(delayMs = AID_POLL_INTERVAL_MS) {
|
|
173
|
-
if (pollTimer !== null || subscribers.size === 0)
|
|
191
|
+
function schedulePoll(state, delayMs = AID_POLL_INTERVAL_MS) {
|
|
192
|
+
if (state.pollTimer !== null || state.subscribers.size === 0)
|
|
174
193
|
return;
|
|
175
|
-
pollTimer = setTimeout(async () => {
|
|
176
|
-
pollTimer = null;
|
|
177
|
-
if (subscribers.size === 0)
|
|
194
|
+
state.pollTimer = setTimeout(async () => {
|
|
195
|
+
state.pollTimer = null;
|
|
196
|
+
if (state.subscribers.size === 0)
|
|
178
197
|
return;
|
|
179
198
|
try {
|
|
180
|
-
notifySubscribers(await readStableSnapshot());
|
|
199
|
+
notifySubscribers(state, await readStableSnapshot(state));
|
|
181
200
|
}
|
|
182
201
|
catch { /* ignore transient IPC errors */ }
|
|
183
202
|
// 下一轮从本轮完成后开始计时,绝不与上一轮重叠。
|
|
184
|
-
schedulePoll();
|
|
203
|
+
schedulePoll(state);
|
|
185
204
|
}, delayMs);
|
|
186
205
|
}
|
|
187
206
|
export const aidSource = {
|
|
188
207
|
kind: 'agents',
|
|
189
|
-
async snapshot() {
|
|
190
|
-
return readStableSnapshot();
|
|
208
|
+
async snapshot(params = {}) {
|
|
209
|
+
return readStableSnapshot(pollState(params));
|
|
191
210
|
},
|
|
192
|
-
subscribe(
|
|
211
|
+
subscribe(params, push) {
|
|
212
|
+
const state = pollState(params);
|
|
193
213
|
// server 会在 subscribe 前先发送 snapshot;以最近成功值作为基线,避免
|
|
194
214
|
// 第一轮轮询把同一份数据再推送一次。
|
|
195
215
|
const subscriber = {
|
|
196
216
|
push,
|
|
197
|
-
lastJson: lastResolvedSnapshot ? JSON.stringify(lastResolvedSnapshot) : '',
|
|
217
|
+
lastJson: state.lastResolvedSnapshot ? JSON.stringify(state.lastResolvedSnapshot) : '',
|
|
198
218
|
};
|
|
199
|
-
subscribers.add(subscriber);
|
|
200
|
-
schedulePoll();
|
|
219
|
+
state.subscribers.add(subscriber);
|
|
220
|
+
schedulePoll(state);
|
|
201
221
|
return () => {
|
|
202
|
-
subscribers.delete(subscriber);
|
|
203
|
-
if (subscribers.size === 0 && pollTimer !== null) {
|
|
204
|
-
clearTimeout(pollTimer);
|
|
205
|
-
pollTimer = null;
|
|
222
|
+
state.subscribers.delete(subscriber);
|
|
223
|
+
if (state.subscribers.size === 0 && state.pollTimer !== null) {
|
|
224
|
+
clearTimeout(state.pollTimer);
|
|
225
|
+
state.pollTimer = null;
|
|
206
226
|
}
|
|
207
227
|
};
|
|
208
228
|
},
|