ec-web 0.0.22 → 0.0.23
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 +4 -2
- package/dist/ipc-client.js +5 -0
- package/dist/server.js +68 -148
- package/dist/sources/baseagent-detector.js +27 -102
- package/dist/sources/basic-settings.js +21 -4
- package/dist/sources/models.js +13 -6
- package/dist/sources/role-definitions.js +37 -12
- package/dist/static/app.js +81 -33
- package/dist/static/basic-settings.js +81 -14
- package/dist/static/index.html +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 用法:
|
|
6
6
|
* ec-web [--port 42705] [--home <EVOLCORE_HOME>]
|
|
7
7
|
*
|
|
8
|
-
* 通过 EVOLCORE_HOME 定位 evolcore 数据目录,启动 HTTP+WS
|
|
8
|
+
* 通过 EVOLCORE_HOME 定位 evolcore 数据目录,启动 HTTP+WS 服务;配对码和访问 token 由 daemon 管理。
|
|
9
9
|
* 与 evolcore daemon 通过 IPC socket(live 状态)+ 文件系统(历史数据)旁路通信。
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'fs';
|
|
@@ -106,7 +106,9 @@ for (const list of Object.values(ifaces)) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
process.stdout.write(`\n${BOLD}${CYAN}EC Web — EvolCore${RST} ${DIM}(home: ${p.root})${RST}\n\n`);
|
|
109
|
-
process.stdout.write(
|
|
109
|
+
process.stdout.write(handle.pairingCode
|
|
110
|
+
? ` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(daemon 提供,5 分钟内有效,配对后 token 缓存 30 天,有访问自动续期)${RST}\n\n`
|
|
111
|
+
: ` ${BOLD}配对码:${RST} ${YELLOW}daemon 暂不可用,请先启动 EvolCore${RST}\n\n`);
|
|
110
112
|
process.stdout.write(` ${BOLD}本机:${RST} http://localhost:${handle.port}\n`);
|
|
111
113
|
for (const ip of lanIps)
|
|
112
114
|
process.stdout.write(` ${BOLD}局域网:${RST} http://${ip}:${handle.port}\n`);
|
package/dist/ipc-client.js
CHANGED
|
@@ -15,9 +15,14 @@ import path from 'path';
|
|
|
15
15
|
const LOCAL_CONTROL_COMMANDS = new Set([
|
|
16
16
|
'evolagent.list',
|
|
17
17
|
'menu.exec',
|
|
18
|
+
'model.catalog',
|
|
18
19
|
'monitor-snapshot',
|
|
19
20
|
'monitor-subscribe',
|
|
20
21
|
'monitor-unsubscribe',
|
|
22
|
+
'remote-auth.pair-code',
|
|
23
|
+
'remote-auth.issue-local',
|
|
24
|
+
'remote-auth.redeem',
|
|
25
|
+
'remote-auth.validate',
|
|
21
26
|
]);
|
|
22
27
|
export function ipcQuery(socketPath, cmd, timeoutMs = 3000) {
|
|
23
28
|
let request = cmd;
|
package/dist/server.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - HTTP: 静态资源 + 配对 API
|
|
5
5
|
* - WebSocket: 订阅式实时推送(aid / msg / session)
|
|
6
|
-
* - 鉴权: 6 位配对码(5
|
|
6
|
+
* - 鉴权: daemon 通过本地 IPC 提供 6 位配对码(5 分钟有效)和远程访问 token(30 天滑动续期)
|
|
7
7
|
* - 安全: 绑定 0.0.0.0(支持远程访问),token 校验;远程连接可执行受控管理操作,不能修改 daemon 配置或读取 secret
|
|
8
8
|
*
|
|
9
|
-
* 与 evolcore
|
|
9
|
+
* 与 evolcore daemon 通过 IPC socket(状态、Menu 和远程访问凭据)+ 文件系统(历史数据)旁路通信。
|
|
10
10
|
*/
|
|
11
11
|
import http from 'http';
|
|
12
12
|
import fs from 'fs';
|
|
@@ -33,13 +33,11 @@ import { ccProjectsDir } from './paths.js';
|
|
|
33
33
|
import { relationRolesSource, handleRelationRolesApi, handlePeerRoleApi } from './sources/relation-roles.js';
|
|
34
34
|
import { roleDefinitionsSource, handleRoleDefinitionsApi } from './sources/role-definitions.js';
|
|
35
35
|
import { handleModelsApi } from './sources/models.js';
|
|
36
|
-
import {
|
|
36
|
+
import { detectBaseAgentsAsync } from './sources/baseagent-detector.js';
|
|
37
37
|
import { readCodexSessionResult } from './sources/session-codex.js';
|
|
38
38
|
import { handleBasicSettingsApi } from './sources/basic-settings.js';
|
|
39
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
40
40
|
const STATIC_DIR = path.join(__dirname, 'static');
|
|
41
|
-
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
|
|
42
|
-
const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
|
|
43
41
|
const DEFAULT_PORT = 42705;
|
|
44
42
|
const PROTOCOL_VERSION = 1; // 与 evolcore ping response 对齐的软校验版本
|
|
45
43
|
function daemonOwnerMenuAuth(localDirect, actorAid) {
|
|
@@ -61,14 +59,6 @@ function daemonTriggerControlToken() {
|
|
|
61
59
|
return undefined;
|
|
62
60
|
}
|
|
63
61
|
}
|
|
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
|
-
}
|
|
72
62
|
const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource, logs: logsSource, gateway: gatewaySource, roles: relationRolesSource, roleDefinitions: roleDefinitionsSource };
|
|
73
63
|
// ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
|
|
74
64
|
function readEcwebVersion() {
|
|
@@ -104,66 +94,21 @@ const MIME = {
|
|
|
104
94
|
};
|
|
105
95
|
const HTML_CACHE_CONTROL = 'no-cache';
|
|
106
96
|
const STATIC_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
|
|
107
|
-
function
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const legacy = path.join(dir, 'watch-web-tokens.json');
|
|
111
|
-
if (!fs.existsSync(current) && fs.existsSync(legacy)) {
|
|
112
|
-
try {
|
|
113
|
-
fs.renameSync(legacy, current);
|
|
114
|
-
}
|
|
115
|
-
catch { }
|
|
116
|
-
}
|
|
117
|
-
return current;
|
|
118
|
-
}
|
|
119
|
-
function loadTokens() {
|
|
120
|
-
try {
|
|
121
|
-
const store = JSON.parse(fs.readFileSync(tokenStorePath(), 'utf-8'));
|
|
122
|
-
if (Array.isArray(store.tokens))
|
|
123
|
-
return store;
|
|
124
|
-
}
|
|
125
|
-
catch { }
|
|
126
|
-
return { tokens: [] };
|
|
97
|
+
async function remoteAuth(type, payload = {}) {
|
|
98
|
+
const response = await ipcQuery(resolvePaths().socket, { type, ...payload }, 2_000);
|
|
99
|
+
return response && typeof response === 'object' ? response : null;
|
|
127
100
|
}
|
|
128
|
-
function
|
|
129
|
-
try {
|
|
130
|
-
fs.mkdirSync(resolvePaths().instanceDir, { recursive: true });
|
|
131
|
-
const tmp = tokenStorePath() + '.tmp';
|
|
132
|
-
fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
|
|
133
|
-
fs.renameSync(tmp, tokenStorePath());
|
|
134
|
-
}
|
|
135
|
-
catch { }
|
|
136
|
-
}
|
|
137
|
-
function pruneExpired(store, now) {
|
|
138
|
-
const before = store.tokens.length;
|
|
139
|
-
store.tokens = store.tokens.filter(t => now - t.lastActive < TOKEN_TTL_MS);
|
|
140
|
-
return store.tokens.length !== before;
|
|
141
|
-
}
|
|
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) {
|
|
101
|
+
async function validateRemoteToken(token) {
|
|
151
102
|
if (!token)
|
|
152
103
|
return null;
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
rec.lastActive = now;
|
|
158
|
-
saveTokens(store);
|
|
159
|
-
return rec;
|
|
160
|
-
}
|
|
161
|
-
if (changed)
|
|
162
|
-
saveTokens(store);
|
|
163
|
-
return null;
|
|
104
|
+
const result = await remoteAuth('remote-auth.validate', { token });
|
|
105
|
+
if (!result?.ok || result.valid !== true)
|
|
106
|
+
return null;
|
|
107
|
+
return result.authorizedBy ? { authorizedBy: result.authorizedBy } : {};
|
|
164
108
|
}
|
|
165
|
-
function
|
|
166
|
-
|
|
109
|
+
async function issueLocalRemoteToken(label) {
|
|
110
|
+
const result = await remoteAuth('remote-auth.issue-local', { label });
|
|
111
|
+
return result?.ok && typeof result.token === 'string' ? result.token : null;
|
|
167
112
|
}
|
|
168
113
|
// ── Static ──
|
|
169
114
|
// 经 AUN Service Proxy 转发时,proxy-server 注入 x-forwarded-prefix 头,
|
|
@@ -469,11 +414,8 @@ function isLocalDirect(req) {
|
|
|
469
414
|
const providerAid = req.headers['x-aun-provider-aid'];
|
|
470
415
|
return !providerAid || (Array.isArray(providerAid) ? providerAid.length === 0 : !providerAid.trim());
|
|
471
416
|
}
|
|
472
|
-
function genPairingCode() {
|
|
473
|
-
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
474
|
-
}
|
|
475
417
|
// ── Pair handler ──
|
|
476
|
-
function handlePair(req, res,
|
|
418
|
+
function handlePair(req, res, log) {
|
|
477
419
|
let body = '';
|
|
478
420
|
req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
|
|
479
421
|
req.destroy(); });
|
|
@@ -484,47 +426,34 @@ function handlePair(req, res, pairingCode, pairingExpiry, authorizedBy, log) {
|
|
|
484
426
|
code = String(JSON.parse(body).code || '');
|
|
485
427
|
}
|
|
486
428
|
catch { }
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
log(`✗ 配对失败(码已过期) from ${ip}`);
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
if (code !== pairingCode) {
|
|
493
|
-
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, reason: '配对码错误' }));
|
|
494
|
-
log(`✗ 配对失败(码错误: ${code}) from ${ip}`);
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
const now = Date.now();
|
|
498
|
-
const token = crypto.randomBytes(32).toString('hex');
|
|
499
|
-
const store = loadTokens();
|
|
500
|
-
pruneExpired(store, now);
|
|
501
|
-
store.tokens.push({
|
|
502
|
-
token,
|
|
503
|
-
createdAt: now,
|
|
504
|
-
lastActive: now,
|
|
429
|
+
void remoteAuth('remote-auth.redeem', {
|
|
430
|
+
code,
|
|
505
431
|
label: ip,
|
|
506
|
-
|
|
432
|
+
}).then(result => {
|
|
433
|
+
if (!result?.ok || typeof result.token !== 'string') {
|
|
434
|
+
const status = result?.code === 'PAIRING_EXPIRED' ? 403 : 403;
|
|
435
|
+
res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, reason: result?.error ?? '配对失败' }));
|
|
436
|
+
log(`✗ 配对失败(${result?.code ?? 'daemon-unavailable'}) from ${ip}`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token: result.token }));
|
|
440
|
+
log(`✓ 配对成功 from ${ip}(daemon token 缓存 30 天,有访问自动续期)`);
|
|
441
|
+
}).catch(error => {
|
|
442
|
+
res.writeHead(503, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, reason: 'daemon unavailable' }));
|
|
443
|
+
log(`✗ 配对失败(daemon unavailable: ${error instanceof Error ? error.message : String(error)}) from ${ip}`);
|
|
507
444
|
});
|
|
508
|
-
saveTokens(store);
|
|
509
|
-
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token }));
|
|
510
|
-
log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期${authorizedBy ? `,批准者 ${authorizedBy}` : ''})`);
|
|
511
445
|
});
|
|
512
446
|
}
|
|
513
447
|
/**
|
|
514
448
|
* 本地直连免配对:自动发 token。远程(隧道或真远程)需配对码。
|
|
515
449
|
* 本地直连判定:socket 来源是回环 AND 无 x-aun-provider-aid 头(非隧道)。
|
|
516
450
|
*/
|
|
517
|
-
function issueLocalDirectToken(req, log) {
|
|
451
|
+
async function issueLocalDirectToken(req, log) {
|
|
518
452
|
if (!isLocalDirect(req))
|
|
519
453
|
return null;
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
pruneExpired(store, now);
|
|
524
|
-
const ip = clientIp(req);
|
|
525
|
-
store.tokens.push({ token, createdAt: now, lastActive: now, label: `local-direct:${ip}` });
|
|
526
|
-
saveTokens(store);
|
|
527
|
-
log(`✓ 本地直连自动授权 from ${ip}`);
|
|
454
|
+
const token = await issueLocalRemoteToken(`local-direct:${clientIp(req)}`);
|
|
455
|
+
if (token)
|
|
456
|
+
log(`✓ 本地直连自动授权 from ${clientIp(req)}`);
|
|
528
457
|
return token;
|
|
529
458
|
}
|
|
530
459
|
// ── WebSocket connection ──
|
|
@@ -808,26 +737,20 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
808
737
|
if (pingResp && pingResp.protocolVersion !== undefined && pingResp.protocolVersion < PROTOCOL_VERSION) {
|
|
809
738
|
log(`⚠️ evolcore protocolVersion=${pingResp.protocolVersion},watch 期望 >=${PROTOCOL_VERSION},部分功能可能异常`);
|
|
810
739
|
}
|
|
811
|
-
|
|
812
|
-
let pairingExpiry = Date.now() + PAIRING_TTL_MS;
|
|
813
|
-
let pairingAuthorizedBy;
|
|
814
|
-
function freshPairing() {
|
|
815
|
-
if (Date.now() > pairingExpiry) {
|
|
816
|
-
pairingCode = genPairingCode();
|
|
817
|
-
pairingExpiry = Date.now() + PAIRING_TTL_MS;
|
|
818
|
-
pairingAuthorizedBy = undefined;
|
|
819
|
-
log(`↺ 配对码已刷新:${pairingCode}(5 分钟有效)`);
|
|
820
|
-
}
|
|
821
|
-
return { code: pairingCode, expiresAt: pairingExpiry };
|
|
822
|
-
}
|
|
823
|
-
const server = http.createServer((req, res) => {
|
|
740
|
+
const server = http.createServer(async (req, res) => {
|
|
824
741
|
const pathname = requestPath(req.url || '');
|
|
825
742
|
const localDirect = isLocalDirect(req);
|
|
826
743
|
if (req.method === 'GET' && (req.url || '') === '/api/available-baseagents') {
|
|
827
744
|
// Base agent 可用性检测 API
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
745
|
+
try {
|
|
746
|
+
const available = await detectBaseAgentsAsync();
|
|
747
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
748
|
+
res.end(JSON.stringify(available));
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
res.writeHead(500, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
752
|
+
res.end(JSON.stringify({ success: false, error: error?.message || String(error) }));
|
|
753
|
+
}
|
|
831
754
|
}
|
|
832
755
|
else if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
|
|
833
756
|
// 取码 API:仅真本地直连可用(socket 回环 + 无 x-aun-provider-aid)。
|
|
@@ -838,28 +761,24 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
838
761
|
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
839
762
|
return;
|
|
840
763
|
}
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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;
|
|
764
|
+
const pair = await remoteAuth('remote-auth.pair-code');
|
|
765
|
+
if (!pair?.ok || typeof pair.code !== 'string' || typeof pair.expiresAt !== 'number') {
|
|
766
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
767
|
+
res.end(JSON.stringify({ error: 'daemon unavailable' }));
|
|
768
|
+
return;
|
|
850
769
|
}
|
|
851
770
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
852
|
-
res.end(JSON.stringify({ code, expiresAt }));
|
|
771
|
+
res.end(JSON.stringify({ code: pair.code, expiresAt: pair.expiresAt }));
|
|
853
772
|
}
|
|
854
773
|
else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
|
|
855
|
-
// 配对 API
|
|
856
|
-
const autoToken = issueLocalDirectToken(req, log);
|
|
774
|
+
// 配对 API:远程(隧道/真远程)需要 daemon 配对码;本地直连自动发 daemon token。
|
|
775
|
+
const autoToken = await issueLocalDirectToken(req, log);
|
|
857
776
|
if (autoToken) {
|
|
858
777
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
859
778
|
res.end(JSON.stringify({ ok: true, token: autoToken }));
|
|
860
779
|
return;
|
|
861
780
|
}
|
|
862
|
-
handlePair(req, res,
|
|
781
|
+
handlePair(req, res, log);
|
|
863
782
|
}
|
|
864
783
|
else if (pathname === '/api/basic-settings' || pathname === '/api/basic-settings/secret') {
|
|
865
784
|
const authHeader = req.headers.authorization || '';
|
|
@@ -867,11 +786,11 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
867
786
|
const { query } = parseUrl(req.url || '');
|
|
868
787
|
let token = bearerToken || query.token || '';
|
|
869
788
|
if (!token) {
|
|
870
|
-
const autoToken = issueLocalDirectToken(req, log);
|
|
789
|
+
const autoToken = await issueLocalDirectToken(req, log);
|
|
871
790
|
if (autoToken)
|
|
872
791
|
token = autoToken;
|
|
873
792
|
}
|
|
874
|
-
if (!token || !
|
|
793
|
+
if (!token || !(await validateRemoteToken(token))) {
|
|
875
794
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
876
795
|
res.end(JSON.stringify({ success: false, error: 'unauthorized' }));
|
|
877
796
|
return;
|
|
@@ -895,11 +814,11 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
895
814
|
const { query } = parseUrl(req.url || '');
|
|
896
815
|
let token = bearerToken || query.token || '';
|
|
897
816
|
if (!token) {
|
|
898
|
-
const autoToken = issueLocalDirectToken(req, log);
|
|
817
|
+
const autoToken = await issueLocalDirectToken(req, log);
|
|
899
818
|
if (autoToken)
|
|
900
819
|
token = autoToken;
|
|
901
820
|
}
|
|
902
|
-
if (!token || !
|
|
821
|
+
if (!token || !(await validateRemoteToken(token))) {
|
|
903
822
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
904
823
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
905
824
|
return;
|
|
@@ -912,11 +831,11 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
912
831
|
const { query } = parseUrl(req.url || '');
|
|
913
832
|
let token = bearerToken || query.token || '';
|
|
914
833
|
if (!token) {
|
|
915
|
-
const autoToken = issueLocalDirectToken(req, log);
|
|
834
|
+
const autoToken = await issueLocalDirectToken(req, log);
|
|
916
835
|
if (autoToken)
|
|
917
836
|
token = autoToken;
|
|
918
837
|
}
|
|
919
|
-
if (!token || !
|
|
838
|
+
if (!token || !(await validateRemoteToken(token))) {
|
|
920
839
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
921
840
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
922
841
|
return;
|
|
@@ -930,11 +849,11 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
930
849
|
const { query } = parseUrl(req.url || '');
|
|
931
850
|
let token = bearerToken || query.token || '';
|
|
932
851
|
if (!token) {
|
|
933
|
-
const autoToken = issueLocalDirectToken(req, log);
|
|
852
|
+
const autoToken = await issueLocalDirectToken(req, log);
|
|
934
853
|
if (autoToken)
|
|
935
854
|
token = autoToken;
|
|
936
855
|
}
|
|
937
|
-
const tokenRecord = token ?
|
|
856
|
+
const tokenRecord = token ? await validateRemoteToken(token) : null;
|
|
938
857
|
if (!token || !tokenRecord) {
|
|
939
858
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
940
859
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
@@ -949,11 +868,11 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
949
868
|
const { query } = parseUrl(req.url || '');
|
|
950
869
|
let token = bearerToken || query.token || '';
|
|
951
870
|
if (!token) {
|
|
952
|
-
const autoToken = issueLocalDirectToken(req, log);
|
|
871
|
+
const autoToken = await issueLocalDirectToken(req, log);
|
|
953
872
|
if (autoToken)
|
|
954
873
|
token = autoToken;
|
|
955
874
|
}
|
|
956
|
-
const tokenRecord = token ?
|
|
875
|
+
const tokenRecord = token ? await validateRemoteToken(token) : null;
|
|
957
876
|
if (!token || !tokenRecord) {
|
|
958
877
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
959
878
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
@@ -967,11 +886,11 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
967
886
|
const { query } = parseUrl(req.url || '');
|
|
968
887
|
let token = bearerToken || query.token || '';
|
|
969
888
|
if (!token) {
|
|
970
|
-
const autoToken = issueLocalDirectToken(req, log);
|
|
889
|
+
const autoToken = await issueLocalDirectToken(req, log);
|
|
971
890
|
if (autoToken)
|
|
972
891
|
token = autoToken;
|
|
973
892
|
}
|
|
974
|
-
const tokenRecord = token ?
|
|
893
|
+
const tokenRecord = token ? await validateRemoteToken(token) : null;
|
|
975
894
|
if (!token || !tokenRecord) {
|
|
976
895
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
977
896
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
@@ -984,11 +903,11 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
984
903
|
}
|
|
985
904
|
});
|
|
986
905
|
const wss = new WebSocketServer({ noServer: true });
|
|
987
|
-
server.on('upgrade', (req, socket, head) => {
|
|
906
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
988
907
|
const { query } = parseUrl(req.url || '');
|
|
989
908
|
// 本地直连免 token;远程需 token。
|
|
990
909
|
const localDirect = isLocalDirect(req);
|
|
991
|
-
const tokenRecord = localDirect ? null :
|
|
910
|
+
const tokenRecord = localDirect ? null : await validateRemoteToken(query.token || '');
|
|
992
911
|
const authed = localDirect || tokenRecord !== null;
|
|
993
912
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
994
913
|
if (!authed) {
|
|
@@ -1000,11 +919,12 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
1000
919
|
});
|
|
1001
920
|
});
|
|
1002
921
|
const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
|
|
922
|
+
const initialPair = await remoteAuth('remote-auth.pair-code');
|
|
1003
923
|
return {
|
|
1004
924
|
url: `http://0.0.0.0:${port}`,
|
|
1005
925
|
port,
|
|
1006
926
|
displaced,
|
|
1007
|
-
pairingCode,
|
|
927
|
+
pairingCode: initialPair?.ok && typeof initialPair.code === 'string' ? initialPair.code : '',
|
|
1008
928
|
close() {
|
|
1009
929
|
return new Promise((resolve) => {
|
|
1010
930
|
// 强制断开所有 WS 客户端(graceful close 握手可能永不完成)
|
|
@@ -1,117 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Base agent 环境检测 — 检测本机可用的 Claude、Codex、Gemini 和内置 EC Agent。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 基座展示,实际凭据和网关配置由配置校验负责。
|
|
4
|
+
* 检测条件与 ec init 共用:Claude/Gemini 按可执行文件检测,Codex 要求满足
|
|
5
|
+
* 版本及 app-server 条件,ecagent 随 EvolCore 内置。不会仅因历史会话目录存在
|
|
6
|
+
* 就判定 CLI 可用。
|
|
8
7
|
*/
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const requireFromHere = createRequire(import.meta.url);
|
|
15
|
-
let _cached = null;
|
|
16
|
-
/**
|
|
17
|
-
* 检测 node:sqlite 模块是否可用(Node 22.5+)
|
|
18
|
-
*/
|
|
19
|
-
function checkSqliteAvailable() {
|
|
20
|
-
try {
|
|
21
|
-
requireFromHere('node:sqlite');
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return false;
|
|
8
|
+
import { resolveParentDistModule, toFileUrl } from './parent-package.js';
|
|
9
|
+
let sharedDetectorPromise = null;
|
|
10
|
+
async function sharedDetector() {
|
|
11
|
+
if (!sharedDetectorPromise) {
|
|
12
|
+
sharedDetectorPromise = import(toFileUrl(resolveParentDistModule('agents', 'baseagent-availability.js')));
|
|
26
13
|
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 检测 Codex state_*.sqlite 文件是否存在
|
|
30
|
-
*/
|
|
31
|
-
function checkCodexStateDb() {
|
|
32
|
-
const codexHome = path.join(os.homedir(), '.codex');
|
|
33
|
-
if (!fs.existsSync(codexHome))
|
|
34
|
-
return false;
|
|
35
|
-
try {
|
|
36
|
-
const files = fs.readdirSync(codexHome).filter(f => /^state_\d+\.sqlite$/.test(f));
|
|
37
|
-
return files.length > 0;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* 检测 Claude projects 目录是否存在
|
|
45
|
-
*/
|
|
46
|
-
function checkClaudeProjects() {
|
|
47
|
-
const claudeProjects = path.join(os.homedir(), '.claude', 'projects');
|
|
48
|
-
return fs.existsSync(claudeProjects);
|
|
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
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* 检测 Codex sessions 目录是否存在
|
|
86
|
-
*/
|
|
87
|
-
function checkCodexSessions() {
|
|
88
|
-
const codexSessions = path.join(os.homedir(), '.codex', 'sessions');
|
|
89
|
-
return fs.existsSync(codexSessions);
|
|
14
|
+
return sharedDetectorPromise;
|
|
90
15
|
}
|
|
91
16
|
/**
|
|
92
17
|
* 检测所有 base agent 的可用性
|
|
93
|
-
*
|
|
18
|
+
* 每次调用都会重新探测,确保基础设置中的“重新检测”反映当前环境。
|
|
94
19
|
*/
|
|
95
|
-
export function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
20
|
+
export async function detectBaseAgentsAsync() {
|
|
21
|
+
const mod = await sharedDetector();
|
|
22
|
+
const detected = new Set(mod.detectAvailableBaseagents());
|
|
23
|
+
return {
|
|
24
|
+
claude: detected.has('claude'),
|
|
25
|
+
codex: detected.has('codex'),
|
|
26
|
+
gemini: detected.has('gemini'),
|
|
27
|
+
ecagent: detected.has('ecagent'),
|
|
28
|
+
};
|
|
104
29
|
}
|
|
105
30
|
/** Return display-oriented detection details for the Basic Settings API. */
|
|
106
|
-
export function detectBaseAgentDetails(defaults = {}) {
|
|
31
|
+
export async function detectBaseAgentDetails(defaults = {}) {
|
|
32
|
+
const mod = await sharedDetector();
|
|
33
|
+
const probes = mod.detectBaseagentDetails();
|
|
107
34
|
const configured = isRecord(defaults?.baseagents) ? defaults.baseagents : {};
|
|
108
|
-
return
|
|
109
|
-
const
|
|
35
|
+
return probes.map((probe) => {
|
|
36
|
+
const id = probe.id;
|
|
110
37
|
return {
|
|
111
38
|
id,
|
|
112
|
-
detected:
|
|
39
|
+
detected: probe.detected,
|
|
113
40
|
configured: Object.prototype.hasOwnProperty.call(configured, id),
|
|
114
|
-
detectionSource,
|
|
41
|
+
detectionSource: probe.detectionSource,
|
|
115
42
|
};
|
|
116
43
|
});
|
|
117
44
|
}
|
|
@@ -121,6 +48,4 @@ function isRecord(value) {
|
|
|
121
48
|
/**
|
|
122
49
|
* 重置缓存(测试用)
|
|
123
50
|
*/
|
|
124
|
-
export function resetDetectionCache() {
|
|
125
|
-
_cached = null;
|
|
126
|
-
}
|
|
51
|
+
export function resetDetectionCache() { }
|
|
@@ -176,6 +176,10 @@ function baseagentPath(fieldPath) {
|
|
|
176
176
|
return null;
|
|
177
177
|
return { baseagent: match[1], field: match[2] };
|
|
178
178
|
}
|
|
179
|
+
function baseagentBlockPath(fieldPath) {
|
|
180
|
+
const match = fieldPath.match(/^baseagents\.([a-z0-9_-]+)$/);
|
|
181
|
+
return match?.[1] || null;
|
|
182
|
+
}
|
|
179
183
|
export function isAllowedBasicSettingsPath(file, fieldPath) {
|
|
180
184
|
try {
|
|
181
185
|
pathSegments(fieldPath);
|
|
@@ -187,6 +191,9 @@ export function isAllowedBasicSettingsPath(file, fieldPath) {
|
|
|
187
191
|
return DAEMON_EXACT_PATHS.has(fieldPath);
|
|
188
192
|
if (DEFAULTS_EXACT_PATHS.has(fieldPath))
|
|
189
193
|
return true;
|
|
194
|
+
const block = baseagentBlockPath(fieldPath);
|
|
195
|
+
if (block)
|
|
196
|
+
return BASEAGENT_SET.has(block);
|
|
190
197
|
const parsed = baseagentPath(fieldPath);
|
|
191
198
|
return !!parsed && BASEAGENT_SET.has(parsed.baseagent) && BASEAGENT_FIELDS[parsed.baseagent]?.has(parsed.field);
|
|
192
199
|
}
|
|
@@ -450,6 +457,11 @@ function validateDefaultsValue(fieldPath, value) {
|
|
|
450
457
|
const parsed = baseagentPath(fieldPath);
|
|
451
458
|
if (parsed)
|
|
452
459
|
return validateBaseagentValue(parsed.baseagent, parsed.field, fieldPath, value);
|
|
460
|
+
if (baseagentBlockPath(fieldPath)) {
|
|
461
|
+
if (!isRecord(value) || Object.keys(value).length > 0)
|
|
462
|
+
throw new Error(`${fieldPath} must be an empty object`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
453
465
|
if (fieldPath === 'active_baseagent') {
|
|
454
466
|
if (!BASEAGENT_SET.has(String(value)))
|
|
455
467
|
throw new Error(`${fieldPath} is invalid`);
|
|
@@ -510,8 +522,7 @@ export function validateBasicSettingsChange(file, change) {
|
|
|
510
522
|
else
|
|
511
523
|
validateDefaultsValue(change.path, change.value);
|
|
512
524
|
}
|
|
513
|
-
function validateBaseagentAvailability(
|
|
514
|
-
const details = detectBaseAgentDetails(config);
|
|
525
|
+
function validateBaseagentAvailability(changes, details) {
|
|
515
526
|
const detected = new Set(details.filter(item => item.detected).map(item => item.id));
|
|
516
527
|
const configured = new Set(details.filter(item => item.configured).map(item => item.id));
|
|
517
528
|
for (const change of changes) {
|
|
@@ -522,6 +533,10 @@ function validateBaseagentAvailability(config, changes) {
|
|
|
522
533
|
if (parsed && !detected.has(parsed.baseagent) && !configured.has(parsed.baseagent)) {
|
|
523
534
|
throw Object.assign(new Error(`Base Agent is not available on this machine: ${parsed.baseagent}`), { statusCode: 400 });
|
|
524
535
|
}
|
|
536
|
+
const block = baseagentBlockPath(change.path);
|
|
537
|
+
if (block && change.op === 'set' && !detected.has(block) && !configured.has(block)) {
|
|
538
|
+
throw Object.assign(new Error(`Base Agent is not available on this machine: ${block}`), { statusCode: 400 });
|
|
539
|
+
}
|
|
525
540
|
}
|
|
526
541
|
}
|
|
527
542
|
function setAtPath(root, segments, value) {
|
|
@@ -675,7 +690,7 @@ async function readSnapshot(options = {}) {
|
|
|
675
690
|
},
|
|
676
691
|
secrets,
|
|
677
692
|
secretRefs,
|
|
678
|
-
baseagents: detectBaseAgentDetails(rawDefaults),
|
|
693
|
+
baseagents: await detectBaseAgentDetails(rawDefaults),
|
|
679
694
|
logTypes: logTypeStats.types,
|
|
680
695
|
logTypeCounts: logTypeStats.counts,
|
|
681
696
|
};
|
|
@@ -779,6 +794,8 @@ export async function handleBasicSettingsApi(req, res, options = {}) {
|
|
|
779
794
|
changes.forEach(change => validateBasicSettingsChange(file, change));
|
|
780
795
|
const modules = await getConfigManager();
|
|
781
796
|
const target = file === 'daemon' ? modules.ConfigTarget.Process : modules.ConfigTarget.Defaults;
|
|
797
|
+
const currentConfig = modules.read(target, undefined, { cache: false, expand: false }) ?? {};
|
|
798
|
+
const detection = file === 'defaults' ? await detectBaseAgentDetails(currentConfig) : [];
|
|
782
799
|
const mutation = modules.mutateConfig(target, undefined, config => {
|
|
783
800
|
if (basicSettingsRevision(config) !== body.revision) {
|
|
784
801
|
throw Object.assign(new Error('Configuration changed after it was loaded. Reload and try again.'), {
|
|
@@ -786,7 +803,7 @@ export async function handleBasicSettingsApi(req, res, options = {}) {
|
|
|
786
803
|
});
|
|
787
804
|
}
|
|
788
805
|
if (file === 'defaults')
|
|
789
|
-
validateBaseagentAvailability(
|
|
806
|
+
validateBaseagentAvailability(changes, detection);
|
|
790
807
|
applyBasicSettingsChanges(config, file, changes);
|
|
791
808
|
const errors = modules.validateConfig(target, config);
|
|
792
809
|
if (errors.length > 0) {
|