evolcore 0.0.26 → 0.0.27

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 (169) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/codex-managed-acl.ps1 +43 -0
  3. package/dist/agents/baseagent-availability.js +68 -0
  4. package/dist/agents/claude-runner.js +123 -22
  5. package/dist/agents/codex/app-server-client.js +63 -26
  6. package/dist/agents/codex-runner.js +316 -411
  7. package/dist/agents/ecagent-runner.js +170 -25
  8. package/dist/agents/gemini-runner.js +6 -0
  9. package/dist/agents/runner-types.js +50 -2
  10. package/dist/aun/aid/agentmd.js +73 -31
  11. package/dist/aun/aid/client.js +1 -1
  12. package/dist/aun/aid/control-aid.js +180 -1
  13. package/dist/aun/aid/identity.js +3 -3
  14. package/dist/aun/group/history.js +303 -0
  15. package/dist/aun/group/identity.js +43 -0
  16. package/dist/aun/group/index.js +5 -0
  17. package/dist/aun/{msg/group.js → group/operations.js} +161 -38
  18. package/dist/aun/{menu-client.js → menu/client.js} +12 -7
  19. package/dist/aun/menu/index.js +2 -0
  20. package/dist/aun/menu/transport.js +49 -0
  21. package/dist/aun/msg/index.js +2 -1
  22. package/dist/aun/msg/managed-operation.js +84 -18
  23. package/dist/aun/msg/mention-schema.js +51 -0
  24. package/dist/aun/msg/p2p.js +13 -8
  25. package/dist/aun/msg/upload.js +43 -88
  26. package/dist/aun/outbox.js +29 -7
  27. package/dist/aun/rpc/client.js +1 -0
  28. package/dist/aun/storage/client.js +39 -50
  29. package/dist/channels/aun.js +522 -233
  30. package/dist/channels/daemon.js +10 -6
  31. package/dist/cli/agent-command.js +108 -6
  32. package/dist/cli/agent-create.js +515 -2
  33. package/dist/cli/agent.js +109 -8
  34. package/dist/cli/aun-commands.js +191 -42
  35. package/dist/cli/config.js +8 -5
  36. package/dist/cli/contact.js +2 -2
  37. package/dist/cli/ctl-command.js +2 -2
  38. package/dist/cli/daemon-commands.js +35 -23
  39. package/dist/cli/diag.js +2 -2
  40. package/dist/cli/fs-command.js +3 -3
  41. package/dist/cli/handoff-command.js +6 -6
  42. package/dist/cli/index.js +8 -2
  43. package/dist/cli/init.js +34 -1
  44. package/dist/cli/managed-command-guard.js +2 -1
  45. package/dist/cli/menu-client.js +9 -5
  46. package/dist/cli/model.js +3 -3
  47. package/dist/cli/queue-command.js +2 -2
  48. package/dist/cli/response.js +2 -2
  49. package/dist/cli/restart-monitor.js +16 -2
  50. package/dist/cli/task-command.js +59 -0
  51. package/dist/cli/task-context.js +97 -49
  52. package/dist/cli/trigger-command.js +20 -10
  53. package/dist/config/builtin-roles.js +10 -0
  54. package/dist/config/config-field-policy.js +11 -0
  55. package/dist/config/config-manager.js +32 -4
  56. package/dist/config/config-operation-service.js +20 -5
  57. package/dist/config/contact-book-store.js +1 -1
  58. package/dist/config/contact-book.js +1 -1
  59. package/dist/config/contact-operation-service.js +1 -1
  60. package/dist/config/peer-role-resolver.js +1 -1
  61. package/dist/config/resolved-config-op.js +3 -0
  62. package/dist/config-store.js +1 -1
  63. package/dist/core/agent-application-service.js +5 -0
  64. package/dist/core/audit/event-key.js +17 -2
  65. package/dist/core/audit/log-integrity.js +34 -8
  66. package/dist/core/auth/agent-delegation.js +48 -14
  67. package/dist/core/auth/auth-gateway.js +2 -0
  68. package/dist/core/auth/authorization-audit.js +34 -38
  69. package/dist/core/auth/operation-authorizer.js +67 -5
  70. package/dist/core/auth/operation-catalog.js +110 -0
  71. package/dist/core/auth/remote-access-store.js +115 -9
  72. package/dist/core/bootstrap-messages.js +66 -7
  73. package/dist/core/bootstrap-service.js +110 -19
  74. package/dist/core/command/agent-control.js +88 -7
  75. package/dist/core/command/agent-storage-menu.js +443 -0
  76. package/dist/core/command/cli-intent-parser.js +19 -0
  77. package/dist/core/command/command-handler.js +76 -39
  78. package/dist/core/command/data-root-resolver.js +273 -0
  79. package/dist/core/command/group-menu.js +7 -2
  80. package/dist/core/command/menu-catalog.js +34 -4
  81. package/dist/core/command/menu-handler.js +348 -77
  82. package/dist/core/command/menu-protocol.js +40 -6
  83. package/dist/core/command/role-menu.js +2 -2
  84. package/dist/core/command/slash-handler.js +37 -22
  85. package/dist/core/event-catalog.js +4 -0
  86. package/dist/core/evolagent-registry.js +28 -2
  87. package/dist/core/group/invite-service.js +924 -0
  88. package/dist/core/interaction-router.js +22 -8
  89. package/dist/core/message/im-renderer.js +76 -15
  90. package/dist/core/message/message-bridge.js +94 -11
  91. package/dist/core/message/message-log.js +5 -3
  92. package/dist/core/message/message-queue.js +73 -41
  93. package/dist/core/message/response-engine.js +709 -117
  94. package/dist/core/permission/approval-gateway.js +36 -3
  95. package/dist/core/permission/ec-command-parser.js +61 -29
  96. package/dist/core/permission/protected-paths.js +134 -16
  97. package/dist/core/permission/sandbox-runtime.js +72 -3
  98. package/dist/core/permission/tool-error-code.js +15 -0
  99. package/dist/core/permission/tool-policy.js +7 -7
  100. package/dist/core/relation/peer-identity.js +57 -6
  101. package/dist/core/relation/shared-data.js +106 -0
  102. package/dist/core/role/runtime-policy.js +64 -0
  103. package/dist/core/runtime-lock.js +10 -26
  104. package/dist/core/session/runner-task-history.js +170 -0
  105. package/dist/core/session/session-manager.js +34 -6
  106. package/dist/core/session/session-mapper.js +41 -0
  107. package/dist/core/session/session-turn-coordinator.js +37 -7
  108. package/dist/core/session/session-turns.js +48 -7
  109. package/dist/core/task/task-recovery-service.js +176 -0
  110. package/dist/core/task/task-resume-operation.js +32 -0
  111. package/dist/eck/group-rules-sync.js +5 -3
  112. package/dist/eck/kit-renderer.js +1 -0
  113. package/dist/index.js +308 -54
  114. package/dist/ipc.js +141 -15
  115. package/dist/paths.js +17 -4
  116. package/dist/product.js +2 -0
  117. package/dist/trigger/event-source.js +19 -0
  118. package/dist/trigger/feedback.js +233 -40
  119. package/dist/trigger/parser.js +55 -5
  120. package/dist/trigger/patch.js +9 -0
  121. package/dist/trigger/scheduler.js +30 -4
  122. package/dist/trigger/validation.js +20 -3
  123. package/dist/utils/ecweb-utils.js +1 -0
  124. package/dist/utils/error-utils.js +40 -2
  125. package/dist/utils/logger.js +23 -13
  126. package/dist/utils/tool-summary.js +59 -3
  127. package/dist/utils/welcome.js +174 -1
  128. package/ecagent/dist/harness/agent-harness.js +3 -0
  129. package/ecagent/dist/harness/session/jsonl-storage.js +21 -2
  130. package/kits/docs/INDEX.md +0 -6
  131. package/kits/docs/channels/aun.md +15 -2
  132. package/kits/docs/channels/feishu.md +1 -1
  133. package/kits/docs/context-assembly.md +29 -17
  134. package/kits/docs/evolcore/INDEX.md +8 -3
  135. package/kits/docs/evolcore/agent-create.md +39 -7
  136. package/kits/docs/evolcore/agent.md +33 -1
  137. package/kits/docs/evolcore/aid.md +9 -1
  138. package/kits/docs/evolcore/config.md +40 -1
  139. package/kits/docs/evolcore/group-collaboration.md +1 -1
  140. package/kits/docs/evolcore/group.md +117 -2
  141. package/kits/docs/evolcore/menu-client.md +16 -0
  142. package/kits/docs/evolcore/msg.md +3 -0
  143. package/kits/docs/evolcore/storage.md +3 -0
  144. package/kits/docs/evolcore/task.md +14 -0
  145. package/kits/docs/evolcore/trigger.md +51 -3
  146. package/kits/docs/venues/group.md +5 -0
  147. package/kits/eck_manifest.json +27 -0
  148. package/kits/rules/01-overview.md +11 -7
  149. package/kits/rules/04-relation.md +2 -1
  150. package/kits/rules/05-venue.md +1 -1
  151. package/kits/rules/06-channel.md +6 -4
  152. package/kits/schemas/agent-config.schema.12.json +25 -1
  153. package/kits/schemas/daemon.schema.6.json +2 -2
  154. package/kits/schemas/defaults.schema.5.json +1 -1
  155. package/kits/schemas/relation-config.schema.1.json +1 -1
  156. package/kits/schemas/relation-config.schema.5.json +1 -1
  157. package/kits/schemas/role-config.schema.1.json +14 -0
  158. package/kits/templates/roles/member.json +5 -0
  159. package/kits/templates/roles/visitor.json +5 -0
  160. package/kits/templates/system-fragments/baseagent.md +1 -1
  161. package/kits/templates/system-fragments/bootstrap.md +26 -9
  162. package/kits/templates/system-fragments/channel.md +8 -44
  163. package/kits/templates/system-fragments/commands.md +10 -2
  164. package/kits/templates/system-fragments/group-unread-history.md +7 -0
  165. package/kits/templates/system-fragments/reply.md +56 -0
  166. package/package.json +7 -3
  167. package/dist/aun/group-identity.js +0 -9
  168. /package/dist/aun/{group-fs-download.js → group/fs-download.js} +0 -0
  169. /package/dist/aun/{msg/group-index.js → group/index-rpc.js} +0 -0
@@ -1,11 +1,11 @@
1
1
  import { getRunnerCatalog, catalogEfforts, assertCatalogSelectable } from '../core/model/model-catalog.js';
2
2
  // Codex/OpenAI API 不认识 Claude Code SDK 的窗口后缀,必须传入基础模型 ID。
3
3
  import { stripContextWindowSuffix } from '../core/model/context-window-suffix.js';
4
- import { hasTrustedFullAccessContext } from './runner-types.js';
4
+ import { formatAsyncQuestionAnswer, hasTrustedFullAccessContext, parseAsyncUserQuestions, unsettledInterruptError, withInterruptDeadline } from './runner-types.js';
5
5
  import { requestDangerousCommandPermission } from '../core/permission/approval-gateway.js';
6
6
  import { checkReadonly, checkDangerousCommand, evaluateToolPreflight } from '../core/permission/tool-policy.js';
7
7
  import { normalizeExecutionPermissionMode, normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
8
- import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/permission/protected-paths.js';
8
+ import { assertWindowsCodexFilesystemRuleBudget, buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate, } from '../core/permission/protected-paths.js';
9
9
  import { CODEX_THREAD_ACTIVE_WRITER, CodexAppServerClient, } from './codex/app-server-client.js';
10
10
  import { resolveOpenaiConfig, resolveCodexCliPath } from './baseagent.js';
11
11
  import { logger } from '../utils/logger.js';
@@ -15,12 +15,12 @@ import { classifyError, ErrorType, isOutputTokenLimitError, isRetryableError } f
15
15
  import { renderActionAsText } from '../core/interaction-router.js';
16
16
  import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from '../core/message/message-utils.js';
17
17
  import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
18
- import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
18
+ import { AGENT_DELEGATION_TOKEN_ENV, CODEX_SESSION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
19
19
  import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
20
- import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, hasPowerShellDelegationAssignment, normalizeEvolcoreCommandArgv, parseCodexToolCommand, parseCodexCarrierCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
20
+ import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, hasPowerShellCredentialAssignment, normalizeEvolcoreCommandArgv, parseCodexToolCommand, parseCodexCarrierCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
21
21
  import { compareVersions } from '../utils/npm-ops.js';
22
22
  import { agentSessionsDir, resolvePaths, resolveRoot } from '../paths.js';
23
- import { ensureProcessManagedTempDir, getManagedTaskTempDir } from '../cli/task-context.js';
23
+ import { ensureSessionRuntimeDir, getManagedTaskTempDir } from '../cli/task-context.js';
24
24
  import { buildSessionTurnList } from '../core/session/session-turns.js';
25
25
  import { CodexSessionFileAdapter } from '../core/session/adapters/codex-session-file-adapter.js';
26
26
  import { execFileSync } from 'child_process';
@@ -38,6 +38,7 @@ const MIME_EXT = {
38
38
  'image/webp': '.webp',
39
39
  };
40
40
  const APPROVAL_LOG_SUMMARY_MAX_LENGTH = 256;
41
+ const CODEX_INTERRUPT_TIMEOUT_MS = 5_000;
41
42
  function toAgentErrorType(message) {
42
43
  switch (classifyError(new Error(message))) {
43
44
  case ErrorType.CONTEXT_TOO_LONG: return 'context_too_long';
@@ -108,60 +109,6 @@ function isCodexReasoningNotification(method) {
108
109
  || method === 'item/reasoning/summaryPartAdded';
109
110
  }
110
111
  const CODEX_THREAD_HANDOFF_TIMEOUT_MS = 2_000;
111
- const managedTempDirsForExitCleanup = new Map();
112
- let managedTempExitCleanupRegistered = false;
113
- function isTrustedManagedTempDirectory(directory, parent) {
114
- try {
115
- const parentPath = path.resolve(parent);
116
- const directoryPath = path.resolve(directory);
117
- // The session directory must be a direct child of the private parent.
118
- // This lexical check is required before canonicalization so a replaced
119
- // parent symlink cannot turn an old session path into another tree.
120
- if (path.dirname(directoryPath) !== parentPath)
121
- return false;
122
- const trustedDirectory = (candidate) => {
123
- const stat = fs.lstatSync(candidate);
124
- if (!stat.isDirectory() || stat.isSymbolicLink())
125
- return false;
126
- if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid())
127
- return false;
128
- return process.platform === 'win32' || (stat.mode & 0o077) === 0;
129
- };
130
- if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
131
- return false;
132
- const canonicalParent = resolveProtectedCandidate(parentPath);
133
- const canonicalDirectory = resolveProtectedCandidate(directoryPath);
134
- // Keep the canonical and lexical paths identical after the lstat checks;
135
- // this rejects symlink replacement in either node or an existing ancestor.
136
- return canonicalParent === parentPath
137
- && canonicalDirectory === directoryPath
138
- && canonicalDirectory !== canonicalParent
139
- && path.dirname(canonicalDirectory) === canonicalParent
140
- && isSameOrDescendant(canonicalDirectory, canonicalParent);
141
- }
142
- catch {
143
- return false;
144
- }
145
- }
146
- function registerManagedTempDirForExitCleanup(directory, parent) {
147
- managedTempDirsForExitCleanup.set(directory, parent);
148
- if (managedTempExitCleanupRegistered)
149
- return;
150
- managedTempExitCleanupRegistered = true;
151
- process.once('exit', () => {
152
- for (const [candidatePath, parentPath] of managedTempDirsForExitCleanup) {
153
- try {
154
- if (isTrustedManagedTempDirectory(candidatePath, parentPath)) {
155
- fs.rmSync(path.resolve(candidatePath), { recursive: true, force: true });
156
- }
157
- }
158
- catch {
159
- // Process exit cleanup is best effort; session cleanup logs failures
160
- // while the runner is still active.
161
- }
162
- }
163
- });
164
- }
165
112
  function stableSecurityValue(value) {
166
113
  if (value === undefined)
167
114
  return 'undefined';
@@ -234,7 +181,6 @@ function outputTextForManagedHook(value) {
234
181
  }
235
182
  return String(value);
236
183
  }
237
- const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
238
184
  const CODEX_NO_ROLLOUT_RE = /no rollout found for thread id/i;
239
185
  const CODEX_ACTIVE_WRITER_RE = /already has an active writer/i;
240
186
  function isCodexNoRolloutError(error) {
@@ -361,7 +307,6 @@ export async function getCodexAppServerAvailabilityAsync() {
361
307
  : `Codex CLI 探测失败:${error instanceof Error ? error.message : String(error)}` };
362
308
  }
363
309
  }
364
- export const CODEX_SESSION_TOKEN_ENV = 'EVOLCORE_CODEX_SESSION_TOKEN';
365
310
  export class CodexRunner {
366
311
  name = 'codex';
367
312
  // turn/start persists a new user turn before runQuery resolves. App-server
@@ -380,14 +325,12 @@ export class CodexRunner {
380
325
  threadProjectPaths = new Map();
381
326
  /** Child Codex threads inherit the EvolCore session that started them. */
382
327
  childThreadSessions = new Map();
383
- sessionTempDirs = new Map();
384
- managedTempNonce = randomBytes(16).toString('hex');
385
328
  threadExternalToolFingerprints = new Map();
386
329
  threadExternalToolBoundaries = new Map();
387
330
  trackedApprovalItems = new Map();
388
331
  pendingToolDenials = new Map();
389
- threadDelegationCarriers = new Map();
390
332
  pendingInterrupts = new Map();
333
+ unconfirmedInterrupts = new Map();
391
334
  steeringTails = new Map();
392
335
  threadOperationLocks = new Map();
393
336
  activeOperationReleases = new Map();
@@ -410,6 +353,9 @@ export class CodexRunner {
410
353
  codexSessionFileAdapter = new CodexSessionFileAdapter();
411
354
  /** threadId → managed-hook credential, issued by this runner's agent only. */
412
355
  threadCredentials = new Map();
356
+ /** @deprecated Compatibility alias for direct runner integrations. */
357
+ threadDelegationCarriers = this.threadCredentials;
358
+ asyncQuestionItems = new Set();
413
359
  constructor(config, callbacks, runtime) {
414
360
  // Compatibility for legacy direct callers that still pass the removed
415
361
  // internal fields in a synthetic config. AgentLoader uses runtime binding.
@@ -426,6 +372,7 @@ export class CodexRunner {
426
372
  configuredHeaders: this.resolvedConfig.headers,
427
373
  });
428
374
  this.capabilities = {
375
+ taskRecovery: true,
429
376
  clear: false,
430
377
  compact: true,
431
378
  fork: true,
@@ -642,16 +589,13 @@ export class CodexRunner {
642
589
  }
643
590
  this.threadSubscriptions.set(threadId, { client: targetClient, state: 'subscribed' });
644
591
  }
645
- createDelegationCarrier() {
646
- return randomBytes(32).toString('base64url');
647
- }
648
- approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix', expectedDelegationToken, executable) {
592
+ approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix', expectedCredentialToken, executable) {
649
593
  const parseOptions = {
650
594
  allowManagedTmpDir: true,
651
595
  dialect,
652
596
  verifyShellExecutable: process.platform === 'win32',
653
597
  ...(managedTempDir ? { managedTempDir } : {}),
654
- ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
598
+ ...(expectedCredentialToken !== undefined ? { expectedCredentialToken } : {}),
655
599
  };
656
600
  const classification = classifyEvolcoreShellCommand(command, parseOptions);
657
601
  if (classification.kind === 'bounded-output')
@@ -678,10 +622,10 @@ export class CodexRunner {
678
622
  }
679
623
  return {
680
624
  argv: normalizeEvolcoreCommandArgv(parsed.argv),
681
- ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
625
+ ...(parsed.credentialToken ? { credentialToken: parsed.credentialToken } : {}),
682
626
  };
683
627
  }
684
- resolveCanonicalEcApproval(toolInput, managedTempDir, expectedDelegationToken) {
628
+ resolveCanonicalEcApproval(toolInput, managedTempDir, expectedCredentialToken) {
685
629
  const explicitArgv = Array.isArray(toolInput.commandArgv)
686
630
  && toolInput.commandArgv.every(value => typeof value === 'string')
687
631
  ? toolInput.commandArgv
@@ -702,12 +646,12 @@ export class CodexRunner {
702
646
  ? resolveCodexShellCarrierArgv(explicitArgv)
703
647
  : undefined;
704
648
  if (explicitCarrier !== undefined) {
705
- return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect, expectedDelegationToken, explicitCarrier.executable);
649
+ return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect, expectedCredentialToken, explicitCarrier.executable);
706
650
  }
707
651
  if (hasCodexCmdCarrierEcIntent(toolInput)) {
708
652
  const parsed = parseCodexToolCommand(toolInput, {
709
653
  verifyShellExecutable: process.platform === 'win32',
710
- ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
654
+ ...(expectedCredentialToken !== undefined ? { expectedCredentialToken } : {}),
711
655
  });
712
656
  if (!parsed.ok)
713
657
  return { parseFailure: parsed };
@@ -719,7 +663,7 @@ export class CodexRunner {
719
663
  } }
720
664
  : {
721
665
  argv: normalizeEvolcoreCommandArgv(parsed.argv),
722
- ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
666
+ ...(parsed.credentialToken ? { credentialToken: parsed.credentialToken } : {}),
723
667
  };
724
668
  }
725
669
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
@@ -739,7 +683,7 @@ export class CodexRunner {
739
683
  allowManagedTmpDir: true,
740
684
  verifyShellExecutable: process.platform === 'win32',
741
685
  ...(managedTempDir ? { managedTempDir } : {}),
742
- ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
686
+ ...(expectedCredentialToken !== undefined ? { expectedCredentialToken } : {}),
743
687
  });
744
688
  if (canonicalCommand?.[0] === 'ec') {
745
689
  return containsLiteralManagedTmpDirOutsideSendContent(canonicalCommand)
@@ -774,7 +718,7 @@ export class CodexRunner {
774
718
  return true;
775
719
  const shellCarrier = resolveCodexShellCarrierArgv(rawArgv);
776
720
  if (shellCarrier?.dialect === 'powershell'
777
- && hasPowerShellDelegationAssignment(shellCarrier.command))
721
+ && hasPowerShellCredentialAssignment(shellCarrier.command))
778
722
  return true;
779
723
  return hasCodexCmdCarrierEcIntent(toolInput)
780
724
  || shellCarrier !== undefined
@@ -787,20 +731,20 @@ export class CodexRunner {
787
731
  }
788
732
  armApprovedDelegationCommand(sessionId, argv) {
789
733
  const threadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
790
- const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
734
+ const sessionToken = threadId ? this.ensureThreadCredentialLoaded(threadId) : undefined;
791
735
  const commandHash = hashDelegatedCommandArgv(argv);
792
736
  const arm = this.permissionContexts.get(sessionId)?.armApprovedDelegationCommand;
793
- if (!carrierToken || !commandHash || !arm)
737
+ if (!sessionToken || !commandHash || !arm)
794
738
  return false;
795
739
  try {
796
- return arm(carrierToken, commandHash);
740
+ return arm(sessionToken, commandHash);
797
741
  }
798
742
  catch (error) {
799
743
  logger.warn(`[CodexRunner] delegation arm callback failed: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
800
744
  return false;
801
745
  }
802
746
  }
803
- delegationCarrierForSession(sessionId, threadId) {
747
+ sessionTokenForSession(sessionId, threadId) {
804
748
  const candidates = [
805
749
  threadId,
806
750
  this.activeSessions.get(sessionId),
@@ -809,45 +753,13 @@ export class CodexRunner {
809
753
  for (const candidate of candidates) {
810
754
  if (!candidate)
811
755
  continue;
812
- const carrier = this.threadDelegationCarriers.get(candidate);
813
- if (carrier)
814
- return carrier;
756
+ const token = this.ensureThreadCredentialLoaded(candidate);
757
+ if (token)
758
+ return token;
815
759
  }
816
760
  return undefined;
817
761
  }
818
- async readThreadDelegationCarrier(appServer, threadId) {
819
- try {
820
- const result = await appServer.threadShellCommand(threadId, `node -e "process.stdout.write(process.env.${AGENT_DELEGATION_TOKEN_ENV}||String())"`);
821
- const carrier = result.output.trim();
822
- if (result.exitCode !== undefined && result.exitCode !== 0)
823
- return undefined;
824
- return /^[A-Za-z0-9_-]{32,128}$/.test(carrier) ? carrier : undefined;
825
- }
826
- catch (error) {
827
- logger.warn(`[CodexRunner] failed to recover delegation carrier for loaded thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
828
- return undefined;
829
- }
830
- }
831
- async loadPersistedDelegationCarrier(sessionManager, sessionId, threadId) {
832
- if (typeof sessionManager?.getSessionById !== 'function')
833
- return undefined;
834
- try {
835
- const session = await sessionManager.getSessionById(sessionId);
836
- const persisted = session?.metadata?.[CODEX_DELEGATION_CARRIER_METADATA_KEY];
837
- if (!persisted || typeof persisted !== 'object' || Array.isArray(persisted))
838
- return undefined;
839
- if (persisted.threadId !== threadId || typeof persisted.carrier !== 'string')
840
- return undefined;
841
- return /^[A-Za-z0-9_-]{32,128}$/.test(persisted.carrier)
842
- ? persisted.carrier
843
- : undefined;
844
- }
845
- catch (error) {
846
- logger.debug(`[CodexRunner] failed to load persisted delegation carrier: session=${sessionId} error=${error}`);
847
- return undefined;
848
- }
849
- }
850
- async commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn) {
762
+ async commitProvisionalThread(sessionId, threadId, turn) {
851
763
  if (this.provisionalSessions.get(sessionId) !== threadId)
852
764
  return false;
853
765
  const bindingResult = this.onSessionIdUpdate
@@ -871,10 +783,6 @@ export class CodexRunner {
871
783
  return false;
872
784
  this.provisionalSessions.delete(sessionId);
873
785
  this.activeSessions.set(sessionId, threadId);
874
- if (carrierToPublish) {
875
- this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
876
- await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
877
- }
878
786
  this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
879
787
  return true;
880
788
  }
@@ -907,9 +815,9 @@ export class CodexRunner {
907
815
  return;
908
816
  this.provisionalSessions.delete(sessionId);
909
817
  this.threadProjectPaths.delete(threadId);
910
- this.threadDelegationCarriers.delete(threadId);
911
818
  this.unbindExternalToolBoundary(threadId);
912
819
  this.clearTrackedApprovalItemsForThread(threadId);
820
+ this.dropThreadCredential(threadId);
913
821
  // A stale provisional start can finish after a replacement run has
914
822
  // already activated another parent thread for this session. Do not tear
915
823
  // down the replacement run's child mappings in that race.
@@ -919,31 +827,6 @@ export class CodexRunner {
919
827
  }
920
828
  this.pruneThreadOperationLocks();
921
829
  }
922
- async persistDelegationCarrier(sessionManager, sessionId, threadId, carrier) {
923
- if (typeof sessionManager?.getSessionById !== 'function')
924
- return;
925
- try {
926
- const session = await sessionManager.getSessionById(sessionId);
927
- if (!session)
928
- return;
929
- const current = session.metadata?.[CODEX_DELEGATION_CARRIER_METADATA_KEY];
930
- if (current?.threadId === threadId && current?.carrier === carrier)
931
- return;
932
- if (typeof sessionManager.patchSessionMetadata === 'function') {
933
- await sessionManager.patchSessionMetadata(sessionId, {
934
- [CODEX_DELEGATION_CARRIER_METADATA_KEY]: { threadId, carrier },
935
- });
936
- }
937
- else if (typeof sessionManager.updateSession === 'function') {
938
- await sessionManager.updateSession(sessionId, {
939
- metadata: { [CODEX_DELEGATION_CARRIER_METADATA_KEY]: { threadId, carrier } },
940
- });
941
- }
942
- }
943
- catch (error) {
944
- logger.warn(`[CodexRunner] failed to persist delegation carrier: session=${sessionId} thread=${threadId} error=${error}`);
945
- }
946
- }
947
830
  resetAppServerClient() {
948
831
  const clients = [this.appServerClient, this.fullAccessAppServerClient]
949
832
  .filter((client) => !!client);
@@ -952,8 +835,8 @@ export class CodexRunner {
952
835
  this.threadSubscriptions.clear();
953
836
  this.threadExternalToolFingerprints.clear();
954
837
  this.threadExternalToolBoundaries.clear();
955
- for (const childThreadId of this.childThreadSessions.keys())
956
- this.threadProjectPaths.delete(childThreadId);
838
+ for (const childThreadId of [...this.childThreadSessions.keys()])
839
+ this.unregisterChildThread(childThreadId);
957
840
  // Child turn ids belong to the discarded app-server connection. Keeping
958
841
  // them would make a later session interrupt attempt target stale threads
959
842
  // through the replacement client.
@@ -961,7 +844,6 @@ export class CodexRunner {
961
844
  this.childThreadSessions.clear();
962
845
  this.trackedApprovalItems.clear();
963
846
  this.pendingToolDenials.clear();
964
- this.threadDelegationCarriers.clear();
965
847
  for (const client of clients) {
966
848
  client.close().catch(error => {
967
849
  logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
@@ -1308,47 +1190,53 @@ export class CodexRunner {
1308
1190
  }
1309
1191
  buildPermissionProfileConfig(sessionId, mode, projectPath, bootstrapAgentMdPath, personalMdPath) {
1310
1192
  const profileName = this.permissionProfileName(sessionId);
1311
- const managedTempDir = this.ensureSessionTempDir(sessionId);
1193
+ const managedTempDir = ensureSessionRuntimeDir(sessionId);
1312
1194
  const network = {
1313
1195
  enabled: true,
1314
1196
  unix_sockets: { [resolvePaths().instanceSocket]: 'allow' },
1315
1197
  };
1198
+ const filesystem = {
1199
+ ...buildCodexProtectedFilesystemRules(resolveRoot(), {
1200
+ denyLClassRead: mode === 'readonly',
1201
+ // The app-server itself is launched behind EvolCore's Linux
1202
+ // H-class guard, which already mounts every L-class directory
1203
+ // read-only. Avoid Codex's per-file glob expansion inside that
1204
+ // nested mount namespace; it can fail while creating a missing
1205
+ // target such as triggers/history.jsonl.
1206
+ expandLClassReadDeny: process.platform !== 'linux',
1207
+ }),
1208
+ ...this.resolveGitMetadataWriteRules(projectPath, mode),
1209
+ ...(bootstrapAgentMdPath ? {
1210
+ [path.resolve(bootstrapAgentMdPath)]: mode === 'readonly' ? 'read' : 'write',
1211
+ } : {}),
1212
+ ...(personalMdPath ? {
1213
+ [path.resolve(personalMdPath)]: mode === 'readonly' ? 'read' : 'write',
1214
+ } : {}),
1215
+ ...(mode !== 'readonly' && managedTempDir ? {
1216
+ [managedTempDir]: 'write',
1217
+ [managedTempDir + '/**']: 'write',
1218
+ [path.join(managedTempDir, 'locks')]: 'write',
1219
+ [path.join(managedTempDir, 'locks') + '/**']: 'write',
1220
+ } : {}),
1221
+ };
1222
+ // Codex resolves this complete profile before serializing the Windows
1223
+ // helper payload. Check after all runner-owned additions, not merely the
1224
+ // protected-path base projection, so Git and managed-temp rules cannot
1225
+ // push an otherwise valid profile back over CreateProcess' limit.
1226
+ assertWindowsCodexFilesystemRuleBudget(filesystem);
1316
1227
  return {
1317
1228
  default_permissions: profileName,
1318
1229
  permissions: {
1319
1230
  [profileName]: {
1320
1231
  extends: mode === 'readonly' ? ':read-only' : ':workspace',
1321
- filesystem: {
1322
- ...buildCodexProtectedFilesystemRules(resolveRoot(), {
1323
- denyLClassRead: mode === 'readonly',
1324
- // The app-server itself is launched behind EvolCore's Linux
1325
- // H-class guard, which already mounts every L-class directory
1326
- // read-only. Avoid Codex's per-file glob expansion inside that
1327
- // nested mount namespace; it can fail while creating a missing
1328
- // target such as triggers/history.jsonl.
1329
- expandLClassReadDeny: process.platform !== 'linux',
1330
- }),
1331
- ...this.resolveGitMetadataWriteRules(projectPath, mode),
1332
- ...(bootstrapAgentMdPath ? {
1333
- [path.resolve(bootstrapAgentMdPath)]: mode === 'readonly' ? 'read' : 'write',
1334
- } : {}),
1335
- ...(personalMdPath ? {
1336
- [path.resolve(personalMdPath)]: mode === 'readonly' ? 'read' : 'write',
1337
- } : {}),
1338
- ...(mode !== 'readonly' && managedTempDir ? {
1339
- [managedTempDir]: 'write',
1340
- [managedTempDir + '/**']: 'write',
1341
- [path.join(path.dirname(managedTempDir), 'evolcore-locks')]: 'write',
1342
- [path.join(path.dirname(managedTempDir), 'evolcore-locks') + '/**']: 'write',
1343
- } : {}),
1344
- },
1232
+ filesystem,
1345
1233
  network,
1346
1234
  },
1347
1235
  },
1348
1236
  };
1349
1237
  }
1350
- buildExecutionSandboxOptions(sessionId, mode, projectPath, bootstrapAgentMdPath, personalMdPath) {
1351
- const decision = resolvePhaseOneExecutionSandbox(mode, this.permissionContexts.get(sessionId)?.role);
1238
+ buildExecutionSandboxOptions(sessionId, mode, projectPath, bootstrapAgentMdPath, personalMdPath, permissionContext = this.permissionContexts.get(sessionId) ?? {}) {
1239
+ const decision = resolvePhaseOneExecutionSandbox(mode, permissionContext.role);
1352
1240
  return {
1353
1241
  decision,
1354
1242
  permissionProfileConfig: decision.state === 'active'
@@ -1386,6 +1274,26 @@ export class CodexRunner {
1386
1274
  }
1387
1275
  setSendPrompt(fn) { this.sendPromptFn = fn; }
1388
1276
  setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
1277
+ async steerAsyncQuestionAnswer(sessionId, answer) {
1278
+ const active = this.activeTurns.get(sessionId);
1279
+ if (!active)
1280
+ return 'inactive';
1281
+ if (active.threadId !== answer.providerThreadId)
1282
+ throw new Error('Async question backend has changed');
1283
+ const client = this.getAppServerClientForThread(active.threadId);
1284
+ try {
1285
+ await client.turnSteer(active.threadId, active.turnId, [{
1286
+ type: 'text', text: formatAsyncQuestionAnswer(answer), text_elements: [],
1287
+ }]);
1288
+ }
1289
+ catch (error) {
1290
+ if (error.code === 'CODEX_NO_ACTIVE_TURN')
1291
+ return 'inactive';
1292
+ // Transport failures are ambiguous: never risk submitting the same answer twice.
1293
+ throw error;
1294
+ }
1295
+ return 'accepted';
1296
+ }
1389
1297
  injectUserMessage(sessionId, text) {
1390
1298
  // Bind queued reminders to the turn that generated them. A completed
1391
1299
  // turn may be replaced before the serialized steer reaches app-server;
@@ -1722,9 +1630,11 @@ export class CodexRunner {
1722
1630
  const knownThreadId = topicBinding
1723
1631
  ? this.cachedThreadForTopic(sessionId, topicAgentSessionId)
1724
1632
  : initialAgentSessionId || this.activeSessions.get(sessionId);
1725
- const operationKey = this.threadOperationKey(knownThreadId, sessionId);
1633
+ const operationKey = this.threadOperationKey(modelOverride?.recovery?.agentSessionId || knownThreadId, sessionId);
1634
+ await this.assertInterruptSettled(sessionId, modelOverride?.recovery?.agentSessionId || knownThreadId);
1726
1635
  const release = await this.acquireThreadOperation(operationKey, 'thread/resume+turn/start');
1727
1636
  try {
1637
+ await this.assertInterruptSettled(sessionId, modelOverride?.recovery?.agentSessionId || knownThreadId);
1728
1638
  const stream = await this.runQueryLocked(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv);
1729
1639
  this.activeOperationReleases.set(sessionId, release);
1730
1640
  return this.holdThreadOperation(stream, sessionId, release);
@@ -1754,6 +1664,14 @@ export class CodexRunner {
1754
1664
  logger.debug(`[CodexRunner] persisted main backend lookup failed; using local cache: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
1755
1665
  }
1756
1666
  }
1667
+ const recoverySessionId = modelOverride?.recovery?.agentSessionId;
1668
+ if (modelOverride?.recovery && (!recoverySessionId || agentSessionId !== recoverySessionId)) {
1669
+ throw new Error('Task recovery requires the original Codex thread binding');
1670
+ }
1671
+ if (modelOverride?.asyncQuestionAnswer && agentSessionId !== modelOverride.asyncQuestionAnswer.providerThreadId) {
1672
+ throw new Error('Async question reply requires its original Codex backend');
1673
+ }
1674
+ await this.assertInterruptSettled(sessionId, agentSessionId);
1757
1675
  let resumingThread = !!agentSessionId;
1758
1676
  const callModel = stripContextWindowSuffix(modelOverride?.model || this.model);
1759
1677
  const callEffort = modelOverride?.effortMode === 'model_default'
@@ -1774,6 +1692,10 @@ export class CodexRunner {
1774
1692
  this.dropExpiredPendingInterrupt(sessionId);
1775
1693
  const effectiveApprovalPolicy = callApprovalPolicy;
1776
1694
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
1695
+ const userInputConfig = { features: {
1696
+ default_mode_request_user_input: modelOverride?.enableRequestUserInput
1697
+ ?? this.resolvedConfig.enableRequestUserInput ?? true,
1698
+ } };
1777
1699
  const externalToolConfig = callMode === 'fullaccess'
1778
1700
  ? {}
1779
1701
  : await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, callMode);
@@ -1781,25 +1703,21 @@ export class CodexRunner {
1781
1703
  await this.assertProactiveHookAvailability(sessionId, appServer);
1782
1704
  const managedTempInstruction = this.managedTempInstruction(sessionId);
1783
1705
  const developerInstructions = [systemPromptAppend, managedTempInstruction].filter(Boolean).join('\n\n') || undefined;
1784
- let knownDelegationCarrier = agentSessionId
1785
- ? this.threadDelegationCarriers.get(agentSessionId)
1706
+ let threadSessionToken = agentSessionId
1707
+ ? this.ensureThreadCredential(agentSessionId)
1708
+ : this.createThreadCredential();
1709
+ if (!threadSessionToken)
1710
+ throw new Error('Codex thread session credential is unavailable');
1711
+ const persistentRuntimeEnv = runtimeEnv
1712
+ ? Object.fromEntries(Object.entries(runtimeEnv).filter(([name]) => callMode === 'fullaccess' || name.toUpperCase() !== AGENT_DELEGATION_TOKEN_ENV))
1786
1713
  : undefined;
1787
- if (agentSessionId && !knownDelegationCarrier) {
1788
- knownDelegationCarrier = await this.loadPersistedDelegationCarrier(sessionManager, sessionId, agentSessionId);
1789
- }
1790
- let delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
1791
- // Keep newly discovered carrier state local until generation-aware
1792
- // activation accepts the backend. A stale run must not publish a carrier
1793
- // that a later generation could mistake for its own thread.
1794
- let carrierToPublish;
1795
- const hasTaskDelegation = typeof runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] === 'string';
1796
1714
  let threadOptions = {
1797
1715
  model: callModel,
1798
1716
  effort: callEffort,
1799
1717
  approvalPolicy: effectiveApprovalPolicy,
1800
1718
  approvalsReviewer: 'user',
1801
1719
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1802
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined, agentSessionId), this.buildLifecycleLockdownConfig(callMode)), callMode),
1720
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, persistentRuntimeEnv ? { shell_environment_policy: { set: persistentRuntimeEnv } } : undefined, capabilityConfig, userInputConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, threadSessionToken, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined), this.buildLifecycleLockdownConfig(callMode)), callMode, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined),
1803
1721
  ...(developerInstructions ? { developerInstructions } : {}),
1804
1722
  };
1805
1723
  logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
@@ -1825,12 +1743,12 @@ export class CodexRunner {
1825
1743
  // A legacy main session can retain a provider ID after its rollout was
1826
1744
  // removed externally. It is safe to recover before input submission;
1827
1745
  // topic bindings remain authoritative and fail closed.
1828
- if (topicBinding || !isCodexNoRolloutError(error))
1746
+ if (topicBinding || recoverySessionId || modelOverride?.asyncQuestionAnswer || !isCodexNoRolloutError(error))
1829
1747
  throw error;
1830
1748
  if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
1831
1749
  throw error;
1832
1750
  const missingThreadId = agentSessionId;
1833
- const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
1751
+ const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId);
1834
1752
  if (cleared !== 'cleared' && cleared !== 'already_clear') {
1835
1753
  const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
1836
1754
  recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
@@ -1839,17 +1757,15 @@ export class CodexRunner {
1839
1757
  if (this.activeSessions.get(sessionId) === missingThreadId)
1840
1758
  this.activeSessions.delete(sessionId);
1841
1759
  this.threadProjectPaths.delete(missingThreadId);
1842
- this.threadDelegationCarriers.delete(missingThreadId);
1843
1760
  this.unbindExternalToolBoundary(missingThreadId);
1844
1761
  this.clearTrackedApprovalItemsForThread(missingThreadId);
1845
1762
  this.dropThreadCredential(missingThreadId);
1846
1763
  agentSessionId = undefined;
1847
1764
  resumingThread = false;
1848
- knownDelegationCarrier = undefined;
1849
- delegationCarrierCandidate = this.createDelegationCarrier();
1765
+ threadSessionToken = this.createThreadCredential();
1850
1766
  threadOptions = {
1851
1767
  ...threadOptions,
1852
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined), this.buildLifecycleLockdownConfig(callMode)), callMode),
1768
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, persistentRuntimeEnv ? { shell_environment_policy: { set: persistentRuntimeEnv } } : undefined, capabilityConfig, userInputConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, threadSessionToken, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined), this.buildLifecycleLockdownConfig(callMode)), callMode, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined),
1853
1769
  };
1854
1770
  logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
1855
1771
  threadResponse = await appServer.threadStart(projectPath, threadOptions);
@@ -1862,43 +1778,12 @@ export class CodexRunner {
1862
1778
  let threadId = threadResponse.thread?.id || agentSessionId;
1863
1779
  if (!threadId)
1864
1780
  throw new Error('Codex app-server did not return a thread id');
1865
- this.markThreadSubscribed(appServer, threadId);
1866
- if (!resumingThread) {
1867
- carrierToPublish = { threadId, carrier: delegationCarrierCandidate };
1868
- }
1869
- else if (hasTaskDelegation && !knownDelegationCarrier) {
1870
- // A BOUND topic cannot silently fork to migrate carrier metadata: its
1871
- // active-turn activation is allowed to confirm only the authoritative
1872
- // backend ID, so a fork would deterministically conflict. threadResume
1873
- // already received the candidate in this turn's environment config;
1874
- // publish it for the same thread after binding confirmation instead.
1875
- let recoveredCarrier = topicBinding
1876
- ? delegationCarrierCandidate
1877
- : await this.readThreadDelegationCarrier(appServer, threadId);
1878
- if (!recoveredCarrier) {
1879
- const staleThreadId = threadId;
1880
- logger.warn(`[CodexRunner] loaded thread has no recoverable delegation carrier; forking once to preserve history: thread=${staleThreadId}`);
1881
- const forked = await appServer.threadFork(staleThreadId, projectPath, undefined, {
1882
- model: callModel,
1883
- effort: callEffort,
1884
- approvalPolicy: effectiveApprovalPolicy,
1885
- approvalsReviewer: 'user',
1886
- ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1887
- config: threadOptions.config,
1888
- });
1889
- const forkedThreadId = forked.thread?.id;
1890
- if (!forkedThreadId)
1891
- throw new Error('Codex delegation-carrier recovery fork did not return a thread id');
1892
- this.markThreadSubscribed(appServer, forkedThreadId);
1893
- await this.releaseThreadSubscription(appServer, staleThreadId, 'carrier-recovery-fork-source');
1894
- await this.releaseThreadSubscription(appServer, forkedThreadId, 'carrier-recovery-fork');
1895
- threadId = forkedThreadId;
1896
- recoveredCarrier = delegationCarrierCandidate;
1897
- }
1898
- carrierToPublish = { threadId, carrier: recoveredCarrier };
1781
+ if (recoverySessionId && threadId !== recoverySessionId) {
1782
+ throw new Error('Task recovery cannot replace the original Codex thread');
1899
1783
  }
1900
- else if (knownDelegationCarrier) {
1901
- carrierToPublish = { threadId, carrier: knownDelegationCarrier };
1784
+ this.markThreadSubscribed(appServer, threadId);
1785
+ if (!resumingThread && this.ensureThreadCredential(threadId, threadSessionToken) !== threadSessionToken) {
1786
+ throw new Error('Codex thread session credential could not be bound');
1902
1787
  }
1903
1788
  const turn = modelOverride?.turn;
1904
1789
  if (!resumingThread) {
@@ -1906,9 +1791,6 @@ export class CodexRunner {
1906
1791
  // has been accepted. In particular, a pending interrupt must not leave
1907
1792
  // a durable ID for a thread that never produced a rollout.
1908
1793
  this.provisionalSessions.set(sessionId, threadId);
1909
- if (carrierToPublish) {
1910
- this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1911
- }
1912
1794
  }
1913
1795
  else {
1914
1796
  agentSessionId = threadId;
@@ -1928,10 +1810,6 @@ export class CodexRunner {
1928
1810
  throw error;
1929
1811
  }
1930
1812
  this.activeSessions.set(sessionId, threadId);
1931
- if (carrierToPublish) {
1932
- this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1933
- await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
1934
- }
1935
1813
  this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
1936
1814
  }
1937
1815
  this.threadProjectPaths.set(threadId, path.resolve(projectPath));
@@ -1939,7 +1817,11 @@ export class CodexRunner {
1939
1817
  const controller = new AbortController();
1940
1818
  this.activeAbortControllers.set(sessionId, controller);
1941
1819
  const tempFiles = [];
1942
- const input = this.buildAppServerInput(prompt, images, tempFiles, sessionId, runtimeEnv);
1820
+ const answer = modelOverride?.asyncQuestionAnswer;
1821
+ if (answer && answer.providerThreadId !== threadId) {
1822
+ throw new Error('Async question reply cannot resume a different Codex thread');
1823
+ }
1824
+ const input = this.buildAppServerInput(answer ? formatAsyncQuestionAnswer(answer) : prompt, images, tempFiles, sessionId, runtimeEnv);
1943
1825
  const queue = new AsyncEventQueue();
1944
1826
  controller.signal.addEventListener('abort', () => queue.end(), { once: true });
1945
1827
  const state = {
@@ -2053,7 +1935,7 @@ export class CodexRunner {
2053
1935
  if (!persisted)
2054
1936
  return false;
2055
1937
  try {
2056
- const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
1938
+ const committed = await this.commitProvisionalThread(sessionId, threadId, turn);
2057
1939
  if (!committed) {
2058
1940
  const staleError = new Error('Codex provisional thread was superseded before persistence confirmation');
2059
1941
  staleError.code = 'RUNNER_START_SUPERSEDED';
@@ -2164,16 +2046,65 @@ export class CodexRunner {
2164
2046
  }
2165
2047
  }
2166
2048
  async interruptAppServerTurn(threadId, turnId) {
2049
+ const sessionId = this.findSessionKeyByThread(threadId);
2050
+ const subscribed = this.threadSubscriptions.get(threadId);
2051
+ const client = subscribed
2052
+ ? subscribed.client
2053
+ : this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
2054
+ const key = JSON.stringify([threadId, turnId]);
2055
+ const previous = this.unconfirmedInterrupts.get(key);
2056
+ previous?.unsubscribe?.();
2057
+ const record = { sessionId, threadId, turnId, client };
2058
+ this.unconfirmedInterrupts.set(key, record);
2059
+ let confirm;
2060
+ const confirmed = new Promise(resolve => { confirm = resolve; });
2061
+ const clear = () => {
2062
+ if (this.unconfirmedInterrupts.get(key) === record)
2063
+ this.unconfirmedInterrupts.delete(key);
2064
+ record.unsubscribe?.();
2065
+ confirm();
2066
+ };
2067
+ record.unsubscribe = client.onNotification?.(notification => {
2068
+ const params = notification.params ?? {};
2069
+ if ((params.threadId ?? params.thread_id) !== threadId)
2070
+ return;
2071
+ if (notification.method === 'thread/closed'
2072
+ || (notification.method === 'turn/completed'
2073
+ && (params.turn?.id ?? params.turnId ?? params.turn_id) === turnId))
2074
+ clear();
2075
+ });
2167
2076
  try {
2168
- const sessionId = this.findSessionKeyByThread(threadId);
2169
- const subscribed = this.threadSubscriptions.get(threadId);
2170
- const client = subscribed
2171
- ? subscribed.client
2172
- : this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
2173
- await client.turnInterrupt(threadId, turnId);
2077
+ await withInterruptDeadline(Promise.race([
2078
+ client.turnInterrupt(threadId, turnId).then(accepted => {
2079
+ if (!accepted)
2080
+ throw new Error('Codex interrupt was not accepted');
2081
+ }),
2082
+ confirmed,
2083
+ ]), 'Codex turn/interrupt', CODEX_INTERRUPT_TIMEOUT_MS);
2174
2084
  }
2175
2085
  catch (error) {
2176
- logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
2086
+ logger.warn(`[CodexRunner] Unconfirmed interrupt: thread=${threadId} turn=${turnId} error=${String(error)}`);
2087
+ }
2088
+ }
2089
+ async assertInterruptSettled(sessionId, threadId) {
2090
+ const pending = [...this.unconfirmedInterrupts.entries()].filter(([, record]) => record.sessionId === sessionId || (threadId && record.threadId === threadId));
2091
+ await Promise.all(pending.map(async ([key, record]) => {
2092
+ try {
2093
+ // Local stream closure and unsubscribe do not prove the backend turn
2094
+ // stopped. Check its exact identity before allowing another writer.
2095
+ const response = await withInterruptDeadline(record.client.threadRead(record.threadId, true, 2_000), 'Codex interrupt verification', 2_000);
2096
+ const turn = response.thread?.turns?.find(turn => turn.id === record.turnId);
2097
+ if (turn && ['completed', 'failed', 'interrupted'].includes(String(turn.status))
2098
+ && !response.thread?.turns?.some(turn => turn.status === 'inProgress')
2099
+ && this.unconfirmedInterrupts.get(key) === record) {
2100
+ this.unconfirmedInterrupts.delete(key);
2101
+ record.unsubscribe?.();
2102
+ }
2103
+ }
2104
+ catch { /* Ambiguous execution remains fenced; report it below. */ }
2105
+ }));
2106
+ if ([...this.unconfirmedInterrupts.values()].some(record => record.sessionId === sessionId || (threadId && record.threadId === threadId))) {
2107
+ throw unsettledInterruptError('Codex');
2177
2108
  }
2178
2109
  }
2179
2110
  // ── Session commands ──
@@ -2185,7 +2116,6 @@ export class CodexRunner {
2185
2116
  return;
2186
2117
  if (previousThreadId && previousThreadId !== agentSessionId) {
2187
2118
  this.threadProjectPaths.delete(previousThreadId);
2188
- this.threadDelegationCarriers.delete(previousThreadId);
2189
2119
  this.unbindExternalToolBoundary(previousThreadId);
2190
2120
  this.clearTrackedApprovalItemsForThread(previousThreadId);
2191
2121
  }
@@ -2269,10 +2199,8 @@ export class CodexRunner {
2269
2199
  if (this.activeSessions.get(sessionId) === capturedThreadId)
2270
2200
  this.activeSessions.delete(sessionId);
2271
2201
  this.chatModes.delete(sessionId);
2272
- this.cleanupSessionTempDir(sessionId);
2273
2202
  if (capturedThreadId) {
2274
2203
  this.threadProjectPaths.delete(capturedThreadId);
2275
- this.threadDelegationCarriers.delete(capturedThreadId);
2276
2204
  this.unbindExternalToolBoundary(capturedThreadId);
2277
2205
  }
2278
2206
  this.clearTrackedApprovalItemsForThread(capturedThreadId);
@@ -2312,9 +2240,7 @@ export class CodexRunner {
2312
2240
  this.activeTurns.delete(sessionId);
2313
2241
  this.steeringTails.delete(sessionId);
2314
2242
  this.chatModes.delete(sessionId);
2315
- this.cleanupSessionTempDir(sessionId);
2316
2243
  this.threadProjectPaths.delete(threadId);
2317
- this.threadDelegationCarriers.delete(threadId);
2318
2244
  this.unbindExternalToolBoundary(threadId);
2319
2245
  this.clearTrackedApprovalItemsForThread(threadId);
2320
2246
  this.pruneThreadOperationLocks();
@@ -2355,19 +2281,19 @@ export class CodexRunner {
2355
2281
  : await this.resolveExternalToolApprovalConfig(appServer, _projectPath, capabilityConfig, compactMode);
2356
2282
  if (compactMode !== 'fullaccess')
2357
2283
  await this.assertProactiveHookAvailability(_sessionId, appServer);
2358
- const compactDelegationCarrier = this.threadDelegationCarriers.get(agentSessionId)
2359
- ?? this.createDelegationCarrier();
2284
+ const compactSessionToken = this.ensureThreadCredential(agentSessionId);
2285
+ if (!compactSessionToken)
2286
+ throw new Error('Codex thread session credential is unavailable');
2360
2287
  await appServer.threadResume(agentSessionId, _projectPath, {
2361
2288
  model: compactModel,
2362
2289
  effort: compactEffort,
2363
2290
  approvalPolicy: compactPolicy,
2364
2291
  approvalsReviewer: 'user',
2365
2292
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
2366
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier, undefined, agentSessionId), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
2293
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactSessionToken), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
2367
2294
  });
2368
2295
  this.markThreadSubscribed(appServer, agentSessionId);
2369
2296
  this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
2370
- this.threadDelegationCarriers.set(agentSessionId, compactDelegationCarrier);
2371
2297
  this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
2372
2298
  await this.startAndWaitForCompact(appServer, agentSessionId);
2373
2299
  return finish({ ok: true, durationMs: Date.now() - startedAt });
@@ -2528,23 +2454,32 @@ export class CodexRunner {
2528
2454
  const dir = this.threadCredentialsDir();
2529
2455
  return dir ? path.join(dir, `${encodeURIComponent(threadId)}.json`) : undefined;
2530
2456
  }
2457
+ createThreadCredential() {
2458
+ return randomBytes(32).toString('hex');
2459
+ }
2531
2460
  /** Issue or re-read this agent's credential for a thread. */
2532
- ensureThreadCredential(threadId) {
2461
+ ensureThreadCredential(threadId, candidate) {
2533
2462
  const cached = this.threadCredentials.get(threadId);
2534
2463
  if (cached)
2535
- return cached;
2464
+ return !candidate || cached === candidate ? cached : undefined;
2536
2465
  const file = this.threadCredentialPath(threadId);
2537
- if (!file)
2538
- return undefined;
2466
+ // Low-level runner callers may not have an agent binding (for example a
2467
+ // standalone integration test). Keep those calls functional in memory;
2468
+ // daemon-owned runners always have an AID and persist the credential.
2469
+ if (!file) {
2470
+ const token = candidate ?? this.createThreadCredential();
2471
+ this.threadCredentials.set(threadId, token);
2472
+ return token;
2473
+ }
2539
2474
  try {
2540
2475
  const record = JSON.parse(fs.readFileSync(file, 'utf8'));
2541
2476
  if (record && typeof record.token === 'string' && record.token && record.threadId === threadId) {
2542
2477
  this.threadCredentials.set(threadId, record.token);
2543
- return record.token;
2478
+ return !candidate || record.token === candidate ? record.token : undefined;
2544
2479
  }
2545
2480
  }
2546
2481
  catch { /* missing or unreadable: fall through to issuance */ }
2547
- const token = randomBytes(32).toString('hex');
2482
+ const token = candidate ?? this.createThreadCredential();
2548
2483
  const record = {
2549
2484
  token,
2550
2485
  threadId,
@@ -2606,49 +2541,55 @@ export class CodexRunner {
2606
2541
  }
2607
2542
  catch { /* best effort */ }
2608
2543
  }
2609
- async forkSession(agentSessionId, projectPath, title) {
2544
+ async forkSession(agentSessionId, projectPath, title, context) {
2610
2545
  const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId), 'fork');
2611
2546
  try {
2612
- return await this.forkSessionLocked(agentSessionId, projectPath, title);
2547
+ return await this.forkSessionLocked(agentSessionId, projectPath, title, undefined, context);
2613
2548
  }
2614
2549
  finally {
2615
2550
  release();
2616
2551
  }
2617
2552
  }
2618
- async forkSessionLocked(agentSessionId, projectPath, title, lastTurnId) {
2619
- const sessionKey = this.findSessionKeyByThread(agentSessionId);
2620
- const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
2621
- const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
2553
+ async forkSessionLocked(agentSessionId, projectPath, title, lastTurnId, context) {
2554
+ // Historical threads may have no live runner binding after idle cleanup or
2555
+ // restart. Cached turn permissions can also outlive a role change. Derive
2556
+ // this management operation solely from its current authenticated request;
2557
+ // callers without that context remain readonly, including old fullaccess
2558
+ // threads. Do not mutate shared turn/session permission maps while forking.
2559
+ const sessionKey = context?.sessionId ?? this.findSessionKeyByThread(agentSessionId);
2560
+ const mode = normalizePermissionMode(context?.permissionMode).mode;
2561
+ const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath, undefined, undefined, context ?? {});
2622
2562
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
2623
- const appServer = this.threadSubscriptions.get(agentSessionId)?.client
2624
- ?? this.getAppServerClient(mode === 'fullaccess');
2563
+ const appServer = this.getAppServerClient();
2625
2564
  const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
2626
2565
  if (sessionKey)
2627
2566
  await this.assertProactiveHookAvailability(sessionKey, appServer);
2628
- const delegationCarrier = this.createDelegationCarrier();
2567
+ const forkSessionToken = this.createThreadCredential();
2629
2568
  const response = await appServer.threadFork(agentSessionId, projectPath, title, {
2630
2569
  model: this.model,
2631
2570
  effort: this.effort,
2632
2571
  approvalPolicy: this.toApprovalPolicy(mode),
2633
2572
  approvalsReviewer: 'user',
2634
2573
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
2635
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionKey, delegationCarrier), this.buildLifecycleLockdownConfig(mode)), mode),
2574
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionKey, forkSessionToken), this.buildLifecycleLockdownConfig(mode)), mode),
2636
2575
  ...(lastTurnId ? { lastTurnId } : {}),
2637
2576
  });
2638
2577
  const forkedThreadId = response.thread?.id;
2639
2578
  if (!forkedThreadId)
2640
2579
  throw new Error('Codex fork did not return a thread id');
2580
+ if (this.ensureThreadCredential(forkedThreadId, forkSessionToken) !== forkSessionToken) {
2581
+ throw new Error('Codex fork session credential could not be bound');
2582
+ }
2641
2583
  this.markThreadSubscribed(appServer, forkedThreadId);
2642
- this.threadDelegationCarriers.set(forkedThreadId, delegationCarrier);
2643
2584
  this.threadProjectPaths.set(forkedThreadId, path.resolve(projectPath));
2644
2585
  this.bindExternalToolBoundary(forkedThreadId, externalToolConfig);
2645
2586
  await this.releaseThreadSubscription(appServer, forkedThreadId, 'fork-finished');
2646
2587
  return forkedThreadId;
2647
2588
  }
2648
- async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
2589
+ async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title, context) {
2649
2590
  const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId), 'forkAt');
2650
2591
  try {
2651
- const appServer = this.getAppServerClientForThread(agentSessionId);
2592
+ const appServer = this.getAppServerClient();
2652
2593
  const history = await this.readForkHistory(appServer, agentSessionId);
2653
2594
  const messages = this.mapThreadToSessionMessages(history, agentSessionId);
2654
2595
  const turns = buildSessionTurnList(messages);
@@ -2658,7 +2599,7 @@ export class CodexRunner {
2658
2599
  const targetTurnId = this.findTurnIdForAssistantMessage(history, assistantMessageId);
2659
2600
  if (!targetTurnId)
2660
2601
  throw new Error('fork point turn not found');
2661
- return await this.forkSessionLocked(agentSessionId, projectPath, title, targetTurnId);
2602
+ return await this.forkSessionLocked(agentSessionId, projectPath, title, targetTurnId, context);
2662
2603
  }
2663
2604
  finally {
2664
2605
  release();
@@ -2730,7 +2671,7 @@ export class CodexRunner {
2730
2671
  return this.getAppServerClientForThread(agentSessionId).threadMetadataUpdate(agentSessionId, gitInfo);
2731
2672
  }
2732
2673
  async getSessionMessages(agentSessionId, projectPath) {
2733
- const response = await this.getAppServerClientForThread(agentSessionId).threadRead(agentSessionId, true);
2674
+ const response = await this.readForkHistory(this.getAppServerClientForThread(agentSessionId), agentSessionId);
2734
2675
  return this.mapThreadToSessionMessages(response, agentSessionId);
2735
2676
  }
2736
2677
  approvalItemKey(threadId, turnId, itemId) {
@@ -3070,9 +3011,9 @@ export class CodexRunner {
3070
3011
  const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
3071
3012
  || request.method === 'execCommandApproval';
3072
3013
  const managedEcIntent = isCommandApproval && this.isManagedEvolcoreCommandIntent(toolInput);
3073
- const delegationCarrier = this.delegationCarrierForSession(sessionKey, typeof params.threadId === 'string' ? params.threadId : undefined);
3014
+ const sessionCredential = this.sessionTokenForSession(sessionKey, typeof params.threadId === 'string' ? params.threadId : undefined);
3074
3015
  const canonicalEcApproval = isCommandApproval
3075
- ? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey), delegationCarrier)
3016
+ ? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey), sessionCredential)
3076
3017
  : undefined;
3077
3018
  const approvedCommand = canonicalEcApproval?.argv;
3078
3019
  const denyInfrastructure = async (policyCode, message) => {
@@ -3122,23 +3063,23 @@ export class CodexRunner {
3122
3063
  const decision = typeof resolution === 'string' ? resolution : resolution.decision;
3123
3064
  const resolutionMeta = typeof resolution === 'string' ? undefined : resolution;
3124
3065
  if (decision !== 'deny' && managedEcIntent && approvedCommand) {
3125
- const carrierThreadIds = [
3066
+ const credentialThreadIds = [
3126
3067
  this.activeSessions.get(sessionKey),
3127
3068
  this.provisionalSessions.get(sessionKey),
3128
3069
  typeof params.threadId === 'string' ? params.threadId : undefined,
3129
3070
  typeof params.conversationId === 'string' ? params.conversationId : undefined,
3130
3071
  ];
3131
- const hasDelegationCarrier = carrierThreadIds.some(threadId => !!threadId && this.threadDelegationCarriers.has(threadId));
3072
+ const hasSessionCredential = credentialThreadIds.some(threadId => !!threadId && !!this.ensureThreadCredentialLoaded(threadId));
3132
3073
  const canArmDelegation = typeof permissionContext?.armApprovedDelegationCommand === 'function';
3133
- // A carrier-bearing thread is managed even if its permission context
3074
+ // A credentialed thread is managed even if its permission context
3134
3075
  // was not wired with an arm callback; it must fail closed here rather
3135
- // than reaching daemon IPC with an unarmed carrier.
3136
- if ((hasDelegationCarrier || canArmDelegation)
3137
- && canonicalEcApproval?.delegationToken
3138
- && canonicalEcApproval.delegationToken !== delegationCarrier) {
3139
- return await denyInfrastructure('delegation_token_mismatch', 'EC command approval contains a delegation token different from the active thread carrier');
3076
+ // than reaching daemon IPC with an unarmed session credential.
3077
+ if ((hasSessionCredential || canArmDelegation)
3078
+ && canonicalEcApproval?.credentialToken
3079
+ && canonicalEcApproval.credentialToken !== sessionCredential) {
3080
+ return await denyInfrastructure('delegation_token_mismatch', 'EC command approval contains a delegation token different from the active thread credential');
3140
3081
  }
3141
- if ((hasDelegationCarrier || canArmDelegation)
3082
+ if ((hasSessionCredential || canArmDelegation)
3142
3083
  && !this.armApprovedDelegationCommand(sessionKey, approvedCommand)) {
3143
3084
  return await denyInfrastructure('delegation_not_armed', 'EC command approval failed to arm delegation');
3144
3085
  }
@@ -3793,14 +3734,7 @@ export class CodexRunner {
3793
3734
  if (paths.length === 0)
3794
3735
  return true;
3795
3736
  const workspace = resolveProtectedCandidate(projectPath);
3796
- const managedTempDir = this.sessionTempDirs.get(sessionKey);
3797
- const trustedTempDir = managedTempDir ? this.isTrustedSessionTempDir(managedTempDir) : false;
3798
- if (managedTempDir && !trustedTempDir) {
3799
- logger.warn(`[CodexRunner] managed TMPDIR lost its trusted ownership or mode: session=${sessionKey} path=${managedTempDir}`);
3800
- }
3801
- const canonicalTempDir = managedTempDir && trustedTempDir
3802
- ? resolveProtectedCandidate(managedTempDir)
3803
- : undefined;
3737
+ const canonicalTempDir = this.getManagedTempDir(sessionKey);
3804
3738
  return paths.some(candidate => {
3805
3739
  const absolute = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(workspace, candidate);
3806
3740
  const canonical = resolveProtectedCandidate(absolute);
@@ -3895,7 +3829,7 @@ export class CodexRunner {
3895
3829
  // distinguishes this explicit empty value from runners without a
3896
3830
  // session-temp capability.
3897
3831
  managedTempDir: permissionContext?.managedTempDir ?? this.getManagedTempDir(sessionKey) ?? '',
3898
- delegationCarrier: this.delegationCarrierForSession(sessionKey),
3832
+ sessionCredential: this.sessionTokenForSession(sessionKey),
3899
3833
  allowProtectedMetadata: false,
3900
3834
  selfAid: permissionContext?.selfAid,
3901
3835
  channel: permissionContext?.channel,
@@ -4252,17 +4186,17 @@ export class CodexRunner {
4252
4186
  return;
4253
4187
  this.childThreadSessions.set(childThreadId, sessionId);
4254
4188
  this.threadProjectPaths.set(childThreadId, path.resolve(projectPath));
4255
- // Child approvals use the parent's capability boundary and delegation
4256
- // carrier. This is inheritance, not a second permission decision.
4189
+ // Child approvals use the parent's capability boundary and session
4190
+ // credential. This is inheritance, not a second permission decision.
4257
4191
  const parentBoundary = parentThreadId ? this.threadExternalToolBoundaries.get(parentThreadId) : undefined;
4258
4192
  const parentFingerprint = parentThreadId ? this.threadExternalToolFingerprints.get(parentThreadId) : undefined;
4259
4193
  if (parentBoundary)
4260
4194
  this.threadExternalToolBoundaries.set(childThreadId, parentBoundary);
4261
4195
  if (parentFingerprint)
4262
4196
  this.threadExternalToolFingerprints.set(childThreadId, parentFingerprint);
4263
- const parentCarrier = parentThreadId ? this.threadDelegationCarriers.get(parentThreadId) : undefined;
4264
- if (parentCarrier)
4265
- this.threadDelegationCarriers.set(childThreadId, parentCarrier);
4197
+ const parentToken = parentThreadId ? this.ensureThreadCredentialLoaded(parentThreadId) : undefined;
4198
+ if (parentToken)
4199
+ this.ensureThreadCredential(childThreadId, parentToken);
4266
4200
  }
4267
4201
  unregisterChildThread(childThreadId) {
4268
4202
  this.childThreadSessions.delete(childThreadId);
@@ -4270,8 +4204,8 @@ export class CodexRunner {
4270
4204
  this.threadProjectPaths.delete(childThreadId);
4271
4205
  this.threadExternalToolBoundaries.delete(childThreadId);
4272
4206
  this.threadExternalToolFingerprints.delete(childThreadId);
4273
- this.threadDelegationCarriers.delete(childThreadId);
4274
4207
  this.clearTrackedApprovalItemsForThread(childThreadId);
4208
+ this.dropThreadCredential(childThreadId);
4275
4209
  }
4276
4210
  cleanupChildThreadsForSession(sessionId) {
4277
4211
  for (const [childThreadId, ownerSessionId] of this.childThreadSessions) {
@@ -4411,10 +4345,14 @@ export class CodexRunner {
4411
4345
  }
4412
4346
  case 'item/agentMessage/delta': {
4413
4347
  const itemId = typeof params.itemId === 'string' ? params.itemId : undefined;
4414
- if (itemId)
4415
- state.streamedAgentMessageIds.add(itemId);
4416
- if (itemId && typeof params.delta === 'string' && params.delta) {
4417
- state.agentMessageDeltaText.set(itemId, (state.agentMessageDeltaText.get(itemId) || '') + params.delta);
4348
+ const delta = typeof params.delta === 'string' ? params.delta : '';
4349
+ if (itemId && delta) {
4350
+ if (!state.streamedAgentMessageIds.has(itemId)) {
4351
+ state.streamedAgentMessageIds.add(itemId);
4352
+ yield { type: 'text_start', contentIndex: 0 };
4353
+ }
4354
+ state.agentMessageDeltaText.set(itemId, (state.agentMessageDeltaText.get(itemId) || '') + delta);
4355
+ yield { type: 'heartbeat', source: 'text' };
4418
4356
  }
4419
4357
  break;
4420
4358
  }
@@ -4715,10 +4653,35 @@ export class CodexRunner {
4715
4653
  switch (item.type) {
4716
4654
  case 'agentMessage':
4717
4655
  {
4656
+ if (item.questions != null) {
4657
+ if (item.id)
4658
+ state.agentMessageDeltaText.delete(item.id);
4659
+ try {
4660
+ const questions = parseAsyncUserQuestions(item.questions);
4661
+ if (!item.id || !state.turnId)
4662
+ throw new Error('Async question is missing its item or turn id');
4663
+ const key = JSON.stringify([state.threadId, state.turnId, item.id]);
4664
+ if (this.asyncQuestionItems.has(key))
4665
+ break;
4666
+ this.asyncQuestionItems.add(key);
4667
+ if (this.asyncQuestionItems.size > 2048)
4668
+ this.asyncQuestionItems.delete(this.asyncQuestionItems.values().next().value);
4669
+ yield { type: 'user_question', request: {
4670
+ providerThreadId: state.threadId, providerTurnId: state.turnId, itemId: item.id, questions,
4671
+ } };
4672
+ }
4673
+ catch (error) {
4674
+ yield { type: 'notice', severity: 'warn', subtype: 'invalid_async_question',
4675
+ message: `Codex 异步问题无法显示:${error instanceof Error ? error.message : String(error)}` };
4676
+ }
4677
+ break;
4678
+ }
4718
4679
  const buffered = item.id ? state.agentMessageDeltaText.get(item.id) : undefined;
4719
4680
  const text = typeof item.text === 'string' && item.text ? item.text : buffered;
4720
- if (text)
4721
- yield { type: 'text', text };
4681
+ if (text) {
4682
+ state.lastAssistantUuid = item.id || state.turnId;
4683
+ yield { type: 'text', text, assistantUuid: state.lastAssistantUuid };
4684
+ }
4722
4685
  if (item.id)
4723
4686
  state.agentMessageDeltaText.delete(item.id);
4724
4687
  }
@@ -4956,6 +4919,7 @@ export class CodexRunner {
4956
4919
  return {
4957
4920
  type: 'complete',
4958
4921
  subtype: status === 'completed' ? 'success' : outputLimit ? 'max_output_tokens' : status,
4922
+ assistantUuid: state.lastAssistantUuid,
4959
4923
  isError: status === 'failed',
4960
4924
  errors: turn.error?.message ? [turn.error.message] : undefined,
4961
4925
  terminalReason,
@@ -4969,126 +4933,36 @@ export class CodexRunner {
4969
4933
  contextUsage,
4970
4934
  };
4971
4935
  }
4972
- managedTempParent() {
4973
- const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
4974
- const base = ensureProcessManagedTempDir();
4975
- return path.join(resolveProtectedCandidate(base), `evolcore-codex-${uid}`);
4976
- }
4977
- ensurePrivateDirectory(directory) {
4978
- try {
4979
- fs.mkdirSync(directory, { mode: 0o700 });
4980
- }
4981
- catch (error) {
4982
- if (error?.code !== 'EEXIST')
4983
- throw error;
4984
- }
4985
- const stat = fs.lstatSync(directory);
4986
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
4987
- throw new Error(`managed temp path is not a real directory: ${directory}`);
4988
- }
4989
- if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
4990
- throw new Error(`managed temp path is owned by another uid: ${directory}`);
4991
- }
4992
- if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
4993
- fs.chmodSync(directory, 0o700);
4994
- }
4995
- ensureSessionTempDir(sessionId) {
4996
- const existing = this.sessionTempDirs.get(sessionId);
4997
- if (existing) {
4998
- try {
4999
- this.ensurePrivateDirectory(this.managedTempParent());
5000
- this.ensurePrivateDirectory(existing);
5001
- return existing;
5002
- }
5003
- catch (error) {
5004
- this.sessionTempDirs.delete(sessionId);
5005
- logger.warn(`[CodexRunner] existing managed TMPDIR is no longer usable: session=${sessionId} path=${existing} error=${error instanceof Error ? error.message : String(error)}`);
5006
- return undefined;
5007
- }
5008
- }
5009
- try {
5010
- const parent = this.managedTempParent();
5011
- this.ensurePrivateDirectory(parent);
5012
- this.ensurePrivateDirectory(path.join(parent, 'evolcore-locks'));
5013
- // Include a per-runner nonce so a daemon restart never reuses contents
5014
- // left behind by an unclean shutdown for the same EvolCore session id.
5015
- const digest = createHash('sha256')
5016
- .update(this.managedTempNonce)
5017
- .update('\0')
5018
- .update(sessionId)
5019
- .digest('hex')
5020
- .slice(0, 24);
5021
- const directory = path.join(parent, digest);
5022
- this.ensurePrivateDirectory(directory);
5023
- this.sessionTempDirs.set(sessionId, directory);
5024
- registerManagedTempDirForExitCleanup(directory, parent);
5025
- return directory;
5026
- }
5027
- catch (error) {
5028
- logger.warn(`[CodexRunner] managed TMPDIR unavailable for session=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
5029
- return undefined;
5030
- }
5031
- }
5032
- isTrustedSessionTempDir(directory) {
5033
- return isTrustedManagedTempDirectory(directory, this.managedTempParent());
5034
- }
5035
4936
  getManagedTempDir(sessionId) {
5036
- const directory = this.ensureSessionTempDir(sessionId);
5037
- if (!directory || !this.isTrustedSessionTempDir(directory))
5038
- return undefined;
5039
- return resolveProtectedCandidate(directory);
5040
- }
5041
- cleanupSessionTempDir(sessionId) {
5042
- const directory = this.sessionTempDirs.get(sessionId);
5043
- this.sessionTempDirs.delete(sessionId);
5044
- if (!directory)
5045
- return;
5046
- // Reuse the same ownership, mode, lexical-parent, and canonicalization
5047
- // checks used at execution time. If the path changed, leave it in place
5048
- // rather than recursively removing an attacker-selected target.
5049
- if (!this.isTrustedSessionTempDir(directory)) {
5050
- managedTempDirsForExitCleanup.delete(directory);
5051
- logger.warn(`[CodexRunner] refused to clean unmanaged TMPDIR: session=${sessionId} path=${directory}`);
5052
- return;
5053
- }
5054
- const candidate = path.resolve(directory);
5055
- try {
5056
- fs.rmSync(candidate, { recursive: true, force: true });
5057
- managedTempDirsForExitCleanup.delete(directory);
5058
- }
5059
- catch (error) {
5060
- logger.warn(`[CodexRunner] failed to clean managed TMPDIR: session=${sessionId} path=${candidate} error=${error instanceof Error ? error.message : String(error)}`);
5061
- }
4937
+ const directory = ensureSessionRuntimeDir(sessionId);
4938
+ return directory ? resolveProtectedCandidate(directory) : undefined;
5062
4939
  }
5063
4940
  managedTempInstruction(sessionId) {
5064
- const managedTempDir = this.ensureSessionTempDir(sessionId);
4941
+ const managedTempDir = ensureSessionRuntimeDir(sessionId);
5065
4942
  if (!managedTempDir)
5066
4943
  return undefined;
5067
- return `临时文件必须使用环境变量 \`$TMPDIR\`。当前值是 \`${managedTempDir}\`,它是当前 EvolCore 会话唯一允许的临时工作区;不要直接使用 \`/tmp\`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 \`/tmp/evolcore-codex-*\` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 \`$TMPDIR\` 重新创建,再继续执行后续 \`ec\` 命令。`;
4944
+ return `临时文件必须使用环境变量 \`$TMPDIR\`。当前值是 \`${managedTempDir}\`,它是当前 EvolCore 会话唯一允许的临时工作区;不要直接使用 \`/tmp\`,也不要使用其他会话的临时目录。该目录随 EvolCore sessionId 保持稳定;若文件不存在,请先在当前 \`$TMPDIR\` 重新创建,再继续执行后续 \`ec\` 命令。`;
5068
4945
  }
5069
- buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier, taskDelegationToken, threadId) {
5070
- const managedTempDir = this.ensureSessionTempDir(sessionId);
4946
+ buildEvolcoreShellEnvironmentConfig(sessionId, sessionToken, taskDelegationToken) {
4947
+ const managedTempDir = ensureSessionRuntimeDir(sessionId);
5071
4948
  // The managed-hook credential rides the thread's shell environment so the
5072
4949
  // PreToolUse hook can prove the call belongs to an EvolCore-owned thread.
5073
4950
  // It follows the thread (start AND resume) rather than the daemon process.
5074
- const sessionToken = threadId ? this.ensureThreadCredential(threadId) : undefined;
5075
4951
  return {
5076
4952
  shell_environment_policy: {
5077
4953
  set: {
5078
4954
  EVOLCORE_SESSION_ID: sessionId,
5079
4955
  EVOLCORE_HOME: resolveRoot(),
5080
4956
  ...(managedTempDir ? { EVOLCORE_SESSION_RUNTIME_DIR: managedTempDir } : {}),
5081
- ...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(path.dirname(managedTempDir), 'evolcore-locks') } : {}),
5082
- ...(taskDelegationToken || delegationCarrier
5083
- ? { [AGENT_DELEGATION_TOKEN_ENV]: taskDelegationToken ?? delegationCarrier }
5084
- : {}),
4957
+ ...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(managedTempDir, 'locks') } : {}),
4958
+ ...(taskDelegationToken ? { [AGENT_DELEGATION_TOKEN_ENV]: taskDelegationToken } : {}),
5085
4959
  ...(sessionToken ? { [CODEX_SESSION_TOKEN_ENV]: sessionToken } : {}),
5086
4960
  ...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
5087
4961
  },
5088
4962
  },
5089
4963
  };
5090
4964
  }
5091
- sanitizeThreadEnvironmentConfig(config, permissionMode) {
4965
+ sanitizeThreadEnvironmentConfig(config, permissionMode, taskDelegationToken) {
5092
4966
  if (!config)
5093
4967
  return null;
5094
4968
  const policy = this.configRecord(config.shell_environment_policy);
@@ -5098,11 +4972,40 @@ export class CodexRunner {
5098
4972
  const safeSet = sanitizeShellExecutionEnvironment(set, {
5099
4973
  lockExecutableResolution: permissionMode === 'readonly',
5100
4974
  });
4975
+ const sessionToken = typeof safeSet[CODEX_SESSION_TOKEN_ENV] === 'string'
4976
+ ? safeSet[CODEX_SESSION_TOKEN_ENV]
4977
+ : undefined;
4978
+ for (const name of Object.keys(safeSet)) {
4979
+ const normalized = name.toUpperCase();
4980
+ if (normalized === AGENT_DELEGATION_TOKEN_ENV || normalized === CODEX_SESSION_TOKEN_ENV) {
4981
+ delete safeSet[name];
4982
+ }
4983
+ }
4984
+ if (sessionToken)
4985
+ safeSet[CODEX_SESSION_TOKEN_ENV] = sessionToken;
4986
+ const existingExclude = Array.isArray(policy.exclude)
4987
+ ? policy.exclude.filter((name) => typeof name === 'string')
4988
+ : [];
4989
+ const carriesTaskDelegation = permissionMode === 'fullaccess'
4990
+ && !!taskDelegationToken;
4991
+ if (carriesTaskDelegation)
4992
+ safeSet[AGENT_DELEGATION_TOKEN_ENV] = taskDelegationToken;
4993
+ const filteredExclude = existingExclude.filter(name => {
4994
+ const normalized = name.toUpperCase();
4995
+ return normalized !== AGENT_DELEGATION_TOKEN_ENV
4996
+ && normalized !== CODEX_SESSION_TOKEN_ENV;
4997
+ });
4998
+ const exclude = carriesTaskDelegation
4999
+ ? filteredExclude
5000
+ : [...new Set([...filteredExclude, AGENT_DELEGATION_TOKEN_ENV])];
5101
5001
  return {
5102
5002
  ...config,
5103
5003
  shell_environment_policy: {
5104
5004
  ...policy,
5105
5005
  set: safeSet,
5006
+ // The task token is invocation-scoped. Persistent non-fullaccess
5007
+ // threads must not inherit a stale value from the app-server process.
5008
+ exclude,
5106
5009
  },
5107
5010
  };
5108
5011
  }
@@ -5119,21 +5022,23 @@ export class CodexRunner {
5119
5022
  this.activeSessions.clear();
5120
5023
  this.provisionalSessions.clear();
5121
5024
  this.activeTurns.clear();
5025
+ for (const childThreadId of [...this.childThreadSessions.keys()])
5026
+ this.unregisterChildThread(childThreadId);
5122
5027
  this.activeSubagentTurns.clear();
5123
5028
  this.childThreadSessions.clear();
5124
5029
  this.threadProjectPaths.clear();
5125
5030
  this.threadExternalToolFingerprints.clear();
5126
5031
  this.threadExternalToolBoundaries.clear();
5127
5032
  this.trackedApprovalItems.clear();
5128
- this.threadDelegationCarriers.clear();
5129
5033
  this.pendingInterrupts.clear();
5034
+ for (const record of this.unconfirmedInterrupts.values())
5035
+ record.unsubscribe?.();
5036
+ this.unconfirmedInterrupts.clear();
5130
5037
  this.steeringTails.clear();
5131
5038
  this.permissionContexts.clear();
5132
5039
  this.chatModes.clear();
5133
5040
  this.pruneThreadOperationLocks();
5134
5041
  this.threadOperationLocks.clear();
5135
- for (const sessionId of [...this.sessionTempDirs.keys()])
5136
- this.cleanupSessionTempDir(sessionId);
5137
5042
  await Promise.all([
5138
5043
  this.appServerClient?.close(),
5139
5044
  this.fullAccessAppServerClient?.close(),