ec-web 0.0.25 → 0.0.26

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 EvolCore contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EvolCore contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,17 +1,17 @@
1
- # EC Web
2
-
3
- Web console and monitoring dashboard for EvolCore.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install -g ec-web
9
- ```
10
-
11
- ## Run
12
-
13
- ```bash
14
- ec-web --home /path/to/runtime-home
15
- ```
16
-
17
- Use `ec watch web` when EC Web is managed by the main daemon.
1
+ # EC Web
2
+
3
+ Web console and monitoring dashboard for EvolCore.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g ec-web
9
+ ```
10
+
11
+ ## Run
12
+
13
+ ```bash
14
+ ec-web --home /path/to/runtime-home
15
+ ```
16
+
17
+ Use `ec watch web` when EC Web is managed by the main daemon.
package/dist/index.js CHANGED
File without changes
package/dist/paths.js CHANGED
@@ -48,7 +48,14 @@ function resolveInstanceSocketPath(root) {
48
48
  const hash = crypto.createHash('sha1').update(canonicalRoot).digest('hex').slice(0, 12);
49
49
  return `\\\\.\\pipe\\daemon-${hash}`;
50
50
  }
51
- return path.join(root, 'data', 'instance', 'daemon.sock');
51
+ const filesystemPath = path.join(root, 'data', 'instance', 'daemon.sock');
52
+ if (Buffer.byteLength(filesystemPath) < 100)
53
+ return filesystemPath;
54
+ const hash = crypto.createHash('sha1').update(root).digest('hex').slice(0, 20);
55
+ const shortFilesystemPath = path.join(os.tmpdir(), `.ec-${hash.slice(0, 15)}.sock`);
56
+ if (Buffer.byteLength(shortFilesystemPath) < 100)
57
+ return shortFilesystemPath;
58
+ return `\0evolcore-${hash}`;
52
59
  }
53
60
  export function resolvePaths() {
54
61
  const root = resolveRoot();
package/dist/server.js CHANGED
@@ -40,6 +40,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
40
  const STATIC_DIR = path.join(__dirname, 'static');
41
41
  const DEFAULT_PORT = 42705;
42
42
  const PROTOCOL_VERSION = 1; // 与 evolcore ping response 对齐的软校验版本
43
+ const ECWEB_TRIGGER_IPC_TYPES = new Set(['trigger.history', 'trigger.eventCatalog']);
43
44
  function daemonOwnerMenuAuth(localDirect, actorAid) {
44
45
  return {
45
46
  localDirect,
@@ -48,6 +49,7 @@ function daemonOwnerMenuAuth(localDirect, actorAid) {
48
49
  // authentication gate (local-direct exemption or a valid bearer token).
49
50
  // ECWeb is the daemon-owner management surface, so remote tokens receive
50
51
  // the same persistent fullaccess approval capability as local requests.
52
+ daemonOwner: true,
51
53
  canApprovePersistentFullAccess: true,
52
54
  };
53
55
  }
@@ -614,7 +616,7 @@ function handleConnection(ws, req, log, tokenRecord) {
614
616
  return;
615
617
  }
616
618
  if (msg.type === 'ipc' && msg.payload) {
617
- if (typeof msg.payload.type !== 'string' || !msg.payload.type.startsWith('trigger.')) {
619
+ if (typeof msg.payload.type !== 'string' || !ECWEB_TRIGGER_IPC_TYPES.has(msg.payload.type)) {
618
620
  send({ type: 'ipc.response', requestId: msg.requestId, data: { ok: false, error: 'unsupported ipc payload' } });
619
621
  return;
620
622
  }
@@ -622,9 +624,7 @@ function handleConnection(ws, req, log, tokenRecord) {
622
624
  const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
623
625
  ? Math.min(Number(msg.timeoutMs), 120_000)
624
626
  : 10000;
625
- const payload = msg.payload.type === 'trigger.history' || msg.payload.type === 'trigger.eventCatalog'
626
- ? { ...msg.payload, controlToken: daemonTriggerControlToken() }
627
- : msg.payload;
627
+ const payload = { ...msg.payload, controlToken: daemonTriggerControlToken() };
628
628
  const resp = await ipcQuery(p.socket, payload, timeoutMs);
629
629
  send({ type: 'ipc.response', requestId: msg.requestId, data: resp ?? { ok: false, error: 'daemon unreachable' } });
630
630
  return;
@@ -796,7 +796,9 @@ export async function startWatchWebServer(opts = {}) {
796
796
  res.end(JSON.stringify({ success: false, error: 'unauthorized' }));
797
797
  return;
798
798
  }
799
- void handleBasicSettingsApi(req, res, { redactConnectionMaps: !isLocalDirect(req) });
799
+ // Pairing grants daemon-owner management authority, just like local
800
+ // access. Authorize above; do not downgrade settings by network origin.
801
+ void handleBasicSettingsApi(req, res);
800
802
  }
801
803
  else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
802
804
  // Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
@@ -65,28 +65,43 @@ function scanAidActivity() {
65
65
  }
66
66
  return result;
67
67
  }
68
- // evolcore 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
69
- // 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
68
+ // evolcore 版本:使用 /system 的轻量 version 视图。daemon 进程级、整轮 watch 不变,
69
+ // 故缓存一次并合并 in-flight 请求,避免每秒重复发 IPC。
70
70
  const versionCache = new Map();
71
+ const versionInFlight = new Map();
71
72
  function authContextKey(params = {}) {
72
73
  const auth = watchMenuRequestAuth(params);
73
74
  return `${auth.localDirect ? 'local' : 'remote'}:${auth.actorAid || ''}`;
74
75
  }
75
76
  async function fetchVersion(socket, params = {}) {
76
- const key = authContextKey(params);
77
+ const key = `${socket}\0${authContextKey(params)}`;
77
78
  const cached = versionCache.get(key);
78
79
  if (cached)
79
80
  return cached;
80
- try {
81
- const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' }, auth: watchMenuRequestAuth(params) }, 4000);
82
- const v = r?.ok ? r.response?.data?.version : null;
83
- if (v)
84
- versionCache.set(key, v);
85
- return v ?? null;
86
- }
87
- catch {
88
- return null;
89
- }
81
+ const pending = versionInFlight.get(key);
82
+ if (pending)
83
+ return pending;
84
+ const request = (async () => {
85
+ try {
86
+ const r = await ipcQuery(socket, {
87
+ type: 'menu.exec',
88
+ payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system', args: { view: 'version' } },
89
+ auth: watchMenuRequestAuth(params),
90
+ }, 4000);
91
+ const v = r?.ok ? r.response?.data?.version : null;
92
+ if (v)
93
+ versionCache.set(key, v);
94
+ return v ?? null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ finally {
100
+ versionInFlight.delete(key);
101
+ }
102
+ })();
103
+ versionInFlight.set(key, request);
104
+ return request;
90
105
  }
91
106
  async function buildSnapshot(fallback = null, params = {}) {
92
107
  const p = resolvePaths();
@@ -580,7 +580,7 @@ export function restartRequiredForChanges(file, changes) {
580
580
  const prefixes = file === 'daemon' ? RESTART_DAEMON_PREFIXES : RESTART_DEFAULTS_PREFIXES;
581
581
  return changes.map(change => change.path).filter(fieldPath => prefixes.some(prefix => fieldPath === prefix || fieldPath.startsWith(prefix)));
582
582
  }
583
- function redactSecrets(config, file, secrets, secretRefs, options = {}) {
583
+ function redactSecrets(config, file, secrets, secretRefs) {
584
584
  const redacted = clone(config);
585
585
  if (file !== 'defaults' || !isRecord(redacted.baseagents))
586
586
  return redacted;
@@ -600,26 +600,6 @@ function redactSecrets(config, file, secrets, secretRefs, options = {}) {
600
600
  raw.apiKey = '******';
601
601
  }
602
602
  }
603
- // Headers and query parameters are also common credential channels. They
604
- // remain visible to local callers for editing, but remote snapshots must
605
- // not disclose any map value, including values under custom key names.
606
- if (options.redactConnectionMaps) {
607
- for (const field of ['headers', 'queryParams']) {
608
- if (!Object.prototype.hasOwnProperty.call(raw, field))
609
- continue;
610
- const fieldPath = `defaults.baseagents.${name}.${field}`;
611
- if (isRecord(raw[field])) {
612
- raw[field] = Object.fromEntries(Object.keys(raw[field]).map(key => {
613
- secrets[`${fieldPath}.${key}`] = true;
614
- return [key, '******'];
615
- }));
616
- }
617
- else {
618
- secrets[fieldPath] = true;
619
- raw[field] = '******';
620
- }
621
- }
622
- }
623
603
  }
624
604
  return redacted;
625
605
  }
@@ -670,7 +650,7 @@ export function collectLogTypeStats(configured, logsDir = resolvePaths().logs) {
670
650
  export function collectLogTypes(configured, logsDir = resolvePaths().logs) {
671
651
  return collectLogTypeStats(configured, logsDir).types;
672
652
  }
673
- async function readSnapshot(options = {}) {
653
+ async function readSnapshot() {
674
654
  const modules = await getConfigManager();
675
655
  const daemonFile = modules.read(modules.ConfigTarget.Process, undefined, { cache: false, expand: false });
676
656
  const defaultsFile = modules.read(modules.ConfigTarget.Defaults, undefined, { cache: false, expand: false });
@@ -682,8 +662,8 @@ async function readSnapshot(options = {}) {
682
662
  const secretRefs = {};
683
663
  const logTypeStats = collectLogTypeStats(rawDaemon?.watch?.logTypes);
684
664
  return {
685
- daemon: redactSecrets(rawDaemon, 'daemon', secrets, secretRefs, options),
686
- defaults: redactSecrets(rawDefaults, 'defaults', secrets, secretRefs, options),
665
+ daemon: redactSecrets(rawDaemon, 'daemon', secrets, secretRefs),
666
+ defaults: redactSecrets(rawDefaults, 'defaults', secrets, secretRefs),
687
667
  revisions: {
688
668
  daemon: basicSettingsRevision(rawDaemon),
689
669
  defaults: basicSettingsRevision(rawDefaults),
@@ -758,12 +738,12 @@ function basicSettingsPath(rawUrl) {
758
738
  return rawUrl.split('?')[0].replace(/\/+$/, '') || '/';
759
739
  }
760
740
  }
761
- export async function handleBasicSettingsApi(req, res, options = {}) {
741
+ export async function handleBasicSettingsApi(req, res) {
762
742
  try {
763
743
  const url = new URL(req.url || '', 'http://localhost');
764
744
  const pathname = basicSettingsPath(req.url || '');
765
745
  if (req.method === 'GET' && pathname === '/api/basic-settings') {
766
- sendJson(res, 200, { success: true, data: await readSnapshot(options) });
746
+ sendJson(res, 200, { success: true, data: await readSnapshot() });
767
747
  return;
768
748
  }
769
749
  if (req.method === 'GET' && pathname === '/api/basic-settings/secret') {
@@ -816,7 +796,7 @@ export async function handleBasicSettingsApi(req, res, options = {}) {
816
796
  changed: mutation.changed,
817
797
  restartRequired: mutation.changed && restartPaths.length > 0,
818
798
  restartPaths: mutation.changed ? restartPaths : [],
819
- data: await readSnapshot(options),
799
+ data: await readSnapshot(),
820
800
  });
821
801
  return;
822
802
  }
@@ -24,9 +24,11 @@ async function getParentModules() {
24
24
  async function getCommandModules() {
25
25
  const permissionPath = resolveParentDistModule('core', 'auth', 'operation-authorizer.js');
26
26
  const auditPath = resolveParentDistModule('core', 'auth', 'authorization-audit.js');
27
+ const runtimePolicyPath = resolveParentDistModule('core', 'role', 'runtime-policy.js');
27
28
  const permission = await import(toFileUrl(permissionPath));
28
29
  const audit = await import(toFileUrl(auditPath));
29
- return { permission, audit };
30
+ const runtimePolicy = await import(toFileUrl(runtimePolicyPath));
31
+ return { permission, audit, runtimePolicy };
30
32
  }
31
33
  export const ROLE_ASSIGNMENT_POLICY = {
32
34
  owner: {
@@ -38,15 +40,17 @@ export const ROLE_ASSIGNMENT_POLICY = {
38
40
  revoke: 'user-roles',
39
41
  },
40
42
  };
41
- function isManagementRoleName(role) {
42
- return role === 'owner' || role === 'admin';
43
- }
44
43
  function isAssignableUserRole(role) {
45
- return typeof role === 'string' && !!role && !isManagementRoleName(role);
44
+ // `admin` is a management role, but it is still a valid relation role.
45
+ // `owner` and `daemon-owner` remain reserved for process/Agent config and
46
+ // can never be assigned through a relation.
47
+ return typeof role === 'string' && !!role && role !== 'owner' && role !== 'daemon-owner';
46
48
  }
47
49
  export function canAssignRole(actorRole, targetRole) {
48
50
  if (!isAssignableUserRole(targetRole))
49
51
  return false;
52
+ if (targetRole === 'admin')
53
+ return actorRole === 'owner';
50
54
  return actorRole === 'owner' || actorRole === 'admin';
51
55
  }
52
56
  export function canRevokeRole(actorRole, targetRole) {
@@ -54,6 +58,8 @@ export function canRevokeRole(actorRole, targetRole) {
54
58
  return true;
55
59
  if (actorRole !== 'admin' || !targetRole)
56
60
  return false;
61
+ if (targetRole === 'admin')
62
+ return false;
57
63
  return isAssignableUserRole(targetRole);
58
64
  }
59
65
  async function getAgentsFromIpc() {
@@ -74,7 +80,8 @@ function resolveEcwebActor(modules, aid, auth) {
74
80
  const processOwners = Array.isArray(processConfig?.owners)
75
81
  ? processConfig.owners.map((value) => String(value || '').trim()).filter(Boolean)
76
82
  : [];
77
- const daemonOwner = !!auth.localDirect || (!!auth.actorAid && processOwners.includes(auth.actorAid));
83
+ const daemonOwner = auth.daemonOwner === true || !!auth.localDirect
84
+ || (!!auth.actorAid && processOwners.includes(auth.actorAid));
78
85
  const actorRole = daemonOwner
79
86
  ? 'owner'
80
87
  : auth.actorAid
@@ -89,11 +96,11 @@ function resolveEcwebActor(modules, aid, auth) {
89
96
  return {
90
97
  daemonOwner,
91
98
  actorRole,
92
- actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
99
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || (daemonOwner ? 'ecweb-remote' : undefined),
93
100
  };
94
101
  }
95
102
  async function authorizeRoleAssignmentRead(modules, aid, auth) {
96
- const { permission, audit } = await getCommandModules();
103
+ const { permission, audit, runtimePolicy } = await getCommandModules();
97
104
  const actor = resolveEcwebActor(modules, aid, auth);
98
105
  const decision = permission.authorizeCommand({
99
106
  intent: {
@@ -118,6 +125,7 @@ async function authorizeRoleAssignmentRead(modules, aid, auth) {
118
125
  actorId: actor.actorId,
119
126
  selfAid: aid,
120
127
  role: actor.actorRole,
128
+ permissionMode: runtimePolicy.resolveRuntimePermissionMode({ selfAid: aid, role: actor.actorRole }).effectiveValue,
121
129
  processRole: actor.daemonOwner ? 'daemon-owner' : 'none',
122
130
  isDaemonOwner: actor.daemonOwner,
123
131
  decision: decision.allow ? 'allow' : 'deny',
@@ -696,11 +704,17 @@ async function buildSnapshot(auth = {}) {
696
704
  if (!readDecision.allow)
697
705
  continue;
698
706
  const roleConfig = modules.roles.readRolesConfig(aid);
707
+ const availableRoles = Object.keys(roleConfig.roles || {});
708
+ // Management roles are resolved separately from user-role definitions.
709
+ // Include the assignable management role in the ECWeb snapshot so relation
710
+ // editors can offer it; owner remains reserved for config.json owners[].
711
+ if (roleExists(modules.roles, aid, 'admin'))
712
+ availableRoles.push('admin');
699
713
  snapshotAgents.push({
700
714
  aid,
701
715
  displayName: agent.displayName ?? agent.personalName,
702
716
  name: agent.name,
703
- roles: Object.keys(roleConfig.roles || {}).filter(role => isAssignableUserRole(role)),
717
+ roles: [...new Set(availableRoles)].filter(role => isAssignableUserRole(role)),
704
718
  defaultRoles: roleConfig.defaultRoles ?? { private: null, group: null },
705
719
  assignments: listRelationRoleAssignments(modules.config, aid),
706
720
  });
@@ -105,8 +105,8 @@ function isReadMethod(method) {
105
105
  async function authorizeRolePolicyOperation(modules, processConfig, auth, aid, operation, args) {
106
106
  const actor = auth.actorAid || '';
107
107
  const processOwners = Array.isArray(processConfig?.owners) ? processConfig.owners.map(String) : [];
108
- const syntheticOwner = !!auth.localDirect || (!!actor && processOwners.includes(actor));
109
- const actorId = auth.localDirect ? 'local-direct' : actor || undefined;
108
+ const syntheticOwner = isDaemonOwnerAuth(processConfig, auth);
109
+ const actorId = auth.localDirect ? 'local-direct' : actor || (syntheticOwner ? 'ecweb-remote' : undefined);
110
110
  const subject = modules.buildAuthSubject({
111
111
  selfAid: aid,
112
112
  actorId,
@@ -116,6 +116,7 @@ async function authorizeRolePolicyOperation(modules, processConfig, auth, aid, o
116
116
  chatType: 'private',
117
117
  conversationId: actor || 'ecweb',
118
118
  processOwners,
119
+ trustedProcessRole: syntheticOwner ? 'daemon-owner' : undefined,
119
120
  ...(syntheticOwner ? {
120
121
  roleDetail: {
121
122
  effectiveRole: 'owner',
@@ -123,8 +124,8 @@ async function authorizeRolePolicyOperation(modules, processConfig, auth, aid, o
123
124
  isAuthenticated: true,
124
125
  allowAccess: true,
125
126
  roleExists: true,
126
- // buildAuthSubject uses the authenticated actor separately from the
127
- // resolved relation role to determine daemon-owner provenance.
127
+ // Keep the token's actor provenance separate from the management
128
+ // authority established by the ECWeb authentication gate.
128
129
  actor: {
129
130
  operatorId: actorId || 'ecweb',
130
131
  operatorChannelKey: `ecweb#${aid}`,
@@ -155,7 +156,7 @@ async function authorizeRolePolicyOperation(modules, processConfig, auth, aid, o
155
156
  });
156
157
  }
157
158
  function isDaemonOwnerAuth(processConfig, auth) {
158
- if (auth.localDirect)
159
+ if (auth.daemonOwner === true || auth.localDirect)
159
160
  return true;
160
161
  const actor = typeof auth.actorAid === 'string' ? auth.actorAid.trim() : '';
161
162
  const processOwners = Array.isArray(processConfig?.owners)
@@ -205,12 +205,12 @@ function listProjects() {
205
205
  if (!db)
206
206
  return [];
207
207
  try {
208
- const rows = db.prepare(`
209
- SELECT cwd, COUNT(*) as count, MAX(updated_at) as lastActivity
210
- FROM threads
211
- WHERE archived = 0
212
- GROUP BY cwd
213
- ORDER BY lastActivity DESC
208
+ const rows = db.prepare(`
209
+ SELECT cwd, COUNT(*) as count, MAX(updated_at) as lastActivity
210
+ FROM threads
211
+ WHERE archived = 0
212
+ GROUP BY cwd
213
+ ORDER BY lastActivity DESC
214
214
  `).all();
215
215
  const projects = rows.map(r => {
216
216
  const cwd = r.cwd || '';
@@ -382,11 +382,11 @@ function listTranscripts(encoded, cwd) {
382
382
  if (!db)
383
383
  return [];
384
384
  try {
385
- const rows = db.prepare(`
386
- SELECT id, title, first_user_message, updated_at, rollout_path, source, thread_source
387
- FROM threads
388
- WHERE cwd = ? AND archived = 0
389
- ORDER BY updated_at DESC
385
+ const rows = db.prepare(`
386
+ SELECT id, title, first_user_message, updated_at, rollout_path, source, thread_source
387
+ FROM threads
388
+ WHERE cwd = ? AND archived = 0
389
+ ORDER BY updated_at DESC
390
390
  `).all(cwd);
391
391
  const out = [];
392
392
  let parsed = 0, hitMem = 0, hitDisk = 0;