evolcore 0.0.11 → 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 (164) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/bin/codex-managed-hook.mjs +80 -0
  3. package/bin/ec-safe-output.js +26 -7
  4. package/dist/agents/claude-runner.js +86 -7
  5. package/dist/agents/codex-app-server-client.js +174 -20
  6. package/dist/agents/codex-runner.js +375 -92
  7. package/dist/agents/ecagent-runner.js +39 -4
  8. package/dist/agents/gemini-runner.js +1 -0
  9. package/dist/aun/aid/client.js +11 -21
  10. package/dist/aun/aid/control-aid.js +36 -13
  11. package/dist/aun/aid/managed-operation.js +107 -0
  12. package/dist/aun/aid/store.js +13 -7
  13. package/dist/aun/group-fs-download.js +56 -0
  14. package/dist/aun/group-identity.js +1 -2
  15. package/dist/aun/msg/group.js +91 -44
  16. package/dist/aun/msg/index.js +1 -2
  17. package/dist/aun/msg/managed-operation.js +865 -0
  18. package/dist/aun/msg/mention-schema.js +20 -0
  19. package/dist/aun/msg/p2p.js +7 -0
  20. package/dist/aun/msg/upload.js +16 -1
  21. package/dist/aun/outbox.js +31 -2
  22. package/dist/aun/rpc/client.js +66 -0
  23. package/dist/aun/rpc/index.js +1 -2
  24. package/dist/aun/storage/{upload.js → client.js} +11 -2
  25. package/dist/aun/storage/index.js +1 -2
  26. package/dist/channels/aun.js +655 -240
  27. package/dist/channels/daemon.js +13 -0
  28. package/dist/cli/agent-command.js +56 -8
  29. package/dist/cli/agent.js +108 -31
  30. package/dist/cli/aun-commands.js +270 -69
  31. package/dist/cli/bench.js +12 -3
  32. package/dist/cli/cli-argv.js +10 -0
  33. package/dist/cli/config.js +9 -17
  34. package/dist/cli/ctl-command.js +1 -1
  35. package/dist/cli/daemon-commands.js +70 -55
  36. package/dist/cli/fs-command.js +79 -10
  37. package/dist/cli/handoff-command.js +3 -0
  38. package/dist/cli/index.js +30 -2
  39. package/dist/cli/init.js +77 -8
  40. package/dist/cli/managed-command-guard.js +41 -0
  41. package/dist/cli/model.js +11 -4
  42. package/dist/cli/queue-command.js +53 -1
  43. package/dist/cli/response.js +71 -0
  44. package/dist/cli/restart-monitor.js +7 -32
  45. package/dist/cli/trigger-command.js +11 -0
  46. package/dist/config/access-policy.js +1 -2
  47. package/dist/config/builtin-roles.js +9 -0
  48. package/dist/config/config-manager.js +21 -1
  49. package/dist/config/contact-book-store.js +11 -3
  50. package/dist/config/contact-request-service.js +161 -15
  51. package/dist/config/mention-mode.js +8 -24
  52. package/dist/config/peer-role-resolver.js +6 -2
  53. package/dist/config/role-migration-startup.js +53 -0
  54. package/dist/config/role-store.js +1 -1
  55. package/dist/core/auth/agent-delegation.js +1 -2
  56. package/dist/core/auth/auth-gateway.js +29 -1
  57. package/dist/core/auth/authorization-audit.js +15 -0
  58. package/dist/core/auth/operation-authorizer.js +31 -7
  59. package/dist/core/auth/operation-catalog.js +196 -25
  60. package/dist/core/bootstrap-messages.js +25 -0
  61. package/dist/core/bootstrap-service.js +64 -4
  62. package/dist/core/capability/providers/claude-capability-provider.js +9 -31
  63. package/dist/core/capability/providers/codex-capability-provider.js +4 -15
  64. package/dist/core/capability/skill-discovery.js +55 -0
  65. package/dist/core/channel-loader.js +1 -1
  66. package/dist/core/command/cli-intent-parser.js +9 -0
  67. package/dist/core/command/command-handler.js +650 -42
  68. package/dist/core/command/connect-menu.js +15 -0
  69. package/dist/core/command/group-menu.js +4 -4
  70. package/dist/core/command/menu-catalog.js +370 -0
  71. package/dist/core/command/menu-handler.js +230 -51
  72. package/dist/core/command/menu-protocol.js +94 -2
  73. package/dist/core/command/slash-gate.js +5 -2
  74. package/dist/core/command/slash-handler.js +99 -31
  75. package/dist/core/daemon-file-cache.js +2 -1
  76. package/dist/core/data-migration.js +9 -2
  77. package/dist/core/event-catalog.js +6 -0
  78. package/dist/core/evolagent.js +4 -2
  79. package/dist/core/handoff/dispatcher.js +5 -2
  80. package/dist/core/handoff/runtime.js +157 -22
  81. package/dist/core/handoff/store.js +29 -1
  82. package/dist/core/handoff/types.js +1 -0
  83. package/dist/core/interaction-router.js +10 -0
  84. package/dist/core/message/im-renderer.js +19 -0
  85. package/dist/core/message/logical-queue-bridge.js +1 -0
  86. package/dist/core/message/mention-schema.js +126 -0
  87. package/dist/core/message/message-bridge.js +298 -88
  88. package/dist/core/message/message-log.js +3 -0
  89. package/dist/core/message/message-queue.js +268 -39
  90. package/dist/core/message/message-utils.js +81 -4
  91. package/dist/core/message/response-engine.js +161 -77
  92. package/dist/core/message/stream-debouncer.js +10 -2
  93. package/dist/core/permission/approval-gateway.js +1 -0
  94. package/dist/core/permission/ec-command-parser.js +243 -18
  95. package/dist/core/permission/index.js +1 -1
  96. package/dist/core/permission/mode.js +15 -0
  97. package/dist/core/permission/readonly-shell-query.js +148 -8
  98. package/dist/core/permission/sandbox-runtime.js +43 -1
  99. package/dist/core/permission/tool-policy.js +322 -57
  100. package/dist/core/protected-paths.js +114 -4
  101. package/dist/core/session/session-manager.js +42 -4
  102. package/dist/core/session/session-mapper.js +20 -1
  103. package/dist/eck/detect.js +18 -1
  104. package/dist/eck/group-rules-sync.js +15 -44
  105. package/dist/eck/manifest-engine.js +39 -13
  106. package/dist/index.js +541 -180
  107. package/dist/ipc.js +232 -10
  108. package/dist/paths.js +3 -0
  109. package/dist/response-system/coordinator.js +6 -4
  110. package/dist/response-system/engines/v1/proactive-flow.js +26 -8
  111. package/dist/response-system/modes/single-session/index.js +14 -1
  112. package/dist/response-system/selector.js +6 -5
  113. package/dist/trigger/anomaly-store.js +4 -0
  114. package/dist/trigger/feedback.js +64 -11
  115. package/dist/trigger/manager.js +21 -1
  116. package/dist/trigger/parser.js +14 -2
  117. package/dist/trigger/scheduler.js +1 -0
  118. package/dist/trigger/script-executor.js +1 -0
  119. package/dist/trigger/validation.js +67 -6
  120. package/dist/utils/codex-app-server-registry.js +90 -0
  121. package/dist/utils/codex-cli.js +5 -1
  122. package/dist/utils/cross-platform.js +15 -0
  123. package/dist/utils/ecweb-supervisor.js +332 -0
  124. package/dist/utils/error-utils.js +10 -0
  125. package/dist/utils/npm-ops.js +5 -12
  126. package/dist/utils/tool-summary.js +11 -0
  127. package/kits/docs/channels/aun.md +1 -1
  128. package/kits/docs/context-assembly.md +2 -2
  129. package/kits/docs/evolcore/agent.md +11 -6
  130. package/kits/docs/evolcore/aid.md +14 -0
  131. package/kits/docs/evolcore/trigger.md +17 -1
  132. package/kits/docs/prompt-loading-architecture.md +1 -2
  133. package/kits/docs/venues/group.md +1 -1
  134. package/kits/eck_manifest.json +0 -12
  135. package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
  136. package/kits/rules/04-relation.md +1 -1
  137. package/kits/rules/05-venue.md +2 -2
  138. package/kits/schemas/role-config.schema.1.json +1 -0
  139. package/kits/schemas/single-session.schema.1.json +3 -3
  140. package/kits/schemas/single-session.schema.2.json +3 -3
  141. package/kits/templates/roles/admin.json +27 -0
  142. package/kits/templates/roles/member.json +34 -1
  143. package/kits/templates/roles/visitor.json +35 -3
  144. package/kits/templates/system-fragments/bootstrap.md +1 -1
  145. package/kits/templates/system-fragments/channel.md +1 -1
  146. package/kits/templates/system-fragments/session.md +1 -1
  147. package/package.json +2 -2
  148. package/dist/aun/msg/payload-type.js +0 -27
  149. package/dist/aun/rpc/caller.js +0 -42
  150. package/dist/aun/rpc/connection.js +0 -25
  151. package/dist/aun/storage/manage.js +0 -10
  152. package/dist/config/access-policy-domain.js +0 -18
  153. package/dist/config/config-batch-get.js +0 -11
  154. package/dist/config/owner-policy.js +0 -4
  155. package/dist/config/role-config-v4-startup.js +0 -32
  156. package/dist/config/role-config-v5-startup.js +0 -27
  157. package/dist/core/auth/trigger-authorization.js +0 -15
  158. package/dist/core/command/evol-menu-version-gate.js +0 -39
  159. package/dist/core/interaction-registration.js +0 -10
  160. package/dist/core/permission/execution-sandbox.js +0 -16
  161. package/dist/core/relation/peer-key.js +0 -1
  162. package/dist/core/session/session-key.js +0 -24
  163. package/dist/eck/baseagent-caps.js +0 -18
  164. package/dist/eck/rules-loader.js +0 -28
@@ -7,19 +7,19 @@
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';
15
14
  import { logger } from '../utils/logger.js';
15
+ import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
16
16
  import { isRetryableError } from '../utils/error-utils.js';
17
17
  import { renderActionAsText } from '../core/interaction-router.js';
18
18
  import { buildEnvelope, sendInteractionPayload } from '../core/message/message-utils.js';
19
19
  import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
20
20
  import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
21
21
  import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
22
- import { classifyEvolcoreShellCommand, parseLiteralShellArgv } from '../core/permission/ec-command-parser.js';
22
+ import { classifyEvolcoreShellCommand, parseLiteralShellArgv, unwrapCodexShellCommandArgv } from '../core/permission/ec-command-parser.js';
23
23
  import { compareVersions } from '../utils/npm-ops.js';
24
24
  import { resolvePaths, resolveRoot } from '../paths.js';
25
25
  import { buildSessionTurnList } from '../core/session/session-turns.js';
@@ -75,6 +75,39 @@ class AsyncEventQueue {
75
75
  }
76
76
  const managedTempDirsForExitCleanup = new Map();
77
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
+ }
78
111
  function registerManagedTempDirForExitCleanup(directory, parent) {
79
112
  managedTempDirsForExitCleanup.set(directory, parent);
80
113
  if (managedTempExitCleanupRegistered)
@@ -83,10 +116,8 @@ function registerManagedTempDirForExitCleanup(directory, parent) {
83
116
  process.once('exit', () => {
84
117
  for (const [candidatePath, parentPath] of managedTempDirsForExitCleanup) {
85
118
  try {
86
- const parentCandidate = resolveProtectedCandidate(parentPath);
87
- const candidate = resolveProtectedCandidate(candidatePath);
88
- if (candidate !== parentCandidate && isSameOrDescendant(candidate, parentCandidate)) {
89
- fs.rmSync(candidate, { recursive: true, force: true });
119
+ if (isTrustedManagedTempDirectory(candidatePath, parentPath)) {
120
+ fs.rmSync(path.resolve(candidatePath), { recursive: true, force: true });
90
121
  }
91
122
  }
92
123
  catch {
@@ -118,10 +149,9 @@ const CODEX_CATALOG_FALLBACK = [
118
149
  ];
119
150
  const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
120
151
  let codexCatalogCache = null;
121
- // The permission bridge is version-locked to the v2 schema audited below:
122
- // named profiles, approvalsReviewer, additional/network approval context, and
123
- // item/permissions/requestApproval must all be present together.
124
- export const MIN_CODEX_CLI_VERSION = '0.144.1';
152
+ // The permission bridge is version-locked to the audited approval schema and
153
+ // managed PreToolUse hook behavior used by proactive first-tool enforcement.
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;
@@ -223,6 +255,8 @@ export class CodexRunner {
223
255
  trackedApprovalItems = new Map();
224
256
  threadDelegationCarriers = new Map();
225
257
  pendingInterrupts = new Map();
258
+ threadOperationLocks = new Map();
259
+ activeOperationReleases = new Map();
226
260
  appServerClient = null;
227
261
  onSessionIdUpdate;
228
262
  onCompactStart;
@@ -280,25 +314,23 @@ export class CodexRunner {
280
314
  createDelegationCarrier() {
281
315
  return randomBytes(32).toString('base64url');
282
316
  }
283
- approvedLiteralEvolcoreCommandArgv(command) {
317
+ approvedLiteralEvolcoreCommandArgv(command, managedTempDir) {
284
318
  const classification = classifyEvolcoreShellCommand(command);
285
319
  if (classification.kind === 'bounded-output')
286
320
  return classification.command.argv;
287
321
  if (classification.kind !== 'literal')
288
322
  return undefined;
289
- const argv = parseLiteralShellArgv(command);
290
- return argv?.[0] === 'ec' ? argv : undefined;
291
- }
292
- unwrapCodexShellCommandArgv(argv) {
293
- if (argv.length !== 3 || argv[1] !== '-lc')
294
- return undefined;
295
- const shell = argv[0];
296
- if (!['bash', '/bin/bash', '/usr/bin/bash', 'sh', '/bin/sh', '/usr/bin/sh'].includes(shell)) {
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')))
297
330
  return undefined;
298
- }
299
- return argv[2];
331
+ return argv?.[0] === 'ec' ? argv : undefined;
300
332
  }
301
- approvedEvolcoreCommandArgv(toolInput) {
333
+ approvedEvolcoreCommandArgv(toolInput, managedTempDir) {
302
334
  const explicitArgv = Array.isArray(toolInput.commandArgv)
303
335
  && toolInput.commandArgv.every(value => typeof value === 'string')
304
336
  ? toolInput.commandArgv
@@ -306,27 +338,30 @@ export class CodexRunner {
306
338
  if (explicitArgv?.[0] === 'ec')
307
339
  return explicitArgv;
308
340
  const explicitWrappedCommand = explicitArgv
309
- ? this.unwrapCodexShellCommandArgv(explicitArgv)
341
+ ? unwrapCodexShellCommandArgv(explicitArgv)
310
342
  : undefined;
311
343
  if (explicitWrappedCommand !== undefined) {
312
- return this.approvedLiteralEvolcoreCommandArgv(explicitWrappedCommand);
344
+ return this.approvedLiteralEvolcoreCommandArgv(explicitWrappedCommand, managedTempDir);
313
345
  }
314
346
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
315
- const directArgv = this.approvedLiteralEvolcoreCommandArgv(command);
347
+ const directArgv = this.approvedLiteralEvolcoreCommandArgv(command, managedTempDir);
316
348
  if (directArgv)
317
349
  return directArgv;
318
- const wrappedArgv = parseLiteralShellArgv(command);
350
+ const wrappedArgv = parseLiteralShellArgv(command, {
351
+ allowManagedTmpDir: true,
352
+ ...(managedTempDir ? { managedTempDir } : {}),
353
+ });
319
354
  const wrappedCommand = wrappedArgv
320
- ? this.unwrapCodexShellCommandArgv(wrappedArgv)
355
+ ? unwrapCodexShellCommandArgv(wrappedArgv)
321
356
  : undefined;
322
357
  return wrappedCommand === undefined
323
358
  ? undefined
324
- : this.approvedLiteralEvolcoreCommandArgv(wrappedCommand);
359
+ : this.approvedLiteralEvolcoreCommandArgv(wrappedCommand, managedTempDir);
325
360
  }
326
361
  armApprovedDelegationCommand(sessionId, toolInput) {
327
362
  const threadId = this.activeSessions.get(sessionId);
328
363
  const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
329
- const argv = this.approvedEvolcoreCommandArgv(toolInput);
364
+ const argv = this.approvedEvolcoreCommandArgv(toolInput, this.getManagedTempDir(sessionId));
330
365
  if (!carrierToken || !argv)
331
366
  return false;
332
367
  const commandHash = hashDelegatedCommandArgv(argv);
@@ -796,6 +831,35 @@ export class CodexRunner {
796
831
  }
797
832
  setSendPrompt(fn) { this.sendPromptFn = fn; }
798
833
  setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
834
+ async evaluatePreToolUse(threadId, toolName, toolInput) {
835
+ const sessionKey = this.findSessionKeyByThread(threadId);
836
+ const activeThread = this.activeSessions.get(sessionKey);
837
+ const context = this.permissionContexts.get(sessionKey);
838
+ if (!activeThread || activeThread !== threadId) {
839
+ return { ok: true, applicable: false };
840
+ }
841
+ const proactiveSession = context?.chatmode === 'proactive';
842
+ if (!context || (proactiveSession && !context.preToolUsePolicyHook)) {
843
+ return { ok: false, applicable: true, decision: 'deny', reason: 'EvolCore proactive session policy context is unavailable' };
844
+ }
845
+ // A tracked interactive thread still needs an authenticated, explicit
846
+ // allow so the managed hook can fail closed when no runner matches.
847
+ if (!context.preToolUsePolicyHook)
848
+ return { ok: true, applicable: true, decision: 'allow' };
849
+ const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
850
+ ? toolInput
851
+ : {};
852
+ try {
853
+ const result = context.preToolUsePolicyHook(toolName, normalizedInput);
854
+ if (result?.block) {
855
+ return { ok: true, applicable: true, decision: 'deny', reason: result.reason };
856
+ }
857
+ return { ok: true, applicable: true, decision: 'allow' };
858
+ }
859
+ catch (error) {
860
+ return { ok: false, applicable: true, decision: 'deny', reason: `policy hook failed: ${error instanceof Error ? error.message : String(error)}` };
861
+ }
862
+ }
799
863
  setPermissionGateway(gw) { this.permissionGateway = gw; }
800
864
  // ── Stream management (needed by MessageProcessor) ──
801
865
  registerStream(key, stream) {
@@ -805,13 +869,135 @@ export class CodexRunner {
805
869
  this.activeStreams.delete(key);
806
870
  this.activeAbortControllers.delete(key);
807
871
  this.pendingInterrupts.delete(key);
872
+ const release = this.activeOperationReleases.get(key);
873
+ if (release) {
874
+ this.activeOperationReleases.delete(key);
875
+ release();
876
+ }
808
877
  }
809
878
  hasActiveStream(key) {
810
879
  return this.activeStreams.has(key) || this.activeAbortControllers.has(key) || this.activeTurns.has(key);
811
880
  }
881
+ threadOperationKey(threadId, sessionId) {
882
+ // Once a thread id is known it is the shared identity, including when
883
+ // multiple EvolCore sessions happen to address the same Codex thread.
884
+ if (threadId)
885
+ return `thread:${threadId}`;
886
+ if (sessionId)
887
+ return `session:${sessionId}`;
888
+ return 'session:unknown';
889
+ }
890
+ getThreadOperationLock(key) {
891
+ const existing = this.threadOperationLocks.get(key);
892
+ if (existing)
893
+ return existing;
894
+ const lock = { aliases: new Set([key]) };
895
+ this.threadOperationLocks.set(key, lock);
896
+ return lock;
897
+ }
898
+ bindThreadOperationKeys(...keys) {
899
+ const uniqueKeys = [...new Set(keys.filter(Boolean))];
900
+ if (uniqueKeys.length < 2)
901
+ return;
902
+ const locks = uniqueKeys.map(key => this.getThreadOperationLock(key));
903
+ const primary = locks[0];
904
+ for (const other of locks.slice(1)) {
905
+ if (other === primary)
906
+ continue;
907
+ const tails = [primary.tail, other.tail].filter((tail) => !!tail);
908
+ if (tails.length === 1) {
909
+ primary.tail = tails[0];
910
+ }
911
+ else if (tails.length > 1) {
912
+ const merged = Promise.all(tails).then(() => undefined);
913
+ primary.tail = merged;
914
+ merged.then(() => {
915
+ if (primary.tail === merged)
916
+ primary.tail = undefined;
917
+ });
918
+ }
919
+ for (const alias of other.aliases) {
920
+ primary.aliases.add(alias);
921
+ this.threadOperationLocks.set(alias, primary);
922
+ }
923
+ }
924
+ }
925
+ isActiveThreadOperationAlias(key) {
926
+ if (key.startsWith('session:'))
927
+ return this.activeSessions.has(key.slice('session:'.length));
928
+ if (key.startsWith('thread:')) {
929
+ const threadId = key.slice('thread:'.length);
930
+ for (const activeThreadId of this.activeSessions.values()) {
931
+ if (activeThreadId === threadId)
932
+ return true;
933
+ }
934
+ return false;
935
+ }
936
+ return true;
937
+ }
938
+ pruneThreadOperationLocks() {
939
+ for (const [key, lock] of this.threadOperationLocks) {
940
+ if (lock.tail || this.isActiveThreadOperationAlias(key))
941
+ continue;
942
+ this.threadOperationLocks.delete(key);
943
+ lock.aliases.delete(key);
944
+ }
945
+ }
946
+ async acquireThreadOperation(key, operation) {
947
+ const lock = this.getThreadOperationLock(key);
948
+ const previous = lock.tail;
949
+ let releaseCurrent;
950
+ const current = new Promise(resolve => {
951
+ releaseCurrent = resolve;
952
+ });
953
+ lock.tail = current;
954
+ if (previous) {
955
+ logger.info(`[CodexRunner] queued ${operation} behind an active operation: ${key}`);
956
+ await previous;
957
+ }
958
+ let released = false;
959
+ return () => {
960
+ if (released)
961
+ return;
962
+ released = true;
963
+ if (lock.tail === current)
964
+ lock.tail = undefined;
965
+ releaseCurrent();
966
+ this.pruneThreadOperationLocks();
967
+ };
968
+ }
969
+ async *holdThreadOperation(stream, sessionId, release) {
970
+ try {
971
+ for await (const item of stream)
972
+ yield item;
973
+ }
974
+ finally {
975
+ if (this.activeOperationReleases.get(sessionId) === release) {
976
+ this.activeOperationReleases.delete(sessionId);
977
+ }
978
+ release();
979
+ }
980
+ }
812
981
  // ── Core: runQuery ──
813
982
  async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv) {
983
+ const knownThreadId = initialAgentSessionId || this.activeSessions.get(sessionId);
984
+ const operationKey = this.threadOperationKey(knownThreadId, sessionId);
985
+ const release = await this.acquireThreadOperation(operationKey, 'thread/resume+turn/start');
986
+ try {
987
+ const stream = await this.runQueryLocked(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv);
988
+ this.activeOperationReleases.set(sessionId, release);
989
+ return this.holdThreadOperation(stream, sessionId, release);
990
+ }
991
+ catch (error) {
992
+ release();
993
+ throw error;
994
+ }
995
+ }
996
+ async runQueryLocked(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv) {
814
997
  let agentSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
998
+ if (this.permissionContexts.get(sessionId)?.chatmode === 'proactive' && process.platform !== 'linux') {
999
+ throw new Error('Codex proactive managed PreToolUse enforcement currently requires Linux');
1000
+ }
815
1001
  const resumingThread = !!agentSessionId;
816
1002
  const callModel = modelOverride?.model || this.model;
817
1003
  const callEffort = modelOverride?.effortMode === 'model_default'
@@ -893,6 +1079,7 @@ export class CodexRunner {
893
1079
  }
894
1080
  agentSessionId = threadId;
895
1081
  this.activeSessions.set(sessionId, threadId);
1082
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
896
1083
  this.threadProjectPaths.set(threadId, path.resolve(projectPath));
897
1084
  this.bindExternalToolBoundary(threadId, externalToolConfig);
898
1085
  this.onSessionIdUpdate?.(sessionId, threadId);
@@ -1035,10 +1222,12 @@ export class CodexRunner {
1035
1222
  }
1036
1223
  if (agentSessionId) {
1037
1224
  this.activeSessions.set(sessionId, agentSessionId);
1225
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${agentSessionId}`);
1038
1226
  }
1039
1227
  else {
1040
1228
  this.activeSessions.delete(sessionId);
1041
1229
  }
1230
+ this.pruneThreadOperationLocks();
1042
1231
  this.onSessionIdUpdate?.(sessionId, agentSessionId);
1043
1232
  }
1044
1233
  async closeSession(sessionId) {
@@ -1057,6 +1246,7 @@ export class CodexRunner {
1057
1246
  this.unbindExternalToolBoundary(threadId);
1058
1247
  }
1059
1248
  this.clearTrackedApprovalItemsForThread(threadId);
1249
+ this.pruneThreadOperationLocks();
1060
1250
  }
1061
1251
  resolveSessionFile(agentSessionId, _projectPath) {
1062
1252
  // Codex session 文件: ~/.codex/sessions/YYYY/MM/DD/rollout-*-{threadId}.jsonl
@@ -1089,10 +1279,12 @@ export class CodexRunner {
1089
1279
  this.threadDelegationCarriers.delete(threadId);
1090
1280
  this.unbindExternalToolBoundary(threadId);
1091
1281
  this.clearTrackedApprovalItemsForThread(threadId);
1282
+ this.pruneThreadOperationLocks();
1092
1283
  this.onSessionIdUpdate?.(sessionId, '');
1093
1284
  return true;
1094
1285
  }
1095
1286
  async compactSession(_sessionId, agentSessionId, _projectPath) {
1287
+ const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId, _sessionId), 'compact');
1096
1288
  try {
1097
1289
  const appServer = this.getAppServerClient();
1098
1290
  this.onCompactStart?.(_sessionId);
@@ -1129,6 +1321,9 @@ export class CodexRunner {
1129
1321
  logger.error('[CodexRunner] Compact failed:', error);
1130
1322
  return false;
1131
1323
  }
1324
+ finally {
1325
+ release();
1326
+ }
1132
1327
  }
1133
1328
  async compact(sessionId, agentSessionId, projectPath) {
1134
1329
  return this.compactSession(sessionId, agentSessionId, projectPath);
@@ -1254,6 +1449,15 @@ export class CodexRunner {
1254
1449
  return newest?.path;
1255
1450
  }
1256
1451
  async forkSession(agentSessionId, projectPath, title) {
1452
+ const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId), 'fork');
1453
+ try {
1454
+ return await this.forkSessionLocked(agentSessionId, projectPath, title);
1455
+ }
1456
+ finally {
1457
+ release();
1458
+ }
1459
+ }
1460
+ async forkSessionLocked(agentSessionId, projectPath, title) {
1257
1461
  const sessionKey = this.findSessionKeyByThread(agentSessionId);
1258
1462
  const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
1259
1463
  const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
@@ -1278,18 +1482,30 @@ export class CodexRunner {
1278
1482
  return forkedThreadId;
1279
1483
  }
1280
1484
  async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
1281
- const turns = buildSessionTurnList(await this.getSessionMessages(agentSessionId, projectPath));
1282
- const targetIndex = turns.findIndex(turn => turn.assistantUuid === assistantMessageId);
1283
- if (targetIndex < 0)
1284
- throw new Error('fork point not found');
1285
- const forkedThreadId = await this.forkSession(agentSessionId, projectPath, title);
1286
- const turnsToDiscard = turns.length - targetIndex - 1;
1287
- if (turnsToDiscard > 0) {
1288
- const rolledBack = await this.rollbackSessionTurns(forkedThreadId, projectPath, turnsToDiscard);
1289
- if (!rolledBack)
1290
- throw new Error('forked thread rollback failed');
1485
+ const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId), 'forkAt');
1486
+ try {
1487
+ const turns = buildSessionTurnList(await this.getSessionMessages(agentSessionId, projectPath));
1488
+ const targetIndex = turns.findIndex(turn => turn.assistantUuid === assistantMessageId);
1489
+ if (targetIndex < 0)
1490
+ throw new Error('fork point not found');
1491
+ const forkedThreadId = await this.forkSessionLocked(agentSessionId, projectPath, title);
1492
+ const turnsToDiscard = turns.length - targetIndex - 1;
1493
+ if (turnsToDiscard > 0) {
1494
+ const rollbackRelease = await this.acquireThreadOperation(this.threadOperationKey(forkedThreadId), 'rollback');
1495
+ try {
1496
+ const rolledBack = await this.rollbackSessionTurnsLocked(forkedThreadId, turnsToDiscard);
1497
+ if (!rolledBack)
1498
+ throw new Error('forked thread rollback failed');
1499
+ }
1500
+ finally {
1501
+ rollbackRelease();
1502
+ }
1503
+ }
1504
+ return forkedThreadId;
1505
+ }
1506
+ finally {
1507
+ release();
1291
1508
  }
1292
- return forkedThreadId;
1293
1509
  }
1294
1510
  async setSessionName(agentSessionId, name) {
1295
1511
  return this.getAppServerClient().threadSetName(agentSessionId, name);
@@ -1421,6 +1637,25 @@ export class CodexRunner {
1421
1637
  // proactive 模式行为策略(首次工具调用必须是 ec msg send)
1422
1638
  const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
1423
1639
  if (policyResult?.block) {
1640
+ logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
1641
+ `requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
1642
+ `reason=${policyResult.reason ?? 'policy denied'} executed=false`);
1643
+ try {
1644
+ await permissionContext?.recordExecutionAnomaly?.({
1645
+ code: 'operation_blocked',
1646
+ severity: 'warning',
1647
+ phase: 'execution',
1648
+ occurredAt: Date.now(),
1649
+ toolName,
1650
+ ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
1651
+ policyCode: policyResult.policyCode ?? 'session_policy_hook',
1652
+ summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
1653
+ effect: 'operation_skipped',
1654
+ });
1655
+ }
1656
+ catch (error) {
1657
+ logger.warn(`[CodexRunner] failed to record policy-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
1658
+ }
1424
1659
  return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1425
1660
  }
1426
1661
  const summary = this.summarizeAppServerRequest(request.method, params);
@@ -1434,10 +1669,10 @@ export class CodexRunner {
1434
1669
  const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
1435
1670
  logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${summary}`);
1436
1671
  try {
1437
- const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd);
1672
+ const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id);
1438
1673
  if (decision !== 'deny'
1439
1674
  && (request.method === 'item/commandExecution/requestApproval' || request.method === 'execCommandApproval')
1440
- && this.approvedEvolcoreCommandArgv(toolInput)) {
1675
+ && this.approvedEvolcoreCommandArgv(toolInput, this.getManagedTempDir(sessionKey))) {
1441
1676
  if (!this.armApprovedDelegationCommand(sessionKey, toolInput)) {
1442
1677
  // Approval policy and delegation authority are separate boundaries.
1443
1678
  // If no ticket can be armed, the CLI still fails closed at IPC token
@@ -1865,10 +2100,15 @@ export class CodexRunner {
1865
2100
  channel: permCtx?.channel,
1866
2101
  peerId: permCtx?.userId,
1867
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),
1868
2108
  } : undefined;
1869
2109
  if (toolName === 'Bash')
1870
2110
  return checkReadonly(toolName, input, projectPath, readonlyContext);
1871
- return { behavior: 'deny', message: '🔒 只读模式:权限升级和文件写入请求已拒绝' };
2111
+ return { behavior: 'deny', message: '🔒 只读模式:权限升级和文件写入请求已拒绝', policyCode: 'readonly_permission_or_write' };
1872
2112
  }
1873
2113
  permissionProfiles(input) {
1874
2114
  return [input.permissions, input.additionalPermissions]
@@ -1965,7 +2205,7 @@ export class CodexRunner {
1965
2205
  return true;
1966
2206
  });
1967
2207
  }
1968
- async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath) {
2208
+ async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId) {
1969
2209
  const permissionContext = this.permissionContexts.get(sessionKey);
1970
2210
  const permissionPrompt = permissionContext?.sendPrompt ?? this.sendPromptFn;
1971
2211
  const hasFilesystemPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
@@ -1980,8 +2220,34 @@ export class CodexRunner {
1980
2220
  // per-session 权限模式(runQuery 写入);缺省回落实例级 currentMode(兼容无 runQuery 上下文的调用)
1981
2221
  const rawMode = this.chatModes.get(sessionKey) ?? this.currentMode;
1982
2222
  const mode = normalizePermissionMode(rawMode).mode;
2223
+ const recordBlockedOperation = async (policyCode, operationInput = toolInput) => {
2224
+ try {
2225
+ await permissionContext?.recordExecutionAnomaly?.({
2226
+ code: 'operation_blocked',
2227
+ severity: 'warning',
2228
+ phase: 'execution',
2229
+ occurredAt: Date.now(),
2230
+ toolName,
2231
+ ...(typeof requestId === 'string' || typeof requestId === 'number' ? { requestId: String(requestId) } : {}),
2232
+ policyCode,
2233
+ decisionSource: 'policy',
2234
+ agentAid: permissionContext?.selfAid,
2235
+ permissionMode: mode,
2236
+ summary: summarizeToolInputForAudit(toolName, operationInput).slice(0, 512),
2237
+ effect: 'operation_skipped',
2238
+ });
2239
+ }
2240
+ catch (error) {
2241
+ logger.warn(`[CodexRunner] failed to record blocked operation: session=${sessionKey} tool=${toolName} code=${policyCode} error=${error instanceof Error ? error.message : String(error)}`);
2242
+ }
2243
+ };
1983
2244
  const preflight = evaluateToolPreflight(toolName, toolInput, {
1984
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,
1985
2251
  selfAid: permissionContext?.selfAid,
1986
2252
  channel: permissionContext?.channel,
1987
2253
  userId: permissionContext?.userId,
@@ -1990,8 +2256,14 @@ export class CodexRunner {
1990
2256
  projectPath: operationCwd,
1991
2257
  workspacePath,
1992
2258
  });
1993
- if (preflight.behavior === 'deny')
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'}`);
2262
+ if (preflight.policyCode) {
2263
+ await recordBlockedOperation(preflight.policyCode);
2264
+ }
1994
2265
  return 'deny';
2266
+ }
1995
2267
  if (preflight.behavior === 'allow')
1996
2268
  return 'allow';
1997
2269
  const checkedInput = preflight.input;
@@ -2015,8 +2287,10 @@ export class CodexRunner {
2015
2287
  const grantScope = `codex:${this.permissionProfileName(sessionKey)}:${mode}`;
2016
2288
  if (mode === 'readonly') {
2017
2289
  const readonly = this.checkCodexReadonly(toolName, checkedInput, operationCwd, sessionKey);
2018
- if (readonly.behavior === 'deny')
2290
+ if (readonly.behavior === 'deny') {
2291
+ await recordBlockedOperation(readonly.policyCode ?? 'readonly_mode', checkedInput);
2019
2292
  return 'deny';
2293
+ }
2020
2294
  return 'allow';
2021
2295
  }
2022
2296
  if (toolName.startsWith('MCP:')) {
@@ -2024,13 +2298,19 @@ export class CodexRunner {
2024
2298
  }
2025
2299
  // auto 模式下检查危险命令,危险命令自动拒绝
2026
2300
  if (mode === 'auto') {
2027
- if (hasFileChangeExpansion || hasFilesystemPermissions || hasUnknownPermissions)
2301
+ if (hasFileChangeExpansion || hasFilesystemPermissions || hasUnknownPermissions) {
2302
+ await recordBlockedOperation('auto_permission_expansion', checkedInput);
2028
2303
  return 'deny';
2029
- if (toolName === 'PermissionGrant' && !hasNetworkPermissions)
2304
+ }
2305
+ if (toolName === 'PermissionGrant' && !hasNetworkPermissions) {
2306
+ await recordBlockedOperation('auto_permission_expansion', checkedInput);
2030
2307
  return 'deny';
2308
+ }
2031
2309
  const dangerous = checkDangerousCommand(toolName, checkedInput);
2032
- if (dangerous.isDangerous)
2310
+ if (dangerous.isDangerous) {
2311
+ await recordBlockedOperation('auto_dangerous_command', checkedInput);
2033
2312
  return 'deny';
2313
+ }
2034
2314
  return 'allow';
2035
2315
  }
2036
2316
  const requiresSandboxExpansionApproval = hasFileChangeExpansion
@@ -2083,6 +2363,15 @@ export class CodexRunner {
2083
2363
  throw new Error('Unsupported Codex app-server request: ' + method);
2084
2364
  }
2085
2365
  async rollbackSessionTurns(agentSessionId, _projectPath, numTurns) {
2366
+ const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId), 'rollback');
2367
+ try {
2368
+ return await this.rollbackSessionTurnsLocked(agentSessionId, numTurns);
2369
+ }
2370
+ finally {
2371
+ release();
2372
+ }
2373
+ }
2374
+ async rollbackSessionTurnsLocked(agentSessionId, numTurns) {
2086
2375
  if (numTurns < 1)
2087
2376
  return true;
2088
2377
  const response = await this.getAppServerClient().threadRollback(agentSessionId, numTurns);
@@ -2121,7 +2410,11 @@ export class CodexRunner {
2121
2410
  }
2122
2411
  readGitHeadFile(projectPath, filePath) {
2123
2412
  try {
2124
- return execFileSync('git', ['show', `HEAD:${filePath.replace(/\\/g, '/')}`], { cwd: projectPath, stdio: ['pipe', 'pipe', 'ignore'] });
2413
+ return execFileSync('git', ['show', `HEAD:${filePath.replace(/\\/g, '/')}`], {
2414
+ cwd: projectPath,
2415
+ stdio: ['pipe', 'pipe', 'ignore'],
2416
+ windowsHide: process.platform === 'win32',
2417
+ });
2125
2418
  }
2126
2419
  catch {
2127
2420
  return null;
@@ -2400,19 +2693,18 @@ export class CodexRunner {
2400
2693
  *reconcileOpenAppServerToolCalls(state, turnId) {
2401
2694
  if (state.openToolCalls.size === 0)
2402
2695
  return;
2403
- const callIds = [...state.openToolCalls.keys()];
2696
+ const openCalls = [...state.openToolCalls.entries()];
2697
+ const callIds = openCalls.map(([callId]) => callId);
2404
2698
  logger.warn(`[CodexRunner] Turn completed with ${callIds.length} open tool call(s): ` +
2405
2699
  `thread=${state.threadId} turn=${turnId || state.turnId || 'unknown'} calls=${callIds.join(',')}`);
2406
- for (const [callId, name] of state.openToolCalls) {
2407
- yield {
2408
- type: 'tool_result',
2409
- name,
2410
- result: '',
2411
- isError: true,
2412
- error: 'Codex app-server ended the turn without an item/completed notification',
2413
- callId,
2414
- };
2415
- }
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
+ };
2416
2708
  state.openToolCalls.clear();
2417
2709
  }
2418
2710
  *mapAppServerItemStarted(item, state) {
@@ -2783,39 +3075,28 @@ export class CodexRunner {
2783
3075
  }
2784
3076
  }
2785
3077
  isTrustedSessionTempDir(directory) {
2786
- try {
2787
- const rawStat = fs.lstatSync(directory);
2788
- if (!rawStat.isDirectory() || rawStat.isSymbolicLink())
2789
- return false;
2790
- const parent = resolveProtectedCandidate(this.managedTempParent());
2791
- const canonical = resolveProtectedCandidate(directory);
2792
- for (const candidate of [parent, canonical]) {
2793
- const stat = fs.lstatSync(candidate);
2794
- if (!stat.isDirectory() || stat.isSymbolicLink())
2795
- return false;
2796
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
2797
- return false;
2798
- if ((stat.mode & 0o077) !== 0)
2799
- return false;
2800
- }
2801
- return canonical !== parent && isSameOrDescendant(canonical, parent);
2802
- }
2803
- catch {
2804
- return false;
2805
- }
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);
2806
3085
  }
2807
3086
  cleanupSessionTempDir(sessionId) {
2808
3087
  const directory = this.sessionTempDirs.get(sessionId);
2809
3088
  this.sessionTempDirs.delete(sessionId);
2810
3089
  if (!directory)
2811
3090
  return;
2812
- const parent = resolveProtectedCandidate(this.managedTempParent());
2813
- const candidate = resolveProtectedCandidate(directory);
2814
- 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)) {
2815
3095
  managedTempDirsForExitCleanup.delete(directory);
2816
3096
  logger.warn(`[CodexRunner] refused to clean unmanaged TMPDIR: session=${sessionId} path=${directory}`);
2817
3097
  return;
2818
3098
  }
3099
+ const candidate = path.resolve(directory);
2819
3100
  try {
2820
3101
  fs.rmSync(candidate, { recursive: true, force: true });
2821
3102
  managedTempDirsForExitCleanup.delete(directory);
@@ -2827,7 +3108,7 @@ export class CodexRunner {
2827
3108
  managedTempInstruction(sessionId) {
2828
3109
  if (!this.ensureSessionTempDir(sessionId))
2829
3110
  return undefined;
2830
- return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。';
3111
+ return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 `/tmp/evolcore-codex-*` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 `$TMPDIR` 重新创建,再继续执行后续 `ec` 命令。';
2831
3112
  }
2832
3113
  buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
2833
3114
  const managedTempDir = this.ensureSessionTempDir(sessionId);
@@ -2877,6 +3158,8 @@ export class CodexRunner {
2877
3158
  this.pendingInterrupts.clear();
2878
3159
  this.permissionContexts.clear();
2879
3160
  this.chatModes.clear();
3161
+ this.pruneThreadOperationLocks();
3162
+ this.threadOperationLocks.clear();
2880
3163
  for (const sessionId of [...this.sessionTempDirs.keys()])
2881
3164
  this.cleanupSessionTempDir(sessionId);
2882
3165
  await this.appServerClient?.close();