evolcore 0.0.4 → 0.0.5
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/CHANGELOG.md +22 -3
- package/dist/cli/daemon-commands.js +46 -15
- package/dist/cli/init.js +42 -22
- package/dist/config/access-policy-domain.js +0 -20
- package/dist/config/access-policy.js +5 -29
- package/dist/core/auth/operation-catalog.js +1 -1
- package/dist/core/command/connect-menu.js +20 -42
- package/dist/core/command/role-menu.js +16 -0
- package/dist/core/message/inbound-admission.js +23 -13
- package/dist/core/session/session-fs-store.js +19 -4
- package/dist/core/session/session-manager.js +207 -1
- package/dist/utils/cross-platform.js +8 -2
- package/dist/utils/process-introspect.js +19 -3
- package/kits/docs/evolcore/config.md +4 -4
- package/kits/docs/evolcore/contact.md +2 -1
- package/kits/schemas/agent-config.schema.9.json +2 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
本文件记录 EvolCore 的重要变更。EvolClaw 版本线的历史记录已归档至
|
|
4
4
|
[`docs/_archive/CHANGELOG-evolclaw.md`](docs/_archive/CHANGELOG-evolclaw.md)。
|
|
5
5
|
|
|
6
|
+
## 0.0.5 (2026-07-31)
|
|
7
|
+
|
|
8
|
+
### 消息准入
|
|
9
|
+
|
|
10
|
+
- 群聊准入统一为开放、联系人、Owner 三种行为,移除额外的渠道白名单配置。
|
|
11
|
+
- 访问策略的修改权限收归 Agent Owner。
|
|
12
|
+
- 完善联系人准入、拉黑与 Owner 权限边界的判定。
|
|
13
|
+
- Connect 群组入口展示实时群组数量。
|
|
14
|
+
|
|
15
|
+
### 运行可靠性
|
|
16
|
+
|
|
17
|
+
- 修复旧版本遗留的 AUN 会话目录身份恢复:可唯一判定身份时安全迁移,无法判定时保持原样并告警。
|
|
18
|
+
- 修正 Linux 容器内进程启动时间与运行时长的计算偏差。
|
|
19
|
+
|
|
20
|
+
### 初始化体验
|
|
21
|
+
|
|
22
|
+
- 优化初始化向导的目录输入,支持家目录简写、错误重试与显式跳过。
|
|
23
|
+
- 未完成初始化时自动运行向导,完成后继续启动服务。
|
|
24
|
+
- 未配置 self-agent 时按实际可用入口给出创建提示。
|
|
25
|
+
|
|
6
26
|
## 0.0.4 (2026-07-30)
|
|
7
27
|
|
|
8
28
|
### 联系人身份与消息准入
|
|
@@ -13,9 +33,8 @@
|
|
|
13
33
|
- 新增联系人请求服务,申请与状态流转改为原子操作,避免并发请求产生不一致。
|
|
14
34
|
- 限制单实例和进程级待绑定请求数量,并限制无效绑定码的连续尝试次数。
|
|
15
35
|
- 拒绝对不存在的联系人执行身份绑定和状态变更。
|
|
16
|
-
- 群聊准入按 `access.policyMode` 分级:`open`
|
|
17
|
-
|
|
18
|
-
既有 Agent 走 `open` 兼容视图,升级后群聊不会因白名单为空被拒。
|
|
36
|
+
- 群聊准入按 `access.policyMode` 分级:`open` 对所有已配置渠道开放,`contacts` 将 Agent 已加入并接收到的
|
|
37
|
+
群聊视为授权,`owners` 仅 Owner 消息开放。
|
|
19
38
|
|
|
20
39
|
### 菜单与授权
|
|
21
40
|
|
|
@@ -29,6 +29,22 @@ import { AGENT_DELEGATION_TOKEN_ENV } from '../core/auth/agent-delegation.js';
|
|
|
29
29
|
import { WEB_CLI_BIN, WEB_PACKAGE_LATEST, WEB_PACKAGE_NAME } from '../product.js';
|
|
30
30
|
import { rotateStdoutLog } from '../utils/log-writer.js';
|
|
31
31
|
const execFileAsync = promisify(execFile);
|
|
32
|
+
function printNoSelfAgentHints(options) {
|
|
33
|
+
const { daemonConfig, ecwebStarted, skipped } = options;
|
|
34
|
+
console.log('\nℹ 未配置任何 self-agent,Control Plane 已启动。');
|
|
35
|
+
console.log(' 命令行:ec agent new <aid>.agentid.pub');
|
|
36
|
+
if (daemonConfig.aid && (daemonConfig.owners?.length ?? 0) > 0) {
|
|
37
|
+
console.log(' Evol App:可通过进程级菜单创建 agent');
|
|
38
|
+
}
|
|
39
|
+
if (ecwebStarted) {
|
|
40
|
+
console.log(' ECWeb:可通过控制台创建 agent');
|
|
41
|
+
}
|
|
42
|
+
if (skipped.length > 0) {
|
|
43
|
+
console.log('跳过的目录:');
|
|
44
|
+
for (const skippedAgent of skipped)
|
|
45
|
+
console.log(` - ${skippedAgent.dirName}: ${skippedAgent.reason}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
32
48
|
async function probeDaemon(socketPath, timeoutMs = 1000) {
|
|
33
49
|
const response = await ipcQuery(socketPath, { type: 'ping' }, timeoutMs);
|
|
34
50
|
if (response?.pong !== true || !Number.isInteger(response.pid) || response.pid <= 0)
|
|
@@ -196,8 +212,16 @@ export async function cmdStart(opts = {}) {
|
|
|
196
212
|
}
|
|
197
213
|
else {
|
|
198
214
|
console.log('⚡ 未检测到初始化配置,自动启动初始化向导...\n');
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
// cmdStart 直接调用 cmdInit,不会经过 CLI 的 `init` 分发分支;
|
|
216
|
+
// 这里需显式抑制 AUN SDK 的常规 keystore 日志。
|
|
217
|
+
const { suppressSdkLogs } = await import('../aun/aid/index.js');
|
|
218
|
+
suppressSdkLogs();
|
|
219
|
+
await cmdInit({ invokedByStart: true });
|
|
220
|
+
if (!loadDefaults()) {
|
|
221
|
+
console.log('⚠ 初始化未完成,未启动 EvolCore。');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
console.log('\n初始化完成,开始启动 EvolCore ....');
|
|
201
225
|
}
|
|
202
226
|
}
|
|
203
227
|
// 控制 AID 门禁:缺 aid 且交互式 → 只补全控制 AID + owners(不重走 baseagent 向导)。
|
|
@@ -236,19 +260,12 @@ export async function cmdStart(opts = {}) {
|
|
|
236
260
|
}
|
|
237
261
|
// 检查至少有一个 self-agent
|
|
238
262
|
const { agents, skipped } = loadAllAgents();
|
|
263
|
+
const isControlAidBootstrap = opts.bindBootstrap && !!daemonCfgStart.aid;
|
|
264
|
+
const shouldPrintNoSelfAgentHints = agents.length === 0 && !isControlAidBootstrap;
|
|
239
265
|
if (agents.length === 0) {
|
|
240
|
-
if (
|
|
266
|
+
if (isControlAidBootstrap) {
|
|
241
267
|
console.log('ℹ 未配置任何 self-agent,绑定 bootstrap 将仅启动控制 AID');
|
|
242
268
|
}
|
|
243
|
-
else {
|
|
244
|
-
console.log('ℹ 未配置任何 self-agent,将仅启动 Control Plane。');
|
|
245
|
-
console.log(' 可通过控制 AID 远程创建 agent,或稍后运行 ec agent new <aid>.agentid.pub');
|
|
246
|
-
if (skipped.length > 0) {
|
|
247
|
-
console.log(`跳过的目录:`);
|
|
248
|
-
for (const s of skipped)
|
|
249
|
-
console.log(` - ${s.dirName}: ${s.reason}`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
269
|
}
|
|
253
270
|
// 检查 instance 目录中的进程状态
|
|
254
271
|
const status = scanInstances();
|
|
@@ -323,10 +340,22 @@ export async function cmdStart(opts = {}) {
|
|
|
323
340
|
console.log(` Logs: ${p.logs}/`);
|
|
324
341
|
console.log(`⏱ ready in ${((Date.now() - cmdStartedAt) / 1000).toFixed(1)}s`);
|
|
325
342
|
if (!ecwebStartedBeforeDaemon) {
|
|
326
|
-
startEcwebIfEnabled(p)
|
|
343
|
+
startEcwebIfEnabled(p)
|
|
344
|
+
.then((ecwebStarted) => {
|
|
345
|
+
if (shouldPrintNoSelfAgentHints) {
|
|
346
|
+
printNoSelfAgentHints({ daemonConfig: loadDaemonConfig(), ecwebStarted, skipped });
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
.catch((err) => {
|
|
327
350
|
console.error(`⚠ ECWeb 启动检查失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
351
|
+
if (shouldPrintNoSelfAgentHints) {
|
|
352
|
+
printNoSelfAgentHints({ daemonConfig: loadDaemonConfig(), ecwebStarted: false, skipped });
|
|
353
|
+
}
|
|
328
354
|
});
|
|
329
355
|
}
|
|
356
|
+
else if (shouldPrintNoSelfAgentHints) {
|
|
357
|
+
printNoSelfAgentHints({ daemonConfig: loadDaemonConfig(), ecwebStarted: true, skipped });
|
|
358
|
+
}
|
|
330
359
|
}, 500);
|
|
331
360
|
let forwardingShutdown = false;
|
|
332
361
|
const forwardSignal = (signal) => {
|
|
@@ -426,8 +455,10 @@ export async function cmdStart(opts = {}) {
|
|
|
426
455
|
}
|
|
427
456
|
console.log(`⏱ done in ${((Date.now() - cmdStartedAt) / 1000).toFixed(1)}s`);
|
|
428
457
|
// ECWeb 自动后台启动
|
|
429
|
-
|
|
430
|
-
|
|
458
|
+
const ecwebStarted = ecwebStartedBeforeDaemon || await startEcwebIfEnabled(p);
|
|
459
|
+
if (shouldPrintNoSelfAgentHints) {
|
|
460
|
+
printNoSelfAgentHints({ daemonConfig: loadDaemonConfig(), ecwebStarted, skipped });
|
|
461
|
+
}
|
|
431
462
|
return;
|
|
432
463
|
}
|
|
433
464
|
// 超时
|
package/dist/cli/init.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import readline from 'readline';
|
|
4
5
|
import { resolvePaths, ensureDataDirs } from '../paths.js';
|
|
@@ -13,6 +14,15 @@ import { WEB_CLI_BIN, WEB_PACKAGE_LATEST } from '../product.js';
|
|
|
13
14
|
function ask(rl, question) {
|
|
14
15
|
return new Promise(resolve => rl.question(question, resolve));
|
|
15
16
|
}
|
|
17
|
+
/** 展开用户在交互式输入中使用的 home 目录简写(shell 不会替 readline 自动做这件事)。 */
|
|
18
|
+
export function expandLeadingTildePath(value, homeDir = os.homedir()) {
|
|
19
|
+
if (value === '~')
|
|
20
|
+
return homeDir;
|
|
21
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) {
|
|
22
|
+
return path.join(homeDir, value.slice(2));
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
16
26
|
const BASEAGENT_CANDIDATES = ['claude', 'codex', 'gemini'];
|
|
17
27
|
function isBaseagentAvailable(baseagent) {
|
|
18
28
|
if (baseagent === 'codex')
|
|
@@ -167,7 +177,7 @@ export async function cmdInit(options) {
|
|
|
167
177
|
await runInteractive();
|
|
168
178
|
}
|
|
169
179
|
// ── 共享 tail(单一出口):提示创建 agent + 生成控制 AID ──
|
|
170
|
-
await initTail();
|
|
180
|
+
await initTail({ invokedByStart: options?.invokedByStart });
|
|
171
181
|
// ── 内部函数 ──
|
|
172
182
|
async function runInteractive() {
|
|
173
183
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -194,29 +204,36 @@ export async function cmdInit(options) {
|
|
|
194
204
|
}
|
|
195
205
|
async function askProjectsDefaultPath() {
|
|
196
206
|
const defaultDir = path.join(defaultProjectsRoot(p.root), 'default');
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
while (true) {
|
|
208
|
+
const input = (await ask(rl, `项目默认目录 [${defaultDir}](输入 skip 跳过): `)).trim();
|
|
209
|
+
if (input.toLowerCase() === 'skip') {
|
|
210
|
+
console.log(' 已跳过项目默认目录配置');
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
const resolved = expandLeadingTildePath(input || defaultDir);
|
|
214
|
+
if (!path.isAbsolute(resolved)) {
|
|
215
|
+
console.log(' ⚠ 请输入绝对路径或回车使用默认值;输入 skip 可跳过');
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!fs.existsSync(resolved)) {
|
|
219
|
+
const create = (await ask(rl, ` 目录不存在,是否创建?[Y/n]: `)).trim().toLowerCase();
|
|
220
|
+
if (create === '' || create === 'y' || create === 'yes') {
|
|
221
|
+
try {
|
|
222
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
console.log(` ⚠ 创建目录失败: ${e?.message || e}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
console.log(` ✓ 已创建 ${resolved}`);
|
|
208
229
|
}
|
|
209
|
-
|
|
210
|
-
console.log(
|
|
211
|
-
|
|
230
|
+
else {
|
|
231
|
+
console.log(' 未创建目录,请重新输入路径;输入 skip 可跳过');
|
|
232
|
+
continue;
|
|
212
233
|
}
|
|
213
|
-
console.log(` ✓ 已创建 ${resolved}`);
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
return undefined;
|
|
217
234
|
}
|
|
235
|
+
return resolved;
|
|
218
236
|
}
|
|
219
|
-
return resolved;
|
|
220
237
|
}
|
|
221
238
|
try {
|
|
222
239
|
if (defaultsExisted) {
|
|
@@ -249,10 +266,10 @@ export async function cmdInit(options) {
|
|
|
249
266
|
}
|
|
250
267
|
}
|
|
251
268
|
/** 补全控制 AID + owners(可单独调用,不走 baseagent 向导)。 */
|
|
252
|
-
export async function initTail() {
|
|
269
|
+
export async function initTail(options = {}) {
|
|
253
270
|
// 提示创建 agent(两分支汇合后执行一次)
|
|
254
271
|
const { agents } = loadAllAgents();
|
|
255
|
-
if (agents.length === 0) {
|
|
272
|
+
if (agents.length === 0 && !options.invokedByStart) {
|
|
256
273
|
console.log('\n提示:尚无 agent,运行以下命令创建:');
|
|
257
274
|
console.log(' ec agent new <aid>.agentid.pub');
|
|
258
275
|
}
|
|
@@ -368,6 +385,9 @@ export async function initTail() {
|
|
|
368
385
|
}
|
|
369
386
|
}
|
|
370
387
|
}
|
|
388
|
+
// ec start 会接着启动 daemon,并在其 ready 后给出可用的 Agent 创建入口。
|
|
389
|
+
if (options.invokedByStart)
|
|
390
|
+
return;
|
|
371
391
|
// 初始化完成总结
|
|
372
392
|
console.log('\n✓ EvolCore 初始化完成');
|
|
373
393
|
const finalCfg = loadDaemonConfig();
|
|
@@ -4,18 +4,6 @@ export function validAgentOwnerIds(agent) {
|
|
|
4
4
|
.map(value => String(value || '').trim())
|
|
5
5
|
.filter(isValidAid))];
|
|
6
6
|
}
|
|
7
|
-
export function configuredChannelKeys(selfAid, agent) {
|
|
8
|
-
const keys = (agent?.channels ?? [])
|
|
9
|
-
.filter(channel => channel && channel.enabled !== false)
|
|
10
|
-
.map(channel => {
|
|
11
|
-
const type = String(channel.type || '').trim().toLowerCase();
|
|
12
|
-
const name = String(channel.name || '').trim();
|
|
13
|
-
return type && name ? `${type}#${selfAid}#${name}` : '';
|
|
14
|
-
})
|
|
15
|
-
.filter(Boolean);
|
|
16
|
-
keys.push(`aun#${selfAid}#main`);
|
|
17
|
-
return [...new Set(keys)];
|
|
18
|
-
}
|
|
19
7
|
export function validateAccessPolicyDomain(selfAid, agent) {
|
|
20
8
|
const access = agent.access;
|
|
21
9
|
if (!access)
|
|
@@ -26,13 +14,5 @@ export function validateAccessPolicyDomain(selfAid, agent) {
|
|
|
26
14
|
message: `Agent ${selfAid} cannot use ${access.policyMode} without an Owner`,
|
|
27
15
|
};
|
|
28
16
|
}
|
|
29
|
-
const configured = new Set(configuredChannelKeys(selfAid, agent));
|
|
30
|
-
const unknown = access.allowedGroupChannels.find(channelKey => !configured.has(channelKey));
|
|
31
|
-
if (unknown) {
|
|
32
|
-
return {
|
|
33
|
-
code: 'unknown_group_access_channel',
|
|
34
|
-
message: `Group access channel is not configured or enabled for ${selfAid}: ${unknown}`,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
17
|
return undefined;
|
|
38
18
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
import { ConfigError, ConfigTarget, mutateConfig, read, validateConfig, } from './config-manager.js';
|
|
3
|
-
import {
|
|
3
|
+
import { validAgentOwnerIds, validateAccessPolicyDomain, } from './access-policy-domain.js';
|
|
4
4
|
export class AccessPolicyError extends Error {
|
|
5
5
|
code;
|
|
6
6
|
data;
|
|
@@ -13,20 +13,13 @@ export class AccessPolicyError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
export const DEFAULT_ACCESS_POLICY = Object.freeze({
|
|
15
15
|
policyMode: 'open',
|
|
16
|
-
allowedGroupChannels: Object.freeze([]),
|
|
17
16
|
requestLimit: Object.freeze({ maxTimes: 600, coolDown: 60 * 60 }),
|
|
18
17
|
});
|
|
19
18
|
export function listAgentOwners(selfAid) {
|
|
20
19
|
const agent = read(ConfigTarget.Agent, { self: selfAid }, { cache: true });
|
|
21
20
|
return validAgentOwnerIds(agent);
|
|
22
21
|
}
|
|
23
|
-
|
|
24
|
-
const agent = read(ConfigTarget.Agent, { self: selfAid }, { cache: true });
|
|
25
|
-
if (!agent)
|
|
26
|
-
return [];
|
|
27
|
-
return configuredChannelKeys(selfAid, agent);
|
|
28
|
-
}
|
|
29
|
-
/** Missing AgentConfig.access uses the compatibility view; malformed Agent config never does. */
|
|
22
|
+
/** Missing AgentConfig.access uses the default open policy; malformed Agent config never does. */
|
|
30
23
|
export function readAccessPolicy(selfAid) {
|
|
31
24
|
return readAccessPolicySnapshot(selfAid).policy;
|
|
32
25
|
}
|
|
@@ -49,7 +42,7 @@ export function mutateAccessPolicy(input) {
|
|
|
49
42
|
});
|
|
50
43
|
}
|
|
51
44
|
const next = input.update(clonePolicy(current.policy));
|
|
52
|
-
assertAccessPolicyWrite(input, agent,
|
|
45
|
+
assertAccessPolicyWrite(input, agent, next);
|
|
53
46
|
agent.access = clonePolicy(next);
|
|
54
47
|
return {
|
|
55
48
|
policy: clonePolicy(next),
|
|
@@ -85,37 +78,20 @@ function accessPolicySnapshotFromAgent(selfAid, agent) {
|
|
|
85
78
|
const policy = clonePolicy(access);
|
|
86
79
|
return { policy, accessRevision: accessPolicyRevision(policy) };
|
|
87
80
|
}
|
|
88
|
-
function assertAccessPolicyWrite(input, agent,
|
|
81
|
+
function assertAccessPolicyWrite(input, agent, policy) {
|
|
89
82
|
const owners = validAgentOwnerIds(agent);
|
|
90
83
|
const normalizedActorAid = String(input.actorAid || '').trim();
|
|
91
84
|
const isOwner = input.actorRole === 'owner' || owners.includes(normalizedActorAid);
|
|
92
|
-
|
|
93
|
-
if (!isOwner && !isGroupChannelAdmin) {
|
|
94
|
-
if (input.resource === 'access.allowedGroupChannels') {
|
|
95
|
-
throw new AccessPolicyError('access_policy_manager_required', 'Only an Agent Owner or Admin can change allowed group channels');
|
|
96
|
-
}
|
|
85
|
+
if (!isOwner) {
|
|
97
86
|
throw new AccessPolicyError('access_policy_owner_required', 'Only an Agent Owner can change access policy');
|
|
98
87
|
}
|
|
99
|
-
if (isGroupChannelAdmin) {
|
|
100
|
-
if (current.policyMode !== policy.policyMode
|
|
101
|
-
|| current.requestLimit.maxTimes !== policy.requestLimit.maxTimes
|
|
102
|
-
|| current.requestLimit.coolDown !== policy.requestLimit.coolDown) {
|
|
103
|
-
throw new AccessPolicyError('access_policy_owner_required', 'An Agent Admin may only change allowed group channels');
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
88
|
if (policy.policyMode !== 'open' && owners.length === 0) {
|
|
107
89
|
throw new AccessPolicyError('restricted_mode_without_owner', `Agent ${input.selfAid} cannot use ${policy.policyMode} without an Owner`);
|
|
108
90
|
}
|
|
109
|
-
const configured = new Set(configuredChannelKeys(input.selfAid, agent));
|
|
110
|
-
const unknown = policy.allowedGroupChannels.find(channelKey => !configured.has(channelKey));
|
|
111
|
-
if (unknown) {
|
|
112
|
-
throw new AccessPolicyError('unknown_group_access_channel', `Group access channel is not configured or enabled for ${input.selfAid}: ${unknown}`);
|
|
113
|
-
}
|
|
114
91
|
}
|
|
115
92
|
function clonePolicy(policy) {
|
|
116
93
|
return {
|
|
117
94
|
policyMode: policy.policyMode,
|
|
118
|
-
allowedGroupChannels: [...policy.allowedGroupChannels],
|
|
119
95
|
requestLimit: { ...policy.requestLimit },
|
|
120
96
|
};
|
|
121
97
|
}
|
|
@@ -397,7 +397,7 @@ const OPERATIONS = [
|
|
|
397
397
|
category: 'write-agent',
|
|
398
398
|
dangerous: false,
|
|
399
399
|
defaultScopes: ['agent'],
|
|
400
|
-
description: 'Manage contacts
|
|
400
|
+
description: 'Manage contacts and contact requests',
|
|
401
401
|
sources: ['menu', 'ecweb', 'control'],
|
|
402
402
|
},
|
|
403
403
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { roleMenuQuery, roleMenuUpdate, } from './role-menu.js';
|
|
1
|
+
import { roleMenuQuery, roleMenuGroupCount, roleMenuUpdate, } from './role-menu.js';
|
|
2
2
|
import { getContactSnapshot, resolveContactView } from '../../config/contact-book.js';
|
|
3
|
-
import {
|
|
3
|
+
import { mutateAccessPolicy, readAccessPolicySnapshot, } from '../../config/access-policy.js';
|
|
4
4
|
import { expirePendingContactRequests, mutateContactWithOperation, reviewContactRequest, } from '../../config/contact-request-service.js';
|
|
5
5
|
import { formatPeerKey, parsePeerKey } from '../relation/peer-identity.js';
|
|
6
6
|
/**
|
|
@@ -13,8 +13,8 @@ import { formatPeerKey, parsePeerKey } from '../relation/peer-identity.js';
|
|
|
13
13
|
* 群组不落 contact.json(contact book 只接受个人 AID,group 会被拒绝)。
|
|
14
14
|
* - 联系人与申请(view=contacts / contact,add / block / unblock / review)→ 直接读写
|
|
15
15
|
* Contact Book v3;contact.json 是当前状态和 pending 版本的唯一事实来源。
|
|
16
|
-
* - 访问设置(view=access /
|
|
17
|
-
*
|
|
16
|
+
* - 访问设置(view=access / resource=<access field>)→
|
|
17
|
+
* 查询和写入 Agent config.json 的 access 字段。
|
|
18
18
|
*/
|
|
19
19
|
const MENU_NAME = 'connect';
|
|
20
20
|
const DEFAULT_PAGE_SIZE = 50;
|
|
@@ -31,8 +31,6 @@ export function connectMenuOperation(kind, args, value) {
|
|
|
31
31
|
if (kind === 'action')
|
|
32
32
|
return 'connect.write';
|
|
33
33
|
const resource = String(args?.resource ?? '');
|
|
34
|
-
if (resource === 'allowedGroupChannels')
|
|
35
|
-
return 'connect.write';
|
|
36
34
|
if (resource === 'policyMode' || resource === 'requestLimit') {
|
|
37
35
|
return 'connect.access.write';
|
|
38
36
|
}
|
|
@@ -55,7 +53,7 @@ export async function handleConnectMenu(req, context) {
|
|
|
55
53
|
}
|
|
56
54
|
try {
|
|
57
55
|
if (req.subtype === 'options')
|
|
58
|
-
return await handleOptions(self, req.args);
|
|
56
|
+
return await handleOptions(self, req.args, context);
|
|
59
57
|
if (req.subtype === 'query')
|
|
60
58
|
return await handleQuery(req, self, context);
|
|
61
59
|
if (req.subtype === 'update')
|
|
@@ -69,26 +67,24 @@ export async function handleConnectMenu(req, context) {
|
|
|
69
67
|
return fail('INVALID_ARGUMENT', `Unknown subtype: ${req.subtype}`);
|
|
70
68
|
}
|
|
71
69
|
// ── Options ──
|
|
72
|
-
async function handleOptions(self, args) {
|
|
70
|
+
async function handleOptions(self, args, context) {
|
|
73
71
|
const option = String(args?.option ?? '').trim();
|
|
74
72
|
if (option) {
|
|
75
|
-
|
|
76
|
-
return fail('NOT_SUPPORTED', `Unknown connect option: ${option}`);
|
|
77
|
-
}
|
|
78
|
-
const selected = new Set(readAccessPolicySnapshot(self).policy.allowedGroupChannels);
|
|
79
|
-
return ok({
|
|
80
|
-
options: listConfiguredChannelKeys(self).map(channelKey => ({
|
|
81
|
-
option: channelKey,
|
|
82
|
-
label: channelKey,
|
|
83
|
-
selected: selected.has(channelKey),
|
|
84
|
-
})),
|
|
85
|
-
});
|
|
73
|
+
return fail('NOT_SUPPORTED', `Unknown connect option: ${option}`);
|
|
86
74
|
}
|
|
87
75
|
await expirePendingContactRequests(self);
|
|
88
76
|
const snapshot = getContactSnapshot(self);
|
|
89
77
|
const contactCount = snapshot.contacts.size;
|
|
90
78
|
const blockedCount = snapshot.blockedIndex.size;
|
|
91
79
|
const pendingCount = [...snapshot.contacts.values()].filter(entry => entry.status === 'pending').length;
|
|
80
|
+
let groupsDescription = '查看所在群组(数量暂不可用)';
|
|
81
|
+
try {
|
|
82
|
+
const groupCount = await roleMenuGroupCount(context);
|
|
83
|
+
groupsDescription = `查看所在群组(${groupCount} 个)`;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Keep the root menu available when AUN group directory is disabled or unavailable.
|
|
87
|
+
}
|
|
92
88
|
return ok({
|
|
93
89
|
options: [
|
|
94
90
|
{
|
|
@@ -99,12 +95,12 @@ async function handleOptions(self, args) {
|
|
|
99
95
|
{
|
|
100
96
|
option: 'groups',
|
|
101
97
|
label: '群组列表',
|
|
102
|
-
description:
|
|
98
|
+
description: groupsDescription,
|
|
103
99
|
},
|
|
104
100
|
{
|
|
105
101
|
option: 'access',
|
|
106
102
|
label: '访问设置',
|
|
107
|
-
description: '
|
|
103
|
+
description: '设置消息准入模式和准入控制消息限额',
|
|
108
104
|
},
|
|
109
105
|
],
|
|
110
106
|
});
|
|
@@ -268,15 +264,11 @@ async function handleAction(req, self, context) {
|
|
|
268
264
|
}
|
|
269
265
|
function isAccessUpdateResource(resource) {
|
|
270
266
|
return resource === 'policyMode'
|
|
271
|
-
|| resource === 'allowedGroupChannels'
|
|
272
267
|
|| resource === 'requestLimit';
|
|
273
268
|
}
|
|
274
269
|
function updateAccessResource(req, context, resource) {
|
|
275
270
|
const args = req.args ?? {};
|
|
276
|
-
|
|
277
|
-
requireAdminContext(context);
|
|
278
|
-
else
|
|
279
|
-
requireOwnerContext(context);
|
|
271
|
+
requireOwnerContext(context);
|
|
280
272
|
if (req.value === undefined || !req.value.trim()) {
|
|
281
273
|
return fail('INVALID_ARGUMENT', 'value is required');
|
|
282
274
|
}
|
|
@@ -284,15 +276,8 @@ function updateAccessResource(req, context, resource) {
|
|
|
284
276
|
const expectedAccessRevision = requireAccessRevision(args);
|
|
285
277
|
const decoded = decodeAccessUpdateValue(req.value);
|
|
286
278
|
if (resource === 'policyMode') {
|
|
287
|
-
if (typeof decoded !== 'string' || !['open', 'contacts', 'owners
|
|
288
|
-
return fail('INVALID_ARGUMENT', 'policyMode must be open, contacts, or owners
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
else if (resource === 'allowedGroupChannels') {
|
|
292
|
-
if (!Array.isArray(decoded)
|
|
293
|
-
|| !decoded.every(value => typeof value === 'string' && value.length > 0)
|
|
294
|
-
|| new Set(decoded).size !== decoded.length) {
|
|
295
|
-
return fail('INVALID_ARGUMENT', 'allowedGroupChannels must be a JSON string array');
|
|
279
|
+
if (typeof decoded !== 'string' || !['open', 'contacts', 'owners'].includes(decoded)) {
|
|
280
|
+
return fail('INVALID_ARGUMENT', 'policyMode must be open, contacts, or owners');
|
|
296
281
|
}
|
|
297
282
|
}
|
|
298
283
|
else {
|
|
@@ -452,16 +437,12 @@ function errorCode(error) {
|
|
|
452
437
|
return 'INTERNAL_ERROR';
|
|
453
438
|
if (code === 'access_policy_owner_required')
|
|
454
439
|
return 'PERMISSION_DENIED';
|
|
455
|
-
if (code === 'access_policy_manager_required')
|
|
456
|
-
return 'PERMISSION_DENIED';
|
|
457
440
|
if (code === 'access_policy_revision_conflict')
|
|
458
441
|
return 'CONFLICT';
|
|
459
442
|
if (code === 'access_policy_temporarily_unavailable')
|
|
460
443
|
return 'TEMPORARILY_UNAVAILABLE';
|
|
461
444
|
if (code === 'restricted_mode_without_owner')
|
|
462
445
|
return 'NOT_ALLOWED';
|
|
463
|
-
if (code === 'unknown_group_access_channel')
|
|
464
|
-
return 'INVALID_ARGUMENT';
|
|
465
446
|
if (code === 'invalid_access_policy')
|
|
466
447
|
return 'INTERNAL_ERROR';
|
|
467
448
|
return typeof code === 'string' ? code : 'INTERNAL_ERROR';
|
|
@@ -538,9 +519,6 @@ function accessPolicyWithUpdate(current, resource, decoded) {
|
|
|
538
519
|
if (resource === 'policyMode') {
|
|
539
520
|
return { ...current, policyMode: decoded };
|
|
540
521
|
}
|
|
541
|
-
if (resource === 'allowedGroupChannels') {
|
|
542
|
-
return { ...current, allowedGroupChannels: [...decoded] };
|
|
543
|
-
}
|
|
544
522
|
const requestLimit = decoded;
|
|
545
523
|
return {
|
|
546
524
|
...current,
|
|
@@ -156,6 +156,22 @@ export async function roleMenuQuery(context, args) {
|
|
|
156
156
|
view: view || null,
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Read the current joined-group count without materializing the full group list.
|
|
161
|
+
* Used by Connect's root options description; failures are handled by the caller
|
|
162
|
+
* so a transient AUN directory outage does not hide the rest of the menu.
|
|
163
|
+
*/
|
|
164
|
+
export async function roleMenuGroupCount(context) {
|
|
165
|
+
assertRequest(context, { self: context.self });
|
|
166
|
+
assertAunDirectory(context);
|
|
167
|
+
const result = await roleDirectory(context).listGroups({ from: context.self, size: 1 });
|
|
168
|
+
if (!result.ok) {
|
|
169
|
+
throw roleError('TEMPORARILY_UNAVAILABLE', result.error || 'Unable to list joined groups', context.self, {
|
|
170
|
+
kind: 'target_source_unavailable', targetType: 'group',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return Math.max(Number(result.total) || 0, result.items.length);
|
|
174
|
+
}
|
|
159
175
|
export async function roleMenuUpdate(context, args, rawValue) {
|
|
160
176
|
assertRequest(context, args);
|
|
161
177
|
const value = parseUpdateValue(rawValue, context.self);
|
|
@@ -44,6 +44,23 @@ export function evaluateInboundAdmissionPreflight(input) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
else {
|
|
48
|
+
// Group admission is based on group membership in `contacts` mode, but
|
|
49
|
+
// owners mode still needs to identify an Owner sender. Resolve only the
|
|
50
|
+
// canonical identity needed for that distinction; group messages do not
|
|
51
|
+
// use contact status or private-channel blocking rules.
|
|
52
|
+
try {
|
|
53
|
+
const principal = resolvePrincipal(selfAid, channelType, actorId, input.channelKey);
|
|
54
|
+
if (principal.principalId) {
|
|
55
|
+
context.primaryId = principal.principalId;
|
|
56
|
+
context.isOwner = resolveContactView(selfAid, principal.principalId).isOwner;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Open/contacts group admission remains membership-based even when a
|
|
61
|
+
// native identity cannot be resolved through the contact book.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
47
64
|
try {
|
|
48
65
|
context.policy = readAccessPolicy(selfAid);
|
|
49
66
|
}
|
|
@@ -62,26 +79,19 @@ export function evaluateOrdinaryInboundAdmission(context) {
|
|
|
62
79
|
if (!policy)
|
|
63
80
|
return { allow: false, reason: 'invalid_access_policy', context };
|
|
64
81
|
if (context.chatType === 'group') {
|
|
65
|
-
if (
|
|
66
|
-
return { allow:
|
|
67
|
-
|
|
68
|
-
// `open` is genuinely open: group traffic is not gated by the optional
|
|
69
|
-
// channel allow-list. In `contacts`, AUN groups are currently open once
|
|
70
|
-
// the agent is receiving that group; non-AUN channels remain explicit.
|
|
71
|
-
if (policy.policyMode === 'open'
|
|
72
|
-
|| (policy.policyMode === 'contacts' && context.channelType === 'aun')) {
|
|
82
|
+
if (context.isOwner)
|
|
83
|
+
return { allow: true, reason: 'owner', context };
|
|
84
|
+
if (policy.policyMode === 'open' || policy.policyMode === 'contacts') {
|
|
73
85
|
return { allow: true, reason: 'group_channel_allowed', context };
|
|
74
86
|
}
|
|
75
|
-
return
|
|
76
|
-
? { allow: true, reason: 'group_channel_allowed', context }
|
|
77
|
-
: { allow: false, reason: 'group_channel_denied', context };
|
|
87
|
+
return { allow: false, reason: 'owners', context };
|
|
78
88
|
}
|
|
79
89
|
if (context.isOwner)
|
|
80
90
|
return { allow: true, reason: 'owner', context };
|
|
81
91
|
if (policy.policyMode === 'open')
|
|
82
92
|
return { allow: true, reason: 'open', context };
|
|
83
|
-
if (policy.policyMode === 'owners
|
|
84
|
-
return { allow: false, reason: '
|
|
93
|
+
if (policy.policyMode === 'owners')
|
|
94
|
+
return { allow: false, reason: 'owners', context };
|
|
85
95
|
if (context.primaryId && isAcceptedContact(context.selfAid, context.primaryId)) {
|
|
86
96
|
return { allow: true, reason: 'contact', context };
|
|
87
97
|
}
|
|
@@ -10,15 +10,25 @@ function encodeSegment(s) {
|
|
|
10
10
|
function decodeSegment(s) {
|
|
11
11
|
return s.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
12
12
|
}
|
|
13
|
+
function resolveAunSelfAID(selfAID, channelKey) {
|
|
14
|
+
const direct = selfAID?.trim();
|
|
15
|
+
if (direct && direct !== '_unknown')
|
|
16
|
+
return direct;
|
|
17
|
+
const parts = channelKey?.split('#');
|
|
18
|
+
const inferred = parts?.length === 3 && parts[0] === 'aun' ? parts[1]?.trim() : undefined;
|
|
19
|
+
if (inferred && inferred !== '_unknown')
|
|
20
|
+
return inferred;
|
|
21
|
+
throw new Error('[SessionFsStore] AUN chat directory requires selfAID or an aun channel key.');
|
|
22
|
+
}
|
|
13
23
|
/**
|
|
14
24
|
* 计算 chat 目录的完整路径。
|
|
15
|
-
* - aun: sessionsDir/aun/<urlEncode(selfAID
|
|
25
|
+
* - aun: sessionsDir/aun/<urlEncode(selfAID)>/<urlEncode(channelId)>/
|
|
16
26
|
* - wecom: sessionsDir/wecom/<urlEncode(channelKey)>/<urlEncode(channelId)>/
|
|
17
27
|
* - 其它: sessionsDir/<channelType>/<urlEncode(channelId)>/
|
|
18
28
|
*/
|
|
19
29
|
export function chatDirPath(sessionsDir, channelType, channelId, selfAID, channelKey) {
|
|
20
30
|
if (channelType === 'aun') {
|
|
21
|
-
return path.join(sessionsDir, channelType, encodeSegment(selfAID
|
|
31
|
+
return path.join(sessionsDir, channelType, encodeSegment(resolveAunSelfAID(selfAID, channelKey)), encodeSegment(channelId));
|
|
22
32
|
}
|
|
23
33
|
if (channelType === 'wecom' && channelKey?.startsWith('wecom#')) {
|
|
24
34
|
return path.join(sessionsDir, channelType, encodeSegment(channelKey), encodeSegment(channelId));
|
|
@@ -47,9 +57,8 @@ export function formatTimestamp(epochMs) {
|
|
|
47
57
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
48
58
|
return `${yyyy}-${mo}-${dd} ${hh}:${mi}:${ss}`;
|
|
49
59
|
}
|
|
50
|
-
|
|
60
|
+
function atomicWrite(filePath, content) {
|
|
51
61
|
const tmpPath = filePath + '.tmp';
|
|
52
|
-
const content = JSON.stringify(data, null, 2) + '\n';
|
|
53
62
|
const fd = fs.openSync(tmpPath, 'w');
|
|
54
63
|
fs.writeSync(fd, content);
|
|
55
64
|
fs.fsyncSync(fd);
|
|
@@ -63,6 +72,12 @@ export function atomicWriteJson(filePath, data) {
|
|
|
63
72
|
}
|
|
64
73
|
fs.renameSync(tmpPath, filePath);
|
|
65
74
|
}
|
|
75
|
+
export function atomicWriteJson(filePath, data) {
|
|
76
|
+
atomicWrite(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
77
|
+
}
|
|
78
|
+
export function atomicWriteText(filePath, content) {
|
|
79
|
+
atomicWrite(filePath, content);
|
|
80
|
+
}
|
|
66
81
|
export function appendJsonl(filePath, record) {
|
|
67
82
|
const line = JSON.stringify(record) + '\n';
|
|
68
83
|
const fd = fs.openSync(filePath, 'a');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ensureDir } from '../../utils/atomic-write.js';
|
|
2
2
|
import { logger } from '../../utils/logger.js';
|
|
3
3
|
import { encodePath } from '../../utils/cross-platform.js';
|
|
4
|
-
import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
|
|
4
|
+
import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, atomicWriteText, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
|
|
5
5
|
import { sessionToFile, fileToSession } from './session-mapper.js';
|
|
6
6
|
import { formatSessionKey, DEFAULT_THREAD_ID } from './session-key.js';
|
|
7
7
|
import { tryParseChannelKey } from '../channel-loader.js';
|
|
@@ -39,6 +39,7 @@ export class SessionManager {
|
|
|
39
39
|
this.eventBus = eventBus;
|
|
40
40
|
this.identityResolver = identityResolver;
|
|
41
41
|
this.migrateChannelKeyFormat();
|
|
42
|
+
this.migrateUnknownAunDirs();
|
|
42
43
|
this.migrateWecomChannelDirs();
|
|
43
44
|
}
|
|
44
45
|
setIdentityResolver(resolver) {
|
|
@@ -1662,4 +1663,209 @@ export class SessionManager {
|
|
|
1662
1663
|
logger.info(`[SessionManager] Migrated ${migrated} WeCom session director${migrated === 1 ? 'y' : 'ies'} to channelKey isolation`);
|
|
1663
1664
|
}
|
|
1664
1665
|
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Recover legacy AUN chats written beneath `_unknown` before selfAID was
|
|
1668
|
+
* persisted. A channel key or persisted selfAID is required to make the
|
|
1669
|
+
* migration unambiguous; all other directories remain untouched.
|
|
1670
|
+
*/
|
|
1671
|
+
migrateUnknownAunDirs() {
|
|
1672
|
+
const legacyDirs = scanChatDirs(this.sessionsDir)
|
|
1673
|
+
.filter(entry => entry.channelType === 'aun' && entry.selfAID === '_unknown');
|
|
1674
|
+
let migrated = 0;
|
|
1675
|
+
for (const entry of legacyDirs) {
|
|
1676
|
+
const selfAIDs = this.findAunSelfAIDCandidates(entry.dirPath);
|
|
1677
|
+
if (selfAIDs.size !== 1) {
|
|
1678
|
+
if (selfAIDs.size > 1) {
|
|
1679
|
+
logger.error(`[SessionManager] Cannot auto-migrate mixed unknown AUN session directory: ${entry.dirPath}`);
|
|
1680
|
+
}
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
const selfAID = [...selfAIDs][0];
|
|
1684
|
+
const target = chatDirPath(this.sessionsDir, 'aun', entry.channelId, selfAID);
|
|
1685
|
+
try {
|
|
1686
|
+
this.mergeUnknownAunDirectory(entry.dirPath, target, selfAID);
|
|
1687
|
+
migrated++;
|
|
1688
|
+
}
|
|
1689
|
+
catch (error) {
|
|
1690
|
+
logger.error(`[SessionManager] Failed to migrate unknown AUN session directory ${entry.dirPath}: ${String(error)}`);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (migrated > 0) {
|
|
1694
|
+
logger.info(`[SessionManager] Migrated ${migrated} unknown AUN session director${migrated === 1 ? 'y' : 'ies'} to selfAID isolation`);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
findAunSelfAIDCandidates(chatDir) {
|
|
1698
|
+
const candidates = new Set();
|
|
1699
|
+
const visitRecord = (record) => {
|
|
1700
|
+
if (!record || typeof record !== 'object')
|
|
1701
|
+
return;
|
|
1702
|
+
const session = record;
|
|
1703
|
+
const direct = typeof session.selfAID === 'string' ? session.selfAID.trim() : '';
|
|
1704
|
+
if (direct && direct !== '_unknown')
|
|
1705
|
+
candidates.add(direct);
|
|
1706
|
+
const channel = typeof session.channel === 'string' ? tryParseChannelKey(session.channel) : null;
|
|
1707
|
+
if (channel?.type === 'aun' && channel.selfAID !== '_unknown')
|
|
1708
|
+
candidates.add(channel.selfAID);
|
|
1709
|
+
};
|
|
1710
|
+
const visitJsonl = (filePath) => {
|
|
1711
|
+
for (const record of readAllJsonlLines(filePath))
|
|
1712
|
+
visitRecord(record);
|
|
1713
|
+
};
|
|
1714
|
+
visitRecord(readJsonFile(path.join(chatDir, 'active.json')));
|
|
1715
|
+
for (const metaFile of scanMetaFiles(chatDir))
|
|
1716
|
+
visitJsonl(path.join(chatDir, metaFile));
|
|
1717
|
+
const threadDir = path.join(chatDir, '_threads');
|
|
1718
|
+
for (const metaFile of scanMetaFiles(threadDir))
|
|
1719
|
+
visitJsonl(path.join(threadDir, metaFile));
|
|
1720
|
+
return candidates;
|
|
1721
|
+
}
|
|
1722
|
+
mergeUnknownAunDirectory(sourceDir, targetDir, selfAID) {
|
|
1723
|
+
if (sourceDir === targetDir)
|
|
1724
|
+
return;
|
|
1725
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1726
|
+
this.mergeUnknownAunDirectoryContents(sourceDir, targetDir, selfAID);
|
|
1727
|
+
fs.rmdirSync(sourceDir);
|
|
1728
|
+
}
|
|
1729
|
+
mergeUnknownAunDirectoryContents(sourceDir, targetDir, selfAID) {
|
|
1730
|
+
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
1731
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
1732
|
+
const targetPath = path.join(targetDir, entry.name);
|
|
1733
|
+
if (entry.isDirectory()) {
|
|
1734
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
1735
|
+
this.mergeUnknownAunDirectoryContents(sourcePath, targetPath, selfAID);
|
|
1736
|
+
fs.rmdirSync(sourcePath);
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
if (!entry.isFile())
|
|
1740
|
+
continue;
|
|
1741
|
+
if (entry.name === 'active.json') {
|
|
1742
|
+
this.mergeUnknownAunActiveFile(sourcePath, targetPath, selfAID);
|
|
1743
|
+
}
|
|
1744
|
+
else if (entry.name.endsWith('.jsonl')) {
|
|
1745
|
+
this.mergeUnknownAunJsonlFile(sourcePath, targetPath, selfAID);
|
|
1746
|
+
}
|
|
1747
|
+
else if (entry.name.endsWith('.json')) {
|
|
1748
|
+
this.mergeUnknownAunJsonFile(sourcePath, targetPath);
|
|
1749
|
+
}
|
|
1750
|
+
else if (!fs.existsSync(targetPath)) {
|
|
1751
|
+
fs.renameSync(sourcePath, targetPath);
|
|
1752
|
+
}
|
|
1753
|
+
else if (fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath))) {
|
|
1754
|
+
fs.unlinkSync(sourcePath);
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
fs.renameSync(sourcePath, this.uniqueLegacyFilePath(targetPath));
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
mergeUnknownAunActiveFile(sourcePath, targetPath, selfAID) {
|
|
1762
|
+
const source = this.normalizeAunSessionRecord(readJsonFile(sourcePath), selfAID);
|
|
1763
|
+
const target = this.normalizeAunSessionRecord(readJsonFile(targetPath), selfAID);
|
|
1764
|
+
const sourceUpdatedAt = typeof source?.updatedAt === 'number' ? source.updatedAt : 0;
|
|
1765
|
+
const targetUpdatedAt = typeof target?.updatedAt === 'number' ? target.updatedAt : 0;
|
|
1766
|
+
const selected = target && targetUpdatedAt >= sourceUpdatedAt ? target : source;
|
|
1767
|
+
if (selected)
|
|
1768
|
+
atomicWriteJson(targetPath, selected);
|
|
1769
|
+
fs.unlinkSync(sourcePath);
|
|
1770
|
+
}
|
|
1771
|
+
mergeUnknownAunJsonlFile(sourcePath, targetPath, selfAID) {
|
|
1772
|
+
const records = [
|
|
1773
|
+
...this.readNormalizedAunJsonl(sourcePath, selfAID),
|
|
1774
|
+
...this.readNormalizedAunJsonl(targetPath, selfAID),
|
|
1775
|
+
];
|
|
1776
|
+
const seen = new Set();
|
|
1777
|
+
const unique = records.filter(record => {
|
|
1778
|
+
if (seen.has(record.key))
|
|
1779
|
+
return false;
|
|
1780
|
+
seen.add(record.key);
|
|
1781
|
+
return true;
|
|
1782
|
+
});
|
|
1783
|
+
unique.sort((left, right) => left.timestamp - right.timestamp || left.order - right.order);
|
|
1784
|
+
atomicWriteText(targetPath, unique.map(record => record.line).join('\n') + (unique.length ? '\n' : ''));
|
|
1785
|
+
fs.unlinkSync(sourcePath);
|
|
1786
|
+
}
|
|
1787
|
+
readNormalizedAunJsonl(filePath, selfAID) {
|
|
1788
|
+
let content;
|
|
1789
|
+
try {
|
|
1790
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
1791
|
+
}
|
|
1792
|
+
catch (error) {
|
|
1793
|
+
if (error?.code === 'ENOENT')
|
|
1794
|
+
return [];
|
|
1795
|
+
throw error;
|
|
1796
|
+
}
|
|
1797
|
+
return content.split('\n').flatMap((rawLine, order) => {
|
|
1798
|
+
const line = rawLine.trim();
|
|
1799
|
+
if (!line)
|
|
1800
|
+
return [];
|
|
1801
|
+
try {
|
|
1802
|
+
const record = this.normalizeAunSessionRecord(JSON.parse(line), selfAID);
|
|
1803
|
+
const normalizedLine = JSON.stringify(record);
|
|
1804
|
+
return [{
|
|
1805
|
+
key: this.stableJson(record),
|
|
1806
|
+
line: normalizedLine,
|
|
1807
|
+
timestamp: this.recordTimestamp(record),
|
|
1808
|
+
order,
|
|
1809
|
+
}];
|
|
1810
|
+
}
|
|
1811
|
+
catch {
|
|
1812
|
+
return [{ key: `raw:${line}`, line, timestamp: Number.MAX_SAFE_INTEGER, order }];
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
normalizeAunSessionRecord(record, selfAID) {
|
|
1817
|
+
if (!record || typeof record !== 'object' || Array.isArray(record))
|
|
1818
|
+
return record;
|
|
1819
|
+
const result = { ...record };
|
|
1820
|
+
const isSessionRecord = 'channel' in result || 'channelType' in result || 'sessionKey' in result || 'selfAID' in result;
|
|
1821
|
+
if (!isSessionRecord)
|
|
1822
|
+
return result;
|
|
1823
|
+
if (!result.selfAID || result.selfAID === '_unknown')
|
|
1824
|
+
result.selfAID = selfAID;
|
|
1825
|
+
return result;
|
|
1826
|
+
}
|
|
1827
|
+
stableJson(value) {
|
|
1828
|
+
if (Array.isArray(value))
|
|
1829
|
+
return `[${value.map(item => this.stableJson(item)).join(',')}]`;
|
|
1830
|
+
if (value && typeof value === 'object') {
|
|
1831
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${this.stableJson(value[key])}`).join(',')}}`;
|
|
1832
|
+
}
|
|
1833
|
+
return JSON.stringify(value);
|
|
1834
|
+
}
|
|
1835
|
+
recordTimestamp(record) {
|
|
1836
|
+
if (!record || typeof record !== 'object')
|
|
1837
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1838
|
+
const value = record;
|
|
1839
|
+
for (const key of ['ts', 'at', 'updatedAt', 'createdAt']) {
|
|
1840
|
+
if (typeof value[key] === 'number' && Number.isFinite(value[key]))
|
|
1841
|
+
return value[key];
|
|
1842
|
+
}
|
|
1843
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1844
|
+
}
|
|
1845
|
+
mergeUnknownAunJsonFile(sourcePath, targetPath) {
|
|
1846
|
+
if (!fs.existsSync(targetPath)) {
|
|
1847
|
+
fs.renameSync(sourcePath, targetPath);
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
const source = readJsonFile(sourcePath);
|
|
1851
|
+
const target = readJsonFile(targetPath);
|
|
1852
|
+
if (source && target && !Array.isArray(source) && !Array.isArray(target)) {
|
|
1853
|
+
atomicWriteJson(targetPath, { ...source, ...target });
|
|
1854
|
+
fs.unlinkSync(sourcePath);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath))) {
|
|
1858
|
+
fs.unlinkSync(sourcePath);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
fs.renameSync(sourcePath, this.uniqueLegacyFilePath(targetPath));
|
|
1862
|
+
}
|
|
1863
|
+
uniqueLegacyFilePath(targetPath) {
|
|
1864
|
+
let index = 1;
|
|
1865
|
+
let candidate = `${targetPath}.legacy-unknown`;
|
|
1866
|
+
while (fs.existsSync(candidate)) {
|
|
1867
|
+
candidate = `${targetPath}.legacy-unknown-${index++}`;
|
|
1868
|
+
}
|
|
1869
|
+
return candidate;
|
|
1870
|
+
}
|
|
1665
1871
|
}
|
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'url';
|
|
|
3
3
|
import { execFileSync, execFile, spawn, spawnSync } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import fs from 'fs';
|
|
6
|
+
import { getProcessStartTime } from './process-introspect.js';
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
export const isWindows = process.platform === 'win32';
|
|
8
9
|
const ENCODE_PATH_MAX = 200;
|
|
@@ -133,10 +134,15 @@ export function getProcessInfo(pid) {
|
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
else {
|
|
136
|
-
|
|
137
|
+
// procps `etimes` can overflow for freshly spawned processes in some
|
|
138
|
+
// container/time-namespace combinations. /proc start time is reliable
|
|
139
|
+
// on Linux and is already used for PID reuse protection elsewhere.
|
|
140
|
+
const startedAt = process.platform === 'linux' ? getProcessStartTime(pid) : null;
|
|
141
|
+
const uptime = startedAt !== null
|
|
142
|
+
? formatUptime(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)))
|
|
143
|
+
: formatUptime(parseInt(execFileSync('ps', ['-p', String(pid), '-o', 'etimes='], { encoding: 'utf-8' }).trim(), 10));
|
|
137
144
|
const cpu = execFileSync('ps', ['-p', String(pid), '-o', '%cpu='], { encoding: 'utf-8' }).trim();
|
|
138
145
|
const mem = execFileSync('ps', ['-p', String(pid), '-o', 'rss='], { encoding: 'utf-8' }).trim();
|
|
139
|
-
const uptime = formatUptime(parseInt(etimes, 10));
|
|
140
146
|
return { uptime, cpu, memory: mem };
|
|
141
147
|
}
|
|
142
148
|
}
|
|
@@ -8,8 +8,25 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
|
-
|
|
11
|
+
const isWindows = process.platform === 'win32';
|
|
12
12
|
const isMacOS = process.platform === 'darwin';
|
|
13
|
+
function readLinuxClockTicks() {
|
|
14
|
+
if (process.platform !== 'linux')
|
|
15
|
+
return 100;
|
|
16
|
+
try {
|
|
17
|
+
const result = spawnSync('getconf', ['CLK_TCK'], {
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
timeout: 1000,
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
21
|
+
});
|
|
22
|
+
const value = Number.parseInt(result.stdout?.trim() || '', 10);
|
|
23
|
+
return value > 0 ? value : 100;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return 100;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const linuxClockTicks = readLinuxClockTicks();
|
|
13
30
|
/** 容差:2 秒(覆盖 macOS 秒级精度 + 时钟漂移) */
|
|
14
31
|
export const START_TIME_TOLERANCE_MS = 2000;
|
|
15
32
|
/**
|
|
@@ -72,8 +89,7 @@ function getStartTimeLinux(pid) {
|
|
|
72
89
|
}
|
|
73
90
|
if (isNaN(btimeSec))
|
|
74
91
|
return null;
|
|
75
|
-
|
|
76
|
-
return (btimeSec + starttimeJiffies / clkTck) * 1000;
|
|
92
|
+
return (btimeSec + starttimeJiffies / linuxClockTicks) * 1000;
|
|
77
93
|
}
|
|
78
94
|
// ── macOS ──
|
|
79
95
|
function getStartTimeMacOS(pid) {
|
|
@@ -134,15 +134,15 @@ ConfigManager 只合并上述物理层并返回物理来源。角色策略不进
|
|
|
134
134
|
{
|
|
135
135
|
"access": {
|
|
136
136
|
"policyMode": "contacts",
|
|
137
|
-
"allowedGroupChannels": [],
|
|
138
137
|
"requestLimit": { "maxTimes": 600, "coolDown": 3600 }
|
|
139
138
|
}
|
|
140
139
|
}
|
|
141
140
|
```
|
|
142
141
|
|
|
143
|
-
`policyMode` 为 `open | contacts | owners
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
`policyMode` 为 `open | contacts | owners`;`open` 对所有已配置渠道的私聊和群聊入站开放,`contacts`
|
|
143
|
+
将 Agent 已加入并接收到的群聊视为授权,`owners` 仅 Owner 消息开放;群聊成员不按 Contact Book 联系人
|
|
144
|
+
状态逐一过滤。`requestLimit` 只限制准入控制消息的自动回复、申请和绑定码提示,不是渠道准入开关;`coolDown`
|
|
145
|
+
单位为秒。该对象由 Connect 的访问设置入口修改,不通过通用 relation/defaults 覆盖链放宽。
|
|
146
146
|
|
|
147
147
|
### AUN 群规则加载策略
|
|
148
148
|
|
|
@@ -56,6 +56,7 @@ ec contact list --blocked --self <agent-aid>
|
|
|
56
56
|
## 拉黑后的消息行为
|
|
57
57
|
|
|
58
58
|
- AUN 和已绑定外部私聊身份的入站在 MessageBridge 业务入口被拦截,不进入 menu/auth/command/session/queue/LLM。
|
|
59
|
-
-
|
|
59
|
+
- 群聊不按成员的联系人状态过滤;`open` 对所有已配置渠道的群聊入站开放,`contacts` 将 Agent 已加入并
|
|
60
|
+
接收到的群聊视为授权,`owners` 仅 Owner 消息开放。
|
|
60
61
|
- 对 `ai`、`bot`、`agent`、`service` 类型的私聊对端,可限频返回 `[rejection] Not accepting messages from you.`。
|
|
61
62
|
- 入站正文中的 `[rejection]` 没有协议控制语义。发送者未被本 Agent 拉黑时,该文本正常进入响应式会话。
|
|
@@ -95,7 +95,6 @@
|
|
|
95
95
|
"additionalProperties": false,
|
|
96
96
|
"required": [
|
|
97
97
|
"policyMode",
|
|
98
|
-
"allowedGroupChannels",
|
|
99
98
|
"requestLimit"
|
|
100
99
|
],
|
|
101
100
|
"properties": {
|
|
@@ -104,18 +103,9 @@
|
|
|
104
103
|
"enum": [
|
|
105
104
|
"open",
|
|
106
105
|
"contacts",
|
|
107
|
-
"owners
|
|
106
|
+
"owners"
|
|
108
107
|
],
|
|
109
|
-
"description": "
|
|
110
|
-
},
|
|
111
|
-
"allowedGroupChannels": {
|
|
112
|
-
"type": "array",
|
|
113
|
-
"items": {
|
|
114
|
-
"type": "string",
|
|
115
|
-
"minLength": 1
|
|
116
|
-
},
|
|
117
|
-
"uniqueItems": true,
|
|
118
|
-
"description": "允许群消息进入的完整渠道实例键;空数组表示关闭所有群聊渠道。"
|
|
108
|
+
"description": "私聊和群聊准入策略:开放、仅联系人或仅 Owner。"
|
|
119
109
|
},
|
|
120
110
|
"requestLimit": {
|
|
121
111
|
"type": "object",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evolcore",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "AI Agent gateway connecting multiple backends (Claude, Codex, Gemini) to messaging channels (Feishu, WeChat, AUN, QQ) with multi-project session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|