evolcore 0.0.13 → 0.0.15
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 +27 -0
- package/bin/codex-managed-hook.mjs +4 -1
- package/bin/install-codex-managed-hooks.mjs +201 -0
- package/dist/agents/claude-runner.js +53 -5
- package/dist/agents/codex-app-server-client.js +123 -2
- package/dist/agents/codex-runner.js +152 -30
- package/dist/agents/ecagent-runner.js +17 -1
- package/dist/agents/gemini-runner.js +9 -4
- package/dist/aun/msg/managed-operation.js +63 -3
- package/dist/channels/aun.js +144 -15
- package/dist/channels/daemon.js +2 -0
- package/dist/channels/feishu.js +6 -1
- package/dist/cli/aun-commands.js +1 -1
- package/dist/cli/fs-command.js +46 -9
- package/dist/cli/task-context.js +176 -0
- package/dist/config/builtin-roles.js +2 -0
- package/dist/config/config-manager.js +6 -2
- package/dist/config/contact-book-store.js +7 -2
- package/dist/core/auth/auth-gateway.js +1 -0
- package/dist/core/auth/authorization-audit.js +32 -0
- package/dist/core/auth/operation-catalog.js +3 -3
- package/dist/core/bootstrap-service.js +7 -1
- package/dist/core/command/command-handler.js +3 -0
- package/dist/core/command/slash-handler.js +1 -1
- package/dist/core/event-catalog.js +2 -0
- package/dist/core/message/im-renderer.js +15 -1
- package/dist/core/message/message-bridge.js +5 -2
- package/dist/core/message/response-engine.js +138 -10
- package/dist/core/permission/approval-gateway.js +99 -16
- package/dist/core/permission/ec-command-parser.js +556 -4
- package/dist/core/permission/tool-policy.js +17 -29
- package/dist/core/runtime-lock.js +101 -0
- package/dist/index.js +30 -3
- package/dist/response-system/engines/v1/proactive-flow.js +92 -8
- package/dist/response-system/modes/single-session/index.js +3 -0
- package/dist/trigger/history.js +42 -7
- package/dist/utils/error-utils.js +7 -0
- package/dist/utils/logger.js +37 -4
- package/kits/templates/roles/admin.json +2 -0
- package/kits/templates/roles/member.json +1 -0
- package/package.json +1 -1
package/dist/channels/aun.js
CHANGED
|
@@ -41,6 +41,40 @@ export const AUN_HANDOFF_MARKER_FIELD = '_evolcore_handoff_id';
|
|
|
41
41
|
const AUN_INTERACTION_CARD_TTL_MS = 24 * 60 * 60 * 1000;
|
|
42
42
|
const AUN_INBOUND_DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
43
43
|
const IMAGE_MIME_TYPE = /^image\/[!#$%&'*+.^_`|~0-9A-Za-z-]+$/i;
|
|
44
|
+
// AUN limits the complete encrypted thought envelope to 8192 bytes. The
|
|
45
|
+
// encrypted group envelope grows with the number of recipient devices, so a
|
|
46
|
+
// conservative plaintext budget avoids rejecting otherwise valid thoughts.
|
|
47
|
+
const AUN_THOUGHT_PLAINTEXT_MAX_BYTES = 4096;
|
|
48
|
+
const AUN_THOUGHT_TRUNCATION_SUFFIX = '\n\n[thought truncated]';
|
|
49
|
+
function truncateUtf8ToBytes(value, maxBytes) {
|
|
50
|
+
if (Buffer.byteLength(value, 'utf8') <= maxBytes)
|
|
51
|
+
return value;
|
|
52
|
+
if (maxBytes <= 0)
|
|
53
|
+
return '';
|
|
54
|
+
let bytes = 0;
|
|
55
|
+
let end = 0;
|
|
56
|
+
for (const char of value) {
|
|
57
|
+
const charBytes = Buffer.byteLength(char, 'utf8');
|
|
58
|
+
if (bytes + charBytes > maxBytes)
|
|
59
|
+
break;
|
|
60
|
+
bytes += charBytes;
|
|
61
|
+
end += char.length;
|
|
62
|
+
}
|
|
63
|
+
return value.slice(0, end);
|
|
64
|
+
}
|
|
65
|
+
function truncateUtf8WithSuffix(value, maxBytes, suffix) {
|
|
66
|
+
if (Buffer.byteLength(value, 'utf8') <= maxBytes)
|
|
67
|
+
return value;
|
|
68
|
+
if (maxBytes <= 0)
|
|
69
|
+
return '';
|
|
70
|
+
const fittedSuffix = truncateUtf8ToBytes(suffix, maxBytes);
|
|
71
|
+
const headBytes = Math.max(0, maxBytes - Buffer.byteLength(fittedSuffix, 'utf8'));
|
|
72
|
+
return `${truncateUtf8ToBytes(value, headBytes)}${fittedSuffix}`;
|
|
73
|
+
}
|
|
74
|
+
function isThoughtTooLargeError(error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
76
|
+
return /thought too large/i.test(message);
|
|
77
|
+
}
|
|
44
78
|
function requireEnvelopeDelivery(channelId, envelopeDelivery, nestedDelivery) {
|
|
45
79
|
if (!isDeliveryTarget(envelopeDelivery)) {
|
|
46
80
|
throw Object.assign(new Error(`AUN outbound envelope requires a top-level delivery route for channelId=${channelId}`), {
|
|
@@ -2774,6 +2808,83 @@ export class AUNChannel {
|
|
|
2774
2808
|
mentions: normalizeAunMentionEntries(payload.mentions),
|
|
2775
2809
|
};
|
|
2776
2810
|
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Keep thought payloads below the encrypted AUN envelope limit. Thought
|
|
2813
|
+
* payloads are usually activity records, so preserving their structure and
|
|
2814
|
+
* truncating large string leaves is more useful than dropping the item.
|
|
2815
|
+
*/
|
|
2816
|
+
limitThoughtPayload(payload) {
|
|
2817
|
+
let serialized;
|
|
2818
|
+
try {
|
|
2819
|
+
serialized = JSON.stringify(payload);
|
|
2820
|
+
}
|
|
2821
|
+
catch {
|
|
2822
|
+
return { type: 'thought', text: '[thought unavailable: payload is not serializable]' };
|
|
2823
|
+
}
|
|
2824
|
+
if (typeof serialized !== 'string') {
|
|
2825
|
+
return { type: 'thought', text: '[thought unavailable: payload is not serializable]' };
|
|
2826
|
+
}
|
|
2827
|
+
if (Buffer.byteLength(serialized, 'utf8') <= AUN_THOUGHT_PLAINTEXT_MAX_BYTES)
|
|
2828
|
+
return payload;
|
|
2829
|
+
const limited = JSON.parse(serialized);
|
|
2830
|
+
limited.thought_truncated = true;
|
|
2831
|
+
const stringLeaves = () => {
|
|
2832
|
+
const leaves = [];
|
|
2833
|
+
const stack = [limited];
|
|
2834
|
+
while (stack.length > 0) {
|
|
2835
|
+
const current = stack.pop();
|
|
2836
|
+
if (!current || typeof current !== 'object')
|
|
2837
|
+
continue;
|
|
2838
|
+
for (const key of Object.keys(current)) {
|
|
2839
|
+
const value = current[key];
|
|
2840
|
+
if (typeof value === 'string') {
|
|
2841
|
+
leaves.push({ parent: current, key, value, bytes: Buffer.byteLength(value, 'utf8') });
|
|
2842
|
+
}
|
|
2843
|
+
else if (value && typeof value === 'object') {
|
|
2844
|
+
stack.push(value);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
return leaves;
|
|
2849
|
+
};
|
|
2850
|
+
let limitedBytes = Buffer.byteLength(JSON.stringify(limited), 'utf8');
|
|
2851
|
+
while (limitedBytes > AUN_THOUGHT_PLAINTEXT_MAX_BYTES) {
|
|
2852
|
+
const leaf = stringLeaves().sort((a, b) => b.bytes - a.bytes)[0];
|
|
2853
|
+
if (!leaf || leaf.bytes === 0)
|
|
2854
|
+
break;
|
|
2855
|
+
if (!Array.isArray(leaf.parent) && typeof leaf.key === 'string') {
|
|
2856
|
+
leaf.parent[`${leaf.key}_truncated`] = true;
|
|
2857
|
+
}
|
|
2858
|
+
limitedBytes = Buffer.byteLength(JSON.stringify(limited), 'utf8');
|
|
2859
|
+
const bytesToRemove = limitedBytes - AUN_THOUGHT_PLAINTEXT_MAX_BYTES + 32;
|
|
2860
|
+
const targetBytes = Math.max(0, leaf.bytes - bytesToRemove);
|
|
2861
|
+
const source = leaf.value.endsWith(AUN_THOUGHT_TRUNCATION_SUFFIX)
|
|
2862
|
+
? leaf.value.slice(0, -AUN_THOUGHT_TRUNCATION_SUFFIX.length)
|
|
2863
|
+
: leaf.value;
|
|
2864
|
+
leaf.parent[leaf.key] = truncateUtf8WithSuffix(source, targetBytes, AUN_THOUGHT_TRUNCATION_SUFFIX);
|
|
2865
|
+
const nextBytes = Buffer.byteLength(JSON.stringify(limited), 'utf8');
|
|
2866
|
+
if (nextBytes >= limitedBytes)
|
|
2867
|
+
break;
|
|
2868
|
+
limitedBytes = nextBytes;
|
|
2869
|
+
}
|
|
2870
|
+
if (limitedBytes <= AUN_THOUGHT_PLAINTEXT_MAX_BYTES) {
|
|
2871
|
+
logger.info(`${this.logPrefix()} Thought payload truncated: ${Buffer.byteLength(serialized, 'utf8')} -> ${limitedBytes} bytes`);
|
|
2872
|
+
return limited;
|
|
2873
|
+
}
|
|
2874
|
+
return this.thoughtPayloadFallback(limited);
|
|
2875
|
+
}
|
|
2876
|
+
thoughtPayloadFallback(payload) {
|
|
2877
|
+
const text = this.activityLogText(payload);
|
|
2878
|
+
const fallback = {
|
|
2879
|
+
type: 'thought',
|
|
2880
|
+
text: truncateUtf8WithSuffix(text, 256, AUN_THOUGHT_TRUNCATION_SUFFIX),
|
|
2881
|
+
thought_truncated: true,
|
|
2882
|
+
};
|
|
2883
|
+
if (typeof payload.chatmode === 'string')
|
|
2884
|
+
fallback.chatmode = truncateUtf8ToBytes(payload.chatmode, 32);
|
|
2885
|
+
logger.info(`${this.logPrefix()} Thought payload reduced to fallback (${Buffer.byteLength(JSON.stringify(fallback), 'utf8')} bytes)`);
|
|
2886
|
+
return fallback;
|
|
2887
|
+
}
|
|
2777
2888
|
rememberInvalidatedInteraction(interactionId, reason) {
|
|
2778
2889
|
this.invalidatedInteractions.set(interactionId, reason);
|
|
2779
2890
|
const timer = setTimeout(() => this.invalidatedInteractions.delete(interactionId), 24 * 60 * 60 * 1000);
|
|
@@ -3180,8 +3291,14 @@ export class AUNChannel {
|
|
|
3180
3291
|
: undefined;
|
|
3181
3292
|
if (operationId) {
|
|
3182
3293
|
const chatDir = chatDirPath(resolvePaths().sessionsDir, 'aun', channelId, this.config.aid);
|
|
3183
|
-
if (hasMessageLogOperation(chatDir, operationId))
|
|
3294
|
+
if (hasMessageLogOperation(chatDir, operationId)) {
|
|
3295
|
+
const duplicate = outbox.findByDedupeKey(this.config.aid, operationId);
|
|
3296
|
+
if (duplicate) {
|
|
3297
|
+
outbox.remove(this.config.aid, duplicate.id);
|
|
3298
|
+
logger.info(`${this.logPrefix()} Removed stale outbox entry for completed operation: ${operationId}`);
|
|
3299
|
+
}
|
|
3184
3300
|
return;
|
|
3301
|
+
}
|
|
3185
3302
|
}
|
|
3186
3303
|
// Write-ahead: persist to outbox before attempting send
|
|
3187
3304
|
const entry = outbox.enqueue(this.config.aid, {
|
|
@@ -3785,18 +3902,17 @@ export class AUNChannel {
|
|
|
3785
3902
|
const encrypt = context?.metadata?.encrypted != null
|
|
3786
3903
|
? !!(context.metadata.encrypted)
|
|
3787
3904
|
: this.shouldEncrypt(targetId);
|
|
3905
|
+
const payloadWithChatmode = context?.metadata?.chatmode && !finalPayload.chatmode
|
|
3906
|
+
? { ...finalPayload, chatmode: context.metadata.chatmode }
|
|
3907
|
+
: finalPayload;
|
|
3788
3908
|
const params = {
|
|
3789
3909
|
context: { type: 'task', id: taskId },
|
|
3790
|
-
payload:
|
|
3910
|
+
payload: this.limitThoughtPayload(payloadWithChatmode),
|
|
3791
3911
|
encrypt,
|
|
3792
3912
|
};
|
|
3793
|
-
// 补齐 chatmode(与 deliverTextEntry/applyReplyContextToPayload 对齐)
|
|
3794
|
-
// 前端强依赖此字段用于消息过滤(proactive 模式的 thought 不显示在聊天流)
|
|
3795
|
-
if (context?.metadata?.chatmode && !params.payload.chatmode) {
|
|
3796
|
-
params.payload.chatmode = context.metadata.chatmode;
|
|
3797
|
-
}
|
|
3798
3913
|
try {
|
|
3799
|
-
|
|
3914
|
+
let thoughtPayload = params.payload;
|
|
3915
|
+
const items = thoughtPayload?.items;
|
|
3800
3916
|
const itemCount = Array.isArray(items) ? items.length : 1;
|
|
3801
3917
|
const stage = finalPayload?.stage ?? (finalPayload?.kind ? `kind=${finalPayload.kind}` : `items=${itemCount}`);
|
|
3802
3918
|
// 提取 thought 文本:兼容旧 items[] 和新扁平 activity payload。
|
|
@@ -3818,12 +3934,28 @@ export class AUNChannel {
|
|
|
3818
3934
|
}
|
|
3819
3935
|
}
|
|
3820
3936
|
else {
|
|
3821
|
-
thoughtText = this.activityLogText(
|
|
3937
|
+
thoughtText = this.activityLogText(thoughtPayload);
|
|
3822
3938
|
}
|
|
3823
|
-
|
|
3939
|
+
const method = delivery.chatType === 'group' ? 'group.thought.put' : 'message.thought.put';
|
|
3940
|
+
if (delivery.chatType === 'group')
|
|
3824
3941
|
params.group_id = delivery.groupId;
|
|
3825
|
-
|
|
3826
|
-
|
|
3942
|
+
else
|
|
3943
|
+
params.to = targetId;
|
|
3944
|
+
let putRes;
|
|
3945
|
+
try {
|
|
3946
|
+
putRes = await this.callAndTrace(method, params);
|
|
3947
|
+
}
|
|
3948
|
+
catch (error) {
|
|
3949
|
+
if (!isThoughtTooLargeError(error))
|
|
3950
|
+
throw error;
|
|
3951
|
+
thoughtPayload = this.thoughtPayloadFallback(thoughtPayload);
|
|
3952
|
+
params.payload = thoughtPayload;
|
|
3953
|
+
thoughtText = this.activityLogText(thoughtPayload);
|
|
3954
|
+
logger.warn(`${this.logPrefix()} Retrying ${method} with minimal payload after server size rejection`);
|
|
3955
|
+
putRes = await this.callAndTrace(method, params);
|
|
3956
|
+
}
|
|
3957
|
+
const tid = putRes?.thought_id;
|
|
3958
|
+
if (delivery.chatType === 'group') {
|
|
3827
3959
|
logger.info(`${this.logPrefix()} thought.put ok group=${targetId} task=${taskId} stage=${stage} encrypt=${encrypt} tid=${tid ?? '?'}`);
|
|
3828
3960
|
this.eventBus?.publish?.({ type: 'message:thought-put', agentName: this.config.aid, channelId, taskId, text: thoughtText });
|
|
3829
3961
|
this.forwardOutbound(putRes);
|
|
@@ -3833,9 +3965,6 @@ export class AUNChannel {
|
|
|
3833
3965
|
}
|
|
3834
3966
|
}
|
|
3835
3967
|
else {
|
|
3836
|
-
params.to = targetId;
|
|
3837
|
-
const putRes = await this.callAndTrace('message.thought.put', params);
|
|
3838
|
-
const tid = putRes?.thought_id;
|
|
3839
3968
|
logger.info(`${this.logPrefix()} thought.put ok p2p=${this.peerLabel(targetId)} task=${taskId} stage=${stage} encrypt=${encrypt} tid=${tid ?? '?'}`);
|
|
3840
3969
|
this.eventBus?.publish?.({ type: 'message:thought-put', agentName: this.config.aid, channelId, taskId, text: thoughtText });
|
|
3841
3970
|
this.forwardOutbound(putRes);
|
package/dist/channels/daemon.js
CHANGED
|
@@ -413,6 +413,7 @@ function normalizeExecutionAnomaly(value) {
|
|
|
413
413
|
const occurredAt = typeof value.occurredAt === 'number' && Number.isFinite(value.occurredAt)
|
|
414
414
|
? value.occurredAt
|
|
415
415
|
: Date.now();
|
|
416
|
+
const correlationId = nonEmptyString(value.correlationId);
|
|
416
417
|
const toolName = nonEmptyString(value.toolName);
|
|
417
418
|
const requestId = nonEmptyString(value.requestId);
|
|
418
419
|
const policyCode = nonEmptyString(value.policyCode);
|
|
@@ -426,6 +427,7 @@ function normalizeExecutionAnomaly(value) {
|
|
|
426
427
|
severity: 'warning',
|
|
427
428
|
phase: 'execution',
|
|
428
429
|
occurredAt,
|
|
430
|
+
...(correlationId ? { correlationId } : {}),
|
|
429
431
|
effect,
|
|
430
432
|
...(toolName ? { toolName } : {}),
|
|
431
433
|
...(requestId ? { requestId } : {}),
|
package/dist/channels/feishu.js
CHANGED
|
@@ -1467,9 +1467,14 @@ export function buildCardV2(interaction, opts) {
|
|
|
1467
1467
|
export function buildResolvedV2(interaction, response) {
|
|
1468
1468
|
const action = response.action;
|
|
1469
1469
|
const kind = interaction.kind;
|
|
1470
|
+
const temporaryGrantButton = kind.kind === 'action'
|
|
1471
|
+
? kind.buttons.find(button => button.key === 'always' || button.key === 'approve_session_30m')
|
|
1472
|
+
: undefined;
|
|
1473
|
+
const temporaryGrantIsFileScoped = temporaryGrantButton?.label.includes('同文件') === true;
|
|
1470
1474
|
const labelMap = {
|
|
1471
1475
|
'allow': '✅ 已允许',
|
|
1472
|
-
'always': '⏱ 已授权同操作 30 分钟',
|
|
1476
|
+
'always': temporaryGrantIsFileScoped ? '⏱ 已授权同文件 30 分钟' : '⏱ 已授权同操作 30 分钟',
|
|
1477
|
+
'approve_session_30m': temporaryGrantIsFileScoped ? '⏱ 已授权同文件 30 分钟' : '⏱ 已授权本会话 30 分钟',
|
|
1473
1478
|
'deny': '❌ 已拒绝',
|
|
1474
1479
|
'cancel': '取消',
|
|
1475
1480
|
};
|
package/dist/cli/aun-commands.js
CHANGED
|
@@ -1698,7 +1698,7 @@ Options:
|
|
|
1698
1698
|
else if (r.handoff_id)
|
|
1699
1699
|
console.log(`✓ 已排队 handoff ${r.handoff_id}(尚未投递)`);
|
|
1700
1700
|
else
|
|
1701
|
-
console.log(`✓ 已发送 message_id=${r.message?.message_id ?? '-'} seq=${r.message?.seq ?? '-'}`);
|
|
1701
|
+
console.log(`✓ 已发送 message_id=${r.message_id ?? r.message?.message_id ?? '-'} seq=${r.message?.seq ?? '-'}`);
|
|
1702
1702
|
});
|
|
1703
1703
|
return;
|
|
1704
1704
|
}
|
package/dist/cli/fs-command.js
CHANGED
|
@@ -34,8 +34,8 @@ Commands:
|
|
|
34
34
|
mkdir [-p] <AID>:<path> 创建目录
|
|
35
35
|
ln -s <target> <AID>:<path> 创建软链(personal storage)
|
|
36
36
|
chmod [mode] <AID>:<path> 切换公开/私有(personal storage)
|
|
37
|
-
setfacl <AID>:<path> -m|-x ... 设置/移除 ACL
|
|
38
|
-
getfacl <AID>:<path> 查看 ACL
|
|
37
|
+
setfacl <AID>:<path> -m|-x ... 设置/移除 ACL(群路径使用 role:member|role:admin)
|
|
38
|
+
getfacl <AID>:<path> 查看 ACL
|
|
39
39
|
token issue|revoke|ls <path> 管理访问 token(personal storage)
|
|
40
40
|
find <AID>:<path> [filters] 查找节点
|
|
41
41
|
df <AID>: 查看容量/配额
|
|
@@ -273,6 +273,12 @@ async function runManagedFs(args, formatJson) {
|
|
|
273
273
|
case 'remove':
|
|
274
274
|
console.log(`✓ 已删除: ${result.path}`);
|
|
275
275
|
return;
|
|
276
|
+
case 'getfacl':
|
|
277
|
+
console.log(JSON.stringify(result.result ?? {}, null, 2));
|
|
278
|
+
return;
|
|
279
|
+
case 'setfacl':
|
|
280
|
+
console.log(`✓ 已${result.removed ? '移除' : '设置'}群 ACL: ${result.path}`);
|
|
281
|
+
return;
|
|
276
282
|
}
|
|
277
283
|
}
|
|
278
284
|
function parseCommonOptions(args) {
|
|
@@ -838,10 +844,16 @@ async function fsSetfacl(args, opts) {
|
|
|
838
844
|
}
|
|
839
845
|
await withClient(opts, async ({ client, store }) => {
|
|
840
846
|
const route = await resolveRoute(target.aid, store);
|
|
841
|
-
if (route.backend === 'group') {
|
|
842
|
-
throw new FsCliError('UNSUPPORTED', 'group fs 当前 SDK facade 没有 setfacl 接口', '请在 personal storage 中使用 setfacl。');
|
|
843
|
-
}
|
|
844
847
|
if (modify) {
|
|
848
|
+
if (route.backend === 'group') {
|
|
849
|
+
const [role, rolePerms] = parseGroupAclSpec(modify, true);
|
|
850
|
+
if (expiresAt !== undefined || maxUses !== undefined) {
|
|
851
|
+
throw new FsCliError('INVALID_ARGUMENT', '群空间 ACL 不支持 --expires 或 --max-uses');
|
|
852
|
+
}
|
|
853
|
+
const result = await client.group.fs.setAcl(remoteRef(target), { granteeAid: role, perms: rolePerms });
|
|
854
|
+
outputSuccess(opts, { command: 'setfacl', backend: route.backend, route, path: remoteRef(target), result }, () => console.log(`✓ 已设置群 ACL: ${remoteRef(target)}`));
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
845
857
|
const [grantee, perms] = parseAclSpec(modify, true);
|
|
846
858
|
const result = await client.storage.setAcl(target.path, {
|
|
847
859
|
owner: target.aid,
|
|
@@ -853,6 +865,12 @@ async function fsSetfacl(args, opts) {
|
|
|
853
865
|
outputSuccess(opts, { command: 'setfacl', backend: route.backend, route, path: remoteRef(target), result }, () => console.log(`✓ 已设置 ACL: ${remoteRef(target)}`));
|
|
854
866
|
return;
|
|
855
867
|
}
|
|
868
|
+
if (route.backend === 'group') {
|
|
869
|
+
const [role] = parseGroupAclSpec(remove || '', false);
|
|
870
|
+
const result = await client.group.fs.removeAcl(remoteRef(target), { granteeAid: role });
|
|
871
|
+
outputSuccess(opts, { command: 'setfacl', backend: route.backend, route, path: remoteRef(target), result }, () => console.log(`✓ 已移除群 ACL: ${remoteRef(target)}`));
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
856
874
|
const [grantee] = parseAclSpec(remove || '', false);
|
|
857
875
|
const result = await client.storage.removeAcl(target.path, {
|
|
858
876
|
owner: target.aid,
|
|
@@ -865,10 +883,9 @@ async function fsGetfacl(args, opts) {
|
|
|
865
883
|
const target = parseRemoteRequired(firstPositional(args), { command: 'getfacl' });
|
|
866
884
|
await withClient(opts, async ({ client, store }) => {
|
|
867
885
|
const route = await resolveRoute(target.aid, store);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const result = await client.storage.listAcl(target.path, { owner: target.aid });
|
|
886
|
+
const result = route.backend === 'group'
|
|
887
|
+
? await client.group.fs.getAcl(remoteRef(target))
|
|
888
|
+
: await client.storage.listAcl(target.path, { owner: target.aid });
|
|
872
889
|
outputSuccess(opts, { command: 'getfacl', backend: route.backend, route, path: remoteRef(target), result }, () => console.log(JSON.stringify(result, null, 2)));
|
|
873
890
|
});
|
|
874
891
|
}
|
|
@@ -1305,6 +1322,26 @@ function parseAclSpec(value, requirePerms) {
|
|
|
1305
1322
|
}
|
|
1306
1323
|
return [parts[1]];
|
|
1307
1324
|
}
|
|
1325
|
+
function parseGroupAclSpec(value, requirePerms) {
|
|
1326
|
+
const parts = value.split(':');
|
|
1327
|
+
if (parts[0] !== 'role' || !parts[1] || (parts.length !== (requirePerms ? 3 : 2))) {
|
|
1328
|
+
throw new FsCliError('INVALID_ARGUMENT', '群空间 ACL 条目格式应为 role:member:<perms>、role:admin:<perms> 或 role:<role>');
|
|
1329
|
+
}
|
|
1330
|
+
const role = `role:${parts[1]}`;
|
|
1331
|
+
if (!['role:member', 'role:admin'].includes(role)) {
|
|
1332
|
+
throw new FsCliError('INVALID_ARGUMENT', '群空间 ACL 仅支持 role:member 或 role:admin');
|
|
1333
|
+
}
|
|
1334
|
+
if (!requirePerms)
|
|
1335
|
+
return [role];
|
|
1336
|
+
const perms = parts[2];
|
|
1337
|
+
if (!perms || !/^(?:r|rw|rwx)$/.test(perms)) {
|
|
1338
|
+
throw new FsCliError('INVALID_ARGUMENT', '群空间 ACL 权限必须是 r、rw 或 rwx');
|
|
1339
|
+
}
|
|
1340
|
+
if (role === 'role:member' && perms !== 'rw') {
|
|
1341
|
+
throw new FsCliError('INVALID_ARGUMENT', 'role:member 只能授予 rw 群空间 ACL');
|
|
1342
|
+
}
|
|
1343
|
+
return [role, perms];
|
|
1344
|
+
}
|
|
1308
1345
|
function assertLocalFile(localPath) {
|
|
1309
1346
|
let st;
|
|
1310
1347
|
try {
|
package/dist/cli/task-context.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { normalizeCausation } from '../core/causation/context.js';
|
|
2
|
+
import { RUNTIME_LOCK_DIR_ENV } from '../core/runtime-lock.js';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
2
7
|
export const TASK_RUNTIME_CONTEXT_ENV = 'EVOLCORE_TASK_RUNTIME_CONTEXT';
|
|
8
|
+
export const SESSION_RUNTIME_DIR_ENV = 'EVOLCORE_SESSION_RUNTIME_DIR';
|
|
9
|
+
export { RUNTIME_LOCK_DIR_ENV };
|
|
10
|
+
const generatedProcessManagedTempDirs = new Set();
|
|
11
|
+
let processTempCleanupRegistered = false;
|
|
3
12
|
function isPlainObject(value) {
|
|
4
13
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
5
14
|
}
|
|
@@ -23,10 +32,171 @@ function normalizeTaskRuntimeContext(value) {
|
|
|
23
32
|
peerType: optionalString(value.peerType),
|
|
24
33
|
peerRole: optionalString(value.peerRole),
|
|
25
34
|
threadId: optionalString(value.threadId),
|
|
35
|
+
sessionRuntimeDir: optionalAbsolutePath(value.sessionRuntimeDir),
|
|
36
|
+
runtimeLockDir: optionalAbsolutePath(value.runtimeLockDir),
|
|
26
37
|
handoffIds: handoffIds?.length ? handoffIds : undefined,
|
|
27
38
|
causation: normalizeCausation(value.causation),
|
|
28
39
|
};
|
|
29
40
|
}
|
|
41
|
+
function optionalAbsolutePath(value) {
|
|
42
|
+
if (typeof value !== 'string' || !path.isAbsolute(value))
|
|
43
|
+
return undefined;
|
|
44
|
+
return path.resolve(value);
|
|
45
|
+
}
|
|
46
|
+
function isPrivateDirectory(directory) {
|
|
47
|
+
try {
|
|
48
|
+
const resolved = path.resolve(directory);
|
|
49
|
+
const stat = fs.lstatSync(resolved);
|
|
50
|
+
// Windows does not expose Unix ownership and permission bits through
|
|
51
|
+
// Stats.mode. The ACL is enforced by the platform, so applying the
|
|
52
|
+
// POSIX 0700 check here rejects otherwise valid Windows directories.
|
|
53
|
+
const isWindows = process.platform === 'win32';
|
|
54
|
+
if (!stat.isDirectory()
|
|
55
|
+
|| stat.isSymbolicLink()
|
|
56
|
+
|| (!isWindows && typeof process.getuid === 'function' && stat.uid !== process.getuid())
|
|
57
|
+
|| (!isWindows && (stat.mode & 0o077) !== 0))
|
|
58
|
+
return false;
|
|
59
|
+
// A private leaf below a symlinked ancestor can still escape the managed
|
|
60
|
+
// namespace. Walk the existing ancestor chain and reject such paths while
|
|
61
|
+
// allowing the normal public system temp parent (for generated roots).
|
|
62
|
+
let current = resolved;
|
|
63
|
+
while (true) {
|
|
64
|
+
const ancestor = fs.lstatSync(current);
|
|
65
|
+
if (ancestor.isSymbolicLink())
|
|
66
|
+
return false;
|
|
67
|
+
const parent = path.dirname(current);
|
|
68
|
+
if (parent === current)
|
|
69
|
+
break;
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Ensure the daemon has a private process-wide temporary root.
|
|
80
|
+
*
|
|
81
|
+
* Service managers commonly omit TMPDIR. In that case create a private
|
|
82
|
+
* directory below the platform temp root and publish it for all managed
|
|
83
|
+
* runners. An explicitly supplied value is accepted only when it is already
|
|
84
|
+
* a private directory owned by this process. A shared system directory such
|
|
85
|
+
* as `/tmp`, a symlink, or a group/world-readable path is replaced with a
|
|
86
|
+
* fresh private root instead of becoming a capability for managed children.
|
|
87
|
+
*/
|
|
88
|
+
export function ensureProcessManagedTempDir() {
|
|
89
|
+
const configured = process.env.TMPDIR?.trim();
|
|
90
|
+
if (configured) {
|
|
91
|
+
if (!path.isAbsolute(configured)) {
|
|
92
|
+
throw new Error('managed TMPDIR is unset or not absolute');
|
|
93
|
+
}
|
|
94
|
+
const resolved = path.resolve(configured);
|
|
95
|
+
if (isPrivateDirectory(resolved))
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'evolcore-managed-tmp-'));
|
|
99
|
+
try {
|
|
100
|
+
fs.chmodSync(directory, 0o700);
|
|
101
|
+
if (!isPrivateDirectory(directory)) {
|
|
102
|
+
throw new Error(`managed temp path is not a private directory: ${directory}`);
|
|
103
|
+
}
|
|
104
|
+
process.env.TMPDIR = directory;
|
|
105
|
+
generatedProcessManagedTempDirs.add(directory);
|
|
106
|
+
if (!processTempCleanupRegistered) {
|
|
107
|
+
processTempCleanupRegistered = true;
|
|
108
|
+
process.once('exit', () => {
|
|
109
|
+
for (const candidate of generatedProcessManagedTempDirs) {
|
|
110
|
+
if (!path.basename(candidate).startsWith('evolcore-managed-tmp-'))
|
|
111
|
+
continue;
|
|
112
|
+
try {
|
|
113
|
+
if (isPrivateDirectory(candidate))
|
|
114
|
+
fs.rmSync(candidate, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Best-effort cleanup; the directory is private and uniquely named.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return directory;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
try {
|
|
126
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function isRunnerOwnedSessionRuntimeDir(directory) {
|
|
133
|
+
const resolved = path.resolve(directory);
|
|
134
|
+
const base = path.basename(resolved);
|
|
135
|
+
const parent = path.dirname(resolved);
|
|
136
|
+
// Codex allocates a private, direct child named with a 24-character digest
|
|
137
|
+
// below its private evolcore-codex-* parent. The runner has already checked
|
|
138
|
+
// canonical containment before returning this path to the response engine.
|
|
139
|
+
const isCodexRuntime = /^[a-f0-9]{24}$/.test(base)
|
|
140
|
+
&& /^evolcore-codex-(?:\d+|user)$/.test(path.basename(parent));
|
|
141
|
+
return isCodexRuntime && isPrivateDirectory(parent) && isPrivateDirectory(resolved);
|
|
142
|
+
}
|
|
143
|
+
/** Whether a task-provided runtime directory stays inside the process-managed
|
|
144
|
+
* TMPDIR and is safe to use for transient writes. */
|
|
145
|
+
export function isManagedSessionRuntimeDir(directory) {
|
|
146
|
+
const tmpDir = process.env.TMPDIR?.trim();
|
|
147
|
+
if (!directory || !path.isAbsolute(directory) || !tmpDir || !path.isAbsolute(tmpDir))
|
|
148
|
+
return false;
|
|
149
|
+
const root = path.resolve(tmpDir);
|
|
150
|
+
const resolved = path.resolve(directory);
|
|
151
|
+
const relative = path.relative(root, resolved);
|
|
152
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
153
|
+
return false;
|
|
154
|
+
return isPrivateDirectory(root) && isPrivateDirectory(resolved);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Create a private runtime directory below the process-provided TMPDIR.
|
|
158
|
+
* There is deliberately no os.tmpdir() fallback: managed sessions must not
|
|
159
|
+
* silently escape the session-owned temporary workspace.
|
|
160
|
+
*/
|
|
161
|
+
export function ensureSessionRuntimeDir(sessionId) {
|
|
162
|
+
const base = process.env.TMPDIR?.trim();
|
|
163
|
+
if (!base || !path.isAbsolute(base) || !sessionId)
|
|
164
|
+
return undefined;
|
|
165
|
+
const root = path.resolve(base);
|
|
166
|
+
const digest = crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 24);
|
|
167
|
+
const directory = path.join(root, `evolcore-runtime-${digest}`);
|
|
168
|
+
try {
|
|
169
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
170
|
+
if (!isPrivateDirectory(root))
|
|
171
|
+
return undefined;
|
|
172
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
173
|
+
fs.mkdirSync(path.join(directory, 'locks'), { recursive: true, mode: 0o700 });
|
|
174
|
+
return isManagedSessionRuntimeDir(directory) ? directory : undefined;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Shared by tasks in one daemon runtime, while remaining outside each
|
|
181
|
+
* session's history/files area. The caller must still pass this path through
|
|
182
|
+
* the task environment before a sandboxed child can use it. */
|
|
183
|
+
export function ensureRuntimeLockDir(sessionRuntimeDir) {
|
|
184
|
+
if (!sessionRuntimeDir || !path.isAbsolute(sessionRuntimeDir))
|
|
185
|
+
return undefined;
|
|
186
|
+
if (!isManagedSessionRuntimeDir(sessionRuntimeDir) && !isRunnerOwnedSessionRuntimeDir(sessionRuntimeDir))
|
|
187
|
+
return undefined;
|
|
188
|
+
const parent = path.dirname(path.resolve(sessionRuntimeDir));
|
|
189
|
+
if (!isPrivateDirectory(parent))
|
|
190
|
+
return undefined;
|
|
191
|
+
const directory = path.join(parent, 'evolcore-locks');
|
|
192
|
+
try {
|
|
193
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
194
|
+
return isPrivateDirectory(directory) ? directory : undefined;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
30
200
|
export function runtimeRefMessageIdForMsgSend(args) {
|
|
31
201
|
const runtime = args.runtime;
|
|
32
202
|
if (!runtime)
|
|
@@ -76,5 +246,11 @@ export function buildTaskRuntimeEnv(ctx) {
|
|
|
76
246
|
return {
|
|
77
247
|
EVOLCORE_SESSION_ID: ctx.sessionId ?? '',
|
|
78
248
|
[TASK_RUNTIME_CONTEXT_ENV]: JSON.stringify(clean),
|
|
249
|
+
// Every managed child receives the session directory as TMPDIR. This keeps
|
|
250
|
+
// ordinary temporary files and runtime-only fallbacks out of shared agent
|
|
251
|
+
// or process directories, including for non-Codex runners.
|
|
252
|
+
...(clean.sessionRuntimeDir ? { TMPDIR: clean.sessionRuntimeDir } : {}),
|
|
253
|
+
...(clean.sessionRuntimeDir ? { [SESSION_RUNTIME_DIR_ENV]: clean.sessionRuntimeDir } : {}),
|
|
254
|
+
...(clean.runtimeLockDir ? { [RUNTIME_LOCK_DIR_ENV]: clean.runtimeLockDir } : {}),
|
|
79
255
|
};
|
|
80
256
|
}
|
|
@@ -49,6 +49,8 @@ export function getManagementCommandPermissions(role) {
|
|
|
49
49
|
'ec.fs.copy': { allow: true, permissionMode: 'auto', scopes: ['relation'], constraints: { groupOnly: true, currentRelationOnly: true, targetCurrentAgentOnly: true } },
|
|
50
50
|
'ec.fs.move': { allow: true, permissionMode: 'auto', scopes: ['relation'], constraints: { groupOnly: true, currentRelationOnly: true, targetCurrentAgentOnly: true } },
|
|
51
51
|
'ec.fs.remove': { allow: true, permissionMode: 'auto', scopes: ['relation'], constraints: { groupOnly: true, currentRelationOnly: true, targetCurrentAgentOnly: true } },
|
|
52
|
+
'ec.fs.getfacl': { allow: true, scopes: ['relation'], constraints: { groupOnly: true, currentRelationOnly: true, targetCurrentAgentOnly: true } },
|
|
53
|
+
'ec.fs.setfacl': { allow: true, permissionMode: 'auto', scopes: ['relation'], constraints: { groupOnly: true, currentRelationOnly: true, targetCurrentAgentOnly: true } },
|
|
52
54
|
'*': { allow: true },
|
|
53
55
|
// Reload is valid from an Agent's own channel and the daemon control plane.
|
|
54
56
|
// The control-channel gate still requires a daemon owner for cross-Agent reloads.
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
|
+
import { ensureManagedLockParent, resolveManagedLockPath } from '../core/runtime-lock.js';
|
|
15
16
|
import crypto from 'crypto';
|
|
16
17
|
import { resolvePaths, agentConfig as agentConfigPath, agentContactConfig, agentRelationConfig, agentDir, agentRelationsDir, agentRolesIndex, agentRoleConfig, } from '../paths.js';
|
|
17
18
|
import { atomicReadJson, atomicWriteJson } from '../utils/atomic-write.js';
|
|
@@ -793,12 +794,15 @@ function sortConfigValue(value) {
|
|
|
793
794
|
.map(([key, nested]) => [key, sortConfigValue(nested)]));
|
|
794
795
|
}
|
|
795
796
|
function acquireConfigMutationLock(file) {
|
|
796
|
-
const lockDir = `${file}.mutation.lock
|
|
797
|
+
const lockDir = resolveManagedLockPath('config', file, `${file}.mutation.lock`);
|
|
797
798
|
const ownerFile = path.join(lockDir, 'owner.json');
|
|
798
799
|
const token = crypto.randomBytes(16).toString('hex');
|
|
799
800
|
const candidate = `${lockDir}.candidate-${process.pid}-${token}`;
|
|
800
801
|
const candidateOwner = path.join(candidate, 'owner.json');
|
|
801
|
-
|
|
802
|
+
if (lockDir === `${file}.mutation.lock`)
|
|
803
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
804
|
+
else
|
|
805
|
+
ensureManagedLockParent(lockDir);
|
|
802
806
|
fs.mkdirSync(candidate);
|
|
803
807
|
fs.writeFileSync(candidateOwner, `${JSON.stringify({ pid: process.pid, token })}\n`, { mode: 0o600 });
|
|
804
808
|
let acquired = false;
|
|
@@ -6,6 +6,7 @@ import { isExplicitGroupId } from '../aun/group-identity.js';
|
|
|
6
6
|
import { agentConfig, agentContactConfig, agentDir, resolvePaths } from '../paths.js';
|
|
7
7
|
import { atomicReadJson, atomicWrite, atomicWriteJson } from '../utils/atomic-write.js';
|
|
8
8
|
import { fileCache } from '../core/daemon-file-cache.js';
|
|
9
|
+
import { ensureManagedLockParent, resolveManagedLockPath } from '../core/runtime-lock.js';
|
|
9
10
|
import { ConfigTarget, notifyConfigWrite, validateConfig } from './config-manager.js';
|
|
10
11
|
import { parseContactAlias } from './contact-alias.js';
|
|
11
12
|
import { clearRoleStoreCache } from './role-store.js';
|
|
@@ -426,12 +427,16 @@ function readContactBookFromDisk(selfAid) {
|
|
|
426
427
|
}
|
|
427
428
|
}
|
|
428
429
|
function acquireAgentLock(selfAid) {
|
|
429
|
-
const
|
|
430
|
+
const legacyLockDir = path.join(agentDir(selfAid), LOCK_NAME);
|
|
431
|
+
const lockDir = resolveManagedLockPath('contact', selfAid, legacyLockDir);
|
|
430
432
|
const ownerFile = path.join(lockDir, 'owner.json');
|
|
431
433
|
const lockToken = crypto.randomBytes(16).toString('hex');
|
|
432
434
|
const candidateDir = `${lockDir}.candidate-${process.pid}-${lockToken}`;
|
|
433
435
|
const candidateOwnerFile = path.join(candidateDir, 'owner.json');
|
|
434
|
-
|
|
436
|
+
if (lockDir === legacyLockDir)
|
|
437
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
438
|
+
else
|
|
439
|
+
ensureManagedLockParent(lockDir);
|
|
435
440
|
fs.mkdirSync(candidateDir);
|
|
436
441
|
fs.writeFileSync(candidateOwnerFile, `${JSON.stringify({
|
|
437
442
|
pid: process.pid,
|
|
@@ -165,6 +165,7 @@ function auditDecision(params, decision) {
|
|
|
165
165
|
auditCommandAuthorization({
|
|
166
166
|
ts: Date.now(),
|
|
167
167
|
...params.auditMetadata,
|
|
168
|
+
correlationId: params.auditMetadata?.correlationId ?? params.auditMetadata?.requestId ?? params.subject.requestId,
|
|
168
169
|
requestId: params.auditMetadata?.requestId ?? params.subject.requestId,
|
|
169
170
|
sessionId: params.auditMetadata?.sessionId ?? params.subject.sessionId,
|
|
170
171
|
agentAid: params.auditMetadata?.agentAid ?? params.subject.selfAid,
|