evolcore 0.0.20 → 0.0.22

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 (147) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +58 -9
  3. package/bin/codex-managed-hook.mjs +3 -0
  4. package/bin/install-codex-managed-hooks.mjs +3 -1
  5. package/dist/agents/baseagent.js +10 -6
  6. package/dist/agents/claude-runner.js +393 -108
  7. package/dist/agents/codex-app-server-client.js +41 -7
  8. package/dist/agents/codex-runner.js +1292 -220
  9. package/dist/agents/ecagent-runner.js +171 -61
  10. package/dist/agents/gemini-runner.js +130 -30
  11. package/dist/agents/request-identity.js +25 -0
  12. package/dist/agents/runner-types.js +19 -0
  13. package/dist/aun/aid/agentmd.js +59 -2
  14. package/dist/aun/aid/identity.js +4 -1
  15. package/dist/aun/aid/index.js +1 -1
  16. package/dist/aun/msg/group.js +72 -6
  17. package/dist/aun/msg/history.js +213 -36
  18. package/dist/aun/msg/managed-operation.js +58 -9
  19. package/dist/aun/msg/p2p.js +5 -0
  20. package/dist/aun/outbox.js +189 -80
  21. package/dist/aun/service-proxy.js +43 -25
  22. package/dist/channels/aun.js +618 -123
  23. package/dist/channels/daemon.js +6 -1
  24. package/dist/cli/agent-command.js +4 -3
  25. package/dist/cli/agent.js +66 -56
  26. package/dist/cli/aun-commands.js +177 -42
  27. package/dist/cli/command-log.js +10 -11
  28. package/dist/cli/contact.js +1 -0
  29. package/dist/cli/daemon-commands.js +98 -123
  30. package/dist/cli/init.js +27 -15
  31. package/dist/cli/task-context.js +50 -0
  32. package/dist/cli/trigger-command.js +14 -5
  33. package/dist/cli/watch-logs.js +10 -3
  34. package/dist/config/builtin-roles.js +1 -0
  35. package/dist/config/config-field-policy.js +19 -5
  36. package/dist/config/config-manager.js +167 -22
  37. package/dist/config/contact-book-store.js +25 -3
  38. package/dist/config/contact-operation-service.js +32 -1
  39. package/dist/config/contact-request-service.js +44 -0
  40. package/dist/config/daemon-services.js +186 -0
  41. package/dist/config/gateway-config.js +20 -9
  42. package/dist/config/role-service.js +54 -3
  43. package/dist/config/schema-migration.js +550 -0
  44. package/dist/config-store.js +151 -9
  45. package/dist/core/agent-application-service.js +279 -0
  46. package/dist/core/audit/log-integrity.js +102 -0
  47. package/dist/core/auth/agent-delegation.js +43 -1
  48. package/dist/core/auth/auth-gateway.js +41 -4
  49. package/dist/core/auth/authorization-audit.js +216 -8
  50. package/dist/core/auth/operation-authorizer.js +41 -1
  51. package/dist/core/auth/operation-catalog.js +9 -1
  52. package/dist/core/bootstrap-messages.js +8 -0
  53. package/dist/core/bootstrap-service.js +99 -27
  54. package/dist/core/causation/aun-association.js +7 -4
  55. package/dist/core/command/agent-control.js +56 -16
  56. package/dist/core/command/command-handler.js +311 -44
  57. package/dist/core/command/connect-menu.js +3 -4
  58. package/dist/core/command/group-menu.js +5 -7
  59. package/dist/core/command/menu-handler.js +288 -80
  60. package/dist/core/command/menu-protocol.js +1 -1
  61. package/dist/core/command/role-menu.js +21 -11
  62. package/dist/core/command/slash-gate.js +85 -18
  63. package/dist/core/command/slash-handler.js +377 -36
  64. package/dist/core/data-migration.js +11 -1
  65. package/dist/core/event-catalog.js +37 -0
  66. package/dist/core/evolagent.js +4 -0
  67. package/dist/core/handoff/dispatcher.js +4 -0
  68. package/dist/core/handoff/runtime.js +33 -3
  69. package/dist/core/handoff/store.js +32 -9
  70. package/dist/core/inference/text-inference.js +7 -15
  71. package/dist/core/message/im-renderer.js +90 -87
  72. package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
  73. package/dist/core/message/message-bridge.js +184 -12
  74. package/dist/core/message/message-log.js +47 -7
  75. package/dist/core/message/message-queue.js +227 -16
  76. package/dist/core/message/message-utils.js +12 -5
  77. package/dist/core/message/response-engine.js +658 -109
  78. package/dist/core/message/send-receipt.js +1 -0
  79. package/dist/core/message/stream-debouncer.js +9 -2
  80. package/dist/core/model/model-catalog.js +23 -15
  81. package/dist/core/model/model-diagnostics.js +28 -10
  82. package/dist/core/permission/approval-gateway.js +180 -6
  83. package/dist/core/permission/ec-command-parser.js +627 -69
  84. package/dist/core/permission/mode.js +18 -3
  85. package/dist/core/{protected-paths.js → permission/protected-paths.js} +27 -14
  86. package/dist/core/permission/readonly-shell-query.js +263 -9
  87. package/dist/core/permission/sandbox-runtime.js +159 -1
  88. package/dist/core/permission/tool-error-code.js +12 -0
  89. package/dist/core/permission/tool-policy.js +618 -23
  90. package/dist/core/session/session-fs-store.js +154 -5
  91. package/dist/core/session/session-manager.js +329 -30
  92. package/dist/core/session/session-renew.js +37 -13
  93. package/dist/core/session/session-turn-coordinator.js +16 -5
  94. package/dist/eck/kit-renderer.js +1 -1
  95. package/dist/index.js +316 -50
  96. package/dist/ipc.js +459 -29
  97. package/dist/paths.js +82 -7
  98. package/dist/response-system/context-builder.js +1 -7
  99. package/dist/response-system/engines/v1/proactive-flow.js +7 -2
  100. package/dist/stats/price-resolver.js +4 -0
  101. package/dist/trigger/anomaly-store.js +1 -0
  102. package/dist/trigger/feedback.js +70 -7
  103. package/dist/trigger/history.js +79 -4
  104. package/dist/trigger/legacy-session-history.js +2 -2
  105. package/dist/trigger/parser.js +13 -3
  106. package/dist/trigger/scheduler.js +20 -3
  107. package/dist/trigger/validation.js +6 -1
  108. package/dist/utils/atomic-write.js +27 -0
  109. package/dist/utils/ecweb-utils.js +16 -2
  110. package/dist/utils/error-utils.js +4 -1
  111. package/dist/utils/logger.js +30 -6
  112. package/dist/utils/process-tree-stats.js +24 -4
  113. package/dist/utils/process-tree-worker.js +31 -0
  114. package/dist/utils/project-path.js +1 -2
  115. package/dist/utils/tool-summary.js +59 -0
  116. package/dist/utils/windows-shell-trust.js +201 -0
  117. package/kits/docs/INDEX.md +1 -1
  118. package/kits/docs/evolcore/INDEX.md +3 -3
  119. package/kits/docs/evolcore/agent-create.md +146 -0
  120. package/kits/docs/evolcore/agent.md +6 -0
  121. package/kits/docs/evolcore/contact.md +7 -1
  122. package/kits/docs/evolcore/group-collaboration.md +251 -0
  123. package/kits/docs/evolcore/group-rules.md +1 -19
  124. package/kits/docs/evolcore/group.md +3 -1
  125. package/kits/docs/evolcore/msg.md +16 -0
  126. package/kits/docs/evolcore/trigger.md +6 -3
  127. package/kits/docs/prompt-loading-architecture.md +6 -0
  128. package/kits/eck_message_manifest.json +6 -6
  129. package/kits/schemas/_meta.json +7 -4
  130. package/kits/schemas/agent-config.schema.11.json +13 -0
  131. package/kits/schemas/agent-config.schema.12.json +427 -0
  132. package/kits/schemas/daemon.schema.5.json +0 -1
  133. package/kits/schemas/daemon.schema.6.json +131 -0
  134. package/kits/schemas/defaults.schema.5.json +15 -3
  135. package/kits/schemas/migrations/README.md +3 -1
  136. package/kits/schemas/relation-config.schema.8.json +13 -0
  137. package/kits/schemas/role-config.schema.1.json +1 -2
  138. package/kits/schemas/single-session.schema.3.json +32 -0
  139. package/kits/templates/message-fragments/item.md +1 -1
  140. package/kits/templates/roles/admin.json +1 -0
  141. package/kits/templates/roles/member.json +1 -0
  142. package/kits/templates/roles/visitor.json +1 -0
  143. package/kits/templates/system-fragments/bootstrap.md +2 -1
  144. package/kits/templates/system-fragments/commands.md +2 -2
  145. package/package.json +6 -3
  146. package/skills/eclink/SKILL.md +2 -0
  147. package/dist/config/aun-gateway-config.js +0 -2
@@ -5,10 +5,11 @@
5
5
  * Implements the same interface surface as AgentRunner (claude-runner.ts)
6
6
  * so MessageProcessor and CommandHandler can work with it transparently.
7
7
  */
8
+ import { hasTrustedFullAccessContext } from './runner-types.js';
8
9
  import { requestDangerousCommandPermission } from '../core/permission/approval-gateway.js';
9
10
  import { checkReadonly, checkDangerousCommand, evaluateToolPreflight } from '../core/permission/tool-policy.js';
10
- import { normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
11
- import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/protected-paths.js';
11
+ import { normalizeExecutionPermissionMode, normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
12
+ import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/permission/protected-paths.js';
12
13
  import { CodexAppServerClient } from './codex-app-server-client.js';
13
14
  import { resolveOpenaiConfig } from './baseagent.js';
14
15
  import { logger } from '../utils/logger.js';
@@ -16,14 +17,14 @@ import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
16
17
  import { auditCodexApprovalDecision, auditToolPreflightDenial } from '../core/auth/authorization-audit.js';
17
18
  import { isRetryableError } from '../utils/error-utils.js';
18
19
  import { renderActionAsText } from '../core/interaction-router.js';
19
- import { buildEnvelope, sendInteractionPayload } from '../core/message/message-utils.js';
20
+ import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from '../core/message/message-utils.js';
20
21
  import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
21
22
  import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
22
23
  import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
23
- import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, parseLiteralShellArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
24
+ import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, hasPowerShellDelegationAssignment, normalizeEvolcoreCommandArgv, parseCodexToolCommand, parseCodexCarrierCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
24
25
  import { compareVersions } from '../utils/npm-ops.js';
25
26
  import { resolvePaths, resolveRoot } from '../paths.js';
26
- import { ensureProcessManagedTempDir } from '../cli/task-context.js';
27
+ import { ensureProcessManagedTempDir, getManagedTaskTempDir } from '../cli/task-context.js';
27
28
  import { buildSessionTurnList } from '../core/session/session-turns.js';
28
29
  import { execFileSync } from 'child_process';
29
30
  import { execCodexCliSync, resolveCodexCliPath } from '../utils/codex-cli.js';
@@ -39,6 +40,13 @@ const MIME_EXT = {
39
40
  'image/gif': '.gif',
40
41
  'image/webp': '.webp',
41
42
  };
43
+ const APPROVAL_LOG_SUMMARY_MAX_LENGTH = 256;
44
+ export function formatApprovalLogSummary(summary) {
45
+ const singleLine = summary.replace(/\s+/gu, ' ').trim();
46
+ if (singleLine.length <= APPROVAL_LOG_SUMMARY_MAX_LENGTH)
47
+ return singleLine;
48
+ return singleLine.slice(0, APPROVAL_LOG_SUMMARY_MAX_LENGTH - 3).trimEnd() + '...';
49
+ }
42
50
  class AsyncEventQueue {
43
51
  queue = [];
44
52
  done = false;
@@ -75,6 +83,17 @@ class AsyncEventQueue {
75
83
  }
76
84
  }
77
85
  }
86
+ function errorCodeForToolDenial(decisionSource, policyCode) {
87
+ if (decisionSource === 'policy')
88
+ return 'POLICY_DENIED';
89
+ if (decisionSource === 'approval')
90
+ return 'USER_DENIED';
91
+ if (policyCode && /delegation|carrier/i.test(policyCode))
92
+ return 'DELEGATION_FAILED';
93
+ if (policyCode && /argument|canonical|quote|shell/i.test(policyCode))
94
+ return 'INVALID_ARGUMENT';
95
+ return 'EXECUTION_FAILED';
96
+ }
78
97
  const managedTempDirsForExitCleanup = new Map();
79
98
  let managedTempExitCleanupRegistered = false;
80
99
  function isTrustedManagedTempDirectory(directory, parent) {
@@ -142,6 +161,63 @@ function stableSecurityValue(value) {
142
161
  function externalToolConfigFingerprint(config) {
143
162
  return createHash('sha256').update(stableSecurityValue(config)).digest('hex');
144
163
  }
164
+ /**
165
+ * Codex can surface a managed hook rejection as item output instead of an
166
+ * app-server approval request. Preserve the policy boundary in that path too
167
+ * so downstream audit/event consumers never infer a successful execution.
168
+ */
169
+ function parseManagedPreToolUseDenial(value) {
170
+ let text;
171
+ if (typeof value === 'string')
172
+ text = value;
173
+ else if (value == null)
174
+ return undefined;
175
+ else {
176
+ try {
177
+ text = JSON.stringify(value) ?? '';
178
+ }
179
+ catch {
180
+ text = String(value);
181
+ }
182
+ }
183
+ const marker = /(?:Command|Tool call) blocked by PreToolUse hook\s*:\s*/i.exec(text);
184
+ if (!marker)
185
+ return undefined;
186
+ let reason = text.slice((marker.index ?? 0) + marker[0].length)
187
+ .replace(/\s*\.\s*(?:Command|Tool):[\s\S]*$/i, '')
188
+ .replace(/(?:^|\s)(?:Command|Tool):[\s\S]*$/i, '')
189
+ .trim();
190
+ if (!reason)
191
+ reason = 'EvolCore PreToolUse policy denied this tool call';
192
+ const policyCode = /工具调用已达到\s*\d+\s*次/.test(reason)
193
+ ? 'proactive_tool_report_required'
194
+ : /请先用\s+ec\s+(?:group\s+send|msg\s+send)/.test(reason)
195
+ ? 'proactive_first_send_required'
196
+ : 'managed_pre_tool_use_denied';
197
+ return { policyCode, reason };
198
+ }
199
+ function outputTextForManagedHook(value) {
200
+ if (typeof value === 'string')
201
+ return value;
202
+ if (value == null)
203
+ return '';
204
+ if (Array.isArray(value))
205
+ return value.map(outputTextForManagedHook).filter(Boolean).join('\n');
206
+ if (typeof value === 'object') {
207
+ const record = value;
208
+ if (typeof record.text === 'string')
209
+ return record.text;
210
+ if (typeof record.message === 'string')
211
+ return record.message;
212
+ try {
213
+ return JSON.stringify(value) ?? '';
214
+ }
215
+ catch {
216
+ return String(value);
217
+ }
218
+ }
219
+ return String(value);
220
+ }
145
221
  const CODEX_CATALOG_FALLBACK = [
146
222
  { slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
147
223
  { slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
@@ -150,6 +226,11 @@ const CODEX_CATALOG_FALLBACK = [
150
226
  { slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
151
227
  ];
152
228
  const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
229
+ const CODEX_NO_ROLLOUT_RE = /no rollout found for thread id/i;
230
+ function isCodexNoRolloutError(error) {
231
+ return error?.code === 'CODEX_ROLLOUT_NOT_FOUND'
232
+ || CODEX_NO_ROLLOUT_RE.test(error instanceof Error ? error.message : String(error));
233
+ }
153
234
  let codexCatalogCache = null;
154
235
  // The permission bridge is version-locked to the audited approval schema and
155
236
  // managed PreToolUse hook behavior used by proactive first-tool enforcement.
@@ -248,15 +329,22 @@ export class CodexRunner {
248
329
  activeAbortControllers = new Map();
249
330
  activeStreams = new Map();
250
331
  activeSessions = new Map(); // sessionId → threadId
332
+ /** Threads created/resumed for a turn but not yet durably admitted by turn/start. */
333
+ provisionalSessions = new Map();
251
334
  activeTurns = new Map();
335
+ activeSubagentTurns = new Map();
252
336
  threadProjectPaths = new Map();
337
+ /** Child Codex threads inherit the EvolCore session that started them. */
338
+ childThreadSessions = new Map();
253
339
  sessionTempDirs = new Map();
254
340
  managedTempNonce = randomBytes(16).toString('hex');
255
341
  threadExternalToolFingerprints = new Map();
256
342
  threadExternalToolBoundaries = new Map();
257
343
  trackedApprovalItems = new Map();
344
+ pendingToolDenials = new Map();
258
345
  threadDelegationCarriers = new Map();
259
346
  pendingInterrupts = new Map();
347
+ steeringTails = new Map();
260
348
  threadOperationLocks = new Map();
261
349
  activeOperationReleases = new Map();
262
350
  appServerClient = null;
@@ -267,16 +355,21 @@ export class CodexRunner {
267
355
  permissionContexts = new Map();
268
356
  resolvedConfig;
269
357
  pendingInterruptTtlMs = 30_000;
270
- constructor(config, callbacks) {
271
- this.resolvedConfig = resolveOpenaiConfig(config);
358
+ constructor(config, callbacks, runtime) {
359
+ // Compatibility for legacy direct callers that still pass the removed
360
+ // internal fields in a synthetic config. AgentLoader uses runtime binding.
361
+ const legacy = config.agents?.codex;
362
+ const binding = {
363
+ agentAid: runtime?.agentAid ?? legacy?.['evolcoreAgentAid'],
364
+ agentConfig: runtime?.agentConfig ?? legacy?.['evolcoreAgentConfig'],
365
+ };
366
+ this.resolvedConfig = resolveOpenaiConfig(config, undefined, binding);
272
367
  this.resolvedConfig.headers = buildModelRequestHeaders({
273
368
  baseagent: 'codex',
274
369
  baseUrl: this.resolvedConfig.baseUrl,
275
- agentAid: config.agents?.codex?.evolcoreAgentAid,
370
+ agentAid: this.resolvedConfig.agentAid,
276
371
  configuredHeaders: this.resolvedConfig.headers,
277
372
  });
278
- this.resolvedConfig.evolcoreAgentAid = config.agents?.codex?.evolcoreAgentAid;
279
- this.resolvedConfig.evolcoreAgentConfig = config.agents?.codex?.evolcoreAgentConfig;
280
373
  this.capabilities = {
281
374
  clear: false,
282
375
  compact: true,
@@ -288,6 +381,7 @@ export class CodexRunner {
288
381
  planApproval: false,
289
382
  // Current file rewind is intentionally degraded: it restores touched files from Git HEAD.
290
383
  fileRewind: 'git-head',
384
+ fullaccess: true,
291
385
  };
292
386
  this.model = this.resolvedConfig.model;
293
387
  if (this.resolvedConfig.effort)
@@ -316,74 +410,148 @@ export class CodexRunner {
316
410
  createDelegationCarrier() {
317
411
  return randomBytes(32).toString('base64url');
318
412
  }
319
- approvedLiteralEvolcoreCommandArgv(command, managedTempDir, dialect = 'posix') {
413
+ approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix', expectedDelegationToken, executable) {
320
414
  const parseOptions = {
321
415
  allowManagedTmpDir: true,
322
416
  dialect,
417
+ verifyShellExecutable: process.platform === 'win32',
323
418
  ...(managedTempDir ? { managedTempDir } : {}),
419
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
324
420
  };
325
421
  const classification = classifyEvolcoreShellCommand(command, parseOptions);
326
422
  if (classification.kind === 'bounded-output')
327
- return classification.command.argv;
328
- if (classification.kind !== 'literal')
329
- return undefined;
423
+ return { argv: classification.command.argv };
330
424
  // Keep the approval/delegation parser aligned with the preflight parser:
331
425
  // the session-managed `$TMPDIR` token is a permitted EC path reference.
332
- const argv = parseLiteralShellArgv(command, parseOptions);
333
- if (argv && containsLiteralManagedTmpDirOutsideSendContent(argv))
334
- return undefined;
335
- return argv?.[0] === 'ec' ? argv : undefined;
426
+ const parsed = executable
427
+ ? parseCodexCarrierCommand({ command, dialect, executable }, parseOptions)
428
+ : dialect === 'powershell'
429
+ ? parseCodexCarrierCommand({ command, dialect }, parseOptions)
430
+ : parseLiteralShellCommand(command, parseOptions);
431
+ if (!parsed.ok)
432
+ return { parseFailure: parsed };
433
+ if (normalizeEvolcoreCommandArgv(parsed.argv)[0] !== 'ec')
434
+ return {};
435
+ if (containsLiteralManagedTmpDirOutsideSendContent(parsed.argv)) {
436
+ return { parseFailure: {
437
+ issue: 'unsafe-expansion',
438
+ offset: 0,
439
+ tokenIndex: 0,
440
+ dialect: dialect ?? 'posix',
441
+ inputLength: command.length,
442
+ } };
443
+ }
444
+ return {
445
+ argv: normalizeEvolcoreCommandArgv(parsed.argv),
446
+ ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
447
+ };
336
448
  }
337
- approvedEvolcoreCommandArgv(toolInput, managedTempDir) {
449
+ resolveCanonicalEcApproval(toolInput, managedTempDir, expectedDelegationToken) {
338
450
  const explicitArgv = Array.isArray(toolInput.commandArgv)
339
451
  && toolInput.commandArgv.every(value => typeof value === 'string')
340
452
  ? toolInput.commandArgv
341
453
  : undefined;
342
- if (explicitArgv?.[0] === 'ec') {
343
- return explicitArgv.some(value => value.includes('\0'))
344
- || containsLiteralManagedTmpDirOutsideSendContent(explicitArgv)
345
- ? undefined
346
- : explicitArgv;
454
+ if (explicitArgv && normalizeEvolcoreCommandArgv(explicitArgv)[0] === 'ec') {
455
+ if (explicitArgv.some(value => value.includes('\0'))) {
456
+ return { parseFailure: {
457
+ issue: 'invalid-control-char', offset: 0,
458
+ tokenIndex: explicitArgv.findIndex(value => value.includes('\0')),
459
+ dialect: 'posix', inputLength: 0,
460
+ } };
461
+ }
462
+ return containsLiteralManagedTmpDirOutsideSendContent(explicitArgv)
463
+ ? { parseFailure: { issue: 'unsafe-expansion', offset: 0, tokenIndex: 0, dialect: 'posix', inputLength: 0 } }
464
+ : { argv: normalizeEvolcoreCommandArgv(explicitArgv) };
347
465
  }
348
466
  const explicitCarrier = explicitArgv
349
467
  ? resolveCodexShellCarrierArgv(explicitArgv)
350
468
  : undefined;
351
469
  if (explicitCarrier !== undefined) {
352
- return this.approvedLiteralEvolcoreCommandArgv(explicitCarrier.command, managedTempDir, explicitCarrier.dialect);
470
+ return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect, expectedDelegationToken, explicitCarrier.executable);
471
+ }
472
+ if (hasCodexCmdCarrierEcIntent(toolInput)) {
473
+ const parsed = parseCodexToolCommand(toolInput, {
474
+ verifyShellExecutable: process.platform === 'win32',
475
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
476
+ });
477
+ if (!parsed.ok)
478
+ return { parseFailure: parsed };
479
+ if (parsed.argv[0] === 'ec') {
480
+ return containsLiteralManagedTmpDirOutsideSendContent(parsed.argv)
481
+ ? { parseFailure: {
482
+ issue: 'unsafe-expansion', offset: 0, tokenIndex: 0,
483
+ dialect: 'cmd', inputLength: 0,
484
+ } }
485
+ : {
486
+ argv: normalizeEvolcoreCommandArgv(parsed.argv),
487
+ ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
488
+ };
489
+ }
490
+ const command = typeof toolInput.command === 'string' ? toolInput.command : '';
491
+ const classification = classifyEvolcoreShellCommand(command, { dialect: 'cmd' });
492
+ return {
493
+ parseFailure: {
494
+ issue: classification.kind === 'composite' ? classification.issue : 'shell-composition',
495
+ offset: 0,
496
+ tokenIndex: 0,
497
+ dialect: 'cmd',
498
+ inputLength: command.length,
499
+ },
500
+ };
353
501
  }
354
502
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
355
- const directArgv = this.approvedLiteralEvolcoreCommandArgv(command, managedTempDir);
356
- if (directArgv)
357
- return directArgv;
358
- const wrappedArgv = parseLiteralShellArgv(command, {
503
+ const canonicalCommand = parseCodexShellCommandArgv(command, {
359
504
  allowManagedTmpDir: true,
505
+ verifyShellExecutable: process.platform === 'win32',
360
506
  ...(managedTempDir ? { managedTempDir } : {}),
507
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
361
508
  });
362
- const shellCarrier = wrappedArgv
363
- ? resolveCodexShellCarrierArgv(wrappedArgv)
364
- : undefined;
365
- return shellCarrier === undefined
366
- ? undefined
367
- : this.approvedLiteralEvolcoreCommandArgv(shellCarrier.command, managedTempDir, shellCarrier.dialect);
509
+ if (canonicalCommand?.[0] === 'ec') {
510
+ return containsLiteralManagedTmpDirOutsideSendContent(canonicalCommand)
511
+ ? { parseFailure: {
512
+ issue: 'unsafe-expansion',
513
+ offset: 0,
514
+ tokenIndex: 0,
515
+ dialect: 'posix',
516
+ inputLength: command.length,
517
+ } }
518
+ : { argv: canonicalCommand };
519
+ }
520
+ const outer = parseLiteralShellCommand(command, {
521
+ allowManagedTmpDir: true,
522
+ ...(managedTempDir ? { managedTempDir } : {}),
523
+ });
524
+ if (!outer.ok) {
525
+ const classification = classifyEvolcoreShellCommand(command, {
526
+ allowManagedTmpDir: true,
527
+ ...(managedTempDir ? { managedTempDir } : {}),
528
+ });
529
+ return classification.kind === 'composite' ? { parseFailure: outer } : {};
530
+ }
531
+ return {};
368
532
  }
369
533
  isManagedEvolcoreCommandIntent(toolInput) {
370
534
  if (Array.isArray(toolInput.commandArgv)) {
371
535
  const rawArgv = toolInput.commandArgv;
372
- if (rawArgv[0] === 'ec')
373
- return true;
374
536
  if (!rawArgv.every(value => typeof value === 'string'))
375
537
  return false;
538
+ if (normalizeEvolcoreCommandArgv(rawArgv)[0] === 'ec')
539
+ return true;
376
540
  const shellCarrier = resolveCodexShellCarrierArgv(rawArgv);
377
- return shellCarrier !== undefined
378
- && classifyEvolcoreShellCommand(shellCarrier.command, {
379
- dialect: shellCarrier.dialect,
380
- }).kind !== 'none';
541
+ if (shellCarrier?.dialect === 'powershell'
542
+ && hasPowerShellDelegationAssignment(shellCarrier.command))
543
+ return true;
544
+ return hasCodexCmdCarrierEcIntent(toolInput)
545
+ || shellCarrier !== undefined
546
+ && classifyEvolcoreShellCommand(shellCarrier.command, {
547
+ dialect: shellCarrier.dialect,
548
+ }).kind !== 'none';
381
549
  }
382
550
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
383
551
  return classifyEvolcoreShellCommand(command).kind !== 'none';
384
552
  }
385
553
  armApprovedDelegationCommand(sessionId, argv) {
386
- const threadId = this.activeSessions.get(sessionId);
554
+ const threadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
387
555
  const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
388
556
  const commandHash = hashDelegatedCommandArgv(argv);
389
557
  const arm = this.permissionContexts.get(sessionId)?.armApprovedDelegationCommand;
@@ -397,6 +565,21 @@ export class CodexRunner {
397
565
  return false;
398
566
  }
399
567
  }
568
+ delegationCarrierForSession(sessionId, threadId) {
569
+ const candidates = [
570
+ threadId,
571
+ this.activeSessions.get(sessionId),
572
+ this.provisionalSessions.get(sessionId),
573
+ ];
574
+ for (const candidate of candidates) {
575
+ if (!candidate)
576
+ continue;
577
+ const carrier = this.threadDelegationCarriers.get(candidate);
578
+ if (carrier)
579
+ return carrier;
580
+ }
581
+ return undefined;
582
+ }
400
583
  async readThreadDelegationCarrier(appServer, threadId) {
401
584
  try {
402
585
  const result = await appServer.threadShellCommand(threadId, `node -e "process.stdout.write(process.env.${AGENT_DELEGATION_TOKEN_ENV}||String())"`);
@@ -429,9 +612,51 @@ export class CodexRunner {
429
612
  return undefined;
430
613
  }
431
614
  }
615
+ async commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn) {
616
+ if (this.provisionalSessions.get(sessionId) !== threadId)
617
+ return false;
618
+ const bindingResult = this.onSessionIdUpdate
619
+ ? ((turn === undefined
620
+ ? await this.onSessionIdUpdate(sessionId, threadId)
621
+ : await this.onSessionIdUpdate(sessionId, threadId, { turn }))
622
+ ?? 'legacy_updated')
623
+ : 'legacy_updated';
624
+ const bindingAccepted = bindingResult === 'activated'
625
+ || bindingResult === 'already_active'
626
+ || bindingResult === 'legacy_updated';
627
+ if (!bindingAccepted) {
628
+ const error = new Error(`backend discovery rejected: ${bindingResult}`);
629
+ error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
630
+ throw error;
631
+ }
632
+ this.provisionalSessions.delete(sessionId);
633
+ this.activeSessions.set(sessionId, threadId);
634
+ if (carrierToPublish) {
635
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
636
+ await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
637
+ }
638
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
639
+ return true;
640
+ }
641
+ cleanupProvisionalThread(sessionId, threadId) {
642
+ if (this.provisionalSessions.get(sessionId) !== threadId)
643
+ return;
644
+ this.provisionalSessions.delete(sessionId);
645
+ this.threadProjectPaths.delete(threadId);
646
+ this.threadDelegationCarriers.delete(threadId);
647
+ this.unbindExternalToolBoundary(threadId);
648
+ this.clearTrackedApprovalItemsForThread(threadId);
649
+ // A stale provisional start can finish after a replacement run has
650
+ // already activated another parent thread for this session. Do not tear
651
+ // down the replacement run's child mappings in that race.
652
+ const activeParentThread = this.activeSessions.get(sessionId);
653
+ if (!activeParentThread || activeParentThread === threadId) {
654
+ this.cleanupChildThreadsForSession(sessionId);
655
+ }
656
+ this.pruneThreadOperationLocks();
657
+ }
432
658
  async persistDelegationCarrier(sessionManager, sessionId, threadId, carrier) {
433
- if (typeof sessionManager?.getSessionById !== 'function'
434
- || typeof sessionManager?.updateSession !== 'function')
659
+ if (typeof sessionManager?.getSessionById !== 'function')
435
660
  return;
436
661
  try {
437
662
  const session = await sessionManager.getSessionById(sessionId);
@@ -440,12 +665,16 @@ export class CodexRunner {
440
665
  const current = session.metadata?.[CODEX_DELEGATION_CARRIER_METADATA_KEY];
441
666
  if (current?.threadId === threadId && current?.carrier === carrier)
442
667
  return;
443
- await sessionManager.updateSession(sessionId, {
444
- metadata: {
445
- ...(session.metadata ?? {}),
668
+ if (typeof sessionManager.patchSessionMetadata === 'function') {
669
+ await sessionManager.patchSessionMetadata(sessionId, {
446
670
  [CODEX_DELEGATION_CARRIER_METADATA_KEY]: { threadId, carrier },
447
- },
448
- });
671
+ });
672
+ }
673
+ else if (typeof sessionManager.updateSession === 'function') {
674
+ await sessionManager.updateSession(sessionId, {
675
+ metadata: { [CODEX_DELEGATION_CARRIER_METADATA_KEY]: { threadId, carrier } },
676
+ });
677
+ }
449
678
  }
450
679
  catch (error) {
451
680
  logger.warn(`[CodexRunner] failed to persist delegation carrier: session=${sessionId} thread=${threadId} error=${error}`);
@@ -456,7 +685,15 @@ export class CodexRunner {
456
685
  this.appServerClient = null;
457
686
  this.threadExternalToolFingerprints.clear();
458
687
  this.threadExternalToolBoundaries.clear();
688
+ for (const childThreadId of this.childThreadSessions.keys())
689
+ this.threadProjectPaths.delete(childThreadId);
690
+ // Child turn ids belong to the discarded app-server connection. Keeping
691
+ // them would make a later session interrupt attempt target stale threads
692
+ // through the replacement client.
693
+ this.activeSubagentTurns.clear();
694
+ this.childThreadSessions.clear();
459
695
  this.trackedApprovalItems.clear();
696
+ this.pendingToolDenials.clear();
460
697
  this.threadDelegationCarriers.clear();
461
698
  client?.close().catch(error => {
462
699
  logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
@@ -485,7 +722,7 @@ export class CodexRunner {
485
722
  return Object.keys(merged).length > 0 ? merged : null;
486
723
  }
487
724
  async resolveCapabilityThreadConfig(projectPath) {
488
- const agentConfig = this.resolvedConfig.evolcoreAgentConfig;
725
+ const agentConfig = this.resolvedConfig.agentConfig;
489
726
  if (!agentConfig) {
490
727
  return {
491
728
  skills: { config: [{ name: 'eclink', enabled: false }] },
@@ -732,6 +969,8 @@ export class CodexRunner {
732
969
  chatModes = new Map();
733
970
  /** 将权限模式映射为 Codex app-server 的 approvalPolicy(纯函数,无副作用,供 per-call 派生用)。 */
734
971
  toApprovalPolicy(mode) {
972
+ if (mode === 'fullaccess')
973
+ return 'never';
735
974
  // `untrusted` guarantees that commands outside Codex's trusted read-only
736
975
  // set reach the EvolCore bridge for EvolCore's per-mode decision.
737
976
  void mode;
@@ -814,6 +1053,12 @@ export class CodexRunner {
814
1053
  filesystem: {
815
1054
  ...buildCodexProtectedFilesystemRules(resolveRoot(), {
816
1055
  denyLClassRead: mode === 'readonly',
1056
+ // The app-server itself is launched behind EvolCore's Linux
1057
+ // H-class guard, which already mounts every L-class directory
1058
+ // read-only. Avoid Codex's per-file glob expansion inside that
1059
+ // nested mount namespace; it can fail while creating a missing
1060
+ // target such as triggers/history.jsonl.
1061
+ expandLClassReadDeny: process.platform !== 'linux',
817
1062
  }),
818
1063
  ...this.resolveGitMetadataWriteRules(projectPath, mode),
819
1064
  ...(bootstrapAgentMdPath ? {
@@ -821,9 +1066,9 @@ export class CodexRunner {
821
1066
  } : {}),
822
1067
  ...(mode !== 'readonly' && managedTempDir ? {
823
1068
  [managedTempDir]: 'write',
824
- [path.join(managedTempDir, '**')]: 'write',
1069
+ [managedTempDir + '/**']: 'write',
825
1070
  [path.join(path.dirname(managedTempDir), 'evolcore-locks')]: 'write',
826
- [path.join(path.dirname(managedTempDir), 'evolcore-locks', '**')]: 'write',
1071
+ [path.join(path.dirname(managedTempDir), 'evolcore-locks') + '/**']: 'write',
827
1072
  } : {}),
828
1073
  },
829
1074
  network,
@@ -841,14 +1086,17 @@ export class CodexRunner {
841
1086
  sandbox: decision.state === 'off' ? 'danger-full-access' : undefined,
842
1087
  };
843
1088
  }
844
- buildLifecycleLockdownConfig() {
1089
+ isTrustedFullAccessSession(sessionKey) {
1090
+ return hasTrustedFullAccessContext(this.permissionContexts.get(sessionKey));
1091
+ }
1092
+ buildLifecycleLockdownConfig(permissionMode) {
845
1093
  return {
846
1094
  features: {
847
1095
  // The app-server is launched with allow_managed_hooks_only=true, so
848
1096
  // enabling the feature exposes only EvolCore's requirements-managed
849
1097
  // PreToolUse hook. Disabling it here suppresses that managed hook too
850
1098
  // and bypasses the pause/proactive policy boundary entirely.
851
- hooks: true,
1099
+ hooks: permissionMode !== 'fullaccess',
852
1100
  },
853
1101
  };
854
1102
  }
@@ -867,50 +1115,145 @@ export class CodexRunner {
867
1115
  }
868
1116
  setSendPrompt(fn) { this.sendPromptFn = fn; }
869
1117
  setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
1118
+ injectUserMessage(sessionId, text) {
1119
+ // Bind queued reminders to the turn that generated them. A completed
1120
+ // turn may be replaced before the serialized steer reaches app-server;
1121
+ // injecting the old reminder into the new turn would corrupt its context.
1122
+ const requestedTurn = this.activeTurns.get(sessionId);
1123
+ if (!requestedTurn) {
1124
+ logger.warn(`[CodexRunner] turn steer skipped because no active turn is registered: session=${sessionId}`);
1125
+ return;
1126
+ }
1127
+ const previous = this.steeringTails.get(sessionId) ?? Promise.resolve();
1128
+ let current;
1129
+ current = previous.catch(() => undefined).then(async () => {
1130
+ const activeTurn = this.activeTurns.get(sessionId);
1131
+ const sameTurn = !!activeTurn
1132
+ && activeTurn.threadId === requestedTurn.threadId
1133
+ && activeTurn.turnId === requestedTurn.turnId;
1134
+ if (!sameTurn) {
1135
+ logger.warn(`[CodexRunner] turn steer skipped because the originating turn is no longer active: `
1136
+ + `session=${sessionId} thread=${requestedTurn.threadId} turn=${requestedTurn.turnId}`);
1137
+ return;
1138
+ }
1139
+ const input = [{ type: 'text', text, text_elements: [] }];
1140
+ const client = this.getAppServerClient();
1141
+ if (typeof client.turnSteer !== 'function') {
1142
+ logger.warn(`[CodexRunner] turn steer unavailable in app-server client: session=${sessionId}`);
1143
+ return;
1144
+ }
1145
+ try {
1146
+ const response = await client.turnSteer(activeTurn.threadId, activeTurn.turnId, input);
1147
+ logger.info(`[CodexRunner] turn steer delivered: session=${sessionId} thread=${activeTurn.threadId} ` +
1148
+ `turn=${response.turnId} chars=${text.length}`);
1149
+ }
1150
+ catch (error) {
1151
+ logger.warn(`[CodexRunner] turn steer failed: session=${sessionId} thread=${activeTurn.threadId} ` +
1152
+ `turn=${activeTurn.turnId} error=${error instanceof Error ? error.message : String(error)}`);
1153
+ }
1154
+ }).finally(() => {
1155
+ if (this.steeringTails.get(sessionId) === current)
1156
+ this.steeringTails.delete(sessionId);
1157
+ });
1158
+ this.steeringTails.set(sessionId, current);
1159
+ }
870
1160
  async assertProactiveHookAvailability(sessionId, appServer) {
871
1161
  if (this.permissionContexts.get(sessionId)?.chatmode !== 'proactive')
872
1162
  return;
873
- if (process.platform === 'win32') {
874
- await appServer.assertManagedHooksAvailable();
875
- return;
876
- }
877
- if (process.platform !== 'linux') {
1163
+ if (process.platform !== 'linux' && process.platform !== 'win32') {
878
1164
  throw new Error('Codex proactive managed PreToolUse enforcement requires Linux or an installed Windows managed hook');
879
1165
  }
1166
+ await appServer.assertManagedHooksAvailable();
880
1167
  }
881
- async evaluatePreToolUse(threadId, toolName, toolInput, signal) {
1168
+ async evaluatePreToolUse(threadId, toolName, toolInput, signal, callId) {
882
1169
  const sessionKey = this.findSessionKeyByThread(threadId);
883
- const activeThread = this.activeSessions.get(sessionKey);
1170
+ const activeThread = this.activeSessions.get(sessionKey) ?? this.provisionalSessions.get(sessionKey);
884
1171
  const context = this.permissionContexts.get(sessionKey);
885
- if (!activeThread || activeThread !== threadId) {
1172
+ const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
1173
+ ? toolInput
1174
+ : {};
1175
+ const recordPreToolUseDenial = async (policyCode, reason) => {
1176
+ const summary = summarizeToolInputForAudit(toolName, normalizedInput).slice(0, 512);
1177
+ auditToolPreflightDenial({
1178
+ toolName,
1179
+ policyCode,
1180
+ reason,
1181
+ summary,
1182
+ sessionId: sessionKey,
1183
+ agentAid: context?.selfAid,
1184
+ agentName: context?.agentName,
1185
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1186
+ channel: context?.channel,
1187
+ actorId: context?.userId,
1188
+ role: context?.role,
1189
+ selfAid: context?.selfAid,
1190
+ callId,
1191
+ correlationId: callId,
1192
+ taskId: context?.taskId,
1193
+ });
1194
+ try {
1195
+ await context?.recordExecutionAnomaly?.({
1196
+ code: 'operation_blocked',
1197
+ severity: 'warning',
1198
+ phase: 'execution',
1199
+ occurredAt: Date.now(),
1200
+ toolName,
1201
+ policyCode,
1202
+ decisionSource: 'policy',
1203
+ agentAid: context?.selfAid,
1204
+ agentName: context?.agentName,
1205
+ sessionId: sessionKey,
1206
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1207
+ summary,
1208
+ effect: 'operation_skipped',
1209
+ });
1210
+ }
1211
+ catch (error) {
1212
+ logger.warn(`[CodexRunner] failed to record managed-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
1213
+ }
1214
+ };
1215
+ if (!activeThread || !this.isThreadOwnedBySession(sessionKey, threadId)) {
886
1216
  return { ok: true, applicable: false };
887
1217
  }
888
1218
  const proactiveSession = context?.chatmode === 'proactive';
889
1219
  if (!context || (proactiveSession && !context.preToolUsePolicyHook)) {
890
- return { ok: false, applicable: true, decision: 'deny', reason: 'EvolCore proactive session policy context is unavailable' };
1220
+ const reason = 'EvolCore proactive session policy context is unavailable';
1221
+ await recordPreToolUseDenial('pre_tool_use_context_unavailable', reason);
1222
+ return { ok: false, applicable: true, decision: 'deny', reason };
891
1223
  }
892
1224
  if (context.pauseController) {
893
1225
  const pauseResult = await context.pauseController.waitAtToolBoundary(signal);
894
1226
  if (pauseResult === 'cancelled') {
895
- return { ok: true, applicable: true, decision: 'deny', reason: '工具调用已取消' };
1227
+ const reason = '工具调用已取消';
1228
+ await recordPreToolUseDenial('tool_call_cancelled', reason);
1229
+ return { ok: true, applicable: true, decision: 'deny', reason };
896
1230
  }
897
1231
  }
1232
+ if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
1233
+ if (this.isTrustedFullAccessSession(sessionKey))
1234
+ return { ok: true, applicable: true, decision: 'allow' };
1235
+ const reason = 'Untrusted fullaccess execution context';
1236
+ await recordPreToolUseDenial('fullaccess_context_untrusted', reason);
1237
+ return { ok: false, applicable: true, decision: 'deny', reason };
1238
+ }
898
1239
  // A tracked interactive thread still needs an authenticated, explicit
899
1240
  // allow so the managed hook can fail closed when no runner matches.
900
1241
  if (!context.preToolUsePolicyHook)
901
1242
  return { ok: true, applicable: true, decision: 'allow' };
902
- const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
903
- ? toolInput
904
- : {};
905
1243
  try {
906
1244
  const result = context.preToolUsePolicyHook(toolName, normalizedInput);
907
1245
  if (result?.block) {
908
- return { ok: true, applicable: true, decision: 'deny', reason: result.reason };
1246
+ const policyCode = result.policyCode ?? 'session_policy_hook';
1247
+ const reason = result.reason ?? 'session policy denied this tool call';
1248
+ await recordPreToolUseDenial(policyCode, reason);
1249
+ return { ok: true, applicable: true, decision: 'deny', reason };
909
1250
  }
910
1251
  return { ok: true, applicable: true, decision: 'allow' };
911
1252
  }
912
1253
  catch (error) {
913
- return { ok: false, applicable: true, decision: 'deny', reason: `policy hook failed: ${error instanceof Error ? error.message : String(error)}` };
1254
+ const reason = `policy hook failed: ${error instanceof Error ? error.message : String(error)}`;
1255
+ await recordPreToolUseDenial('pre_tool_use_hook_failed', reason);
1256
+ return { ok: false, applicable: true, decision: 'deny', reason };
914
1257
  }
915
1258
  }
916
1259
  setPermissionGateway(gw) { this.permissionGateway = gw; }
@@ -919,9 +1262,19 @@ export class CodexRunner {
919
1262
  this.activeStreams.set(key, stream);
920
1263
  }
921
1264
  cleanupStream(key) {
1265
+ const hadController = this.activeAbortControllers.has(key);
1266
+ const hadTurn = this.activeTurns.has(key);
1267
+ const provisionalThread = this.provisionalSessions.get(key);
922
1268
  this.activeStreams.delete(key);
923
1269
  this.activeAbortControllers.delete(key);
924
1270
  this.pendingInterrupts.delete(key);
1271
+ // A stale runner-start path may dispose the stream without ever entering
1272
+ // transformAppServerStream. Reclaim its provisional thread, but preserve
1273
+ // a replacement run that has already installed a new controller/turn.
1274
+ if (provisionalThread && !hadController && !hadTurn) {
1275
+ this.cleanupProvisionalThread(key, provisionalThread);
1276
+ }
1277
+ this.chatModes.delete(key);
925
1278
  const release = this.activeOperationReleases.get(key);
926
1279
  if (release) {
927
1280
  this.activeOperationReleases.delete(key);
@@ -976,14 +1329,20 @@ export class CodexRunner {
976
1329
  }
977
1330
  }
978
1331
  isActiveThreadOperationAlias(key) {
979
- if (key.startsWith('session:'))
980
- return this.activeSessions.has(key.slice('session:'.length));
1332
+ if (key.startsWith('session:')) {
1333
+ const sessionId = key.slice('session:'.length);
1334
+ return this.activeSessions.has(sessionId) || this.provisionalSessions.has(sessionId);
1335
+ }
981
1336
  if (key.startsWith('thread:')) {
982
1337
  const threadId = key.slice('thread:'.length);
983
1338
  for (const activeThreadId of this.activeSessions.values()) {
984
1339
  if (activeThreadId === threadId)
985
1340
  return true;
986
1341
  }
1342
+ for (const provisionalThreadId of this.provisionalSessions.values()) {
1343
+ if (provisionalThreadId === threadId)
1344
+ return true;
1345
+ }
987
1346
  return false;
988
1347
  }
989
1348
  return true;
@@ -1032,8 +1391,22 @@ export class CodexRunner {
1032
1391
  }
1033
1392
  }
1034
1393
  // ── Core: runQuery ──
1394
+ cachedThreadForTopic(sessionId, agentSessionId) {
1395
+ if (!agentSessionId)
1396
+ return undefined;
1397
+ return this.activeSessions.get(sessionId) === agentSessionId ? agentSessionId : undefined;
1398
+ }
1035
1399
  async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv) {
1036
- const knownThreadId = initialAgentSessionId || this.activeSessions.get(sessionId);
1400
+ const topicBinding = modelOverride?.backend?.kind === 'topic';
1401
+ if (topicBinding && (!modelOverride?.turn || modelOverride.turn.sessionId !== sessionId || !this.onSessionIdUpdate)) {
1402
+ const error = new Error('topic backend run requires an active TurnLease and binding callback');
1403
+ error.code = 'TOPIC_TURN_LEASE_REQUIRED';
1404
+ throw error;
1405
+ }
1406
+ const topicAgentSessionId = topicBinding ? modelOverride?.backend?.agentSessionId ?? null : undefined;
1407
+ const knownThreadId = topicBinding
1408
+ ? this.cachedThreadForTopic(sessionId, topicAgentSessionId)
1409
+ : initialAgentSessionId || this.activeSessions.get(sessionId);
1037
1410
  const operationKey = this.threadOperationKey(knownThreadId, sessionId);
1038
1411
  const release = await this.acquireThreadOperation(operationKey, 'thread/resume+turn/start');
1039
1412
  try {
@@ -1047,8 +1420,26 @@ export class CodexRunner {
1047
1420
  }
1048
1421
  }
1049
1422
  async runQueryLocked(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv) {
1050
- let agentSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
1051
- const resumingThread = !!agentSessionId;
1423
+ // A daemon-managed task must never fall back to the app-server's
1424
+ // process-wide TMPDIR if its session namespace disappeared.
1425
+ getManagedTaskTempDir(runtimeEnv);
1426
+ const topicBinding = modelOverride?.backend?.kind === 'topic';
1427
+ const topicAgentSessionId = topicBinding ? modelOverride?.backend?.agentSessionId ?? null : undefined;
1428
+ let agentSessionId = topicBinding
1429
+ ? (topicAgentSessionId || undefined)
1430
+ : initialAgentSessionId || this.activeSessions.get(sessionId);
1431
+ if (!topicBinding && !initialAgentSessionId && typeof sessionManager?.getSessionById === 'function') {
1432
+ try {
1433
+ const persisted = await sessionManager.getSessionById(sessionId);
1434
+ agentSessionId = typeof persisted?.agentSessionId === 'string' && persisted.agentSessionId.trim()
1435
+ ? persisted.agentSessionId.trim()
1436
+ : undefined;
1437
+ }
1438
+ catch (error) {
1439
+ logger.debug(`[CodexRunner] persisted main backend lookup failed; using local cache: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
1440
+ }
1441
+ }
1442
+ let resumingThread = !!agentSessionId;
1052
1443
  const callModel = modelOverride?.model || this.model;
1053
1444
  const callEffort = modelOverride?.effortMode === 'model_default'
1054
1445
  ? undefined
@@ -1057,8 +1448,10 @@ export class CodexRunner {
1057
1448
  // 写入 chatModes 供异步审批回调按 sessionKey 读取,并据此派生本次 thread 的 approvalPolicy/sandbox,
1058
1449
  // 不依赖共享的 this.approvalPolicy/this.sandboxMode(多会话并发互不污染)。
1059
1450
  const requestedPermissionMode = modelOverride?.permissionMode || this.currentMode;
1060
- const normalizedPermission = normalizePermissionMode(requestedPermissionMode);
1061
- const callMode = normalizedPermission.mode;
1451
+ const callMode = normalizeExecutionPermissionMode(requestedPermissionMode);
1452
+ if (callMode === 'fullaccess' && !hasTrustedFullAccessContext(this.permissionContexts.get(sessionId))) {
1453
+ throw new Error('Codex fullaccess requires a trusted per-call execution authorization');
1454
+ }
1062
1455
  this.chatModes.set(sessionId, callMode);
1063
1456
  const callApprovalPolicy = this.toApprovalPolicy(callMode);
1064
1457
  const executionSandbox = this.buildExecutionSandboxOptions(sessionId, callMode, projectPath, modelOverride?.bootstrapAgentMdPath);
@@ -1066,8 +1459,11 @@ export class CodexRunner {
1066
1459
  this.dropExpiredPendingInterrupt(sessionId);
1067
1460
  const effectiveApprovalPolicy = callApprovalPolicy;
1068
1461
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
1069
- const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, callMode);
1070
- await this.assertProactiveHookAvailability(sessionId, appServer);
1462
+ const externalToolConfig = callMode === 'fullaccess'
1463
+ ? {}
1464
+ : await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, callMode);
1465
+ if (callMode !== 'fullaccess')
1466
+ await this.assertProactiveHookAvailability(sessionId, appServer);
1071
1467
  const managedTempInstruction = this.managedTempInstruction(sessionId);
1072
1468
  const developerInstructions = [systemPromptAppend, managedTempInstruction].filter(Boolean).join('\n\n') || undefined;
1073
1469
  let knownDelegationCarrier = agentSessionId
@@ -1075,35 +1471,80 @@ export class CodexRunner {
1075
1471
  : undefined;
1076
1472
  if (agentSessionId && !knownDelegationCarrier) {
1077
1473
  knownDelegationCarrier = await this.loadPersistedDelegationCarrier(sessionManager, sessionId, agentSessionId);
1078
- if (knownDelegationCarrier) {
1079
- this.threadDelegationCarriers.set(agentSessionId, knownDelegationCarrier);
1080
- }
1081
1474
  }
1082
- const delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
1475
+ let delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
1476
+ // Keep newly discovered carrier state local until generation-aware
1477
+ // activation accepts the backend. A stale run must not publish a carrier
1478
+ // that a later generation could mistake for its own thread.
1479
+ let carrierToPublish;
1083
1480
  const hasTaskDelegation = typeof runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] === 'string';
1084
- const threadOptions = {
1481
+ let threadOptions = {
1085
1482
  model: callModel,
1086
1483
  effort: callEffort,
1087
1484
  approvalPolicy: effectiveApprovalPolicy,
1088
1485
  approvalsReviewer: 'user',
1089
1486
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1090
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig()), callMode),
1487
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
1091
1488
  ...(developerInstructions ? { developerInstructions } : {}),
1092
1489
  };
1093
1490
  logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
1094
1491
  `role=${executionSandbox.decision.role ?? 'none'} sandbox=${executionSandbox.decision.state}`);
1095
- const threadResponse = agentSessionId
1096
- ? await appServer.threadResume(agentSessionId, projectPath, threadOptions)
1097
- : await appServer.threadStart(projectPath, threadOptions);
1492
+ let threadResponse;
1493
+ if (agentSessionId) {
1494
+ try {
1495
+ threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
1496
+ }
1497
+ catch (error) {
1498
+ // A legacy main session can retain a provider ID after its rollout was
1499
+ // removed externally. It is safe to recover before input submission;
1500
+ // topic bindings remain authoritative and fail closed.
1501
+ if (topicBinding || !isCodexNoRolloutError(error))
1502
+ throw error;
1503
+ if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
1504
+ throw error;
1505
+ const missingThreadId = agentSessionId;
1506
+ const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
1507
+ if (cleared !== 'cleared' && cleared !== 'already_clear') {
1508
+ const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
1509
+ recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
1510
+ throw recoveryError;
1511
+ }
1512
+ if (this.activeSessions.get(sessionId) === missingThreadId)
1513
+ this.activeSessions.delete(sessionId);
1514
+ this.threadProjectPaths.delete(missingThreadId);
1515
+ this.threadDelegationCarriers.delete(missingThreadId);
1516
+ this.unbindExternalToolBoundary(missingThreadId);
1517
+ this.clearTrackedApprovalItemsForThread(missingThreadId);
1518
+ agentSessionId = undefined;
1519
+ resumingThread = false;
1520
+ knownDelegationCarrier = undefined;
1521
+ delegationCarrierCandidate = this.createDelegationCarrier();
1522
+ threadOptions = {
1523
+ ...threadOptions,
1524
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
1525
+ };
1526
+ logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
1527
+ threadResponse = await appServer.threadStart(projectPath, threadOptions);
1528
+ }
1529
+ }
1530
+ else {
1531
+ threadResponse = await appServer.threadStart(projectPath, threadOptions);
1532
+ }
1098
1533
  let threadId = threadResponse.thread?.id || agentSessionId;
1099
1534
  if (!threadId)
1100
1535
  throw new Error('Codex app-server did not return a thread id');
1101
1536
  if (!resumingThread) {
1102
- this.threadDelegationCarriers.set(threadId, delegationCarrierCandidate);
1103
- await this.persistDelegationCarrier(sessionManager, sessionId, threadId, delegationCarrierCandidate);
1537
+ carrierToPublish = { threadId, carrier: delegationCarrierCandidate };
1104
1538
  }
1105
1539
  else if (hasTaskDelegation && !knownDelegationCarrier) {
1106
- let recoveredCarrier = await this.readThreadDelegationCarrier(appServer, threadId);
1540
+ // A BOUND topic cannot silently fork to migrate carrier metadata: its
1541
+ // active-turn activation is allowed to confirm only the authoritative
1542
+ // backend ID, so a fork would deterministically conflict. threadResume
1543
+ // already received the candidate in this turn's environment config;
1544
+ // publish it for the same thread after binding confirmation instead.
1545
+ let recoveredCarrier = topicBinding
1546
+ ? delegationCarrierCandidate
1547
+ : await this.readThreadDelegationCarrier(appServer, threadId);
1107
1548
  if (!recoveredCarrier) {
1108
1549
  const staleThreadId = threadId;
1109
1550
  logger.warn(`[CodexRunner] loaded thread has no recoverable delegation carrier; forking once to preserve history: thread=${staleThreadId}`);
@@ -1121,19 +1562,46 @@ export class CodexRunner {
1121
1562
  threadId = forkedThreadId;
1122
1563
  recoveredCarrier = delegationCarrierCandidate;
1123
1564
  }
1124
- this.threadDelegationCarriers.set(threadId, recoveredCarrier);
1125
- await this.persistDelegationCarrier(sessionManager, sessionId, threadId, recoveredCarrier);
1565
+ carrierToPublish = { threadId, carrier: recoveredCarrier };
1126
1566
  }
1127
1567
  else if (knownDelegationCarrier) {
1128
- this.threadDelegationCarriers.set(threadId, knownDelegationCarrier);
1129
- await this.persistDelegationCarrier(sessionManager, sessionId, threadId, knownDelegationCarrier);
1568
+ carrierToPublish = { threadId, carrier: knownDelegationCarrier };
1569
+ }
1570
+ const turn = modelOverride?.turn;
1571
+ if (!resumingThread) {
1572
+ // Keep a newly-created backend private to this runner until turn/start
1573
+ // has been accepted. In particular, a pending interrupt must not leave
1574
+ // a durable ID for a thread that never produced a rollout.
1575
+ this.provisionalSessions.set(sessionId, threadId);
1576
+ if (carrierToPublish) {
1577
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1578
+ }
1579
+ }
1580
+ else {
1581
+ agentSessionId = threadId;
1582
+ const bindingResult = this.onSessionIdUpdate
1583
+ ? ((turn === undefined
1584
+ ? await this.onSessionIdUpdate(sessionId, threadId)
1585
+ : await this.onSessionIdUpdate(sessionId, threadId, { turn }))
1586
+ ?? 'legacy_updated')
1587
+ : 'legacy_updated';
1588
+ const bindingAccepted = bindingResult === 'activated'
1589
+ || bindingResult === 'already_active'
1590
+ || bindingResult === 'legacy_updated';
1591
+ if (!bindingAccepted) {
1592
+ const error = new Error(`backend discovery rejected: ${bindingResult}`);
1593
+ error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
1594
+ throw error;
1595
+ }
1596
+ this.activeSessions.set(sessionId, threadId);
1597
+ if (carrierToPublish) {
1598
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1599
+ await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
1600
+ }
1601
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
1130
1602
  }
1131
- agentSessionId = threadId;
1132
- this.activeSessions.set(sessionId, threadId);
1133
- this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
1134
1603
  this.threadProjectPaths.set(threadId, path.resolve(projectPath));
1135
1604
  this.bindExternalToolBoundary(threadId, externalToolConfig);
1136
- this.onSessionIdUpdate?.(sessionId, threadId);
1137
1605
  const controller = new AbortController();
1138
1606
  this.activeAbortControllers.set(sessionId, controller);
1139
1607
  const tempFiles = [];
@@ -1142,21 +1610,55 @@ export class CodexRunner {
1142
1610
  controller.signal.addEventListener('abort', () => queue.end(), { once: true });
1143
1611
  const state = {
1144
1612
  threadId,
1613
+ sessionId,
1614
+ projectPath,
1145
1615
  model: callModel,
1616
+ committed: resumingThread,
1146
1617
  streamedAgentMessageIds: new Set(),
1147
1618
  agentMessageDeltaText: new Map(),
1148
1619
  completedItemIds: new Set(),
1149
1620
  emittedEditCallIds: new Set(),
1150
1621
  completedTurnIds: new Set(),
1151
1622
  openToolCalls: new Map(),
1623
+ activeSubagentThreads: new Set(),
1624
+ turnCompleted: false,
1152
1625
  };
1153
1626
  const unsubscribe = appServer.onNotification(notification => {
1154
1627
  // 仅从 turn/started 锁定权威 turnId — resume 时会有上一轮 turn 的残留通知
1155
1628
  // (如 thread/tokenUsage/updated)先于新 turn 到达,不能用它们 latch turnId
1156
1629
  const params = notification.params || {};
1157
1630
  const notifThreadId = params.threadId ?? params.thread_id;
1158
- if (notifThreadId !== undefined && notifThreadId !== threadId)
1631
+ const item = params.item && typeof params.item === 'object' ? params.item : undefined;
1632
+ const emittedByChild = typeof notifThreadId === 'string'
1633
+ && this.childThreadSessions.get(notifThreadId) === sessionId;
1634
+ if (notifThreadId !== undefined && notifThreadId !== threadId && !emittedByChild)
1159
1635
  return;
1636
+ this.trackSubagentLifecycle(notification, state);
1637
+ if (emittedByChild && notifThreadId !== threadId) {
1638
+ const childTurnId = this.extractTurnId(notification);
1639
+ if (notification.method === 'turn/started' && childTurnId) {
1640
+ this.activeSubagentTurns.set(notifThreadId, {
1641
+ sessionId,
1642
+ parentThreadId: threadId,
1643
+ turnId: childTurnId,
1644
+ });
1645
+ }
1646
+ else if (notification.method === 'turn/completed') {
1647
+ const activeChildTurn = this.activeSubagentTurns.get(notifThreadId);
1648
+ if (!childTurnId || activeChildTurn?.turnId === childTurnId) {
1649
+ this.activeSubagentTurns.delete(notifThreadId);
1650
+ }
1651
+ state.activeSubagentThreads.delete(notifThreadId);
1652
+ this.unregisterChildThread(notifThreadId);
1653
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1654
+ }
1655
+ if (this.isSubagentActivityItem(item))
1656
+ queue.push(notification);
1657
+ if (notification.method === 'item/completed' && state.turnCompleted) {
1658
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1659
+ }
1660
+ return;
1661
+ }
1160
1662
  if (notification.method === 'turn/started') {
1161
1663
  const startedTurnId = this.extractTurnId(notification);
1162
1664
  if (startedTurnId && !state.turnId) {
@@ -1172,15 +1674,22 @@ export class CodexRunner {
1172
1674
  return;
1173
1675
  queue.push(notification);
1174
1676
  // 仅在已锁定 turnId 后才允许 turn/completed 结束队列,避免残留的旧 turn/completed 误关
1175
- if (notification.method === 'turn/completed' && state.turnId)
1176
- queue.end();
1677
+ if (notification.method === 'turn/completed' && state.turnId) {
1678
+ state.turnCompleted = true;
1679
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1680
+ }
1681
+ else if (notification.method === 'item/completed' && state.turnCompleted) {
1682
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1683
+ }
1177
1684
  });
1178
1685
  if (this.consumePendingInterrupt(sessionId)) {
1179
1686
  controller.abort('User interrupt');
1180
1687
  this.activeAbortControllers.delete(sessionId);
1181
1688
  this.activeStreams.delete(sessionId);
1689
+ if (!resumingThread)
1690
+ this.cleanupProvisionalThread(sessionId, threadId);
1182
1691
  logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
1183
- return this.transformAppServerStream(queue, sessionId, state, unsubscribe, tempFiles);
1692
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
1184
1693
  }
1185
1694
  try {
1186
1695
  const turnResponse = await appServer.turnStart(threadId, input, {
@@ -1190,6 +1699,14 @@ export class CodexRunner {
1190
1699
  approvalPolicy: effectiveApprovalPolicy,
1191
1700
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1192
1701
  });
1702
+ if (!resumingThread) {
1703
+ state.commitProvisional = async () => {
1704
+ const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
1705
+ if (committed)
1706
+ agentSessionId = threadId;
1707
+ return committed;
1708
+ };
1709
+ }
1193
1710
  const turnId = turnResponse.turn?.id;
1194
1711
  if (turnId && !state.turnId) {
1195
1712
  state.turnId = turnId;
@@ -1204,8 +1721,10 @@ export class CodexRunner {
1204
1721
  }
1205
1722
  const status = turnResponse.turn?.status;
1206
1723
  if (status === 'completed' || status === 'failed') {
1724
+ this.primeSubagentStateFromTurn(turnResponse.turn, state);
1207
1725
  queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
1208
- queue.end();
1726
+ state.turnCompleted = true;
1727
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1209
1728
  }
1210
1729
  }
1211
1730
  catch (error) {
@@ -1214,9 +1733,11 @@ export class CodexRunner {
1214
1733
  this.activeTurns.delete(sessionId);
1215
1734
  this.pendingInterrupts.delete(sessionId);
1216
1735
  this.cleanupTempFiles(tempFiles);
1736
+ if (!resumingThread)
1737
+ this.cleanupProvisionalThread(sessionId, threadId);
1217
1738
  throw error;
1218
1739
  }
1219
- return this.transformAppServerStream(queue, sessionId, state, unsubscribe, tempFiles);
1740
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
1220
1741
  }
1221
1742
  // ── Interrupt ──
1222
1743
  async interrupt(sessionKey) {
@@ -1227,6 +1748,7 @@ export class CodexRunner {
1227
1748
  const interruptTurn = activeTurn
1228
1749
  ? this.interruptAppServerTurn(activeTurn.threadId, activeTurn.turnId)
1229
1750
  : Promise.resolve();
1751
+ const interruptSubagents = this.interruptSubagentTurns(sessionKey);
1230
1752
  if (!activeTurn)
1231
1753
  this.rememberPendingInterrupt(sessionKey);
1232
1754
  if (controller)
@@ -1237,7 +1759,17 @@ export class CodexRunner {
1237
1759
  this.activeTurns.delete(sessionKey);
1238
1760
  logger.info(`[CodexRunner] Interrupted session: ${sessionKey}`);
1239
1761
  }
1240
- await interruptTurn;
1762
+ await Promise.all([interruptTurn, interruptSubagents]);
1763
+ }
1764
+ async interruptSubagentTurns(sessionId, parentThreadId) {
1765
+ const entries = [...this.activeSubagentTurns.entries()]
1766
+ .filter(([, active]) => active.sessionId === sessionId && (!parentThreadId || active.parentThreadId === parentThreadId));
1767
+ await Promise.all(entries.map(async ([childThreadId, active]) => {
1768
+ await this.interruptAppServerTurn(childThreadId, active.turnId).catch(() => { });
1769
+ const current = this.activeSubagentTurns.get(childThreadId);
1770
+ if (current === active)
1771
+ this.activeSubagentTurns.delete(childThreadId);
1772
+ }));
1241
1773
  }
1242
1774
  rememberPendingInterrupt(sessionId) {
1243
1775
  this.pendingInterrupts.set(sessionId, Date.now());
@@ -1266,40 +1798,96 @@ export class CodexRunner {
1266
1798
  // ── Session commands ──
1267
1799
  updateSessionId(sessionId, agentSessionId) {
1268
1800
  const previousThreadId = this.activeSessions.get(sessionId);
1269
- if (previousThreadId && previousThreadId !== agentSessionId) {
1270
- this.threadProjectPaths.delete(previousThreadId);
1271
- this.threadDelegationCarriers.delete(previousThreadId);
1272
- this.unbindExternalToolBoundary(previousThreadId);
1273
- this.clearTrackedApprovalItemsForThread(previousThreadId);
1274
- }
1275
- if (agentSessionId) {
1276
- this.activeSessions.set(sessionId, agentSessionId);
1277
- this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${agentSessionId}`);
1278
- }
1279
- else {
1280
- this.activeSessions.delete(sessionId);
1801
+ const applyLocal = () => {
1802
+ const currentThreadId = this.activeSessions.get(sessionId);
1803
+ if (currentThreadId !== previousThreadId && !(previousThreadId === undefined && currentThreadId === undefined))
1804
+ return;
1805
+ if (previousThreadId && previousThreadId !== agentSessionId) {
1806
+ this.threadProjectPaths.delete(previousThreadId);
1807
+ this.threadDelegationCarriers.delete(previousThreadId);
1808
+ this.unbindExternalToolBoundary(previousThreadId);
1809
+ this.clearTrackedApprovalItemsForThread(previousThreadId);
1810
+ }
1811
+ if (agentSessionId)
1812
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${agentSessionId}`);
1813
+ this.pruneThreadOperationLocks();
1814
+ if (agentSessionId)
1815
+ this.activeSessions.set(sessionId, agentSessionId);
1816
+ else
1817
+ this.activeSessions.delete(sessionId);
1818
+ };
1819
+ if (!this.onSessionIdUpdate) {
1820
+ applyLocal();
1821
+ return;
1281
1822
  }
1282
- this.pruneThreadOperationLocks();
1283
- this.onSessionIdUpdate?.(sessionId, agentSessionId);
1823
+ void this.onSessionIdUpdate(sessionId, agentSessionId).then(result => {
1824
+ if (result === 'activated' || result === 'already_active' || result === 'legacy_updated' || !agentSessionId) {
1825
+ applyLocal();
1826
+ }
1827
+ }).catch(error => {
1828
+ logger.warn(`[CodexRunner] session binding callback failed: ${error instanceof Error ? error.message : String(error)}`);
1829
+ if (!agentSessionId)
1830
+ applyLocal();
1831
+ });
1284
1832
  }
1285
1833
  async closeSession(sessionId) {
1834
+ const capturedThreadId = this.activeSessions.get(sessionId);
1835
+ const capturedProvisionalThreadId = this.provisionalSessions.get(sessionId);
1836
+ const capturedPermissionContext = this.permissionContexts.get(sessionId);
1837
+ const capturedStream = this.activeStreams.get(sessionId);
1838
+ const capturedController = this.activeAbortControllers.get(sessionId);
1839
+ const capturedTurn = this.activeTurns.get(sessionId);
1840
+ const capturedSteeringTail = this.steeringTails.get(sessionId);
1286
1841
  this.permissionContexts.get(sessionId)?.pauseController?.cancel();
1287
- const threadId = this.activeSessions.get(sessionId);
1288
- this.activeSessions.delete(sessionId);
1289
- this.activeStreams.delete(sessionId);
1290
- this.activeAbortControllers.delete(sessionId);
1291
- this.activeTurns.delete(sessionId);
1292
- this.pendingInterrupts.delete(sessionId);
1293
- this.permissionContexts.delete(sessionId);
1294
- this.chatModes.delete(sessionId);
1295
- this.cleanupSessionTempDir(sessionId);
1296
- if (threadId) {
1297
- this.threadProjectPaths.delete(threadId);
1298
- this.threadDelegationCarriers.delete(threadId);
1299
- this.unbindExternalToolBoundary(threadId);
1842
+ // Do not call interrupt(sessionId) here: a new run may have replaced the
1843
+ // session-scoped maps while an older close was waiting. Interrupt and
1844
+ // clean only the exact lifecycle captured at entry.
1845
+ if (capturedTurn) {
1846
+ await this.interruptAppServerTurn(capturedTurn.threadId, capturedTurn.turnId).catch(() => { });
1847
+ }
1848
+ const capturedParentThreadId = capturedThreadId ?? capturedProvisionalThreadId;
1849
+ if (capturedParentThreadId) {
1850
+ await this.interruptSubagentTurns(sessionId, capturedParentThreadId);
1851
+ }
1852
+ else if (!this.activeSessions.has(sessionId) && !this.provisionalSessions.has(sessionId)) {
1853
+ await this.interruptSubagentTurns(sessionId);
1854
+ }
1855
+ capturedController?.abort('Session closed');
1856
+ const lifecycleReplaced = (capturedStream !== undefined && this.activeStreams.get(sessionId) !== capturedStream)
1857
+ || (capturedController !== undefined && this.activeAbortControllers.get(sessionId) !== capturedController)
1858
+ || (capturedTurn !== undefined && this.activeTurns.get(sessionId) !== capturedTurn)
1859
+ || (capturedPermissionContext !== undefined && this.permissionContexts.get(sessionId) !== capturedPermissionContext)
1860
+ || (capturedThreadId !== undefined && this.activeSessions.get(sessionId) !== capturedThreadId);
1861
+ if (capturedStream !== undefined && this.activeStreams.get(sessionId) === capturedStream)
1862
+ this.activeStreams.delete(sessionId);
1863
+ if (capturedController !== undefined && this.activeAbortControllers.get(sessionId) === capturedController)
1864
+ this.activeAbortControllers.delete(sessionId);
1865
+ if (capturedTurn !== undefined && this.activeTurns.get(sessionId) === capturedTurn)
1866
+ this.activeTurns.delete(sessionId);
1867
+ if (this.pendingInterrupts.has(sessionId) && !lifecycleReplaced)
1868
+ this.pendingInterrupts.delete(sessionId);
1869
+ if (this.permissionContexts.get(sessionId) === capturedPermissionContext)
1870
+ this.permissionContexts.delete(sessionId);
1871
+ if (this.steeringTails.get(sessionId) === capturedSteeringTail)
1872
+ this.steeringTails.delete(sessionId);
1873
+ if (capturedProvisionalThreadId)
1874
+ this.cleanupProvisionalThread(sessionId, capturedProvisionalThreadId);
1875
+ // Session-level configuration and thread indexes are shared by later
1876
+ // runs. Only remove them when the captured lifecycle still owns the key.
1877
+ if (!lifecycleReplaced) {
1878
+ if (this.activeSessions.get(sessionId) === capturedThreadId)
1879
+ this.activeSessions.delete(sessionId);
1880
+ this.chatModes.delete(sessionId);
1881
+ this.cleanupSessionTempDir(sessionId);
1882
+ if (capturedThreadId) {
1883
+ this.threadProjectPaths.delete(capturedThreadId);
1884
+ this.threadDelegationCarriers.delete(capturedThreadId);
1885
+ this.unbindExternalToolBoundary(capturedThreadId);
1886
+ }
1887
+ this.clearTrackedApprovalItemsForThread(capturedThreadId);
1888
+ this.cleanupChildThreadsForSession(sessionId);
1889
+ this.pruneThreadOperationLocks();
1300
1890
  }
1301
- this.clearTrackedApprovalItemsForThread(threadId);
1302
- this.pruneThreadOperationLocks();
1303
1891
  }
1304
1892
  resolveSessionFile(agentSessionId, _projectPath) {
1305
1893
  // Codex session 文件: ~/.codex/sessions/YYYY/MM/DD/rollout-*-{threadId}.jsonl
@@ -1326,6 +1914,8 @@ export class CodexRunner {
1326
1914
  // Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
1327
1915
  const threadId = this.activeSessions.get(sessionId) ?? _agentSessionId;
1328
1916
  this.activeSessions.delete(sessionId);
1917
+ this.activeTurns.delete(sessionId);
1918
+ this.steeringTails.delete(sessionId);
1329
1919
  this.chatModes.delete(sessionId);
1330
1920
  this.cleanupSessionTempDir(sessionId);
1331
1921
  this.threadProjectPaths.delete(threadId);
@@ -1333,54 +1923,67 @@ export class CodexRunner {
1333
1923
  this.unbindExternalToolBoundary(threadId);
1334
1924
  this.clearTrackedApprovalItemsForThread(threadId);
1335
1925
  this.pruneThreadOperationLocks();
1336
- this.onSessionIdUpdate?.(sessionId, '');
1926
+ await this.onSessionIdUpdate?.(sessionId, '');
1337
1927
  return true;
1338
1928
  }
1339
- async compactSession(_sessionId, agentSessionId, _projectPath) {
1929
+ async compactSession(_sessionId, agentSessionId, _projectPath, modelOverride) {
1930
+ const startedAt = Date.now();
1931
+ const finish = (result) => {
1932
+ logger.info(`[CodexRunner] Compact result: thread=${agentSessionId} ok=${result.ok} code=${result.ok ? 'ok' : result.code} durationMs=${result.durationMs}`);
1933
+ return result;
1934
+ };
1340
1935
  const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId, _sessionId), 'compact');
1341
1936
  try {
1342
1937
  const appServer = this.getAppServerClient();
1343
1938
  this.onCompactStart?.(_sessionId);
1344
1939
  try {
1345
- return await this.startAndWaitForCompact(appServer, agentSessionId);
1940
+ await this.startAndWaitForCompact(appServer, agentSessionId);
1941
+ return finish({ ok: true, durationMs: Date.now() - startedAt });
1346
1942
  }
1347
1943
  catch (error) {
1348
1944
  if (!this.isThreadNotFoundError(error))
1349
1945
  throw error;
1350
1946
  logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
1351
- // 优先用 per-session 模式派生(与 runQuery 一致),缺省回落实例级
1352
- const compactMode = this.chatModes.get(_sessionId) ?? this.currentMode;
1947
+ const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
1948
+ const compactModel = modelOverride?.model || this.model;
1949
+ const compactEffort = modelOverride?.effortMode === 'model_default'
1950
+ ? undefined
1951
+ : modelOverride?.effort ?? this.effort;
1353
1952
  const compactPolicy = this.toApprovalPolicy(compactMode);
1354
1953
  const executionSandbox = this.buildExecutionSandboxOptions(_sessionId, compactMode, _projectPath);
1355
1954
  const capabilityConfig = await this.resolveCapabilityThreadConfig(_projectPath);
1356
- const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, _projectPath, capabilityConfig, compactMode);
1357
- await this.assertProactiveHookAvailability(_sessionId, appServer);
1955
+ const externalToolConfig = compactMode === 'fullaccess'
1956
+ ? {}
1957
+ : await this.resolveExternalToolApprovalConfig(appServer, _projectPath, capabilityConfig, compactMode);
1958
+ if (compactMode !== 'fullaccess')
1959
+ await this.assertProactiveHookAvailability(_sessionId, appServer);
1358
1960
  const compactDelegationCarrier = this.threadDelegationCarriers.get(agentSessionId)
1359
1961
  ?? this.createDelegationCarrier();
1360
1962
  await appServer.threadResume(agentSessionId, _projectPath, {
1361
- model: this.model,
1362
- effort: this.effort,
1963
+ model: compactModel,
1964
+ effort: compactEffort,
1363
1965
  approvalPolicy: compactPolicy,
1364
1966
  approvalsReviewer: 'user',
1365
1967
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1366
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier), this.buildLifecycleLockdownConfig()), compactMode),
1968
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
1367
1969
  });
1368
1970
  this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
1369
1971
  this.threadDelegationCarriers.set(agentSessionId, compactDelegationCarrier);
1370
1972
  this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
1371
- return await this.startAndWaitForCompact(appServer, agentSessionId);
1973
+ await this.startAndWaitForCompact(appServer, agentSessionId);
1974
+ return finish({ ok: true, durationMs: Date.now() - startedAt });
1372
1975
  }
1373
1976
  }
1374
1977
  catch (error) {
1375
- logger.error('[CodexRunner] Compact failed:', error);
1376
- return false;
1978
+ const message = error instanceof Error ? error.message : String(error);
1979
+ return finish({ ok: false, code: 'sdk_error', message, durationMs: Date.now() - startedAt });
1377
1980
  }
1378
1981
  finally {
1379
1982
  release();
1380
1983
  }
1381
1984
  }
1382
- async compact(sessionId, agentSessionId, projectPath) {
1383
- return this.compactSession(sessionId, agentSessionId, projectPath);
1985
+ async compact(sessionId, agentSessionId, projectPath, modelOverride) {
1986
+ return this.compactSession(sessionId, agentSessionId, projectPath, modelOverride);
1384
1987
  }
1385
1988
  async startAndWaitForCompact(appServer, threadId) {
1386
1989
  const completion = this.waitForThreadCompacted(appServer, threadId, Date.now());
@@ -1527,7 +2130,7 @@ export class CodexRunner {
1527
2130
  approvalPolicy: this.toApprovalPolicy(mode),
1528
2131
  approvalsReviewer: 'user',
1529
2132
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1530
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionKey, delegationCarrier), this.buildLifecycleLockdownConfig()), mode),
2133
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionKey, delegationCarrier), this.buildLifecycleLockdownConfig(mode)), mode),
1531
2134
  });
1532
2135
  const forkedThreadId = response.thread?.id;
1533
2136
  if (!forkedThreadId)
@@ -1582,13 +2185,14 @@ export class CodexRunner {
1582
2185
  trackApprovalItemNotification(notification) {
1583
2186
  const params = (notification.params || {});
1584
2187
  const item = params.item && typeof params.item === 'object' ? params.item : undefined;
1585
- const itemId = item?.id ?? params.itemId;
2188
+ const itemId = item?.id ?? params.itemId ?? params.item_id;
1586
2189
  const threadId = this.requestThreadId(params);
1587
- const key = this.approvalItemKey(threadId, params.turnId, itemId);
2190
+ const turnId = params.turnId ?? params.turn_id;
2191
+ const key = this.approvalItemKey(threadId, turnId, itemId);
1588
2192
  if (notification.method === 'item/started' && key && item && threadId) {
1589
2193
  this.trackedApprovalItems.set(key, {
1590
2194
  threadId,
1591
- turnId: params.turnId,
2195
+ turnId,
1592
2196
  itemId,
1593
2197
  item: { ...item },
1594
2198
  });
@@ -1598,7 +2202,7 @@ export class CodexRunner {
1598
2202
  const tracked = this.trackedApprovalItems.get(key);
1599
2203
  this.trackedApprovalItems.set(key, {
1600
2204
  threadId,
1601
- turnId: params.turnId,
2205
+ turnId,
1602
2206
  itemId,
1603
2207
  item: {
1604
2208
  ...(tracked?.item ?? { id: itemId, type: 'fileChange' }),
@@ -1622,22 +2226,40 @@ export class CodexRunner {
1622
2226
  this.trackedApprovalItems.delete(trackedKey);
1623
2227
  }
1624
2228
  }
2229
+ this.clearPendingToolDenialsForTurn(threadId, completedTurnId);
1625
2230
  }
1626
2231
  }
1627
2232
  findTrackedApprovalItem(params) {
1628
- const key = this.approvalItemKey(this.requestThreadId(params), params.turnId, params.itemId);
2233
+ const key = this.approvalItemKey(this.requestThreadId(params), params.turnId ?? params.turn_id, params.itemId ?? params.item_id);
1629
2234
  return key ? this.trackedApprovalItems.get(key)?.item : undefined;
1630
2235
  }
1631
2236
  requestThreadId(params) {
1632
- const threadId = typeof params.threadId === 'string' && params.threadId.length > 0
1633
- ? params.threadId
2237
+ const threadIdValue = params.threadId ?? params.thread_id;
2238
+ const threadId = typeof threadIdValue === 'string' && threadIdValue.length > 0
2239
+ ? threadIdValue
1634
2240
  : undefined;
1635
2241
  if (threadId)
1636
2242
  return threadId;
1637
- return typeof params.conversationId === 'string' && params.conversationId.length > 0
1638
- ? params.conversationId
2243
+ const conversationIdValue = params.conversationId ?? params.conversation_id;
2244
+ return typeof conversationIdValue === 'string' && conversationIdValue.length > 0
2245
+ ? conversationIdValue
1639
2246
  : undefined;
1640
2247
  }
2248
+ clearPendingToolDenialsForTurn(threadId, turnId) {
2249
+ for (const key of this.pendingToolDenials.keys()) {
2250
+ let parsed;
2251
+ try {
2252
+ parsed = JSON.parse(key);
2253
+ }
2254
+ catch {
2255
+ continue;
2256
+ }
2257
+ if (!Array.isArray(parsed) || parsed[0] !== threadId)
2258
+ continue;
2259
+ if (!turnId || parsed[1] === turnId)
2260
+ this.pendingToolDenials.delete(key);
2261
+ }
2262
+ }
1641
2263
  clearTrackedApprovalItemsForThread(threadId) {
1642
2264
  if (!threadId)
1643
2265
  return;
@@ -1645,6 +2267,73 @@ export class CodexRunner {
1645
2267
  if (tracked.threadId === threadId)
1646
2268
  this.trackedApprovalItems.delete(key);
1647
2269
  }
2270
+ for (const key of this.pendingToolDenials.keys()) {
2271
+ let parsed;
2272
+ try {
2273
+ parsed = JSON.parse(key);
2274
+ }
2275
+ catch {
2276
+ continue;
2277
+ }
2278
+ if (Array.isArray(parsed) && parsed[0] === threadId)
2279
+ this.pendingToolDenials.delete(key);
2280
+ }
2281
+ }
2282
+ rememberToolDenial(request, params, denial) {
2283
+ const itemId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2284
+ const threadId = this.requestThreadId(params);
2285
+ const rawTurnId = params.turnId ?? params.turn_id;
2286
+ const turnId = typeof rawTurnId === 'string' ? rawTurnId : this.findActiveTurnId(threadId);
2287
+ const key = this.approvalItemKey(threadId, turnId, itemId);
2288
+ if (!key)
2289
+ return;
2290
+ this.pendingToolDenials.set(key, {
2291
+ ...denial,
2292
+ ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
2293
+ method: request.method,
2294
+ });
2295
+ }
2296
+ steerPolicyReason(sessionId, policyCode, reason) {
2297
+ const prefix = policyCode ? `策略拒绝(${policyCode})` : '策略拒绝';
2298
+ this.injectUserMessage(sessionId, `⚠️ ${prefix}:${reason}。命令未执行,请按提示重新执行正确的命令。`);
2299
+ }
2300
+ findActiveTurnId(threadId) {
2301
+ if (!threadId)
2302
+ return undefined;
2303
+ for (const active of this.activeTurns.values()) {
2304
+ if (active.threadId === threadId)
2305
+ return active.turnId;
2306
+ }
2307
+ return undefined;
2308
+ }
2309
+ takeToolDenial(state, itemId) {
2310
+ const exactKey = this.approvalItemKey(state.threadId, state.turnId, itemId);
2311
+ if (exactKey) {
2312
+ const exact = this.pendingToolDenials.get(exactKey);
2313
+ if (exact) {
2314
+ this.pendingToolDenials.delete(exactKey);
2315
+ return exact;
2316
+ }
2317
+ }
2318
+ // Legacy approval callbacks may not carry turnId. Consume the unique
2319
+ // matching item for this thread rather than losing the structured reason.
2320
+ const candidates = [];
2321
+ for (const [key, denial] of this.pendingToolDenials) {
2322
+ let parsed;
2323
+ try {
2324
+ parsed = JSON.parse(key);
2325
+ }
2326
+ catch {
2327
+ continue;
2328
+ }
2329
+ if (!Array.isArray(parsed) || parsed[0] !== state.threadId || String(parsed[2]) !== String(itemId))
2330
+ continue;
2331
+ candidates.push([key, denial]);
2332
+ }
2333
+ if (candidates.length !== 1)
2334
+ return undefined;
2335
+ this.pendingToolDenials.delete(candidates[0][0]);
2336
+ return candidates[0][1];
1648
2337
  }
1649
2338
  async handleAppServerRequest(request) {
1650
2339
  const params = (request.params || {});
@@ -1654,7 +2343,12 @@ export class CodexRunner {
1654
2343
  }
1655
2344
  if (request.method === 'item/tool/requestUserInput') {
1656
2345
  const trackedItem = this.findTrackedApprovalItem(params);
2346
+ const requestSessionKey = this.findSessionKeyByThread(params.threadId, params.conversationId);
2347
+ const trustedFullAccess = (this.chatModes.get(requestSessionKey) ?? this.currentMode) === 'fullaccess'
2348
+ && this.isTrustedFullAccessSession(requestSessionKey);
1657
2349
  if (trackedItem?.type === 'mcpToolCall') {
2350
+ if (trustedFullAccess)
2351
+ return this.handleMcpToolApproval(params, trackedItem);
1658
2352
  const unattendedDenial = await this.denyUnattendedExternalToolApproval(params, `MCP:${String(trackedItem.server || 'unknown')}/${String(trackedItem.tool || 'unknown')}`, request.id);
1659
2353
  if (unattendedDenial)
1660
2354
  return unattendedDenial;
@@ -1664,6 +2358,12 @@ export class CodexRunner {
1664
2358
  return this.handleToolRequestUserInput(params);
1665
2359
  }
1666
2360
  if (this.looksLikeExternalToolApproval(params.questions)) {
2361
+ // In trusted fullaccess, an untracked approval prompt is still an SDK
2362
+ // permission surface, not ordinary model/user interaction. The
2363
+ // fullaccess branch in handleMcpToolApproval deliberately does not
2364
+ // require capability-bound metadata and selects a one-shot allow.
2365
+ if (trustedFullAccess)
2366
+ return this.handleMcpToolApproval(params, {});
1667
2367
  const unattendedDenial = await this.denyUnattendedExternalToolApproval(params, 'MCP:untracked', request.id);
1668
2368
  if (unattendedDenial)
1669
2369
  return unattendedDenial;
@@ -1690,30 +2390,51 @@ export class CodexRunner {
1690
2390
  ? 'FileChange'
1691
2391
  : 'Bash';
1692
2392
  const toolInput = this.buildPermissionInput(request.method, params);
1693
- // proactive 模式行为策略(首次工具调用必须是 ec msg send)
2393
+ // A trusted fullaccess run has already been explicitly authorized by the
2394
+ // daemon owner. Do not route its approvals through ordinary capability,
2395
+ // workspace, or unattended-trigger policy checks.
2396
+ if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
2397
+ if (!this.isTrustedFullAccessSession(sessionKey)) {
2398
+ const reason = 'Untrusted fullaccess execution context';
2399
+ this.rememberToolDenial(request, params, {
2400
+ decision: 'deny',
2401
+ decisionSource: 'policy',
2402
+ policyCode: 'fullaccess_context_untrusted',
2403
+ reason,
2404
+ });
2405
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', 'fullaccess_context_untrusted', reason);
2406
+ setImmediate(() => this.steerPolicyReason(sessionKey, 'fullaccess_context_untrusted', reason));
2407
+ return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2408
+ }
2409
+ const response = this.toAppServerApprovalResponse(request.method, 'allow', toolInput, false);
2410
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'allow', 'approval', undefined, 'trusted fullaccess execution');
2411
+ return response;
2412
+ }
2413
+ // Keep the policy check in the approval path as a fail-closed fallback.
2414
+ // Some app-server tool routes can reach approval without invoking the
2415
+ // command hook; allowing here would bypass the proactive gate.
1694
2416
  const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
1695
2417
  if (policyResult?.block) {
1696
2418
  logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
1697
2419
  `requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
1698
2420
  `reason=${policyResult.reason ?? 'policy denied'} executed=false`);
1699
- try {
1700
- await permissionContext?.recordExecutionAnomaly?.({
1701
- code: 'operation_blocked',
1702
- severity: 'warning',
1703
- phase: 'execution',
1704
- occurredAt: Date.now(),
1705
- toolName,
1706
- ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
1707
- policyCode: policyResult.policyCode ?? 'session_policy_hook',
1708
- summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
1709
- effect: 'operation_skipped',
1710
- });
1711
- }
1712
- catch (error) {
1713
- logger.warn(`[CodexRunner] failed to record policy-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
2421
+ const policyCode = policyResult.policyCode ?? 'session_policy_hook';
2422
+ const policyReason = policyResult.reason ?? 'session policy denied approval';
2423
+ this.rememberToolDenial(request, params, {
2424
+ decision: 'deny',
2425
+ decisionSource: 'policy',
2426
+ policyCode,
2427
+ reason: policyReason,
2428
+ });
2429
+ // ResponseEngine's wrapped policy hook already steers Codex sessions
2430
+ // that have the pure managed-hook policy installed. Standalone callers
2431
+ // may only provide policyHook, so keep this fallback for them without
2432
+ // duplicating the same user message in production.
2433
+ if (!permissionContext?.preToolUsePolicyHook) {
2434
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, policyReason));
1714
2435
  }
1715
2436
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1716
- this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', policyResult.policyCode ?? 'session_policy_hook', policyResult.reason ?? 'session policy denied approval');
2437
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', policyCode, policyReason);
1717
2438
  return response;
1718
2439
  }
1719
2440
  const summary = this.summarizeAppServerRequest(request.method, params);
@@ -1722,18 +2443,28 @@ export class CodexRunner {
1722
2443
  if (!workspacePath) {
1723
2444
  logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
1724
2445
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2446
+ this.rememberToolDenial(request, params, {
2447
+ decision: 'deny',
2448
+ decisionSource: 'infrastructure',
2449
+ policyCode: 'approval_workspace_unknown',
2450
+ reason: 'thread workspace is unknown',
2451
+ });
1725
2452
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', 'approval_workspace_unknown', 'thread workspace is unknown');
2453
+ setImmediate(() => this.steerPolicyReason(sessionKey, 'approval_workspace_unknown', 'thread workspace is unknown'));
1726
2454
  return response;
1727
2455
  }
1728
2456
  const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
1729
2457
  const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
1730
- logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${summary}`);
2458
+ const logSummary = formatApprovalLogSummary(summary);
2459
+ logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${logSummary}`);
1731
2460
  const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
1732
2461
  || request.method === 'execCommandApproval';
1733
2462
  const managedEcIntent = isCommandApproval && this.isManagedEvolcoreCommandIntent(toolInput);
1734
- const approvedCommand = isCommandApproval
1735
- ? this.approvedEvolcoreCommandArgv(toolInput, this.getManagedTempDir(sessionKey))
2463
+ const delegationCarrier = this.delegationCarrierForSession(sessionKey, typeof params.threadId === 'string' ? params.threadId : undefined);
2464
+ const canonicalEcApproval = isCommandApproval
2465
+ ? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey), delegationCarrier)
1736
2466
  : undefined;
2467
+ const approvedCommand = canonicalEcApproval?.argv;
1737
2468
  const denyInfrastructure = async (policyCode, message) => {
1738
2469
  logger.warn(`[CodexRunner] ${message}: session=${sessionKey} requestId=${request.id ?? '<missing>'}`);
1739
2470
  try {
@@ -1747,8 +2478,9 @@ export class CodexRunner {
1747
2478
  policyCode,
1748
2479
  decisionSource: 'infrastructure',
1749
2480
  agentAid: permissionContext?.selfAid,
2481
+ agentName: permissionContext?.agentName,
1750
2482
  sessionId: sessionKey,
1751
- permissionMode: normalizePermissionMode(sessionMode).mode,
2483
+ permissionMode: normalizeExecutionPermissionMode(sessionMode),
1752
2484
  summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
1753
2485
  effect: 'operation_skipped',
1754
2486
  });
@@ -1757,17 +2489,32 @@ export class CodexRunner {
1757
2489
  logger.warn(`[CodexRunner] failed to record infrastructure approval denial: session=${sessionKey} code=${policyCode} error=${error instanceof Error ? error.message : String(error)}`);
1758
2490
  }
1759
2491
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1760
- this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', policyCode, message);
2492
+ this.rememberToolDenial(request, params, {
2493
+ decision: 'deny',
2494
+ decisionSource: 'infrastructure',
2495
+ policyCode,
2496
+ reason: message,
2497
+ });
2498
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', policyCode, message, canonicalEcApproval?.parseFailure);
2499
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, message));
1761
2500
  return response;
1762
2501
  };
1763
2502
  if (managedEcIntent && !approvedCommand) {
1764
- return await denyInfrastructure('ec_command_not_canonical', 'EC command approval denied because no canonical argv could be derived');
2503
+ if (canonicalEcApproval?.parseFailure?.issue === 'untrusted-shell') {
2504
+ return await denyInfrastructure('ec_shell_untrusted_executable', 'EC command approval denied because the shell executable failed Windows Authenticode trust validation');
2505
+ }
2506
+ return await denyInfrastructure('ec_command_not_canonical', canonicalEcApproval?.parseFailure
2507
+ ? `EC command approval denied because canonical argv parsing failed (${canonicalEcApproval.parseFailure.issue})`
2508
+ : 'EC command approval denied because no canonical argv could be derived');
1765
2509
  }
1766
2510
  try {
1767
- const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id);
2511
+ const resolution = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id, typeof params.itemId === 'string' ? params.itemId : typeof params.item_id === 'string' ? params.item_id : undefined);
2512
+ const decision = typeof resolution === 'string' ? resolution : resolution.decision;
2513
+ const resolutionMeta = typeof resolution === 'string' ? undefined : resolution;
1768
2514
  if (decision !== 'deny' && managedEcIntent && approvedCommand) {
1769
2515
  const carrierThreadIds = [
1770
2516
  this.activeSessions.get(sessionKey),
2517
+ this.provisionalSessions.get(sessionKey),
1771
2518
  typeof params.threadId === 'string' ? params.threadId : undefined,
1772
2519
  typeof params.conversationId === 'string' ? params.conversationId : undefined,
1773
2520
  ];
@@ -1776,18 +2523,35 @@ export class CodexRunner {
1776
2523
  // A carrier-bearing thread is managed even if its permission context
1777
2524
  // was not wired with an arm callback; it must fail closed here rather
1778
2525
  // than reaching daemon IPC with an unarmed carrier.
2526
+ if ((hasDelegationCarrier || canArmDelegation)
2527
+ && canonicalEcApproval?.delegationToken
2528
+ && canonicalEcApproval.delegationToken !== delegationCarrier) {
2529
+ return await denyInfrastructure('delegation_token_mismatch', 'EC command approval contains a delegation token different from the active thread carrier');
2530
+ }
1779
2531
  if ((hasDelegationCarrier || canArmDelegation)
1780
2532
  && !this.armApprovedDelegationCommand(sessionKey, approvedCommand)) {
1781
2533
  return await denyInfrastructure('delegation_not_armed', 'EC command approval failed to arm delegation');
1782
2534
  }
1783
2535
  }
1784
2536
  const response = this.toAppServerApprovalResponse(request.method, decision, toolInput, unattended);
1785
- this.logAppServerApprovalAudit(request, sessionKey, toolName, decision === 'deny' ? 'deny' : 'allow', decision === 'deny' ? (sessionMode === 'auto' || sessionMode === 'readonly' ? 'policy' : 'approval') : 'approval', decision === 'deny' && (sessionMode === 'auto' || sessionMode === 'readonly')
1786
- ? 'permission_mode_denied'
1787
- : undefined, decision === 'deny'
1788
- ? (reason || (sessionMode === 'auto' || sessionMode === 'readonly'
1789
- ? `permission mode ${sessionMode} denied approval`
1790
- : 'user approval denied'))
2537
+ const decisionSource = resolutionMeta?.decisionSource
2538
+ ?? (sessionMode === 'auto' || sessionMode === 'readonly' ? 'policy' : 'approval');
2539
+ const policyCode = resolutionMeta?.policyCode
2540
+ ?? (decisionSource === 'policy' ? 'permission_mode_denied' : undefined);
2541
+ const denialReason = resolutionMeta?.reason || reason || (policyCode
2542
+ ? `permission mode ${sessionMode} denied approval`
2543
+ : 'user approval denied');
2544
+ if (decision === 'deny') {
2545
+ this.rememberToolDenial(request, params, {
2546
+ decision: 'deny',
2547
+ decisionSource,
2548
+ ...(policyCode ? { policyCode } : {}),
2549
+ reason: denialReason,
2550
+ });
2551
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, denialReason));
2552
+ }
2553
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, decision === 'deny' ? 'deny' : 'allow', decision === 'deny' ? decisionSource : 'approval', decision === 'deny' ? policyCode : undefined, decision === 'deny'
2554
+ ? denialReason
1791
2555
  : 'approval accepted');
1792
2556
  logger.info(`[CodexRunner] app-server approval response id=${request.id} method=${request.method} decision=${decision} response=${JSON.stringify(response)}`);
1793
2557
  return response;
@@ -1798,29 +2562,38 @@ export class CodexRunner {
1798
2562
  throw error;
1799
2563
  }
1800
2564
  }
1801
- logAppServerApprovalAudit(request, sessionKey, toolName, decision, decisionSource, policyCode, reason) {
2565
+ logAppServerApprovalAudit(request, sessionKey, toolName, decision, decisionSource, policyCode, reason, parseFailure) {
1802
2566
  const context = this.permissionContexts.get(sessionKey);
2567
+ const params = (request.params || {});
2568
+ const rawCallId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2569
+ const callId = typeof rawCallId === 'string' && rawCallId ? rawCallId : undefined;
1803
2570
  auditCodexApprovalDecision({
1804
2571
  requestId: request.id !== undefined ? String(request.id) : undefined,
2572
+ callId,
2573
+ correlationId: callId,
1805
2574
  method: request.method,
1806
2575
  sessionId: sessionKey,
1807
2576
  agentAid: context?.selfAid,
2577
+ agentName: context?.agentName,
1808
2578
  toolName,
1809
2579
  decision,
1810
2580
  decisionSource,
1811
2581
  policyCode,
1812
- permissionMode: normalizePermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode).mode,
2582
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1813
2583
  role: context?.role,
1814
2584
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
2585
+ parseFailure,
1815
2586
  });
1816
2587
  logger.info(`[CodexRunner] app-server approval audit ${JSON.stringify({
1817
2588
  requestId: request.id !== undefined ? String(request.id) : undefined,
2589
+ ...(callId ? { callId, correlationId: callId } : {}),
1818
2590
  method: request.method,
1819
2591
  sessionId: sessionKey,
1820
2592
  toolName,
1821
2593
  decision,
1822
2594
  decisionSource,
1823
2595
  ...(policyCode ? { policyCode } : {}),
2596
+ ...(parseFailure ? { parseFailure } : {}),
1824
2597
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
1825
2598
  executed: false,
1826
2599
  })}`);
@@ -1910,6 +2683,12 @@ export class CodexRunner {
1910
2683
  ? { requestId: String(requestId) }
1911
2684
  : {}),
1912
2685
  policy: context.approvalRouting?.approverPolicy ?? 'requester',
2686
+ policyCode: 'permission_mode_denied',
2687
+ decisionSource: 'policy',
2688
+ agentAid: context.selfAid,
2689
+ agentName: context.agentName,
2690
+ sessionId: sessionKey,
2691
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1913
2692
  summary: `${toolName} runtime authorization denied by unattended Trigger policy`,
1914
2693
  effect: 'operation_skipped',
1915
2694
  });
@@ -1924,6 +2703,27 @@ export class CodexRunner {
1924
2703
  const questions = Array.isArray(params.questions) ? params.questions : [];
1925
2704
  if (questions.length === 0)
1926
2705
  throw new Error('Codex MCP 审批未包含问题,已拒绝');
2706
+ const sessionKey = this.findSessionKeyByThread(params.threadId, params.conversationId);
2707
+ if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
2708
+ if (!this.isTrustedFullAccessSession(sessionKey))
2709
+ return this.denyUntrackedExternalToolApproval(params);
2710
+ const answers = {};
2711
+ for (const question of questions) {
2712
+ const questionId = typeof question.id === 'string' ? question.id : `q-${Object.keys(answers).length + 1}`;
2713
+ const labels = Array.isArray(question.options)
2714
+ ? question.options.map((option) => typeof option?.label === 'string' ? option.label.trim() : '').filter(Boolean)
2715
+ : [];
2716
+ let answer = '';
2717
+ try {
2718
+ answer = this.selectMcpApprovalAnswer(question, true);
2719
+ }
2720
+ catch {
2721
+ answer = labels[0] ?? 'allow';
2722
+ }
2723
+ answers[questionId] = { answers: [answer] };
2724
+ }
2725
+ return { answers };
2726
+ }
1927
2727
  const hasExactIdentity = typeof trackedItem.server === 'string'
1928
2728
  && trackedItem.server.length > 0
1929
2729
  && typeof trackedItem.tool === 'string'
@@ -1940,7 +2740,6 @@ export class CodexRunner {
1940
2740
  logger.warn(`[CodexRunner] MCP approval denied because the hardened external-tool config is unbound: thread=${threadId ?? '<missing>'}`);
1941
2741
  return this.denyUntrackedExternalToolApproval(params);
1942
2742
  }
1943
- const sessionKey = this.findSessionKeyByThread(params.threadId, params.conversationId);
1944
2743
  const server = trackedItem.server;
1945
2744
  const tool = trackedItem.tool;
1946
2745
  const isAppCall = typeof trackedItem.mcpAppResourceUri === 'string'
@@ -1964,7 +2763,7 @@ export class CodexRunner {
1964
2763
  };
1965
2764
  const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
1966
2765
  const workspacePath = this.resolvePermissionWorkspacePath(params);
1967
- const mode = normalizePermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode).mode;
2766
+ const mode = normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode);
1968
2767
  // MCP servers are inherited unless capability config explicitly disables
1969
2768
  // them. Apps retain their separate request/bypass lifecycle policy.
1970
2769
  let allow = !policyResult?.block
@@ -2084,7 +2883,8 @@ export class CodexRunner {
2084
2883
  chatmode: context.chatmode,
2085
2884
  replyContext: context.replyContext,
2086
2885
  });
2087
- sent = !!await sendInteractionPayload(context.adapter, envelope, interaction, undefined, context.replyContext);
2886
+ const receipt = await sendInteractionPayload(context.adapter, envelope, interaction, undefined, context.replyContext);
2887
+ sent = isInteractionSendAccepted(receipt);
2088
2888
  }
2089
2889
  if (!sent) {
2090
2890
  await sendPrompt(renderActionAsText(interaction));
@@ -2147,9 +2947,20 @@ export class CodexRunner {
2147
2947
  if (activeThreadId === candidate)
2148
2948
  return sessionKey;
2149
2949
  }
2950
+ for (const [sessionKey, provisionalThreadId] of this.provisionalSessions.entries()) {
2951
+ if (provisionalThreadId === candidate)
2952
+ return sessionKey;
2953
+ }
2954
+ const childSessionKey = this.childThreadSessions.get(candidate);
2955
+ if (childSessionKey)
2956
+ return childSessionKey;
2150
2957
  }
2151
2958
  return candidates[0] || 'codex-app-server';
2152
2959
  }
2960
+ isThreadOwnedBySession(sessionId, threadId) {
2961
+ const activeThread = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
2962
+ return activeThread === threadId || this.childThreadSessions.get(threadId) === sessionId;
2963
+ }
2153
2964
  buildPermissionInput(method, params) {
2154
2965
  if (method === 'item/permissions/requestApproval') {
2155
2966
  return { permissions: params.permissions, cwd: params.cwd, reason: params.reason };
@@ -2211,6 +3022,12 @@ export class CodexRunner {
2211
3022
  const threadProjectPath = this.threadProjectPaths.get(threadId);
2212
3023
  if (threadProjectPath)
2213
3024
  return threadProjectPath;
3025
+ const parentSession = this.childThreadSessions.get(threadId);
3026
+ const parentThread = parentSession
3027
+ ? this.activeSessions.get(parentSession) ?? this.provisionalSessions.get(parentSession)
3028
+ : undefined;
3029
+ if (parentThread)
3030
+ return this.threadProjectPaths.get(parentThread);
2214
3031
  }
2215
3032
  return undefined;
2216
3033
  }
@@ -2239,7 +3056,7 @@ export class CodexRunner {
2239
3056
  // Revalidate ownership, mode, and parent containment at the final
2240
3057
  // readonly decision. The map entry alone is stale if the directory was
2241
3058
  // replaced between preflight and the approval callback.
2242
- managedTempDir: this.getManagedTempDir(sessionKey),
3059
+ managedTempDir: permCtx?.managedTempDir ?? this.getManagedTempDir(sessionKey),
2243
3060
  allowReadonlySourceDiagnostics: permCtx?.allowReadonlySourceDiagnostics === true,
2244
3061
  } : undefined;
2245
3062
  if (toolName === 'Bash')
@@ -2341,7 +3158,7 @@ export class CodexRunner {
2341
3158
  return true;
2342
3159
  });
2343
3160
  }
2344
- async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId) {
3161
+ async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId, callId) {
2345
3162
  const permissionContext = this.permissionContexts.get(sessionKey);
2346
3163
  const permissionPrompt = permissionContext?.sendPrompt ?? this.sendPromptFn;
2347
3164
  const hasFilesystemPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
@@ -2355,7 +3172,13 @@ export class CodexRunner {
2355
3172
  && this.fileChangeRequiresExpansion(toolInput, workspacePath, sessionKey);
2356
3173
  // per-session 权限模式(runQuery 写入);缺省回落实例级 currentMode(兼容无 runQuery 上下文的调用)
2357
3174
  const rawMode = this.chatModes.get(sessionKey) ?? this.currentMode;
2358
- const mode = normalizePermissionMode(rawMode).mode;
3175
+ const mode = normalizeExecutionPermissionMode(rawMode);
3176
+ if (rawMode === 'fullaccess') {
3177
+ if (!this.isTrustedFullAccessSession(sessionKey)) {
3178
+ return 'deny';
3179
+ }
3180
+ return 'allow';
3181
+ }
2359
3182
  const recordBlockedOperation = async (policyCode, operationInput = toolInput, denialReason = 'policy denied', matchedPath) => {
2360
3183
  const summary = summarizeToolInputForAudit(toolName, operationInput).slice(0, 512);
2361
3184
  auditToolPreflightDenial({
@@ -2365,12 +3188,15 @@ export class CodexRunner {
2365
3188
  summary,
2366
3189
  sessionId: sessionKey,
2367
3190
  agentAid: permissionContext?.selfAid,
3191
+ agentName: permissionContext?.agentName,
2368
3192
  permissionMode: mode,
2369
3193
  channel: permissionContext?.channel,
2370
3194
  actorId: permissionContext?.userId,
2371
3195
  role: permissionContext?.role,
2372
3196
  selfAid: permissionContext?.selfAid,
2373
3197
  requestId: typeof requestId === 'string' || typeof requestId === 'number' ? String(requestId) : undefined,
3198
+ callId,
3199
+ correlationId: callId,
2374
3200
  taskId: permissionContext?.taskId,
2375
3201
  matchedPath,
2376
3202
  });
@@ -2385,6 +3211,7 @@ export class CodexRunner {
2385
3211
  policyCode,
2386
3212
  decisionSource: 'policy',
2387
3213
  agentAid: permissionContext?.selfAid,
3214
+ agentName: permissionContext?.agentName,
2388
3215
  permissionMode: mode,
2389
3216
  summary,
2390
3217
  effect: 'operation_skipped',
@@ -2399,7 +3226,8 @@ export class CodexRunner {
2399
3226
  // An unavailable registered root must fail closed; the policy helper
2400
3227
  // distinguishes this explicit empty value from runners without a
2401
3228
  // session-temp capability.
2402
- managedTempDir: this.getManagedTempDir(sessionKey) ?? '',
3229
+ managedTempDir: permissionContext?.managedTempDir ?? this.getManagedTempDir(sessionKey) ?? '',
3230
+ delegationCarrier: this.delegationCarrierForSession(sessionKey),
2403
3231
  allowProtectedMetadata: false,
2404
3232
  selfAid: permissionContext?.selfAid,
2405
3233
  channel: permissionContext?.channel,
@@ -2416,7 +3244,12 @@ export class CodexRunner {
2416
3244
  if (preflight.policyCode) {
2417
3245
  await recordBlockedOperation(preflight.policyCode, toolInput, preflight.message ?? 'tool preflight denied', preflight.matchedPath);
2418
3246
  }
2419
- return 'deny';
3247
+ return {
3248
+ decision: 'deny',
3249
+ decisionSource: 'policy',
3250
+ policyCode: preflight.policyCode ?? 'tool_preflight_denied',
3251
+ reason: preflight.message ?? 'tool preflight denied',
3252
+ };
2420
3253
  }
2421
3254
  if (preflight.behavior === 'allow')
2422
3255
  return 'allow';
@@ -2686,7 +3519,8 @@ export class CodexRunner {
2686
3519
  const input = [{ type: 'text', text: prompt, text_elements: [] }];
2687
3520
  if (!images?.length)
2688
3521
  return input;
2689
- const tmpDir = runtimeEnv?.EVOLCORE_SESSION_RUNTIME_DIR
3522
+ const tmpDir = getManagedTaskTempDir(runtimeEnv)
3523
+ ?? runtimeEnv?.EVOLCORE_SESSION_RUNTIME_DIR
2690
3524
  ?? (sessionId ? this.getManagedTempDir(sessionId) : undefined)
2691
3525
  ?? process.env.TMPDIR;
2692
3526
  if (!tmpDir || !path.isAbsolute(tmpDir))
@@ -2729,19 +3563,142 @@ export class CodexRunner {
2729
3563
  }
2730
3564
  return !state.turnId || !turnId || turnId === state.turnId;
2731
3565
  }
2732
- async *transformAppServerStream(notifications, sessionId, state, unsubscribe, tempFiles) {
3566
+ isSubagentActivityItem(item) {
3567
+ return typeof item?.type === 'string' && item.type.toLowerCase() === 'subagentactivity';
3568
+ }
3569
+ subagentThreadId(item) {
3570
+ const value = item?.agentThreadId ?? item?.agent_thread_id;
3571
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3572
+ }
3573
+ subagentPath(item) {
3574
+ const value = item?.agentPath ?? item?.agent_path;
3575
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3576
+ }
3577
+ subagentType(item) {
3578
+ const value = item?.agentType ?? item?.agent_type;
3579
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3580
+ }
3581
+ registerChildThread(sessionId, childThreadId, projectPath) {
3582
+ const parentThreadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
3583
+ if (childThreadId === parentThreadId)
3584
+ return;
3585
+ this.childThreadSessions.set(childThreadId, sessionId);
3586
+ this.threadProjectPaths.set(childThreadId, path.resolve(projectPath));
3587
+ // Child approvals use the parent's capability boundary and delegation
3588
+ // carrier. This is inheritance, not a second permission decision.
3589
+ const parentBoundary = parentThreadId ? this.threadExternalToolBoundaries.get(parentThreadId) : undefined;
3590
+ const parentFingerprint = parentThreadId ? this.threadExternalToolFingerprints.get(parentThreadId) : undefined;
3591
+ if (parentBoundary)
3592
+ this.threadExternalToolBoundaries.set(childThreadId, parentBoundary);
3593
+ if (parentFingerprint)
3594
+ this.threadExternalToolFingerprints.set(childThreadId, parentFingerprint);
3595
+ const parentCarrier = parentThreadId ? this.threadDelegationCarriers.get(parentThreadId) : undefined;
3596
+ if (parentCarrier)
3597
+ this.threadDelegationCarriers.set(childThreadId, parentCarrier);
3598
+ }
3599
+ unregisterChildThread(childThreadId) {
3600
+ this.childThreadSessions.delete(childThreadId);
3601
+ this.activeSubagentTurns.delete(childThreadId);
3602
+ this.threadProjectPaths.delete(childThreadId);
3603
+ this.threadExternalToolBoundaries.delete(childThreadId);
3604
+ this.threadExternalToolFingerprints.delete(childThreadId);
3605
+ this.threadDelegationCarriers.delete(childThreadId);
3606
+ this.clearTrackedApprovalItemsForThread(childThreadId);
3607
+ }
3608
+ cleanupChildThreadsForSession(sessionId) {
3609
+ for (const [childThreadId, ownerSessionId] of this.childThreadSessions) {
3610
+ if (ownerSessionId === sessionId)
3611
+ this.unregisterChildThread(childThreadId);
3612
+ }
3613
+ }
3614
+ trackSubagentLifecycle(notification, state) {
3615
+ const params = (notification.params || {});
3616
+ const item = params.item && typeof params.item === 'object' ? params.item : undefined;
3617
+ if (!this.isSubagentActivityItem(item))
3618
+ return;
3619
+ const childThreadId = this.subagentThreadId(item);
3620
+ if (!childThreadId)
3621
+ return;
3622
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3623
+ if (kind === 'started') {
3624
+ state.activeSubagentThreads.add(childThreadId);
3625
+ this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3626
+ }
3627
+ if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3628
+ state.activeSubagentThreads.delete(childThreadId);
3629
+ this.unregisterChildThread(childThreadId);
3630
+ }
3631
+ }
3632
+ primeSubagentStateFromTurn(turn, state) {
3633
+ for (const item of this.getTurnItems(turn)) {
3634
+ if (!this.isSubagentActivityItem(item))
3635
+ continue;
3636
+ const childThreadId = this.subagentThreadId(item);
3637
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3638
+ if (!childThreadId)
3639
+ continue;
3640
+ if (kind === 'started') {
3641
+ state.activeSubagentThreads.add(childThreadId);
3642
+ this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3643
+ }
3644
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3645
+ state.activeSubagentThreads.delete(childThreadId);
3646
+ this.unregisterChildThread(childThreadId);
3647
+ }
3648
+ }
3649
+ }
3650
+ endAppServerQueueWhenSubagentsDone(queue, state) {
3651
+ if (!state.turnCompleted)
3652
+ return;
3653
+ if (state.activeSubagentThreads.size > 0) {
3654
+ logger.info(`[CodexRunner] parent turn completed; waiting for subagent thread(s): `
3655
+ + `${[...state.activeSubagentThreads].join(',')}`);
3656
+ return;
3657
+ }
3658
+ queue.end();
3659
+ }
3660
+ async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, tempFiles) {
2733
3661
  try {
2734
- yield { type: 'session_id', sessionId: state.threadId };
3662
+ let sessionIdEmitted = state.committed;
3663
+ if (sessionIdEmitted)
3664
+ yield { type: 'session_id', sessionId: state.threadId };
2735
3665
  for await (const notification of notifications) {
2736
3666
  if (!this.activeAbortControllers.has(sessionId))
2737
3667
  break;
3668
+ if (!state.committed && notification.method === 'turn/completed') {
3669
+ const turn = notification.params?.turn ?? {};
3670
+ if (turn.status === 'completed') {
3671
+ const committed = await state.commitProvisional?.();
3672
+ if (!committed) {
3673
+ const staleError = new Error('Codex provisional thread was superseded before commit');
3674
+ staleError.code = 'RUNNER_START_SUPERSEDED';
3675
+ throw staleError;
3676
+ }
3677
+ state.committed = true;
3678
+ if (!sessionIdEmitted) {
3679
+ sessionIdEmitted = true;
3680
+ yield { type: 'session_id', sessionId: state.threadId };
3681
+ }
3682
+ }
3683
+ }
2738
3684
  yield* this.mapAppServerNotification(notification, sessionId, state);
2739
3685
  }
2740
3686
  }
2741
3687
  finally {
2742
3688
  unsubscribe();
2743
- this.activeAbortControllers.delete(sessionId);
2744
- this.activeTurns.delete(sessionId);
3689
+ if (this.activeAbortControllers.get(sessionId) === controller) {
3690
+ this.activeAbortControllers.delete(sessionId);
3691
+ }
3692
+ const activeTurn = this.activeTurns.get(sessionId);
3693
+ if (activeTurn?.threadId === state.threadId
3694
+ && (!state.turnId || activeTurn.turnId === state.turnId)) {
3695
+ this.activeTurns.delete(sessionId);
3696
+ }
3697
+ if (!state.committed)
3698
+ this.cleanupProvisionalThread(sessionId, state.threadId);
3699
+ for (const childThreadId of state.activeSubagentThreads)
3700
+ this.unregisterChildThread(childThreadId);
3701
+ state.activeSubagentThreads.clear();
2745
3702
  this.cleanupTempFiles(tempFiles);
2746
3703
  }
2747
3704
  }
@@ -2809,12 +3766,16 @@ export class CodexRunner {
2809
3766
  }
2810
3767
  case 'turn/completed': {
2811
3768
  const turn = params.turn || {};
2812
- const turnId = turn.id || params.turnId;
3769
+ const turnId = turn.id || params.turnId || state.turnId;
2813
3770
  if (turnId && state.completedTurnIds.has(turnId))
2814
3771
  break;
2815
3772
  if (turnId)
2816
3773
  state.completedTurnIds.add(turnId);
2817
- this.activeTurns.delete(sessionId);
3774
+ const activeTurn = this.activeTurns.get(sessionId);
3775
+ if (activeTurn?.threadId === state.threadId && activeTurn.turnId === turnId) {
3776
+ this.activeTurns.delete(sessionId);
3777
+ }
3778
+ this.clearPendingToolDenialsForTurn(state.threadId, turnId);
2818
3779
  yield* this.reconcileOpenAppServerToolCalls(state, turnId);
2819
3780
  if (turn.status === 'failed' && turn.error?.message) {
2820
3781
  if (isRetryableError(new Error(turn.error.message))) {
@@ -2935,11 +3896,36 @@ export class CodexRunner {
2935
3896
  };
2936
3897
  break;
2937
3898
  case 'subAgentActivity':
2938
- yield {
2939
- type: 'task_progress',
2940
- summary: `Subagent ${item.kind}: ${item.agentPath || item.agentThreadId}`,
2941
- };
3899
+ case 'SubAgentActivity': {
3900
+ const childThreadId = this.subagentThreadId(item);
3901
+ if (!childThreadId)
3902
+ break;
3903
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3904
+ const description = this.subagentPath(item) ?? childThreadId;
3905
+ if (kind === 'started') {
3906
+ yield {
3907
+ type: 'task_started',
3908
+ taskId: childThreadId,
3909
+ taskKind: 'agent',
3910
+ description,
3911
+ subagentType: this.subagentType(item),
3912
+ };
3913
+ }
3914
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3915
+ yield {
3916
+ type: 'task_notification',
3917
+ taskId: childThreadId,
3918
+ taskKind: 'agent',
3919
+ description,
3920
+ status: kind === 'completed' ? 'completed' : kind,
3921
+ summary: description,
3922
+ };
3923
+ }
3924
+ else {
3925
+ yield { type: 'task_progress', taskId: childThreadId, taskKind: 'agent', description, summary: `Subagent ${item.kind}: ${description}` };
3926
+ }
2942
3927
  break;
3928
+ }
2943
3929
  case 'enteredReviewMode':
2944
3930
  yield { type: 'task_progress', summary: item.review || 'Review mode entered' };
2945
3931
  break;
@@ -2954,6 +3940,59 @@ export class CodexRunner {
2954
3940
  *mapAppServerItemCompleted(item, state) {
2955
3941
  if (!item)
2956
3942
  return;
3943
+ const denial = this.takeToolDenial(state, item.id);
3944
+ const denialFields = denial
3945
+ ? {
3946
+ isError: true,
3947
+ error: denial.reason,
3948
+ errorCode: errorCodeForToolDenial(denial.decisionSource, denial.policyCode),
3949
+ decision: 'deny',
3950
+ decisionSource: denial.decisionSource,
3951
+ policyCode: denial.policyCode,
3952
+ reason: denial.reason,
3953
+ requestId: denial.requestId,
3954
+ executed: false,
3955
+ executionState: 'blocked',
3956
+ }
3957
+ : undefined;
3958
+ const itemErrorText = outputTextForManagedHook(typeof item.error === 'string' ? item.error : item.error?.message);
3959
+ const hookErrorText = /(?:Command|Tool call) blocked by PreToolUse hook\s*:/i.test(itemErrorText)
3960
+ ? itemErrorText
3961
+ : undefined;
3962
+ const managedHookDenial = (item.status === 'failed' || item.status === 'declined' || hookErrorText)
3963
+ ? parseManagedPreToolUseDenial([
3964
+ item.aggregatedOutput,
3965
+ item.output,
3966
+ item.result,
3967
+ hookErrorText,
3968
+ ].filter(value => value !== undefined && value !== null).map(outputTextForManagedHook).join('\n'))
3969
+ : undefined;
3970
+ const managedHookFields = !denial && managedHookDenial
3971
+ ? {
3972
+ isError: true,
3973
+ error: managedHookDenial.reason,
3974
+ errorCode: 'POLICY_DENIED',
3975
+ decision: 'deny',
3976
+ decisionSource: 'policy',
3977
+ policyCode: managedHookDenial.policyCode,
3978
+ reason: managedHookDenial.reason,
3979
+ executed: false,
3980
+ executionState: 'blocked',
3981
+ }
3982
+ : undefined;
3983
+ const genericDeclineFields = !denial && !managedHookFields && item.status === 'declined'
3984
+ ? {
3985
+ isError: true,
3986
+ error: 'Approval declined',
3987
+ errorCode: 'USER_DENIED',
3988
+ decision: 'deny',
3989
+ decisionSource: 'approval',
3990
+ reason: 'Approval declined',
3991
+ executed: false,
3992
+ executionState: 'blocked',
3993
+ }
3994
+ : undefined;
3995
+ const blockedFields = denialFields ?? managedHookFields ?? genericDeclineFields;
2957
3996
  switch (item.type) {
2958
3997
  case 'agentMessage':
2959
3998
  {
@@ -2970,8 +4009,10 @@ export class CodexRunner {
2970
4009
  type: 'tool_result',
2971
4010
  name: 'Shell',
2972
4011
  result: item.aggregatedOutput ?? '',
2973
- isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed',
2974
- ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
4012
+ ...(blockedFields ?? {
4013
+ isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed' || item.status === 'declined',
4014
+ ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
4015
+ }),
2975
4016
  callId: item.id,
2976
4017
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
2977
4018
  };
@@ -2981,8 +4022,8 @@ export class CodexRunner {
2981
4022
  type: 'tool_result',
2982
4023
  name: `MCP:${item.server}/${item.tool}`,
2983
4024
  result: item.result,
2984
- isError: item.status === 'failed',
2985
- error: item.error?.message,
4025
+ ...(blockedFields ?? { isError: item.status === 'failed' }),
4026
+ ...(blockedFields ? {} : { error: item.error?.message }),
2986
4027
  ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
2987
4028
  callId: item.id,
2988
4029
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
@@ -2993,7 +4034,7 @@ export class CodexRunner {
2993
4034
  type: 'tool_result',
2994
4035
  name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool,
2995
4036
  result: item.contentItems,
2996
- isError: item.success === false || item.status === 'failed',
4037
+ ...(blockedFields ?? { isError: item.success === false || item.status === 'failed' }),
2997
4038
  ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
2998
4039
  callId: item.id,
2999
4040
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
@@ -3008,12 +4049,12 @@ export class CodexRunner {
3008
4049
  yield editEvent;
3009
4050
  }
3010
4051
  }
3011
- yield { type: 'tool_result', name: 'Edit', result: item.changes, isError: item.status === 'failed', callId: item.id };
4052
+ yield { type: 'tool_result', name: 'Edit', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
3012
4053
  }
3013
4054
  else {
3014
4055
  const desc = this.normalizeFileChanges(item.changes).map((change) => this.describeFileChange(change)).join(', ');
3015
4056
  yield { type: 'tool_use', name: 'FileChange', input: { description: desc }, callId: item.id };
3016
- yield { type: 'tool_result', name: 'FileChange', result: item.changes, isError: item.status === 'failed', callId: item.id };
4057
+ yield { type: 'tool_result', name: 'FileChange', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
3017
4058
  }
3018
4059
  break;
3019
4060
  case 'webSearch':
@@ -3067,6 +4108,34 @@ export class CodexRunner {
3067
4108
  callId: item.id,
3068
4109
  };
3069
4110
  break;
4111
+ case 'subAgentActivity':
4112
+ case 'SubAgentActivity': {
4113
+ const childThreadId = this.subagentThreadId(item);
4114
+ if (!childThreadId)
4115
+ break;
4116
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
4117
+ const description = this.subagentPath(item) ?? childThreadId;
4118
+ if (kind === 'started') {
4119
+ yield {
4120
+ type: 'task_started',
4121
+ taskId: childThreadId,
4122
+ taskKind: 'agent',
4123
+ description,
4124
+ subagentType: this.subagentType(item),
4125
+ };
4126
+ }
4127
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
4128
+ yield {
4129
+ type: 'task_notification',
4130
+ taskId: childThreadId,
4131
+ taskKind: 'agent',
4132
+ description,
4133
+ status: kind === 'completed' ? 'completed' : kind,
4134
+ summary: description,
4135
+ };
4136
+ }
4137
+ break;
4138
+ }
3070
4139
  }
3071
4140
  }
3072
4141
  *mapAppServerFileChangePatchUpdated(params, state) {
@@ -3314,16 +4383,21 @@ export class CodexRunner {
3314
4383
  for (const [key, controller] of this.activeAbortControllers) {
3315
4384
  controller.abort('dispose');
3316
4385
  }
4386
+ await Promise.all([...this.activeSubagentTurns.entries()].map(([threadId, active]) => this.interruptAppServerTurn(threadId, active.turnId).catch(() => { })));
3317
4387
  this.activeAbortControllers.clear();
3318
4388
  this.activeStreams.clear();
3319
4389
  this.activeSessions.clear();
4390
+ this.provisionalSessions.clear();
3320
4391
  this.activeTurns.clear();
4392
+ this.activeSubagentTurns.clear();
4393
+ this.childThreadSessions.clear();
3321
4394
  this.threadProjectPaths.clear();
3322
4395
  this.threadExternalToolFingerprints.clear();
3323
4396
  this.threadExternalToolBoundaries.clear();
3324
4397
  this.trackedApprovalItems.clear();
3325
4398
  this.threadDelegationCarriers.clear();
3326
4399
  this.pendingInterrupts.clear();
4400
+ this.steeringTails.clear();
3327
4401
  this.permissionContexts.clear();
3328
4402
  this.chatModes.clear();
3329
4403
  this.pruneThreadOperationLocks();
@@ -3362,11 +4436,9 @@ export class CodexAgentPlugin {
3362
4436
  agents: {
3363
4437
  codex: {
3364
4438
  ...(override || {}),
3365
- evolcoreAgentAid: agent.config.aid,
3366
- evolcoreAgentConfig: agent.config,
3367
4439
  },
3368
4440
  },
3369
4441
  };
3370
- return { evolagentName: agent.name, baseagent: 'codex', agent: new CodexRunner(merged, callbacks) };
4442
+ return { evolagentName: agent.name, baseagent: 'codex', agent: new CodexRunner(merged, callbacks, { agentAid: agent.config.aid, agentConfig: agent.config }) };
3371
4443
  }
3372
4444
  }