evolcore 0.0.12 → 0.0.13

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 (104) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/ec-safe-output.js +26 -7
  3. package/dist/agents/claude-runner.js +53 -7
  4. package/dist/agents/codex-app-server-client.js +53 -5
  5. package/dist/agents/codex-runner.js +104 -59
  6. package/dist/agents/ecagent-runner.js +4 -1
  7. package/dist/aun/aid/control-aid.js +36 -13
  8. package/dist/aun/aid/store.js +13 -7
  9. package/dist/aun/group-fs-download.js +56 -0
  10. package/dist/aun/group-identity.js +1 -2
  11. package/dist/aun/msg/group.js +11 -33
  12. package/dist/aun/msg/index.js +1 -2
  13. package/dist/aun/msg/managed-operation.js +116 -55
  14. package/dist/aun/msg/upload.js +16 -1
  15. package/dist/aun/outbox.js +31 -2
  16. package/dist/aun/rpc/client.js +66 -0
  17. package/dist/aun/rpc/index.js +1 -2
  18. package/dist/aun/storage/{upload.js → client.js} +11 -2
  19. package/dist/aun/storage/index.js +1 -2
  20. package/dist/channels/aun.js +371 -136
  21. package/dist/cli/agent.js +9 -96
  22. package/dist/cli/cli-argv.js +10 -0
  23. package/dist/cli/config.js +9 -17
  24. package/dist/cli/ctl-command.js +1 -1
  25. package/dist/cli/daemon-commands.js +39 -24
  26. package/dist/cli/fs-command.js +19 -10
  27. package/dist/cli/init.js +77 -8
  28. package/dist/cli/queue-command.js +42 -0
  29. package/dist/cli/restart-monitor.js +3 -14
  30. package/dist/cli/trigger-command.js +7 -0
  31. package/dist/config/access-policy.js +1 -2
  32. package/dist/config/builtin-roles.js +5 -0
  33. package/dist/config/config-manager.js +21 -1
  34. package/dist/config/peer-role-resolver.js +6 -2
  35. package/dist/config/role-migration-startup.js +53 -0
  36. package/dist/config/role-store.js +1 -1
  37. package/dist/core/auth/agent-delegation.js +1 -1
  38. package/dist/core/auth/auth-gateway.js +29 -1
  39. package/dist/core/auth/authorization-audit.js +15 -0
  40. package/dist/core/auth/operation-authorizer.js +1 -0
  41. package/dist/core/auth/operation-catalog.js +9 -0
  42. package/dist/core/bootstrap-messages.js +9 -2
  43. package/dist/core/bootstrap-service.js +1 -0
  44. package/dist/core/command/command-handler.js +198 -51
  45. package/dist/core/command/menu-catalog.js +4 -0
  46. package/dist/core/command/menu-handler.js +85 -18
  47. package/dist/core/command/menu-protocol.js +23 -2
  48. package/dist/core/command/slash-handler.js +58 -10
  49. package/dist/core/event-catalog.js +6 -0
  50. package/dist/core/handoff/runtime.js +12 -1
  51. package/dist/core/handoff/store.js +5 -1
  52. package/dist/core/interaction-router.js +10 -0
  53. package/dist/core/message/im-renderer.js +19 -0
  54. package/dist/core/message/message-bridge.js +63 -11
  55. package/dist/core/message/message-log.js +2 -0
  56. package/dist/core/message/message-queue.js +267 -39
  57. package/dist/core/message/message-utils.js +81 -4
  58. package/dist/core/message/response-engine.js +107 -41
  59. package/dist/core/message/stream-debouncer.js +10 -2
  60. package/dist/core/permission/approval-gateway.js +1 -0
  61. package/dist/core/permission/ec-command-parser.js +93 -14
  62. package/dist/core/permission/index.js +1 -1
  63. package/dist/core/permission/mode.js +15 -0
  64. package/dist/core/permission/readonly-shell-query.js +148 -8
  65. package/dist/core/permission/sandbox-runtime.js +21 -0
  66. package/dist/core/permission/tool-policy.js +249 -27
  67. package/dist/core/protected-paths.js +107 -2
  68. package/dist/core/session/session-manager.js +21 -2
  69. package/dist/core/session/session-mapper.js +20 -1
  70. package/dist/eck/detect.js +18 -1
  71. package/dist/eck/group-rules-sync.js +15 -44
  72. package/dist/index.js +161 -86
  73. package/dist/ipc.js +16 -3
  74. package/dist/paths.js +3 -0
  75. package/dist/trigger/feedback.js +64 -11
  76. package/dist/trigger/manager.js +21 -1
  77. package/dist/trigger/parser.js +14 -2
  78. package/dist/trigger/scheduler.js +1 -0
  79. package/dist/trigger/validation.js +67 -6
  80. package/dist/utils/ecweb-supervisor.js +332 -0
  81. package/dist/utils/error-utils.js +10 -0
  82. package/kits/docs/evolcore/trigger.md +17 -1
  83. package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
  84. package/kits/schemas/role-config.schema.1.json +1 -0
  85. package/kits/templates/roles/admin.json +5 -0
  86. package/kits/templates/roles/member.json +7 -1
  87. package/kits/templates/roles/visitor.json +2 -0
  88. package/package.json +1 -1
  89. package/dist/aun/msg/payload-type.js +0 -27
  90. package/dist/aun/rpc/caller.js +0 -42
  91. package/dist/aun/rpc/connection.js +0 -25
  92. package/dist/aun/storage/manage.js +0 -10
  93. package/dist/config/access-policy-domain.js +0 -18
  94. package/dist/config/config-batch-get.js +0 -11
  95. package/dist/config/owner-policy.js +0 -4
  96. package/dist/config/role-config-v4-startup.js +0 -32
  97. package/dist/config/role-config-v5-startup.js +0 -27
  98. package/dist/core/auth/trigger-authorization.js +0 -15
  99. package/dist/core/interaction-registration.js +0 -10
  100. package/dist/core/permission/execution-sandbox.js +0 -16
  101. package/dist/core/relation/peer-key.js +0 -1
  102. package/dist/core/session/session-key.js +0 -24
  103. package/dist/eck/baseagent-caps.js +0 -18
  104. package/dist/eck/rules-loader.js +0 -28
package/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  本文件记录 EvolCore 的重要变更。EvolClaw 版本线的历史记录已归档至
4
4
  [`docs/_archive/CHANGELOG-evolclaw.md`](docs/_archive/CHANGELOG-evolclaw.md)。
5
5
 
6
+ ## 0.0.13 (2026-08-20)
7
+
8
+ ### AUN 消息与文件
9
+
10
+ - 显式区分 AUN 私聊与群聊投递路由,强化入站去重、顺序处理、消息队列持久化和失败恢复。
11
+ - 统一 AUN RPC 与存储客户端,扩展群文件写入与下载能力,收敛文件操作和载荷处理边界。
12
+ - 增加群文件下载票据校验和内容哈希验证,补齐短连接 RPC、存储上传及启动期角色配置迁移能力。
13
+
14
+ ### 权限与关系
15
+
16
+ - 收敛角色迁移、权限作用域和最小能力模式,强化 Owner、关系范围与工具执行审计边界。
17
+ - 贯通会话投递元数据和跨会话授权校验,避免托管操作越过当前 Agent、会话或关系范围。
18
+ - 强化受管 `$TMPDIR`、群规则读取和群文件上传的路径隔离,拒绝符号链接、越界目录及不符合权限/所有者要求的路径。
19
+ - 完善菜单授权审计的请求与会话关联信息,扩展只读 Shell 查询、配置批量读取和委派有效期校验。
20
+
21
+ ### 会话与运行时
22
+
23
+ - 完善消息撤回隔离、错误回传、菜单交互、重启命令和 Trigger 反馈的上下文校验。
24
+ - 简化 Agent 运行器与启动流程,修正系统重启路径并清理托管会话凭据。
25
+ - 为 Codex app-server 请求增加 request ID 与载荷指纹重放防护,使重复请求复用结果、冲突请求拒绝;会话重试按至多执行一次处理。
26
+ - 统一 EC Web 守护进程的发现、接管、健康检查和退出清理,修正 Linux 进程启动时间计算,并改进 Codex 初始化/升级探测与 Web 消息路由展示。
27
+
6
28
  ## 0.0.12 (2026-08-19)
7
29
 
8
30
  ### AUN 通信
@@ -82,12 +82,27 @@ function resolveExecutionPaths(payload) {
82
82
  return { workspace, cwd };
83
83
  }
84
84
 
85
+ function resolveManagedTempRoot() {
86
+ const raw = typeof process.env.TMPDIR === 'string' ? process.env.TMPDIR.trim() : '';
87
+ if (!raw || !path.isAbsolute(raw)) fail('managed TMPDIR is unavailable');
88
+ try {
89
+ const root = fs.realpathSync(raw);
90
+ if (!fs.statSync(root).isDirectory()) fail('managed TMPDIR is not a directory');
91
+ return root;
92
+ } catch {
93
+ fail('managed TMPDIR is unavailable');
94
+ }
95
+ }
96
+
85
97
  function openOutputFile(payload, paths) {
86
98
  if (payload.outputPath === undefined) return undefined;
87
- if (typeof payload.outputPath !== 'string' || !payload.outputPath || path.isAbsolute(payload.outputPath)) {
88
- fail('output path must be relative');
89
- }
90
- const target = path.resolve(paths.cwd, payload.outputPath);
99
+ if (typeof payload.outputPath !== 'string' || !payload.outputPath) fail('output path is invalid');
100
+
101
+ const isManagedTempOutput = path.isAbsolute(payload.outputPath);
102
+ const outputRoot = isManagedTempOutput ? resolveManagedTempRoot() : paths.workspace;
103
+ const target = isManagedTempOutput
104
+ ? path.resolve(payload.outputPath)
105
+ : path.resolve(paths.cwd, payload.outputPath);
91
106
  const parent = path.dirname(target);
92
107
  let realParent;
93
108
  try {
@@ -97,9 +112,13 @@ function openOutputFile(payload, paths) {
97
112
  fail('output parent does not exist');
98
113
  }
99
114
  const canonicalTarget = path.join(realParent, path.basename(target));
100
- if (!sameOrDescendant(canonicalTarget, paths.workspace)) fail('output path escapes workspace');
101
- const relative = path.relative(paths.workspace, canonicalTarget);
102
- if (relative === '.git' || relative.startsWith(`.git${path.sep}`)) fail('output path may not target Git metadata');
115
+ if (!sameOrDescendant(canonicalTarget, outputRoot)) {
116
+ fail(isManagedTempOutput ? 'output path escapes managed TMPDIR' : 'output path escapes workspace');
117
+ }
118
+ if (!isManagedTempOutput) {
119
+ const relative = path.relative(paths.workspace, canonicalTarget);
120
+ if (relative === '.git' || relative.startsWith(`.git${path.sep}`)) fail('output path may not target Git metadata');
121
+ }
103
122
 
104
123
  const noFollow = fs.constants.O_NOFOLLOW ?? 0;
105
124
  try {
@@ -20,10 +20,9 @@ import { getPackageRoot, resolvePaths } from '../paths.js';
20
20
  import { resolveEffective } from '../config/config-manager.js';
21
21
  import { sanitizeSessionTitle } from '../core/session/session-title.js';
22
22
  import { resolveClaudeCapabilityRunOptionsForProject } from '../core/capability/capability-manager.js';
23
- import { normalizePermissionMode } from '../core/permission/mode.js';
24
- import { resolvePhaseOneExecutionSandbox } from '../core/permission/execution-sandbox.js';
23
+ import { normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
25
24
  import { buildClaudeProtectedFilesystem, containsHClassReference, containsLClassReference, isHClassPath, isLClassPath, resolveProtectedCandidate, } from '../core/protected-paths.js';
26
- import { buildHClassGuardCommand, ensureClaudeGitWorktreeConfig, prependExecutableDirectory, resolveBubblewrapPath, shouldFailIfClaudeSandboxUnavailable, } from '../core/permission/sandbox-runtime.js';
25
+ import { buildHClassGuardCommand, createSandboxInitializationError, isSandboxInitializationFailure, SANDBOX_INITIALIZATION_FAILED, ensureClaudeGitWorktreeConfig, prependExecutableDirectory, resolveBubblewrapPath, shouldFailIfClaudeSandboxUnavailable, } from '../core/permission/sandbox-runtime.js';
27
26
  import { buildClaudeUnixSocketAllowlist } from '../core/permission/unix-socket-policy.js';
28
27
  import { contextTokensForUsage, usageForContext, isClaudeContextUsageModel, isOneMillionContextModel, realContextWindowForModel, autoCompactWindowForModel } from './runner-types.js';
29
28
  export { hasCompact, hasModelSwitcher, hasPermissionController } from './runner-types.js';
@@ -1759,6 +1758,30 @@ export class AgentRunner {
1759
1758
  else {
1760
1759
  logger.error(`[AgentRunner] Subprocess stream failed (session=${sessionId}) with no captured stderr.`);
1761
1760
  }
1761
+ if (isSandboxInitializationFailure(err, buf ?? [])) {
1762
+ const detail = createSandboxInitializationError(err, buf ?? []);
1763
+ await this.permissionContexts.get(sessionId)?.recordExecutionAnomaly?.({
1764
+ code: 'capability_unavailable',
1765
+ severity: 'warning',
1766
+ phase: 'execution',
1767
+ occurredAt: Date.now(),
1768
+ toolName: 'Bash',
1769
+ policyCode: SANDBOX_INITIALIZATION_FAILED,
1770
+ decisionSource: 'infrastructure',
1771
+ agentAid: this.permissionContexts.get(sessionId)?.selfAid,
1772
+ summary: detail.message.slice(0, 512),
1773
+ effect: 'operation_skipped',
1774
+ });
1775
+ yield {
1776
+ type: 'complete',
1777
+ isError: true,
1778
+ subtype: 'infrastructure',
1779
+ terminalReason: SANDBOX_INITIALIZATION_FAILED,
1780
+ errors: [detail.message],
1781
+ queryFinal: true,
1782
+ };
1783
+ return;
1784
+ }
1762
1785
  throw err;
1763
1786
  }
1764
1787
  finally {
@@ -1865,6 +1888,9 @@ export class AgentRunner {
1865
1888
  toolName,
1866
1889
  ...(typeof requestId === 'string' && requestId ? { requestId } : {}),
1867
1890
  policyCode: code,
1891
+ decisionSource: 'policy',
1892
+ agentAid: this.permissionContexts.get(sessionId)?.selfAid,
1893
+ permissionMode: callPermissionMode,
1868
1894
  summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
1869
1895
  effect: 'operation_skipped',
1870
1896
  });
@@ -1932,6 +1958,7 @@ export class AgentRunner {
1932
1958
  role: permCtx?.role,
1933
1959
  permissionMode: callPermissionMode,
1934
1960
  allowHostProcessCommands: true,
1961
+ allowProtectedMetadata: false,
1935
1962
  safeOutputHelperPath: path.join(getPackageRoot(), 'bin', 'ec-safe-output.js'),
1936
1963
  projectPath,
1937
1964
  });
@@ -1963,10 +1990,11 @@ export class AgentRunner {
1963
1990
  channel: permCtx?.channel,
1964
1991
  peerId: permCtx?.userId,
1965
1992
  role: permCtx?.role,
1993
+ allowProtectedMetadata: false,
1966
1994
  };
1967
1995
  const roResult = checkReadonly(toolName, toolInput, projectPath, readonlyContext);
1968
1996
  if (roResult.behavior === 'deny') {
1969
- await recordPreflightDenial(toolName, toolInput, undefined, input.tool_use_id ?? input.toolUseID, 'readonly_mode');
1997
+ await recordPreflightDenial(toolName, toolInput, undefined, input.tool_use_id ?? input.toolUseID, roResult.policyCode ?? 'readonly_mode');
1970
1998
  return hookDecision('deny', roResult.message, updatedInput);
1971
1999
  }
1972
2000
  return hookDecision('allow', undefined, updatedInput);
@@ -2052,6 +2080,7 @@ export class AgentRunner {
2052
2080
  role: permCtx?.role,
2053
2081
  permissionMode: callPermissionMode,
2054
2082
  allowHostProcessCommands: true,
2083
+ allowProtectedMetadata: false,
2055
2084
  safeOutputHelperPath: path.join(getPackageRoot(), 'bin', 'ec-safe-output.js'),
2056
2085
  projectPath,
2057
2086
  });
@@ -2119,10 +2148,11 @@ export class AgentRunner {
2119
2148
  channel: permCtx?.channel,
2120
2149
  peerId: permCtx?.userId,
2121
2150
  role: permCtx?.role,
2151
+ allowProtectedMetadata: false,
2122
2152
  };
2123
2153
  const roResult = checkReadonly(toolName, input, projectPath, readonlyContext);
2124
2154
  if (roResult.behavior === 'deny') {
2125
- await recordPreflightDenial(toolName, input, undefined, options.toolUseID, 'readonly_mode');
2155
+ await recordPreflightDenial(toolName, input, undefined, options.toolUseID, roResult.policyCode ?? 'readonly_mode');
2126
2156
  return { behavior: 'deny', message: roResult.message, decisionClassification: 'user_reject' };
2127
2157
  }
2128
2158
  return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
@@ -2373,12 +2403,28 @@ export class AgentRunner {
2373
2403
  logger.info('[AgentRunner] Creating query with images:', images.length, 'first image size:', images[0]?.data?.length ?? 0);
2374
2404
  logger.debug('[AgentRunner] Skipping resume for image message to avoid history conflict');
2375
2405
  msgStream.push(prompt, images, inputId);
2376
- sdkStream = createQuery(msgStream);
2406
+ try {
2407
+ sdkStream = createQuery(msgStream);
2408
+ }
2409
+ catch (error) {
2410
+ const stderr = this.recentStderr.get(sessionId) ?? [];
2411
+ if (isSandboxInitializationFailure(error, stderr))
2412
+ throw createSandboxInitializationError(error, stderr);
2413
+ throw error;
2414
+ }
2377
2415
  }
2378
2416
  else {
2379
2417
  logger.debug('[AgentRunner] Creating query with text only, agentSessionId:', initialClaudeSessionId);
2380
2418
  msgStream.push(prompt, undefined, inputId);
2381
- sdkStream = createQuery(msgStream, agentSessionId, resumeAt);
2419
+ try {
2420
+ sdkStream = createQuery(msgStream, agentSessionId, resumeAt);
2421
+ }
2422
+ catch (error) {
2423
+ const stderr = this.recentStderr.get(sessionId) ?? [];
2424
+ if (isSandboxInitializationFailure(error, stderr))
2425
+ throw createSandboxInitializationError(error, stderr);
2426
+ throw error;
2427
+ }
2382
2428
  }
2383
2429
  this.activeMessageStreams.set(sessionId, msgStream);
2384
2430
  this.activeQueries.set(sessionId, sdkStream);
@@ -30,6 +30,22 @@ function runtimeProviderId(baseUrl) {
30
30
  const digest = createHash('sha256').update(baseUrl || 'default').digest('hex').slice(0, 12);
31
31
  return `evolcore_runtime_${digest}`;
32
32
  }
33
+ function stableJson(value) {
34
+ if (Array.isArray(value))
35
+ return `[${value.map(stableJson).join(',')}]`;
36
+ if (value && typeof value === 'object') {
37
+ return `{${Object.entries(value)
38
+ .sort(([left], [right]) => left.localeCompare(right))
39
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
40
+ .join(',')}}`;
41
+ }
42
+ return JSON.stringify(value) ?? 'undefined';
43
+ }
44
+ function serverRequestFingerprint(request) {
45
+ return createHash('sha256')
46
+ .update(`${request.method}\n${stableJson(request.params)}`)
47
+ .digest('hex');
48
+ }
33
49
  function hasPermissionProfile(config) {
34
50
  if (!config || typeof config.default_permissions !== 'string' || !config.default_permissions)
35
51
  return false;
@@ -41,6 +57,7 @@ export class CodexAppServerClient {
41
57
  options;
42
58
  proc = null;
43
59
  pending = new Map();
60
+ serverRequestReplays = new Map();
44
61
  notificationHandlers = new Set();
45
62
  nextId = 1;
46
63
  initialized = false;
@@ -305,6 +322,7 @@ export class CodexAppServerClient {
305
322
  pending.reject(new Error('Codex app-server closed'));
306
323
  }
307
324
  this.pending.clear();
325
+ this.serverRequestReplays.clear();
308
326
  if (!proc) {
309
327
  this.cleanupManagedHookRuntime();
310
328
  return;
@@ -627,15 +645,44 @@ export class CodexAppServerClient {
627
645
  this.respondError(request.id, new Error('Unsupported Codex app-server request: ' + request.method));
628
646
  return;
629
647
  }
630
- Promise.resolve(handler(request))
648
+ const replayKey = String(request.id);
649
+ const fingerprint = serverRequestFingerprint(request);
650
+ const previous = this.serverRequestReplays.get(replayKey);
651
+ if (previous) {
652
+ if (previous.fingerprint !== fingerprint) {
653
+ const conflict = new Error(`Conflicting Codex app-server request payload for id=${request.id}`);
654
+ logger.warn(`[CodexAppServer] server request ID conflict id=${request.id} method=${request.method}`);
655
+ this.respondError(request.id, conflict);
656
+ return;
657
+ }
658
+ // A retry must receive a response too, but must never invoke the
659
+ // approval handler a second time.
660
+ this.respondFromPromise(request.id, request.method, previous.response);
661
+ return;
662
+ }
663
+ let response;
664
+ try {
665
+ // Invoke synchronously so existing app-server latency stays unchanged,
666
+ // while the explicit catch still turns a synchronous handler throw into
667
+ // the same replayable rejected response as an async failure.
668
+ response = Promise.resolve(handler(request));
669
+ }
670
+ catch (error) {
671
+ response = Promise.reject(error);
672
+ }
673
+ this.serverRequestReplays.set(replayKey, { fingerprint, response });
674
+ this.respondFromPromise(request.id, request.method, response);
675
+ }
676
+ respondFromPromise(id, method, response) {
677
+ response
631
678
  .then(result => {
632
- logger.info(`[CodexAppServer] server response id=${request.id} method=${request.method} result=${JSON.stringify(result ?? null)}`);
633
- this.respond(request.id, result ?? null);
679
+ logger.info(`[CodexAppServer] server response id=${id} method=${method} result=${JSON.stringify(result ?? null)}`);
680
+ this.respond(id, result ?? null);
634
681
  })
635
682
  .catch(error => {
636
683
  const err = error instanceof Error ? error : new Error(String(error));
637
- logger.warn(`[CodexAppServer] server request failed id=${request.id} method=${request.method}: ${err.message}`);
638
- this.respondError(request.id, err);
684
+ logger.warn(`[CodexAppServer] server request failed id=${id} method=${method}: ${err.message}`);
685
+ this.respondError(id, err);
639
686
  });
640
687
  }
641
688
  emitNotification(notification) {
@@ -676,6 +723,7 @@ export class CodexAppServerClient {
676
723
  pending.reject(error);
677
724
  }
678
725
  this.pending.clear();
726
+ this.serverRequestReplays.clear();
679
727
  this.cleanupManagedHookRuntime();
680
728
  }
681
729
  }
@@ -7,8 +7,7 @@
7
7
  */
8
8
  import { requestDangerousCommandPermission } from '../core/permission/approval-gateway.js';
9
9
  import { checkReadonly, checkDangerousCommand, evaluateToolPreflight } from '../core/permission/tool-policy.js';
10
- import { normalizePermissionMode } from '../core/permission/mode.js';
11
- import { resolvePhaseOneExecutionSandbox } from '../core/permission/execution-sandbox.js';
10
+ import { normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
12
11
  import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/protected-paths.js';
13
12
  import { CodexAppServerClient } from './codex-app-server-client.js';
14
13
  import { resolveOpenaiConfig } from './baseagent.js';
@@ -76,6 +75,39 @@ class AsyncEventQueue {
76
75
  }
77
76
  const managedTempDirsForExitCleanup = new Map();
78
77
  let managedTempExitCleanupRegistered = false;
78
+ function isTrustedManagedTempDirectory(directory, parent) {
79
+ try {
80
+ const parentPath = path.resolve(parent);
81
+ const directoryPath = path.resolve(directory);
82
+ // The session directory must be a direct child of the private parent.
83
+ // This lexical check is required before canonicalization so a replaced
84
+ // parent symlink cannot turn an old session path into another tree.
85
+ if (path.dirname(directoryPath) !== parentPath)
86
+ return false;
87
+ const trustedDirectory = (candidate) => {
88
+ const stat = fs.lstatSync(candidate);
89
+ if (!stat.isDirectory() || stat.isSymbolicLink())
90
+ return false;
91
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
92
+ return false;
93
+ return (stat.mode & 0o077) === 0;
94
+ };
95
+ if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
96
+ return false;
97
+ const canonicalParent = resolveProtectedCandidate(parentPath);
98
+ const canonicalDirectory = resolveProtectedCandidate(directoryPath);
99
+ // Keep the canonical and lexical paths identical after the lstat checks;
100
+ // this rejects symlink replacement in either node or an existing ancestor.
101
+ return canonicalParent === parentPath
102
+ && canonicalDirectory === directoryPath
103
+ && canonicalDirectory !== canonicalParent
104
+ && path.dirname(canonicalDirectory) === canonicalParent
105
+ && isSameOrDescendant(canonicalDirectory, canonicalParent);
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
79
111
  function registerManagedTempDirForExitCleanup(directory, parent) {
80
112
  managedTempDirsForExitCleanup.set(directory, parent);
81
113
  if (managedTempExitCleanupRegistered)
@@ -84,10 +116,8 @@ function registerManagedTempDirForExitCleanup(directory, parent) {
84
116
  process.once('exit', () => {
85
117
  for (const [candidatePath, parentPath] of managedTempDirsForExitCleanup) {
86
118
  try {
87
- const parentCandidate = resolveProtectedCandidate(parentPath);
88
- const candidate = resolveProtectedCandidate(candidatePath);
89
- if (candidate !== parentCandidate && isSameOrDescendant(candidate, parentCandidate)) {
90
- fs.rmSync(candidate, { recursive: true, force: true });
119
+ if (isTrustedManagedTempDirectory(candidatePath, parentPath)) {
120
+ fs.rmSync(path.resolve(candidatePath), { recursive: true, force: true });
91
121
  }
92
122
  }
93
123
  catch {
@@ -121,7 +151,7 @@ const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
121
151
  let codexCatalogCache = null;
122
152
  // The permission bridge is version-locked to the audited approval schema and
123
153
  // managed PreToolUse hook behavior used by proactive first-tool enforcement.
124
- export const MIN_CODEX_CLI_VERSION = '0.147.0';
154
+ export const MIN_CODEX_CLI_VERSION = '0.145.0';
125
155
  export function parseCodexCliVersion(output) {
126
156
  const match = output.match(/\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/);
127
157
  return match?.[1] ?? null;
@@ -144,19 +174,18 @@ export function getCodexCliVersion() {
144
174
  }
145
175
  export function getCodexAppServerAvailability() {
146
176
  if (!resolveCodexCliPath()) {
147
- const upgradeHint = '请升级 Codex CLI:npm install -g @openai/codex@latest';
148
- return { available: false, reason: `未检测到可用 Codex CLI。${upgradeHint}` };
177
+ return { available: false, reason: '未检测到 Codex CLI。' };
149
178
  }
150
179
  const version = getCodexCliVersion();
151
- const upgradeHint = '请升级 Codex CLI:npm install -g @openai/codex@latest';
152
180
  if (!version) {
153
- return { available: false, reason: `未检测到可用 Codex CLI。${upgradeHint}` };
181
+ return { available: false, reason: '无法读取 Codex CLI 版本。' };
154
182
  }
155
183
  if (!isCodexCliVersionSupported(version)) {
156
184
  return {
157
185
  available: false,
158
186
  version,
159
- reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。${upgradeHint}`,
187
+ reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。请升级 Codex CLI:npm install -g @openai/codex@latest`,
188
+ requiresUpgrade: true,
160
189
  };
161
190
  }
162
191
  try {
@@ -168,7 +197,7 @@ export function getCodexAppServerAvailability() {
168
197
  return { available: true, version };
169
198
  }
170
199
  catch {
171
- return { available: false, version, reason: `Codex CLI ${version} 不支持 app-server。${upgradeHint}` };
200
+ return { available: false, version, reason: `Codex CLI ${version} 不支持 app-server。` };
172
201
  }
173
202
  }
174
203
  export function isCodexAppServerAvailable() {
@@ -208,6 +237,9 @@ export function getCodexEfforts(model) {
208
237
  // ── Codex Runner ──
209
238
  export class CodexRunner {
210
239
  name = 'codex';
240
+ // turn/start persists a new user turn before runQuery resolves. App-server
241
+ // has no retry-in-place API for a completed or failed turn.
242
+ retryInputSemantics = 'at_most_once';
211
243
  capabilities;
212
244
  model;
213
245
  effort;
@@ -282,16 +314,23 @@ export class CodexRunner {
282
314
  createDelegationCarrier() {
283
315
  return randomBytes(32).toString('base64url');
284
316
  }
285
- approvedLiteralEvolcoreCommandArgv(command) {
317
+ approvedLiteralEvolcoreCommandArgv(command, managedTempDir) {
286
318
  const classification = classifyEvolcoreShellCommand(command);
287
319
  if (classification.kind === 'bounded-output')
288
320
  return classification.command.argv;
289
321
  if (classification.kind !== 'literal')
290
322
  return undefined;
291
- const argv = parseLiteralShellArgv(command);
323
+ // Keep the approval/delegation parser aligned with the preflight parser:
324
+ // the session-managed `$TMPDIR` token is a permitted EC path reference.
325
+ const argv = parseLiteralShellArgv(command, {
326
+ allowManagedTmpDir: true,
327
+ ...(managedTempDir ? { managedTempDir } : {}),
328
+ });
329
+ if (argv?.some(value => value.includes('$TMPDIR')))
330
+ return undefined;
292
331
  return argv?.[0] === 'ec' ? argv : undefined;
293
332
  }
294
- approvedEvolcoreCommandArgv(toolInput) {
333
+ approvedEvolcoreCommandArgv(toolInput, managedTempDir) {
295
334
  const explicitArgv = Array.isArray(toolInput.commandArgv)
296
335
  && toolInput.commandArgv.every(value => typeof value === 'string')
297
336
  ? toolInput.commandArgv
@@ -302,24 +341,27 @@ export class CodexRunner {
302
341
  ? unwrapCodexShellCommandArgv(explicitArgv)
303
342
  : undefined;
304
343
  if (explicitWrappedCommand !== undefined) {
305
- return this.approvedLiteralEvolcoreCommandArgv(explicitWrappedCommand);
344
+ return this.approvedLiteralEvolcoreCommandArgv(explicitWrappedCommand, managedTempDir);
306
345
  }
307
346
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
308
- const directArgv = this.approvedLiteralEvolcoreCommandArgv(command);
347
+ const directArgv = this.approvedLiteralEvolcoreCommandArgv(command, managedTempDir);
309
348
  if (directArgv)
310
349
  return directArgv;
311
- const wrappedArgv = parseLiteralShellArgv(command);
350
+ const wrappedArgv = parseLiteralShellArgv(command, {
351
+ allowManagedTmpDir: true,
352
+ ...(managedTempDir ? { managedTempDir } : {}),
353
+ });
312
354
  const wrappedCommand = wrappedArgv
313
355
  ? unwrapCodexShellCommandArgv(wrappedArgv)
314
356
  : undefined;
315
357
  return wrappedCommand === undefined
316
358
  ? undefined
317
- : this.approvedLiteralEvolcoreCommandArgv(wrappedCommand);
359
+ : this.approvedLiteralEvolcoreCommandArgv(wrappedCommand, managedTempDir);
318
360
  }
319
361
  armApprovedDelegationCommand(sessionId, toolInput) {
320
362
  const threadId = this.activeSessions.get(sessionId);
321
363
  const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
322
- const argv = this.approvedEvolcoreCommandArgv(toolInput);
364
+ const argv = this.approvedEvolcoreCommandArgv(toolInput, this.getManagedTempDir(sessionId));
323
365
  if (!carrierToken || !argv)
324
366
  return false;
325
367
  const commandHash = hashDelegatedCommandArgv(argv);
@@ -1630,7 +1672,7 @@ export class CodexRunner {
1630
1672
  const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id);
1631
1673
  if (decision !== 'deny'
1632
1674
  && (request.method === 'item/commandExecution/requestApproval' || request.method === 'execCommandApproval')
1633
- && this.approvedEvolcoreCommandArgv(toolInput)) {
1675
+ && this.approvedEvolcoreCommandArgv(toolInput, this.getManagedTempDir(sessionKey))) {
1634
1676
  if (!this.armApprovedDelegationCommand(sessionKey, toolInput)) {
1635
1677
  // Approval policy and delegation authority are separate boundaries.
1636
1678
  // If no ticket can be armed, the CLI still fails closed at IPC token
@@ -2058,10 +2100,15 @@ export class CodexRunner {
2058
2100
  channel: permCtx?.channel,
2059
2101
  peerId: permCtx?.userId,
2060
2102
  role: permCtx?.role,
2103
+ allowProtectedMetadata: false,
2104
+ // Revalidate ownership, mode, and parent containment at the final
2105
+ // readonly decision. The map entry alone is stale if the directory was
2106
+ // replaced between preflight and the approval callback.
2107
+ managedTempDir: this.getManagedTempDir(sessionKey),
2061
2108
  } : undefined;
2062
2109
  if (toolName === 'Bash')
2063
2110
  return checkReadonly(toolName, input, projectPath, readonlyContext);
2064
- return { behavior: 'deny', message: '🔒 只读模式:权限升级和文件写入请求已拒绝' };
2111
+ return { behavior: 'deny', message: '🔒 只读模式:权限升级和文件写入请求已拒绝', policyCode: 'readonly_permission_or_write' };
2065
2112
  }
2066
2113
  permissionProfiles(input) {
2067
2114
  return [input.permissions, input.additionalPermissions]
@@ -2183,6 +2230,9 @@ export class CodexRunner {
2183
2230
  toolName,
2184
2231
  ...(typeof requestId === 'string' || typeof requestId === 'number' ? { requestId: String(requestId) } : {}),
2185
2232
  policyCode,
2233
+ decisionSource: 'policy',
2234
+ agentAid: permissionContext?.selfAid,
2235
+ permissionMode: mode,
2186
2236
  summary: summarizeToolInputForAudit(toolName, operationInput).slice(0, 512),
2187
2237
  effect: 'operation_skipped',
2188
2238
  });
@@ -2193,6 +2243,11 @@ export class CodexRunner {
2193
2243
  };
2194
2244
  const preflight = evaluateToolPreflight(toolName, toolInput, {
2195
2245
  sessionId: sessionKey,
2246
+ // An unavailable registered root must fail closed; the policy helper
2247
+ // distinguishes this explicit empty value from runners without a
2248
+ // session-temp capability.
2249
+ managedTempDir: this.getManagedTempDir(sessionKey) ?? '',
2250
+ allowProtectedMetadata: false,
2196
2251
  selfAid: permissionContext?.selfAid,
2197
2252
  channel: permissionContext?.channel,
2198
2253
  userId: permissionContext?.userId,
@@ -2202,6 +2257,8 @@ export class CodexRunner {
2202
2257
  workspacePath,
2203
2258
  });
2204
2259
  if (preflight.behavior === 'deny') {
2260
+ logger.warn(`[CodexRunner] tool preflight denied: session=${sessionKey} tool=${toolName} ` +
2261
+ `policy=${preflight.policyCode ?? 'unknown'} reason=${preflight.message ?? 'policy denied'}`);
2205
2262
  if (preflight.policyCode) {
2206
2263
  await recordBlockedOperation(preflight.policyCode);
2207
2264
  }
@@ -2231,7 +2288,7 @@ export class CodexRunner {
2231
2288
  if (mode === 'readonly') {
2232
2289
  const readonly = this.checkCodexReadonly(toolName, checkedInput, operationCwd, sessionKey);
2233
2290
  if (readonly.behavior === 'deny') {
2234
- await recordBlockedOperation('readonly_mode', checkedInput);
2291
+ await recordBlockedOperation(readonly.policyCode ?? 'readonly_mode', checkedInput);
2235
2292
  return 'deny';
2236
2293
  }
2237
2294
  return 'allow';
@@ -2636,19 +2693,18 @@ export class CodexRunner {
2636
2693
  *reconcileOpenAppServerToolCalls(state, turnId) {
2637
2694
  if (state.openToolCalls.size === 0)
2638
2695
  return;
2639
- const callIds = [...state.openToolCalls.keys()];
2696
+ const openCalls = [...state.openToolCalls.entries()];
2697
+ const callIds = openCalls.map(([callId]) => callId);
2640
2698
  logger.warn(`[CodexRunner] Turn completed with ${callIds.length} open tool call(s): ` +
2641
2699
  `thread=${state.threadId} turn=${turnId || state.turnId || 'unknown'} calls=${callIds.join(',')}`);
2642
- for (const [callId, name] of state.openToolCalls) {
2643
- yield {
2644
- type: 'tool_result',
2645
- name,
2646
- result: '',
2647
- isError: true,
2648
- error: 'Codex app-server ended the turn without an item/completed notification',
2649
- callId,
2650
- };
2651
- }
2700
+ yield {
2701
+ type: 'notice',
2702
+ severity: 'warn',
2703
+ subtype: 'missing-item-completed',
2704
+ message: 'Turn completed before tool completion was received',
2705
+ closedToolCallIds: callIds,
2706
+ missingItems: openCalls.map(([itemId, toolName]) => ({ itemId, toolName })),
2707
+ };
2652
2708
  state.openToolCalls.clear();
2653
2709
  }
2654
2710
  *mapAppServerItemStarted(item, state) {
@@ -3019,39 +3075,28 @@ export class CodexRunner {
3019
3075
  }
3020
3076
  }
3021
3077
  isTrustedSessionTempDir(directory) {
3022
- try {
3023
- const rawStat = fs.lstatSync(directory);
3024
- if (!rawStat.isDirectory() || rawStat.isSymbolicLink())
3025
- return false;
3026
- const parent = resolveProtectedCandidate(this.managedTempParent());
3027
- const canonical = resolveProtectedCandidate(directory);
3028
- for (const candidate of [parent, canonical]) {
3029
- const stat = fs.lstatSync(candidate);
3030
- if (!stat.isDirectory() || stat.isSymbolicLink())
3031
- return false;
3032
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
3033
- return false;
3034
- if ((stat.mode & 0o077) !== 0)
3035
- return false;
3036
- }
3037
- return canonical !== parent && isSameOrDescendant(canonical, parent);
3038
- }
3039
- catch {
3040
- return false;
3041
- }
3078
+ return isTrustedManagedTempDirectory(directory, this.managedTempParent());
3079
+ }
3080
+ getManagedTempDir(sessionId) {
3081
+ const directory = this.sessionTempDirs.get(sessionId);
3082
+ if (!directory || !this.isTrustedSessionTempDir(directory))
3083
+ return undefined;
3084
+ return resolveProtectedCandidate(directory);
3042
3085
  }
3043
3086
  cleanupSessionTempDir(sessionId) {
3044
3087
  const directory = this.sessionTempDirs.get(sessionId);
3045
3088
  this.sessionTempDirs.delete(sessionId);
3046
3089
  if (!directory)
3047
3090
  return;
3048
- const parent = resolveProtectedCandidate(this.managedTempParent());
3049
- const candidate = resolveProtectedCandidate(directory);
3050
- if (candidate === parent || !isSameOrDescendant(candidate, parent)) {
3091
+ // Reuse the same ownership, mode, lexical-parent, and canonicalization
3092
+ // checks used at execution time. If the path changed, leave it in place
3093
+ // rather than recursively removing an attacker-selected target.
3094
+ if (!this.isTrustedSessionTempDir(directory)) {
3051
3095
  managedTempDirsForExitCleanup.delete(directory);
3052
3096
  logger.warn(`[CodexRunner] refused to clean unmanaged TMPDIR: session=${sessionId} path=${directory}`);
3053
3097
  return;
3054
3098
  }
3099
+ const candidate = path.resolve(directory);
3055
3100
  try {
3056
3101
  fs.rmSync(candidate, { recursive: true, force: true });
3057
3102
  managedTempDirsForExitCleanup.delete(directory);
@@ -3063,7 +3108,7 @@ export class CodexRunner {
3063
3108
  managedTempInstruction(sessionId) {
3064
3109
  if (!this.ensureSessionTempDir(sessionId))
3065
3110
  return undefined;
3066
- return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。';
3111
+ return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 `/tmp/evolcore-codex-*` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 `$TMPDIR` 重新创建,再继续执行后续 `ec` 命令。';
3067
3112
  }
3068
3113
  buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
3069
3114
  const managedTempDir = this.ensureSessionTempDir(sessionId);
@@ -967,6 +967,9 @@ export class EcagentRunner {
967
967
  occurredAt: Date.now(),
968
968
  toolName,
969
969
  policyCode,
970
+ decisionSource: 'policy',
971
+ agentAid: permissionContext?.selfAid,
972
+ permissionMode: mode,
970
973
  summary: summarizeToolInputForAudit(toolName, operationInput).slice(0, 512),
971
974
  effect: 'operation_skipped',
972
975
  });
@@ -1003,7 +1006,7 @@ export class EcagentRunner {
1003
1006
  sessionId, channel: permissionContext?.channel, peerId: permissionContext?.userId, role: permissionContext?.role,
1004
1007
  });
1005
1008
  if (decision.behavior === 'deny') {
1006
- await recordBlockedOperation('readonly_mode', checkedInput);
1009
+ await recordBlockedOperation(decision.policyCode ?? 'readonly_mode', checkedInput);
1007
1010
  return { block: true, reason: decision.message };
1008
1011
  }
1009
1012
  return {};