evolcore 0.0.21 → 0.0.23
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/AGENTS.md +62 -0
- package/CHANGELOG.md +53 -0
- package/README.md +2 -0
- package/bin/codex-managed-acl.ps1 +155 -0
- package/bin/codex-managed-hook.mjs +3 -0
- package/bin/install-codex-managed-hooks.mjs +376 -96
- package/dist/agents/baseagent-availability.js +48 -0
- package/dist/agents/baseagent.js +1 -1
- package/dist/agents/claude-runner.js +44 -226
- package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +103 -17
- 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 +154 -0
- package/dist/agents/codex-runner.js +1292 -219
- package/dist/agents/ecagent-runner.js +10 -31
- package/dist/agents/gemini-runner.js +2 -6
- package/dist/aun/aid/store.js +2 -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 +51 -4
- package/dist/aun/outbox.js +7 -0
- package/dist/aun/rpc/client.js +8 -1
- package/dist/channels/aun.js +240 -36
- package/dist/channels/wechat.js +81 -10
- package/dist/cli/aun-commands.js +48 -4
- package/dist/cli/command-log.js +21 -2
- package/dist/cli/daemon-commands.js +169 -51
- package/dist/cli/index.js +20 -0
- package/dist/cli/init.js +2 -10
- package/dist/cli/menu-client.js +95 -0
- package/dist/cli/model.js +23 -8
- package/dist/cli/task-context.js +16 -2
- package/dist/cli/trigger-command.js +22 -5
- package/dist/config/config-field-policy.js +3 -0
- package/dist/config/config-manager.js +32 -5
- package/dist/config/contact-book-store.js +25 -3
- package/dist/config/schema-migration.js +14 -2
- package/dist/core/auth/agent-delegation.js +15 -0
- package/dist/core/auth/auth-gateway.js +8 -0
- package/dist/core/auth/authorization-audit.js +70 -6
- package/dist/core/auth/operation-catalog.js +4 -0
- package/dist/core/auth/remote-access-store.js +135 -0
- package/dist/core/bootstrap-messages.js +8 -0
- package/dist/core/bootstrap-service.js +93 -25
- package/dist/core/command/command-handler.js +62 -5
- package/dist/core/command/daemon-diagnostics.js +448 -0
- package/dist/core/command/menu-catalog.js +3 -0
- package/dist/core/command/menu-handler.js +150 -33
- package/dist/core/command/menu-protocol.js +120 -1
- package/dist/core/command/slash-gate.js +3 -3
- package/dist/core/command/slash-handler.js +83 -40
- package/dist/core/data-migration.js +11 -1
- package/dist/core/event-catalog.js +32 -0
- package/dist/core/handoff/runtime.js +23 -3
- package/dist/core/message/im-renderer.js +7 -3
- package/dist/core/message/message-bridge.js +60 -2
- package/dist/core/message/message-log.js +33 -0
- package/dist/core/message/message-queue.js +41 -15
- package/dist/core/message/response-engine.js +246 -114
- package/dist/core/model/catalog-service.js +308 -0
- package/dist/core/model/model-catalog.js +124 -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 +1 -1
- package/dist/core/permission/approval-gateway.js +16 -1
- package/dist/core/permission/ec-command-parser.js +272 -70
- package/dist/core/permission/protected-paths.js +74 -14
- package/dist/core/permission/sandbox-runtime.js +4 -2
- package/dist/core/permission/tool-error-code.js +12 -0
- package/dist/core/permission/tool-policy.js +290 -30
- package/dist/core/runtime-lock.js +13 -8
- package/dist/core/session/session-manager.js +30 -0
- package/dist/core/session/session-renew.js +18 -1
- package/dist/core/session/session-turn-coordinator.js +5 -1
- package/dist/index.js +104 -34
- package/dist/ipc.js +218 -96
- package/dist/paths.js +18 -0
- package/dist/response-system/engines/v1/proactive-flow.js +7 -2
- package/dist/stats/price-resolver.js +4 -0
- package/dist/trigger/feedback.js +14 -2
- package/dist/trigger/manager.js +3 -1
- package/dist/trigger/parser.js +86 -13
- package/dist/trigger/patch.js +104 -1
- package/dist/trigger/scheduler.js +20 -3
- package/dist/utils/ecweb-supervisor.js +11 -2
- package/dist/utils/ecweb-utils.js +8 -26
- package/dist/utils/error-utils.js +7 -0
- package/dist/utils/logger.js +9 -4
- package/dist/utils/npm-ops.js +47 -15
- package/dist/utils/tool-summary.js +59 -0
- package/dist/utils/windows-shell-trust.js +201 -0
- package/kits/docs/evolcore/INDEX.md +3 -2
- package/kits/docs/evolcore/agent-create.md +159 -0
- package/kits/docs/evolcore/agent.md +6 -0
- package/kits/docs/evolcore/group-collaboration.md +256 -0
- package/kits/docs/evolcore/group-rules.md +1 -19
- package/kits/docs/evolcore/group.md +3 -1
- package/kits/docs/evolcore/menu-client.md +34 -0
- package/kits/docs/evolcore/trigger.md +6 -3
- package/kits/docs/prompt-loading-architecture.md +6 -0
- package/kits/eck_message_manifest.json +6 -6
- 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/schemas/_meta.json +3 -2
- package/kits/schemas/agent-config.schema.12.json +427 -0
- package/kits/templates/message-fragments/item.md +1 -1
- package/kits/templates/roles/visitor.json +1 -0
- package/kits/templates/system-fragments/bootstrap.md +10 -2
- package/kits/templates/system-fragments/commands.md +2 -2
- package/package.json +11 -3
|
@@ -1,16 +1,10 @@
|
|
|
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
|
|
7
|
+
import { CODEX_THREAD_ACTIVE_WRITER, CodexAppServerClient, } from './codex/app-server-client.js';
|
|
14
8
|
import { resolveOpenaiConfig } from './baseagent.js';
|
|
15
9
|
import { logger } from '../utils/logger.js';
|
|
16
10
|
import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
|
|
@@ -21,7 +15,7 @@ import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from
|
|
|
21
15
|
import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
|
|
22
16
|
import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
|
|
23
17
|
import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
|
|
24
|
-
import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, parseCodexToolCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
|
|
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
20
|
import { resolvePaths, resolveRoot } from '../paths.js';
|
|
27
21
|
import { ensureProcessManagedTempDir, getManagedTaskTempDir } from '../cli/task-context.js';
|
|
@@ -47,6 +41,7 @@ export function formatApprovalLogSummary(summary) {
|
|
|
47
41
|
return singleLine;
|
|
48
42
|
return singleLine.slice(0, APPROVAL_LOG_SUMMARY_MAX_LENGTH - 3).trimEnd() + '...';
|
|
49
43
|
}
|
|
44
|
+
// ── Codex 模型目录(动态获取,含 effort) ──
|
|
50
45
|
class AsyncEventQueue {
|
|
51
46
|
queue = [];
|
|
52
47
|
done = false;
|
|
@@ -83,6 +78,18 @@ class AsyncEventQueue {
|
|
|
83
78
|
}
|
|
84
79
|
}
|
|
85
80
|
}
|
|
81
|
+
function errorCodeForToolDenial(decisionSource, policyCode) {
|
|
82
|
+
if (decisionSource === 'policy')
|
|
83
|
+
return 'POLICY_DENIED';
|
|
84
|
+
if (decisionSource === 'approval')
|
|
85
|
+
return 'USER_DENIED';
|
|
86
|
+
if (policyCode && /delegation|carrier/i.test(policyCode))
|
|
87
|
+
return 'DELEGATION_FAILED';
|
|
88
|
+
if (policyCode && /argument|canonical|quote|shell/i.test(policyCode))
|
|
89
|
+
return 'INVALID_ARGUMENT';
|
|
90
|
+
return 'EXECUTION_FAILED';
|
|
91
|
+
}
|
|
92
|
+
const CODEX_THREAD_HANDOFF_TIMEOUT_MS = 2_000;
|
|
86
93
|
const managedTempDirsForExitCleanup = new Map();
|
|
87
94
|
let managedTempExitCleanupRegistered = false;
|
|
88
95
|
function isTrustedManagedTempDirectory(directory, parent) {
|
|
@@ -98,9 +105,9 @@ function isTrustedManagedTempDirectory(directory, parent) {
|
|
|
98
105
|
const stat = fs.lstatSync(candidate);
|
|
99
106
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
100
107
|
return false;
|
|
101
|
-
if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
|
|
108
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid())
|
|
102
109
|
return false;
|
|
103
|
-
return (stat.mode & 0o077) === 0;
|
|
110
|
+
return process.platform === 'win32' || (stat.mode & 0o077) === 0;
|
|
104
111
|
};
|
|
105
112
|
if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
|
|
106
113
|
return false;
|
|
@@ -150,15 +157,74 @@ function stableSecurityValue(value) {
|
|
|
150
157
|
function externalToolConfigFingerprint(config) {
|
|
151
158
|
return createHash('sha256').update(stableSecurityValue(config)).digest('hex');
|
|
152
159
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Codex can surface a managed hook rejection as item output instead of an
|
|
162
|
+
* app-server approval request. Preserve the policy boundary in that path too
|
|
163
|
+
* so downstream audit/event consumers never infer a successful execution.
|
|
164
|
+
*/
|
|
165
|
+
function parseManagedPreToolUseDenial(value) {
|
|
166
|
+
let text;
|
|
167
|
+
if (typeof value === 'string')
|
|
168
|
+
text = value;
|
|
169
|
+
else if (value == null)
|
|
170
|
+
return undefined;
|
|
171
|
+
else {
|
|
172
|
+
try {
|
|
173
|
+
text = JSON.stringify(value) ?? '';
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
text = String(value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const marker = /(?:Command|Tool call) blocked by PreToolUse hook\s*:\s*/i.exec(text);
|
|
180
|
+
if (!marker)
|
|
181
|
+
return undefined;
|
|
182
|
+
let reason = text.slice((marker.index ?? 0) + marker[0].length)
|
|
183
|
+
.replace(/\s*\.\s*(?:Command|Tool):[\s\S]*$/i, '')
|
|
184
|
+
.replace(/(?:^|\s)(?:Command|Tool):[\s\S]*$/i, '')
|
|
185
|
+
.trim();
|
|
186
|
+
if (!reason)
|
|
187
|
+
reason = 'EvolCore PreToolUse policy denied this tool call';
|
|
188
|
+
const policyCode = /工具调用已达到\s*\d+\s*次/.test(reason)
|
|
189
|
+
? 'proactive_tool_report_required'
|
|
190
|
+
: /请先用\s+ec\s+(?:group\s+send|msg\s+send)/.test(reason)
|
|
191
|
+
? 'proactive_first_send_required'
|
|
192
|
+
: 'managed_pre_tool_use_denied';
|
|
193
|
+
return { policyCode, reason };
|
|
194
|
+
}
|
|
195
|
+
function outputTextForManagedHook(value) {
|
|
196
|
+
if (typeof value === 'string')
|
|
197
|
+
return value;
|
|
198
|
+
if (value == null)
|
|
199
|
+
return '';
|
|
200
|
+
if (Array.isArray(value))
|
|
201
|
+
return value.map(outputTextForManagedHook).filter(Boolean).join('\n');
|
|
202
|
+
if (typeof value === 'object') {
|
|
203
|
+
const record = value;
|
|
204
|
+
if (typeof record.text === 'string')
|
|
205
|
+
return record.text;
|
|
206
|
+
if (typeof record.message === 'string')
|
|
207
|
+
return record.message;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.stringify(value) ?? '';
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return String(value);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return String(value);
|
|
216
|
+
}
|
|
160
217
|
const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
|
|
161
|
-
|
|
218
|
+
const CODEX_NO_ROLLOUT_RE = /no rollout found for thread id/i;
|
|
219
|
+
const CODEX_ACTIVE_WRITER_RE = /already has an active writer/i;
|
|
220
|
+
function isCodexNoRolloutError(error) {
|
|
221
|
+
return error?.code === 'CODEX_ROLLOUT_NOT_FOUND'
|
|
222
|
+
|| CODEX_NO_ROLLOUT_RE.test(error instanceof Error ? error.message : String(error));
|
|
223
|
+
}
|
|
224
|
+
function isCodexActiveWriterError(error) {
|
|
225
|
+
return error?.code === CODEX_THREAD_ACTIVE_WRITER
|
|
226
|
+
|| CODEX_ACTIVE_WRITER_RE.test(error instanceof Error ? error.message : String(error));
|
|
227
|
+
}
|
|
162
228
|
// The permission bridge is version-locked to the audited approval schema and
|
|
163
229
|
// managed PreToolUse hook behavior used by proactive first-tool enforcement.
|
|
164
230
|
export const MIN_CODEX_CLI_VERSION = '0.144.1';
|
|
@@ -213,37 +279,6 @@ export function getCodexAppServerAvailability() {
|
|
|
213
279
|
export function isCodexAppServerAvailable() {
|
|
214
280
|
return getCodexAppServerAvailability().available;
|
|
215
281
|
}
|
|
216
|
-
function fetchCodexCatalog() {
|
|
217
|
-
if (codexCatalogCache)
|
|
218
|
-
return codexCatalogCache;
|
|
219
|
-
try {
|
|
220
|
-
const output = execCodexCliSync(['debug', 'models'], {
|
|
221
|
-
encoding: 'utf-8',
|
|
222
|
-
timeout: 5000,
|
|
223
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
224
|
-
});
|
|
225
|
-
const catalog = JSON.parse(output);
|
|
226
|
-
const models = catalog.models
|
|
227
|
-
.filter(m => m.visibility === 'list')
|
|
228
|
-
.map(m => ({
|
|
229
|
-
slug: m.slug,
|
|
230
|
-
efforts: (m.supported_reasoning_levels || []).map(l => l.effort),
|
|
231
|
-
}));
|
|
232
|
-
if (models.length > 0) {
|
|
233
|
-
codexCatalogCache = models;
|
|
234
|
-
return models;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
catch (e) {
|
|
238
|
-
logger.debug(`[CodexRunner] Failed to fetch model catalog, using fallback: ${e}`);
|
|
239
|
-
}
|
|
240
|
-
return CODEX_CATALOG_FALLBACK;
|
|
241
|
-
}
|
|
242
|
-
export function getCodexEfforts(model) {
|
|
243
|
-
const catalog = fetchCodexCatalog();
|
|
244
|
-
const entry = catalog.find(m => m.slug === model);
|
|
245
|
-
return entry?.efforts ?? catalog[0]?.efforts ?? ['low', 'medium', 'high'];
|
|
246
|
-
}
|
|
247
282
|
// ── Codex Runner ──
|
|
248
283
|
export class CodexRunner {
|
|
249
284
|
name = 'codex';
|
|
@@ -256,18 +291,30 @@ export class CodexRunner {
|
|
|
256
291
|
activeAbortControllers = new Map();
|
|
257
292
|
activeStreams = new Map();
|
|
258
293
|
activeSessions = new Map(); // sessionId → threadId
|
|
294
|
+
/** Threads created/resumed for a turn but not yet durably admitted by turn/start. */
|
|
295
|
+
provisionalSessions = new Map();
|
|
259
296
|
activeTurns = new Map();
|
|
297
|
+
activeSubagentTurns = new Map();
|
|
260
298
|
threadProjectPaths = new Map();
|
|
299
|
+
/** Child Codex threads inherit the EvolCore session that started them. */
|
|
300
|
+
childThreadSessions = new Map();
|
|
261
301
|
sessionTempDirs = new Map();
|
|
262
302
|
managedTempNonce = randomBytes(16).toString('hex');
|
|
263
303
|
threadExternalToolFingerprints = new Map();
|
|
264
304
|
threadExternalToolBoundaries = new Map();
|
|
265
305
|
trackedApprovalItems = new Map();
|
|
306
|
+
pendingToolDenials = new Map();
|
|
266
307
|
threadDelegationCarriers = new Map();
|
|
267
308
|
pendingInterrupts = new Map();
|
|
309
|
+
steeringTails = new Map();
|
|
268
310
|
threadOperationLocks = new Map();
|
|
269
311
|
activeOperationReleases = new Map();
|
|
312
|
+
/** Last app-server client that loaded each thread, including unsubscribed threads. */
|
|
313
|
+
threadSubscriptions = new Map();
|
|
314
|
+
/** Ordinary sessions stay behind the EvolCore H/L filesystem guard. */
|
|
270
315
|
appServerClient = null;
|
|
316
|
+
/** Trusted fullaccess calls use a separate unmasked app-server process. */
|
|
317
|
+
fullAccessAppServerClient = null;
|
|
271
318
|
onSessionIdUpdate;
|
|
272
319
|
onCompactStart;
|
|
273
320
|
permissionGateway;
|
|
@@ -308,43 +355,228 @@ export class CodexRunner {
|
|
|
308
355
|
this.effort = this.resolvedConfig.effort;
|
|
309
356
|
this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
|
|
310
357
|
}
|
|
311
|
-
getAppServerClient() {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
358
|
+
getAppServerClient(fullAccess = false) {
|
|
359
|
+
const existing = fullAccess ? this.fullAccessAppServerClient : this.appServerClient;
|
|
360
|
+
if (existing)
|
|
361
|
+
return existing;
|
|
362
|
+
const client = new CodexAppServerClient({
|
|
363
|
+
apiKey: this.resolvedConfig.apiKey,
|
|
364
|
+
baseUrl: this.resolvedConfig.baseUrl,
|
|
365
|
+
provider: this.resolvedConfig.provider,
|
|
366
|
+
headers: this.resolvedConfig.headers,
|
|
367
|
+
queryParams: this.resolvedConfig.queryParams,
|
|
368
|
+
model: this.model,
|
|
369
|
+
effort: this.effort,
|
|
370
|
+
enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
|
|
371
|
+
approvalsReviewer: 'user',
|
|
372
|
+
protectEvolcoreFilesystem: !fullAccess,
|
|
373
|
+
// A completed turn is explicitly unsubscribed below. This is only an
|
|
374
|
+
// optimization on newer Codex versions; old versions retain the thread
|
|
375
|
+
// until the cross-client archive fallback runs.
|
|
376
|
+
onServerRequest: request => this.handleAppServerRequest(request),
|
|
377
|
+
});
|
|
378
|
+
client.onNotification(notification => {
|
|
379
|
+
this.trackApprovalItemNotification(notification);
|
|
380
|
+
if (notification.method !== 'thread/closed')
|
|
381
|
+
return;
|
|
382
|
+
const params = (notification.params || {});
|
|
383
|
+
const threadId = params.threadId ?? params.thread_id;
|
|
384
|
+
if (typeof threadId !== 'string')
|
|
385
|
+
return;
|
|
386
|
+
const subscription = this.threadSubscriptions.get(threadId);
|
|
387
|
+
if (subscription?.client === client) {
|
|
388
|
+
// Keep the last client identity so a later same-mode turn resumes on
|
|
389
|
+
// the same app-server even after the runtime has fully unloaded.
|
|
390
|
+
this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
if (fullAccess) {
|
|
394
|
+
this.fullAccessAppServerClient = client;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
326
397
|
this.appServerClient = client;
|
|
327
398
|
}
|
|
328
|
-
return
|
|
399
|
+
return client;
|
|
400
|
+
}
|
|
401
|
+
getAppServerClientForThread(threadId) {
|
|
402
|
+
const subscribed = this.threadSubscriptions.get(threadId);
|
|
403
|
+
if (subscribed)
|
|
404
|
+
return subscribed.client;
|
|
405
|
+
const sessionId = this.findSessionKeyByThread(threadId);
|
|
406
|
+
return this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
|
|
407
|
+
}
|
|
408
|
+
markThreadSubscribed(client, threadId) {
|
|
409
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
410
|
+
if (existing?.client === client && existing.state === 'subscribed' && !existing.release)
|
|
411
|
+
return;
|
|
412
|
+
this.threadSubscriptions.set(threadId, { client, state: 'subscribed' });
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Release only one thread from one app-server connection. Unsubscribe is
|
|
416
|
+
* intentionally not treated as an immediate unload: current Codex builds can
|
|
417
|
+
* keep an unsubscribed thread loaded for a long grace period. The owner therefore
|
|
418
|
+
* remains in the map until a different client successfully resumes it.
|
|
419
|
+
*/
|
|
420
|
+
async releaseThreadSubscription(client, threadId, reason, options = {}) {
|
|
421
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
422
|
+
if (existing && existing.client !== client)
|
|
423
|
+
return false;
|
|
424
|
+
if (existing?.release)
|
|
425
|
+
return await existing.release.catch(error => {
|
|
426
|
+
logger.warn(`[CodexRunner] Existing thread release failed: thread=${threadId} `
|
|
427
|
+
+ `error=${error instanceof Error ? error.message : String(error)}`);
|
|
428
|
+
return false;
|
|
429
|
+
});
|
|
430
|
+
if (existing?.state === 'unsubscribed')
|
|
431
|
+
return true;
|
|
432
|
+
let releasePromise;
|
|
433
|
+
releasePromise = Promise.resolve().then(async () => {
|
|
434
|
+
let released = false;
|
|
435
|
+
try {
|
|
436
|
+
const unsubscribe = client.threadUnsubscribe;
|
|
437
|
+
if (typeof unsubscribe === 'function') {
|
|
438
|
+
if (options.waitForClose && typeof client.threadUnsubscribeAndWait === 'function') {
|
|
439
|
+
try {
|
|
440
|
+
await client.threadUnsubscribeAndWait.call(client, threadId, options.timeoutMs ?? CODEX_THREAD_HANDOFF_TIMEOUT_MS);
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
// The unsubscribe RPC has already succeeded when this is a
|
|
444
|
+
// close-timeout. Keep the owner marked unsubscribed and let the
|
|
445
|
+
// target resume decide whether the archive fallback is needed.
|
|
446
|
+
if (error?.code !== 'CODEX_THREAD_CLOSE_TIMEOUT')
|
|
447
|
+
throw error;
|
|
448
|
+
logger.debug(`[CodexRunner] thread/closed not observed before handoff timeout: `
|
|
449
|
+
+ `thread=${threadId} reason=${reason}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
await unsubscribe.call(client, threadId);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
logger.debug(`[CodexRunner] Released thread subscription: thread=${threadId} reason=${reason}`);
|
|
457
|
+
released = true;
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
logger.warn(`[CodexRunner] Failed to release thread subscription: thread=${threadId} reason=${reason} `
|
|
462
|
+
+ `error=${error instanceof Error ? error.message : String(error)}`);
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
const current = this.threadSubscriptions.get(threadId);
|
|
467
|
+
if (current?.client === client && current.release === releasePromise) {
|
|
468
|
+
if (released) {
|
|
469
|
+
this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
this.threadSubscriptions.set(threadId, { client, state: current.state });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
this.threadSubscriptions.set(threadId, {
|
|
478
|
+
client,
|
|
479
|
+
state: existing?.state ?? 'subscribed',
|
|
480
|
+
release: releasePromise,
|
|
481
|
+
});
|
|
482
|
+
return await releasePromise;
|
|
483
|
+
}
|
|
484
|
+
async prepareThreadSubscription(client, threadId) {
|
|
485
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
486
|
+
if (!existing)
|
|
487
|
+
return undefined;
|
|
488
|
+
if (existing.client === client) {
|
|
489
|
+
if (existing.release)
|
|
490
|
+
await existing.release.catch(() => undefined);
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
if (existing.state === 'subscribed') {
|
|
494
|
+
const released = await this.releaseThreadSubscription(existing.client, threadId, 'mode-handoff', {
|
|
495
|
+
waitForClose: true,
|
|
496
|
+
timeoutMs: CODEX_THREAD_HANDOFF_TIMEOUT_MS,
|
|
497
|
+
});
|
|
498
|
+
if (!released) {
|
|
499
|
+
const error = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
|
|
500
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return existing.client;
|
|
505
|
+
}
|
|
506
|
+
async archiveThreadForHandoff(sourceClient, targetClient, threadId) {
|
|
507
|
+
const existing = this.threadSubscriptions.get(threadId);
|
|
508
|
+
if (existing && existing.client !== sourceClient) {
|
|
509
|
+
const error = new Error(`Codex thread ${threadId} ownership changed during handoff`);
|
|
510
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
const sessionId = this.findSessionKeyByThread(threadId);
|
|
514
|
+
if ([...this.activeSubagentTurns.values()].some(active => active.sessionId === sessionId && active.parentThreadId === threadId)) {
|
|
515
|
+
const error = new Error(`Codex thread ${threadId} has an active descendant turn`);
|
|
516
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
// Old app-server versions can acknowledge unsubscribe while retaining the
|
|
520
|
+
// writer. Archive is the only per-thread operation that synchronously
|
|
521
|
+
// tears down that runtime; the target client restores it before resume.
|
|
522
|
+
const archive = sourceClient.threadArchive;
|
|
523
|
+
const unarchive = targetClient.threadUnarchive;
|
|
524
|
+
if (typeof archive !== 'function' || typeof unarchive !== 'function') {
|
|
525
|
+
const error = new Error(`Codex thread ${threadId} requires archive/unarchive handoff support`);
|
|
526
|
+
error.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
let archived = false;
|
|
530
|
+
try {
|
|
531
|
+
await archive.call(sourceClient, threadId);
|
|
532
|
+
archived = true;
|
|
533
|
+
await unarchive.call(targetClient, threadId);
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
if (archived) {
|
|
537
|
+
// Do not strand the user's rollout under archived_sessions when the
|
|
538
|
+
// target client fails midway through the compatibility handoff.
|
|
539
|
+
try {
|
|
540
|
+
await unarchive.call(sourceClient, threadId);
|
|
541
|
+
this.threadSubscriptions.set(threadId, { client: sourceClient, state: 'subscribed' });
|
|
542
|
+
}
|
|
543
|
+
catch (restoreError) {
|
|
544
|
+
logger.error(`[CodexRunner] Failed to restore archived thread after handoff failure: thread=${threadId} `
|
|
545
|
+
+ `error=${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
logger.warn(`[CodexRunner] Archive handoff failed: thread=${threadId} `
|
|
549
|
+
+ `error=${error instanceof Error ? error.message : String(error)}`);
|
|
550
|
+
const handoffError = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
|
|
551
|
+
handoffError.code = CODEX_THREAD_ACTIVE_WRITER;
|
|
552
|
+
throw handoffError;
|
|
553
|
+
}
|
|
554
|
+
this.threadSubscriptions.set(threadId, { client: targetClient, state: 'subscribed' });
|
|
329
555
|
}
|
|
330
556
|
createDelegationCarrier() {
|
|
331
557
|
return randomBytes(32).toString('base64url');
|
|
332
558
|
}
|
|
333
|
-
approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix') {
|
|
559
|
+
approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix', expectedDelegationToken, executable) {
|
|
334
560
|
const parseOptions = {
|
|
335
561
|
allowManagedTmpDir: true,
|
|
336
562
|
dialect,
|
|
563
|
+
verifyShellExecutable: process.platform === 'win32',
|
|
337
564
|
...(managedTempDir ? { managedTempDir } : {}),
|
|
565
|
+
...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
|
|
338
566
|
};
|
|
339
567
|
const classification = classifyEvolcoreShellCommand(command, parseOptions);
|
|
340
568
|
if (classification.kind === 'bounded-output')
|
|
341
569
|
return { argv: classification.command.argv };
|
|
342
570
|
// Keep the approval/delegation parser aligned with the preflight parser:
|
|
343
571
|
// the session-managed `$TMPDIR` token is a permitted EC path reference.
|
|
344
|
-
const parsed =
|
|
572
|
+
const parsed = executable
|
|
573
|
+
? parseCodexCarrierCommand({ command, dialect, executable }, parseOptions)
|
|
574
|
+
: dialect === 'powershell'
|
|
575
|
+
? parseCodexCarrierCommand({ command, dialect }, parseOptions)
|
|
576
|
+
: parseLiteralShellCommand(command, parseOptions);
|
|
345
577
|
if (!parsed.ok)
|
|
346
578
|
return { parseFailure: parsed };
|
|
347
|
-
if (parsed.argv[0] !== 'ec')
|
|
579
|
+
if (normalizeEvolcoreCommandArgv(parsed.argv)[0] !== 'ec')
|
|
348
580
|
return {};
|
|
349
581
|
if (containsLiteralManagedTmpDirOutsideSendContent(parsed.argv)) {
|
|
350
582
|
return { parseFailure: {
|
|
@@ -355,14 +587,17 @@ export class CodexRunner {
|
|
|
355
587
|
inputLength: command.length,
|
|
356
588
|
} };
|
|
357
589
|
}
|
|
358
|
-
return {
|
|
590
|
+
return {
|
|
591
|
+
argv: normalizeEvolcoreCommandArgv(parsed.argv),
|
|
592
|
+
...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
|
|
593
|
+
};
|
|
359
594
|
}
|
|
360
|
-
resolveCanonicalEcApproval(toolInput, managedTempDir) {
|
|
595
|
+
resolveCanonicalEcApproval(toolInput, managedTempDir, expectedDelegationToken) {
|
|
361
596
|
const explicitArgv = Array.isArray(toolInput.commandArgv)
|
|
362
597
|
&& toolInput.commandArgv.every(value => typeof value === 'string')
|
|
363
598
|
? toolInput.commandArgv
|
|
364
599
|
: undefined;
|
|
365
|
-
if (explicitArgv
|
|
600
|
+
if (explicitArgv && normalizeEvolcoreCommandArgv(explicitArgv)[0] === 'ec') {
|
|
366
601
|
if (explicitArgv.some(value => value.includes('\0'))) {
|
|
367
602
|
return { parseFailure: {
|
|
368
603
|
issue: 'invalid-control-char', offset: 0,
|
|
@@ -372,16 +607,19 @@ export class CodexRunner {
|
|
|
372
607
|
}
|
|
373
608
|
return containsLiteralManagedTmpDirOutsideSendContent(explicitArgv)
|
|
374
609
|
? { parseFailure: { issue: 'unsafe-expansion', offset: 0, tokenIndex: 0, dialect: 'posix', inputLength: 0 } }
|
|
375
|
-
: { argv: explicitArgv };
|
|
610
|
+
: { argv: normalizeEvolcoreCommandArgv(explicitArgv) };
|
|
376
611
|
}
|
|
377
612
|
const explicitCarrier = explicitArgv
|
|
378
613
|
? resolveCodexShellCarrierArgv(explicitArgv)
|
|
379
614
|
: undefined;
|
|
380
615
|
if (explicitCarrier !== undefined) {
|
|
381
|
-
return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect);
|
|
616
|
+
return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect, expectedDelegationToken, explicitCarrier.executable);
|
|
382
617
|
}
|
|
383
618
|
if (hasCodexCmdCarrierEcIntent(toolInput)) {
|
|
384
|
-
const parsed = parseCodexToolCommand(toolInput
|
|
619
|
+
const parsed = parseCodexToolCommand(toolInput, {
|
|
620
|
+
verifyShellExecutable: process.platform === 'win32',
|
|
621
|
+
...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
|
|
622
|
+
});
|
|
385
623
|
if (!parsed.ok)
|
|
386
624
|
return { parseFailure: parsed };
|
|
387
625
|
if (parsed.argv[0] === 'ec') {
|
|
@@ -390,7 +628,10 @@ export class CodexRunner {
|
|
|
390
628
|
issue: 'unsafe-expansion', offset: 0, tokenIndex: 0,
|
|
391
629
|
dialect: 'cmd', inputLength: 0,
|
|
392
630
|
} }
|
|
393
|
-
: {
|
|
631
|
+
: {
|
|
632
|
+
argv: normalizeEvolcoreCommandArgv(parsed.argv),
|
|
633
|
+
...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
|
|
634
|
+
};
|
|
394
635
|
}
|
|
395
636
|
const command = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
396
637
|
const classification = classifyEvolcoreShellCommand(command, { dialect: 'cmd' });
|
|
@@ -407,7 +648,9 @@ export class CodexRunner {
|
|
|
407
648
|
const command = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
408
649
|
const canonicalCommand = parseCodexShellCommandArgv(command, {
|
|
409
650
|
allowManagedTmpDir: true,
|
|
651
|
+
verifyShellExecutable: process.platform === 'win32',
|
|
410
652
|
...(managedTempDir ? { managedTempDir } : {}),
|
|
653
|
+
...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
|
|
411
654
|
});
|
|
412
655
|
if (canonicalCommand?.[0] === 'ec') {
|
|
413
656
|
return containsLiteralManagedTmpDirOutsideSendContent(canonicalCommand)
|
|
@@ -436,11 +679,14 @@ export class CodexRunner {
|
|
|
436
679
|
isManagedEvolcoreCommandIntent(toolInput) {
|
|
437
680
|
if (Array.isArray(toolInput.commandArgv)) {
|
|
438
681
|
const rawArgv = toolInput.commandArgv;
|
|
439
|
-
if (rawArgv[0] === 'ec')
|
|
440
|
-
return true;
|
|
441
682
|
if (!rawArgv.every(value => typeof value === 'string'))
|
|
442
683
|
return false;
|
|
684
|
+
if (normalizeEvolcoreCommandArgv(rawArgv)[0] === 'ec')
|
|
685
|
+
return true;
|
|
443
686
|
const shellCarrier = resolveCodexShellCarrierArgv(rawArgv);
|
|
687
|
+
if (shellCarrier?.dialect === 'powershell'
|
|
688
|
+
&& hasPowerShellDelegationAssignment(shellCarrier.command))
|
|
689
|
+
return true;
|
|
444
690
|
return hasCodexCmdCarrierEcIntent(toolInput)
|
|
445
691
|
|| shellCarrier !== undefined
|
|
446
692
|
&& classifyEvolcoreShellCommand(shellCarrier.command, {
|
|
@@ -451,7 +697,7 @@ export class CodexRunner {
|
|
|
451
697
|
return classifyEvolcoreShellCommand(command).kind !== 'none';
|
|
452
698
|
}
|
|
453
699
|
armApprovedDelegationCommand(sessionId, argv) {
|
|
454
|
-
const threadId = this.activeSessions.get(sessionId);
|
|
700
|
+
const threadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
|
|
455
701
|
const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
|
|
456
702
|
const commandHash = hashDelegatedCommandArgv(argv);
|
|
457
703
|
const arm = this.permissionContexts.get(sessionId)?.armApprovedDelegationCommand;
|
|
@@ -465,6 +711,21 @@ export class CodexRunner {
|
|
|
465
711
|
return false;
|
|
466
712
|
}
|
|
467
713
|
}
|
|
714
|
+
delegationCarrierForSession(sessionId, threadId) {
|
|
715
|
+
const candidates = [
|
|
716
|
+
threadId,
|
|
717
|
+
this.activeSessions.get(sessionId),
|
|
718
|
+
this.provisionalSessions.get(sessionId),
|
|
719
|
+
];
|
|
720
|
+
for (const candidate of candidates) {
|
|
721
|
+
if (!candidate)
|
|
722
|
+
continue;
|
|
723
|
+
const carrier = this.threadDelegationCarriers.get(candidate);
|
|
724
|
+
if (carrier)
|
|
725
|
+
return carrier;
|
|
726
|
+
}
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
468
729
|
async readThreadDelegationCarrier(appServer, threadId) {
|
|
469
730
|
try {
|
|
470
731
|
const result = await appServer.threadShellCommand(threadId, `node -e "process.stdout.write(process.env.${AGENT_DELEGATION_TOKEN_ENV}||String())"`);
|
|
@@ -497,6 +758,49 @@ export class CodexRunner {
|
|
|
497
758
|
return undefined;
|
|
498
759
|
}
|
|
499
760
|
}
|
|
761
|
+
async commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn) {
|
|
762
|
+
if (this.provisionalSessions.get(sessionId) !== threadId)
|
|
763
|
+
return false;
|
|
764
|
+
const bindingResult = this.onSessionIdUpdate
|
|
765
|
+
? ((turn === undefined
|
|
766
|
+
? await this.onSessionIdUpdate(sessionId, threadId)
|
|
767
|
+
: await this.onSessionIdUpdate(sessionId, threadId, { turn }))
|
|
768
|
+
?? 'legacy_updated')
|
|
769
|
+
: 'legacy_updated';
|
|
770
|
+
const bindingAccepted = bindingResult === 'activated'
|
|
771
|
+
|| bindingResult === 'already_active'
|
|
772
|
+
|| bindingResult === 'legacy_updated';
|
|
773
|
+
if (!bindingAccepted) {
|
|
774
|
+
const error = new Error(`backend discovery rejected: ${bindingResult}`);
|
|
775
|
+
error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
|
|
776
|
+
throw error;
|
|
777
|
+
}
|
|
778
|
+
this.provisionalSessions.delete(sessionId);
|
|
779
|
+
this.activeSessions.set(sessionId, threadId);
|
|
780
|
+
if (carrierToPublish) {
|
|
781
|
+
this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
|
|
782
|
+
await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
|
|
783
|
+
}
|
|
784
|
+
this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
cleanupProvisionalThread(sessionId, threadId) {
|
|
788
|
+
if (this.provisionalSessions.get(sessionId) !== threadId)
|
|
789
|
+
return;
|
|
790
|
+
this.provisionalSessions.delete(sessionId);
|
|
791
|
+
this.threadProjectPaths.delete(threadId);
|
|
792
|
+
this.threadDelegationCarriers.delete(threadId);
|
|
793
|
+
this.unbindExternalToolBoundary(threadId);
|
|
794
|
+
this.clearTrackedApprovalItemsForThread(threadId);
|
|
795
|
+
// A stale provisional start can finish after a replacement run has
|
|
796
|
+
// already activated another parent thread for this session. Do not tear
|
|
797
|
+
// down the replacement run's child mappings in that race.
|
|
798
|
+
const activeParentThread = this.activeSessions.get(sessionId);
|
|
799
|
+
if (!activeParentThread || activeParentThread === threadId) {
|
|
800
|
+
this.cleanupChildThreadsForSession(sessionId);
|
|
801
|
+
}
|
|
802
|
+
this.pruneThreadOperationLocks();
|
|
803
|
+
}
|
|
500
804
|
async persistDelegationCarrier(sessionManager, sessionId, threadId, carrier) {
|
|
501
805
|
if (typeof sessionManager?.getSessionById !== 'function')
|
|
502
806
|
return;
|
|
@@ -523,15 +827,28 @@ export class CodexRunner {
|
|
|
523
827
|
}
|
|
524
828
|
}
|
|
525
829
|
resetAppServerClient() {
|
|
526
|
-
const
|
|
830
|
+
const clients = [this.appServerClient, this.fullAccessAppServerClient]
|
|
831
|
+
.filter((client) => !!client);
|
|
527
832
|
this.appServerClient = null;
|
|
833
|
+
this.fullAccessAppServerClient = null;
|
|
834
|
+
this.threadSubscriptions.clear();
|
|
528
835
|
this.threadExternalToolFingerprints.clear();
|
|
529
836
|
this.threadExternalToolBoundaries.clear();
|
|
837
|
+
for (const childThreadId of this.childThreadSessions.keys())
|
|
838
|
+
this.threadProjectPaths.delete(childThreadId);
|
|
839
|
+
// Child turn ids belong to the discarded app-server connection. Keeping
|
|
840
|
+
// them would make a later session interrupt attempt target stale threads
|
|
841
|
+
// through the replacement client.
|
|
842
|
+
this.activeSubagentTurns.clear();
|
|
843
|
+
this.childThreadSessions.clear();
|
|
530
844
|
this.trackedApprovalItems.clear();
|
|
845
|
+
this.pendingToolDenials.clear();
|
|
531
846
|
this.threadDelegationCarriers.clear();
|
|
532
|
-
client
|
|
533
|
-
|
|
534
|
-
|
|
847
|
+
for (const client of clients) {
|
|
848
|
+
client.close().catch(error => {
|
|
849
|
+
logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
|
|
850
|
+
});
|
|
851
|
+
}
|
|
535
852
|
}
|
|
536
853
|
mergeThreadConfig(...configs) {
|
|
537
854
|
const merged = {};
|
|
@@ -779,19 +1096,16 @@ export class CodexRunner {
|
|
|
779
1096
|
// ── ModelSwitcher ──
|
|
780
1097
|
setModel(model) { this.model = model; this.resetAppServerClient(); }
|
|
781
1098
|
getModel() { return this.model; }
|
|
1099
|
+
getModelCatalog() {
|
|
1100
|
+
return getRunnerCatalog('codex', this.resolvedConfig.agentAid, this.resolvedConfig);
|
|
1101
|
+
}
|
|
782
1102
|
async listModels() {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return ids;
|
|
790
|
-
}
|
|
791
|
-
catch (error) {
|
|
792
|
-
logger.debug(`[CodexRunner] app-server model/list failed, using catalog fallback: ${error}`);
|
|
793
|
-
}
|
|
794
|
-
return fetchCodexCatalog().map(m => m.slug);
|
|
1103
|
+
const catalog = await this.getModelCatalog();
|
|
1104
|
+
assertCatalogSelectable(catalog);
|
|
1105
|
+
return catalog.models.map(m => m.id);
|
|
1106
|
+
}
|
|
1107
|
+
async getAvailableEfforts(model) {
|
|
1108
|
+
return catalogEfforts(await this.getModelCatalog(), model, 'codex');
|
|
795
1109
|
}
|
|
796
1110
|
// ── Effort ──
|
|
797
1111
|
setEffort(effort) { this.effort = effort; this.resetAppServerClient(); }
|
|
@@ -848,7 +1162,9 @@ export class CodexRunner {
|
|
|
848
1162
|
if (!looksLikeGitMetadataDirectory(resolved))
|
|
849
1163
|
return;
|
|
850
1164
|
paths.add(resolved);
|
|
851
|
-
paths
|
|
1165
|
+
// Keep exact paths native, but Codex requires POSIX separators for
|
|
1166
|
+
// recursive filesystem globs even on Windows.
|
|
1167
|
+
paths.add(`${resolved}/**`);
|
|
852
1168
|
};
|
|
853
1169
|
try {
|
|
854
1170
|
const stat = fs.statSync(marker);
|
|
@@ -862,7 +1178,7 @@ export class CodexRunner {
|
|
|
862
1178
|
}
|
|
863
1179
|
}
|
|
864
1180
|
catch { }
|
|
865
|
-
for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith(
|
|
1181
|
+
for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith('/**'))) {
|
|
866
1182
|
try {
|
|
867
1183
|
const commonDirectory = fs.readFileSync(path.join(gitDirectory, 'commondir'), 'utf8').trim();
|
|
868
1184
|
if (commonDirectory)
|
|
@@ -900,9 +1216,9 @@ export class CodexRunner {
|
|
|
900
1216
|
} : {}),
|
|
901
1217
|
...(mode !== 'readonly' && managedTempDir ? {
|
|
902
1218
|
[managedTempDir]: 'write',
|
|
903
|
-
[
|
|
1219
|
+
[managedTempDir + '/**']: 'write',
|
|
904
1220
|
[path.join(path.dirname(managedTempDir), 'evolcore-locks')]: 'write',
|
|
905
|
-
[path.join(path.dirname(managedTempDir), 'evolcore-locks'
|
|
1221
|
+
[path.join(path.dirname(managedTempDir), 'evolcore-locks') + '/**']: 'write',
|
|
906
1222
|
} : {}),
|
|
907
1223
|
},
|
|
908
1224
|
network,
|
|
@@ -949,55 +1265,145 @@ export class CodexRunner {
|
|
|
949
1265
|
}
|
|
950
1266
|
setSendPrompt(fn) { this.sendPromptFn = fn; }
|
|
951
1267
|
setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
|
|
1268
|
+
injectUserMessage(sessionId, text) {
|
|
1269
|
+
// Bind queued reminders to the turn that generated them. A completed
|
|
1270
|
+
// turn may be replaced before the serialized steer reaches app-server;
|
|
1271
|
+
// injecting the old reminder into the new turn would corrupt its context.
|
|
1272
|
+
const requestedTurn = this.activeTurns.get(sessionId);
|
|
1273
|
+
if (!requestedTurn) {
|
|
1274
|
+
logger.warn(`[CodexRunner] turn steer skipped because no active turn is registered: session=${sessionId}`);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
const previous = this.steeringTails.get(sessionId) ?? Promise.resolve();
|
|
1278
|
+
let current;
|
|
1279
|
+
current = previous.catch(() => undefined).then(async () => {
|
|
1280
|
+
const activeTurn = this.activeTurns.get(sessionId);
|
|
1281
|
+
const sameTurn = !!activeTurn
|
|
1282
|
+
&& activeTurn.threadId === requestedTurn.threadId
|
|
1283
|
+
&& activeTurn.turnId === requestedTurn.turnId;
|
|
1284
|
+
if (!sameTurn) {
|
|
1285
|
+
logger.warn(`[CodexRunner] turn steer skipped because the originating turn is no longer active: `
|
|
1286
|
+
+ `session=${sessionId} thread=${requestedTurn.threadId} turn=${requestedTurn.turnId}`);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const input = [{ type: 'text', text, text_elements: [] }];
|
|
1290
|
+
const client = this.getAppServerClientForThread(activeTurn.threadId);
|
|
1291
|
+
if (typeof client.turnSteer !== 'function') {
|
|
1292
|
+
logger.warn(`[CodexRunner] turn steer unavailable in app-server client: session=${sessionId}`);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
const response = await client.turnSteer(activeTurn.threadId, activeTurn.turnId, input);
|
|
1297
|
+
logger.info(`[CodexRunner] turn steer delivered: session=${sessionId} thread=${activeTurn.threadId} ` +
|
|
1298
|
+
`turn=${response.turnId} chars=${text.length}`);
|
|
1299
|
+
}
|
|
1300
|
+
catch (error) {
|
|
1301
|
+
logger.warn(`[CodexRunner] turn steer failed: session=${sessionId} thread=${activeTurn.threadId} ` +
|
|
1302
|
+
`turn=${activeTurn.turnId} error=${error instanceof Error ? error.message : String(error)}`);
|
|
1303
|
+
}
|
|
1304
|
+
}).finally(() => {
|
|
1305
|
+
if (this.steeringTails.get(sessionId) === current)
|
|
1306
|
+
this.steeringTails.delete(sessionId);
|
|
1307
|
+
});
|
|
1308
|
+
this.steeringTails.set(sessionId, current);
|
|
1309
|
+
}
|
|
952
1310
|
async assertProactiveHookAvailability(sessionId, appServer) {
|
|
953
1311
|
if (this.permissionContexts.get(sessionId)?.chatmode !== 'proactive')
|
|
954
1312
|
return;
|
|
955
|
-
if (process.platform
|
|
956
|
-
await appServer.assertManagedHooksAvailable();
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
if (process.platform !== 'linux') {
|
|
1313
|
+
if (process.platform !== 'linux' && process.platform !== 'win32') {
|
|
960
1314
|
throw new Error('Codex proactive managed PreToolUse enforcement requires Linux or an installed Windows managed hook');
|
|
961
1315
|
}
|
|
1316
|
+
await appServer.assertManagedHooksAvailable();
|
|
962
1317
|
}
|
|
963
|
-
async evaluatePreToolUse(threadId, toolName, toolInput, signal) {
|
|
1318
|
+
async evaluatePreToolUse(threadId, toolName, toolInput, signal, callId) {
|
|
964
1319
|
const sessionKey = this.findSessionKeyByThread(threadId);
|
|
965
|
-
const activeThread = this.activeSessions.get(sessionKey);
|
|
1320
|
+
const activeThread = this.activeSessions.get(sessionKey) ?? this.provisionalSessions.get(sessionKey);
|
|
966
1321
|
const context = this.permissionContexts.get(sessionKey);
|
|
967
|
-
|
|
1322
|
+
const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
|
|
1323
|
+
? toolInput
|
|
1324
|
+
: {};
|
|
1325
|
+
const recordPreToolUseDenial = async (policyCode, reason) => {
|
|
1326
|
+
const summary = summarizeToolInputForAudit(toolName, normalizedInput).slice(0, 512);
|
|
1327
|
+
auditToolPreflightDenial({
|
|
1328
|
+
toolName,
|
|
1329
|
+
policyCode,
|
|
1330
|
+
reason,
|
|
1331
|
+
summary,
|
|
1332
|
+
sessionId: sessionKey,
|
|
1333
|
+
agentAid: context?.selfAid,
|
|
1334
|
+
agentName: context?.agentName,
|
|
1335
|
+
permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
|
|
1336
|
+
channel: context?.channel,
|
|
1337
|
+
actorId: context?.userId,
|
|
1338
|
+
role: context?.role,
|
|
1339
|
+
selfAid: context?.selfAid,
|
|
1340
|
+
callId,
|
|
1341
|
+
correlationId: callId,
|
|
1342
|
+
taskId: context?.taskId,
|
|
1343
|
+
});
|
|
1344
|
+
try {
|
|
1345
|
+
await context?.recordExecutionAnomaly?.({
|
|
1346
|
+
code: 'operation_blocked',
|
|
1347
|
+
severity: 'warning',
|
|
1348
|
+
phase: 'execution',
|
|
1349
|
+
occurredAt: Date.now(),
|
|
1350
|
+
toolName,
|
|
1351
|
+
policyCode,
|
|
1352
|
+
decisionSource: 'policy',
|
|
1353
|
+
agentAid: context?.selfAid,
|
|
1354
|
+
agentName: context?.agentName,
|
|
1355
|
+
sessionId: sessionKey,
|
|
1356
|
+
permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
|
|
1357
|
+
summary,
|
|
1358
|
+
effect: 'operation_skipped',
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
catch (error) {
|
|
1362
|
+
logger.warn(`[CodexRunner] failed to record managed-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
if (!activeThread || !this.isThreadOwnedBySession(sessionKey, threadId)) {
|
|
968
1366
|
return { ok: true, applicable: false };
|
|
969
1367
|
}
|
|
970
1368
|
const proactiveSession = context?.chatmode === 'proactive';
|
|
971
1369
|
if (!context || (proactiveSession && !context.preToolUsePolicyHook)) {
|
|
972
|
-
|
|
1370
|
+
const reason = 'EvolCore proactive session policy context is unavailable';
|
|
1371
|
+
await recordPreToolUseDenial('pre_tool_use_context_unavailable', reason);
|
|
1372
|
+
return { ok: false, applicable: true, decision: 'deny', reason };
|
|
973
1373
|
}
|
|
974
1374
|
if (context.pauseController) {
|
|
975
1375
|
const pauseResult = await context.pauseController.waitAtToolBoundary(signal);
|
|
976
1376
|
if (pauseResult === 'cancelled') {
|
|
977
|
-
|
|
1377
|
+
const reason = '工具调用已取消';
|
|
1378
|
+
await recordPreToolUseDenial('tool_call_cancelled', reason);
|
|
1379
|
+
return { ok: true, applicable: true, decision: 'deny', reason };
|
|
978
1380
|
}
|
|
979
1381
|
}
|
|
980
1382
|
if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1383
|
+
if (this.isTrustedFullAccessSession(sessionKey))
|
|
1384
|
+
return { ok: true, applicable: true, decision: 'allow' };
|
|
1385
|
+
const reason = 'Untrusted fullaccess execution context';
|
|
1386
|
+
await recordPreToolUseDenial('fullaccess_context_untrusted', reason);
|
|
1387
|
+
return { ok: false, applicable: true, decision: 'deny', reason };
|
|
984
1388
|
}
|
|
985
1389
|
// A tracked interactive thread still needs an authenticated, explicit
|
|
986
1390
|
// allow so the managed hook can fail closed when no runner matches.
|
|
987
1391
|
if (!context.preToolUsePolicyHook)
|
|
988
1392
|
return { ok: true, applicable: true, decision: 'allow' };
|
|
989
|
-
const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
|
|
990
|
-
? toolInput
|
|
991
|
-
: {};
|
|
992
1393
|
try {
|
|
993
1394
|
const result = context.preToolUsePolicyHook(toolName, normalizedInput);
|
|
994
1395
|
if (result?.block) {
|
|
995
|
-
|
|
1396
|
+
const policyCode = result.policyCode ?? 'session_policy_hook';
|
|
1397
|
+
const reason = result.reason ?? 'session policy denied this tool call';
|
|
1398
|
+
await recordPreToolUseDenial(policyCode, reason);
|
|
1399
|
+
return { ok: true, applicable: true, decision: 'deny', reason };
|
|
996
1400
|
}
|
|
997
1401
|
return { ok: true, applicable: true, decision: 'allow' };
|
|
998
1402
|
}
|
|
999
1403
|
catch (error) {
|
|
1000
|
-
|
|
1404
|
+
const reason = `policy hook failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1405
|
+
await recordPreToolUseDenial('pre_tool_use_hook_failed', reason);
|
|
1406
|
+
return { ok: false, applicable: true, decision: 'deny', reason };
|
|
1001
1407
|
}
|
|
1002
1408
|
}
|
|
1003
1409
|
setPermissionGateway(gw) { this.permissionGateway = gw; }
|
|
@@ -1006,14 +1412,36 @@ export class CodexRunner {
|
|
|
1006
1412
|
this.activeStreams.set(key, stream);
|
|
1007
1413
|
}
|
|
1008
1414
|
cleanupStream(key) {
|
|
1415
|
+
const hadController = this.activeAbortControllers.has(key);
|
|
1416
|
+
const hadTurn = this.activeTurns.has(key);
|
|
1417
|
+
const provisionalThread = this.provisionalSessions.get(key);
|
|
1418
|
+
const subscribedThreadId = this.activeTurns.get(key)?.threadId
|
|
1419
|
+
?? this.activeSessions.get(key)
|
|
1420
|
+
?? provisionalThread;
|
|
1421
|
+
const subscription = subscribedThreadId ? this.threadSubscriptions.get(subscribedThreadId) : undefined;
|
|
1009
1422
|
this.activeStreams.delete(key);
|
|
1010
1423
|
this.activeAbortControllers.delete(key);
|
|
1011
1424
|
this.pendingInterrupts.delete(key);
|
|
1425
|
+
// A stale runner-start path may dispose the stream without ever entering
|
|
1426
|
+
// transformAppServerStream. Reclaim its provisional thread, but preserve
|
|
1427
|
+
// a replacement run that has already installed a new controller/turn.
|
|
1428
|
+
if (provisionalThread && !hadController && !hadTurn) {
|
|
1429
|
+
this.cleanupProvisionalThread(key, provisionalThread);
|
|
1430
|
+
}
|
|
1012
1431
|
this.chatModes.delete(key);
|
|
1013
1432
|
const release = this.activeOperationReleases.get(key);
|
|
1433
|
+
let releaseAfterSubscription;
|
|
1434
|
+
if (subscription) {
|
|
1435
|
+
releaseAfterSubscription = this.releaseThreadSubscription(subscription.client, subscribedThreadId, 'stream-cleanup');
|
|
1436
|
+
}
|
|
1014
1437
|
if (release) {
|
|
1015
1438
|
this.activeOperationReleases.delete(key);
|
|
1016
|
-
|
|
1439
|
+
if (releaseAfterSubscription) {
|
|
1440
|
+
void releaseAfterSubscription.then(release, release);
|
|
1441
|
+
}
|
|
1442
|
+
else {
|
|
1443
|
+
release();
|
|
1444
|
+
}
|
|
1017
1445
|
}
|
|
1018
1446
|
}
|
|
1019
1447
|
hasActiveStream(key) {
|
|
@@ -1064,14 +1492,20 @@ export class CodexRunner {
|
|
|
1064
1492
|
}
|
|
1065
1493
|
}
|
|
1066
1494
|
isActiveThreadOperationAlias(key) {
|
|
1067
|
-
if (key.startsWith('session:'))
|
|
1068
|
-
|
|
1495
|
+
if (key.startsWith('session:')) {
|
|
1496
|
+
const sessionId = key.slice('session:'.length);
|
|
1497
|
+
return this.activeSessions.has(sessionId) || this.provisionalSessions.has(sessionId);
|
|
1498
|
+
}
|
|
1069
1499
|
if (key.startsWith('thread:')) {
|
|
1070
1500
|
const threadId = key.slice('thread:'.length);
|
|
1071
1501
|
for (const activeThreadId of this.activeSessions.values()) {
|
|
1072
1502
|
if (activeThreadId === threadId)
|
|
1073
1503
|
return true;
|
|
1074
1504
|
}
|
|
1505
|
+
for (const provisionalThreadId of this.provisionalSessions.values()) {
|
|
1506
|
+
if (provisionalThreadId === threadId)
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1075
1509
|
return false;
|
|
1076
1510
|
}
|
|
1077
1511
|
return true;
|
|
@@ -1157,7 +1591,18 @@ export class CodexRunner {
|
|
|
1157
1591
|
let agentSessionId = topicBinding
|
|
1158
1592
|
? (topicAgentSessionId || undefined)
|
|
1159
1593
|
: initialAgentSessionId || this.activeSessions.get(sessionId);
|
|
1160
|
-
|
|
1594
|
+
if (!topicBinding && !initialAgentSessionId && typeof sessionManager?.getSessionById === 'function') {
|
|
1595
|
+
try {
|
|
1596
|
+
const persisted = await sessionManager.getSessionById(sessionId);
|
|
1597
|
+
agentSessionId = typeof persisted?.agentSessionId === 'string' && persisted.agentSessionId.trim()
|
|
1598
|
+
? persisted.agentSessionId.trim()
|
|
1599
|
+
: undefined;
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
logger.debug(`[CodexRunner] persisted main backend lookup failed; using local cache: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
let resumingThread = !!agentSessionId;
|
|
1161
1606
|
const callModel = modelOverride?.model || this.model;
|
|
1162
1607
|
const callEffort = modelOverride?.effortMode === 'model_default'
|
|
1163
1608
|
? undefined
|
|
@@ -1173,7 +1618,7 @@ export class CodexRunner {
|
|
|
1173
1618
|
this.chatModes.set(sessionId, callMode);
|
|
1174
1619
|
const callApprovalPolicy = this.toApprovalPolicy(callMode);
|
|
1175
1620
|
const executionSandbox = this.buildExecutionSandboxOptions(sessionId, callMode, projectPath, modelOverride?.bootstrapAgentMdPath);
|
|
1176
|
-
const appServer = this.getAppServerClient();
|
|
1621
|
+
const appServer = this.getAppServerClient(callMode === 'fullaccess');
|
|
1177
1622
|
this.dropExpiredPendingInterrupt(sessionId);
|
|
1178
1623
|
const effectiveApprovalPolicy = callApprovalPolicy;
|
|
1179
1624
|
const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
|
|
@@ -1190,29 +1635,81 @@ export class CodexRunner {
|
|
|
1190
1635
|
if (agentSessionId && !knownDelegationCarrier) {
|
|
1191
1636
|
knownDelegationCarrier = await this.loadPersistedDelegationCarrier(sessionManager, sessionId, agentSessionId);
|
|
1192
1637
|
}
|
|
1193
|
-
|
|
1638
|
+
let delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
|
|
1194
1639
|
// Keep newly discovered carrier state local until generation-aware
|
|
1195
1640
|
// activation accepts the backend. A stale run must not publish a carrier
|
|
1196
1641
|
// that a later generation could mistake for its own thread.
|
|
1197
1642
|
let carrierToPublish;
|
|
1198
1643
|
const hasTaskDelegation = typeof runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] === 'string';
|
|
1199
|
-
|
|
1644
|
+
let threadOptions = {
|
|
1200
1645
|
model: callModel,
|
|
1201
1646
|
effort: callEffort,
|
|
1202
1647
|
approvalPolicy: effectiveApprovalPolicy,
|
|
1203
1648
|
approvalsReviewer: 'user',
|
|
1204
1649
|
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
1205
|
-
config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
|
|
1650
|
+
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),
|
|
1206
1651
|
...(developerInstructions ? { developerInstructions } : {}),
|
|
1207
1652
|
};
|
|
1208
1653
|
logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
|
|
1209
1654
|
`role=${executionSandbox.decision.role ?? 'none'} sandbox=${executionSandbox.decision.state}`);
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1655
|
+
let threadResponse;
|
|
1656
|
+
let previousAppServer;
|
|
1657
|
+
if (agentSessionId) {
|
|
1658
|
+
// A thread may have been loaded by the other permission-mode client.
|
|
1659
|
+
// Release that one thread before asking this client to resume it.
|
|
1660
|
+
previousAppServer = await this.prepareThreadSubscription(appServer, agentSessionId);
|
|
1661
|
+
try {
|
|
1662
|
+
threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
|
|
1663
|
+
}
|
|
1664
|
+
catch (error) {
|
|
1665
|
+
if (isCodexActiveWriterError(error) && previousAppServer && previousAppServer !== appServer) {
|
|
1666
|
+
// Some Codex builds acknowledge unsubscribe before unloading the
|
|
1667
|
+
// runtime. Archive/unarchive is a synchronous per-thread teardown
|
|
1668
|
+
// and preserves the same persisted thread id for the retry.
|
|
1669
|
+
await this.archiveThreadForHandoff(previousAppServer, appServer, agentSessionId);
|
|
1670
|
+
threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
// A legacy main session can retain a provider ID after its rollout was
|
|
1674
|
+
// removed externally. It is safe to recover before input submission;
|
|
1675
|
+
// topic bindings remain authoritative and fail closed.
|
|
1676
|
+
if (topicBinding || !isCodexNoRolloutError(error))
|
|
1677
|
+
throw error;
|
|
1678
|
+
if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
|
|
1679
|
+
throw error;
|
|
1680
|
+
const missingThreadId = agentSessionId;
|
|
1681
|
+
const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
|
|
1682
|
+
if (cleared !== 'cleared' && cleared !== 'already_clear') {
|
|
1683
|
+
const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
|
|
1684
|
+
recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
|
|
1685
|
+
throw recoveryError;
|
|
1686
|
+
}
|
|
1687
|
+
if (this.activeSessions.get(sessionId) === missingThreadId)
|
|
1688
|
+
this.activeSessions.delete(sessionId);
|
|
1689
|
+
this.threadProjectPaths.delete(missingThreadId);
|
|
1690
|
+
this.threadDelegationCarriers.delete(missingThreadId);
|
|
1691
|
+
this.unbindExternalToolBoundary(missingThreadId);
|
|
1692
|
+
this.clearTrackedApprovalItemsForThread(missingThreadId);
|
|
1693
|
+
agentSessionId = undefined;
|
|
1694
|
+
resumingThread = false;
|
|
1695
|
+
knownDelegationCarrier = undefined;
|
|
1696
|
+
delegationCarrierCandidate = this.createDelegationCarrier();
|
|
1697
|
+
threadOptions = {
|
|
1698
|
+
...threadOptions,
|
|
1699
|
+
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),
|
|
1700
|
+
};
|
|
1701
|
+
logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
|
|
1702
|
+
threadResponse = await appServer.threadStart(projectPath, threadOptions);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
else {
|
|
1707
|
+
threadResponse = await appServer.threadStart(projectPath, threadOptions);
|
|
1708
|
+
}
|
|
1213
1709
|
let threadId = threadResponse.thread?.id || agentSessionId;
|
|
1214
1710
|
if (!threadId)
|
|
1215
1711
|
throw new Error('Codex app-server did not return a thread id');
|
|
1712
|
+
this.markThreadSubscribed(appServer, threadId);
|
|
1216
1713
|
if (!resumingThread) {
|
|
1217
1714
|
carrierToPublish = { threadId, carrier: delegationCarrierCandidate };
|
|
1218
1715
|
}
|
|
@@ -1239,6 +1736,9 @@ export class CodexRunner {
|
|
|
1239
1736
|
const forkedThreadId = forked.thread?.id;
|
|
1240
1737
|
if (!forkedThreadId)
|
|
1241
1738
|
throw new Error('Codex delegation-carrier recovery fork did not return a thread id');
|
|
1739
|
+
this.markThreadSubscribed(appServer, forkedThreadId);
|
|
1740
|
+
await this.releaseThreadSubscription(appServer, staleThreadId, 'carrier-recovery-fork-source');
|
|
1741
|
+
await this.releaseThreadSubscription(appServer, forkedThreadId, 'carrier-recovery-fork');
|
|
1242
1742
|
threadId = forkedThreadId;
|
|
1243
1743
|
recoveredCarrier = delegationCarrierCandidate;
|
|
1244
1744
|
}
|
|
@@ -1247,28 +1747,40 @@ export class CodexRunner {
|
|
|
1247
1747
|
else if (knownDelegationCarrier) {
|
|
1248
1748
|
carrierToPublish = { threadId, carrier: knownDelegationCarrier };
|
|
1249
1749
|
}
|
|
1250
|
-
agentSessionId = threadId;
|
|
1251
1750
|
const turn = modelOverride?.turn;
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|| bindingResult === 'legacy_updated';
|
|
1261
|
-
if (!bindingAccepted) {
|
|
1262
|
-
const error = new Error(`backend discovery rejected: ${bindingResult}`);
|
|
1263
|
-
error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
|
|
1264
|
-
throw error;
|
|
1751
|
+
if (!resumingThread) {
|
|
1752
|
+
// Keep a newly-created backend private to this runner until turn/start
|
|
1753
|
+
// has been accepted. In particular, a pending interrupt must not leave
|
|
1754
|
+
// a durable ID for a thread that never produced a rollout.
|
|
1755
|
+
this.provisionalSessions.set(sessionId, threadId);
|
|
1756
|
+
if (carrierToPublish) {
|
|
1757
|
+
this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
|
|
1758
|
+
}
|
|
1265
1759
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
this.
|
|
1269
|
-
|
|
1760
|
+
else {
|
|
1761
|
+
agentSessionId = threadId;
|
|
1762
|
+
const bindingResult = this.onSessionIdUpdate
|
|
1763
|
+
? ((turn === undefined
|
|
1764
|
+
? await this.onSessionIdUpdate(sessionId, threadId)
|
|
1765
|
+
: await this.onSessionIdUpdate(sessionId, threadId, { turn }))
|
|
1766
|
+
?? 'legacy_updated')
|
|
1767
|
+
: 'legacy_updated';
|
|
1768
|
+
const bindingAccepted = bindingResult === 'activated'
|
|
1769
|
+
|| bindingResult === 'already_active'
|
|
1770
|
+
|| bindingResult === 'legacy_updated';
|
|
1771
|
+
if (!bindingAccepted) {
|
|
1772
|
+
const error = new Error(`backend discovery rejected: ${bindingResult}`);
|
|
1773
|
+
error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
|
|
1774
|
+
await this.releaseThreadSubscription(appServer, threadId, 'backend-binding-rejected');
|
|
1775
|
+
throw error;
|
|
1776
|
+
}
|
|
1777
|
+
this.activeSessions.set(sessionId, threadId);
|
|
1778
|
+
if (carrierToPublish) {
|
|
1779
|
+
this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
|
|
1780
|
+
await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
|
|
1781
|
+
}
|
|
1782
|
+
this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
|
|
1270
1783
|
}
|
|
1271
|
-
this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
|
|
1272
1784
|
this.threadProjectPaths.set(threadId, path.resolve(projectPath));
|
|
1273
1785
|
this.bindExternalToolBoundary(threadId, externalToolConfig);
|
|
1274
1786
|
const controller = new AbortController();
|
|
@@ -1279,21 +1791,56 @@ export class CodexRunner {
|
|
|
1279
1791
|
controller.signal.addEventListener('abort', () => queue.end(), { once: true });
|
|
1280
1792
|
const state = {
|
|
1281
1793
|
threadId,
|
|
1794
|
+
sessionId,
|
|
1795
|
+
projectPath,
|
|
1282
1796
|
model: callModel,
|
|
1797
|
+
committed: resumingThread,
|
|
1283
1798
|
streamedAgentMessageIds: new Set(),
|
|
1284
1799
|
agentMessageDeltaText: new Map(),
|
|
1285
1800
|
completedItemIds: new Set(),
|
|
1286
1801
|
emittedEditCallIds: new Set(),
|
|
1287
1802
|
completedTurnIds: new Set(),
|
|
1288
1803
|
openToolCalls: new Map(),
|
|
1804
|
+
activeSubagentThreads: new Set(),
|
|
1805
|
+
turnCompleted: false,
|
|
1289
1806
|
};
|
|
1290
1807
|
const unsubscribe = appServer.onNotification(notification => {
|
|
1291
1808
|
// 仅从 turn/started 锁定权威 turnId — resume 时会有上一轮 turn 的残留通知
|
|
1292
1809
|
// (如 thread/tokenUsage/updated)先于新 turn 到达,不能用它们 latch turnId
|
|
1293
1810
|
const params = notification.params || {};
|
|
1294
1811
|
const notifThreadId = params.threadId ?? params.thread_id;
|
|
1295
|
-
|
|
1812
|
+
const item = params.item && typeof params.item === 'object' ? params.item : undefined;
|
|
1813
|
+
const emittedByChild = typeof notifThreadId === 'string'
|
|
1814
|
+
&& this.childThreadSessions.get(notifThreadId) === sessionId;
|
|
1815
|
+
if (notifThreadId !== undefined && notifThreadId !== threadId && !emittedByChild)
|
|
1816
|
+
return;
|
|
1817
|
+
this.trackSubagentLifecycle(notification, state, appServer);
|
|
1818
|
+
if (emittedByChild && notifThreadId !== threadId) {
|
|
1819
|
+
const childTurnId = this.extractTurnId(notification);
|
|
1820
|
+
if (notification.method === 'turn/started' && childTurnId) {
|
|
1821
|
+
this.activeSubagentTurns.set(notifThreadId, {
|
|
1822
|
+
sessionId,
|
|
1823
|
+
parentThreadId: threadId,
|
|
1824
|
+
turnId: childTurnId,
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
else if (notification.method === 'turn/completed') {
|
|
1828
|
+
const activeChildTurn = this.activeSubagentTurns.get(notifThreadId);
|
|
1829
|
+
if (!childTurnId || activeChildTurn?.turnId === childTurnId) {
|
|
1830
|
+
this.activeSubagentTurns.delete(notifThreadId);
|
|
1831
|
+
}
|
|
1832
|
+
state.activeSubagentThreads.delete(notifThreadId);
|
|
1833
|
+
void this.releaseThreadSubscription(appServer, notifThreadId, 'subagent-turn-finished');
|
|
1834
|
+
this.unregisterChildThread(notifThreadId);
|
|
1835
|
+
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
1836
|
+
}
|
|
1837
|
+
if (this.isSubagentActivityItem(item))
|
|
1838
|
+
queue.push(notification);
|
|
1839
|
+
if (notification.method === 'item/completed' && state.turnCompleted) {
|
|
1840
|
+
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
1841
|
+
}
|
|
1296
1842
|
return;
|
|
1843
|
+
}
|
|
1297
1844
|
if (notification.method === 'turn/started') {
|
|
1298
1845
|
const startedTurnId = this.extractTurnId(notification);
|
|
1299
1846
|
if (startedTurnId && !state.turnId) {
|
|
@@ -1309,15 +1856,22 @@ export class CodexRunner {
|
|
|
1309
1856
|
return;
|
|
1310
1857
|
queue.push(notification);
|
|
1311
1858
|
// 仅在已锁定 turnId 后才允许 turn/completed 结束队列,避免残留的旧 turn/completed 误关
|
|
1312
|
-
if (notification.method === 'turn/completed' && state.turnId)
|
|
1313
|
-
|
|
1859
|
+
if (notification.method === 'turn/completed' && state.turnId) {
|
|
1860
|
+
state.turnCompleted = true;
|
|
1861
|
+
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
1862
|
+
}
|
|
1863
|
+
else if (notification.method === 'item/completed' && state.turnCompleted) {
|
|
1864
|
+
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
1865
|
+
}
|
|
1314
1866
|
});
|
|
1315
1867
|
if (this.consumePendingInterrupt(sessionId)) {
|
|
1316
1868
|
controller.abort('User interrupt');
|
|
1317
1869
|
this.activeAbortControllers.delete(sessionId);
|
|
1318
1870
|
this.activeStreams.delete(sessionId);
|
|
1871
|
+
if (!resumingThread)
|
|
1872
|
+
this.cleanupProvisionalThread(sessionId, threadId);
|
|
1319
1873
|
logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
|
|
1320
|
-
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
|
|
1874
|
+
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
|
|
1321
1875
|
}
|
|
1322
1876
|
try {
|
|
1323
1877
|
const turnResponse = await appServer.turnStart(threadId, input, {
|
|
@@ -1327,6 +1881,14 @@ export class CodexRunner {
|
|
|
1327
1881
|
approvalPolicy: effectiveApprovalPolicy,
|
|
1328
1882
|
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
1329
1883
|
});
|
|
1884
|
+
if (!resumingThread) {
|
|
1885
|
+
state.commitProvisional = async () => {
|
|
1886
|
+
const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
|
|
1887
|
+
if (committed)
|
|
1888
|
+
agentSessionId = threadId;
|
|
1889
|
+
return committed;
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1330
1892
|
const turnId = turnResponse.turn?.id;
|
|
1331
1893
|
if (turnId && !state.turnId) {
|
|
1332
1894
|
state.turnId = turnId;
|
|
@@ -1341,19 +1903,24 @@ export class CodexRunner {
|
|
|
1341
1903
|
}
|
|
1342
1904
|
const status = turnResponse.turn?.status;
|
|
1343
1905
|
if (status === 'completed' || status === 'failed') {
|
|
1906
|
+
this.primeSubagentStateFromTurn(turnResponse.turn, state, appServer);
|
|
1344
1907
|
queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
|
|
1345
|
-
|
|
1908
|
+
state.turnCompleted = true;
|
|
1909
|
+
this.endAppServerQueueWhenSubagentsDone(queue, state);
|
|
1346
1910
|
}
|
|
1347
1911
|
}
|
|
1348
1912
|
catch (error) {
|
|
1349
1913
|
unsubscribe();
|
|
1914
|
+
await this.releaseThreadSubscription(appServer, threadId, 'turn-start-failed');
|
|
1350
1915
|
this.activeAbortControllers.delete(sessionId);
|
|
1351
1916
|
this.activeTurns.delete(sessionId);
|
|
1352
1917
|
this.pendingInterrupts.delete(sessionId);
|
|
1353
1918
|
this.cleanupTempFiles(tempFiles);
|
|
1919
|
+
if (!resumingThread)
|
|
1920
|
+
this.cleanupProvisionalThread(sessionId, threadId);
|
|
1354
1921
|
throw error;
|
|
1355
1922
|
}
|
|
1356
|
-
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
|
|
1923
|
+
return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
|
|
1357
1924
|
}
|
|
1358
1925
|
// ── Interrupt ──
|
|
1359
1926
|
async interrupt(sessionKey) {
|
|
@@ -1364,6 +1931,7 @@ export class CodexRunner {
|
|
|
1364
1931
|
const interruptTurn = activeTurn
|
|
1365
1932
|
? this.interruptAppServerTurn(activeTurn.threadId, activeTurn.turnId)
|
|
1366
1933
|
: Promise.resolve();
|
|
1934
|
+
const interruptSubagents = this.interruptSubagentTurns(sessionKey);
|
|
1367
1935
|
if (!activeTurn)
|
|
1368
1936
|
this.rememberPendingInterrupt(sessionKey);
|
|
1369
1937
|
if (controller)
|
|
@@ -1374,7 +1942,17 @@ export class CodexRunner {
|
|
|
1374
1942
|
this.activeTurns.delete(sessionKey);
|
|
1375
1943
|
logger.info(`[CodexRunner] Interrupted session: ${sessionKey}`);
|
|
1376
1944
|
}
|
|
1377
|
-
await interruptTurn;
|
|
1945
|
+
await Promise.all([interruptTurn, interruptSubagents]);
|
|
1946
|
+
}
|
|
1947
|
+
async interruptSubagentTurns(sessionId, parentThreadId) {
|
|
1948
|
+
const entries = [...this.activeSubagentTurns.entries()]
|
|
1949
|
+
.filter(([, active]) => active.sessionId === sessionId && (!parentThreadId || active.parentThreadId === parentThreadId));
|
|
1950
|
+
await Promise.all(entries.map(async ([childThreadId, active]) => {
|
|
1951
|
+
await this.interruptAppServerTurn(childThreadId, active.turnId).catch(() => { });
|
|
1952
|
+
const current = this.activeSubagentTurns.get(childThreadId);
|
|
1953
|
+
if (current === active)
|
|
1954
|
+
this.activeSubagentTurns.delete(childThreadId);
|
|
1955
|
+
}));
|
|
1378
1956
|
}
|
|
1379
1957
|
rememberPendingInterrupt(sessionId) {
|
|
1380
1958
|
this.pendingInterrupts.set(sessionId, Date.now());
|
|
@@ -1394,7 +1972,12 @@ export class CodexRunner {
|
|
|
1394
1972
|
}
|
|
1395
1973
|
async interruptAppServerTurn(threadId, turnId) {
|
|
1396
1974
|
try {
|
|
1397
|
-
|
|
1975
|
+
const sessionId = this.findSessionKeyByThread(threadId);
|
|
1976
|
+
const subscribed = this.threadSubscriptions.get(threadId);
|
|
1977
|
+
const client = subscribed
|
|
1978
|
+
? subscribed.client
|
|
1979
|
+
: this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
|
|
1980
|
+
await client.turnInterrupt(threadId, turnId);
|
|
1398
1981
|
}
|
|
1399
1982
|
catch (error) {
|
|
1400
1983
|
logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
|
|
@@ -1437,10 +2020,12 @@ export class CodexRunner {
|
|
|
1437
2020
|
}
|
|
1438
2021
|
async closeSession(sessionId) {
|
|
1439
2022
|
const capturedThreadId = this.activeSessions.get(sessionId);
|
|
2023
|
+
const capturedProvisionalThreadId = this.provisionalSessions.get(sessionId);
|
|
1440
2024
|
const capturedPermissionContext = this.permissionContexts.get(sessionId);
|
|
1441
2025
|
const capturedStream = this.activeStreams.get(sessionId);
|
|
1442
2026
|
const capturedController = this.activeAbortControllers.get(sessionId);
|
|
1443
2027
|
const capturedTurn = this.activeTurns.get(sessionId);
|
|
2028
|
+
const capturedSteeringTail = this.steeringTails.get(sessionId);
|
|
1444
2029
|
this.permissionContexts.get(sessionId)?.pauseController?.cancel();
|
|
1445
2030
|
// Do not call interrupt(sessionId) here: a new run may have replaced the
|
|
1446
2031
|
// session-scoped maps while an older close was waiting. Interrupt and
|
|
@@ -1448,12 +2033,24 @@ export class CodexRunner {
|
|
|
1448
2033
|
if (capturedTurn) {
|
|
1449
2034
|
await this.interruptAppServerTurn(capturedTurn.threadId, capturedTurn.turnId).catch(() => { });
|
|
1450
2035
|
}
|
|
2036
|
+
const capturedParentThreadId = capturedThreadId ?? capturedProvisionalThreadId;
|
|
2037
|
+
if (capturedParentThreadId) {
|
|
2038
|
+
await this.interruptSubagentTurns(sessionId, capturedParentThreadId);
|
|
2039
|
+
}
|
|
2040
|
+
else if (!this.activeSessions.has(sessionId) && !this.provisionalSessions.has(sessionId)) {
|
|
2041
|
+
await this.interruptSubagentTurns(sessionId);
|
|
2042
|
+
}
|
|
1451
2043
|
capturedController?.abort('Session closed');
|
|
2044
|
+
const capturedSubscription = capturedThreadId ? this.threadSubscriptions.get(capturedThreadId) : undefined;
|
|
1452
2045
|
const lifecycleReplaced = (capturedStream !== undefined && this.activeStreams.get(sessionId) !== capturedStream)
|
|
1453
2046
|
|| (capturedController !== undefined && this.activeAbortControllers.get(sessionId) !== capturedController)
|
|
1454
2047
|
|| (capturedTurn !== undefined && this.activeTurns.get(sessionId) !== capturedTurn)
|
|
1455
2048
|
|| (capturedPermissionContext !== undefined && this.permissionContexts.get(sessionId) !== capturedPermissionContext)
|
|
1456
2049
|
|| (capturedThreadId !== undefined && this.activeSessions.get(sessionId) !== capturedThreadId);
|
|
2050
|
+
if (capturedSubscription && !lifecycleReplaced
|
|
2051
|
+
&& this.threadSubscriptions.get(capturedThreadId) === capturedSubscription) {
|
|
2052
|
+
await this.releaseThreadSubscription(capturedSubscription.client, capturedThreadId, 'session-closed');
|
|
2053
|
+
}
|
|
1457
2054
|
if (capturedStream !== undefined && this.activeStreams.get(sessionId) === capturedStream)
|
|
1458
2055
|
this.activeStreams.delete(sessionId);
|
|
1459
2056
|
if (capturedController !== undefined && this.activeAbortControllers.get(sessionId) === capturedController)
|
|
@@ -1464,6 +2061,10 @@ export class CodexRunner {
|
|
|
1464
2061
|
this.pendingInterrupts.delete(sessionId);
|
|
1465
2062
|
if (this.permissionContexts.get(sessionId) === capturedPermissionContext)
|
|
1466
2063
|
this.permissionContexts.delete(sessionId);
|
|
2064
|
+
if (this.steeringTails.get(sessionId) === capturedSteeringTail)
|
|
2065
|
+
this.steeringTails.delete(sessionId);
|
|
2066
|
+
if (capturedProvisionalThreadId)
|
|
2067
|
+
this.cleanupProvisionalThread(sessionId, capturedProvisionalThreadId);
|
|
1467
2068
|
// Session-level configuration and thread indexes are shared by later
|
|
1468
2069
|
// runs. Only remove them when the captured lifecycle still owns the key.
|
|
1469
2070
|
if (!lifecycleReplaced) {
|
|
@@ -1477,6 +2078,7 @@ export class CodexRunner {
|
|
|
1477
2078
|
this.unbindExternalToolBoundary(capturedThreadId);
|
|
1478
2079
|
}
|
|
1479
2080
|
this.clearTrackedApprovalItemsForThread(capturedThreadId);
|
|
2081
|
+
this.cleanupChildThreadsForSession(sessionId);
|
|
1480
2082
|
this.pruneThreadOperationLocks();
|
|
1481
2083
|
}
|
|
1482
2084
|
}
|
|
@@ -1504,7 +2106,13 @@ export class CodexRunner {
|
|
|
1504
2106
|
async clearSession(sessionId, _agentSessionId, _projectPath) {
|
|
1505
2107
|
// Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
|
|
1506
2108
|
const threadId = this.activeSessions.get(sessionId) ?? _agentSessionId;
|
|
2109
|
+
const subscription = this.threadSubscriptions.get(threadId);
|
|
2110
|
+
if (subscription) {
|
|
2111
|
+
await this.releaseThreadSubscription(subscription.client, threadId, 'session-cleared');
|
|
2112
|
+
}
|
|
1507
2113
|
this.activeSessions.delete(sessionId);
|
|
2114
|
+
this.activeTurns.delete(sessionId);
|
|
2115
|
+
this.steeringTails.delete(sessionId);
|
|
1508
2116
|
this.chatModes.delete(sessionId);
|
|
1509
2117
|
this.cleanupSessionTempDir(sessionId);
|
|
1510
2118
|
this.threadProjectPaths.delete(threadId);
|
|
@@ -1522,8 +2130,12 @@ export class CodexRunner {
|
|
|
1522
2130
|
return result;
|
|
1523
2131
|
};
|
|
1524
2132
|
const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId, _sessionId), 'compact');
|
|
2133
|
+
let compactAppServer;
|
|
1525
2134
|
try {
|
|
1526
|
-
const
|
|
2135
|
+
const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
|
|
2136
|
+
const appServer = this.getAppServerClient(compactMode === 'fullaccess');
|
|
2137
|
+
compactAppServer = appServer;
|
|
2138
|
+
await this.prepareThreadSubscription(appServer, agentSessionId);
|
|
1527
2139
|
this.onCompactStart?.(_sessionId);
|
|
1528
2140
|
try {
|
|
1529
2141
|
await this.startAndWaitForCompact(appServer, agentSessionId);
|
|
@@ -1533,7 +2145,6 @@ export class CodexRunner {
|
|
|
1533
2145
|
if (!this.isThreadNotFoundError(error))
|
|
1534
2146
|
throw error;
|
|
1535
2147
|
logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
|
|
1536
|
-
const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
|
|
1537
2148
|
const compactModel = modelOverride?.model || this.model;
|
|
1538
2149
|
const compactEffort = modelOverride?.effortMode === 'model_default'
|
|
1539
2150
|
? undefined
|
|
@@ -1556,6 +2167,7 @@ export class CodexRunner {
|
|
|
1556
2167
|
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
1557
2168
|
config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
|
|
1558
2169
|
});
|
|
2170
|
+
this.markThreadSubscribed(appServer, agentSessionId);
|
|
1559
2171
|
this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
|
|
1560
2172
|
this.threadDelegationCarriers.set(agentSessionId, compactDelegationCarrier);
|
|
1561
2173
|
this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
|
|
@@ -1568,6 +2180,9 @@ export class CodexRunner {
|
|
|
1568
2180
|
return finish({ ok: false, code: 'sdk_error', message, durationMs: Date.now() - startedAt });
|
|
1569
2181
|
}
|
|
1570
2182
|
finally {
|
|
2183
|
+
if (compactAppServer) {
|
|
2184
|
+
await this.releaseThreadSubscription(compactAppServer, agentSessionId, 'compact-finished');
|
|
2185
|
+
}
|
|
1571
2186
|
release();
|
|
1572
2187
|
}
|
|
1573
2188
|
}
|
|
@@ -1708,7 +2323,8 @@ export class CodexRunner {
|
|
|
1708
2323
|
const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
1709
2324
|
const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
|
|
1710
2325
|
const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
|
|
1711
|
-
const appServer = this.
|
|
2326
|
+
const appServer = this.threadSubscriptions.get(agentSessionId)?.client
|
|
2327
|
+
?? this.getAppServerClient(mode === 'fullaccess');
|
|
1712
2328
|
const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
|
|
1713
2329
|
if (sessionKey)
|
|
1714
2330
|
await this.assertProactiveHookAvailability(sessionKey, appServer);
|
|
@@ -1724,9 +2340,11 @@ export class CodexRunner {
|
|
|
1724
2340
|
const forkedThreadId = response.thread?.id;
|
|
1725
2341
|
if (!forkedThreadId)
|
|
1726
2342
|
throw new Error('Codex fork did not return a thread id');
|
|
2343
|
+
this.markThreadSubscribed(appServer, forkedThreadId);
|
|
1727
2344
|
this.threadDelegationCarriers.set(forkedThreadId, delegationCarrier);
|
|
1728
2345
|
this.threadProjectPaths.set(forkedThreadId, path.resolve(projectPath));
|
|
1729
2346
|
this.bindExternalToolBoundary(forkedThreadId, externalToolConfig);
|
|
2347
|
+
await this.releaseThreadSubscription(appServer, forkedThreadId, 'fork-finished');
|
|
1730
2348
|
return forkedThreadId;
|
|
1731
2349
|
}
|
|
1732
2350
|
async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
|
|
@@ -1756,14 +2374,14 @@ export class CodexRunner {
|
|
|
1756
2374
|
}
|
|
1757
2375
|
}
|
|
1758
2376
|
async setSessionName(agentSessionId, name) {
|
|
1759
|
-
return this.
|
|
2377
|
+
return this.getAppServerClientForThread(agentSessionId).threadSetName(agentSessionId, name);
|
|
1760
2378
|
}
|
|
1761
2379
|
async updateSessionMetadata(agentSessionId, metadata) {
|
|
1762
2380
|
const gitInfo = metadata?.gitInfo && typeof metadata.gitInfo === 'object' ? metadata.gitInfo : undefined;
|
|
1763
|
-
return this.
|
|
2381
|
+
return this.getAppServerClientForThread(agentSessionId).threadMetadataUpdate(agentSessionId, gitInfo);
|
|
1764
2382
|
}
|
|
1765
2383
|
async getSessionMessages(agentSessionId, projectPath) {
|
|
1766
|
-
const response = await this.
|
|
2384
|
+
const response = await this.getAppServerClientForThread(agentSessionId).threadRead(agentSessionId, true);
|
|
1767
2385
|
return this.mapThreadToSessionMessages(response, agentSessionId);
|
|
1768
2386
|
}
|
|
1769
2387
|
approvalItemKey(threadId, turnId, itemId) {
|
|
@@ -1774,13 +2392,14 @@ export class CodexRunner {
|
|
|
1774
2392
|
trackApprovalItemNotification(notification) {
|
|
1775
2393
|
const params = (notification.params || {});
|
|
1776
2394
|
const item = params.item && typeof params.item === 'object' ? params.item : undefined;
|
|
1777
|
-
const itemId = item?.id ?? params.itemId;
|
|
2395
|
+
const itemId = item?.id ?? params.itemId ?? params.item_id;
|
|
1778
2396
|
const threadId = this.requestThreadId(params);
|
|
1779
|
-
const
|
|
2397
|
+
const turnId = params.turnId ?? params.turn_id;
|
|
2398
|
+
const key = this.approvalItemKey(threadId, turnId, itemId);
|
|
1780
2399
|
if (notification.method === 'item/started' && key && item && threadId) {
|
|
1781
2400
|
this.trackedApprovalItems.set(key, {
|
|
1782
2401
|
threadId,
|
|
1783
|
-
turnId
|
|
2402
|
+
turnId,
|
|
1784
2403
|
itemId,
|
|
1785
2404
|
item: { ...item },
|
|
1786
2405
|
});
|
|
@@ -1790,7 +2409,7 @@ export class CodexRunner {
|
|
|
1790
2409
|
const tracked = this.trackedApprovalItems.get(key);
|
|
1791
2410
|
this.trackedApprovalItems.set(key, {
|
|
1792
2411
|
threadId,
|
|
1793
|
-
turnId
|
|
2412
|
+
turnId,
|
|
1794
2413
|
itemId,
|
|
1795
2414
|
item: {
|
|
1796
2415
|
...(tracked?.item ?? { id: itemId, type: 'fileChange' }),
|
|
@@ -1814,22 +2433,40 @@ export class CodexRunner {
|
|
|
1814
2433
|
this.trackedApprovalItems.delete(trackedKey);
|
|
1815
2434
|
}
|
|
1816
2435
|
}
|
|
2436
|
+
this.clearPendingToolDenialsForTurn(threadId, completedTurnId);
|
|
1817
2437
|
}
|
|
1818
2438
|
}
|
|
1819
2439
|
findTrackedApprovalItem(params) {
|
|
1820
|
-
const key = this.approvalItemKey(this.requestThreadId(params), params.turnId, params.itemId);
|
|
2440
|
+
const key = this.approvalItemKey(this.requestThreadId(params), params.turnId ?? params.turn_id, params.itemId ?? params.item_id);
|
|
1821
2441
|
return key ? this.trackedApprovalItems.get(key)?.item : undefined;
|
|
1822
2442
|
}
|
|
1823
2443
|
requestThreadId(params) {
|
|
1824
|
-
const
|
|
1825
|
-
|
|
2444
|
+
const threadIdValue = params.threadId ?? params.thread_id;
|
|
2445
|
+
const threadId = typeof threadIdValue === 'string' && threadIdValue.length > 0
|
|
2446
|
+
? threadIdValue
|
|
1826
2447
|
: undefined;
|
|
1827
2448
|
if (threadId)
|
|
1828
2449
|
return threadId;
|
|
1829
|
-
|
|
1830
|
-
|
|
2450
|
+
const conversationIdValue = params.conversationId ?? params.conversation_id;
|
|
2451
|
+
return typeof conversationIdValue === 'string' && conversationIdValue.length > 0
|
|
2452
|
+
? conversationIdValue
|
|
1831
2453
|
: undefined;
|
|
1832
2454
|
}
|
|
2455
|
+
clearPendingToolDenialsForTurn(threadId, turnId) {
|
|
2456
|
+
for (const key of this.pendingToolDenials.keys()) {
|
|
2457
|
+
let parsed;
|
|
2458
|
+
try {
|
|
2459
|
+
parsed = JSON.parse(key);
|
|
2460
|
+
}
|
|
2461
|
+
catch {
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
if (!Array.isArray(parsed) || parsed[0] !== threadId)
|
|
2465
|
+
continue;
|
|
2466
|
+
if (!turnId || parsed[1] === turnId)
|
|
2467
|
+
this.pendingToolDenials.delete(key);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
1833
2470
|
clearTrackedApprovalItemsForThread(threadId) {
|
|
1834
2471
|
if (!threadId)
|
|
1835
2472
|
return;
|
|
@@ -1837,6 +2474,73 @@ export class CodexRunner {
|
|
|
1837
2474
|
if (tracked.threadId === threadId)
|
|
1838
2475
|
this.trackedApprovalItems.delete(key);
|
|
1839
2476
|
}
|
|
2477
|
+
for (const key of this.pendingToolDenials.keys()) {
|
|
2478
|
+
let parsed;
|
|
2479
|
+
try {
|
|
2480
|
+
parsed = JSON.parse(key);
|
|
2481
|
+
}
|
|
2482
|
+
catch {
|
|
2483
|
+
continue;
|
|
2484
|
+
}
|
|
2485
|
+
if (Array.isArray(parsed) && parsed[0] === threadId)
|
|
2486
|
+
this.pendingToolDenials.delete(key);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
rememberToolDenial(request, params, denial) {
|
|
2490
|
+
const itemId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
|
|
2491
|
+
const threadId = this.requestThreadId(params);
|
|
2492
|
+
const rawTurnId = params.turnId ?? params.turn_id;
|
|
2493
|
+
const turnId = typeof rawTurnId === 'string' ? rawTurnId : this.findActiveTurnId(threadId);
|
|
2494
|
+
const key = this.approvalItemKey(threadId, turnId, itemId);
|
|
2495
|
+
if (!key)
|
|
2496
|
+
return;
|
|
2497
|
+
this.pendingToolDenials.set(key, {
|
|
2498
|
+
...denial,
|
|
2499
|
+
...(request.id !== undefined ? { requestId: String(request.id) } : {}),
|
|
2500
|
+
method: request.method,
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
steerPolicyReason(sessionId, policyCode, reason) {
|
|
2504
|
+
const prefix = policyCode ? `策略拒绝(${policyCode})` : '策略拒绝';
|
|
2505
|
+
this.injectUserMessage(sessionId, `⚠️ ${prefix}:${reason}。命令未执行,请按提示重新执行正确的命令。`);
|
|
2506
|
+
}
|
|
2507
|
+
findActiveTurnId(threadId) {
|
|
2508
|
+
if (!threadId)
|
|
2509
|
+
return undefined;
|
|
2510
|
+
for (const active of this.activeTurns.values()) {
|
|
2511
|
+
if (active.threadId === threadId)
|
|
2512
|
+
return active.turnId;
|
|
2513
|
+
}
|
|
2514
|
+
return undefined;
|
|
2515
|
+
}
|
|
2516
|
+
takeToolDenial(state, itemId) {
|
|
2517
|
+
const exactKey = this.approvalItemKey(state.threadId, state.turnId, itemId);
|
|
2518
|
+
if (exactKey) {
|
|
2519
|
+
const exact = this.pendingToolDenials.get(exactKey);
|
|
2520
|
+
if (exact) {
|
|
2521
|
+
this.pendingToolDenials.delete(exactKey);
|
|
2522
|
+
return exact;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
// Legacy approval callbacks may not carry turnId. Consume the unique
|
|
2526
|
+
// matching item for this thread rather than losing the structured reason.
|
|
2527
|
+
const candidates = [];
|
|
2528
|
+
for (const [key, denial] of this.pendingToolDenials) {
|
|
2529
|
+
let parsed;
|
|
2530
|
+
try {
|
|
2531
|
+
parsed = JSON.parse(key);
|
|
2532
|
+
}
|
|
2533
|
+
catch {
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
if (!Array.isArray(parsed) || parsed[0] !== state.threadId || String(parsed[2]) !== String(itemId))
|
|
2537
|
+
continue;
|
|
2538
|
+
candidates.push([key, denial]);
|
|
2539
|
+
}
|
|
2540
|
+
if (candidates.length !== 1)
|
|
2541
|
+
return undefined;
|
|
2542
|
+
this.pendingToolDenials.delete(candidates[0][0]);
|
|
2543
|
+
return candidates[0][1];
|
|
1840
2544
|
}
|
|
1841
2545
|
async handleAppServerRequest(request) {
|
|
1842
2546
|
const params = (request.params || {});
|
|
@@ -1898,18 +2602,62 @@ export class CodexRunner {
|
|
|
1898
2602
|
// workspace, or unattended-trigger policy checks.
|
|
1899
2603
|
if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
|
|
1900
2604
|
if (!this.isTrustedFullAccessSession(sessionKey)) {
|
|
2605
|
+
const reason = 'Untrusted fullaccess execution context';
|
|
2606
|
+
this.rememberToolDenial(request, params, {
|
|
2607
|
+
decision: 'deny',
|
|
2608
|
+
decisionSource: 'policy',
|
|
2609
|
+
policyCode: 'fullaccess_context_untrusted',
|
|
2610
|
+
reason,
|
|
2611
|
+
});
|
|
2612
|
+
this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', 'fullaccess_context_untrusted', reason);
|
|
2613
|
+
setImmediate(() => this.steerPolicyReason(sessionKey, 'fullaccess_context_untrusted', reason));
|
|
1901
2614
|
return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
1902
2615
|
}
|
|
2616
|
+
// Fullaccess uses the current task delegation token in its shell
|
|
2617
|
+
// environment, so it must not enter the ordinary workspace/carrier
|
|
2618
|
+
// approval path even if the app-server asks for an infrastructure
|
|
2619
|
+
// approval despite approvalPolicy=never.
|
|
1903
2620
|
const response = this.toAppServerApprovalResponse(request.method, 'allow', toolInput, false);
|
|
1904
2621
|
this.logAppServerApprovalAudit(request, sessionKey, toolName, 'allow', 'approval', undefined, 'trusted fullaccess execution');
|
|
1905
2622
|
return response;
|
|
1906
2623
|
}
|
|
1907
|
-
//
|
|
1908
|
-
|
|
2624
|
+
// Keep the policy check in the approval path as a fail-closed fallback.
|
|
2625
|
+
// Some app-server tool routes can reach approval without invoking the
|
|
2626
|
+
// command hook; allowing here would bypass the proactive gate.
|
|
2627
|
+
const policyResult = (this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess'
|
|
2628
|
+
? undefined
|
|
2629
|
+
: this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
|
|
1909
2630
|
if (policyResult?.block) {
|
|
1910
2631
|
logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
|
|
1911
2632
|
`requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
|
|
1912
2633
|
`reason=${policyResult.reason ?? 'policy denied'} executed=false`);
|
|
2634
|
+
const policyCode = policyResult.policyCode ?? 'session_policy_hook';
|
|
2635
|
+
const policyReason = policyResult.reason ?? 'session policy denied approval';
|
|
2636
|
+
this.rememberToolDenial(request, params, {
|
|
2637
|
+
decision: 'deny',
|
|
2638
|
+
decisionSource: 'policy',
|
|
2639
|
+
policyCode,
|
|
2640
|
+
reason: policyReason,
|
|
2641
|
+
});
|
|
2642
|
+
// ResponseEngine's wrapped policy hook already steers Codex sessions
|
|
2643
|
+
// that have the pure managed-hook policy installed. Standalone callers
|
|
2644
|
+
// may only provide policyHook, so keep this fallback for them without
|
|
2645
|
+
// duplicating the same user message in production.
|
|
2646
|
+
if (!permissionContext?.preToolUsePolicyHook) {
|
|
2647
|
+
setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, policyReason));
|
|
2648
|
+
}
|
|
2649
|
+
const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
2650
|
+
this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', policyCode, policyReason);
|
|
2651
|
+
return response;
|
|
2652
|
+
}
|
|
2653
|
+
const summary = this.summarizeAppServerRequest(request.method, params);
|
|
2654
|
+
const reason = params.reason || params.decisionReason || undefined;
|
|
2655
|
+
const auditSummary = (summarizeToolInputForAudit(toolName, toolInput)
|
|
2656
|
+
|| summarizeToolInputForAudit('Bash', { command: summary })).slice(0, 512);
|
|
2657
|
+
const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
2658
|
+
const workspacePath = this.resolvePermissionWorkspacePath(params);
|
|
2659
|
+
if (!workspacePath) {
|
|
2660
|
+
logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
|
|
1913
2661
|
try {
|
|
1914
2662
|
await permissionContext?.recordExecutionAnomaly?.({
|
|
1915
2663
|
code: 'operation_blocked',
|
|
@@ -1918,41 +2666,39 @@ export class CodexRunner {
|
|
|
1918
2666
|
occurredAt: Date.now(),
|
|
1919
2667
|
toolName,
|
|
1920
2668
|
...(request.id !== undefined ? { requestId: String(request.id) } : {}),
|
|
1921
|
-
policyCode:
|
|
1922
|
-
decisionSource: '
|
|
2669
|
+
policyCode: 'approval_workspace_unknown',
|
|
2670
|
+
decisionSource: 'infrastructure',
|
|
1923
2671
|
agentAid: permissionContext?.selfAid,
|
|
1924
2672
|
agentName: permissionContext?.agentName,
|
|
1925
2673
|
sessionId: sessionKey,
|
|
1926
|
-
permissionMode: normalizeExecutionPermissionMode(
|
|
1927
|
-
summary:
|
|
2674
|
+
permissionMode: normalizeExecutionPermissionMode(sessionMode),
|
|
2675
|
+
summary: auditSummary,
|
|
1928
2676
|
effect: 'operation_skipped',
|
|
1929
2677
|
});
|
|
1930
2678
|
}
|
|
1931
2679
|
catch (error) {
|
|
1932
|
-
logger.warn(`[CodexRunner] failed to record
|
|
2680
|
+
logger.warn(`[CodexRunner] failed to record unknown-workspace approval denial: session=${sessionKey} error=${error instanceof Error ? error.message : String(error)}`);
|
|
1933
2681
|
}
|
|
1934
2682
|
const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
1935
|
-
this.
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
if (!workspacePath) {
|
|
1942
|
-
logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
|
|
1943
|
-
const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
2683
|
+
this.rememberToolDenial(request, params, {
|
|
2684
|
+
decision: 'deny',
|
|
2685
|
+
decisionSource: 'infrastructure',
|
|
2686
|
+
policyCode: 'approval_workspace_unknown',
|
|
2687
|
+
reason: 'thread workspace is unknown',
|
|
2688
|
+
});
|
|
1944
2689
|
this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', 'approval_workspace_unknown', 'thread workspace is unknown');
|
|
2690
|
+
setImmediate(() => this.steerPolicyReason(sessionKey, 'approval_workspace_unknown', 'thread workspace is unknown'));
|
|
1945
2691
|
return response;
|
|
1946
2692
|
}
|
|
1947
2693
|
const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
|
|
1948
|
-
const
|
|
1949
|
-
const logSummary = formatApprovalLogSummary(summary);
|
|
2694
|
+
const logSummary = formatApprovalLogSummary(auditSummary || summary);
|
|
1950
2695
|
logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${logSummary}`);
|
|
1951
2696
|
const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
|
|
1952
2697
|
|| request.method === 'execCommandApproval';
|
|
1953
2698
|
const managedEcIntent = isCommandApproval && this.isManagedEvolcoreCommandIntent(toolInput);
|
|
2699
|
+
const delegationCarrier = this.delegationCarrierForSession(sessionKey, typeof params.threadId === 'string' ? params.threadId : undefined);
|
|
1954
2700
|
const canonicalEcApproval = isCommandApproval
|
|
1955
|
-
? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey))
|
|
2701
|
+
? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey), delegationCarrier)
|
|
1956
2702
|
: undefined;
|
|
1957
2703
|
const approvedCommand = canonicalEcApproval?.argv;
|
|
1958
2704
|
const denyInfrastructure = async (policyCode, message) => {
|
|
@@ -1979,19 +2725,32 @@ export class CodexRunner {
|
|
|
1979
2725
|
logger.warn(`[CodexRunner] failed to record infrastructure approval denial: session=${sessionKey} code=${policyCode} error=${error instanceof Error ? error.message : String(error)}`);
|
|
1980
2726
|
}
|
|
1981
2727
|
const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
2728
|
+
this.rememberToolDenial(request, params, {
|
|
2729
|
+
decision: 'deny',
|
|
2730
|
+
decisionSource: 'infrastructure',
|
|
2731
|
+
policyCode,
|
|
2732
|
+
reason: message,
|
|
2733
|
+
});
|
|
1982
2734
|
this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', policyCode, message, canonicalEcApproval?.parseFailure);
|
|
2735
|
+
setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, message));
|
|
1983
2736
|
return response;
|
|
1984
2737
|
};
|
|
1985
2738
|
if (managedEcIntent && !approvedCommand) {
|
|
2739
|
+
if (canonicalEcApproval?.parseFailure?.issue === 'untrusted-shell') {
|
|
2740
|
+
return await denyInfrastructure('ec_shell_untrusted_executable', 'EC command approval denied because the shell executable failed Windows Authenticode trust validation');
|
|
2741
|
+
}
|
|
1986
2742
|
return await denyInfrastructure('ec_command_not_canonical', canonicalEcApproval?.parseFailure
|
|
1987
2743
|
? `EC command approval denied because canonical argv parsing failed (${canonicalEcApproval.parseFailure.issue})`
|
|
1988
2744
|
: 'EC command approval denied because no canonical argv could be derived');
|
|
1989
2745
|
}
|
|
1990
2746
|
try {
|
|
1991
|
-
const
|
|
2747
|
+
const resolution = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id, typeof params.itemId === 'string' ? params.itemId : typeof params.item_id === 'string' ? params.item_id : undefined);
|
|
2748
|
+
const decision = typeof resolution === 'string' ? resolution : resolution.decision;
|
|
2749
|
+
const resolutionMeta = typeof resolution === 'string' ? undefined : resolution;
|
|
1992
2750
|
if (decision !== 'deny' && managedEcIntent && approvedCommand) {
|
|
1993
2751
|
const carrierThreadIds = [
|
|
1994
2752
|
this.activeSessions.get(sessionKey),
|
|
2753
|
+
this.provisionalSessions.get(sessionKey),
|
|
1995
2754
|
typeof params.threadId === 'string' ? params.threadId : undefined,
|
|
1996
2755
|
typeof params.conversationId === 'string' ? params.conversationId : undefined,
|
|
1997
2756
|
];
|
|
@@ -2000,18 +2759,35 @@ export class CodexRunner {
|
|
|
2000
2759
|
// A carrier-bearing thread is managed even if its permission context
|
|
2001
2760
|
// was not wired with an arm callback; it must fail closed here rather
|
|
2002
2761
|
// than reaching daemon IPC with an unarmed carrier.
|
|
2762
|
+
if ((hasDelegationCarrier || canArmDelegation)
|
|
2763
|
+
&& canonicalEcApproval?.delegationToken
|
|
2764
|
+
&& canonicalEcApproval.delegationToken !== delegationCarrier) {
|
|
2765
|
+
return await denyInfrastructure('delegation_token_mismatch', 'EC command approval contains a delegation token different from the active thread carrier');
|
|
2766
|
+
}
|
|
2003
2767
|
if ((hasDelegationCarrier || canArmDelegation)
|
|
2004
2768
|
&& !this.armApprovedDelegationCommand(sessionKey, approvedCommand)) {
|
|
2005
2769
|
return await denyInfrastructure('delegation_not_armed', 'EC command approval failed to arm delegation');
|
|
2006
2770
|
}
|
|
2007
2771
|
}
|
|
2008
2772
|
const response = this.toAppServerApprovalResponse(request.method, decision, toolInput, unattended);
|
|
2009
|
-
|
|
2010
|
-
? '
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2773
|
+
const decisionSource = resolutionMeta?.decisionSource
|
|
2774
|
+
?? (sessionMode === 'auto' || sessionMode === 'readonly' ? 'policy' : 'approval');
|
|
2775
|
+
const policyCode = resolutionMeta?.policyCode
|
|
2776
|
+
?? (decisionSource === 'policy' ? 'permission_mode_denied' : undefined);
|
|
2777
|
+
const denialReason = resolutionMeta?.reason || reason || (policyCode
|
|
2778
|
+
? `permission mode ${sessionMode} denied approval`
|
|
2779
|
+
: 'user approval denied');
|
|
2780
|
+
if (decision === 'deny') {
|
|
2781
|
+
this.rememberToolDenial(request, params, {
|
|
2782
|
+
decision: 'deny',
|
|
2783
|
+
decisionSource,
|
|
2784
|
+
...(policyCode ? { policyCode } : {}),
|
|
2785
|
+
reason: denialReason,
|
|
2786
|
+
});
|
|
2787
|
+
setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, denialReason));
|
|
2788
|
+
}
|
|
2789
|
+
this.logAppServerApprovalAudit(request, sessionKey, toolName, decision === 'deny' ? 'deny' : 'allow', decision === 'deny' ? decisionSource : 'approval', decision === 'deny' ? policyCode : undefined, decision === 'deny'
|
|
2790
|
+
? denialReason
|
|
2015
2791
|
: 'approval accepted');
|
|
2016
2792
|
logger.info(`[CodexRunner] app-server approval response id=${request.id} method=${request.method} decision=${decision} response=${JSON.stringify(response)}`);
|
|
2017
2793
|
return response;
|
|
@@ -2024,8 +2800,23 @@ export class CodexRunner {
|
|
|
2024
2800
|
}
|
|
2025
2801
|
logAppServerApprovalAudit(request, sessionKey, toolName, decision, decisionSource, policyCode, reason, parseFailure) {
|
|
2026
2802
|
const context = this.permissionContexts.get(sessionKey);
|
|
2803
|
+
const params = (request.params || {});
|
|
2804
|
+
const rawCallId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
|
|
2805
|
+
const callId = typeof rawCallId === 'string' && rawCallId ? rawCallId : undefined;
|
|
2806
|
+
const toolInput = this.buildPermissionInput(request.method, params);
|
|
2807
|
+
const rawSummary = summarizeToolInputForAudit(toolName, toolInput);
|
|
2808
|
+
const auditSummary = (rawSummary
|
|
2809
|
+
|| summarizeToolInputForAudit('Bash', { command: this.summarizeAppServerRequest(request.method, params) })).slice(0, 512);
|
|
2810
|
+
const threadId = this.requestThreadId(params);
|
|
2811
|
+
const rawCwd = typeof toolInput.cwd === 'string' && toolInput.cwd
|
|
2812
|
+
? toolInput.cwd
|
|
2813
|
+
: typeof params.cwd === 'string' && params.cwd
|
|
2814
|
+
? params.cwd
|
|
2815
|
+
: undefined;
|
|
2027
2816
|
auditCodexApprovalDecision({
|
|
2028
2817
|
requestId: request.id !== undefined ? String(request.id) : undefined,
|
|
2818
|
+
callId,
|
|
2819
|
+
correlationId: callId,
|
|
2029
2820
|
method: request.method,
|
|
2030
2821
|
sessionId: sessionKey,
|
|
2031
2822
|
agentAid: context?.selfAid,
|
|
@@ -2036,11 +2827,16 @@ export class CodexRunner {
|
|
|
2036
2827
|
policyCode,
|
|
2037
2828
|
permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
|
|
2038
2829
|
role: context?.role,
|
|
2830
|
+
taskId: context?.taskId,
|
|
2831
|
+
summary: auditSummary,
|
|
2832
|
+
threadId,
|
|
2833
|
+
cwd: rawCwd,
|
|
2039
2834
|
reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
|
|
2040
2835
|
parseFailure,
|
|
2041
2836
|
});
|
|
2042
2837
|
logger.info(`[CodexRunner] app-server approval audit ${JSON.stringify({
|
|
2043
2838
|
requestId: request.id !== undefined ? String(request.id) : undefined,
|
|
2839
|
+
...(callId ? { callId, correlationId: callId } : {}),
|
|
2044
2840
|
method: request.method,
|
|
2045
2841
|
sessionId: sessionKey,
|
|
2046
2842
|
toolName,
|
|
@@ -2048,6 +2844,7 @@ export class CodexRunner {
|
|
|
2048
2844
|
decisionSource,
|
|
2049
2845
|
...(policyCode ? { policyCode } : {}),
|
|
2050
2846
|
...(parseFailure ? { parseFailure } : {}),
|
|
2847
|
+
...(auditSummary ? { summary: auditSummary } : {}),
|
|
2051
2848
|
reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
|
|
2052
2849
|
executed: false,
|
|
2053
2850
|
})}`);
|
|
@@ -2401,9 +3198,20 @@ export class CodexRunner {
|
|
|
2401
3198
|
if (activeThreadId === candidate)
|
|
2402
3199
|
return sessionKey;
|
|
2403
3200
|
}
|
|
3201
|
+
for (const [sessionKey, provisionalThreadId] of this.provisionalSessions.entries()) {
|
|
3202
|
+
if (provisionalThreadId === candidate)
|
|
3203
|
+
return sessionKey;
|
|
3204
|
+
}
|
|
3205
|
+
const childSessionKey = this.childThreadSessions.get(candidate);
|
|
3206
|
+
if (childSessionKey)
|
|
3207
|
+
return childSessionKey;
|
|
2404
3208
|
}
|
|
2405
3209
|
return candidates[0] || 'codex-app-server';
|
|
2406
3210
|
}
|
|
3211
|
+
isThreadOwnedBySession(sessionId, threadId) {
|
|
3212
|
+
const activeThread = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
|
|
3213
|
+
return activeThread === threadId || this.childThreadSessions.get(threadId) === sessionId;
|
|
3214
|
+
}
|
|
2407
3215
|
buildPermissionInput(method, params) {
|
|
2408
3216
|
if (method === 'item/permissions/requestApproval') {
|
|
2409
3217
|
return { permissions: params.permissions, cwd: params.cwd, reason: params.reason };
|
|
@@ -2465,6 +3273,12 @@ export class CodexRunner {
|
|
|
2465
3273
|
const threadProjectPath = this.threadProjectPaths.get(threadId);
|
|
2466
3274
|
if (threadProjectPath)
|
|
2467
3275
|
return threadProjectPath;
|
|
3276
|
+
const parentSession = this.childThreadSessions.get(threadId);
|
|
3277
|
+
const parentThread = parentSession
|
|
3278
|
+
? this.activeSessions.get(parentSession) ?? this.provisionalSessions.get(parentSession)
|
|
3279
|
+
: undefined;
|
|
3280
|
+
if (parentThread)
|
|
3281
|
+
return this.threadProjectPaths.get(parentThread);
|
|
2468
3282
|
}
|
|
2469
3283
|
return undefined;
|
|
2470
3284
|
}
|
|
@@ -2595,7 +3409,7 @@ export class CodexRunner {
|
|
|
2595
3409
|
return true;
|
|
2596
3410
|
});
|
|
2597
3411
|
}
|
|
2598
|
-
async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId) {
|
|
3412
|
+
async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId, callId) {
|
|
2599
3413
|
const permissionContext = this.permissionContexts.get(sessionKey);
|
|
2600
3414
|
const permissionPrompt = permissionContext?.sendPrompt ?? this.sendPromptFn;
|
|
2601
3415
|
const hasFilesystemPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
|
|
@@ -2632,6 +3446,8 @@ export class CodexRunner {
|
|
|
2632
3446
|
role: permissionContext?.role,
|
|
2633
3447
|
selfAid: permissionContext?.selfAid,
|
|
2634
3448
|
requestId: typeof requestId === 'string' || typeof requestId === 'number' ? String(requestId) : undefined,
|
|
3449
|
+
callId,
|
|
3450
|
+
correlationId: callId,
|
|
2635
3451
|
taskId: permissionContext?.taskId,
|
|
2636
3452
|
matchedPath,
|
|
2637
3453
|
});
|
|
@@ -2662,6 +3478,7 @@ export class CodexRunner {
|
|
|
2662
3478
|
// distinguishes this explicit empty value from runners without a
|
|
2663
3479
|
// session-temp capability.
|
|
2664
3480
|
managedTempDir: permissionContext?.managedTempDir ?? this.getManagedTempDir(sessionKey) ?? '',
|
|
3481
|
+
delegationCarrier: this.delegationCarrierForSession(sessionKey),
|
|
2665
3482
|
allowProtectedMetadata: false,
|
|
2666
3483
|
selfAid: permissionContext?.selfAid,
|
|
2667
3484
|
channel: permissionContext?.channel,
|
|
@@ -2678,7 +3495,12 @@ export class CodexRunner {
|
|
|
2678
3495
|
if (preflight.policyCode) {
|
|
2679
3496
|
await recordBlockedOperation(preflight.policyCode, toolInput, preflight.message ?? 'tool preflight denied', preflight.matchedPath);
|
|
2680
3497
|
}
|
|
2681
|
-
return
|
|
3498
|
+
return {
|
|
3499
|
+
decision: 'deny',
|
|
3500
|
+
decisionSource: 'policy',
|
|
3501
|
+
policyCode: preflight.policyCode ?? 'tool_preflight_denied',
|
|
3502
|
+
reason: preflight.message ?? 'tool preflight denied',
|
|
3503
|
+
};
|
|
2682
3504
|
}
|
|
2683
3505
|
if (preflight.behavior === 'allow')
|
|
2684
3506
|
return 'allow';
|
|
@@ -2793,7 +3615,7 @@ export class CodexRunner {
|
|
|
2793
3615
|
async rollbackSessionTurnsLocked(agentSessionId, numTurns) {
|
|
2794
3616
|
if (numTurns < 1)
|
|
2795
3617
|
return true;
|
|
2796
|
-
const response = await this.
|
|
3618
|
+
const response = await this.getAppServerClientForThread(agentSessionId).threadRollback(agentSessionId, numTurns);
|
|
2797
3619
|
return !!response.thread;
|
|
2798
3620
|
}
|
|
2799
3621
|
async rewindFiles(agentSessionId, projectPath, userMessageId) {
|
|
@@ -2992,17 +3814,138 @@ export class CodexRunner {
|
|
|
2992
3814
|
}
|
|
2993
3815
|
return !state.turnId || !turnId || turnId === state.turnId;
|
|
2994
3816
|
}
|
|
2995
|
-
|
|
3817
|
+
isSubagentActivityItem(item) {
|
|
3818
|
+
return typeof item?.type === 'string' && item.type.toLowerCase() === 'subagentactivity';
|
|
3819
|
+
}
|
|
3820
|
+
subagentThreadId(item) {
|
|
3821
|
+
const value = item?.agentThreadId ?? item?.agent_thread_id;
|
|
3822
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
3823
|
+
}
|
|
3824
|
+
subagentPath(item) {
|
|
3825
|
+
const value = item?.agentPath ?? item?.agent_path;
|
|
3826
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
3827
|
+
}
|
|
3828
|
+
subagentType(item) {
|
|
3829
|
+
const value = item?.agentType ?? item?.agent_type;
|
|
3830
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
3831
|
+
}
|
|
3832
|
+
registerChildThread(sessionId, childThreadId, projectPath) {
|
|
3833
|
+
const parentThreadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
|
|
3834
|
+
if (childThreadId === parentThreadId)
|
|
3835
|
+
return;
|
|
3836
|
+
this.childThreadSessions.set(childThreadId, sessionId);
|
|
3837
|
+
this.threadProjectPaths.set(childThreadId, path.resolve(projectPath));
|
|
3838
|
+
// Child approvals use the parent's capability boundary and delegation
|
|
3839
|
+
// carrier. This is inheritance, not a second permission decision.
|
|
3840
|
+
const parentBoundary = parentThreadId ? this.threadExternalToolBoundaries.get(parentThreadId) : undefined;
|
|
3841
|
+
const parentFingerprint = parentThreadId ? this.threadExternalToolFingerprints.get(parentThreadId) : undefined;
|
|
3842
|
+
if (parentBoundary)
|
|
3843
|
+
this.threadExternalToolBoundaries.set(childThreadId, parentBoundary);
|
|
3844
|
+
if (parentFingerprint)
|
|
3845
|
+
this.threadExternalToolFingerprints.set(childThreadId, parentFingerprint);
|
|
3846
|
+
const parentCarrier = parentThreadId ? this.threadDelegationCarriers.get(parentThreadId) : undefined;
|
|
3847
|
+
if (parentCarrier)
|
|
3848
|
+
this.threadDelegationCarriers.set(childThreadId, parentCarrier);
|
|
3849
|
+
}
|
|
3850
|
+
unregisterChildThread(childThreadId) {
|
|
3851
|
+
this.childThreadSessions.delete(childThreadId);
|
|
3852
|
+
this.activeSubagentTurns.delete(childThreadId);
|
|
3853
|
+
this.threadProjectPaths.delete(childThreadId);
|
|
3854
|
+
this.threadExternalToolBoundaries.delete(childThreadId);
|
|
3855
|
+
this.threadExternalToolFingerprints.delete(childThreadId);
|
|
3856
|
+
this.threadDelegationCarriers.delete(childThreadId);
|
|
3857
|
+
this.clearTrackedApprovalItemsForThread(childThreadId);
|
|
3858
|
+
}
|
|
3859
|
+
cleanupChildThreadsForSession(sessionId) {
|
|
3860
|
+
for (const [childThreadId, ownerSessionId] of this.childThreadSessions) {
|
|
3861
|
+
if (ownerSessionId === sessionId)
|
|
3862
|
+
this.unregisterChildThread(childThreadId);
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
trackSubagentLifecycle(notification, state, appServer) {
|
|
3866
|
+
const params = (notification.params || {});
|
|
3867
|
+
const item = params.item && typeof params.item === 'object' ? params.item : undefined;
|
|
3868
|
+
if (!this.isSubagentActivityItem(item))
|
|
3869
|
+
return;
|
|
3870
|
+
const childThreadId = this.subagentThreadId(item);
|
|
3871
|
+
if (!childThreadId)
|
|
3872
|
+
return;
|
|
3873
|
+
const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
|
|
3874
|
+
if (kind === 'started') {
|
|
3875
|
+
state.activeSubagentThreads.add(childThreadId);
|
|
3876
|
+
this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
|
|
3877
|
+
if (appServer)
|
|
3878
|
+
this.markThreadSubscribed(appServer, childThreadId);
|
|
3879
|
+
}
|
|
3880
|
+
if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
|
|
3881
|
+
state.activeSubagentThreads.delete(childThreadId);
|
|
3882
|
+
if (appServer)
|
|
3883
|
+
void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
|
|
3884
|
+
this.unregisterChildThread(childThreadId);
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
primeSubagentStateFromTurn(turn, state, appServer) {
|
|
3888
|
+
for (const item of this.getTurnItems(turn)) {
|
|
3889
|
+
if (!this.isSubagentActivityItem(item))
|
|
3890
|
+
continue;
|
|
3891
|
+
const childThreadId = this.subagentThreadId(item);
|
|
3892
|
+
const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
|
|
3893
|
+
if (!childThreadId)
|
|
3894
|
+
continue;
|
|
3895
|
+
if (kind === 'started') {
|
|
3896
|
+
state.activeSubagentThreads.add(childThreadId);
|
|
3897
|
+
this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
|
|
3898
|
+
if (appServer)
|
|
3899
|
+
this.markThreadSubscribed(appServer, childThreadId);
|
|
3900
|
+
}
|
|
3901
|
+
else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
|
|
3902
|
+
state.activeSubagentThreads.delete(childThreadId);
|
|
3903
|
+
if (appServer)
|
|
3904
|
+
void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
|
|
3905
|
+
this.unregisterChildThread(childThreadId);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
endAppServerQueueWhenSubagentsDone(queue, state) {
|
|
3910
|
+
if (!state.turnCompleted)
|
|
3911
|
+
return;
|
|
3912
|
+
if (state.activeSubagentThreads.size > 0) {
|
|
3913
|
+
logger.info(`[CodexRunner] parent turn completed; waiting for subagent thread(s): `
|
|
3914
|
+
+ `${[...state.activeSubagentThreads].join(',')}`);
|
|
3915
|
+
return;
|
|
3916
|
+
}
|
|
3917
|
+
queue.end();
|
|
3918
|
+
}
|
|
3919
|
+
async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, appServer, tempFiles) {
|
|
2996
3920
|
try {
|
|
2997
|
-
|
|
3921
|
+
let sessionIdEmitted = state.committed;
|
|
3922
|
+
if (sessionIdEmitted)
|
|
3923
|
+
yield { type: 'session_id', sessionId: state.threadId };
|
|
2998
3924
|
for await (const notification of notifications) {
|
|
2999
3925
|
if (!this.activeAbortControllers.has(sessionId))
|
|
3000
3926
|
break;
|
|
3927
|
+
if (!state.committed && notification.method === 'turn/completed') {
|
|
3928
|
+
const turn = notification.params?.turn ?? {};
|
|
3929
|
+
if (turn.status === 'completed') {
|
|
3930
|
+
const committed = await state.commitProvisional?.();
|
|
3931
|
+
if (!committed) {
|
|
3932
|
+
const staleError = new Error('Codex provisional thread was superseded before commit');
|
|
3933
|
+
staleError.code = 'RUNNER_START_SUPERSEDED';
|
|
3934
|
+
throw staleError;
|
|
3935
|
+
}
|
|
3936
|
+
state.committed = true;
|
|
3937
|
+
if (!sessionIdEmitted) {
|
|
3938
|
+
sessionIdEmitted = true;
|
|
3939
|
+
yield { type: 'session_id', sessionId: state.threadId };
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3001
3943
|
yield* this.mapAppServerNotification(notification, sessionId, state);
|
|
3002
3944
|
}
|
|
3003
3945
|
}
|
|
3004
3946
|
finally {
|
|
3005
3947
|
unsubscribe();
|
|
3948
|
+
await this.releaseThreadSubscription(appServer, state.threadId, 'turn-finished');
|
|
3006
3949
|
if (this.activeAbortControllers.get(sessionId) === controller) {
|
|
3007
3950
|
this.activeAbortControllers.delete(sessionId);
|
|
3008
3951
|
}
|
|
@@ -3011,6 +3954,11 @@ export class CodexRunner {
|
|
|
3011
3954
|
&& (!state.turnId || activeTurn.turnId === state.turnId)) {
|
|
3012
3955
|
this.activeTurns.delete(sessionId);
|
|
3013
3956
|
}
|
|
3957
|
+
if (!state.committed)
|
|
3958
|
+
this.cleanupProvisionalThread(sessionId, state.threadId);
|
|
3959
|
+
for (const childThreadId of state.activeSubagentThreads)
|
|
3960
|
+
this.unregisterChildThread(childThreadId);
|
|
3961
|
+
state.activeSubagentThreads.clear();
|
|
3014
3962
|
this.cleanupTempFiles(tempFiles);
|
|
3015
3963
|
}
|
|
3016
3964
|
}
|
|
@@ -3078,12 +4026,16 @@ export class CodexRunner {
|
|
|
3078
4026
|
}
|
|
3079
4027
|
case 'turn/completed': {
|
|
3080
4028
|
const turn = params.turn || {};
|
|
3081
|
-
const turnId = turn.id || params.turnId;
|
|
4029
|
+
const turnId = turn.id || params.turnId || state.turnId;
|
|
3082
4030
|
if (turnId && state.completedTurnIds.has(turnId))
|
|
3083
4031
|
break;
|
|
3084
4032
|
if (turnId)
|
|
3085
4033
|
state.completedTurnIds.add(turnId);
|
|
3086
|
-
this.activeTurns.
|
|
4034
|
+
const activeTurn = this.activeTurns.get(sessionId);
|
|
4035
|
+
if (activeTurn?.threadId === state.threadId && activeTurn.turnId === turnId) {
|
|
4036
|
+
this.activeTurns.delete(sessionId);
|
|
4037
|
+
}
|
|
4038
|
+
this.clearPendingToolDenialsForTurn(state.threadId, turnId);
|
|
3087
4039
|
yield* this.reconcileOpenAppServerToolCalls(state, turnId);
|
|
3088
4040
|
if (turn.status === 'failed' && turn.error?.message) {
|
|
3089
4041
|
if (isRetryableError(new Error(turn.error.message))) {
|
|
@@ -3204,11 +4156,36 @@ export class CodexRunner {
|
|
|
3204
4156
|
};
|
|
3205
4157
|
break;
|
|
3206
4158
|
case 'subAgentActivity':
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
4159
|
+
case 'SubAgentActivity': {
|
|
4160
|
+
const childThreadId = this.subagentThreadId(item);
|
|
4161
|
+
if (!childThreadId)
|
|
4162
|
+
break;
|
|
4163
|
+
const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
|
|
4164
|
+
const description = this.subagentPath(item) ?? childThreadId;
|
|
4165
|
+
if (kind === 'started') {
|
|
4166
|
+
yield {
|
|
4167
|
+
type: 'task_started',
|
|
4168
|
+
taskId: childThreadId,
|
|
4169
|
+
taskKind: 'agent',
|
|
4170
|
+
description,
|
|
4171
|
+
subagentType: this.subagentType(item),
|
|
4172
|
+
};
|
|
4173
|
+
}
|
|
4174
|
+
else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
|
|
4175
|
+
yield {
|
|
4176
|
+
type: 'task_notification',
|
|
4177
|
+
taskId: childThreadId,
|
|
4178
|
+
taskKind: 'agent',
|
|
4179
|
+
description,
|
|
4180
|
+
status: kind === 'completed' ? 'completed' : kind,
|
|
4181
|
+
summary: description,
|
|
4182
|
+
};
|
|
4183
|
+
}
|
|
4184
|
+
else {
|
|
4185
|
+
yield { type: 'task_progress', taskId: childThreadId, taskKind: 'agent', description, summary: `Subagent ${item.kind}: ${description}` };
|
|
4186
|
+
}
|
|
3211
4187
|
break;
|
|
4188
|
+
}
|
|
3212
4189
|
case 'enteredReviewMode':
|
|
3213
4190
|
yield { type: 'task_progress', summary: item.review || 'Review mode entered' };
|
|
3214
4191
|
break;
|
|
@@ -3223,6 +4200,59 @@ export class CodexRunner {
|
|
|
3223
4200
|
*mapAppServerItemCompleted(item, state) {
|
|
3224
4201
|
if (!item)
|
|
3225
4202
|
return;
|
|
4203
|
+
const denial = this.takeToolDenial(state, item.id);
|
|
4204
|
+
const denialFields = denial
|
|
4205
|
+
? {
|
|
4206
|
+
isError: true,
|
|
4207
|
+
error: denial.reason,
|
|
4208
|
+
errorCode: errorCodeForToolDenial(denial.decisionSource, denial.policyCode),
|
|
4209
|
+
decision: 'deny',
|
|
4210
|
+
decisionSource: denial.decisionSource,
|
|
4211
|
+
policyCode: denial.policyCode,
|
|
4212
|
+
reason: denial.reason,
|
|
4213
|
+
requestId: denial.requestId,
|
|
4214
|
+
executed: false,
|
|
4215
|
+
executionState: 'blocked',
|
|
4216
|
+
}
|
|
4217
|
+
: undefined;
|
|
4218
|
+
const itemErrorText = outputTextForManagedHook(typeof item.error === 'string' ? item.error : item.error?.message);
|
|
4219
|
+
const hookErrorText = /(?:Command|Tool call) blocked by PreToolUse hook\s*:/i.test(itemErrorText)
|
|
4220
|
+
? itemErrorText
|
|
4221
|
+
: undefined;
|
|
4222
|
+
const managedHookDenial = (item.status === 'failed' || item.status === 'declined' || hookErrorText)
|
|
4223
|
+
? parseManagedPreToolUseDenial([
|
|
4224
|
+
item.aggregatedOutput,
|
|
4225
|
+
item.output,
|
|
4226
|
+
item.result,
|
|
4227
|
+
hookErrorText,
|
|
4228
|
+
].filter(value => value !== undefined && value !== null).map(outputTextForManagedHook).join('\n'))
|
|
4229
|
+
: undefined;
|
|
4230
|
+
const managedHookFields = !denial && managedHookDenial
|
|
4231
|
+
? {
|
|
4232
|
+
isError: true,
|
|
4233
|
+
error: managedHookDenial.reason,
|
|
4234
|
+
errorCode: 'POLICY_DENIED',
|
|
4235
|
+
decision: 'deny',
|
|
4236
|
+
decisionSource: 'policy',
|
|
4237
|
+
policyCode: managedHookDenial.policyCode,
|
|
4238
|
+
reason: managedHookDenial.reason,
|
|
4239
|
+
executed: false,
|
|
4240
|
+
executionState: 'blocked',
|
|
4241
|
+
}
|
|
4242
|
+
: undefined;
|
|
4243
|
+
const genericDeclineFields = !denial && !managedHookFields && item.status === 'declined'
|
|
4244
|
+
? {
|
|
4245
|
+
isError: true,
|
|
4246
|
+
error: 'Approval declined',
|
|
4247
|
+
errorCode: 'USER_DENIED',
|
|
4248
|
+
decision: 'deny',
|
|
4249
|
+
decisionSource: 'approval',
|
|
4250
|
+
reason: 'Approval declined',
|
|
4251
|
+
executed: false,
|
|
4252
|
+
executionState: 'blocked',
|
|
4253
|
+
}
|
|
4254
|
+
: undefined;
|
|
4255
|
+
const blockedFields = denialFields ?? managedHookFields ?? genericDeclineFields;
|
|
3226
4256
|
switch (item.type) {
|
|
3227
4257
|
case 'agentMessage':
|
|
3228
4258
|
{
|
|
@@ -3239,8 +4269,10 @@ export class CodexRunner {
|
|
|
3239
4269
|
type: 'tool_result',
|
|
3240
4270
|
name: 'Shell',
|
|
3241
4271
|
result: item.aggregatedOutput ?? '',
|
|
3242
|
-
|
|
3243
|
-
|
|
4272
|
+
...(blockedFields ?? {
|
|
4273
|
+
isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed' || item.status === 'declined',
|
|
4274
|
+
...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
|
|
4275
|
+
}),
|
|
3244
4276
|
callId: item.id,
|
|
3245
4277
|
durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
|
|
3246
4278
|
};
|
|
@@ -3250,8 +4282,8 @@ export class CodexRunner {
|
|
|
3250
4282
|
type: 'tool_result',
|
|
3251
4283
|
name: `MCP:${item.server}/${item.tool}`,
|
|
3252
4284
|
result: item.result,
|
|
3253
|
-
isError: item.status === 'failed',
|
|
3254
|
-
error: item.error?.message,
|
|
4285
|
+
...(blockedFields ?? { isError: item.status === 'failed' }),
|
|
4286
|
+
...(blockedFields ? {} : { error: item.error?.message }),
|
|
3255
4287
|
...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
|
|
3256
4288
|
callId: item.id,
|
|
3257
4289
|
durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
|
|
@@ -3262,7 +4294,7 @@ export class CodexRunner {
|
|
|
3262
4294
|
type: 'tool_result',
|
|
3263
4295
|
name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool,
|
|
3264
4296
|
result: item.contentItems,
|
|
3265
|
-
isError: item.success === false || item.status === 'failed',
|
|
4297
|
+
...(blockedFields ?? { isError: item.success === false || item.status === 'failed' }),
|
|
3266
4298
|
...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
|
|
3267
4299
|
callId: item.id,
|
|
3268
4300
|
durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
|
|
@@ -3277,12 +4309,12 @@ export class CodexRunner {
|
|
|
3277
4309
|
yield editEvent;
|
|
3278
4310
|
}
|
|
3279
4311
|
}
|
|
3280
|
-
yield { type: 'tool_result', name: 'Edit', result: item.changes, isError: item.status === 'failed', callId: item.id };
|
|
4312
|
+
yield { type: 'tool_result', name: 'Edit', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
|
|
3281
4313
|
}
|
|
3282
4314
|
else {
|
|
3283
4315
|
const desc = this.normalizeFileChanges(item.changes).map((change) => this.describeFileChange(change)).join(', ');
|
|
3284
4316
|
yield { type: 'tool_use', name: 'FileChange', input: { description: desc }, callId: item.id };
|
|
3285
|
-
yield { type: 'tool_result', name: 'FileChange', result: item.changes, isError: item.status === 'failed', callId: item.id };
|
|
4317
|
+
yield { type: 'tool_result', name: 'FileChange', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
|
|
3286
4318
|
}
|
|
3287
4319
|
break;
|
|
3288
4320
|
case 'webSearch':
|
|
@@ -3336,6 +4368,34 @@ export class CodexRunner {
|
|
|
3336
4368
|
callId: item.id,
|
|
3337
4369
|
};
|
|
3338
4370
|
break;
|
|
4371
|
+
case 'subAgentActivity':
|
|
4372
|
+
case 'SubAgentActivity': {
|
|
4373
|
+
const childThreadId = this.subagentThreadId(item);
|
|
4374
|
+
if (!childThreadId)
|
|
4375
|
+
break;
|
|
4376
|
+
const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
|
|
4377
|
+
const description = this.subagentPath(item) ?? childThreadId;
|
|
4378
|
+
if (kind === 'started') {
|
|
4379
|
+
yield {
|
|
4380
|
+
type: 'task_started',
|
|
4381
|
+
taskId: childThreadId,
|
|
4382
|
+
taskKind: 'agent',
|
|
4383
|
+
description,
|
|
4384
|
+
subagentType: this.subagentType(item),
|
|
4385
|
+
};
|
|
4386
|
+
}
|
|
4387
|
+
else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
|
|
4388
|
+
yield {
|
|
4389
|
+
type: 'task_notification',
|
|
4390
|
+
taskId: childThreadId,
|
|
4391
|
+
taskKind: 'agent',
|
|
4392
|
+
description,
|
|
4393
|
+
status: kind === 'completed' ? 'completed' : kind,
|
|
4394
|
+
summary: description,
|
|
4395
|
+
};
|
|
4396
|
+
}
|
|
4397
|
+
break;
|
|
4398
|
+
}
|
|
3339
4399
|
}
|
|
3340
4400
|
}
|
|
3341
4401
|
*mapAppServerFileChangePatchUpdated(params, state) {
|
|
@@ -3466,10 +4526,10 @@ export class CodexRunner {
|
|
|
3466
4526
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
3467
4527
|
throw new Error(`managed temp path is not a real directory: ${directory}`);
|
|
3468
4528
|
}
|
|
3469
|
-
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
4529
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
3470
4530
|
throw new Error(`managed temp path is owned by another uid: ${directory}`);
|
|
3471
4531
|
}
|
|
3472
|
-
if ((stat.mode & 0o077) !== 0)
|
|
4532
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
|
|
3473
4533
|
fs.chmodSync(directory, 0o700);
|
|
3474
4534
|
}
|
|
3475
4535
|
ensureSessionTempDir(sessionId) {
|
|
@@ -3545,7 +4605,7 @@ export class CodexRunner {
|
|
|
3545
4605
|
return undefined;
|
|
3546
4606
|
return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 `/tmp/evolcore-codex-*` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 `$TMPDIR` 重新创建,再继续执行后续 `ec` 命令。';
|
|
3547
4607
|
}
|
|
3548
|
-
buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
|
|
4608
|
+
buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier, taskDelegationToken) {
|
|
3549
4609
|
const managedTempDir = this.ensureSessionTempDir(sessionId);
|
|
3550
4610
|
return {
|
|
3551
4611
|
shell_environment_policy: {
|
|
@@ -3554,7 +4614,9 @@ export class CodexRunner {
|
|
|
3554
4614
|
EVOLCORE_HOME: resolveRoot(),
|
|
3555
4615
|
...(managedTempDir ? { EVOLCORE_SESSION_RUNTIME_DIR: managedTempDir } : {}),
|
|
3556
4616
|
...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(path.dirname(managedTempDir), 'evolcore-locks') } : {}),
|
|
3557
|
-
...(
|
|
4617
|
+
...(taskDelegationToken || delegationCarrier
|
|
4618
|
+
? { [AGENT_DELEGATION_TOKEN_ENV]: taskDelegationToken ?? delegationCarrier }
|
|
4619
|
+
: {}),
|
|
3558
4620
|
...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
|
|
3559
4621
|
},
|
|
3560
4622
|
},
|
|
@@ -3583,24 +4645,35 @@ export class CodexRunner {
|
|
|
3583
4645
|
for (const [key, controller] of this.activeAbortControllers) {
|
|
3584
4646
|
controller.abort('dispose');
|
|
3585
4647
|
}
|
|
4648
|
+
await Promise.all([...this.activeSubagentTurns.entries()].map(([threadId, active]) => this.interruptAppServerTurn(threadId, active.turnId).catch(() => { })));
|
|
4649
|
+
await Promise.all([...this.threadSubscriptions.entries()].map(([threadId, subscription]) => this.releaseThreadSubscription(subscription.client, threadId, 'runner-dispose')));
|
|
4650
|
+
this.threadSubscriptions.clear();
|
|
3586
4651
|
this.activeAbortControllers.clear();
|
|
3587
4652
|
this.activeStreams.clear();
|
|
3588
4653
|
this.activeSessions.clear();
|
|
4654
|
+
this.provisionalSessions.clear();
|
|
3589
4655
|
this.activeTurns.clear();
|
|
4656
|
+
this.activeSubagentTurns.clear();
|
|
4657
|
+
this.childThreadSessions.clear();
|
|
3590
4658
|
this.threadProjectPaths.clear();
|
|
3591
4659
|
this.threadExternalToolFingerprints.clear();
|
|
3592
4660
|
this.threadExternalToolBoundaries.clear();
|
|
3593
4661
|
this.trackedApprovalItems.clear();
|
|
3594
4662
|
this.threadDelegationCarriers.clear();
|
|
3595
4663
|
this.pendingInterrupts.clear();
|
|
4664
|
+
this.steeringTails.clear();
|
|
3596
4665
|
this.permissionContexts.clear();
|
|
3597
4666
|
this.chatModes.clear();
|
|
3598
4667
|
this.pruneThreadOperationLocks();
|
|
3599
4668
|
this.threadOperationLocks.clear();
|
|
3600
4669
|
for (const sessionId of [...this.sessionTempDirs.keys()])
|
|
3601
4670
|
this.cleanupSessionTempDir(sessionId);
|
|
3602
|
-
await
|
|
4671
|
+
await Promise.all([
|
|
4672
|
+
this.appServerClient?.close(),
|
|
4673
|
+
this.fullAccessAppServerClient?.close(),
|
|
4674
|
+
]);
|
|
3603
4675
|
this.appServerClient = null;
|
|
4676
|
+
this.fullAccessAppServerClient = null;
|
|
3604
4677
|
}
|
|
3605
4678
|
}
|
|
3606
4679
|
// ── Plugin ──
|