evolcore 0.0.22 → 0.0.24
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.
- package/CHANGELOG.md +74 -14
- package/README.md +4 -0
- package/bin/codex-managed-acl.ps1 +160 -0
- package/bin/codex-managed-hook.mjs +127 -10
- package/bin/ec-safe-output.js +85 -3
- package/bin/install-codex-managed-hooks.mjs +384 -95
- package/dist/agents/baseagent-availability.js +48 -0
- package/dist/agents/baseagent.js +30 -2
- package/dist/agents/claude-runner.js +179 -240
- package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +90 -20
- package/dist/{utils/codex-app-server-registry.js → agents/codex/app-server-registry.js} +2 -2
- package/dist/agents/codex/catalog.js +70 -0
- package/dist/agents/codex/diagnostics.js +369 -0
- package/dist/agents/codex-runner.js +839 -192
- package/dist/agents/ecagent-runner.js +27 -34
- package/dist/agents/gemini-runner.js +62 -8
- package/dist/agents/runner-types.js +1 -1
- package/dist/aun/aid/managed-operation.js +28 -6
- package/dist/aun/aid/store.js +43 -0
- package/dist/aun/menu-client.js +272 -0
- package/dist/aun/msg/group.js +65 -4
- package/dist/aun/msg/index.js +1 -1
- package/dist/aun/msg/managed-operation.js +57 -5
- package/dist/aun/outbox.js +56 -18
- package/dist/aun/rpc/client.js +7 -1
- package/dist/channels/aun.js +178 -77
- package/dist/channels/daemon.js +7 -1
- package/dist/channels/dingtalk.js +1 -0
- package/dist/channels/feishu.js +10 -4
- package/dist/channels/wechat.js +81 -10
- package/dist/channels/wecom.js +2 -1
- package/dist/cli/agent-command.js +29 -3
- package/dist/cli/agent-create.js +538 -0
- package/dist/cli/agent.js +26 -675
- package/dist/cli/aun-commands.js +108 -24
- package/dist/cli/command-log.js +21 -2
- package/dist/cli/ctl-command.js +2 -0
- package/dist/cli/daemon-commands.js +289 -61
- package/dist/cli/index.js +22 -2
- package/dist/cli/init.js +2 -10
- package/dist/cli/managed-command-guard.js +3 -0
- package/dist/cli/menu-client.js +95 -0
- package/dist/cli/model.js +23 -8
- package/dist/cli/restart-monitor.js +432 -8
- package/dist/cli/stats.js +110 -0
- package/dist/cli/task-context.js +12 -2
- package/dist/cli/trigger-command.js +9 -1
- package/dist/config/builtin-roles.js +2 -0
- package/dist/config/schema-migration.js +14 -2
- package/dist/core/auth/agent-delegation.js +10 -7
- package/dist/core/auth/auth-gateway.js +3 -2
- package/dist/core/auth/authorization-audit.js +75 -18
- package/dist/core/auth/operation-authorizer.js +63 -3
- package/dist/core/auth/operation-catalog.js +14 -2
- package/dist/core/auth/remote-access-store.js +136 -0
- package/dist/core/baseagent-loader.js +4 -4
- package/dist/core/bootstrap-messages.js +39 -18
- package/dist/core/bootstrap-service.js +32 -26
- package/dist/core/capability/capability-manager.js +8 -4
- package/dist/core/capability/providers/codex-capability-provider.js +1 -1
- package/dist/core/command/agent-control.js +7 -2
- package/dist/core/command/command-handler.js +128 -30
- package/dist/core/command/daemon-diagnostics.js +448 -0
- package/dist/core/command/menu-catalog.js +6 -1
- package/dist/core/command/menu-handler.js +410 -98
- package/dist/core/command/menu-protocol.js +123 -0
- package/dist/core/command/slash-gate.js +4 -6
- package/dist/core/command/slash-handler.js +199 -70
- package/dist/core/data-migration.js +5 -15
- package/dist/core/event-catalog.js +3 -0
- package/dist/core/handoff/runtime.js +3 -1
- package/dist/core/handoff/store.js +7 -1
- package/dist/core/message/im-renderer.js +13 -6
- package/dist/core/message/message-bridge.js +5 -36
- package/dist/core/message/message-queue.js +20 -15
- package/dist/core/message/message-utils.js +6 -3
- package/dist/core/message/response-engine.js +147 -94
- package/dist/core/model/catalog-service.js +310 -0
- package/dist/core/model/model-catalog.js +134 -334
- package/dist/core/model/model-diagnostics.js +4 -5
- package/dist/core/model/model-fallback.js +9 -16
- package/dist/core/model/model-permission.js +5 -2
- package/dist/core/permission/approval-gateway.js +183 -32
- package/dist/core/permission/protected-paths.js +63 -4
- package/dist/core/permission/sandbox-runtime.js +4 -2
- package/dist/core/permission/tool-policy.js +257 -31
- package/dist/core/runtime-lock.js +13 -8
- package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
- package/dist/core/session/session-manager.js +49 -13
- package/dist/core/session/session-renew.js +1 -0
- package/dist/index.js +214 -57
- package/dist/ipc.js +191 -102
- package/dist/paths.js +23 -1
- package/dist/stats/billing.js +18 -4
- package/dist/trigger/manager.js +3 -1
- package/dist/trigger/parser.js +77 -13
- package/dist/trigger/patch.js +104 -1
- package/dist/utils/atomic-write.js +26 -2
- package/dist/utils/cross-platform.js +436 -1
- package/dist/utils/ecweb-supervisor.js +11 -2
- package/dist/utils/ecweb-utils.js +8 -26
- package/dist/utils/error-utils.js +16 -2
- package/dist/utils/model-prices.jsonl +23 -20
- package/dist/utils/npm-ops.js +70 -15
- package/dist/utils/windows-autostart.js +1 -1
- package/kits/docs/evolcore/INDEX.md +1 -0
- package/kits/docs/evolcore/agent-create.md +13 -0
- package/kits/docs/evolcore/group-collaboration.md +7 -2
- package/kits/docs/evolcore/menu-client.md +34 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
- package/kits/migrations/migrate-role-config-v4.mjs +11 -5
- package/kits/migrations/migrate-role-config-v5.mjs +8 -2
- package/kits/templates/roles/admin.json +2 -0
- package/kits/templates/roles/visitor.json +1 -0
- package/kits/templates/system-fragments/bootstrap.md +8 -1
- package/package.json +16 -4
- package/dist/utils/codex-cli.js +0 -43
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
* Codex Agent Runner
|
|
3
|
-
*
|
|
4
|
-
* Integrates Codex app-server as an agent backend.
|
|
5
|
-
* Implements the same interface surface as AgentRunner (claude-runner.ts)
|
|
6
|
-
* so MessageProcessor and CommandHandler can work with it transparently.
|
|
7
|
-
*/
|
|
1
|
+
import { getRunnerCatalog, catalogEfforts, assertCatalogSelectable } from '../core/model/model-catalog.js';
|
|
8
2
|
import { hasTrustedFullAccessContext } from './runner-types.js';
|
|
9
3
|
import { requestDangerousCommandPermission } from '../core/permission/approval-gateway.js';
|
|
10
4
|
import { checkReadonly, checkDangerousCommand, evaluateToolPreflight } from '../core/permission/tool-policy.js';
|
|
11
5
|
import { normalizeExecutionPermissionMode, normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
|
|
12
6
|
import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/permission/protected-paths.js';
|
|
13
|
-
import { CodexAppServerClient } from './codex
|
|
14
|
-
import { resolveOpenaiConfig } from './baseagent.js';
|
|
7
|
+
import { CODEX_THREAD_ACTIVE_WRITER, CodexAppServerClient, } from './codex/app-server-client.js';
|
|
8
|
+
import { resolveOpenaiConfig, resolveCodexCliPath } from './baseagent.js';
|
|
15
9
|
import { logger } from '../utils/logger.js';
|
|
16
10
|
import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
|
|
17
|
-
import { auditCodexApprovalDecision, auditToolPreflightDenial } from '../core/auth/authorization-audit.js';
|
|
11
|
+
import { auditCodexApprovalDecision, auditPermissionLifecycle, auditToolPreflightDenial } from '../core/auth/authorization-audit.js';
|
|
18
12
|
import { isRetryableError } from '../utils/error-utils.js';
|
|
19
13
|
import { renderActionAsText } from '../core/interaction-router.js';
|
|
20
14
|
import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from '../core/message/message-utils.js';
|
|
@@ -23,11 +17,12 @@ import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/au
|
|
|
23
17
|
import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
|
|
24
18
|
import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, hasPowerShellDelegationAssignment, normalizeEvolcoreCommandArgv, parseCodexToolCommand, parseCodexCarrierCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
|
|
25
19
|
import { compareVersions } from '../utils/npm-ops.js';
|
|
26
|
-
import { resolvePaths, resolveRoot } from '../paths.js';
|
|
20
|
+
import { agentSessionsDir, resolvePaths, resolveRoot } from '../paths.js';
|
|
27
21
|
import { ensureProcessManagedTempDir, getManagedTaskTempDir } from '../cli/task-context.js';
|
|
28
22
|
import { buildSessionTurnList } from '../core/session/session-turns.js';
|
|
23
|
+
import { CodexSessionFileAdapter } from '../core/session/adapters/codex-session-file-adapter.js';
|
|
29
24
|
import { execFileSync } from 'child_process';
|
|
30
|
-
import {
|
|
25
|
+
import { probeCli, probeCliSync } from '../utils/cross-platform.js';
|
|
31
26
|
import { createHash, randomBytes } from 'crypto';
|
|
32
27
|
import { buildModelRequestHeaders } from './request-identity.js';
|
|
33
28
|
import fs from 'fs';
|
|
@@ -47,6 +42,14 @@ export function formatApprovalLogSummary(summary) {
|
|
|
47
42
|
return singleLine;
|
|
48
43
|
return singleLine.slice(0, APPROVAL_LOG_SUMMARY_MAX_LENGTH - 3).trimEnd() + '...';
|
|
49
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* 规范化模型 ID:去掉 Claude Code SDK 专有的上下文窗口后缀 [1m]/[200k]
|
|
47
|
+
* Codex/OpenAI API 不认识这些后缀,必须传入标准模型 ID
|
|
48
|
+
*/
|
|
49
|
+
function stripContextWindowSuffix(modelId) {
|
|
50
|
+
return modelId.replace(/\[(?:1m|200k)\]$/i, '');
|
|
51
|
+
}
|
|
52
|
+
// ── Codex 模型目录(动态获取,含 effort) ──
|
|
50
53
|
class AsyncEventQueue {
|
|
51
54
|
queue = [];
|
|
52
55
|
done = false;
|
|
@@ -94,6 +97,12 @@ function errorCodeForToolDenial(decisionSource, policyCode) {
|
|
|
94
97
|
return 'INVALID_ARGUMENT';
|
|
95
98
|
return 'EXECUTION_FAILED';
|
|
96
99
|
}
|
|
100
|
+
function isCodexReasoningNotification(method) {
|
|
101
|
+
return method === 'item/reasoning/textDelta'
|
|
102
|
+
|| method === 'item/reasoning/summaryTextDelta'
|
|
103
|
+
|| method === 'item/reasoning/summaryPartAdded';
|
|
104
|
+
}
|
|
105
|
+
const CODEX_THREAD_HANDOFF_TIMEOUT_MS = 2_000;
|
|
97
106
|
const managedTempDirsForExitCleanup = new Map();
|
|
98
107
|
let managedTempExitCleanupRegistered = false;
|
|
99
108
|
function isTrustedManagedTempDirectory(directory, parent) {
|
|
@@ -109,9 +118,9 @@ function isTrustedManagedTempDirectory(directory, parent) {
|
|
|
109
118
|
const stat = fs.lstatSync(candidate);
|
|
110
119
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
111
120
|
return false;
|
|
112
|
-
if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
|
|
121
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid())
|
|
113
122
|
return false;
|
|
114
|
-
return (stat.mode & 0o077) === 0;
|
|
123
|
+
return process.platform === 'win32' || (stat.mode & 0o077) === 0;
|
|
115
124
|
};
|
|
116
125
|
if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
|
|
117
126
|
return false;
|
|
@@ -218,20 +227,17 @@ function outputTextForManagedHook(value) {
|
|
|
218
227
|
}
|
|
219
228
|
return String(value);
|
|
220
229
|
}
|
|
221
|
-
const CODEX_CATALOG_FALLBACK = [
|
|
222
|
-
{ slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
223
|
-
{ slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
224
|
-
{ slug: 'gpt-5.4-mini', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
225
|
-
{ slug: 'gpt-5.3-codex', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
226
|
-
{ slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
227
|
-
];
|
|
228
230
|
const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
|
|
229
231
|
const CODEX_NO_ROLLOUT_RE = /no rollout found for thread id/i;
|
|
232
|
+
const CODEX_ACTIVE_WRITER_RE = /already has an active writer/i;
|
|
230
233
|
function isCodexNoRolloutError(error) {
|
|
231
234
|
return error?.code === 'CODEX_ROLLOUT_NOT_FOUND'
|
|
232
235
|
|| CODEX_NO_ROLLOUT_RE.test(error instanceof Error ? error.message : String(error));
|
|
233
236
|
}
|
|
234
|
-
|
|
237
|
+
function isCodexActiveWriterError(error) {
|
|
238
|
+
return error?.code === CODEX_THREAD_ACTIVE_WRITER
|
|
239
|
+
|| CODEX_ACTIVE_WRITER_RE.test(error instanceof Error ? error.message : String(error));
|
|
240
|
+
}
|
|
235
241
|
// The permission bridge is version-locked to the audited approval schema and
|
|
236
242
|
// managed PreToolUse hook behavior used by proactive first-tool enforcement.
|
|
237
243
|
export const MIN_CODEX_CLI_VERSION = '0.144.1';
|
|
@@ -242,21 +248,59 @@ export function parseCodexCliVersion(output) {
|
|
|
242
248
|
export function isCodexCliVersionSupported(version) {
|
|
243
249
|
return compareVersions(version, MIN_CODEX_CLI_VERSION) >= 0;
|
|
244
250
|
}
|
|
251
|
+
// Windows shim/process startup can exceed the quick probe deadline under load.
|
|
252
|
+
const CODEX_CLI_PROBE_TIMEOUT_MS = 3000;
|
|
253
|
+
const CODEX_CLI_PROBE_RETRY_TIMEOUT_MS = 15_000;
|
|
254
|
+
function compactCodexProbeText(value, maxLength = 600) {
|
|
255
|
+
if (Buffer.isBuffer(value))
|
|
256
|
+
value = value.toString('utf-8');
|
|
257
|
+
if (typeof value !== 'string')
|
|
258
|
+
return undefined;
|
|
259
|
+
const text = value.replace(/\s+/gu, ' ').trim();
|
|
260
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3).trimEnd()}...`;
|
|
261
|
+
}
|
|
262
|
+
function logCodexProbeFailure(args, startedAt, error, timeoutMs) {
|
|
263
|
+
const detail = error;
|
|
264
|
+
logger.warn(`[CodexProbe] CLI probe failed: ${JSON.stringify({
|
|
265
|
+
command: ['codex', ...args],
|
|
266
|
+
path: detail?.path ?? null,
|
|
267
|
+
spawnargs: detail?.spawnargs ?? null,
|
|
268
|
+
elapsedMs: Date.now() - startedAt,
|
|
269
|
+
timeoutMs,
|
|
270
|
+
code: detail?.code ?? null,
|
|
271
|
+
status: detail?.status ?? null,
|
|
272
|
+
signal: detail?.signal ?? null,
|
|
273
|
+
timedOut: detail?.code === 'ETIMEDOUT',
|
|
274
|
+
message: compactCodexProbeText(detail?.message ?? String(error)),
|
|
275
|
+
stdout: compactCodexProbeText(detail?.stdout),
|
|
276
|
+
stderr: compactCodexProbeText(detail?.stderr),
|
|
277
|
+
})}`);
|
|
278
|
+
}
|
|
279
|
+
function probeCodexCli(args) {
|
|
280
|
+
return probeCliSync('codex', args, { onFailure: logCodexProbeFailure });
|
|
281
|
+
}
|
|
245
282
|
export function getCodexCliVersion() {
|
|
283
|
+
const startedAt = Date.now();
|
|
246
284
|
try {
|
|
247
|
-
const output =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
285
|
+
const output = probeCodexCli(['--version']);
|
|
286
|
+
const version = parseCodexCliVersion(output);
|
|
287
|
+
if (!version) {
|
|
288
|
+
logCodexProbeFailure(['--version'], startedAt, {
|
|
289
|
+
code: 'INVALID_VERSION_OUTPUT',
|
|
290
|
+
message: 'Codex CLI version output could not be parsed',
|
|
291
|
+
status: 0,
|
|
292
|
+
stdout: output,
|
|
293
|
+
}, CODEX_CLI_PROBE_TIMEOUT_MS);
|
|
294
|
+
}
|
|
295
|
+
return version;
|
|
253
296
|
}
|
|
254
297
|
catch {
|
|
255
298
|
return null;
|
|
256
299
|
}
|
|
257
300
|
}
|
|
258
301
|
export function getCodexAppServerAvailability() {
|
|
259
|
-
|
|
302
|
+
const cliPath = resolveCodexCliPath();
|
|
303
|
+
if (!cliPath) {
|
|
260
304
|
return { available: false, reason: '未检测到 Codex CLI。' };
|
|
261
305
|
}
|
|
262
306
|
const version = getCodexCliVersion();
|
|
@@ -272,52 +316,45 @@ export function getCodexAppServerAvailability() {
|
|
|
272
316
|
};
|
|
273
317
|
}
|
|
274
318
|
try {
|
|
275
|
-
|
|
276
|
-
encoding: 'utf-8',
|
|
277
|
-
timeout: 3000,
|
|
278
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
279
|
-
});
|
|
319
|
+
probeCodexCli(['app-server', '--help']);
|
|
280
320
|
return { available: true, version };
|
|
281
321
|
}
|
|
282
|
-
catch {
|
|
283
|
-
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (error?.code === 'ETIMEDOUT') {
|
|
324
|
+
return { available: false, version, reason: `Codex CLI ${version} 的 app-server 探测超时,请稍后重试。` };
|
|
325
|
+
}
|
|
326
|
+
return { available: false, version, reason: `Codex CLI ${version} 的 app-server 探测失败,请检查 CLI 启动日志。` };
|
|
284
327
|
}
|
|
285
328
|
}
|
|
286
329
|
export function isCodexAppServerAvailable() {
|
|
287
330
|
return getCodexAppServerAvailability().available;
|
|
288
331
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
332
|
+
/** Daemon startup/hot reload use asynchronous probes; sync CLI callers share the cache. */
|
|
333
|
+
export async function getCodexAppServerAvailabilityAsync() {
|
|
334
|
+
const options = {
|
|
335
|
+
timeouts: [CODEX_CLI_PROBE_TIMEOUT_MS, CODEX_CLI_PROBE_RETRY_TIMEOUT_MS],
|
|
336
|
+
onFailure: logCodexProbeFailure,
|
|
337
|
+
};
|
|
338
|
+
let version;
|
|
292
339
|
try {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
slug: m.slug,
|
|
303
|
-
efforts: (m.supported_reasoning_levels || []).map(l => l.effort),
|
|
304
|
-
}));
|
|
305
|
-
if (models.length > 0) {
|
|
306
|
-
codexCatalogCache = models;
|
|
307
|
-
return models;
|
|
308
|
-
}
|
|
340
|
+
version = parseCodexCliVersion(await probeCli('codex', ['--version'], options)) ?? undefined;
|
|
341
|
+
if (!version)
|
|
342
|
+
return { available: false, reason: '无法读取 Codex CLI 版本。' };
|
|
343
|
+
if (!isCodexCliVersionSupported(version)) {
|
|
344
|
+
return { available: false, version, requiresUpgrade: true,
|
|
345
|
+
reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。请升级 Codex CLI:npm install -g @openai/codex@latest` };
|
|
346
|
+
}
|
|
347
|
+
await probeCli('codex', ['app-server', '--help'], options);
|
|
348
|
+
return { available: true, version };
|
|
309
349
|
}
|
|
310
|
-
catch (
|
|
311
|
-
|
|
350
|
+
catch (error) {
|
|
351
|
+
const detail = error;
|
|
352
|
+
return { available: false, version, reason: detail.code === 'ETIMEDOUT' || detail.killed
|
|
353
|
+
? 'Codex CLI 探测超时,请稍后重试。'
|
|
354
|
+
: `Codex CLI 探测失败:${error instanceof Error ? error.message : String(error)}` };
|
|
312
355
|
}
|
|
313
|
-
return CODEX_CATALOG_FALLBACK;
|
|
314
356
|
}
|
|
315
|
-
export
|
|
316
|
-
const catalog = fetchCodexCatalog();
|
|
317
|
-
const entry = catalog.find(m => m.slug === model);
|
|
318
|
-
return entry?.efforts ?? catalog[0]?.efforts ?? ['low', 'medium', 'high'];
|
|
319
|
-
}
|
|
320
|
-
// ── Codex Runner ──
|
|
357
|
+
export const CODEX_SESSION_TOKEN_ENV = 'EVOLCORE_CODEX_SESSION_TOKEN';
|
|
321
358
|
export class CodexRunner {
|
|
322
359
|
name = 'codex';
|
|
323
360
|
// turn/start persists a new user turn before runQuery resolves. App-server
|
|
@@ -329,7 +366,7 @@ export class CodexRunner {
|
|
|
329
366
|
activeAbortControllers = new Map();
|
|
330
367
|
activeStreams = new Map();
|
|
331
368
|
activeSessions = new Map(); // sessionId → threadId
|
|
332
|
-
/**
|
|
369
|
+
/** New threads held in memory until the exact first turn is visible in the rollout. */
|
|
333
370
|
provisionalSessions = new Map();
|
|
334
371
|
activeTurns = new Map();
|
|
335
372
|
activeSubagentTurns = new Map();
|
|
@@ -347,14 +384,25 @@ export class CodexRunner {
|
|
|
347
384
|
steeringTails = new Map();
|
|
348
385
|
threadOperationLocks = new Map();
|
|
349
386
|
activeOperationReleases = new Map();
|
|
387
|
+
/** Last app-server client that loaded each thread, including unsubscribed threads. */
|
|
388
|
+
threadSubscriptions = new Map();
|
|
389
|
+
/** Ordinary sessions stay behind the EvolCore H/L filesystem guard. */
|
|
350
390
|
appServerClient = null;
|
|
391
|
+
/** Trusted fullaccess calls use a separate unmasked app-server process. */
|
|
392
|
+
fullAccessAppServerClient = null;
|
|
351
393
|
onSessionIdUpdate;
|
|
352
394
|
onCompactStart;
|
|
353
395
|
permissionGateway;
|
|
354
396
|
sendPromptFn;
|
|
355
397
|
permissionContexts = new Map();
|
|
398
|
+
/** Fallback authorization lifecycle ids for app-server requests without an RPC id. */
|
|
399
|
+
approvalLifecycleIds = new WeakMap();
|
|
356
400
|
resolvedConfig;
|
|
357
401
|
pendingInterruptTtlMs = 30_000;
|
|
402
|
+
/** Fallback for direct runner callers that do not inject SessionManager. */
|
|
403
|
+
codexSessionFileAdapter = new CodexSessionFileAdapter();
|
|
404
|
+
/** threadId → managed-hook credential, issued by this runner's agent only. */
|
|
405
|
+
threadCredentials = new Map();
|
|
358
406
|
constructor(config, callbacks, runtime) {
|
|
359
407
|
// Compatibility for legacy direct callers that still pass the removed
|
|
360
408
|
// internal fields in a synthetic config. AgentLoader uses runtime binding.
|
|
@@ -383,29 +431,208 @@ export class CodexRunner {
|
|
|
383
431
|
fileRewind: 'git-head',
|
|
384
432
|
fullaccess: true,
|
|
385
433
|
};
|
|
386
|
-
this.model = this.resolvedConfig.model;
|
|
434
|
+
this.model = stripContextWindowSuffix(this.resolvedConfig.model);
|
|
387
435
|
if (this.resolvedConfig.effort)
|
|
388
436
|
this.effort = this.resolvedConfig.effort;
|
|
389
437
|
this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
|
|
390
438
|
}
|
|
391
|
-
getAppServerClient() {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
439
|
+
getAppServerClient(fullAccess = false) {
|
|
440
|
+
const existing = fullAccess ? this.fullAccessAppServerClient : this.appServerClient;
|
|
441
|
+
if (existing)
|
|
442
|
+
return existing;
|
|
443
|
+
const client = new CodexAppServerClient({
|
|
444
|
+
apiKey: this.resolvedConfig.apiKey,
|
|
445
|
+
baseUrl: this.resolvedConfig.baseUrl,
|
|
446
|
+
provider: this.resolvedConfig.provider,
|
|
447
|
+
headers: this.resolvedConfig.headers,
|
|
448
|
+
queryParams: this.resolvedConfig.queryParams,
|
|
449
|
+
model: this.model,
|
|
450
|
+
effort: this.effort,
|
|
451
|
+
enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
|
|
452
|
+
approvalsReviewer: 'user',
|
|
453
|
+
protectEvolcoreFilesystem: !fullAccess,
|
|
454
|
+
// A completed turn is explicitly unsubscribed below. This is only an
|
|
455
|
+
// optimization on newer Codex versions; old versions retain the thread
|
|
456
|
+
// until the cross-client archive fallback runs.
|
|
457
|
+
onServerRequest: request => this.handleAppServerRequest(request),
|
|
458
|
+
});
|
|
459
|
+
client.onNotification(notification => {
|
|
460
|
+
this.trackApprovalItemNotification(notification);
|
|
461
|
+
if (notification.method !== 'thread/closed')
|
|
462
|
+
return;
|
|
463
|
+
const params = (notification.params || {});
|
|
464
|
+
const threadId = params.threadId ?? params.thread_id;
|
|
465
|
+
if (typeof threadId !== 'string')
|
|
466
|
+
return;
|
|
467
|
+
const subscription = this.threadSubscriptions.get(threadId);
|
|
468
|
+
if (subscription?.client === client) {
|
|
469
|
+
// Keep the last client identity so a later same-mode turn resumes on
|
|
470
|
+
// the same app-server even after the runtime has fully unloaded.
|
|
471
|
+
this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
if (fullAccess) {
|
|
475
|
+
this.fullAccessAppServerClient = client;
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
406
478
|
this.appServerClient = client;
|
|
407
479
|
}
|
|
408
|
-
return
|
|
480
|
+
return client;
|
|
481
|
+
}
|
|
482
|
+
getAppServerClientForThread(threadId) {
|
|
483
|
+
const subscribed = this.threadSubscriptions.get(threadId);
|
|
484
|
+
if (subscribed)
|
|
485
|
+
return subscribed.client;
|
|
486
|
+
const sessionId = this.findSessionKeyByThread(threadId);
|
|
487
|
+
return this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
|
|
488
|
+
}
|
|
489
|
+
markThreadSubscribed(client, threadId) {
|
|
490
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
491
|
+
if (existing?.client === client && existing.state === 'subscribed' && !existing.release)
|
|
492
|
+
return;
|
|
493
|
+
this.threadSubscriptions.set(threadId, { client, state: 'subscribed' });
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Release only one thread from one app-server connection. Unsubscribe is
|
|
497
|
+
* intentionally not treated as an immediate unload: current Codex builds can
|
|
498
|
+
* keep an unsubscribed thread loaded for a long grace period. The owner therefore
|
|
499
|
+
* remains in the map until a different client successfully resumes it.
|
|
500
|
+
*/
|
|
501
|
+
async releaseThreadSubscription(client, threadId, reason, options = {}) {
|
|
502
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
503
|
+
if (existing && existing.client !== client)
|
|
504
|
+
return false;
|
|
505
|
+
if (existing?.release)
|
|
506
|
+
return await existing.release.catch(error => {
|
|
507
|
+
logger.warn(`[CodexRunner] Existing thread release failed: thread=${threadId} `
|
|
508
|
+
+ `error=${error instanceof Error ? error.message : String(error)}`);
|
|
509
|
+
return false;
|
|
510
|
+
});
|
|
511
|
+
if (existing?.state === 'unsubscribed')
|
|
512
|
+
return true;
|
|
513
|
+
let releasePromise;
|
|
514
|
+
releasePromise = Promise.resolve().then(async () => {
|
|
515
|
+
let released = false;
|
|
516
|
+
try {
|
|
517
|
+
const unsubscribe = client.threadUnsubscribe;
|
|
518
|
+
if (typeof unsubscribe === 'function') {
|
|
519
|
+
if (options.waitForClose && typeof client.threadUnsubscribeAndWait === 'function') {
|
|
520
|
+
try {
|
|
521
|
+
await client.threadUnsubscribeAndWait.call(client, threadId, options.timeoutMs ?? CODEX_THREAD_HANDOFF_TIMEOUT_MS);
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
// The unsubscribe RPC has already succeeded when this is a
|
|
525
|
+
// close-timeout. Keep the owner marked unsubscribed and let the
|
|
526
|
+
// target resume decide whether the archive fallback is needed.
|
|
527
|
+
if (error?.code !== 'CODEX_THREAD_CLOSE_TIMEOUT')
|
|
528
|
+
throw error;
|
|
529
|
+
logger.debug(`[CodexRunner] thread/closed not observed before handoff timeout: `
|
|
530
|
+
+ `thread=${threadId} reason=${reason}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
await unsubscribe.call(client, threadId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
logger.debug(`[CodexRunner] Released thread subscription: thread=${threadId} reason=${reason}`);
|
|
538
|
+
released = true;
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
logger.warn(`[CodexRunner] Failed to release thread subscription: thread=${threadId} reason=${reason} `
|
|
543
|
+
+ `error=${error instanceof Error ? error.message : String(error)}`);
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
const current = this.threadSubscriptions.get(threadId);
|
|
548
|
+
if (current?.client === client && current.release === releasePromise) {
|
|
549
|
+
if (released) {
|
|
550
|
+
this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
this.threadSubscriptions.set(threadId, { client, state: current.state });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
this.threadSubscriptions.set(threadId, {
|
|
559
|
+
client,
|
|
560
|
+
state: existing?.state ?? 'subscribed',
|
|
561
|
+
release: releasePromise,
|
|
562
|
+
});
|
|
563
|
+
return await releasePromise;
|
|
564
|
+
}
|
|
565
|
+
async prepareThreadSubscription(client, threadId) {
|
|
566
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
567
|
+
if (!existing)
|
|
568
|
+
return undefined;
|
|
569
|
+
if (existing.client === client) {
|
|
570
|
+
if (existing.release)
|
|
571
|
+
await existing.release.catch(() => undefined);
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
if (existing.state === 'subscribed') {
|
|
575
|
+
const released = await this.releaseThreadSubscription(existing.client, threadId, 'mode-handoff', {
|
|
576
|
+
waitForClose: true,
|
|
577
|
+
timeoutMs: CODEX_THREAD_HANDOFF_TIMEOUT_MS,
|
|
578
|
+
});
|
|
579
|
+
if (!released) {
|
|
580
|
+
const error = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
|
|
581
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return existing.client;
|
|
586
|
+
}
|
|
587
|
+
async archiveThreadForHandoff(sourceClient, targetClient, threadId) {
|
|
588
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
589
|
+
if (existing && existing.client !== sourceClient) {
|
|
590
|
+
const error = new Error(`Codex thread ${threadId} ownership changed during handoff`);
|
|
591
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
const sessionId = this.findSessionKeyByThread(threadId);
|
|
595
|
+
if ([...this.activeSubagentTurns.values()].some(active => active.sessionId === sessionId && active.parentThreadId === threadId)) {
|
|
596
|
+
const error = new Error(`Codex thread ${threadId} has an active descendant turn`);
|
|
597
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
// Old app-server versions can acknowledge unsubscribe while retaining the
|
|
601
|
+
// writer. Archive is the only per-thread operation that synchronously
|
|
602
|
+
// tears down that runtime; the target client restores it before resume.
|
|
603
|
+
const archive = sourceClient.threadArchive;
|
|
604
|
+
const unarchive = targetClient.threadUnarchive;
|
|
605
|
+
if (typeof archive !== 'function' || typeof unarchive !== 'function') {
|
|
606
|
+
const error = new Error(`Codex thread ${threadId} requires archive/unarchive handoff support`);
|
|
607
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
let archived = false;
|
|
611
|
+
try {
|
|
612
|
+
await archive.call(sourceClient, threadId);
|
|
613
|
+
archived = true;
|
|
614
|
+
await unarchive.call(targetClient, threadId);
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
if (archived) {
|
|
618
|
+
// Do not strand the user's rollout under archived_sessions when the
|
|
619
|
+
// target client fails midway through the compatibility handoff.
|
|
620
|
+
try {
|
|
621
|
+
await unarchive.call(sourceClient, threadId);
|
|
622
|
+
this.threadSubscriptions.set(threadId, { client: sourceClient, state: 'subscribed' });
|
|
623
|
+
}
|
|
624
|
+
catch (restoreError) {
|
|
625
|
+
logger.error(`[CodexRunner] Failed to restore archived thread after handoff failure: thread=${threadId} `
|
|
626
|
+
+ `error=${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
logger.warn(`[CodexRunner] Archive handoff failed: thread=${threadId} `
|
|
630
|
+
+ `error=${error instanceof Error ? error.message : String(error)}`);
|
|
631
|
+
const handoffError = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
|
|
632
|
+
handoffError.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
633
|
+
throw handoffError;
|
|
634
|
+
}
|
|
635
|
+
this.threadSubscriptions.set(threadId, { client: targetClient, state: 'subscribed' });
|
|
409
636
|
}
|
|
410
637
|
createDelegationCarrier() {
|
|
411
638
|
return randomBytes(32).toString('base64url');
|
|
@@ -629,6 +856,11 @@ export class CodexRunner {
|
|
|
629
856
|
error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
|
|
630
857
|
throw error;
|
|
631
858
|
}
|
|
859
|
+
// The binding callback can yield while a replacement run claims this
|
|
860
|
+
// session. Re-check the provisional owner before publishing any local
|
|
861
|
+
// indexes so a late first turn cannot overwrite the newer thread.
|
|
862
|
+
if (this.provisionalSessions.get(sessionId) !== threadId)
|
|
863
|
+
return false;
|
|
632
864
|
this.provisionalSessions.delete(sessionId);
|
|
633
865
|
this.activeSessions.set(sessionId, threadId);
|
|
634
866
|
if (carrierToPublish) {
|
|
@@ -638,6 +870,30 @@ export class CodexRunner {
|
|
|
638
870
|
this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
|
|
639
871
|
return true;
|
|
640
872
|
}
|
|
873
|
+
async waitForCodexTurnPersistence(sessionManager, projectPath, threadId, turnId, timeoutMs = 0) {
|
|
874
|
+
const check = () => {
|
|
875
|
+
try {
|
|
876
|
+
if (typeof sessionManager?.checkSessionTurnPersisted === 'function') {
|
|
877
|
+
return sessionManager.checkSessionTurnPersisted(projectPath, threadId, 'codex', turnId, this.resolvedConfig.agentAid) === true;
|
|
878
|
+
}
|
|
879
|
+
return this.codexSessionFileAdapter.checkTurnPersisted(projectPath, threadId, turnId, this.resolvedConfig.agentAid);
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
logger.debug(`[CodexRunner] rollout persistence check failed: session=${threadId} turn=${turnId} `
|
|
883
|
+
+ `error=${error instanceof Error ? error.message : String(error)}`);
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
888
|
+
while (true) {
|
|
889
|
+
if (check())
|
|
890
|
+
return true;
|
|
891
|
+
const remaining = deadline - Date.now();
|
|
892
|
+
if (remaining <= 0)
|
|
893
|
+
return false;
|
|
894
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(10, remaining)));
|
|
895
|
+
}
|
|
896
|
+
}
|
|
641
897
|
cleanupProvisionalThread(sessionId, threadId) {
|
|
642
898
|
if (this.provisionalSessions.get(sessionId) !== threadId)
|
|
643
899
|
return;
|
|
@@ -681,8 +937,11 @@ export class CodexRunner {
|
|
|
681
937
|
}
|
|
682
938
|
}
|
|
683
939
|
resetAppServerClient() {
|
|
684
|
-
const
|
|
940
|
+
const clients = [this.appServerClient, this.fullAccessAppServerClient]
|
|
941
|
+
.filter((client) => !!client);
|
|
685
942
|
this.appServerClient = null;
|
|
943
|
+
this.fullAccessAppServerClient = null;
|
|
944
|
+
this.threadSubscriptions.clear();
|
|
686
945
|
this.threadExternalToolFingerprints.clear();
|
|
687
946
|
this.threadExternalToolBoundaries.clear();
|
|
688
947
|
for (const childThreadId of this.childThreadSessions.keys())
|
|
@@ -695,9 +954,11 @@ export class CodexRunner {
|
|
|
695
954
|
this.trackedApprovalItems.clear();
|
|
696
955
|
this.pendingToolDenials.clear();
|
|
697
956
|
this.threadDelegationCarriers.clear();
|
|
698
|
-
client
|
|
699
|
-
|
|
700
|
-
|
|
957
|
+
for (const client of clients) {
|
|
958
|
+
client.close().catch(error => {
|
|
959
|
+
logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
|
|
960
|
+
});
|
|
961
|
+
}
|
|
701
962
|
}
|
|
702
963
|
mergeThreadConfig(...configs) {
|
|
703
964
|
const merged = {};
|
|
@@ -943,21 +1204,18 @@ export class CodexRunner {
|
|
|
943
1204
|
}
|
|
944
1205
|
}
|
|
945
1206
|
// ── ModelSwitcher ──
|
|
946
|
-
setModel(model) { this.model = model; this.resetAppServerClient(); }
|
|
1207
|
+
setModel(model) { this.model = stripContextWindowSuffix(model); this.resetAppServerClient(); }
|
|
947
1208
|
getModel() { return this.model; }
|
|
1209
|
+
getModelCatalog() {
|
|
1210
|
+
return getRunnerCatalog('codex', this.resolvedConfig.agentAid, this.resolvedConfig);
|
|
1211
|
+
}
|
|
948
1212
|
async listModels() {
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
return ids;
|
|
956
|
-
}
|
|
957
|
-
catch (error) {
|
|
958
|
-
logger.debug(`[CodexRunner] app-server model/list failed, using catalog fallback: ${error}`);
|
|
959
|
-
}
|
|
960
|
-
return fetchCodexCatalog().map(m => m.slug);
|
|
1213
|
+
const catalog = await this.getModelCatalog();
|
|
1214
|
+
assertCatalogSelectable(catalog);
|
|
1215
|
+
return catalog.models.map(m => m.id);
|
|
1216
|
+
}
|
|
1217
|
+
async getAvailableEfforts(model) {
|
|
1218
|
+
return catalogEfforts(await this.getModelCatalog(), model, 'codex');
|
|
961
1219
|
}
|
|
962
1220
|
// ── Effort ──
|
|
963
1221
|
setEffort(effort) { this.effort = effort; this.resetAppServerClient(); }
|
|
@@ -1014,7 +1272,9 @@ export class CodexRunner {
|
|
|
1014
1272
|
if (!looksLikeGitMetadataDirectory(resolved))
|
|
1015
1273
|
return;
|
|
1016
1274
|
paths.add(resolved);
|
|
1017
|
-
paths
|
|
1275
|
+
// Keep exact paths native, but Codex requires POSIX separators for
|
|
1276
|
+
// recursive filesystem globs even on Windows.
|
|
1277
|
+
paths.add(`${resolved}/**`);
|
|
1018
1278
|
};
|
|
1019
1279
|
try {
|
|
1020
1280
|
const stat = fs.statSync(marker);
|
|
@@ -1028,7 +1288,7 @@ export class CodexRunner {
|
|
|
1028
1288
|
}
|
|
1029
1289
|
}
|
|
1030
1290
|
catch { }
|
|
1031
|
-
for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith(
|
|
1291
|
+
for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith('/**'))) {
|
|
1032
1292
|
try {
|
|
1033
1293
|
const commonDirectory = fs.readFileSync(path.join(gitDirectory, 'commondir'), 'utf8').trim();
|
|
1034
1294
|
if (commonDirectory)
|
|
@@ -1137,7 +1397,7 @@ export class CodexRunner {
|
|
|
1137
1397
|
return;
|
|
1138
1398
|
}
|
|
1139
1399
|
const input = [{ type: 'text', text, text_elements: [] }];
|
|
1140
|
-
const client = this.
|
|
1400
|
+
const client = this.getAppServerClientForThread(activeTurn.threadId);
|
|
1141
1401
|
if (typeof client.turnSteer !== 'function') {
|
|
1142
1402
|
logger.warn(`[CodexRunner] turn steer unavailable in app-server client: session=${sessionId}`);
|
|
1143
1403
|
return;
|
|
@@ -1165,7 +1425,28 @@ export class CodexRunner {
|
|
|
1165
1425
|
}
|
|
1166
1426
|
await appServer.assertManagedHooksAvailable();
|
|
1167
1427
|
}
|
|
1168
|
-
|
|
1428
|
+
/**
|
|
1429
|
+
* Decide whether the managed hook may proceed with a tool call.
|
|
1430
|
+
*
|
|
1431
|
+
* Credential contract (see CODEX_SESSION_TOKEN_ENV):
|
|
1432
|
+
* - sessionToken present and issued by this runner for this thread → the
|
|
1433
|
+
* call belongs to an EvolCore-managed thread; full policy evaluation.
|
|
1434
|
+
* - sessionToken present but unknown/mismatched → forgery or stale state;
|
|
1435
|
+
* fail closed with applicable=true so no other outcome allows it.
|
|
1436
|
+
* - sessionToken absent → unmanaged process; not this runner's concern
|
|
1437
|
+
* (applicable=false; the IPC layer allows such calls outright).
|
|
1438
|
+
*/
|
|
1439
|
+
async evaluatePreToolUse(threadId, toolName, toolInput, signal, callId, sessionToken) {
|
|
1440
|
+
if (typeof sessionToken === 'string' && sessionToken) {
|
|
1441
|
+
if (!this.validateThreadCredential(threadId, sessionToken)) {
|
|
1442
|
+
return {
|
|
1443
|
+
ok: false,
|
|
1444
|
+
applicable: true,
|
|
1445
|
+
decision: 'deny',
|
|
1446
|
+
reason: 'Codex session credential was not recognized',
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1169
1450
|
const sessionKey = this.findSessionKeyByThread(threadId);
|
|
1170
1451
|
const activeThread = this.activeSessions.get(sessionKey) ?? this.provisionalSessions.get(sessionKey);
|
|
1171
1452
|
const context = this.permissionContexts.get(sessionKey);
|
|
@@ -1213,6 +1494,16 @@ export class CodexRunner {
|
|
|
1213
1494
|
}
|
|
1214
1495
|
};
|
|
1215
1496
|
if (!activeThread || !this.isThreadOwnedBySession(sessionKey, threadId)) {
|
|
1497
|
+
if (typeof sessionToken === 'string' && sessionToken) {
|
|
1498
|
+
// A credentialed thread this runner cannot place (e.g. resumed state
|
|
1499
|
+
// lost across a runner reload) must never fall through to allow.
|
|
1500
|
+
return {
|
|
1501
|
+
ok: false,
|
|
1502
|
+
applicable: true,
|
|
1503
|
+
decision: 'deny',
|
|
1504
|
+
reason: 'EvolCore-managed Codex thread is not tracked by an active session',
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1216
1507
|
return { ok: true, applicable: false };
|
|
1217
1508
|
}
|
|
1218
1509
|
const proactiveSession = context?.chatmode === 'proactive';
|
|
@@ -1265,6 +1556,10 @@ export class CodexRunner {
|
|
|
1265
1556
|
const hadController = this.activeAbortControllers.has(key);
|
|
1266
1557
|
const hadTurn = this.activeTurns.has(key);
|
|
1267
1558
|
const provisionalThread = this.provisionalSessions.get(key);
|
|
1559
|
+
const subscribedThreadId = this.activeTurns.get(key)?.threadId
|
|
1560
|
+
?? this.activeSessions.get(key)
|
|
1561
|
+
?? provisionalThread;
|
|
1562
|
+
const subscription = subscribedThreadId ? this.threadSubscriptions.get(subscribedThreadId) : undefined;
|
|
1268
1563
|
this.activeStreams.delete(key);
|
|
1269
1564
|
this.activeAbortControllers.delete(key);
|
|
1270
1565
|
this.pendingInterrupts.delete(key);
|
|
@@ -1276,9 +1571,18 @@ export class CodexRunner {
|
|
|
1276
1571
|
}
|
|
1277
1572
|
this.chatModes.delete(key);
|
|
1278
1573
|
const release = this.activeOperationReleases.get(key);
|
|
1574
|
+
let releaseAfterSubscription;
|
|
1575
|
+
if (subscription) {
|
|
1576
|
+
releaseAfterSubscription = this.releaseThreadSubscription(subscription.client, subscribedThreadId, 'stream-cleanup');
|
|
1577
|
+
}
|
|
1279
1578
|
if (release) {
|
|
1280
1579
|
this.activeOperationReleases.delete(key);
|
|
1281
|
-
|
|
1580
|
+
if (releaseAfterSubscription) {
|
|
1581
|
+
void releaseAfterSubscription.then(release, release);
|
|
1582
|
+
}
|
|
1583
|
+
else {
|
|
1584
|
+
release();
|
|
1585
|
+
}
|
|
1282
1586
|
}
|
|
1283
1587
|
}
|
|
1284
1588
|
hasActiveStream(key) {
|
|
@@ -1440,7 +1744,7 @@ export class CodexRunner {
|
|
|
1440
1744
|
}
|
|
1441
1745
|
}
|
|
1442
1746
|
let resumingThread = !!agentSessionId;
|
|
1443
|
-
const callModel = modelOverride?.model || this.model;
|
|
1747
|
+
const callModel = stripContextWindowSuffix(modelOverride?.model || this.model);
|
|
1444
1748
|
const callEffort = modelOverride?.effortMode === 'model_default'
|
|
1445
1749
|
? undefined
|
|
1446
1750
|
: modelOverride?.effort ?? this.effort;
|
|
@@ -1455,7 +1759,7 @@ export class CodexRunner {
|
|
|
1455
1759
|
this.chatModes.set(sessionId, callMode);
|
|
1456
1760
|
const callApprovalPolicy = this.toApprovalPolicy(callMode);
|
|
1457
1761
|
const executionSandbox = this.buildExecutionSandboxOptions(sessionId, callMode, projectPath, modelOverride?.bootstrapAgentMdPath);
|
|
1458
|
-
const appServer = this.getAppServerClient();
|
|
1762
|
+
const appServer = this.getAppServerClient(callMode === 'fullaccess');
|
|
1459
1763
|
this.dropExpiredPendingInterrupt(sessionId);
|
|
1460
1764
|
const effectiveApprovalPolicy = callApprovalPolicy;
|
|
1461
1765
|
const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
|
|
@@ -1484,47 +1788,61 @@ export class CodexRunner {
|
|
|
1484
1788
|
approvalPolicy: effectiveApprovalPolicy,
|
|
1485
1789
|
approvalsReviewer: 'user',
|
|
1486
1790
|
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
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),
|
|
1791
|
+
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),
|
|
1488
1792
|
...(developerInstructions ? { developerInstructions } : {}),
|
|
1489
1793
|
};
|
|
1490
1794
|
logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
|
|
1491
1795
|
`role=${executionSandbox.decision.role ?? 'none'} sandbox=${executionSandbox.decision.state}`);
|
|
1492
1796
|
let threadResponse;
|
|
1797
|
+
let previousAppServer;
|
|
1493
1798
|
if (agentSessionId) {
|
|
1799
|
+
// A thread may have been loaded by the other permission-mode client.
|
|
1800
|
+
// Release that one thread before asking this client to resume it.
|
|
1801
|
+
previousAppServer = await this.prepareThreadSubscription(appServer, agentSessionId);
|
|
1494
1802
|
try {
|
|
1495
1803
|
threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
|
|
1496
1804
|
}
|
|
1497
1805
|
catch (error) {
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1806
|
+
if (isCodexActiveWriterError(error) && previousAppServer && previousAppServer !== appServer) {
|
|
1807
|
+
// Some Codex builds acknowledge unsubscribe before unloading the
|
|
1808
|
+
// runtime. Archive/unarchive is a synchronous per-thread teardown
|
|
1809
|
+
// and preserves the same persisted thread id for the retry.
|
|
1810
|
+
await this.archiveThreadForHandoff(previousAppServer, appServer, agentSessionId);
|
|
1811
|
+
threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
|
|
1812
|
+
}
|
|
1813
|
+
else {
|
|
1814
|
+
// A legacy main session can retain a provider ID after its rollout was
|
|
1815
|
+
// removed externally. It is safe to recover before input submission;
|
|
1816
|
+
// topic bindings remain authoritative and fail closed.
|
|
1817
|
+
if (topicBinding || !isCodexNoRolloutError(error))
|
|
1818
|
+
throw error;
|
|
1819
|
+
if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
|
|
1820
|
+
throw error;
|
|
1821
|
+
const missingThreadId = agentSessionId;
|
|
1822
|
+
const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
|
|
1823
|
+
if (cleared !== 'cleared' && cleared !== 'already_clear') {
|
|
1824
|
+
const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
|
|
1825
|
+
recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
|
|
1826
|
+
throw recoveryError;
|
|
1827
|
+
}
|
|
1828
|
+
if (this.activeSessions.get(sessionId) === missingThreadId)
|
|
1829
|
+
this.activeSessions.delete(sessionId);
|
|
1830
|
+
this.threadProjectPaths.delete(missingThreadId);
|
|
1831
|
+
this.threadDelegationCarriers.delete(missingThreadId);
|
|
1832
|
+
this.unbindExternalToolBoundary(missingThreadId);
|
|
1833
|
+
this.clearTrackedApprovalItemsForThread(missingThreadId);
|
|
1834
|
+
this.dropThreadCredential(missingThreadId);
|
|
1835
|
+
agentSessionId = undefined;
|
|
1836
|
+
resumingThread = false;
|
|
1837
|
+
knownDelegationCarrier = undefined;
|
|
1838
|
+
delegationCarrierCandidate = this.createDelegationCarrier();
|
|
1839
|
+
threadOptions = {
|
|
1840
|
+
...threadOptions,
|
|
1841
|
+
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),
|
|
1842
|
+
};
|
|
1843
|
+
logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
|
|
1844
|
+
threadResponse = await appServer.threadStart(projectPath, threadOptions);
|
|
1511
1845
|
}
|
|
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
1846
|
}
|
|
1529
1847
|
}
|
|
1530
1848
|
else {
|
|
@@ -1533,6 +1851,7 @@ export class CodexRunner {
|
|
|
1533
1851
|
let threadId = threadResponse.thread?.id || agentSessionId;
|
|
1534
1852
|
if (!threadId)
|
|
1535
1853
|
throw new Error('Codex app-server did not return a thread id');
|
|
1854
|
+
this.markThreadSubscribed(appServer, threadId);
|
|
1536
1855
|
if (!resumingThread) {
|
|
1537
1856
|
carrierToPublish = { threadId, carrier: delegationCarrierCandidate };
|
|
1538
1857
|
}
|
|
@@ -1559,6 +1878,9 @@ export class CodexRunner {
|
|
|
1559
1878
|
const forkedThreadId = forked.thread?.id;
|
|
1560
1879
|
if (!forkedThreadId)
|
|
1561
1880
|
throw new Error('Codex delegation-carrier recovery fork did not return a thread id');
|
|
1881
|
+
this.markThreadSubscribed(appServer, forkedThreadId);
|
|
1882
|
+
await this.releaseThreadSubscription(appServer, staleThreadId, 'carrier-recovery-fork-source');
|
|
1883
|
+
await this.releaseThreadSubscription(appServer, forkedThreadId, 'carrier-recovery-fork');
|
|
1562
1884
|
threadId = forkedThreadId;
|
|
1563
1885
|
recoveredCarrier = delegationCarrierCandidate;
|
|
1564
1886
|
}
|
|
@@ -1591,6 +1913,7 @@ export class CodexRunner {
|
|
|
1591
1913
|
if (!bindingAccepted) {
|
|
1592
1914
|
const error = new Error(`backend discovery rejected: ${bindingResult}`);
|
|
1593
1915
|
error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
|
|
1916
|
+
await this.releaseThreadSubscription(appServer, threadId, 'backend-binding-rejected');
|
|
1594
1917
|
throw error;
|
|
1595
1918
|
}
|
|
1596
1919
|
this.activeSessions.set(sessionId, threadId);
|
|
@@ -1614,6 +1937,7 @@ export class CodexRunner {
|
|
|
1614
1937
|
projectPath,
|
|
1615
1938
|
model: callModel,
|
|
1616
1939
|
committed: resumingThread,
|
|
1940
|
+
persistenceFinalized: resumingThread,
|
|
1617
1941
|
streamedAgentMessageIds: new Set(),
|
|
1618
1942
|
agentMessageDeltaText: new Map(),
|
|
1619
1943
|
completedItemIds: new Set(),
|
|
@@ -1633,7 +1957,7 @@ export class CodexRunner {
|
|
|
1633
1957
|
&& this.childThreadSessions.get(notifThreadId) === sessionId;
|
|
1634
1958
|
if (notifThreadId !== undefined && notifThreadId !== threadId && !emittedByChild)
|
|
1635
1959
|
return;
|
|
1636
|
-
this.trackSubagentLifecycle(notification, state);
|
|
1960
|
+
this.trackSubagentLifecycle(notification, state, appServer);
|
|
1637
1961
|
if (emittedByChild && notifThreadId !== threadId) {
|
|
1638
1962
|
const childTurnId = this.extractTurnId(notification);
|
|
1639
1963
|
if (notification.method === 'turn/started' && childTurnId) {
|
|
@@ -1649,11 +1973,13 @@ export class CodexRunner {
|
|
|
1649
1973
|
this.activeSubagentTurns.delete(notifThreadId);
|
|
1650
1974
|
}
|
|
1651
1975
|
state.activeSubagentThreads.delete(notifThreadId);
|
|
1976
|
+
void this.releaseThreadSubscription(appServer, notifThreadId, 'subagent-turn-finished');
|
|
1652
1977
|
this.unregisterChildThread(notifThreadId);
|
|
1653
1978
|
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
1654
1979
|
}
|
|
1655
|
-
if (this.isSubagentActivityItem(item))
|
|
1980
|
+
if (this.isSubagentActivityItem(item) || isCodexReasoningNotification(notification.method)) {
|
|
1656
1981
|
queue.push(notification);
|
|
1982
|
+
}
|
|
1657
1983
|
if (notification.method === 'item/completed' && state.turnCompleted) {
|
|
1658
1984
|
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
1659
1985
|
}
|
|
@@ -1689,7 +2015,7 @@ export class CodexRunner {
|
|
|
1689
2015
|
if (!resumingThread)
|
|
1690
2016
|
this.cleanupProvisionalThread(sessionId, threadId);
|
|
1691
2017
|
logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
|
|
1692
|
-
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
|
|
2018
|
+
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
|
|
1693
2019
|
}
|
|
1694
2020
|
try {
|
|
1695
2021
|
const turnResponse = await appServer.turnStart(threadId, input, {
|
|
@@ -1700,11 +2026,43 @@ export class CodexRunner {
|
|
|
1700
2026
|
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
1701
2027
|
});
|
|
1702
2028
|
if (!resumingThread) {
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
if (committed)
|
|
1706
|
-
|
|
1707
|
-
|
|
2029
|
+
let confirmationInFlight;
|
|
2030
|
+
state.confirmProvisional = (waitMs = 0) => {
|
|
2031
|
+
if (state.committed)
|
|
2032
|
+
return Promise.resolve(true);
|
|
2033
|
+
if (state.persistenceFinalized)
|
|
2034
|
+
return Promise.resolve(false);
|
|
2035
|
+
if (confirmationInFlight)
|
|
2036
|
+
return confirmationInFlight;
|
|
2037
|
+
const providerTurnId = state.turnId;
|
|
2038
|
+
if (!providerTurnId)
|
|
2039
|
+
return Promise.resolve(false);
|
|
2040
|
+
const confirmation = (async () => {
|
|
2041
|
+
const persisted = await this.waitForCodexTurnPersistence(sessionManager, projectPath, threadId, providerTurnId, waitMs);
|
|
2042
|
+
if (!persisted)
|
|
2043
|
+
return false;
|
|
2044
|
+
try {
|
|
2045
|
+
const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
|
|
2046
|
+
if (!committed) {
|
|
2047
|
+
const staleError = new Error('Codex provisional thread was superseded before persistence confirmation');
|
|
2048
|
+
staleError.code = 'RUNNER_START_SUPERSEDED';
|
|
2049
|
+
throw staleError;
|
|
2050
|
+
}
|
|
2051
|
+
agentSessionId = threadId;
|
|
2052
|
+
state.committed = true;
|
|
2053
|
+
return true;
|
|
2054
|
+
}
|
|
2055
|
+
catch (error) {
|
|
2056
|
+
state.persistenceFinalized = true;
|
|
2057
|
+
throw error;
|
|
2058
|
+
}
|
|
2059
|
+
})();
|
|
2060
|
+
confirmationInFlight = confirmation;
|
|
2061
|
+
void confirmation.finally(() => {
|
|
2062
|
+
if (confirmationInFlight === confirmation)
|
|
2063
|
+
confirmationInFlight = undefined;
|
|
2064
|
+
}).catch(() => undefined);
|
|
2065
|
+
return confirmation;
|
|
1708
2066
|
};
|
|
1709
2067
|
}
|
|
1710
2068
|
const turnId = turnResponse.turn?.id;
|
|
@@ -1712,6 +2070,12 @@ export class CodexRunner {
|
|
|
1712
2070
|
state.turnId = turnId;
|
|
1713
2071
|
this.activeTurns.set(sessionId, { threadId, turnId });
|
|
1714
2072
|
}
|
|
2073
|
+
if (!resumingThread && turnId) {
|
|
2074
|
+
// Check immediately, but leave a just-created rollout as a candidate.
|
|
2075
|
+
// Subsequent notifications recheck it and the terminal boundary gets
|
|
2076
|
+
// the short settle window below.
|
|
2077
|
+
await state.confirmProvisional?.(0);
|
|
2078
|
+
}
|
|
1715
2079
|
if (turnId && this.consumePendingInterrupt(sessionId)) {
|
|
1716
2080
|
await this.interruptAppServerTurn(threadId, turnId);
|
|
1717
2081
|
controller.abort('User interrupt');
|
|
@@ -1721,7 +2085,7 @@ export class CodexRunner {
|
|
|
1721
2085
|
}
|
|
1722
2086
|
const status = turnResponse.turn?.status;
|
|
1723
2087
|
if (status === 'completed' || status === 'failed') {
|
|
1724
|
-
this.primeSubagentStateFromTurn(turnResponse.turn, state);
|
|
2088
|
+
this.primeSubagentStateFromTurn(turnResponse.turn, state, appServer);
|
|
1725
2089
|
queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
|
|
1726
2090
|
state.turnCompleted = true;
|
|
1727
2091
|
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
@@ -1729,6 +2093,7 @@ export class CodexRunner {
|
|
|
1729
2093
|
}
|
|
1730
2094
|
catch (error) {
|
|
1731
2095
|
unsubscribe();
|
|
2096
|
+
await this.releaseThreadSubscription(appServer, threadId, 'turn-start-failed');
|
|
1732
2097
|
this.activeAbortControllers.delete(sessionId);
|
|
1733
2098
|
this.activeTurns.delete(sessionId);
|
|
1734
2099
|
this.pendingInterrupts.delete(sessionId);
|
|
@@ -1737,7 +2102,7 @@ export class CodexRunner {
|
|
|
1737
2102
|
this.cleanupProvisionalThread(sessionId, threadId);
|
|
1738
2103
|
throw error;
|
|
1739
2104
|
}
|
|
1740
|
-
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
|
|
2105
|
+
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
|
|
1741
2106
|
}
|
|
1742
2107
|
// ── Interrupt ──
|
|
1743
2108
|
async interrupt(sessionKey) {
|
|
@@ -1789,7 +2154,12 @@ export class CodexRunner {
|
|
|
1789
2154
|
}
|
|
1790
2155
|
async interruptAppServerTurn(threadId, turnId) {
|
|
1791
2156
|
try {
|
|
1792
|
-
|
|
2157
|
+
const sessionId = this.findSessionKeyByThread(threadId);
|
|
2158
|
+
const subscribed = this.threadSubscriptions.get(threadId);
|
|
2159
|
+
const client = subscribed
|
|
2160
|
+
? subscribed.client
|
|
2161
|
+
: this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
|
|
2162
|
+
await client.turnInterrupt(threadId, turnId);
|
|
1793
2163
|
}
|
|
1794
2164
|
catch (error) {
|
|
1795
2165
|
logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
|
|
@@ -1853,11 +2223,21 @@ export class CodexRunner {
|
|
|
1853
2223
|
await this.interruptSubagentTurns(sessionId);
|
|
1854
2224
|
}
|
|
1855
2225
|
capturedController?.abort('Session closed');
|
|
2226
|
+
const capturedSubscription = capturedThreadId ? this.threadSubscriptions.get(capturedThreadId) : undefined;
|
|
2227
|
+
const currentActiveThreadId = this.activeSessions.get(sessionId);
|
|
2228
|
+
const currentProvisionalThreadId = this.provisionalSessions.get(sessionId);
|
|
2229
|
+
const capturedThreadIds = new Set([capturedThreadId, capturedProvisionalThreadId].filter((value) => value !== undefined));
|
|
2230
|
+
const threadLifecycleReplaced = (currentActiveThreadId !== undefined && !capturedThreadIds.has(currentActiveThreadId))
|
|
2231
|
+
|| (currentProvisionalThreadId !== undefined && !capturedThreadIds.has(currentProvisionalThreadId));
|
|
1856
2232
|
const lifecycleReplaced = (capturedStream !== undefined && this.activeStreams.get(sessionId) !== capturedStream)
|
|
1857
2233
|
|| (capturedController !== undefined && this.activeAbortControllers.get(sessionId) !== capturedController)
|
|
1858
2234
|
|| (capturedTurn !== undefined && this.activeTurns.get(sessionId) !== capturedTurn)
|
|
1859
2235
|
|| (capturedPermissionContext !== undefined && this.permissionContexts.get(sessionId) !== capturedPermissionContext)
|
|
1860
|
-
||
|
|
2236
|
+
|| threadLifecycleReplaced;
|
|
2237
|
+
if (capturedSubscription && !lifecycleReplaced
|
|
2238
|
+
&& this.threadSubscriptions.get(capturedThreadId) === capturedSubscription) {
|
|
2239
|
+
await this.releaseThreadSubscription(capturedSubscription.client, capturedThreadId, 'session-closed');
|
|
2240
|
+
}
|
|
1861
2241
|
if (capturedStream !== undefined && this.activeStreams.get(sessionId) === capturedStream)
|
|
1862
2242
|
this.activeStreams.delete(sessionId);
|
|
1863
2243
|
if (capturedController !== undefined && this.activeAbortControllers.get(sessionId) === capturedController)
|
|
@@ -1890,7 +2270,7 @@ export class CodexRunner {
|
|
|
1890
2270
|
}
|
|
1891
2271
|
}
|
|
1892
2272
|
resolveSessionFile(agentSessionId, _projectPath) {
|
|
1893
|
-
// Codex
|
|
2273
|
+
// Codex sessions are shared with the local Codex Desktop installation.
|
|
1894
2274
|
const sessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
|
1895
2275
|
if (!fs.existsSync(sessionsDir))
|
|
1896
2276
|
return null;
|
|
@@ -1913,6 +2293,10 @@ export class CodexRunner {
|
|
|
1913
2293
|
async clearSession(sessionId, _agentSessionId, _projectPath) {
|
|
1914
2294
|
// Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
|
|
1915
2295
|
const threadId = this.activeSessions.get(sessionId) ?? _agentSessionId;
|
|
2296
|
+
const subscription = this.threadSubscriptions.get(threadId);
|
|
2297
|
+
if (subscription) {
|
|
2298
|
+
await this.releaseThreadSubscription(subscription.client, threadId, 'session-cleared');
|
|
2299
|
+
}
|
|
1916
2300
|
this.activeSessions.delete(sessionId);
|
|
1917
2301
|
this.activeTurns.delete(sessionId);
|
|
1918
2302
|
this.steeringTails.delete(sessionId);
|
|
@@ -1933,8 +2317,12 @@ export class CodexRunner {
|
|
|
1933
2317
|
return result;
|
|
1934
2318
|
};
|
|
1935
2319
|
const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId, _sessionId), 'compact');
|
|
2320
|
+
let compactAppServer;
|
|
1936
2321
|
try {
|
|
1937
|
-
const
|
|
2322
|
+
const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
|
|
2323
|
+
const appServer = this.getAppServerClient(compactMode === 'fullaccess');
|
|
2324
|
+
compactAppServer = appServer;
|
|
2325
|
+
await this.prepareThreadSubscription(appServer, agentSessionId);
|
|
1938
2326
|
this.onCompactStart?.(_sessionId);
|
|
1939
2327
|
try {
|
|
1940
2328
|
await this.startAndWaitForCompact(appServer, agentSessionId);
|
|
@@ -1944,8 +2332,7 @@ export class CodexRunner {
|
|
|
1944
2332
|
if (!this.isThreadNotFoundError(error))
|
|
1945
2333
|
throw error;
|
|
1946
2334
|
logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
|
|
1947
|
-
const
|
|
1948
|
-
const compactModel = modelOverride?.model || this.model;
|
|
2335
|
+
const compactModel = stripContextWindowSuffix(modelOverride?.model || this.model);
|
|
1949
2336
|
const compactEffort = modelOverride?.effortMode === 'model_default'
|
|
1950
2337
|
? undefined
|
|
1951
2338
|
: modelOverride?.effort ?? this.effort;
|
|
@@ -1965,8 +2352,9 @@ export class CodexRunner {
|
|
|
1965
2352
|
approvalPolicy: compactPolicy,
|
|
1966
2353
|
approvalsReviewer: 'user',
|
|
1967
2354
|
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
1968
|
-
config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
|
|
2355
|
+
config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier, undefined, agentSessionId), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
|
|
1969
2356
|
});
|
|
2357
|
+
this.markThreadSubscribed(appServer, agentSessionId);
|
|
1970
2358
|
this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
|
|
1971
2359
|
this.threadDelegationCarriers.set(agentSessionId, compactDelegationCarrier);
|
|
1972
2360
|
this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
|
|
@@ -1979,6 +2367,9 @@ export class CodexRunner {
|
|
|
1979
2367
|
return finish({ ok: false, code: 'sdk_error', message, durationMs: Date.now() - startedAt });
|
|
1980
2368
|
}
|
|
1981
2369
|
finally {
|
|
2370
|
+
if (compactAppServer) {
|
|
2371
|
+
await this.releaseThreadSubscription(compactAppServer, agentSessionId, 'compact-finished');
|
|
2372
|
+
}
|
|
1982
2373
|
release();
|
|
1983
2374
|
}
|
|
1984
2375
|
}
|
|
@@ -2070,10 +2461,14 @@ export class CodexRunner {
|
|
|
2070
2461
|
}
|
|
2071
2462
|
return false;
|
|
2072
2463
|
}
|
|
2464
|
+
codexHomeDir() {
|
|
2465
|
+
return process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
|
|
2466
|
+
}
|
|
2073
2467
|
findCodexSessionFile(threadId) {
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2468
|
+
return this.findRolloutFile(path.join(this.codexHomeDir(), 'sessions'), threadId)
|
|
2469
|
+
?? this.findRolloutFile(path.join(this.codexHomeDir(), 'archived_sessions'), threadId);
|
|
2470
|
+
}
|
|
2471
|
+
findRolloutFile(root, threadId) {
|
|
2077
2472
|
const stack = [root];
|
|
2078
2473
|
let newest;
|
|
2079
2474
|
while (stack.length > 0) {
|
|
@@ -2105,6 +2500,101 @@ export class CodexRunner {
|
|
|
2105
2500
|
}
|
|
2106
2501
|
return newest?.path;
|
|
2107
2502
|
}
|
|
2503
|
+
// ── Managed-hook thread credentials ─────────────────────────────────
|
|
2504
|
+
// The Windows managed hook is machine-wide, so unmanaged Codex processes
|
|
2505
|
+
// also reach it. Credentials let the hook tell EvolCore-owned threads
|
|
2506
|
+
// (policy enforced) apart from unmanaged ones (allowed unconditionally).
|
|
2507
|
+
// They follow the thread (not the daemon process) so a daemon restart does
|
|
2508
|
+
// not strand a resumable thread: the token file is re-read on resume, and
|
|
2509
|
+
// it is removed lazily once the rollout file itself disappears.
|
|
2510
|
+
threadCredentialsDir() {
|
|
2511
|
+
const aid = this.resolvedConfig.agentAid;
|
|
2512
|
+
if (!aid)
|
|
2513
|
+
return undefined;
|
|
2514
|
+
return path.join(agentSessionsDir(aid), 'codex-tokens');
|
|
2515
|
+
}
|
|
2516
|
+
threadCredentialPath(threadId) {
|
|
2517
|
+
const dir = this.threadCredentialsDir();
|
|
2518
|
+
return dir ? path.join(dir, `${encodeURIComponent(threadId)}.json`) : undefined;
|
|
2519
|
+
}
|
|
2520
|
+
/** Issue or re-read this agent's credential for a thread. */
|
|
2521
|
+
ensureThreadCredential(threadId) {
|
|
2522
|
+
const cached = this.threadCredentials.get(threadId);
|
|
2523
|
+
if (cached)
|
|
2524
|
+
return cached;
|
|
2525
|
+
const file = this.threadCredentialPath(threadId);
|
|
2526
|
+
if (!file)
|
|
2527
|
+
return undefined;
|
|
2528
|
+
try {
|
|
2529
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
2530
|
+
if (record && typeof record.token === 'string' && record.token && record.threadId === threadId) {
|
|
2531
|
+
this.threadCredentials.set(threadId, record.token);
|
|
2532
|
+
return record.token;
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
catch { /* missing or unreadable: fall through to issuance */ }
|
|
2536
|
+
const token = randomBytes(32).toString('hex');
|
|
2537
|
+
const record = {
|
|
2538
|
+
token,
|
|
2539
|
+
threadId,
|
|
2540
|
+
createdAt: new Date().toISOString(),
|
|
2541
|
+
rolloutPath: this.findCodexSessionFile(threadId),
|
|
2542
|
+
};
|
|
2543
|
+
try {
|
|
2544
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
2545
|
+
fs.writeFileSync(file, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
2546
|
+
this.threadCredentials.set(threadId, token);
|
|
2547
|
+
return token;
|
|
2548
|
+
}
|
|
2549
|
+
catch (error) {
|
|
2550
|
+
logger.warn(`[CodexRunner] failed to persist thread credential: thread=${threadId} error=${error instanceof Error ? error.message : String(error)}`);
|
|
2551
|
+
return undefined;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Validate a credential presented by the managed hook. Returns the matched
|
|
2556
|
+
* threadId when this runner issued the token; undefined otherwise. Token
|
|
2557
|
+
* records whose rollout file has disappeared are pruned lazily.
|
|
2558
|
+
*/
|
|
2559
|
+
validateThreadCredential(threadId, token) {
|
|
2560
|
+
const expected = this.ensureThreadCredentialLoaded(threadId);
|
|
2561
|
+
if (!expected || expected !== token)
|
|
2562
|
+
return false;
|
|
2563
|
+
const rollout = this.findCodexSessionFile(threadId);
|
|
2564
|
+
if (!rollout) {
|
|
2565
|
+
// The thread's rollout is gone: nothing left to protect, drop the record.
|
|
2566
|
+
this.dropThreadCredential(threadId);
|
|
2567
|
+
return false;
|
|
2568
|
+
}
|
|
2569
|
+
return true;
|
|
2570
|
+
}
|
|
2571
|
+
ensureThreadCredentialLoaded(threadId) {
|
|
2572
|
+
const cached = this.threadCredentials.get(threadId);
|
|
2573
|
+
if (cached)
|
|
2574
|
+
return cached;
|
|
2575
|
+
const file = this.threadCredentialPath(threadId);
|
|
2576
|
+
if (!file)
|
|
2577
|
+
return undefined;
|
|
2578
|
+
try {
|
|
2579
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
2580
|
+
if (record && typeof record.token === 'string' && record.token && record.threadId === threadId) {
|
|
2581
|
+
this.threadCredentials.set(threadId, record.token);
|
|
2582
|
+
return record.token;
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
catch { /* absent or unreadable */ }
|
|
2586
|
+
return undefined;
|
|
2587
|
+
}
|
|
2588
|
+
dropThreadCredential(threadId) {
|
|
2589
|
+
this.threadCredentials.delete(threadId);
|
|
2590
|
+
const file = this.threadCredentialPath(threadId);
|
|
2591
|
+
if (!file)
|
|
2592
|
+
return;
|
|
2593
|
+
try {
|
|
2594
|
+
fs.rmSync(file, { force: true });
|
|
2595
|
+
}
|
|
2596
|
+
catch { /* best effort */ }
|
|
2597
|
+
}
|
|
2108
2598
|
async forkSession(agentSessionId, projectPath, title) {
|
|
2109
2599
|
const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId), 'fork');
|
|
2110
2600
|
try {
|
|
@@ -2119,7 +2609,8 @@ export class CodexRunner {
|
|
|
2119
2609
|
const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
2120
2610
|
const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
|
|
2121
2611
|
const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
|
|
2122
|
-
const appServer = this.
|
|
2612
|
+
const appServer = this.threadSubscriptions.get(agentSessionId)?.client
|
|
2613
|
+
?? this.getAppServerClient(mode === 'fullaccess');
|
|
2123
2614
|
const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
|
|
2124
2615
|
if (sessionKey)
|
|
2125
2616
|
await this.assertProactiveHookAvailability(sessionKey, appServer);
|
|
@@ -2135,9 +2626,11 @@ export class CodexRunner {
|
|
|
2135
2626
|
const forkedThreadId = response.thread?.id;
|
|
2136
2627
|
if (!forkedThreadId)
|
|
2137
2628
|
throw new Error('Codex fork did not return a thread id');
|
|
2629
|
+
this.markThreadSubscribed(appServer, forkedThreadId);
|
|
2138
2630
|
this.threadDelegationCarriers.set(forkedThreadId, delegationCarrier);
|
|
2139
2631
|
this.threadProjectPaths.set(forkedThreadId, path.resolve(projectPath));
|
|
2140
2632
|
this.bindExternalToolBoundary(forkedThreadId, externalToolConfig);
|
|
2633
|
+
await this.releaseThreadSubscription(appServer, forkedThreadId, 'fork-finished');
|
|
2141
2634
|
return forkedThreadId;
|
|
2142
2635
|
}
|
|
2143
2636
|
async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
|
|
@@ -2167,14 +2660,14 @@ export class CodexRunner {
|
|
|
2167
2660
|
}
|
|
2168
2661
|
}
|
|
2169
2662
|
async setSessionName(agentSessionId, name) {
|
|
2170
|
-
return this.
|
|
2663
|
+
return this.getAppServerClientForThread(agentSessionId).threadSetName(agentSessionId, name);
|
|
2171
2664
|
}
|
|
2172
2665
|
async updateSessionMetadata(agentSessionId, metadata) {
|
|
2173
2666
|
const gitInfo = metadata?.gitInfo && typeof metadata.gitInfo === 'object' ? metadata.gitInfo : undefined;
|
|
2174
|
-
return this.
|
|
2667
|
+
return this.getAppServerClientForThread(agentSessionId).threadMetadataUpdate(agentSessionId, gitInfo);
|
|
2175
2668
|
}
|
|
2176
2669
|
async getSessionMessages(agentSessionId, projectPath) {
|
|
2177
|
-
const response = await this.
|
|
2670
|
+
const response = await this.getAppServerClientForThread(agentSessionId).threadRead(agentSessionId, true);
|
|
2178
2671
|
return this.mapThreadToSessionMessages(response, agentSessionId);
|
|
2179
2672
|
}
|
|
2180
2673
|
approvalItemKey(threadId, turnId, itemId) {
|
|
@@ -2390,6 +2883,31 @@ export class CodexRunner {
|
|
|
2390
2883
|
? 'FileChange'
|
|
2391
2884
|
: 'Bash';
|
|
2392
2885
|
const toolInput = this.buildPermissionInput(request.method, params);
|
|
2886
|
+
const approvalCallId = typeof (params.itemId ?? params.item_id ?? params.callId ?? params.call_id) === 'string'
|
|
2887
|
+
? String(params.itemId ?? params.item_id ?? params.callId ?? params.call_id)
|
|
2888
|
+
: undefined;
|
|
2889
|
+
const approvalRequestId = request.id !== undefined
|
|
2890
|
+
? String(request.id)
|
|
2891
|
+
: approvalCallId ?? `codex-approval-${crypto.randomUUID()}`;
|
|
2892
|
+
this.approvalLifecycleIds.set(request, approvalRequestId);
|
|
2893
|
+
const approvalContext = this.permissionContexts.get(sessionKey);
|
|
2894
|
+
auditPermissionLifecycle({
|
|
2895
|
+
phase: 'request',
|
|
2896
|
+
requestId: approvalRequestId,
|
|
2897
|
+
callId: approvalCallId,
|
|
2898
|
+
correlationId: approvalCallId,
|
|
2899
|
+
sessionId: sessionKey,
|
|
2900
|
+
agentAid: approvalContext?.selfAid,
|
|
2901
|
+
agentName: approvalContext?.agentName,
|
|
2902
|
+
permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
|
|
2903
|
+
toolName,
|
|
2904
|
+
decision: 'pending',
|
|
2905
|
+
decisionSource: 'approval',
|
|
2906
|
+
role: approvalContext?.role,
|
|
2907
|
+
taskId: approvalContext?.taskId,
|
|
2908
|
+
summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
|
|
2909
|
+
approvalMethod: request.method,
|
|
2910
|
+
});
|
|
2393
2911
|
// A trusted fullaccess run has already been explicitly authorized by the
|
|
2394
2912
|
// daemon owner. Do not route its approvals through ordinary capability,
|
|
2395
2913
|
// workspace, or unattended-trigger policy checks.
|
|
@@ -2406,6 +2924,10 @@ export class CodexRunner {
|
|
|
2406
2924
|
setImmediate(() => this.steerPolicyReason(sessionKey, 'fullaccess_context_untrusted', reason));
|
|
2407
2925
|
return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
2408
2926
|
}
|
|
2927
|
+
// Fullaccess uses the current task delegation token in its shell
|
|
2928
|
+
// environment, so it must not enter the ordinary workspace/carrier
|
|
2929
|
+
// approval path even if the app-server asks for an infrastructure
|
|
2930
|
+
// approval despite approvalPolicy=never.
|
|
2409
2931
|
const response = this.toAppServerApprovalResponse(request.method, 'allow', toolInput, false);
|
|
2410
2932
|
this.logAppServerApprovalAudit(request, sessionKey, toolName, 'allow', 'approval', undefined, 'trusted fullaccess execution');
|
|
2411
2933
|
return response;
|
|
@@ -2413,7 +2935,9 @@ export class CodexRunner {
|
|
|
2413
2935
|
// Keep the policy check in the approval path as a fail-closed fallback.
|
|
2414
2936
|
// Some app-server tool routes can reach approval without invoking the
|
|
2415
2937
|
// command hook; allowing here would bypass the proactive gate.
|
|
2416
|
-
const policyResult = this.
|
|
2938
|
+
const policyResult = (this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess'
|
|
2939
|
+
? undefined
|
|
2940
|
+
: this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
|
|
2417
2941
|
if (policyResult?.block) {
|
|
2418
2942
|
logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
|
|
2419
2943
|
`requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
|
|
@@ -2439,9 +2963,33 @@ export class CodexRunner {
|
|
|
2439
2963
|
}
|
|
2440
2964
|
const summary = this.summarizeAppServerRequest(request.method, params);
|
|
2441
2965
|
const reason = params.reason || params.decisionReason || undefined;
|
|
2966
|
+
const auditSummary = (summarizeToolInputForAudit(toolName, toolInput)
|
|
2967
|
+
|| summarizeToolInputForAudit('Bash', { command: summary })).slice(0, 512);
|
|
2968
|
+
const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
2442
2969
|
const workspacePath = this.resolvePermissionWorkspacePath(params);
|
|
2443
2970
|
if (!workspacePath) {
|
|
2444
2971
|
logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
|
|
2972
|
+
try {
|
|
2973
|
+
await permissionContext?.recordExecutionAnomaly?.({
|
|
2974
|
+
code: 'operation_blocked',
|
|
2975
|
+
severity: 'warning',
|
|
2976
|
+
phase: 'execution',
|
|
2977
|
+
occurredAt: Date.now(),
|
|
2978
|
+
toolName,
|
|
2979
|
+
...(request.id !== undefined ? { requestId: String(request.id) } : {}),
|
|
2980
|
+
policyCode: 'approval_workspace_unknown',
|
|
2981
|
+
decisionSource: 'infrastructure',
|
|
2982
|
+
agentAid: permissionContext?.selfAid,
|
|
2983
|
+
agentName: permissionContext?.agentName,
|
|
2984
|
+
sessionId: sessionKey,
|
|
2985
|
+
permissionMode: normalizeExecutionPermissionMode(sessionMode),
|
|
2986
|
+
summary: auditSummary,
|
|
2987
|
+
effect: 'operation_skipped',
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
catch (error) {
|
|
2991
|
+
logger.warn(`[CodexRunner] failed to record unknown-workspace approval denial: session=${sessionKey} error=${error instanceof Error ? error.message : String(error)}`);
|
|
2992
|
+
}
|
|
2445
2993
|
const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
2446
2994
|
this.rememberToolDenial(request, params, {
|
|
2447
2995
|
decision: 'deny',
|
|
@@ -2454,8 +3002,7 @@ export class CodexRunner {
|
|
|
2454
3002
|
return response;
|
|
2455
3003
|
}
|
|
2456
3004
|
const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
|
|
2457
|
-
const
|
|
2458
|
-
const logSummary = formatApprovalLogSummary(summary);
|
|
3005
|
+
const logSummary = formatApprovalLogSummary(auditSummary || summary);
|
|
2459
3006
|
logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${logSummary}`);
|
|
2460
3007
|
const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
|
|
2461
3008
|
|| request.method === 'execCommandApproval';
|
|
@@ -2567,6 +3114,16 @@ export class CodexRunner {
|
|
|
2567
3114
|
const params = (request.params || {});
|
|
2568
3115
|
const rawCallId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
|
|
2569
3116
|
const callId = typeof rawCallId === 'string' && rawCallId ? rawCallId : undefined;
|
|
3117
|
+
const toolInput = this.buildPermissionInput(request.method, params);
|
|
3118
|
+
const rawSummary = summarizeToolInputForAudit(toolName, toolInput);
|
|
3119
|
+
const auditSummary = (rawSummary
|
|
3120
|
+
|| summarizeToolInputForAudit('Bash', { command: this.summarizeAppServerRequest(request.method, params) })).slice(0, 512);
|
|
3121
|
+
const threadId = this.requestThreadId(params);
|
|
3122
|
+
const rawCwd = typeof toolInput.cwd === 'string' && toolInput.cwd
|
|
3123
|
+
? toolInput.cwd
|
|
3124
|
+
: typeof params.cwd === 'string' && params.cwd
|
|
3125
|
+
? params.cwd
|
|
3126
|
+
: undefined;
|
|
2570
3127
|
auditCodexApprovalDecision({
|
|
2571
3128
|
requestId: request.id !== undefined ? String(request.id) : undefined,
|
|
2572
3129
|
callId,
|
|
@@ -2581,9 +3138,56 @@ export class CodexRunner {
|
|
|
2581
3138
|
policyCode,
|
|
2582
3139
|
permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
|
|
2583
3140
|
role: context?.role,
|
|
3141
|
+
taskId: context?.taskId,
|
|
3142
|
+
summary: auditSummary,
|
|
3143
|
+
threadId,
|
|
3144
|
+
cwd: rawCwd,
|
|
2584
3145
|
reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
|
|
2585
3146
|
parseFailure,
|
|
2586
3147
|
});
|
|
3148
|
+
const lifecycleRequestId = request.id !== undefined
|
|
3149
|
+
? String(request.id)
|
|
3150
|
+
: this.approvalLifecycleIds.get(request)
|
|
3151
|
+
?? callId
|
|
3152
|
+
?? `codex-approval-${crypto.randomUUID()}`;
|
|
3153
|
+
auditPermissionLifecycle({
|
|
3154
|
+
phase: 'response',
|
|
3155
|
+
requestId: lifecycleRequestId,
|
|
3156
|
+
callId,
|
|
3157
|
+
correlationId: callId,
|
|
3158
|
+
sessionId: sessionKey,
|
|
3159
|
+
agentAid: context?.selfAid,
|
|
3160
|
+
agentName: context?.agentName,
|
|
3161
|
+
permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
|
|
3162
|
+
toolName,
|
|
3163
|
+
decision,
|
|
3164
|
+
decisionSource,
|
|
3165
|
+
policyCode,
|
|
3166
|
+
reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
|
|
3167
|
+
role: context?.role,
|
|
3168
|
+
taskId: context?.taskId,
|
|
3169
|
+
summary: auditSummary,
|
|
3170
|
+
approvalMethod: request.method,
|
|
3171
|
+
});
|
|
3172
|
+
auditPermissionLifecycle({
|
|
3173
|
+
phase: 'final',
|
|
3174
|
+
requestId: lifecycleRequestId,
|
|
3175
|
+
callId,
|
|
3176
|
+
correlationId: callId,
|
|
3177
|
+
sessionId: sessionKey,
|
|
3178
|
+
agentAid: context?.selfAid,
|
|
3179
|
+
agentName: context?.agentName,
|
|
3180
|
+
permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
|
|
3181
|
+
toolName,
|
|
3182
|
+
decision,
|
|
3183
|
+
decisionSource,
|
|
3184
|
+
policyCode,
|
|
3185
|
+
reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
|
|
3186
|
+
role: context?.role,
|
|
3187
|
+
taskId: context?.taskId,
|
|
3188
|
+
summary: auditSummary,
|
|
3189
|
+
approvalMethod: request.method,
|
|
3190
|
+
});
|
|
2587
3191
|
logger.info(`[CodexRunner] app-server approval audit ${JSON.stringify({
|
|
2588
3192
|
requestId: request.id !== undefined ? String(request.id) : undefined,
|
|
2589
3193
|
...(callId ? { callId, correlationId: callId } : {}),
|
|
@@ -2594,6 +3198,7 @@ export class CodexRunner {
|
|
|
2594
3198
|
decisionSource,
|
|
2595
3199
|
...(policyCode ? { policyCode } : {}),
|
|
2596
3200
|
...(parseFailure ? { parseFailure } : {}),
|
|
3201
|
+
...(auditSummary ? { summary: auditSummary } : {}),
|
|
2597
3202
|
reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
|
|
2598
3203
|
executed: false,
|
|
2599
3204
|
})}`);
|
|
@@ -3364,7 +3969,7 @@ export class CodexRunner {
|
|
|
3364
3969
|
async rollbackSessionTurnsLocked(agentSessionId, numTurns) {
|
|
3365
3970
|
if (numTurns < 1)
|
|
3366
3971
|
return true;
|
|
3367
|
-
const response = await this.
|
|
3972
|
+
const response = await this.getAppServerClientForThread(agentSessionId).threadRollback(agentSessionId, numTurns);
|
|
3368
3973
|
return !!response.thread;
|
|
3369
3974
|
}
|
|
3370
3975
|
async rewindFiles(agentSessionId, projectPath, userMessageId) {
|
|
@@ -3611,7 +4216,7 @@ export class CodexRunner {
|
|
|
3611
4216
|
this.unregisterChildThread(childThreadId);
|
|
3612
4217
|
}
|
|
3613
4218
|
}
|
|
3614
|
-
trackSubagentLifecycle(notification, state) {
|
|
4219
|
+
trackSubagentLifecycle(notification, state, appServer) {
|
|
3615
4220
|
const params = (notification.params || {});
|
|
3616
4221
|
const item = params.item && typeof params.item === 'object' ? params.item : undefined;
|
|
3617
4222
|
if (!this.isSubagentActivityItem(item))
|
|
@@ -3623,13 +4228,17 @@ export class CodexRunner {
|
|
|
3623
4228
|
if (kind === 'started') {
|
|
3624
4229
|
state.activeSubagentThreads.add(childThreadId);
|
|
3625
4230
|
this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
|
|
4231
|
+
if (appServer)
|
|
4232
|
+
this.markThreadSubscribed(appServer, childThreadId);
|
|
3626
4233
|
}
|
|
3627
4234
|
if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
|
|
3628
4235
|
state.activeSubagentThreads.delete(childThreadId);
|
|
4236
|
+
if (appServer)
|
|
4237
|
+
void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
|
|
3629
4238
|
this.unregisterChildThread(childThreadId);
|
|
3630
4239
|
}
|
|
3631
4240
|
}
|
|
3632
|
-
primeSubagentStateFromTurn(turn, state) {
|
|
4241
|
+
primeSubagentStateFromTurn(turn, state, appServer) {
|
|
3633
4242
|
for (const item of this.getTurnItems(turn)) {
|
|
3634
4243
|
if (!this.isSubagentActivityItem(item))
|
|
3635
4244
|
continue;
|
|
@@ -3640,9 +4249,13 @@ export class CodexRunner {
|
|
|
3640
4249
|
if (kind === 'started') {
|
|
3641
4250
|
state.activeSubagentThreads.add(childThreadId);
|
|
3642
4251
|
this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
|
|
4252
|
+
if (appServer)
|
|
4253
|
+
this.markThreadSubscribed(appServer, childThreadId);
|
|
3643
4254
|
}
|
|
3644
4255
|
else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
|
|
3645
4256
|
state.activeSubagentThreads.delete(childThreadId);
|
|
4257
|
+
if (appServer)
|
|
4258
|
+
void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
|
|
3646
4259
|
this.unregisterChildThread(childThreadId);
|
|
3647
4260
|
}
|
|
3648
4261
|
}
|
|
@@ -3657,7 +4270,7 @@ export class CodexRunner {
|
|
|
3657
4270
|
}
|
|
3658
4271
|
queue.end();
|
|
3659
4272
|
}
|
|
3660
|
-
async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, tempFiles) {
|
|
4273
|
+
async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, appServer, tempFiles) {
|
|
3661
4274
|
try {
|
|
3662
4275
|
let sessionIdEmitted = state.committed;
|
|
3663
4276
|
if (sessionIdEmitted)
|
|
@@ -3665,27 +4278,39 @@ export class CodexRunner {
|
|
|
3665
4278
|
for await (const notification of notifications) {
|
|
3666
4279
|
if (!this.activeAbortControllers.has(sessionId))
|
|
3667
4280
|
break;
|
|
3668
|
-
if (!state.committed &&
|
|
3669
|
-
const
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
state.
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
yield { type: 'session_id', sessionId: state.threadId };
|
|
3681
|
-
}
|
|
4281
|
+
if (!state.committed && !state.persistenceFinalized) {
|
|
4282
|
+
const terminal = notification.method === 'turn/completed';
|
|
4283
|
+
const committed = await state.confirmProvisional?.(terminal ? 250 : 0) ?? false;
|
|
4284
|
+
if (committed && !sessionIdEmitted) {
|
|
4285
|
+
sessionIdEmitted = true;
|
|
4286
|
+
yield { type: 'session_id', sessionId: state.threadId };
|
|
4287
|
+
}
|
|
4288
|
+
else if (terminal && !committed) {
|
|
4289
|
+
state.persistenceFinalized = true;
|
|
4290
|
+
const persistenceError = new Error(`Codex turn completed before its rollout became recoverable: thread=${state.threadId} turn=${state.turnId ?? '<unknown>'}`);
|
|
4291
|
+
persistenceError.code = 'CODEX_TURN_PERSISTENCE_UNCONFIRMED';
|
|
4292
|
+
throw persistenceError;
|
|
3682
4293
|
}
|
|
3683
4294
|
}
|
|
3684
4295
|
yield* this.mapAppServerNotification(notification, sessionId, state);
|
|
3685
4296
|
}
|
|
3686
4297
|
}
|
|
3687
4298
|
finally {
|
|
4299
|
+
if (!state.committed && !state.persistenceFinalized && state.confirmProvisional) {
|
|
4300
|
+
try {
|
|
4301
|
+
// Do not let an interrupt wait for persistence that did not exist at
|
|
4302
|
+
// cancellation time. A zero-wait check still captures a rollout
|
|
4303
|
+
// that was already durable before the stream was closed.
|
|
4304
|
+
await state.confirmProvisional(0);
|
|
4305
|
+
}
|
|
4306
|
+
catch (error) {
|
|
4307
|
+
logger.debug(`[CodexRunner] provisional thread rejected during final persistence check: `
|
|
4308
|
+
+ `session=${sessionId} thread=${state.threadId} error=${error instanceof Error ? error.message : String(error)}`);
|
|
4309
|
+
}
|
|
4310
|
+
state.persistenceFinalized = true;
|
|
4311
|
+
}
|
|
3688
4312
|
unsubscribe();
|
|
4313
|
+
await this.releaseThreadSubscription(appServer, state.threadId, 'turn-finished');
|
|
3689
4314
|
if (this.activeAbortControllers.get(sessionId) === controller) {
|
|
3690
4315
|
this.activeAbortControllers.delete(sessionId);
|
|
3691
4316
|
}
|
|
@@ -3730,6 +4355,15 @@ export class CodexRunner {
|
|
|
3730
4355
|
}
|
|
3731
4356
|
break;
|
|
3732
4357
|
}
|
|
4358
|
+
// Reasoning notifications are provider-internal liveness signals.
|
|
4359
|
+
// Do not expose their content; the ResponseEngine consumes the heartbeat
|
|
4360
|
+
// solely to reset the idle monitor.
|
|
4361
|
+
case 'item/reasoning/textDelta':
|
|
4362
|
+
case 'item/reasoning/summaryTextDelta':
|
|
4363
|
+
case 'item/reasoning/summaryPartAdded': {
|
|
4364
|
+
yield { type: 'heartbeat', source: 'reasoning' };
|
|
4365
|
+
break;
|
|
4366
|
+
}
|
|
3733
4367
|
case 'item/completed': {
|
|
3734
4368
|
const item = params.item;
|
|
3735
4369
|
if (item?.id)
|
|
@@ -4266,10 +4900,10 @@ export class CodexRunner {
|
|
|
4266
4900
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
4267
4901
|
throw new Error(`managed temp path is not a real directory: ${directory}`);
|
|
4268
4902
|
}
|
|
4269
|
-
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
4903
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
4270
4904
|
throw new Error(`managed temp path is owned by another uid: ${directory}`);
|
|
4271
4905
|
}
|
|
4272
|
-
if ((stat.mode & 0o077) !== 0)
|
|
4906
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
|
|
4273
4907
|
fs.chmodSync(directory, 0o700);
|
|
4274
4908
|
}
|
|
4275
4909
|
ensureSessionTempDir(sessionId) {
|
|
@@ -4341,12 +4975,17 @@ export class CodexRunner {
|
|
|
4341
4975
|
}
|
|
4342
4976
|
}
|
|
4343
4977
|
managedTempInstruction(sessionId) {
|
|
4344
|
-
|
|
4978
|
+
const managedTempDir = this.ensureSessionTempDir(sessionId);
|
|
4979
|
+
if (!managedTempDir)
|
|
4345
4980
|
return undefined;
|
|
4346
|
-
return
|
|
4981
|
+
return `临时文件必须使用环境变量 \`$TMPDIR\`。当前值是 \`${managedTempDir}\`,它是当前 EvolCore 会话唯一允许的临时工作区;不要直接使用 \`/tmp\`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 \`/tmp/evolcore-codex-*\` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 \`$TMPDIR\` 重新创建,再继续执行后续 \`ec\` 命令。`;
|
|
4347
4982
|
}
|
|
4348
|
-
buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
|
|
4983
|
+
buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier, taskDelegationToken, threadId) {
|
|
4349
4984
|
const managedTempDir = this.ensureSessionTempDir(sessionId);
|
|
4985
|
+
// The managed-hook credential rides the thread's shell environment so the
|
|
4986
|
+
// PreToolUse hook can prove the call belongs to an EvolCore-owned thread.
|
|
4987
|
+
// It follows the thread (start AND resume) rather than the daemon process.
|
|
4988
|
+
const sessionToken = threadId ? this.ensureThreadCredential(threadId) : undefined;
|
|
4350
4989
|
return {
|
|
4351
4990
|
shell_environment_policy: {
|
|
4352
4991
|
set: {
|
|
@@ -4354,7 +4993,10 @@ export class CodexRunner {
|
|
|
4354
4993
|
EVOLCORE_HOME: resolveRoot(),
|
|
4355
4994
|
...(managedTempDir ? { EVOLCORE_SESSION_RUNTIME_DIR: managedTempDir } : {}),
|
|
4356
4995
|
...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(path.dirname(managedTempDir), 'evolcore-locks') } : {}),
|
|
4357
|
-
...(
|
|
4996
|
+
...(taskDelegationToken || delegationCarrier
|
|
4997
|
+
? { [AGENT_DELEGATION_TOKEN_ENV]: taskDelegationToken ?? delegationCarrier }
|
|
4998
|
+
: {}),
|
|
4999
|
+
...(sessionToken ? { [CODEX_SESSION_TOKEN_ENV]: sessionToken } : {}),
|
|
4358
5000
|
...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
|
|
4359
5001
|
},
|
|
4360
5002
|
},
|
|
@@ -4384,6 +5026,8 @@ export class CodexRunner {
|
|
|
4384
5026
|
controller.abort('dispose');
|
|
4385
5027
|
}
|
|
4386
5028
|
await Promise.all([...this.activeSubagentTurns.entries()].map(([threadId, active]) => this.interruptAppServerTurn(threadId, active.turnId).catch(() => { })));
|
|
5029
|
+
await Promise.all([...this.threadSubscriptions.entries()].map(([threadId, subscription]) => this.releaseThreadSubscription(subscription.client, threadId, 'runner-dispose')));
|
|
5030
|
+
this.threadSubscriptions.clear();
|
|
4387
5031
|
this.activeAbortControllers.clear();
|
|
4388
5032
|
this.activeStreams.clear();
|
|
4389
5033
|
this.activeSessions.clear();
|
|
@@ -4404,8 +5048,13 @@ export class CodexRunner {
|
|
|
4404
5048
|
this.threadOperationLocks.clear();
|
|
4405
5049
|
for (const sessionId of [...this.sessionTempDirs.keys()])
|
|
4406
5050
|
this.cleanupSessionTempDir(sessionId);
|
|
4407
|
-
await
|
|
5051
|
+
await Promise.all([
|
|
5052
|
+
this.appServerClient?.close(),
|
|
5053
|
+
this.fullAccessAppServerClient?.close(),
|
|
5054
|
+
]);
|
|
5055
|
+
this.codexSessionFileAdapter.close();
|
|
4408
5056
|
this.appServerClient = null;
|
|
5057
|
+
this.fullAccessAppServerClient = null;
|
|
4409
5058
|
}
|
|
4410
5059
|
}
|
|
4411
5060
|
// ── Plugin ──
|
|
@@ -4414,8 +5063,6 @@ export class CodexAgentPlugin {
|
|
|
4414
5063
|
isEnabled(agent) {
|
|
4415
5064
|
if (!agent.config.baseagents?.codex)
|
|
4416
5065
|
return false;
|
|
4417
|
-
if (!isCodexAppServerAvailable())
|
|
4418
|
-
return false;
|
|
4419
5066
|
try {
|
|
4420
5067
|
const override = agent.config.baseagents.codex;
|
|
4421
5068
|
const syntheticConfig = { agents: { codex: override } };
|
|
@@ -4426,8 +5073,8 @@ export class CodexAgentPlugin {
|
|
|
4426
5073
|
return false;
|
|
4427
5074
|
}
|
|
4428
5075
|
}
|
|
4429
|
-
createAgent(agent, callbacks) {
|
|
4430
|
-
const availability =
|
|
5076
|
+
async createAgent(agent, callbacks) {
|
|
5077
|
+
const availability = await getCodexAppServerAvailabilityAsync();
|
|
4431
5078
|
if (!availability.available) {
|
|
4432
5079
|
throw new Error(availability.reason || 'Missing codex CLI with app-server');
|
|
4433
5080
|
}
|