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.
Files changed (41) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bin/codex-managed-hook.mjs +4 -1
  3. package/bin/install-codex-managed-hooks.mjs +201 -0
  4. package/dist/agents/claude-runner.js +53 -5
  5. package/dist/agents/codex-app-server-client.js +123 -2
  6. package/dist/agents/codex-runner.js +152 -30
  7. package/dist/agents/ecagent-runner.js +17 -1
  8. package/dist/agents/gemini-runner.js +9 -4
  9. package/dist/aun/msg/managed-operation.js +63 -3
  10. package/dist/channels/aun.js +144 -15
  11. package/dist/channels/daemon.js +2 -0
  12. package/dist/channels/feishu.js +6 -1
  13. package/dist/cli/aun-commands.js +1 -1
  14. package/dist/cli/fs-command.js +46 -9
  15. package/dist/cli/task-context.js +176 -0
  16. package/dist/config/builtin-roles.js +2 -0
  17. package/dist/config/config-manager.js +6 -2
  18. package/dist/config/contact-book-store.js +7 -2
  19. package/dist/core/auth/auth-gateway.js +1 -0
  20. package/dist/core/auth/authorization-audit.js +32 -0
  21. package/dist/core/auth/operation-catalog.js +3 -3
  22. package/dist/core/bootstrap-service.js +7 -1
  23. package/dist/core/command/command-handler.js +3 -0
  24. package/dist/core/command/slash-handler.js +1 -1
  25. package/dist/core/event-catalog.js +2 -0
  26. package/dist/core/message/im-renderer.js +15 -1
  27. package/dist/core/message/message-bridge.js +5 -2
  28. package/dist/core/message/response-engine.js +138 -10
  29. package/dist/core/permission/approval-gateway.js +99 -16
  30. package/dist/core/permission/ec-command-parser.js +556 -4
  31. package/dist/core/permission/tool-policy.js +17 -29
  32. package/dist/core/runtime-lock.js +101 -0
  33. package/dist/index.js +30 -3
  34. package/dist/response-system/engines/v1/proactive-flow.js +92 -8
  35. package/dist/response-system/modes/single-session/index.js +3 -0
  36. package/dist/trigger/history.js +42 -7
  37. package/dist/utils/error-utils.js +7 -0
  38. package/dist/utils/logger.js +37 -4
  39. package/kits/templates/roles/admin.json +2 -0
  40. package/kits/templates/roles/member.json +1 -0
  41. package/package.json +1 -1
@@ -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: finalPayload,
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
- const items = finalPayload?.items;
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(finalPayload);
3937
+ thoughtText = this.activityLogText(thoughtPayload);
3822
3938
  }
3823
- if (delivery.chatType === 'group') {
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
- const putRes = await this.callAndTrace('group.thought.put', params);
3826
- const tid = putRes?.thought_id;
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);
@@ -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 } : {}),
@@ -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
  };
@@ -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
  }
@@ -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(personal storage
38
- getfacl <AID>:<path> 查看 ACL(personal storage)
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
- if (route.backend === 'group') {
869
- throw new FsCliError('UNSUPPORTED', 'group fs 当前 SDK facade 没有 getfacl/listAcl 接口', '请在 personal storage 中使用 getfacl。');
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 {
@@ -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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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 lockDir = path.join(agentDir(selfAid), LOCK_NAME);
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
- fs.mkdirSync(path.dirname(lockDir), { recursive: true });
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,