ec-web 0.0.24 → 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/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), remoteAccess: !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,18 +738,15 @@ 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') {
770
- if (options.remoteAccess) {
771
- throw Object.assign(new Error('Remote access cannot reveal configuration secrets'), { statusCode: 403 });
772
- }
773
750
  const file = url.searchParams.get('file') || 'defaults';
774
751
  const fieldPath = url.searchParams.get('path') || '';
775
752
  if (!requestedSecretPath(file, fieldPath)) {
@@ -785,9 +762,6 @@ export async function handleBasicSettingsApi(req, res, options = {}) {
785
762
  return;
786
763
  }
787
764
  if (req.method === 'PATCH' && pathname === '/api/basic-settings') {
788
- if (options.remoteAccess) {
789
- throw Object.assign(new Error('Remote access cannot mutate basic settings'), { statusCode: 403 });
790
- }
791
765
  const body = await readJsonBody(req);
792
766
  if (!isRecord(body) || (body.file !== 'daemon' && body.file !== 'defaults'))
793
767
  throw new Error('file must be daemon or defaults');
@@ -822,7 +796,7 @@ export async function handleBasicSettingsApi(req, res, options = {}) {
822
796
  changed: mutation.changed,
823
797
  restartRequired: mutation.changed && restartPaths.length > 0,
824
798
  restartPaths: mutation.changed ? restartPaths : [],
825
- data: await readSnapshot(options),
799
+ data: await readSnapshot(),
826
800
  });
827
801
  return;
828
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)
@@ -13,6 +13,7 @@ export function watchMenuAuth(params) {
13
13
  return {
14
14
  localDirect: auth?.localDirect === true,
15
15
  actorAid: typeof auth?.actorAid === 'string' ? auth.actorAid : null,
16
+ daemonOwner: auth?.daemonOwner === true,
16
17
  // This marker is injected by the ECWeb server, never read from browser
17
18
  // request parameters. Authenticated ECWeb has daemon-owner authority.
18
19
  canApprovePersistentFullAccess: auth?.canApprovePersistentFullAccess === true,
@@ -9296,7 +9296,7 @@ function assignedRoleFor(scope, item) {
9296
9296
  function assignableRolesForAgent(agentAid) {
9297
9297
  const agent = (state.roles?.agents || []).find(item => item.aid === agentAid);
9298
9298
  const roles = agent?.roles || Object.keys(state.roleDefinitions?.roles || {});
9299
- return Array.from(new Set(roles)).filter(role => role !== 'owner' && role !== 'admin');
9299
+ return Array.from(new Set(roles)).filter(role => role !== 'owner');
9300
9300
  }
9301
9301
 
9302
9302
  function memberMatchesSearch(member) {
@@ -49,7 +49,7 @@
49
49
 
50
50
  </nav>
51
51
  <span style="flex:1"></span>
52
- 2026-09-14 01:48
52
+ <span class="build-ts" style="font-size:10px;color:var(--dim)" data-i18n-title="common.buildTime" title="构建时间">2026-09-18 10:34</span>
53
53
  <span id="conn-status" class="conn-status" data-i18n="status.connecting">连接中…</span>
54
54
  <button id="lang-btn" class="theme-btn" data-i18n-title="language.buttonTitle" title="切换语言">🌐</button>
55
55
  <button id="theme-btn" class="theme-btn" data-i18n-title="theme.buttonTitle" title="主题">🌗</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ec-web",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "Web console and monitoring dashboard for EvolCore",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "dist/"
11
11
  ],
12
12
  "scripts": {
13
- "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc && node -e \"try{require('child_process').execFileSync('chmod',['+x','dist/index.js'])}catch{}\" && node -e \"const fs=require('fs'),path=require('path');fs.cpSync('src/static','dist/static',{recursive:true});const f='dist/static/index.html',n=new Date(),ts=n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0')+'-'+String(n.getDate()).padStart(2,'0')+' '+String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0');fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace(/(\\<span class=\\\"build-ts\\\"[^>]*\\>)[^<]+(\\<\\/span\\>)/,'$1'+ts+'$2'))\"",
13
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc && node -e \"try{require('child_process').execFileSync('chmod',['+x','dist/index.js'])}catch{}\" && node -e \"const fs=require('fs'),path=require('path');fs.cpSync('src/static','dist/static',{recursive:true});const f='dist/static/index.html',n=new Date(),ts=n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0')+'-'+String(n.getDate()).padStart(2,'0')+' '+String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0');fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace(/(\\<span class=\\\"build-ts\\\"[^>]*\\>)[^<]+(\\<\\/span\\>)/,(_match,open,close)=>open+ts+close))\"",
14
14
  "dev": "node dev.mjs",
15
15
  "prepublishOnly": "npm run build"
16
16
  },