evolcore 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/MIGRATION-0.5.0.md +378 -0
- package/README.md +318 -14
- package/ROLE_ACCESS_CONTROL.md +174 -0
- package/assets/.env.template +4 -0
- package/bin/ec-safe-output.js +161 -0
- package/bin/ec.js +29 -0
- package/dist/agents/baseagent.js +163 -0
- package/dist/agents/claude-runner.js +2565 -0
- package/dist/agents/codex-app-server-client.js +448 -0
- package/dist/agents/codex-runner.js +2682 -0
- package/dist/agents/gemini-runner.js +666 -0
- package/dist/agents/runner-types.js +75 -0
- package/dist/aun/aid/agentmd.js +216 -0
- package/dist/aun/aid/client.js +132 -0
- package/dist/aun/aid/control-aid.js +91 -0
- package/dist/aun/aid/identity.js +518 -0
- package/dist/aun/aid/index.js +4 -0
- package/dist/aun/aid/store.js +74 -0
- package/dist/aun/aid/types.js +1 -0
- package/dist/aun/aid/validation.js +21 -0
- package/dist/aun/group-identity.js +10 -0
- package/dist/aun/msg/group-index.js +6 -0
- package/dist/aun/msg/group.js +1231 -0
- package/dist/aun/msg/history.js +123 -0
- package/dist/aun/msg/index.js +5 -0
- package/dist/aun/msg/p2p.js +393 -0
- package/dist/aun/msg/payload-type.js +27 -0
- package/dist/aun/msg/upload.js +137 -0
- package/dist/aun/outbox.js +168 -0
- package/dist/aun/rpc/caller.js +42 -0
- package/dist/aun/rpc/connection.js +25 -0
- package/dist/aun/rpc/index.js +2 -0
- package/dist/aun/service-proxy.js +225 -0
- package/dist/aun/storage/download.js +29 -0
- package/dist/aun/storage/index.js +3 -0
- package/dist/aun/storage/manage.js +10 -0
- package/dist/aun/storage/upload.js +68 -0
- package/dist/channels/aun.js +4164 -0
- package/dist/channels/contact-bind-code.js +134 -0
- package/dist/channels/daemon.js +422 -0
- package/dist/channels/dingtalk.js +1479 -0
- package/dist/channels/feishu.js +1865 -0
- package/dist/channels/qqbot.js +409 -0
- package/dist/channels/wechat.js +817 -0
- package/dist/channels/wecom-card.js +101 -0
- package/dist/channels/wecom-onboarding.js +82 -0
- package/dist/channels/wecom-state.js +191 -0
- package/dist/channels/wecom.js +1157 -0
- package/dist/cli/agent-command.js +642 -0
- package/dist/cli/agent.js +1059 -0
- package/dist/cli/aun-commands.js +2003 -0
- package/dist/cli/bench.js +1228 -0
- package/dist/cli/cli-argv.js +66 -0
- package/dist/cli/code-stats.js +329 -0
- package/dist/cli/command-log.js +82 -0
- package/dist/cli/config-selector.js +69 -0
- package/dist/cli/config.js +261 -0
- package/dist/cli/contact.js +71 -0
- package/dist/cli/ctl-command.js +62 -0
- package/dist/cli/daemon-commands.js +2750 -0
- package/dist/cli/fs-command.js +1447 -0
- package/dist/cli/handoff-command.js +302 -0
- package/dist/cli/help.js +35 -0
- package/dist/cli/index.js +374 -0
- package/dist/cli/init-channel.js +1372 -0
- package/dist/cli/init.js +590 -0
- package/dist/cli/link-rules.js +240 -0
- package/dist/cli/model.js +591 -0
- package/dist/cli/net-check.js +723 -0
- package/dist/cli/queue-command.js +150 -0
- package/dist/cli/raw-key-input.js +25 -0
- package/dist/cli/response.js +344 -0
- package/dist/cli/restart-monitor.js +480 -0
- package/dist/cli/stats.js +609 -0
- package/dist/cli/task-context.js +80 -0
- package/dist/cli/trigger-command.js +545 -0
- package/dist/cli/version.js +93 -0
- package/dist/cli/watch-logs.js +33 -0
- package/dist/cli/watch-msg.js +673 -0
- package/dist/config/boot-log.js +266 -0
- package/dist/config/builtin-role-templates.js +42 -0
- package/dist/config/builtin-roles.js +91 -0
- package/dist/config/config-batch-get.js +11 -0
- package/dist/config/config-field-policy.js +261 -0
- package/dist/config/config-manager.js +1120 -0
- package/dist/config/config-operation-service.js +384 -0
- package/dist/config/contact-alias.js +68 -0
- package/dist/config/contact-book-store.js +454 -0
- package/dist/config/contact-book-v2-startup.js +35 -0
- package/dist/config/contact-book.js +224 -0
- package/dist/config/contact-operation-service.js +110 -0
- package/dist/config/gateway-config.js +858 -0
- package/dist/config/lifecycle.js +17 -0
- package/dist/config/mention-mode.js +27 -0
- package/dist/config/merge.js +161 -0
- package/dist/config/owner-policy.js +4 -0
- package/dist/config/peer-role-resolver.js +218 -0
- package/dist/config/resolved-config-op.js +483 -0
- package/dist/config/role-config-v4-startup.js +32 -0
- package/dist/config/role-config-v5-startup.js +27 -0
- package/dist/config/role-ranks.js +18 -0
- package/dist/config/role-schema.js +105 -0
- package/dist/config/role-service.js +156 -0
- package/dist/config/role-store.js +215 -0
- package/dist/config/roles.js +64 -0
- package/dist/config/schema-registry.js +154 -0
- package/dist/config/snapshot.js +598 -0
- package/dist/config-store.js +501 -0
- package/dist/core/auth/agent-delegation.js +111 -0
- package/dist/core/auth/auth-gateway.js +166 -0
- package/dist/core/auth/authenticated-actor.js +6 -0
- package/dist/core/auth/authorization-audit.js +119 -0
- package/dist/core/auth/operation-authorizer.js +720 -0
- package/dist/core/auth/operation-catalog.js +731 -0
- package/dist/core/baseagent-loader.js +54 -0
- package/dist/core/bootstrap-service.js +175 -0
- package/dist/core/capability/capability-manager.js +316 -0
- package/dist/core/capability/providers/claude-capability-provider.js +176 -0
- package/dist/core/capability/providers/codex-capability-provider.js +148 -0
- package/dist/core/capability/providers/gemini-capability-provider.js +10 -0
- package/dist/core/capability/types.js +27 -0
- package/dist/core/causation/audit.js +103 -0
- package/dist/core/causation/aun-association.js +111 -0
- package/dist/core/causation/context.js +93 -0
- package/dist/core/causation/index.js +4 -0
- package/dist/core/causation/types.js +2 -0
- package/dist/core/channel-loader.js +277 -0
- package/dist/core/command/agent-control.js +616 -0
- package/dist/core/command/cli-intent-parser.js +225 -0
- package/dist/core/command/command-handler.js +1733 -0
- package/dist/core/command/connect-menu.js +374 -0
- package/dist/core/command/menu-handler.js +3452 -0
- package/dist/core/command/menu-protocol.js +247 -0
- package/dist/core/command/role-menu.js +1623 -0
- package/dist/core/command/slash-gate.js +148 -0
- package/dist/core/command/slash-handler.js +3066 -0
- package/dist/core/daemon-file-cache.js +222 -0
- package/dist/core/event-bus.js +32 -0
- package/dist/core/event-catalog.js +810 -0
- package/dist/core/evolagent-registry.js +545 -0
- package/dist/core/evolagent.js +342 -0
- package/dist/core/handoff/dispatcher.js +229 -0
- package/dist/core/handoff/mutex.js +46 -0
- package/dist/core/handoff/runtime.js +324 -0
- package/dist/core/handoff/store.js +537 -0
- package/dist/core/handoff/types.js +12 -0
- package/dist/core/inference/text-inference.js +173 -0
- package/dist/core/interaction-registration.js +10 -0
- package/dist/core/interaction-router.js +278 -0
- package/dist/core/message/create-status.js +67 -0
- package/dist/core/message/im-renderer.js +659 -0
- package/dist/core/message/items-formatter.js +76 -0
- package/dist/core/message/logical-queue-bridge.js +123 -0
- package/dist/core/message/message-bridge.js +874 -0
- package/dist/core/message/message-cache.js +56 -0
- package/dist/core/message/message-log.js +339 -0
- package/dist/core/message/message-processor.js +4 -0
- package/dist/core/message/message-queue.js +1446 -0
- package/dist/core/message/message-utils.js +76 -0
- package/dist/core/message/peer-mode.js +105 -0
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/message/response-depth.js +33 -0
- package/dist/core/message/response-engine.js +4020 -0
- package/dist/core/message/response-snapshot.js +83 -0
- package/dist/core/message/send-receipt.js +24 -0
- package/dist/core/message/stream-debouncer.js +139 -0
- package/dist/core/message/stream-idle-monitor.js +124 -0
- package/dist/core/model/config-scope.js +162 -0
- package/dist/core/model/field-scope.js +78 -0
- package/dist/core/model/model-catalog.js +227 -0
- package/dist/core/model/model-diagnostics.js +182 -0
- package/dist/core/model/model-permission.js +90 -0
- package/dist/core/permission/approval-gateway.js +1017 -0
- package/dist/core/permission/ec-command-parser.js +347 -0
- package/dist/core/permission/execution-sandbox.js +16 -0
- package/dist/core/permission/index.js +6 -0
- package/dist/core/permission/mode.js +24 -0
- package/dist/core/permission/sandbox-runtime.js +265 -0
- package/dist/core/permission/tool-policy.js +1019 -0
- package/dist/core/permission/unix-socket-policy.js +99 -0
- package/dist/core/protected-paths.js +332 -0
- package/dist/core/relation/peer-identity.js +222 -0
- package/dist/core/relation/peer-key.js +1 -0
- package/dist/core/role/runtime-policy.js +141 -0
- package/dist/core/session/adapters/claude-session-file-adapter.js +218 -0
- package/dist/core/session/adapters/codex-session-file-adapter.js +333 -0
- package/dist/core/session/adapters/gemini-session-file-adapter.js +181 -0
- package/dist/core/session/session-file-adapter.js +7 -0
- package/dist/core/session/session-file-health.js +45 -0
- package/dist/core/session/session-fs-store.js +273 -0
- package/dist/core/session/session-key.js +24 -0
- package/dist/core/session/session-manager.js +1643 -0
- package/dist/core/session/session-mapper.js +100 -0
- package/dist/core/session/session-renew.js +314 -0
- package/dist/core/session/session-title.js +128 -0
- package/dist/core/session/session-turn-coordinator.js +205 -0
- package/dist/core/session/session-turns.js +67 -0
- package/dist/core/system-channels.js +29 -0
- package/dist/eck/baseagent-caps.js +18 -0
- package/dist/eck/detect.js +47 -0
- package/dist/eck/group-rules-sync.js +345 -0
- package/dist/eck/init.js +77 -0
- package/dist/eck/kit-renderer.js +359 -0
- package/dist/eck/manifest-engine.js +446 -0
- package/dist/eck/message-renderer.js +199 -0
- package/dist/eck/rules-loader.js +28 -0
- package/dist/index.js +2926 -4
- package/dist/ipc.js +777 -0
- package/dist/paths.js +262 -0
- package/dist/product.js +18 -0
- package/dist/response-system/context-builder.js +71 -0
- package/dist/response-system/coordinator.js +117 -0
- package/dist/response-system/decision-executor.js +86 -0
- package/dist/response-system/engines/v1/index.js +21 -0
- package/dist/response-system/engines/v1/interactive-flow.js +27 -0
- package/dist/response-system/engines/v1/proactive-flow.js +137 -0
- package/dist/response-system/engines/v1/types.js +1 -0
- package/dist/response-system/extensions.js +41 -0
- package/dist/response-system/index.js +6 -0
- package/dist/response-system/modes/index.js +7 -0
- package/dist/response-system/modes/single-session/index.js +72 -0
- package/dist/response-system/queues/fifo-queue.js +44 -0
- package/dist/response-system/queues/index.js +6 -0
- package/dist/response-system/queues/lifo-queue.js +42 -0
- package/dist/response-system/queues/priority-queue.js +63 -0
- package/dist/response-system/registry.js +97 -0
- package/dist/response-system/resolver.js +37 -0
- package/dist/response-system/selector.js +23 -0
- package/dist/response-system/types.js +7 -0
- package/dist/stats/billing.js +163 -0
- package/dist/stats/budget.js +93 -0
- package/dist/stats/db.js +403 -0
- package/dist/stats/eck-vars.js +89 -0
- package/dist/stats/index.js +11 -0
- package/dist/stats/normalizer.js +80 -0
- package/dist/stats/price-resolver.js +138 -0
- package/dist/stats/query.js +763 -0
- package/dist/stats/role-budget.js +168 -0
- package/dist/stats/writer.js +151 -0
- package/dist/trigger/anomaly-store.js +258 -0
- package/dist/trigger/audit.js +152 -0
- package/dist/trigger/event-source.js +119 -0
- package/dist/trigger/feedback.js +685 -0
- package/dist/trigger/history.js +290 -0
- package/dist/trigger/manager.js +294 -0
- package/dist/trigger/parser.js +595 -0
- package/dist/trigger/patch.js +155 -0
- package/dist/trigger/scheduler.js +1602 -0
- package/dist/trigger/script-executor.js +155 -0
- package/dist/trigger/state.js +145 -0
- package/dist/trigger/types.js +1 -0
- package/dist/trigger/validation.js +634 -0
- package/dist/types.js +12 -0
- package/dist/utils/aid-bind.js +313 -0
- package/dist/utils/atomic-write.js +95 -0
- package/dist/utils/avatar-upload.js +123 -0
- package/dist/utils/cross-platform.js +297 -0
- package/dist/utils/ecweb-utils.js +73 -0
- package/dist/utils/error-dict.json +153 -0
- package/dist/utils/error-utils.js +349 -0
- package/dist/utils/instance-registry.js +444 -0
- package/dist/utils/locale.js +21 -0
- package/dist/utils/log-writer.js +270 -0
- package/dist/utils/logger.js +89 -0
- package/dist/utils/markdown-to-plain-text.js +20 -0
- package/dist/utils/media-cache.js +274 -0
- package/dist/utils/model-prices.jsonl +20 -0
- package/dist/utils/npm-ops.js +210 -0
- package/dist/utils/process-introspect.js +133 -0
- package/dist/utils/process-tree-stats.js +271 -0
- package/dist/utils/project-path.js +74 -0
- package/dist/utils/restart-safety.js +31 -0
- package/dist/utils/stats.js +410 -0
- package/dist/utils/system-memory.js +62 -0
- package/dist/utils/tool-summary.js +284 -0
- package/dist/utils/welcome.js +268 -0
- package/kits/docs/GUIDE.md +20 -0
- package/kits/docs/INDEX.md +66 -0
- package/kits/docs/aun/CHEATSHEET.md +19 -0
- package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
- package/kits/docs/channels/aun.md +65 -0
- package/kits/docs/channels/feishu.md +56 -0
- package/kits/docs/context-assembly.md +366 -0
- package/kits/docs/eck_templates/GUIDE.template.md +22 -0
- package/kits/docs/eck_templates/INDEX.template.md +28 -0
- package/kits/docs/eck_templates/path-registry.template.md +33 -0
- package/kits/docs/eck_templates/runtime.template.md +19 -0
- package/kits/docs/evolcore/INDEX.md +68 -0
- package/kits/docs/evolcore/agent.md +77 -0
- package/kits/docs/evolcore/aid.md +52 -0
- package/kits/docs/evolcore/config.md +149 -0
- package/kits/docs/evolcore/contact.md +57 -0
- package/kits/docs/evolcore/ctl.md +46 -0
- package/kits/docs/evolcore/event.md +216 -0
- package/kits/docs/evolcore/fs-architecture.md +1215 -0
- package/kits/docs/evolcore/fs.md +110 -0
- package/kits/docs/evolcore/group-fs.md +17 -0
- package/kits/docs/evolcore/group-rules.md +226 -0
- package/kits/docs/evolcore/group.md +150 -0
- package/kits/docs/evolcore/model.md +50 -0
- package/kits/docs/evolcore/msg.md +136 -0
- package/kits/docs/evolcore/response.md +75 -0
- package/kits/docs/evolcore/rpc.md +37 -0
- package/kits/docs/evolcore/self-summary.md +29 -0
- package/kits/docs/evolcore/stats.md +83 -0
- package/kits/docs/evolcore/storage.md +50 -0
- package/kits/docs/evolcore/trigger.md +539 -0
- package/kits/docs/identity/AID_PROFILE_SPEC.md +26 -0
- package/kits/docs/identity/PATH_OPS.md +16 -0
- package/kits/docs/identity/ROLE_DETAIL.md +23 -0
- package/kits/docs/identity/identity-tools.md +26 -0
- package/kits/docs/path-registry.md +43 -0
- package/kits/docs/prompt-loading-architecture.md +266 -0
- package/kits/docs/venues/aun-group.md +45 -0
- package/kits/docs/venues/aun-private.md +10 -0
- package/kits/docs/venues/client-desktop.md +10 -0
- package/kits/docs/venues/client-mobile.md +10 -0
- package/kits/docs/venues/feishu-group.md +13 -0
- package/kits/docs/venues/feishu-private.md +9 -0
- package/kits/docs/venues/group.md +25 -0
- package/kits/docs/venues/private.md +10 -0
- package/kits/eck_manifest.auxiliary.json +43 -0
- package/kits/eck_manifest.json +203 -0
- package/kits/eck_message_manifest.json +63 -0
- package/kits/migrations/README-role-config-v4.md +32 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
- package/kits/migrations/migrate-role-config-v4.mjs +623 -0
- package/kits/migrations/migrate-role-config-v5.mjs +346 -0
- package/kits/migrations/rename-config-file.mjs +99 -0
- package/kits/rules/01-overview.md +142 -0
- package/kits/rules/02-navigation.md +76 -0
- package/kits/rules/03-identity.md +34 -0
- package/kits/rules/04-relation.md +59 -0
- package/kits/rules/05-venue.md +44 -0
- package/kits/rules/06-channel.md +59 -0
- package/kits/schemas/_meta.json +32 -0
- package/kits/schemas/agent-config.schema.1.json +177 -0
- package/kits/schemas/agent-config.schema.2.json +239 -0
- package/kits/schemas/agent-config.schema.3.json +119 -0
- package/kits/schemas/agent-config.schema.4.json +208 -0
- package/kits/schemas/agent-config.schema.5.json +326 -0
- package/kits/schemas/agent-config.schema.6.json +322 -0
- package/kits/schemas/contact-book.schema.1.json +36 -0
- package/kits/schemas/contact-book.schema.2.json +43 -0
- package/kits/schemas/daemon.schema.1.json +90 -0
- package/kits/schemas/defaults.schema.1.json +81 -0
- package/kits/schemas/menu-exec-schema-commands.md +208 -0
- package/kits/schemas/migrations/README.md +28 -0
- package/kits/schemas/relation-config.schema.1.json +158 -0
- package/kits/schemas/relation-config.schema.2.json +73 -0
- package/kits/schemas/relation-config.schema.3.json +50 -0
- package/kits/schemas/relation-config.schema.4.json +47 -0
- package/kits/schemas/relation-config.schema.5.json +47 -0
- package/kits/schemas/role-config.schema.1.json +201 -0
- package/kits/schemas/role-registry.schema.1.json +35 -0
- package/kits/schemas/single-session.schema.1.json +31 -0
- package/kits/templates/bootstrap-welcome.md +15 -0
- package/kits/templates/message-fragments/handoff-request-to-target.md +13 -0
- package/kits/templates/message-fragments/handoff-response-to-origin.md +10 -0
- package/kits/templates/message-fragments/inject-default.md +2 -0
- package/kits/templates/message-fragments/item.md +2 -0
- package/kits/templates/roles/admin.json +9 -0
- package/kits/templates/roles/member.json +41 -0
- package/kits/templates/roles/owner.json +9 -0
- package/kits/templates/roles/visitor.json +40 -0
- package/kits/templates/system-fragments/baseagent.md +14 -0
- package/kits/templates/system-fragments/bootstrap.md +16 -0
- package/kits/templates/system-fragments/channel.md +48 -0
- package/kits/templates/system-fragments/commands.md +28 -0
- package/kits/templates/system-fragments/identity.md +11 -0
- package/kits/templates/system-fragments/relation.md +19 -0
- package/kits/templates/system-fragments/session.md +53 -0
- package/kits/templates/system-fragments/venue.md +31 -0
- package/package.json +50 -15
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,2682 @@
|
|
|
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
|
+
*/
|
|
8
|
+
import { requestDangerousCommandPermission } from '../core/permission/approval-gateway.js';
|
|
9
|
+
import { checkReadonly, checkDangerousCommand, evaluateToolPreflight } from '../core/permission/tool-policy.js';
|
|
10
|
+
import { normalizePermissionMode } from '../core/permission/mode.js';
|
|
11
|
+
import { resolvePhaseOneExecutionSandbox } from '../core/permission/execution-sandbox.js';
|
|
12
|
+
import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/protected-paths.js';
|
|
13
|
+
import { CodexAppServerClient } from './codex-app-server-client.js';
|
|
14
|
+
import { resolveOpenaiConfig } from './baseagent.js';
|
|
15
|
+
import { logger } from '../utils/logger.js';
|
|
16
|
+
import { isRetryableError } from '../utils/error-utils.js';
|
|
17
|
+
import { renderActionAsText } from '../core/interaction-router.js';
|
|
18
|
+
import { buildEnvelope, sendInteractionPayload } from '../core/message/message-utils.js';
|
|
19
|
+
import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
|
|
20
|
+
import { compareVersions } from '../utils/npm-ops.js';
|
|
21
|
+
import { resolvePaths, resolveRoot } from '../paths.js';
|
|
22
|
+
import { buildSessionTurnList } from '../core/session/session-turns.js';
|
|
23
|
+
import { execFileSync } from 'child_process';
|
|
24
|
+
import { createHash, randomBytes } from 'crypto';
|
|
25
|
+
import fs from 'fs';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import os from 'os';
|
|
28
|
+
// ── MIME → 扩展名映射 ──
|
|
29
|
+
const MIME_EXT = {
|
|
30
|
+
'image/png': '.png',
|
|
31
|
+
'image/jpeg': '.jpg',
|
|
32
|
+
'image/gif': '.gif',
|
|
33
|
+
'image/webp': '.webp',
|
|
34
|
+
};
|
|
35
|
+
class AsyncEventQueue {
|
|
36
|
+
queue = [];
|
|
37
|
+
done = false;
|
|
38
|
+
error = null;
|
|
39
|
+
waiting = null;
|
|
40
|
+
push(item) {
|
|
41
|
+
if (this.done)
|
|
42
|
+
return;
|
|
43
|
+
this.queue.push(item);
|
|
44
|
+
this.waiting?.();
|
|
45
|
+
}
|
|
46
|
+
end() {
|
|
47
|
+
this.done = true;
|
|
48
|
+
this.waiting?.();
|
|
49
|
+
}
|
|
50
|
+
fail(error) {
|
|
51
|
+
this.error = error;
|
|
52
|
+
this.done = true;
|
|
53
|
+
this.waiting?.();
|
|
54
|
+
}
|
|
55
|
+
async *[Symbol.asyncIterator]() {
|
|
56
|
+
while (true) {
|
|
57
|
+
while (this.queue.length > 0) {
|
|
58
|
+
yield this.queue.shift();
|
|
59
|
+
}
|
|
60
|
+
if (this.error)
|
|
61
|
+
throw this.error;
|
|
62
|
+
if (this.done)
|
|
63
|
+
return;
|
|
64
|
+
await new Promise((resolve) => {
|
|
65
|
+
this.waiting = resolve;
|
|
66
|
+
});
|
|
67
|
+
this.waiting = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const managedTempDirsForExitCleanup = new Map();
|
|
72
|
+
let managedTempExitCleanupRegistered = false;
|
|
73
|
+
function registerManagedTempDirForExitCleanup(directory, parent) {
|
|
74
|
+
managedTempDirsForExitCleanup.set(directory, parent);
|
|
75
|
+
if (managedTempExitCleanupRegistered)
|
|
76
|
+
return;
|
|
77
|
+
managedTempExitCleanupRegistered = true;
|
|
78
|
+
process.once('exit', () => {
|
|
79
|
+
for (const [candidatePath, parentPath] of managedTempDirsForExitCleanup) {
|
|
80
|
+
try {
|
|
81
|
+
const parentCandidate = resolveProtectedCandidate(parentPath);
|
|
82
|
+
const candidate = resolveProtectedCandidate(candidatePath);
|
|
83
|
+
if (candidate !== parentCandidate && isSameOrDescendant(candidate, parentCandidate)) {
|
|
84
|
+
fs.rmSync(candidate, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Process exit cleanup is best effort; session cleanup logs failures
|
|
89
|
+
// while the runner is still active.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function stableSecurityValue(value) {
|
|
95
|
+
if (value === undefined)
|
|
96
|
+
return 'undefined';
|
|
97
|
+
if (value === null || typeof value !== 'object')
|
|
98
|
+
return JSON.stringify(value) ?? String(value);
|
|
99
|
+
if (Array.isArray(value))
|
|
100
|
+
return `[${value.map(stableSecurityValue).join(',')}]`;
|
|
101
|
+
const record = value;
|
|
102
|
+
return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${stableSecurityValue(record[key])}`).join(',')}}`;
|
|
103
|
+
}
|
|
104
|
+
function externalToolConfigFingerprint(config) {
|
|
105
|
+
return createHash('sha256').update(stableSecurityValue(config)).digest('hex');
|
|
106
|
+
}
|
|
107
|
+
const CODEX_CATALOG_FALLBACK = [
|
|
108
|
+
{ slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
109
|
+
{ slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
110
|
+
{ slug: 'gpt-5.4-mini', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
111
|
+
{ slug: 'gpt-5.3-codex', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
112
|
+
{ slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
113
|
+
];
|
|
114
|
+
let codexCatalogCache = null;
|
|
115
|
+
// The permission bridge is version-locked to the v2 schema audited below:
|
|
116
|
+
// named profiles, approvalsReviewer, additional/network approval context, and
|
|
117
|
+
// item/permissions/requestApproval must all be present together.
|
|
118
|
+
export const MIN_CODEX_CLI_VERSION = '0.144.1';
|
|
119
|
+
export function parseCodexCliVersion(output) {
|
|
120
|
+
const match = output.match(/\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/);
|
|
121
|
+
return match?.[1] ?? null;
|
|
122
|
+
}
|
|
123
|
+
export function isCodexCliVersionSupported(version) {
|
|
124
|
+
return compareVersions(version, MIN_CODEX_CLI_VERSION) >= 0;
|
|
125
|
+
}
|
|
126
|
+
export function getCodexCliVersion() {
|
|
127
|
+
try {
|
|
128
|
+
const output = execFileSync('codex', ['--version'], {
|
|
129
|
+
encoding: 'utf-8',
|
|
130
|
+
timeout: 3000,
|
|
131
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
132
|
+
});
|
|
133
|
+
return parseCodexCliVersion(output);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function getCodexAppServerAvailability() {
|
|
140
|
+
const version = getCodexCliVersion();
|
|
141
|
+
const upgradeHint = '请升级 Codex CLI:npm install -g @openai/codex@latest';
|
|
142
|
+
if (!version) {
|
|
143
|
+
return { available: false, reason: `未检测到可用 Codex CLI。${upgradeHint}` };
|
|
144
|
+
}
|
|
145
|
+
if (!isCodexCliVersionSupported(version)) {
|
|
146
|
+
return {
|
|
147
|
+
available: false,
|
|
148
|
+
version,
|
|
149
|
+
reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。${upgradeHint}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
execFileSync('codex', ['app-server', '--help'], {
|
|
154
|
+
encoding: 'utf-8',
|
|
155
|
+
timeout: 3000,
|
|
156
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
157
|
+
});
|
|
158
|
+
return { available: true, version };
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return { available: false, version, reason: `Codex CLI ${version} 不支持 app-server。${upgradeHint}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export function isCodexAppServerAvailable() {
|
|
165
|
+
return getCodexAppServerAvailability().available;
|
|
166
|
+
}
|
|
167
|
+
function fetchCodexCatalog() {
|
|
168
|
+
if (codexCatalogCache)
|
|
169
|
+
return codexCatalogCache;
|
|
170
|
+
try {
|
|
171
|
+
const output = execFileSync('codex', ['debug', 'models'], {
|
|
172
|
+
encoding: 'utf-8',
|
|
173
|
+
timeout: 5000,
|
|
174
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
175
|
+
});
|
|
176
|
+
const catalog = JSON.parse(output);
|
|
177
|
+
const models = catalog.models
|
|
178
|
+
.filter(m => m.visibility === 'list')
|
|
179
|
+
.map(m => ({
|
|
180
|
+
slug: m.slug,
|
|
181
|
+
efforts: (m.supported_reasoning_levels || []).map(l => l.effort),
|
|
182
|
+
}));
|
|
183
|
+
if (models.length > 0) {
|
|
184
|
+
codexCatalogCache = models;
|
|
185
|
+
return models;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
logger.debug(`[CodexRunner] Failed to fetch model catalog, using fallback: ${e}`);
|
|
190
|
+
}
|
|
191
|
+
return CODEX_CATALOG_FALLBACK;
|
|
192
|
+
}
|
|
193
|
+
export function getCodexEfforts(model) {
|
|
194
|
+
const catalog = fetchCodexCatalog();
|
|
195
|
+
const entry = catalog.find(m => m.slug === model);
|
|
196
|
+
return entry?.efforts ?? catalog[0]?.efforts ?? ['low', 'medium', 'high'];
|
|
197
|
+
}
|
|
198
|
+
// ── Codex Runner ──
|
|
199
|
+
export class CodexRunner {
|
|
200
|
+
name = 'codex';
|
|
201
|
+
capabilities;
|
|
202
|
+
model;
|
|
203
|
+
effort;
|
|
204
|
+
activeAbortControllers = new Map();
|
|
205
|
+
activeStreams = new Map();
|
|
206
|
+
activeSessions = new Map(); // sessionId → threadId
|
|
207
|
+
activeTurns = new Map();
|
|
208
|
+
threadProjectPaths = new Map();
|
|
209
|
+
sessionTempDirs = new Map();
|
|
210
|
+
managedTempNonce = randomBytes(16).toString('hex');
|
|
211
|
+
threadExternalToolFingerprints = new Map();
|
|
212
|
+
threadExternalToolBoundaries = new Map();
|
|
213
|
+
trackedApprovalItems = new Map();
|
|
214
|
+
pendingInterrupts = new Map();
|
|
215
|
+
appServerClient = null;
|
|
216
|
+
onSessionIdUpdate;
|
|
217
|
+
onCompactStart;
|
|
218
|
+
permissionGateway;
|
|
219
|
+
sendPromptFn;
|
|
220
|
+
permissionContexts = new Map();
|
|
221
|
+
resolvedConfig;
|
|
222
|
+
pendingInterruptTtlMs = 30_000;
|
|
223
|
+
constructor(config, callbacks) {
|
|
224
|
+
this.resolvedConfig = resolveOpenaiConfig(config);
|
|
225
|
+
this.resolvedConfig.evolcoreAgentAid = config.agents?.codex?.evolcoreAgentAid;
|
|
226
|
+
this.resolvedConfig.evolcoreAgentConfig = config.agents?.codex?.evolcoreAgentConfig;
|
|
227
|
+
this.capabilities = {
|
|
228
|
+
clear: false,
|
|
229
|
+
compact: true,
|
|
230
|
+
fork: true,
|
|
231
|
+
forkAtTurn: true,
|
|
232
|
+
// Requires Codex CLI feature flag: default_mode_request_user_input.
|
|
233
|
+
askUserQuestion: this.resolvedConfig.enableRequestUserInput === true,
|
|
234
|
+
// Codex app-server exposes plan streaming, but not Claude-style ExitPlanMode approval.
|
|
235
|
+
planApproval: false,
|
|
236
|
+
// Current file rewind is intentionally degraded: it restores touched files from Git HEAD.
|
|
237
|
+
fileRewind: 'git-head',
|
|
238
|
+
};
|
|
239
|
+
this.model = this.resolvedConfig.model;
|
|
240
|
+
if (this.resolvedConfig.effort)
|
|
241
|
+
this.effort = this.resolvedConfig.effort;
|
|
242
|
+
this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
|
|
243
|
+
}
|
|
244
|
+
getAppServerClient() {
|
|
245
|
+
if (!this.appServerClient) {
|
|
246
|
+
const client = new CodexAppServerClient({
|
|
247
|
+
apiKey: this.resolvedConfig.apiKey,
|
|
248
|
+
baseUrl: this.resolvedConfig.baseUrl,
|
|
249
|
+
model: this.model,
|
|
250
|
+
effort: this.effort,
|
|
251
|
+
enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
|
|
252
|
+
approvalsReviewer: 'user',
|
|
253
|
+
onServerRequest: request => this.handleAppServerRequest(request),
|
|
254
|
+
});
|
|
255
|
+
client.onNotification(notification => this.trackApprovalItemNotification(notification));
|
|
256
|
+
this.appServerClient = client;
|
|
257
|
+
}
|
|
258
|
+
return this.appServerClient;
|
|
259
|
+
}
|
|
260
|
+
resetAppServerClient() {
|
|
261
|
+
const client = this.appServerClient;
|
|
262
|
+
this.appServerClient = null;
|
|
263
|
+
this.threadExternalToolFingerprints.clear();
|
|
264
|
+
this.threadExternalToolBoundaries.clear();
|
|
265
|
+
this.trackedApprovalItems.clear();
|
|
266
|
+
client?.close().catch(error => {
|
|
267
|
+
logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
mergeThreadConfig(...configs) {
|
|
271
|
+
const merged = {};
|
|
272
|
+
for (const config of configs) {
|
|
273
|
+
if (!config)
|
|
274
|
+
continue;
|
|
275
|
+
for (const [key, value] of Object.entries(config)) {
|
|
276
|
+
const current = merged[key];
|
|
277
|
+
if (value
|
|
278
|
+
&& typeof value === 'object'
|
|
279
|
+
&& !Array.isArray(value)
|
|
280
|
+
&& current
|
|
281
|
+
&& typeof current === 'object'
|
|
282
|
+
&& !Array.isArray(current)) {
|
|
283
|
+
merged[key] = this.mergeThreadConfig(current, value) ?? {};
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
merged[key] = value;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return Object.keys(merged).length > 0 ? merged : null;
|
|
291
|
+
}
|
|
292
|
+
async resolveCapabilityThreadConfig(projectPath) {
|
|
293
|
+
const agentConfig = this.resolvedConfig.evolcoreAgentConfig;
|
|
294
|
+
if (!agentConfig)
|
|
295
|
+
return {};
|
|
296
|
+
try {
|
|
297
|
+
return await resolveCodexCapabilityThreadConfigForProject(agentConfig, projectPath, 'codex');
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
logger.warn(`[CodexRunner] Failed to resolve Codex capability thread config: ${error}`);
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
configRecord(value) {
|
|
305
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
306
|
+
? value
|
|
307
|
+
: undefined;
|
|
308
|
+
}
|
|
309
|
+
projectConfigKeys(source, aliases) {
|
|
310
|
+
const result = {};
|
|
311
|
+
for (const [inputKey, outputKey] of Object.entries(aliases)) {
|
|
312
|
+
const value = source[inputKey];
|
|
313
|
+
if (value !== undefined && value !== null && result[outputKey] === undefined)
|
|
314
|
+
result[outputKey] = value;
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
projectMcpServerConfig(entry) {
|
|
319
|
+
const projected = this.projectConfigKeys(entry, {
|
|
320
|
+
command: 'command',
|
|
321
|
+
args: 'args',
|
|
322
|
+
env: 'env',
|
|
323
|
+
env_vars: 'env_vars',
|
|
324
|
+
envVars: 'env_vars',
|
|
325
|
+
cwd: 'cwd',
|
|
326
|
+
url: 'url',
|
|
327
|
+
serverUrl: 'url',
|
|
328
|
+
bearer_token_env_var: 'bearer_token_env_var',
|
|
329
|
+
bearerTokenEnvVar: 'bearer_token_env_var',
|
|
330
|
+
http_headers: 'http_headers',
|
|
331
|
+
httpHeaders: 'http_headers',
|
|
332
|
+
env_http_headers: 'env_http_headers',
|
|
333
|
+
envHttpHeaders: 'env_http_headers',
|
|
334
|
+
startup_timeout_sec: 'startup_timeout_sec',
|
|
335
|
+
startupTimeoutSec: 'startup_timeout_sec',
|
|
336
|
+
startup_timeout_ms: 'startup_timeout_ms',
|
|
337
|
+
startupTimeoutMs: 'startup_timeout_ms',
|
|
338
|
+
tool_timeout_sec: 'tool_timeout_sec',
|
|
339
|
+
toolTimeoutSec: 'tool_timeout_sec',
|
|
340
|
+
enabled: 'enabled',
|
|
341
|
+
required: 'required',
|
|
342
|
+
enabled_tools: 'enabled_tools',
|
|
343
|
+
enabledTools: 'enabled_tools',
|
|
344
|
+
disabled_tools: 'disabled_tools',
|
|
345
|
+
disabledTools: 'disabled_tools',
|
|
346
|
+
scopes: 'scopes',
|
|
347
|
+
auth: 'auth',
|
|
348
|
+
oauth_resource: 'oauth_resource',
|
|
349
|
+
oauthResource: 'oauth_resource',
|
|
350
|
+
experimental_environment: 'experimental_environment',
|
|
351
|
+
experimentalEnvironment: 'experimental_environment',
|
|
352
|
+
default_tools_approval_mode: 'default_tools_approval_mode',
|
|
353
|
+
defaultToolsApprovalMode: 'default_tools_approval_mode',
|
|
354
|
+
tools: 'tools',
|
|
355
|
+
});
|
|
356
|
+
const tools = this.configRecord(projected.tools);
|
|
357
|
+
if (tools) {
|
|
358
|
+
projected.tools = Object.fromEntries(Object.entries(tools).map(([tool, config]) => [tool, {
|
|
359
|
+
...this.projectConfigKeys(this.configRecord(config) ?? {}, {
|
|
360
|
+
enabled: 'enabled',
|
|
361
|
+
approval_mode: 'approval_mode',
|
|
362
|
+
approvalMode: 'approval_mode',
|
|
363
|
+
}),
|
|
364
|
+
}]));
|
|
365
|
+
}
|
|
366
|
+
return projected;
|
|
367
|
+
}
|
|
368
|
+
projectAppConfig(entry) {
|
|
369
|
+
const projected = this.projectConfigKeys(entry, {
|
|
370
|
+
enabled: 'enabled',
|
|
371
|
+
approvals_reviewer: 'approvals_reviewer',
|
|
372
|
+
approvalsReviewer: 'approvals_reviewer',
|
|
373
|
+
destructive_enabled: 'destructive_enabled',
|
|
374
|
+
destructiveEnabled: 'destructive_enabled',
|
|
375
|
+
open_world_enabled: 'open_world_enabled',
|
|
376
|
+
openWorldEnabled: 'open_world_enabled',
|
|
377
|
+
default_tools_approval_mode: 'default_tools_approval_mode',
|
|
378
|
+
defaultToolsApprovalMode: 'default_tools_approval_mode',
|
|
379
|
+
default_tools_enabled: 'default_tools_enabled',
|
|
380
|
+
defaultToolsEnabled: 'default_tools_enabled',
|
|
381
|
+
tools: 'tools',
|
|
382
|
+
});
|
|
383
|
+
const tools = this.configRecord(projected.tools);
|
|
384
|
+
if (tools) {
|
|
385
|
+
projected.tools = Object.fromEntries(Object.entries(tools).map(([tool, config]) => [tool, {
|
|
386
|
+
...this.projectConfigKeys(this.configRecord(config) ?? {}, {
|
|
387
|
+
enabled: 'enabled',
|
|
388
|
+
approval_mode: 'approval_mode',
|
|
389
|
+
approvalMode: 'approval_mode',
|
|
390
|
+
}),
|
|
391
|
+
}]));
|
|
392
|
+
}
|
|
393
|
+
return projected;
|
|
394
|
+
}
|
|
395
|
+
externalToolApprovalPolicy(entry, approvalMode, includeReviewer = false) {
|
|
396
|
+
const tools = this.configRecord(entry?.tools);
|
|
397
|
+
return {
|
|
398
|
+
...(entry ?? {}),
|
|
399
|
+
...(includeReviewer ? { approvals_reviewer: 'user' } : {}),
|
|
400
|
+
default_tools_approval_mode: approvalMode,
|
|
401
|
+
...(tools && Object.keys(tools).length > 0 ? {
|
|
402
|
+
tools: Object.fromEntries(Object.entries(tools).map(([tool, config]) => [tool, {
|
|
403
|
+
...(this.configRecord(config) ?? {}),
|
|
404
|
+
approval_mode: approvalMode,
|
|
405
|
+
}])),
|
|
406
|
+
} : {}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
buildExternalToolApprovalConfig(effectiveConfig, capabilityConfig, mode = 'readonly') {
|
|
410
|
+
const sources = [effectiveConfig, capabilityConfig];
|
|
411
|
+
const result = {};
|
|
412
|
+
const allowMcpTools = mode !== 'readonly';
|
|
413
|
+
const allowApps = mode === 'request' || mode === 'bypass';
|
|
414
|
+
const mcpApprovalMode = allowMcpTools ? 'approve' : 'prompt';
|
|
415
|
+
const appApprovalMode = allowApps ? 'approve' : 'prompt';
|
|
416
|
+
const mcpSections = sources.map(source => this.configRecord(source.mcp_servers)).filter(Boolean);
|
|
417
|
+
const mcpIds = new Set(mcpSections.flatMap(section => Object.keys(section)));
|
|
418
|
+
if (mcpIds.size > 0) {
|
|
419
|
+
const mcpOverrides = {};
|
|
420
|
+
for (const id of mcpIds) {
|
|
421
|
+
const mergedEntry = this.mergeThreadConfig(...mcpSections.map(section => this.configRecord(section[id]))) ?? {};
|
|
422
|
+
const entry = this.projectMcpServerConfig(mergedEntry);
|
|
423
|
+
mcpOverrides[id] = this.externalToolApprovalPolicy(entry, mcpApprovalMode);
|
|
424
|
+
}
|
|
425
|
+
result.mcp_servers = mcpOverrides;
|
|
426
|
+
}
|
|
427
|
+
const appSections = sources.map(source => this.configRecord(source.apps)).filter(Boolean);
|
|
428
|
+
const appIds = new Set(appSections.flatMap(section => Object.keys(section)).filter(id => id !== '_default'));
|
|
429
|
+
const appDefault = this.projectAppConfig(this.mergeThreadConfig(...appSections.map(section => this.configRecord(section._default))) ?? {});
|
|
430
|
+
const appOverrides = {
|
|
431
|
+
_default: this.externalToolApprovalPolicy(allowApps && appDefault.enabled === true
|
|
432
|
+
? appDefault
|
|
433
|
+
: { ...appDefault, enabled: false }, appApprovalMode, true),
|
|
434
|
+
};
|
|
435
|
+
for (const id of appIds) {
|
|
436
|
+
const entry = this.projectAppConfig(this.mergeThreadConfig(...appSections.map(section => this.configRecord(section[id]))) ?? {});
|
|
437
|
+
appOverrides[id] = this.externalToolApprovalPolicy(allowApps ? entry : { ...entry, enabled: false }, appApprovalMode, true);
|
|
438
|
+
}
|
|
439
|
+
result.apps = appOverrides;
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
bindExternalToolBoundary(threadId, config) {
|
|
443
|
+
const deniedMcpServers = new Set();
|
|
444
|
+
const deniedApps = new Set();
|
|
445
|
+
const allowedApps = new Set();
|
|
446
|
+
const mcpServers = this.configRecord(config.mcp_servers) ?? {};
|
|
447
|
+
for (const [serverId, rawEntry] of Object.entries(mcpServers)) {
|
|
448
|
+
const entry = this.configRecord(rawEntry);
|
|
449
|
+
if (entry?.enabled === false)
|
|
450
|
+
deniedMcpServers.add(serverId);
|
|
451
|
+
}
|
|
452
|
+
const apps = this.configRecord(config.apps) ?? {};
|
|
453
|
+
for (const [appId, rawEntry] of Object.entries(apps)) {
|
|
454
|
+
if (appId === '_default')
|
|
455
|
+
continue;
|
|
456
|
+
const entry = this.configRecord(rawEntry);
|
|
457
|
+
if (entry?.enabled === false)
|
|
458
|
+
deniedApps.add(appId);
|
|
459
|
+
else
|
|
460
|
+
allowedApps.add(appId);
|
|
461
|
+
}
|
|
462
|
+
const appDefault = this.configRecord(apps._default);
|
|
463
|
+
this.threadExternalToolFingerprints.set(threadId, externalToolConfigFingerprint(config));
|
|
464
|
+
this.threadExternalToolBoundaries.set(threadId, {
|
|
465
|
+
deniedMcpServers,
|
|
466
|
+
deniedApps,
|
|
467
|
+
allowedApps,
|
|
468
|
+
allowAnyApp: appDefault?.enabled === true,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
unbindExternalToolBoundary(threadId) {
|
|
472
|
+
if (!threadId)
|
|
473
|
+
return;
|
|
474
|
+
this.threadExternalToolFingerprints.delete(threadId);
|
|
475
|
+
this.threadExternalToolBoundaries.delete(threadId);
|
|
476
|
+
}
|
|
477
|
+
async resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode = 'readonly') {
|
|
478
|
+
const configRead = appServer.configRead;
|
|
479
|
+
if (typeof configRead !== 'function') {
|
|
480
|
+
return this.buildExternalToolApprovalConfig({}, capabilityConfig, mode);
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
const response = await configRead.call(appServer, projectPath);
|
|
484
|
+
const effectiveConfig = this.configRecord(response?.config) ?? {};
|
|
485
|
+
return this.buildExternalToolApprovalConfig(effectiveConfig, capabilityConfig, mode);
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
logger.warn(`[CodexRunner] config/read failed; preserving inherited MCP defaults: ${error}`);
|
|
489
|
+
return this.buildExternalToolApprovalConfig({}, capabilityConfig, mode);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// ── ModelSwitcher ──
|
|
493
|
+
setModel(model) { this.model = model; this.resetAppServerClient(); }
|
|
494
|
+
getModel() { return this.model; }
|
|
495
|
+
async listModels() {
|
|
496
|
+
try {
|
|
497
|
+
const response = await this.getAppServerClient().modelList(false);
|
|
498
|
+
const ids = (response.data ?? [])
|
|
499
|
+
.map(model => model.id || model.slug || model.name || model.model)
|
|
500
|
+
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
501
|
+
if (ids.length > 0)
|
|
502
|
+
return ids;
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
logger.debug(`[CodexRunner] app-server model/list failed, using catalog fallback: ${error}`);
|
|
506
|
+
}
|
|
507
|
+
return fetchCodexCatalog().map(m => m.slug);
|
|
508
|
+
}
|
|
509
|
+
// ── Effort ──
|
|
510
|
+
setEffort(effort) { this.effort = effort; this.resetAppServerClient(); }
|
|
511
|
+
getEffort() { return this.effort; }
|
|
512
|
+
// ── Permission ──
|
|
513
|
+
currentMode = 'readonly';
|
|
514
|
+
// per-call/per-session 权限模式:runQuery 按本次解析结果写入,审批回调(异步、按 sessionKey 路由)据此判定。
|
|
515
|
+
// 避免多会话共享 this.currentMode 时的并发污染(与 claude-runner 的 per-call permissionMode 同构)。
|
|
516
|
+
chatModes = new Map();
|
|
517
|
+
/** 将权限模式映射为 Codex app-server 的 approvalPolicy(纯函数,无副作用,供 per-call 派生用)。 */
|
|
518
|
+
toApprovalPolicy(mode) {
|
|
519
|
+
// `untrusted` guarantees that commands outside Codex's trusted read-only
|
|
520
|
+
// set reach the EvolCore bridge for EvolCore's per-mode decision.
|
|
521
|
+
void mode;
|
|
522
|
+
return 'untrusted';
|
|
523
|
+
}
|
|
524
|
+
permissionProfileName(sessionId) {
|
|
525
|
+
const normalized = sessionId.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
526
|
+
if (normalized && normalized === sessionId && normalized.length <= 64) {
|
|
527
|
+
return `__evolcore_${normalized}_hclass_v1`;
|
|
528
|
+
}
|
|
529
|
+
// Sanitizing and truncating alone can collapse distinct EvolCore sessions
|
|
530
|
+
// onto one Codex profile (for example `a/b` and `a?b`). Keep a readable
|
|
531
|
+
// prefix, but bind every lossy name to the original session id with a
|
|
532
|
+
// collision-resistant suffix.
|
|
533
|
+
const label = normalized.slice(0, 40) || 'session';
|
|
534
|
+
const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 24);
|
|
535
|
+
return `__evolcore_${label}_${digest}_hclass_v1`;
|
|
536
|
+
}
|
|
537
|
+
resolveGitMetadataWriteRules(projectPath, mode) {
|
|
538
|
+
if (mode === 'readonly')
|
|
539
|
+
return {};
|
|
540
|
+
const project = path.resolve(projectPath);
|
|
541
|
+
const marker = path.join(project, '.git');
|
|
542
|
+
const paths = new Set();
|
|
543
|
+
const looksLikeGitMetadataDirectory = (candidate) => {
|
|
544
|
+
if (path.dirname(candidate) === candidate)
|
|
545
|
+
return false;
|
|
546
|
+
return ['HEAD', 'config', 'commondir', 'gitdir'].some(name => fs.existsSync(path.join(candidate, name)));
|
|
547
|
+
};
|
|
548
|
+
const addDirectory = (candidate) => {
|
|
549
|
+
if (!candidate)
|
|
550
|
+
return;
|
|
551
|
+
try {
|
|
552
|
+
if (!fs.statSync(candidate).isDirectory())
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const resolved = path.resolve(candidate);
|
|
559
|
+
if (!looksLikeGitMetadataDirectory(resolved))
|
|
560
|
+
return;
|
|
561
|
+
paths.add(resolved);
|
|
562
|
+
paths.add(path.join(resolved, '**'));
|
|
563
|
+
};
|
|
564
|
+
try {
|
|
565
|
+
const stat = fs.statSync(marker);
|
|
566
|
+
if (stat.isDirectory()) {
|
|
567
|
+
addDirectory(marker);
|
|
568
|
+
}
|
|
569
|
+
else if (stat.isFile()) {
|
|
570
|
+
const match = fs.readFileSync(marker, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
|
|
571
|
+
if (match)
|
|
572
|
+
addDirectory(path.resolve(project, match[1]));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
catch { }
|
|
576
|
+
for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith(`${path.sep}**`))) {
|
|
577
|
+
try {
|
|
578
|
+
const commonDirectory = fs.readFileSync(path.join(gitDirectory, 'commondir'), 'utf8').trim();
|
|
579
|
+
if (commonDirectory)
|
|
580
|
+
addDirectory(path.resolve(gitDirectory, commonDirectory));
|
|
581
|
+
}
|
|
582
|
+
catch { }
|
|
583
|
+
}
|
|
584
|
+
return Object.fromEntries([...paths].map(candidate => [candidate, 'write']));
|
|
585
|
+
}
|
|
586
|
+
buildPermissionProfileConfig(sessionId, mode, projectPath) {
|
|
587
|
+
const profileName = this.permissionProfileName(sessionId);
|
|
588
|
+
const managedTempDir = this.ensureSessionTempDir(sessionId);
|
|
589
|
+
const network = {
|
|
590
|
+
enabled: true,
|
|
591
|
+
unix_sockets: { [resolvePaths().instanceSocket]: 'allow' },
|
|
592
|
+
};
|
|
593
|
+
return {
|
|
594
|
+
default_permissions: profileName,
|
|
595
|
+
permissions: {
|
|
596
|
+
[profileName]: {
|
|
597
|
+
extends: mode === 'readonly' ? ':read-only' : ':workspace',
|
|
598
|
+
filesystem: {
|
|
599
|
+
...buildCodexProtectedFilesystemRules(resolveRoot()),
|
|
600
|
+
...this.resolveGitMetadataWriteRules(projectPath, mode),
|
|
601
|
+
...(mode !== 'readonly' && managedTempDir ? {
|
|
602
|
+
[managedTempDir]: 'write',
|
|
603
|
+
[path.join(managedTempDir, '**')]: 'write',
|
|
604
|
+
} : {}),
|
|
605
|
+
},
|
|
606
|
+
network,
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
buildExecutionSandboxOptions(sessionId, mode, projectPath) {
|
|
612
|
+
const decision = resolvePhaseOneExecutionSandbox(mode, this.permissionContexts.get(sessionId)?.role);
|
|
613
|
+
return {
|
|
614
|
+
decision,
|
|
615
|
+
permissionProfileConfig: decision.state === 'active'
|
|
616
|
+
? this.buildPermissionProfileConfig(sessionId, decision.permissionMode, projectPath)
|
|
617
|
+
: undefined,
|
|
618
|
+
sandbox: decision.state === 'off' ? 'danger-full-access' : undefined,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
buildLifecycleLockdownConfig() {
|
|
622
|
+
return {
|
|
623
|
+
features: {
|
|
624
|
+
hooks: false,
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
setMode(mode) {
|
|
629
|
+
const normalized = normalizePermissionMode(mode);
|
|
630
|
+
this.currentMode = normalized.mode;
|
|
631
|
+
}
|
|
632
|
+
getMode() { return this.currentMode; }
|
|
633
|
+
listModes() {
|
|
634
|
+
return [
|
|
635
|
+
{ key: 'readonly', nameZh: '只读', description: '只读操作自动执行,写入直接拒绝', available: true },
|
|
636
|
+
{ key: 'auto', nameZh: '自动', description: '常规操作自动执行,危险操作自动拒绝', available: true },
|
|
637
|
+
{ key: 'request', nameZh: '审批', description: '默认能力自动执行,缺失能力进入人工审批', available: true },
|
|
638
|
+
{ key: 'bypass', nameZh: '放行', description: 'owner 关闭任务沙盒,危险操作和硬边界仍受控', available: true },
|
|
639
|
+
];
|
|
640
|
+
}
|
|
641
|
+
setSendPrompt(fn) { this.sendPromptFn = fn; }
|
|
642
|
+
setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
|
|
643
|
+
setPermissionGateway(gw) { this.permissionGateway = gw; }
|
|
644
|
+
// ── Stream management (needed by MessageProcessor) ──
|
|
645
|
+
registerStream(key, stream) {
|
|
646
|
+
this.activeStreams.set(key, stream);
|
|
647
|
+
}
|
|
648
|
+
cleanupStream(key) {
|
|
649
|
+
this.activeStreams.delete(key);
|
|
650
|
+
this.activeAbortControllers.delete(key);
|
|
651
|
+
this.pendingInterrupts.delete(key);
|
|
652
|
+
}
|
|
653
|
+
hasActiveStream(key) {
|
|
654
|
+
return this.activeStreams.has(key) || this.activeAbortControllers.has(key) || this.activeTurns.has(key);
|
|
655
|
+
}
|
|
656
|
+
// ── Core: runQuery ──
|
|
657
|
+
async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv) {
|
|
658
|
+
let agentSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
|
|
659
|
+
const callModel = modelOverride?.model || this.model;
|
|
660
|
+
const callEffort = modelOverride?.effort ?? this.effort;
|
|
661
|
+
// per-call 权限模式:优先 override(message-processor 解析后传入),缺省回落实例级 currentMode。
|
|
662
|
+
// 写入 chatModes 供异步审批回调按 sessionKey 读取,并据此派生本次 thread 的 approvalPolicy/sandbox,
|
|
663
|
+
// 不依赖共享的 this.approvalPolicy/this.sandboxMode(多会话并发互不污染)。
|
|
664
|
+
const requestedPermissionMode = modelOverride?.permissionMode || this.currentMode;
|
|
665
|
+
const normalizedPermission = normalizePermissionMode(requestedPermissionMode);
|
|
666
|
+
const callMode = normalizedPermission.mode;
|
|
667
|
+
this.chatModes.set(sessionId, callMode);
|
|
668
|
+
const callApprovalPolicy = this.toApprovalPolicy(callMode);
|
|
669
|
+
const executionSandbox = this.buildExecutionSandboxOptions(sessionId, callMode, projectPath);
|
|
670
|
+
const appServer = this.getAppServerClient();
|
|
671
|
+
this.dropExpiredPendingInterrupt(sessionId);
|
|
672
|
+
const effectiveApprovalPolicy = callApprovalPolicy;
|
|
673
|
+
const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
|
|
674
|
+
const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, callMode);
|
|
675
|
+
const managedTempInstruction = this.managedTempInstruction(sessionId);
|
|
676
|
+
const developerInstructions = [systemPromptAppend, managedTempInstruction].filter(Boolean).join('\n\n') || undefined;
|
|
677
|
+
const threadOptions = {
|
|
678
|
+
model: callModel,
|
|
679
|
+
effort: callEffort,
|
|
680
|
+
approvalPolicy: effectiveApprovalPolicy,
|
|
681
|
+
approvalsReviewer: 'user',
|
|
682
|
+
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
683
|
+
config: this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId), this.buildLifecycleLockdownConfig()),
|
|
684
|
+
...(developerInstructions ? { developerInstructions } : {}),
|
|
685
|
+
};
|
|
686
|
+
logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
|
|
687
|
+
`role=${executionSandbox.decision.role ?? 'none'} sandbox=${executionSandbox.decision.state}`);
|
|
688
|
+
const threadResponse = agentSessionId
|
|
689
|
+
? await appServer.threadResume(agentSessionId, projectPath, threadOptions)
|
|
690
|
+
: await appServer.threadStart(projectPath, threadOptions);
|
|
691
|
+
const threadId = threadResponse.thread?.id || agentSessionId;
|
|
692
|
+
if (!threadId)
|
|
693
|
+
throw new Error('Codex app-server did not return a thread id');
|
|
694
|
+
agentSessionId = threadId;
|
|
695
|
+
this.activeSessions.set(sessionId, threadId);
|
|
696
|
+
this.threadProjectPaths.set(threadId, path.resolve(projectPath));
|
|
697
|
+
this.bindExternalToolBoundary(threadId, externalToolConfig);
|
|
698
|
+
this.onSessionIdUpdate?.(sessionId, threadId);
|
|
699
|
+
const controller = new AbortController();
|
|
700
|
+
this.activeAbortControllers.set(sessionId, controller);
|
|
701
|
+
const tempFiles = [];
|
|
702
|
+
const input = this.buildAppServerInput(prompt, images, tempFiles);
|
|
703
|
+
const queue = new AsyncEventQueue();
|
|
704
|
+
controller.signal.addEventListener('abort', () => queue.end(), { once: true });
|
|
705
|
+
const state = {
|
|
706
|
+
threadId,
|
|
707
|
+
model: callModel,
|
|
708
|
+
streamedAgentMessageIds: new Set(),
|
|
709
|
+
agentMessageDeltaText: new Map(),
|
|
710
|
+
completedItemIds: new Set(),
|
|
711
|
+
emittedEditCallIds: new Set(),
|
|
712
|
+
completedTurnIds: new Set(),
|
|
713
|
+
openToolCalls: new Map(),
|
|
714
|
+
};
|
|
715
|
+
const unsubscribe = appServer.onNotification(notification => {
|
|
716
|
+
// 仅从 turn/started 锁定权威 turnId — resume 时会有上一轮 turn 的残留通知
|
|
717
|
+
// (如 thread/tokenUsage/updated)先于新 turn 到达,不能用它们 latch turnId
|
|
718
|
+
const params = notification.params || {};
|
|
719
|
+
const notifThreadId = params.threadId ?? params.thread_id;
|
|
720
|
+
if (notifThreadId !== undefined && notifThreadId !== threadId)
|
|
721
|
+
return;
|
|
722
|
+
if (notification.method === 'turn/started') {
|
|
723
|
+
const startedTurnId = this.extractTurnId(notification);
|
|
724
|
+
if (startedTurnId && !state.turnId) {
|
|
725
|
+
state.turnId = startedTurnId;
|
|
726
|
+
this.activeTurns.set(sessionId, { threadId, turnId: startedTurnId });
|
|
727
|
+
if (this.consumePendingInterrupt(sessionId)) {
|
|
728
|
+
this.interruptAppServerTurn(threadId, startedTurnId).catch(() => { });
|
|
729
|
+
this.activeTurns.delete(sessionId);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (!this.isAppServerTurnNotification(notification, state))
|
|
734
|
+
return;
|
|
735
|
+
queue.push(notification);
|
|
736
|
+
// 仅在已锁定 turnId 后才允许 turn/completed 结束队列,避免残留的旧 turn/completed 误关
|
|
737
|
+
if (notification.method === 'turn/completed' && state.turnId)
|
|
738
|
+
queue.end();
|
|
739
|
+
});
|
|
740
|
+
if (this.consumePendingInterrupt(sessionId)) {
|
|
741
|
+
controller.abort('User interrupt');
|
|
742
|
+
this.activeAbortControllers.delete(sessionId);
|
|
743
|
+
this.activeStreams.delete(sessionId);
|
|
744
|
+
logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
|
|
745
|
+
return this.transformAppServerStream(queue, sessionId, state, unsubscribe, tempFiles);
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
const turnResponse = await appServer.turnStart(threadId, input, {
|
|
749
|
+
cwd: projectPath,
|
|
750
|
+
model: callModel,
|
|
751
|
+
effort: callEffort,
|
|
752
|
+
approvalPolicy: effectiveApprovalPolicy,
|
|
753
|
+
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
754
|
+
});
|
|
755
|
+
const turnId = turnResponse.turn?.id;
|
|
756
|
+
if (turnId && !state.turnId) {
|
|
757
|
+
state.turnId = turnId;
|
|
758
|
+
this.activeTurns.set(sessionId, { threadId, turnId });
|
|
759
|
+
}
|
|
760
|
+
if (turnId && this.consumePendingInterrupt(sessionId)) {
|
|
761
|
+
await this.interruptAppServerTurn(threadId, turnId);
|
|
762
|
+
controller.abort('User interrupt');
|
|
763
|
+
this.activeAbortControllers.delete(sessionId);
|
|
764
|
+
this.activeStreams.delete(sessionId);
|
|
765
|
+
this.activeTurns.delete(sessionId);
|
|
766
|
+
}
|
|
767
|
+
const status = turnResponse.turn?.status;
|
|
768
|
+
if (status === 'completed' || status === 'failed') {
|
|
769
|
+
queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
|
|
770
|
+
queue.end();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
unsubscribe();
|
|
775
|
+
this.activeAbortControllers.delete(sessionId);
|
|
776
|
+
this.activeTurns.delete(sessionId);
|
|
777
|
+
this.pendingInterrupts.delete(sessionId);
|
|
778
|
+
this.cleanupTempFiles(tempFiles);
|
|
779
|
+
throw error;
|
|
780
|
+
}
|
|
781
|
+
return this.transformAppServerStream(queue, sessionId, state, unsubscribe, tempFiles);
|
|
782
|
+
}
|
|
783
|
+
// ── Interrupt ──
|
|
784
|
+
async interrupt(sessionKey) {
|
|
785
|
+
const controller = this.activeAbortControllers.get(sessionKey);
|
|
786
|
+
const activeTurn = this.activeTurns.get(sessionKey);
|
|
787
|
+
const hadActiveState = !!controller || !!activeTurn || this.activeStreams.has(sessionKey);
|
|
788
|
+
const interruptTurn = activeTurn
|
|
789
|
+
? this.interruptAppServerTurn(activeTurn.threadId, activeTurn.turnId)
|
|
790
|
+
: Promise.resolve();
|
|
791
|
+
if (!activeTurn)
|
|
792
|
+
this.rememberPendingInterrupt(sessionKey);
|
|
793
|
+
if (controller)
|
|
794
|
+
controller.abort('User interrupt');
|
|
795
|
+
if (hadActiveState) {
|
|
796
|
+
this.activeAbortControllers.delete(sessionKey);
|
|
797
|
+
this.activeStreams.delete(sessionKey);
|
|
798
|
+
this.activeTurns.delete(sessionKey);
|
|
799
|
+
logger.info(`[CodexRunner] Interrupted session: ${sessionKey}`);
|
|
800
|
+
}
|
|
801
|
+
await interruptTurn;
|
|
802
|
+
}
|
|
803
|
+
rememberPendingInterrupt(sessionId) {
|
|
804
|
+
this.pendingInterrupts.set(sessionId, Date.now());
|
|
805
|
+
}
|
|
806
|
+
consumePendingInterrupt(sessionId) {
|
|
807
|
+
if (!this.pendingInterrupts.has(sessionId))
|
|
808
|
+
return false;
|
|
809
|
+
const requestedAt = this.pendingInterrupts.get(sessionId);
|
|
810
|
+
this.pendingInterrupts.delete(sessionId);
|
|
811
|
+
return Date.now() - requestedAt <= this.pendingInterruptTtlMs;
|
|
812
|
+
}
|
|
813
|
+
dropExpiredPendingInterrupt(sessionId) {
|
|
814
|
+
const requestedAt = this.pendingInterrupts.get(sessionId);
|
|
815
|
+
if (requestedAt !== undefined && Date.now() - requestedAt > this.pendingInterruptTtlMs) {
|
|
816
|
+
this.pendingInterrupts.delete(sessionId);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async interruptAppServerTurn(threadId, turnId) {
|
|
820
|
+
try {
|
|
821
|
+
await this.getAppServerClient().turnInterrupt(threadId, turnId);
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// ── Session commands ──
|
|
828
|
+
updateSessionId(sessionId, agentSessionId) {
|
|
829
|
+
const previousThreadId = this.activeSessions.get(sessionId);
|
|
830
|
+
if (previousThreadId && previousThreadId !== agentSessionId) {
|
|
831
|
+
this.threadProjectPaths.delete(previousThreadId);
|
|
832
|
+
this.unbindExternalToolBoundary(previousThreadId);
|
|
833
|
+
this.clearTrackedApprovalItemsForThread(previousThreadId);
|
|
834
|
+
}
|
|
835
|
+
if (agentSessionId) {
|
|
836
|
+
this.activeSessions.set(sessionId, agentSessionId);
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
this.activeSessions.delete(sessionId);
|
|
840
|
+
}
|
|
841
|
+
this.onSessionIdUpdate?.(sessionId, agentSessionId);
|
|
842
|
+
}
|
|
843
|
+
async closeSession(sessionId) {
|
|
844
|
+
const threadId = this.activeSessions.get(sessionId);
|
|
845
|
+
this.activeSessions.delete(sessionId);
|
|
846
|
+
this.activeStreams.delete(sessionId);
|
|
847
|
+
this.activeAbortControllers.delete(sessionId);
|
|
848
|
+
this.activeTurns.delete(sessionId);
|
|
849
|
+
this.pendingInterrupts.delete(sessionId);
|
|
850
|
+
this.permissionContexts.delete(sessionId);
|
|
851
|
+
this.chatModes.delete(sessionId);
|
|
852
|
+
this.cleanupSessionTempDir(sessionId);
|
|
853
|
+
if (threadId) {
|
|
854
|
+
this.threadProjectPaths.delete(threadId);
|
|
855
|
+
this.unbindExternalToolBoundary(threadId);
|
|
856
|
+
}
|
|
857
|
+
this.clearTrackedApprovalItemsForThread(threadId);
|
|
858
|
+
}
|
|
859
|
+
resolveSessionFile(agentSessionId, _projectPath) {
|
|
860
|
+
// Codex session 文件: ~/.codex/sessions/YYYY/MM/DD/rollout-*-{threadId}.jsonl
|
|
861
|
+
const sessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
|
862
|
+
if (!fs.existsSync(sessionsDir))
|
|
863
|
+
return null;
|
|
864
|
+
// 递归搜索文件名包含 threadId 的 JSONL 文件
|
|
865
|
+
const search = (dir) => {
|
|
866
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
867
|
+
if (entry.isDirectory()) {
|
|
868
|
+
const found = search(path.join(dir, entry.name));
|
|
869
|
+
if (found)
|
|
870
|
+
return found;
|
|
871
|
+
}
|
|
872
|
+
else if (entry.name.endsWith('.jsonl') && entry.name.includes(agentSessionId)) {
|
|
873
|
+
return path.join(dir, entry.name);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return null;
|
|
877
|
+
};
|
|
878
|
+
return search(sessionsDir);
|
|
879
|
+
}
|
|
880
|
+
async clearSession(sessionId, _agentSessionId, _projectPath) {
|
|
881
|
+
// Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
|
|
882
|
+
const threadId = this.activeSessions.get(sessionId) ?? _agentSessionId;
|
|
883
|
+
this.activeSessions.delete(sessionId);
|
|
884
|
+
this.chatModes.delete(sessionId);
|
|
885
|
+
this.cleanupSessionTempDir(sessionId);
|
|
886
|
+
this.threadProjectPaths.delete(threadId);
|
|
887
|
+
this.unbindExternalToolBoundary(threadId);
|
|
888
|
+
this.clearTrackedApprovalItemsForThread(threadId);
|
|
889
|
+
this.onSessionIdUpdate?.(sessionId, '');
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
async compactSession(_sessionId, agentSessionId, _projectPath) {
|
|
893
|
+
try {
|
|
894
|
+
const appServer = this.getAppServerClient();
|
|
895
|
+
this.onCompactStart?.(_sessionId);
|
|
896
|
+
try {
|
|
897
|
+
return await this.startAndWaitForCompact(appServer, agentSessionId);
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
if (!this.isThreadNotFoundError(error))
|
|
901
|
+
throw error;
|
|
902
|
+
logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
|
|
903
|
+
// 优先用 per-session 模式派生(与 runQuery 一致),缺省回落实例级
|
|
904
|
+
const compactMode = this.chatModes.get(_sessionId) ?? this.currentMode;
|
|
905
|
+
const compactPolicy = this.toApprovalPolicy(compactMode);
|
|
906
|
+
const executionSandbox = this.buildExecutionSandboxOptions(_sessionId, compactMode, _projectPath);
|
|
907
|
+
const capabilityConfig = await this.resolveCapabilityThreadConfig(_projectPath);
|
|
908
|
+
const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, _projectPath, capabilityConfig, compactMode);
|
|
909
|
+
await appServer.threadResume(agentSessionId, _projectPath, {
|
|
910
|
+
model: this.model,
|
|
911
|
+
effort: this.effort,
|
|
912
|
+
approvalPolicy: compactPolicy,
|
|
913
|
+
approvalsReviewer: 'user',
|
|
914
|
+
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
915
|
+
config: this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId), this.buildLifecycleLockdownConfig()),
|
|
916
|
+
});
|
|
917
|
+
this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
|
|
918
|
+
this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
|
|
919
|
+
return await this.startAndWaitForCompact(appServer, agentSessionId);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
logger.error('[CodexRunner] Compact failed:', error);
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
async compact(sessionId, agentSessionId, projectPath) {
|
|
928
|
+
return this.compactSession(sessionId, agentSessionId, projectPath);
|
|
929
|
+
}
|
|
930
|
+
async startAndWaitForCompact(appServer, threadId) {
|
|
931
|
+
const completion = this.waitForThreadCompacted(appServer, threadId, Date.now());
|
|
932
|
+
try {
|
|
933
|
+
await appServer.threadCompactStart(threadId);
|
|
934
|
+
await completion.promise;
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
finally {
|
|
938
|
+
completion.dispose();
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
waitForThreadCompacted(appServer, threadId, startedAtMs) {
|
|
942
|
+
let unsubscribe;
|
|
943
|
+
let pollTimer;
|
|
944
|
+
let settled = false;
|
|
945
|
+
const settle = (resolve, source) => {
|
|
946
|
+
if (settled)
|
|
947
|
+
return;
|
|
948
|
+
settled = true;
|
|
949
|
+
if (pollTimer)
|
|
950
|
+
clearInterval(pollTimer);
|
|
951
|
+
logger.info(`[CodexRunner] Compact completed for thread: ${threadId} (${source})`);
|
|
952
|
+
unsubscribe?.();
|
|
953
|
+
resolve();
|
|
954
|
+
};
|
|
955
|
+
const promise = new Promise(resolve => {
|
|
956
|
+
unsubscribe = appServer.onNotification(notification => {
|
|
957
|
+
if (notification.method !== 'thread/compacted')
|
|
958
|
+
return;
|
|
959
|
+
const params = notification.params || {};
|
|
960
|
+
const notifThreadId = params.threadId ?? params.thread_id;
|
|
961
|
+
if (notifThreadId !== threadId)
|
|
962
|
+
return;
|
|
963
|
+
settle(resolve, 'notification');
|
|
964
|
+
});
|
|
965
|
+
pollTimer = setInterval(() => {
|
|
966
|
+
if (this.hasPersistedCompactCompletion(threadId, startedAtMs)) {
|
|
967
|
+
settle(resolve, 'session-log');
|
|
968
|
+
}
|
|
969
|
+
}, 1000);
|
|
970
|
+
pollTimer.unref?.();
|
|
971
|
+
});
|
|
972
|
+
return {
|
|
973
|
+
promise,
|
|
974
|
+
dispose: () => {
|
|
975
|
+
if (pollTimer)
|
|
976
|
+
clearInterval(pollTimer);
|
|
977
|
+
if (!settled)
|
|
978
|
+
unsubscribe?.();
|
|
979
|
+
},
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
hasPersistedCompactCompletion(threadId, startedAtMs) {
|
|
983
|
+
const sessionFile = this.findCodexSessionFile(threadId);
|
|
984
|
+
if (!sessionFile)
|
|
985
|
+
return false;
|
|
986
|
+
let text = '';
|
|
987
|
+
try {
|
|
988
|
+
text = fs.readFileSync(sessionFile, 'utf8');
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
const threshold = startedAtMs - 1000;
|
|
994
|
+
for (const line of text.trimEnd().split('\n').reverse()) {
|
|
995
|
+
if (!line.trim())
|
|
996
|
+
continue;
|
|
997
|
+
let entry;
|
|
998
|
+
try {
|
|
999
|
+
entry = JSON.parse(line);
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
1005
|
+
if (!Number.isFinite(ts))
|
|
1006
|
+
continue;
|
|
1007
|
+
if (ts < threshold)
|
|
1008
|
+
break;
|
|
1009
|
+
const payloadType = entry.payload?.type;
|
|
1010
|
+
if (entry.type === 'compacted' || payloadType === 'context_compacted')
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
findCodexSessionFile(threadId) {
|
|
1016
|
+
const root = process.env.CODEX_HOME
|
|
1017
|
+
? path.join(process.env.CODEX_HOME, 'sessions')
|
|
1018
|
+
: path.join(process.env.HOME || os.homedir(), '.codex', 'sessions');
|
|
1019
|
+
const stack = [root];
|
|
1020
|
+
let newest;
|
|
1021
|
+
while (stack.length > 0) {
|
|
1022
|
+
const dir = stack.pop();
|
|
1023
|
+
let entries;
|
|
1024
|
+
try {
|
|
1025
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
for (const entry of entries) {
|
|
1031
|
+
const fullPath = path.join(dir, entry.name);
|
|
1032
|
+
if (entry.isDirectory()) {
|
|
1033
|
+
stack.push(fullPath);
|
|
1034
|
+
}
|
|
1035
|
+
else if (entry.isFile() && entry.name.includes(threadId) && entry.name.endsWith('.jsonl')) {
|
|
1036
|
+
let mtimeMs = 0;
|
|
1037
|
+
try {
|
|
1038
|
+
mtimeMs = fs.statSync(fullPath).mtimeMs;
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (!newest || mtimeMs > newest.mtimeMs)
|
|
1044
|
+
newest = { path: fullPath, mtimeMs };
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return newest?.path;
|
|
1049
|
+
}
|
|
1050
|
+
async forkSession(agentSessionId, projectPath, title) {
|
|
1051
|
+
const sessionKey = this.findSessionKeyByThread(agentSessionId);
|
|
1052
|
+
const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
1053
|
+
const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
|
|
1054
|
+
const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
|
|
1055
|
+
const appServer = this.getAppServerClient();
|
|
1056
|
+
const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
|
|
1057
|
+
const response = await appServer.threadFork(agentSessionId, projectPath, title, {
|
|
1058
|
+
model: this.model,
|
|
1059
|
+
effort: this.effort,
|
|
1060
|
+
approvalPolicy: this.toApprovalPolicy(mode),
|
|
1061
|
+
approvalsReviewer: 'user',
|
|
1062
|
+
...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
|
|
1063
|
+
config: this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionKey), this.buildLifecycleLockdownConfig()),
|
|
1064
|
+
});
|
|
1065
|
+
const forkedThreadId = response.thread?.id;
|
|
1066
|
+
if (!forkedThreadId)
|
|
1067
|
+
throw new Error('Codex fork did not return a thread id');
|
|
1068
|
+
this.threadProjectPaths.set(forkedThreadId, path.resolve(projectPath));
|
|
1069
|
+
this.bindExternalToolBoundary(forkedThreadId, externalToolConfig);
|
|
1070
|
+
return forkedThreadId;
|
|
1071
|
+
}
|
|
1072
|
+
async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
|
|
1073
|
+
const turns = buildSessionTurnList(await this.getSessionMessages(agentSessionId, projectPath));
|
|
1074
|
+
const targetIndex = turns.findIndex(turn => turn.assistantUuid === assistantMessageId);
|
|
1075
|
+
if (targetIndex < 0)
|
|
1076
|
+
throw new Error('fork point not found');
|
|
1077
|
+
const forkedThreadId = await this.forkSession(agentSessionId, projectPath, title);
|
|
1078
|
+
const turnsToDiscard = turns.length - targetIndex - 1;
|
|
1079
|
+
if (turnsToDiscard > 0) {
|
|
1080
|
+
const rolledBack = await this.rollbackSessionTurns(forkedThreadId, projectPath, turnsToDiscard);
|
|
1081
|
+
if (!rolledBack)
|
|
1082
|
+
throw new Error('forked thread rollback failed');
|
|
1083
|
+
}
|
|
1084
|
+
return forkedThreadId;
|
|
1085
|
+
}
|
|
1086
|
+
async setSessionName(agentSessionId, name) {
|
|
1087
|
+
return this.getAppServerClient().threadSetName(agentSessionId, name);
|
|
1088
|
+
}
|
|
1089
|
+
async updateSessionMetadata(agentSessionId, metadata) {
|
|
1090
|
+
const gitInfo = metadata?.gitInfo && typeof metadata.gitInfo === 'object' ? metadata.gitInfo : undefined;
|
|
1091
|
+
return this.getAppServerClient().threadMetadataUpdate(agentSessionId, gitInfo);
|
|
1092
|
+
}
|
|
1093
|
+
async getSessionMessages(agentSessionId, projectPath) {
|
|
1094
|
+
const response = await this.getAppServerClient().threadRead(agentSessionId, true);
|
|
1095
|
+
return this.mapThreadToSessionMessages(response, agentSessionId);
|
|
1096
|
+
}
|
|
1097
|
+
approvalItemKey(threadId, turnId, itemId) {
|
|
1098
|
+
if (typeof threadId !== 'string' || typeof turnId !== 'string' || typeof itemId !== 'string')
|
|
1099
|
+
return undefined;
|
|
1100
|
+
return JSON.stringify([threadId, turnId, itemId]);
|
|
1101
|
+
}
|
|
1102
|
+
trackApprovalItemNotification(notification) {
|
|
1103
|
+
const params = (notification.params || {});
|
|
1104
|
+
const item = params.item && typeof params.item === 'object' ? params.item : undefined;
|
|
1105
|
+
const itemId = item?.id ?? params.itemId;
|
|
1106
|
+
const threadId = this.requestThreadId(params);
|
|
1107
|
+
const key = this.approvalItemKey(threadId, params.turnId, itemId);
|
|
1108
|
+
if (notification.method === 'item/started' && key && item && threadId) {
|
|
1109
|
+
this.trackedApprovalItems.set(key, {
|
|
1110
|
+
threadId,
|
|
1111
|
+
turnId: params.turnId,
|
|
1112
|
+
itemId,
|
|
1113
|
+
item: { ...item },
|
|
1114
|
+
});
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (notification.method === 'item/fileChange/patchUpdated' && key && threadId) {
|
|
1118
|
+
const tracked = this.trackedApprovalItems.get(key);
|
|
1119
|
+
this.trackedApprovalItems.set(key, {
|
|
1120
|
+
threadId,
|
|
1121
|
+
turnId: params.turnId,
|
|
1122
|
+
itemId,
|
|
1123
|
+
item: {
|
|
1124
|
+
...(tracked?.item ?? { id: itemId, type: 'fileChange' }),
|
|
1125
|
+
changes: params.changes,
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (notification.method === 'item/completed' && key) {
|
|
1131
|
+
this.trackedApprovalItems.delete(key);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (notification.method === 'turn/completed' && threadId) {
|
|
1135
|
+
const completedTurnId = typeof params.turnId === 'string'
|
|
1136
|
+
? params.turnId
|
|
1137
|
+
: typeof params.turn?.id === 'string'
|
|
1138
|
+
? params.turn.id
|
|
1139
|
+
: undefined;
|
|
1140
|
+
for (const [trackedKey, tracked] of this.trackedApprovalItems) {
|
|
1141
|
+
if (tracked.threadId === threadId && (!completedTurnId || tracked.turnId === completedTurnId)) {
|
|
1142
|
+
this.trackedApprovalItems.delete(trackedKey);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
findTrackedApprovalItem(params) {
|
|
1148
|
+
const key = this.approvalItemKey(this.requestThreadId(params), params.turnId, params.itemId);
|
|
1149
|
+
return key ? this.trackedApprovalItems.get(key)?.item : undefined;
|
|
1150
|
+
}
|
|
1151
|
+
requestThreadId(params) {
|
|
1152
|
+
const threadId = typeof params.threadId === 'string' && params.threadId.length > 0
|
|
1153
|
+
? params.threadId
|
|
1154
|
+
: undefined;
|
|
1155
|
+
if (threadId)
|
|
1156
|
+
return threadId;
|
|
1157
|
+
return typeof params.conversationId === 'string' && params.conversationId.length > 0
|
|
1158
|
+
? params.conversationId
|
|
1159
|
+
: undefined;
|
|
1160
|
+
}
|
|
1161
|
+
clearTrackedApprovalItemsForThread(threadId) {
|
|
1162
|
+
if (!threadId)
|
|
1163
|
+
return;
|
|
1164
|
+
for (const [key, tracked] of this.trackedApprovalItems) {
|
|
1165
|
+
if (tracked.threadId === threadId)
|
|
1166
|
+
this.trackedApprovalItems.delete(key);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
async handleAppServerRequest(request) {
|
|
1170
|
+
const params = (request.params || {});
|
|
1171
|
+
if (request.method === 'mcpServer/elicitation/request') {
|
|
1172
|
+
logger.warn(`[CodexRunner] MCP elicitation cancelled because EvolCore has no audited elicitation bridge: thread=${params.threadId ?? '<missing>'} server=${params.serverName ?? '<missing>'}`);
|
|
1173
|
+
return { action: 'cancel', content: null, _meta: null };
|
|
1174
|
+
}
|
|
1175
|
+
if (request.method === 'item/tool/requestUserInput') {
|
|
1176
|
+
const trackedItem = this.findTrackedApprovalItem(params);
|
|
1177
|
+
if (trackedItem?.type === 'mcpToolCall') {
|
|
1178
|
+
const unattendedDenial = await this.denyUnattendedExternalToolApproval(params, `MCP:${String(trackedItem.server || 'unknown')}/${String(trackedItem.tool || 'unknown')}`, request.id);
|
|
1179
|
+
if (unattendedDenial)
|
|
1180
|
+
return unattendedDenial;
|
|
1181
|
+
return this.handleMcpToolApproval(params, trackedItem);
|
|
1182
|
+
}
|
|
1183
|
+
if (trackedItem) {
|
|
1184
|
+
return this.handleToolRequestUserInput(params);
|
|
1185
|
+
}
|
|
1186
|
+
if (this.looksLikeExternalToolApproval(params.questions)) {
|
|
1187
|
+
const unattendedDenial = await this.denyUnattendedExternalToolApproval(params, 'MCP:untracked', request.id);
|
|
1188
|
+
if (unattendedDenial)
|
|
1189
|
+
return unattendedDenial;
|
|
1190
|
+
return this.denyUntrackedExternalToolApproval(params);
|
|
1191
|
+
}
|
|
1192
|
+
return this.handleToolRequestUserInput(params);
|
|
1193
|
+
}
|
|
1194
|
+
const approvalMethods = new Set([
|
|
1195
|
+
'item/commandExecution/requestApproval',
|
|
1196
|
+
'item/fileChange/requestApproval',
|
|
1197
|
+
'item/permissions/requestApproval',
|
|
1198
|
+
'applyPatchApproval',
|
|
1199
|
+
'execCommandApproval',
|
|
1200
|
+
]);
|
|
1201
|
+
if (!approvalMethods.has(request.method)) {
|
|
1202
|
+
throw new Error(`Unsupported Codex app-server request: ${request.method}`);
|
|
1203
|
+
}
|
|
1204
|
+
const sessionKey = this.findSessionKeyByThread(params.threadId, params.conversationId);
|
|
1205
|
+
const permissionContext = this.permissionContexts.get(sessionKey);
|
|
1206
|
+
const unattended = permissionContext?.approvalInteractionPolicy === 'deny';
|
|
1207
|
+
const toolName = request.method === 'item/permissions/requestApproval'
|
|
1208
|
+
? 'PermissionGrant'
|
|
1209
|
+
: request.method.includes('fileChange') || request.method === 'applyPatchApproval'
|
|
1210
|
+
? 'FileChange'
|
|
1211
|
+
: 'Bash';
|
|
1212
|
+
const toolInput = this.buildPermissionInput(request.method, params);
|
|
1213
|
+
// proactive 模式行为策略(首次工具调用必须是 ec msg send)
|
|
1214
|
+
const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
|
|
1215
|
+
if (policyResult?.block) {
|
|
1216
|
+
return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
1217
|
+
}
|
|
1218
|
+
const summary = this.summarizeAppServerRequest(request.method, params);
|
|
1219
|
+
const reason = params.reason || params.decisionReason || undefined;
|
|
1220
|
+
const workspacePath = this.resolvePermissionWorkspacePath(params);
|
|
1221
|
+
if (!workspacePath) {
|
|
1222
|
+
logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
|
|
1223
|
+
return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
|
|
1224
|
+
}
|
|
1225
|
+
const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
|
|
1226
|
+
const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
1227
|
+
logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${summary}`);
|
|
1228
|
+
try {
|
|
1229
|
+
const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd);
|
|
1230
|
+
const response = this.toAppServerApprovalResponse(request.method, decision, toolInput, unattended);
|
|
1231
|
+
logger.info(`[CodexRunner] app-server approval response id=${request.id} method=${request.method} decision=${decision} response=${JSON.stringify(response)}`);
|
|
1232
|
+
return response;
|
|
1233
|
+
}
|
|
1234
|
+
catch (error) {
|
|
1235
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1236
|
+
logger.warn(`[CodexRunner] app-server approval failed id=${request.id} method=${request.method}: ${message}`);
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
classifyMcpApprovalLabel(label) {
|
|
1241
|
+
const normalized = label.trim().toLowerCase().replace(/[\s_-]+/g, ' ');
|
|
1242
|
+
const negative = /\b(decline|deny|reject|cancel|no|block|stop)\b|do not|don't|not\s+(?:accept|approve|allow)|拒绝|不同意|不允许|取消|否|停止/i.test(normalized);
|
|
1243
|
+
if (negative)
|
|
1244
|
+
return 'deny';
|
|
1245
|
+
const positive = /\b(accept|approve|allow|yes|ok|okay|proceed|continue)\b|同意|允许|批准|确认|继续/i.test(normalized);
|
|
1246
|
+
if (!positive)
|
|
1247
|
+
return 'other';
|
|
1248
|
+
const persistent = /\b(always|session|chat|conversation|remember|across)\b|don't ask|do not ask|永久|始终|会话|不再询问|记住/i.test(normalized);
|
|
1249
|
+
return persistent ? 'allow_persistent' : 'allow_once';
|
|
1250
|
+
}
|
|
1251
|
+
selectMcpApprovalAnswer(question, allow) {
|
|
1252
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
1253
|
+
const labels = options
|
|
1254
|
+
.map((option) => typeof option?.label === 'string' ? option.label.trim() : '')
|
|
1255
|
+
.filter(Boolean);
|
|
1256
|
+
if (allow) {
|
|
1257
|
+
const oneShot = labels.find(label => this.classifyMcpApprovalLabel(label) === 'allow_once'
|
|
1258
|
+
&& /\b(once|this time)\b|本次|仅此次/i.test(label));
|
|
1259
|
+
if (oneShot)
|
|
1260
|
+
return oneShot;
|
|
1261
|
+
const explicit = labels.find(label => this.classifyMcpApprovalLabel(label) === 'allow_once');
|
|
1262
|
+
if (explicit)
|
|
1263
|
+
return explicit;
|
|
1264
|
+
const persistent = labels.find(label => this.classifyMcpApprovalLabel(label) === 'allow_persistent');
|
|
1265
|
+
if (persistent)
|
|
1266
|
+
return persistent;
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
const decline = labels.find(label => this.classifyMcpApprovalLabel(label) === 'deny'
|
|
1270
|
+
&& !/\bcancel\b|取消/i.test(label));
|
|
1271
|
+
if (decline)
|
|
1272
|
+
return decline;
|
|
1273
|
+
const cancel = labels.find(label => this.classifyMcpApprovalLabel(label) === 'deny');
|
|
1274
|
+
if (cancel)
|
|
1275
|
+
return cancel;
|
|
1276
|
+
}
|
|
1277
|
+
throw new Error(`Codex MCP 审批缺少可验证的${allow ? '允许' : '拒绝'}选项`);
|
|
1278
|
+
}
|
|
1279
|
+
looksLikeExternalToolApproval(rawQuestions) {
|
|
1280
|
+
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0)
|
|
1281
|
+
return false;
|
|
1282
|
+
return rawQuestions.every(question => {
|
|
1283
|
+
if (!question || typeof question !== 'object' || Array.isArray(question))
|
|
1284
|
+
return false;
|
|
1285
|
+
const options = Array.isArray(question.options)
|
|
1286
|
+
? question.options
|
|
1287
|
+
: [];
|
|
1288
|
+
const labels = options
|
|
1289
|
+
.map((option) => typeof option?.label === 'string' ? option.label : '')
|
|
1290
|
+
.filter(Boolean);
|
|
1291
|
+
const hasAllow = labels.some((label) => {
|
|
1292
|
+
const kind = this.classifyMcpApprovalLabel(label);
|
|
1293
|
+
return kind === 'allow_once' || kind === 'allow_persistent';
|
|
1294
|
+
});
|
|
1295
|
+
const hasDeny = labels.some((label) => this.classifyMcpApprovalLabel(label) === 'deny');
|
|
1296
|
+
return hasAllow && hasDeny;
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
denyUntrackedExternalToolApproval(params) {
|
|
1300
|
+
const questions = Array.isArray(params.questions) ? params.questions : [];
|
|
1301
|
+
const answers = {};
|
|
1302
|
+
for (const question of questions) {
|
|
1303
|
+
const questionId = typeof question.id === 'string' ? question.id : `q-${Object.keys(answers).length + 1}`;
|
|
1304
|
+
answers[questionId] = { answers: [this.selectMcpApprovalAnswer(question, false)] };
|
|
1305
|
+
}
|
|
1306
|
+
logger.warn(`[CodexRunner] external tool approval denied because mcpToolCall metadata is missing: thread=${params.threadId ?? '<missing>'} item=${params.itemId ?? '<missing>'}`);
|
|
1307
|
+
return { answers };
|
|
1308
|
+
}
|
|
1309
|
+
async denyUnattendedExternalToolApproval(params, toolName, requestId) {
|
|
1310
|
+
const sessionKey = this.findSessionKeyByThread(params.threadId, params.conversationId);
|
|
1311
|
+
const context = this.permissionContexts.get(sessionKey);
|
|
1312
|
+
if (context?.approvalInteractionPolicy !== 'deny')
|
|
1313
|
+
return undefined;
|
|
1314
|
+
try {
|
|
1315
|
+
await context.recordExecutionAnomaly?.({
|
|
1316
|
+
code: 'runtime_permission_denied',
|
|
1317
|
+
severity: 'warning',
|
|
1318
|
+
phase: 'execution',
|
|
1319
|
+
occurredAt: Date.now(),
|
|
1320
|
+
toolName,
|
|
1321
|
+
...(typeof requestId === 'string' || typeof requestId === 'number'
|
|
1322
|
+
? { requestId: String(requestId) }
|
|
1323
|
+
: {}),
|
|
1324
|
+
policy: context.approvalRouting?.approverPolicy ?? 'requester',
|
|
1325
|
+
summary: `${toolName} runtime authorization denied by unattended Trigger policy`,
|
|
1326
|
+
effect: 'operation_skipped',
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
catch (error) {
|
|
1330
|
+
logger.warn(`[CodexRunner] failed to record unattended MCP denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
|
|
1331
|
+
}
|
|
1332
|
+
logger.info(`[CodexRunner] unattended MCP approval denied: session=${sessionKey} tool=${toolName}`);
|
|
1333
|
+
return this.denyUntrackedExternalToolApproval(params);
|
|
1334
|
+
}
|
|
1335
|
+
async handleMcpToolApproval(params, trackedItem) {
|
|
1336
|
+
const questions = Array.isArray(params.questions) ? params.questions : [];
|
|
1337
|
+
if (questions.length === 0)
|
|
1338
|
+
throw new Error('Codex MCP 审批未包含问题,已拒绝');
|
|
1339
|
+
const hasExactIdentity = typeof trackedItem.server === 'string'
|
|
1340
|
+
&& trackedItem.server.length > 0
|
|
1341
|
+
&& typeof trackedItem.tool === 'string'
|
|
1342
|
+
&& trackedItem.tool.length > 0
|
|
1343
|
+
&& Object.prototype.hasOwnProperty.call(trackedItem, 'arguments');
|
|
1344
|
+
if (!hasExactIdentity) {
|
|
1345
|
+
logger.warn(`[CodexRunner] MCP approval denied because exact tool metadata is missing: thread=${params.threadId ?? '<missing>'} item=${params.itemId ?? '<missing>'}`);
|
|
1346
|
+
return this.denyUntrackedExternalToolApproval(params);
|
|
1347
|
+
}
|
|
1348
|
+
const threadId = this.requestThreadId(params);
|
|
1349
|
+
const configFingerprint = threadId ? this.threadExternalToolFingerprints.get(threadId) : undefined;
|
|
1350
|
+
const boundary = threadId ? this.threadExternalToolBoundaries.get(threadId) : undefined;
|
|
1351
|
+
if (!configFingerprint || !boundary) {
|
|
1352
|
+
logger.warn(`[CodexRunner] MCP approval denied because the hardened external-tool config is unbound: thread=${threadId ?? '<missing>'}`);
|
|
1353
|
+
return this.denyUntrackedExternalToolApproval(params);
|
|
1354
|
+
}
|
|
1355
|
+
const sessionKey = this.findSessionKeyByThread(params.threadId, params.conversationId);
|
|
1356
|
+
const server = trackedItem.server;
|
|
1357
|
+
const tool = trackedItem.tool;
|
|
1358
|
+
const isAppCall = typeof trackedItem.mcpAppResourceUri === 'string'
|
|
1359
|
+
|| (trackedItem.appContext && typeof trackedItem.appContext === 'object');
|
|
1360
|
+
const isExplicitlyDenied = isAppCall
|
|
1361
|
+
? boundary.deniedApps.has(server)
|
|
1362
|
+
: boundary.deniedMcpServers.has(server);
|
|
1363
|
+
const isAllowedApp = !isAppCall || boundary.allowedApps.has(server) || boundary.allowAnyApp;
|
|
1364
|
+
if (isExplicitlyDenied || !isAllowedApp) {
|
|
1365
|
+
logger.warn(`[CodexRunner] MCP approval denied by the explicit capability boundary: thread=${threadId} server=${server}`);
|
|
1366
|
+
return this.denyUntrackedExternalToolApproval(params);
|
|
1367
|
+
}
|
|
1368
|
+
const toolName = `MCP:${server}/${tool}`;
|
|
1369
|
+
const toolInput = {
|
|
1370
|
+
server,
|
|
1371
|
+
tool,
|
|
1372
|
+
arguments: trackedItem.arguments,
|
|
1373
|
+
...(typeof trackedItem.pluginId === 'string' ? { pluginId: trackedItem.pluginId } : {}),
|
|
1374
|
+
...(typeof trackedItem.mcpAppResourceUri === 'string' ? { mcpAppResourceUri: trackedItem.mcpAppResourceUri } : {}),
|
|
1375
|
+
...(trackedItem.appContext && typeof trackedItem.appContext === 'object' ? { appContext: trackedItem.appContext } : {}),
|
|
1376
|
+
};
|
|
1377
|
+
const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
|
|
1378
|
+
const workspacePath = this.resolvePermissionWorkspacePath(params);
|
|
1379
|
+
const mode = normalizePermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode).mode;
|
|
1380
|
+
// MCP servers are inherited unless capability config explicitly disables
|
|
1381
|
+
// them. Apps retain their separate request/bypass lifecycle policy.
|
|
1382
|
+
let allow = !policyResult?.block
|
|
1383
|
+
&& !!workspacePath
|
|
1384
|
+
&& (isAppCall
|
|
1385
|
+
? mode === 'request' || mode === 'bypass'
|
|
1386
|
+
: mode !== 'readonly');
|
|
1387
|
+
if (allow) {
|
|
1388
|
+
try {
|
|
1389
|
+
for (const question of questions)
|
|
1390
|
+
this.selectMcpApprovalAnswer(question, true);
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
allow = false;
|
|
1394
|
+
logger.warn(`[CodexRunner] MCP approval denied because Codex offered no one-shot accept option: thread=${params.threadId ?? '<missing>'} item=${params.itemId ?? '<missing>'}`);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const answers = {};
|
|
1398
|
+
for (const question of questions) {
|
|
1399
|
+
const questionId = typeof question.id === 'string' ? question.id : `q-${Object.keys(answers).length + 1}`;
|
|
1400
|
+
answers[questionId] = { answers: [this.selectMcpApprovalAnswer(question, allow)] };
|
|
1401
|
+
}
|
|
1402
|
+
return { answers };
|
|
1403
|
+
}
|
|
1404
|
+
async handleToolRequestUserInput(params) {
|
|
1405
|
+
const sessionKey = this.findSessionKeyByThread(params.threadId, params.conversationId);
|
|
1406
|
+
const questions = Array.isArray(params.questions) ? params.questions : [];
|
|
1407
|
+
const answers = {};
|
|
1408
|
+
for (const question of questions) {
|
|
1409
|
+
const questionId = typeof question.id === 'string' ? question.id : `q-${Object.keys(answers).length + 1}`;
|
|
1410
|
+
answers[questionId] = {
|
|
1411
|
+
answers: await this.collectUserInputAnswer(sessionKey, question),
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
return { answers };
|
|
1415
|
+
}
|
|
1416
|
+
async collectUserInputAnswer(sessionKey, question) {
|
|
1417
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
1418
|
+
const fallback = options[0]?.label ? [String(options[0].label)] : [''];
|
|
1419
|
+
const context = this.permissionContexts.get(sessionKey);
|
|
1420
|
+
if (context?.approvalInteractionPolicy === 'deny') {
|
|
1421
|
+
logger.info(`[CodexRunner] interaction.auto_resolved kind=ask_user session=${sessionKey}`);
|
|
1422
|
+
const labels = options
|
|
1423
|
+
.map((option) => typeof option?.label === 'string' ? option.label.trim() : '')
|
|
1424
|
+
.filter(Boolean);
|
|
1425
|
+
if (labels.length > 0) {
|
|
1426
|
+
const prompt = `${String(question.header ?? '')} ${String(question.question ?? '')}`;
|
|
1427
|
+
const isPlanApproval = /\bplan\b|计划|方案/i.test(prompt);
|
|
1428
|
+
const affirmative = isPlanApproval
|
|
1429
|
+
? labels.find(label => {
|
|
1430
|
+
const kind = this.classifyMcpApprovalLabel(label);
|
|
1431
|
+
return kind === 'allow_once' || kind === 'allow_persistent';
|
|
1432
|
+
})
|
|
1433
|
+
: undefined;
|
|
1434
|
+
return [affirmative ?? labels[0]];
|
|
1435
|
+
}
|
|
1436
|
+
return ['当前为无人值守 Trigger,请基于已有上下文选择最安全合理的方案并继续执行。'];
|
|
1437
|
+
}
|
|
1438
|
+
const canFreeText = question.isOther !== false || options.length === 0;
|
|
1439
|
+
const sendPrompt = context?.adapter && context.channelId
|
|
1440
|
+
? async (text) => context.adapter.send(buildEnvelope({
|
|
1441
|
+
channel: context.adapter.channelName,
|
|
1442
|
+
channelId: context.channelId,
|
|
1443
|
+
replyContext: context.replyContext,
|
|
1444
|
+
}), { kind: 'result.text', text, isFinal: true })
|
|
1445
|
+
: this.sendPromptFn;
|
|
1446
|
+
if (!context?.interactionRouter || !sendPrompt) {
|
|
1447
|
+
if (sendPrompt)
|
|
1448
|
+
await sendPrompt(this.formatUserInputFallback(question, fallback));
|
|
1449
|
+
return fallback;
|
|
1450
|
+
}
|
|
1451
|
+
const requestId = `codex-ask-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1452
|
+
const buttonArgMap = {};
|
|
1453
|
+
const buttons = options.length > 0
|
|
1454
|
+
? options.map((option, index) => {
|
|
1455
|
+
const key = `opt-${index}`;
|
|
1456
|
+
buttonArgMap[key] = String(index + 1);
|
|
1457
|
+
return { key, label: String(option.label || `选项 ${index + 1}`), style: 'default' };
|
|
1458
|
+
})
|
|
1459
|
+
: [{ key: 'custom', label: '提交', style: 'primary' }];
|
|
1460
|
+
const bodyLines = [String(question.question || '')];
|
|
1461
|
+
if (options.some((option) => option.description)) {
|
|
1462
|
+
bodyLines.push('', ...options.map((option, index) => `${index + 1}. ${String(option.label || `选项 ${index + 1}`)}${option.description ? ` — ${option.description}` : ''}`));
|
|
1463
|
+
}
|
|
1464
|
+
const interaction = {
|
|
1465
|
+
type: 'interaction',
|
|
1466
|
+
id: requestId,
|
|
1467
|
+
channelId: context.channelId || '',
|
|
1468
|
+
sessionId: sessionKey,
|
|
1469
|
+
initiatorId: context.userId,
|
|
1470
|
+
kind: {
|
|
1471
|
+
kind: 'action',
|
|
1472
|
+
title: String(question.header || '问题'),
|
|
1473
|
+
body: bodyLines.join('\n'),
|
|
1474
|
+
buttons,
|
|
1475
|
+
allowCustomInput: canFreeText,
|
|
1476
|
+
},
|
|
1477
|
+
fallback: {
|
|
1478
|
+
command: 'ask',
|
|
1479
|
+
buttonArgMap,
|
|
1480
|
+
acceptFreeText: canFreeText,
|
|
1481
|
+
freeTextHint: canFreeText ? '或回复 /ask <自定义内容>' : undefined,
|
|
1482
|
+
},
|
|
1483
|
+
};
|
|
1484
|
+
const router = context.interactionRouter;
|
|
1485
|
+
router.markWaiting(sessionKey);
|
|
1486
|
+
let waitMarked = true;
|
|
1487
|
+
let sent = false;
|
|
1488
|
+
try {
|
|
1489
|
+
await context.flushPending?.();
|
|
1490
|
+
if (context.adapter && context.channelId) {
|
|
1491
|
+
const envelope = buildEnvelope({
|
|
1492
|
+
taskId: context.taskId,
|
|
1493
|
+
channel: context.channel ?? context.adapter.channelName,
|
|
1494
|
+
channelId: context.channelId,
|
|
1495
|
+
agentName: context.agentName,
|
|
1496
|
+
chatmode: context.chatmode,
|
|
1497
|
+
replyContext: context.replyContext,
|
|
1498
|
+
});
|
|
1499
|
+
sent = !!await sendInteractionPayload(context.adapter, envelope, interaction, undefined, context.replyContext);
|
|
1500
|
+
}
|
|
1501
|
+
if (!sent) {
|
|
1502
|
+
await sendPrompt(renderActionAsText(interaction));
|
|
1503
|
+
sent = true;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
catch (error) {
|
|
1507
|
+
logger.warn('[CodexRunner] requestUserInput prompt send failed:', error);
|
|
1508
|
+
}
|
|
1509
|
+
if (!sent) {
|
|
1510
|
+
router.unmarkWaiting(sessionKey);
|
|
1511
|
+
return fallback;
|
|
1512
|
+
}
|
|
1513
|
+
return new Promise((resolve) => {
|
|
1514
|
+
router.register(requestId, sessionKey, (action, values) => {
|
|
1515
|
+
resolve(this.parseUserInputAction(action, values, options, fallback));
|
|
1516
|
+
}, {
|
|
1517
|
+
initiatorId: context.userId,
|
|
1518
|
+
fallbackCommand: 'ask',
|
|
1519
|
+
});
|
|
1520
|
+
if (waitMarked) {
|
|
1521
|
+
router.unmarkWaiting(sessionKey);
|
|
1522
|
+
waitMarked = false;
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
parseUserInputAction(action, values, options, fallback) {
|
|
1527
|
+
if (action === '_custom_input') {
|
|
1528
|
+
const customText = typeof values?.custom_text === 'string' ? values.custom_text.trim() : '';
|
|
1529
|
+
return customText ? [customText] : fallback;
|
|
1530
|
+
}
|
|
1531
|
+
if (action.startsWith('opt-')) {
|
|
1532
|
+
const index = Number.parseInt(action.slice(4), 10);
|
|
1533
|
+
const label = options[index]?.label;
|
|
1534
|
+
return label ? [String(label)] : fallback;
|
|
1535
|
+
}
|
|
1536
|
+
const selected = action.split(',').map(part => part.trim()).filter(Boolean);
|
|
1537
|
+
if (selected.length > 0 && selected.every(part => /^\d+$/.test(part))) {
|
|
1538
|
+
const labels = selected
|
|
1539
|
+
.map(part => options[Number.parseInt(part, 10) - 1]?.label)
|
|
1540
|
+
.filter((label) => typeof label === 'string' && label.length > 0);
|
|
1541
|
+
if (labels.length > 0)
|
|
1542
|
+
return labels;
|
|
1543
|
+
}
|
|
1544
|
+
return action.trim() ? [action.trim()] : fallback;
|
|
1545
|
+
}
|
|
1546
|
+
formatUserInputFallback(question, fallback) {
|
|
1547
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
1548
|
+
const lines = [String(question.header || '问题'), String(question.question || '')].filter(Boolean);
|
|
1549
|
+
if (options.length > 0) {
|
|
1550
|
+
lines.push('', ...options.map((option, index) => `${index + 1}. ${String(option.label || `选项 ${index + 1}`)}${option.description ? ` — ${option.description}` : ''}`));
|
|
1551
|
+
}
|
|
1552
|
+
lines.push('', `自动选择:${fallback.join(', ')}`);
|
|
1553
|
+
return lines.join('\n');
|
|
1554
|
+
}
|
|
1555
|
+
findSessionKeyByThread(threadId, conversationId) {
|
|
1556
|
+
const candidates = [threadId, conversationId].filter((value) => typeof value === 'string' && value.length > 0);
|
|
1557
|
+
for (const candidate of candidates) {
|
|
1558
|
+
for (const [sessionKey, activeThreadId] of this.activeSessions.entries()) {
|
|
1559
|
+
if (activeThreadId === candidate)
|
|
1560
|
+
return sessionKey;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return candidates[0] || 'codex-app-server';
|
|
1564
|
+
}
|
|
1565
|
+
buildPermissionInput(method, params) {
|
|
1566
|
+
if (method === 'item/permissions/requestApproval') {
|
|
1567
|
+
return { permissions: params.permissions, cwd: params.cwd, reason: params.reason };
|
|
1568
|
+
}
|
|
1569
|
+
const trackedItem = this.findTrackedApprovalItem(params);
|
|
1570
|
+
if (method.includes('fileChange') || method === 'applyPatchApproval') {
|
|
1571
|
+
return {
|
|
1572
|
+
fileChanges: params.fileChanges ?? trackedItem?.changes,
|
|
1573
|
+
grantRoot: params.grantRoot,
|
|
1574
|
+
reason: params.reason,
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
const rawCommand = params.command ?? trackedItem?.command;
|
|
1578
|
+
const command = Array.isArray(rawCommand) ? rawCommand.join(' ') : (rawCommand || '');
|
|
1579
|
+
return {
|
|
1580
|
+
command,
|
|
1581
|
+
...(Array.isArray(rawCommand) ? { commandArgv: rawCommand } : {}),
|
|
1582
|
+
cwd: params.cwd ?? trackedItem?.cwd,
|
|
1583
|
+
reason: params.reason,
|
|
1584
|
+
commandActions: params.commandActions || params.parsedCmd || trackedItem?.commandActions,
|
|
1585
|
+
additionalPermissions: params.additionalPermissions,
|
|
1586
|
+
networkApprovalContext: params.networkApprovalContext,
|
|
1587
|
+
proposedNetworkPolicyAmendments: params.proposedNetworkPolicyAmendments,
|
|
1588
|
+
proposedExecpolicyAmendment: params.proposedExecpolicyAmendment,
|
|
1589
|
+
environmentId: params.environmentId,
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
summarizeAppServerRequest(method, params) {
|
|
1593
|
+
if (method === 'item/permissions/requestApproval') {
|
|
1594
|
+
return params.reason || '运行时权限升级';
|
|
1595
|
+
}
|
|
1596
|
+
if (method.includes('fileChange') || method === 'applyPatchApproval') {
|
|
1597
|
+
if (params.grantRoot)
|
|
1598
|
+
return '允许写入:' + params.grantRoot;
|
|
1599
|
+
const trackedItem = this.findTrackedApprovalItem(params);
|
|
1600
|
+
const changes = this.normalizeFileChanges(params.fileChanges ?? trackedItem?.changes);
|
|
1601
|
+
const descriptions = changes.map(change => this.describeFileChange(change)).filter(Boolean);
|
|
1602
|
+
return descriptions.length ? descriptions.join(', ') : '文件变更审批';
|
|
1603
|
+
}
|
|
1604
|
+
const networkContext = params.networkApprovalContext;
|
|
1605
|
+
if (networkContext && typeof networkContext === 'object' && !Array.isArray(networkContext)) {
|
|
1606
|
+
const host = typeof networkContext.host === 'string' ? networkContext.host : '';
|
|
1607
|
+
const protocol = typeof networkContext.protocol === 'string' ? networkContext.protocol : '';
|
|
1608
|
+
if (host)
|
|
1609
|
+
return `允许网络访问:${protocol ? `${protocol}://` : ''}${host}`;
|
|
1610
|
+
}
|
|
1611
|
+
const trackedItem = this.findTrackedApprovalItem(params);
|
|
1612
|
+
const rawCommand = params.command ?? trackedItem?.command;
|
|
1613
|
+
const command = Array.isArray(rawCommand) ? rawCommand.join(' ') : rawCommand;
|
|
1614
|
+
return command || '命令执行审批';
|
|
1615
|
+
}
|
|
1616
|
+
resolvePermissionWorkspacePath(params) {
|
|
1617
|
+
const threadId = typeof params.threadId === 'string'
|
|
1618
|
+
? params.threadId
|
|
1619
|
+
: typeof params.conversationId === 'string'
|
|
1620
|
+
? params.conversationId
|
|
1621
|
+
: undefined;
|
|
1622
|
+
if (threadId) {
|
|
1623
|
+
const threadProjectPath = this.threadProjectPaths.get(threadId);
|
|
1624
|
+
if (threadProjectPath)
|
|
1625
|
+
return threadProjectPath;
|
|
1626
|
+
}
|
|
1627
|
+
return undefined;
|
|
1628
|
+
}
|
|
1629
|
+
resolvePermissionOperationCwd(params, workspacePath, toolName) {
|
|
1630
|
+
if (toolName === 'FileChange')
|
|
1631
|
+
return resolveProtectedCandidate(workspacePath);
|
|
1632
|
+
const trackedItem = this.findTrackedApprovalItem(params);
|
|
1633
|
+
const rawCwd = typeof params.cwd === 'string' && params.cwd
|
|
1634
|
+
? params.cwd
|
|
1635
|
+
: typeof trackedItem?.cwd === 'string' && trackedItem.cwd
|
|
1636
|
+
? trackedItem.cwd
|
|
1637
|
+
: undefined;
|
|
1638
|
+
if (!rawCwd)
|
|
1639
|
+
return resolveProtectedCandidate(workspacePath);
|
|
1640
|
+
const absolute = path.isAbsolute(rawCwd) ? rawCwd : path.resolve(workspacePath, rawCwd);
|
|
1641
|
+
return resolveProtectedCandidate(absolute);
|
|
1642
|
+
}
|
|
1643
|
+
checkCodexReadonly(toolName, input, projectPath, sessionKey) {
|
|
1644
|
+
const permCtx = sessionKey ? this.permissionContexts.get(sessionKey) : undefined;
|
|
1645
|
+
const readonlyContext = sessionKey ? {
|
|
1646
|
+
sessionId: sessionKey,
|
|
1647
|
+
channel: permCtx?.channel,
|
|
1648
|
+
peerId: permCtx?.userId,
|
|
1649
|
+
role: permCtx?.role,
|
|
1650
|
+
} : undefined;
|
|
1651
|
+
if (toolName === 'Bash')
|
|
1652
|
+
return checkReadonly(toolName, input, projectPath, readonlyContext);
|
|
1653
|
+
return { behavior: 'deny', message: '🔒 只读模式:权限升级和文件写入请求已拒绝' };
|
|
1654
|
+
}
|
|
1655
|
+
permissionProfiles(input) {
|
|
1656
|
+
return [input.permissions, input.additionalPermissions]
|
|
1657
|
+
.filter((value) => !!value && typeof value === 'object' && !Array.isArray(value));
|
|
1658
|
+
}
|
|
1659
|
+
filesystemProfileRequestsAccess(fileSystem) {
|
|
1660
|
+
if (!fileSystem || typeof fileSystem !== 'object' || Array.isArray(fileSystem))
|
|
1661
|
+
return false;
|
|
1662
|
+
const fsProfile = fileSystem;
|
|
1663
|
+
if (Array.isArray(fsProfile.read) && fsProfile.read.length > 0)
|
|
1664
|
+
return true;
|
|
1665
|
+
if (Array.isArray(fsProfile.write) && fsProfile.write.length > 0)
|
|
1666
|
+
return true;
|
|
1667
|
+
return Array.isArray(fsProfile.entries) && fsProfile.entries.some(entry => {
|
|
1668
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
1669
|
+
return false;
|
|
1670
|
+
return entry.access !== 'deny';
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
hasFilesystemPermissionRequest(input) {
|
|
1674
|
+
return this.permissionProfiles(input)
|
|
1675
|
+
.some(profile => this.filesystemProfileRequestsAccess(profile.fileSystem));
|
|
1676
|
+
}
|
|
1677
|
+
hasNetworkPermissionRequest(input) {
|
|
1678
|
+
if (this.permissionProfiles(input).some(profile => {
|
|
1679
|
+
const network = profile.network;
|
|
1680
|
+
return !!network
|
|
1681
|
+
&& typeof network === 'object'
|
|
1682
|
+
&& !Array.isArray(network)
|
|
1683
|
+
&& network.enabled === true;
|
|
1684
|
+
}))
|
|
1685
|
+
return true;
|
|
1686
|
+
const context = input.networkApprovalContext;
|
|
1687
|
+
if (context && typeof context === 'object' && !Array.isArray(context)) {
|
|
1688
|
+
const record = context;
|
|
1689
|
+
if (typeof record.host === 'string' && record.host.length > 0)
|
|
1690
|
+
return true;
|
|
1691
|
+
}
|
|
1692
|
+
return Array.isArray(input.proposedNetworkPolicyAmendments)
|
|
1693
|
+
&& input.proposedNetworkPolicyAmendments.length > 0;
|
|
1694
|
+
}
|
|
1695
|
+
hasUnknownPermissionRequest(input) {
|
|
1696
|
+
return this.permissionProfiles(input).some(profile => Object.keys(profile).some(key => key !== 'network' && key !== 'fileSystem'));
|
|
1697
|
+
}
|
|
1698
|
+
fileChangeRequiresExpansion(input, projectPath, sessionKey) {
|
|
1699
|
+
if (typeof input.grantRoot === 'string' && input.grantRoot.length > 0)
|
|
1700
|
+
return true;
|
|
1701
|
+
const paths = [];
|
|
1702
|
+
for (const change of this.normalizeFileChanges(input.fileChanges)) {
|
|
1703
|
+
for (const candidate of [
|
|
1704
|
+
change?.path,
|
|
1705
|
+
change?.move_path,
|
|
1706
|
+
change?.movePath,
|
|
1707
|
+
change?.kind?.move_path,
|
|
1708
|
+
change?.kind?.movePath,
|
|
1709
|
+
]) {
|
|
1710
|
+
if (typeof candidate === 'string' && candidate.length > 0)
|
|
1711
|
+
paths.push(candidate);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (paths.length === 0)
|
|
1715
|
+
return true;
|
|
1716
|
+
const workspace = resolveProtectedCandidate(projectPath);
|
|
1717
|
+
const managedTempDir = this.sessionTempDirs.get(sessionKey);
|
|
1718
|
+
const trustedTempDir = managedTempDir ? this.isTrustedSessionTempDir(managedTempDir) : false;
|
|
1719
|
+
if (managedTempDir && !trustedTempDir) {
|
|
1720
|
+
logger.warn(`[CodexRunner] managed TMPDIR lost its trusted ownership or mode: session=${sessionKey} path=${managedTempDir}`);
|
|
1721
|
+
}
|
|
1722
|
+
const canonicalTempDir = managedTempDir && trustedTempDir
|
|
1723
|
+
? resolveProtectedCandidate(managedTempDir)
|
|
1724
|
+
: undefined;
|
|
1725
|
+
return paths.some(candidate => {
|
|
1726
|
+
const absolute = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(workspace, candidate);
|
|
1727
|
+
const canonical = resolveProtectedCandidate(absolute);
|
|
1728
|
+
if (isSameOrDescendant(canonical, workspace))
|
|
1729
|
+
return false;
|
|
1730
|
+
if (canonicalTempDir
|
|
1731
|
+
&& canonical !== canonicalTempDir
|
|
1732
|
+
&& isSameOrDescendant(canonical, canonicalTempDir)) {
|
|
1733
|
+
try {
|
|
1734
|
+
const stat = fs.statSync(canonical);
|
|
1735
|
+
if (stat.isFile() && stat.nlink > 1) {
|
|
1736
|
+
logger.warn(`[CodexRunner] managed TMPDIR FileChange rejected hard link: session=${sessionKey} path=${canonical}`);
|
|
1737
|
+
return true;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
// A new file is safe as long as its canonical existing ancestor stays
|
|
1742
|
+
// inside the managed session temp directory.
|
|
1743
|
+
}
|
|
1744
|
+
logger.info(`[CodexRunner] managed TMPDIR FileChange accepted: session=${sessionKey} path=${canonical}`);
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
return true;
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath) {
|
|
1751
|
+
const permissionContext = this.permissionContexts.get(sessionKey);
|
|
1752
|
+
const permissionPrompt = permissionContext?.sendPrompt ?? this.sendPromptFn;
|
|
1753
|
+
const hasFilesystemPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
|
|
1754
|
+
&& this.hasFilesystemPermissionRequest(toolInput);
|
|
1755
|
+
const hasNetworkPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
|
|
1756
|
+
&& this.hasNetworkPermissionRequest(toolInput);
|
|
1757
|
+
const hasUnknownPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
|
|
1758
|
+
&& this.hasUnknownPermissionRequest(toolInput);
|
|
1759
|
+
const hasPermissionExpansion = hasFilesystemPermissions || hasNetworkPermissions || hasUnknownPermissions;
|
|
1760
|
+
const hasFileChangeExpansion = toolName === 'FileChange'
|
|
1761
|
+
&& this.fileChangeRequiresExpansion(toolInput, workspacePath, sessionKey);
|
|
1762
|
+
// per-session 权限模式(runQuery 写入);缺省回落实例级 currentMode(兼容无 runQuery 上下文的调用)
|
|
1763
|
+
const rawMode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
1764
|
+
const mode = normalizePermissionMode(rawMode).mode;
|
|
1765
|
+
const preflight = evaluateToolPreflight(toolName, toolInput, {
|
|
1766
|
+
sessionId: sessionKey,
|
|
1767
|
+
channel: permissionContext?.channel,
|
|
1768
|
+
userId: permissionContext?.userId,
|
|
1769
|
+
role: permissionContext?.role,
|
|
1770
|
+
permissionMode: mode,
|
|
1771
|
+
projectPath: operationCwd,
|
|
1772
|
+
workspacePath,
|
|
1773
|
+
});
|
|
1774
|
+
if (preflight.behavior === 'deny')
|
|
1775
|
+
return 'deny';
|
|
1776
|
+
if (preflight.behavior === 'allow')
|
|
1777
|
+
return 'allow';
|
|
1778
|
+
const checkedInput = preflight.input;
|
|
1779
|
+
if (toolName === 'FileChange') {
|
|
1780
|
+
const hasGrantRoot = typeof checkedInput.grantRoot === 'string'
|
|
1781
|
+
&& checkedInput.grantRoot.length > 0;
|
|
1782
|
+
const hasConcreteChanges = this.normalizeFileChanges(checkedInput.fileChanges)
|
|
1783
|
+
.some(change => typeof change?.path === 'string' && change.path.length > 0);
|
|
1784
|
+
if (!hasGrantRoot && !hasConcreteChanges)
|
|
1785
|
+
return 'deny';
|
|
1786
|
+
}
|
|
1787
|
+
if (toolName === 'Bash' && !hasPermissionExpansion) {
|
|
1788
|
+
const command = typeof checkedInput.command === 'string'
|
|
1789
|
+
? checkedInput.command.trim()
|
|
1790
|
+
: '';
|
|
1791
|
+
if (!command)
|
|
1792
|
+
return 'deny';
|
|
1793
|
+
}
|
|
1794
|
+
if (toolName === 'PermissionGrant' && !hasPermissionExpansion)
|
|
1795
|
+
return 'deny';
|
|
1796
|
+
const grantScope = `codex:${this.permissionProfileName(sessionKey)}:${mode}`;
|
|
1797
|
+
if (mode === 'readonly') {
|
|
1798
|
+
const readonly = this.checkCodexReadonly(toolName, checkedInput, operationCwd, sessionKey);
|
|
1799
|
+
if (readonly.behavior === 'deny')
|
|
1800
|
+
return 'deny';
|
|
1801
|
+
return 'allow';
|
|
1802
|
+
}
|
|
1803
|
+
if (toolName.startsWith('MCP:')) {
|
|
1804
|
+
return 'allow';
|
|
1805
|
+
}
|
|
1806
|
+
// auto 模式下检查危险命令,危险命令自动拒绝
|
|
1807
|
+
if (mode === 'auto') {
|
|
1808
|
+
if (hasFileChangeExpansion || hasFilesystemPermissions || hasUnknownPermissions)
|
|
1809
|
+
return 'deny';
|
|
1810
|
+
if (toolName === 'PermissionGrant' && !hasNetworkPermissions)
|
|
1811
|
+
return 'deny';
|
|
1812
|
+
const dangerous = checkDangerousCommand(toolName, checkedInput);
|
|
1813
|
+
if (dangerous.isDangerous)
|
|
1814
|
+
return 'deny';
|
|
1815
|
+
return 'allow';
|
|
1816
|
+
}
|
|
1817
|
+
const requiresSandboxExpansionApproval = hasFileChangeExpansion
|
|
1818
|
+
|| hasFilesystemPermissions
|
|
1819
|
+
|| hasUnknownPermissions;
|
|
1820
|
+
if (requiresSandboxExpansionApproval) {
|
|
1821
|
+
if (!this.permissionGateway || !permissionPrompt)
|
|
1822
|
+
return 'deny';
|
|
1823
|
+
return this.permissionGateway.requestPermission(sessionKey, toolName, checkedInput, permissionPrompt, this.permissionContexts.get(sessionKey), summary, reason || 'Codex 请求扩大当前命令或 turn 的文件系统权限', grantScope);
|
|
1824
|
+
}
|
|
1825
|
+
if (mode === 'request' || mode === 'bypass') {
|
|
1826
|
+
const dangerous = await requestDangerousCommandPermission(this.permissionGateway, sessionKey, toolName, checkedInput, permissionPrompt, this.permissionContexts.get(sessionKey), grantScope, mode);
|
|
1827
|
+
if (dangerous.matched)
|
|
1828
|
+
return dangerous.decision;
|
|
1829
|
+
return 'allow';
|
|
1830
|
+
}
|
|
1831
|
+
// Network is already part of every profile, so a defensive
|
|
1832
|
+
// network-only permission callback is accepted without a second approval.
|
|
1833
|
+
return 'allow';
|
|
1834
|
+
}
|
|
1835
|
+
toAppServerApprovalResponse(method, decision, toolInput, unattended = false) {
|
|
1836
|
+
if (method === 'execCommandApproval' || method === 'applyPatchApproval') {
|
|
1837
|
+
return { decision: decision === 'deny' ? 'denied' : 'approved' };
|
|
1838
|
+
}
|
|
1839
|
+
if (method === 'item/commandExecution/requestApproval') {
|
|
1840
|
+
return { decision: decision === 'deny' ? 'decline' : 'accept' };
|
|
1841
|
+
}
|
|
1842
|
+
if (method === 'item/fileChange/requestApproval') {
|
|
1843
|
+
return { decision: decision === 'deny' ? 'decline' : 'accept' };
|
|
1844
|
+
}
|
|
1845
|
+
if (method === 'item/permissions/requestApproval') {
|
|
1846
|
+
// Codex expects a permissions object for this RPC. Throwing here turns a
|
|
1847
|
+
// single denied capability into a terminal runner error, which is
|
|
1848
|
+
// especially harmful for unattended Trigger runs: the operation should
|
|
1849
|
+
// be skipped while the model remains free to continue with a fallback.
|
|
1850
|
+
// Keep the historical RPC-error behavior for interactive callers, but
|
|
1851
|
+
// return an empty turn-scoped grant in unattended mode.
|
|
1852
|
+
if (decision === 'deny') {
|
|
1853
|
+
if (unattended)
|
|
1854
|
+
return { permissions: {}, scope: 'turn', strictAutoReview: true };
|
|
1855
|
+
throw new Error('Permission request denied');
|
|
1856
|
+
}
|
|
1857
|
+
const permissions = toolInput?.permissions;
|
|
1858
|
+
return {
|
|
1859
|
+
permissions: permissions && typeof permissions === 'object' ? permissions : {},
|
|
1860
|
+
scope: 'turn',
|
|
1861
|
+
strictAutoReview: true,
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
throw new Error('Unsupported Codex app-server request: ' + method);
|
|
1865
|
+
}
|
|
1866
|
+
async rollbackSessionTurns(agentSessionId, _projectPath, numTurns) {
|
|
1867
|
+
if (numTurns < 1)
|
|
1868
|
+
return true;
|
|
1869
|
+
const response = await this.getAppServerClient().threadRollback(agentSessionId, numTurns);
|
|
1870
|
+
return !!response.thread;
|
|
1871
|
+
}
|
|
1872
|
+
async rewindFiles(agentSessionId, projectPath, userMessageId) {
|
|
1873
|
+
const messages = await this.getSessionMessages(agentSessionId, projectPath);
|
|
1874
|
+
const targetIndex = messages.findIndex(message => message.uuid === userMessageId);
|
|
1875
|
+
if (targetIndex < 0)
|
|
1876
|
+
return { canRewind: false, error: 'target turn not found' };
|
|
1877
|
+
const changedFiles = new Set();
|
|
1878
|
+
for (let i = targetIndex; i < messages.length; i++) {
|
|
1879
|
+
const message = messages[i];
|
|
1880
|
+
const content = Array.isArray(message.message?.content) ? message.message.content : [];
|
|
1881
|
+
for (const part of content) {
|
|
1882
|
+
if (part?.type === 'file_change' && typeof part.path === 'string')
|
|
1883
|
+
changedFiles.add(part.path);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if (changedFiles.size === 0) {
|
|
1887
|
+
return { canRewind: false, error: 'no file changes recorded for target turn' };
|
|
1888
|
+
}
|
|
1889
|
+
const snapshotFiles = [...changedFiles];
|
|
1890
|
+
for (const filePath of snapshotFiles) {
|
|
1891
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath);
|
|
1892
|
+
const content = this.readGitHeadFile(projectPath, filePath);
|
|
1893
|
+
if (content === null) {
|
|
1894
|
+
fs.rmSync(absolutePath, { force: true });
|
|
1895
|
+
}
|
|
1896
|
+
else {
|
|
1897
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
1898
|
+
fs.writeFileSync(absolutePath, content);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
return { canRewind: true, filesChanged: snapshotFiles };
|
|
1902
|
+
}
|
|
1903
|
+
readGitHeadFile(projectPath, filePath) {
|
|
1904
|
+
try {
|
|
1905
|
+
return execFileSync('git', ['show', `HEAD:${filePath.replace(/\\/g, '/')}`], { cwd: projectPath, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
mapThreadToSessionMessages(response, fallbackThreadId) {
|
|
1912
|
+
const thread = response.thread;
|
|
1913
|
+
const threadId = thread?.id || fallbackThreadId;
|
|
1914
|
+
const messages = [];
|
|
1915
|
+
for (const turn of thread?.turns ?? []) {
|
|
1916
|
+
for (const item of this.getTurnItems(turn)) {
|
|
1917
|
+
if (item.type === 'userMessage') {
|
|
1918
|
+
messages.push({
|
|
1919
|
+
type: 'user',
|
|
1920
|
+
uuid: item.id || turn.id || (threadId + '-user-' + messages.length),
|
|
1921
|
+
session_id: threadId,
|
|
1922
|
+
message: { role: 'user', content: this.mapUserInputToContent(item.content) },
|
|
1923
|
+
parent_tool_use_id: null,
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
else if (item.type === 'agentMessage') {
|
|
1927
|
+
messages.push({
|
|
1928
|
+
type: 'assistant',
|
|
1929
|
+
uuid: item.id || turn.id || (threadId + '-assistant-' + messages.length),
|
|
1930
|
+
session_id: threadId,
|
|
1931
|
+
message: { role: 'assistant', content: item.text || '' },
|
|
1932
|
+
parent_tool_use_id: null,
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
else if (item.type === 'file_change') {
|
|
1936
|
+
messages.push({
|
|
1937
|
+
type: 'system',
|
|
1938
|
+
uuid: item.id || turn.id || (threadId + '-file-' + messages.length),
|
|
1939
|
+
session_id: threadId,
|
|
1940
|
+
message: { role: 'system', content: this.mapFileChangeToContent(item) },
|
|
1941
|
+
parent_tool_use_id: null,
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return messages;
|
|
1947
|
+
}
|
|
1948
|
+
getTurnItems(turn) {
|
|
1949
|
+
const items = Array.isArray(turn?.items) ? turn.items : [];
|
|
1950
|
+
const input = Array.isArray(turn?.input) ? turn.input : [];
|
|
1951
|
+
const output = Array.isArray(turn?.output) ? turn.output : [];
|
|
1952
|
+
return [...items, ...input, ...output];
|
|
1953
|
+
}
|
|
1954
|
+
mapUserInputToContent(content) {
|
|
1955
|
+
if (!Array.isArray(content))
|
|
1956
|
+
return [];
|
|
1957
|
+
return content.map((part) => {
|
|
1958
|
+
if (part?.type === 'text')
|
|
1959
|
+
return { type: 'text', text: part.text || '' };
|
|
1960
|
+
if (part?.type === 'localImage')
|
|
1961
|
+
return { type: 'image', path: part.path };
|
|
1962
|
+
if (part?.type === 'image')
|
|
1963
|
+
return { type: 'image', url: part.url };
|
|
1964
|
+
return { type: 'text', text: part?.text || part?.name || '' };
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
mapFileChangeToContent(item) {
|
|
1968
|
+
const changes = this.normalizeFileChanges(item.changes);
|
|
1969
|
+
return changes
|
|
1970
|
+
.filter((change) => typeof change?.path === 'string')
|
|
1971
|
+
.map((change) => {
|
|
1972
|
+
const kind = this.normalizeFileChangeKind(change.kind ?? change.type);
|
|
1973
|
+
return {
|
|
1974
|
+
type: 'file_change',
|
|
1975
|
+
path: change.path,
|
|
1976
|
+
...(kind ? { kind } : {}),
|
|
1977
|
+
};
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
normalizeFileChanges(changes) {
|
|
1981
|
+
if (Array.isArray(changes))
|
|
1982
|
+
return changes;
|
|
1983
|
+
if (!changes || typeof changes !== 'object')
|
|
1984
|
+
return [];
|
|
1985
|
+
return Object.entries(changes).map(([filePath, change]) => ({
|
|
1986
|
+
...(change && typeof change === 'object' ? change : {}),
|
|
1987
|
+
path: filePath,
|
|
1988
|
+
}));
|
|
1989
|
+
}
|
|
1990
|
+
describeFileChange(change) {
|
|
1991
|
+
const kind = this.normalizeFileChangeKind(change?.kind ?? change?.type);
|
|
1992
|
+
const filePath = typeof change?.path === 'string' ? change.path : '';
|
|
1993
|
+
return [kind || 'change', filePath].filter(Boolean).join(' ');
|
|
1994
|
+
}
|
|
1995
|
+
normalizeFileChangeKind(kind) {
|
|
1996
|
+
if (typeof kind === 'string')
|
|
1997
|
+
return kind;
|
|
1998
|
+
if (!kind || typeof kind !== 'object')
|
|
1999
|
+
return undefined;
|
|
2000
|
+
const data = kind;
|
|
2001
|
+
for (const key of ['type', 'kind', 'action', 'operation', 'op']) {
|
|
2002
|
+
if (typeof data[key] === 'string')
|
|
2003
|
+
return data[key];
|
|
2004
|
+
}
|
|
2005
|
+
return undefined;
|
|
2006
|
+
}
|
|
2007
|
+
isThreadNotFoundError(error) {
|
|
2008
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2009
|
+
return /thread\/compact\/start failed: thread not found:/i.test(message)
|
|
2010
|
+
|| /thread not found:/i.test(message);
|
|
2011
|
+
}
|
|
2012
|
+
setCompactStartCallback(callback) {
|
|
2013
|
+
this.onCompactStart = callback;
|
|
2014
|
+
}
|
|
2015
|
+
// ── Event stream transformation ──
|
|
2016
|
+
buildAppServerInput(prompt, images, tempFiles) {
|
|
2017
|
+
const input = [{ type: 'text', text: prompt, text_elements: [] }];
|
|
2018
|
+
if (!images?.length)
|
|
2019
|
+
return input;
|
|
2020
|
+
const tmpDir = os.tmpdir();
|
|
2021
|
+
for (let i = 0; i < images.length; i++) {
|
|
2022
|
+
const img = images[i];
|
|
2023
|
+
const ext = MIME_EXT[img.mimeType || ''] || '.jpg';
|
|
2024
|
+
const tmpPath = path.join(tmpDir, `evolcore-img-${Date.now()}-${i}${ext}`);
|
|
2025
|
+
fs.writeFileSync(tmpPath, Buffer.from(img.data, 'base64'));
|
|
2026
|
+
tempFiles.push(tmpPath);
|
|
2027
|
+
input.push({ type: 'localImage', path: tmpPath });
|
|
2028
|
+
}
|
|
2029
|
+
logger.info(`[CodexRunner] Attached ${images.length} image(s) as localImage`);
|
|
2030
|
+
return input;
|
|
2031
|
+
}
|
|
2032
|
+
cleanupTempFiles(tempFiles) {
|
|
2033
|
+
if (!tempFiles?.length)
|
|
2034
|
+
return;
|
|
2035
|
+
for (const tempFile of tempFiles) {
|
|
2036
|
+
try {
|
|
2037
|
+
fs.unlinkSync(tempFile);
|
|
2038
|
+
}
|
|
2039
|
+
catch { /* ignore */ }
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
extractTurnId(notification) {
|
|
2043
|
+
const params = notification.params || {};
|
|
2044
|
+
return typeof params.turnId === 'string' ? params.turnId :
|
|
2045
|
+
typeof params.turn_id === 'string' ? params.turn_id :
|
|
2046
|
+
typeof params.turn?.id === 'string' ? params.turn.id : undefined;
|
|
2047
|
+
}
|
|
2048
|
+
isAppServerTurnNotification(notification, state) {
|
|
2049
|
+
const params = notification.params || {};
|
|
2050
|
+
const notifThreadId = params.threadId ?? params.thread_id;
|
|
2051
|
+
if (notifThreadId !== undefined && notifThreadId !== state.threadId)
|
|
2052
|
+
return false;
|
|
2053
|
+
const turnId = this.extractTurnId(notification);
|
|
2054
|
+
if (!state.turnId) {
|
|
2055
|
+
return notification.method === 'turn/started' || !turnId;
|
|
2056
|
+
}
|
|
2057
|
+
return !state.turnId || !turnId || turnId === state.turnId;
|
|
2058
|
+
}
|
|
2059
|
+
async *transformAppServerStream(notifications, sessionId, state, unsubscribe, tempFiles) {
|
|
2060
|
+
try {
|
|
2061
|
+
yield { type: 'session_id', sessionId: state.threadId };
|
|
2062
|
+
for await (const notification of notifications) {
|
|
2063
|
+
if (!this.activeAbortControllers.has(sessionId))
|
|
2064
|
+
break;
|
|
2065
|
+
yield* this.mapAppServerNotification(notification, sessionId, state);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
finally {
|
|
2069
|
+
unsubscribe();
|
|
2070
|
+
this.activeAbortControllers.delete(sessionId);
|
|
2071
|
+
this.activeTurns.delete(sessionId);
|
|
2072
|
+
this.cleanupTempFiles(tempFiles);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
*mapAppServerNotification(notification, sessionId, state) {
|
|
2076
|
+
const params = notification.params || {};
|
|
2077
|
+
switch (notification.method) {
|
|
2078
|
+
case 'turn/started': {
|
|
2079
|
+
const turnId = this.extractTurnId(notification);
|
|
2080
|
+
if (turnId) {
|
|
2081
|
+
state.turnId = turnId;
|
|
2082
|
+
this.activeTurns.set(sessionId, { threadId: state.threadId, turnId });
|
|
2083
|
+
}
|
|
2084
|
+
yield { type: 'state_changed', state: 'running' };
|
|
2085
|
+
break;
|
|
2086
|
+
}
|
|
2087
|
+
case 'item/started': {
|
|
2088
|
+
for (const event of this.mapAppServerItemStarted(params.item, state)) {
|
|
2089
|
+
this.trackAppServerToolLifecycle(event, state);
|
|
2090
|
+
yield event;
|
|
2091
|
+
}
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
2094
|
+
case 'item/agentMessage/delta': {
|
|
2095
|
+
const itemId = typeof params.itemId === 'string' ? params.itemId : undefined;
|
|
2096
|
+
if (itemId)
|
|
2097
|
+
state.streamedAgentMessageIds.add(itemId);
|
|
2098
|
+
if (itemId && typeof params.delta === 'string' && params.delta) {
|
|
2099
|
+
state.agentMessageDeltaText.set(itemId, (state.agentMessageDeltaText.get(itemId) || '') + params.delta);
|
|
2100
|
+
}
|
|
2101
|
+
break;
|
|
2102
|
+
}
|
|
2103
|
+
case 'item/completed': {
|
|
2104
|
+
const item = params.item;
|
|
2105
|
+
if (item?.id)
|
|
2106
|
+
state.completedItemIds.add(item.id);
|
|
2107
|
+
for (const event of this.mapAppServerItemCompleted(item, state)) {
|
|
2108
|
+
this.trackAppServerToolLifecycle(event, state);
|
|
2109
|
+
yield event;
|
|
2110
|
+
}
|
|
2111
|
+
break;
|
|
2112
|
+
}
|
|
2113
|
+
case 'item/fileChange/patchUpdated': {
|
|
2114
|
+
for (const event of this.mapAppServerFileChangePatchUpdated(params, state)) {
|
|
2115
|
+
this.trackAppServerToolLifecycle(event, state);
|
|
2116
|
+
yield event;
|
|
2117
|
+
}
|
|
2118
|
+
break;
|
|
2119
|
+
}
|
|
2120
|
+
case 'turn/plan/updated': {
|
|
2121
|
+
const plan = Array.isArray(params.plan) ? params.plan : [];
|
|
2122
|
+
const completed = plan.filter((step) => step?.status === 'completed').length;
|
|
2123
|
+
const summary = plan.length ? `计划进度:${completed}/${plan.length}` : (params.explanation || '计划已更新');
|
|
2124
|
+
yield { type: 'task_progress', summary };
|
|
2125
|
+
break;
|
|
2126
|
+
}
|
|
2127
|
+
case 'thread/tokenUsage/updated': {
|
|
2128
|
+
state.tokenUsage = params.tokenUsage;
|
|
2129
|
+
break;
|
|
2130
|
+
}
|
|
2131
|
+
case 'thread/compacted': {
|
|
2132
|
+
logger.info(`[CodexRunner] Compact completed for thread: ${params.threadId || state.threadId}`);
|
|
2133
|
+
const preTokens = this.pickNumber(state.tokenUsage?.last?.inputTokens, state.tokenUsage?.last?.input_tokens, state.tokenUsage?.last?.totalTokens, state.tokenUsage?.last?.total_tokens) ?? 0;
|
|
2134
|
+
yield { type: 'compact', preTokens };
|
|
2135
|
+
break;
|
|
2136
|
+
}
|
|
2137
|
+
case 'turn/completed': {
|
|
2138
|
+
const turn = params.turn || {};
|
|
2139
|
+
const turnId = turn.id || params.turnId;
|
|
2140
|
+
if (turnId && state.completedTurnIds.has(turnId))
|
|
2141
|
+
break;
|
|
2142
|
+
if (turnId)
|
|
2143
|
+
state.completedTurnIds.add(turnId);
|
|
2144
|
+
this.activeTurns.delete(sessionId);
|
|
2145
|
+
yield* this.reconcileOpenAppServerToolCalls(state, turnId);
|
|
2146
|
+
if (turn.status === 'failed' && turn.error?.message) {
|
|
2147
|
+
if (isRetryableError(new Error(turn.error.message))) {
|
|
2148
|
+
throw new Error(turn.error.message);
|
|
2149
|
+
}
|
|
2150
|
+
yield { type: 'error', error: turn.error.message, errorType: 'unknown' };
|
|
2151
|
+
}
|
|
2152
|
+
yield this.mapAppServerTurnComplete(turn, state);
|
|
2153
|
+
break;
|
|
2154
|
+
}
|
|
2155
|
+
case 'error': {
|
|
2156
|
+
if (!params.message) {
|
|
2157
|
+
// SSE idle timeout reconnect — not a real task error, suppress
|
|
2158
|
+
logger.debug(`[CodexRunner] app-server SSE reconnect (no message)`);
|
|
2159
|
+
break;
|
|
2160
|
+
}
|
|
2161
|
+
if (isRetryableError(new Error(params.message))) {
|
|
2162
|
+
throw new Error(params.message);
|
|
2163
|
+
}
|
|
2164
|
+
yield { type: 'error', error: params.message, errorType: 'unknown' };
|
|
2165
|
+
break;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
trackAppServerToolLifecycle(event, state) {
|
|
2170
|
+
if (event.type !== 'tool_use' && event.type !== 'tool_result')
|
|
2171
|
+
return;
|
|
2172
|
+
if (!event.callId)
|
|
2173
|
+
return;
|
|
2174
|
+
if (event.type === 'tool_use') {
|
|
2175
|
+
state.openToolCalls.set(event.callId, event.name);
|
|
2176
|
+
}
|
|
2177
|
+
else if (event.type === 'tool_result') {
|
|
2178
|
+
state.openToolCalls.delete(event.callId);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
*reconcileOpenAppServerToolCalls(state, turnId) {
|
|
2182
|
+
if (state.openToolCalls.size === 0)
|
|
2183
|
+
return;
|
|
2184
|
+
const callIds = [...state.openToolCalls.keys()];
|
|
2185
|
+
logger.warn(`[CodexRunner] Turn completed with ${callIds.length} open tool call(s): ` +
|
|
2186
|
+
`thread=${state.threadId} turn=${turnId || state.turnId || 'unknown'} calls=${callIds.join(',')}`);
|
|
2187
|
+
for (const [callId, name] of state.openToolCalls) {
|
|
2188
|
+
yield {
|
|
2189
|
+
type: 'tool_result',
|
|
2190
|
+
name,
|
|
2191
|
+
result: '',
|
|
2192
|
+
isError: true,
|
|
2193
|
+
error: 'Codex app-server ended the turn without an item/completed notification',
|
|
2194
|
+
callId,
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
state.openToolCalls.clear();
|
|
2198
|
+
}
|
|
2199
|
+
*mapAppServerItemStarted(item, state) {
|
|
2200
|
+
if (!item)
|
|
2201
|
+
return;
|
|
2202
|
+
switch (item.type) {
|
|
2203
|
+
case 'commandExecution':
|
|
2204
|
+
yield { type: 'tool_use', name: 'Shell', input: { command: item.command, cwd: item.cwd }, callId: item.id };
|
|
2205
|
+
break;
|
|
2206
|
+
case 'mcpToolCall':
|
|
2207
|
+
yield { type: 'tool_use', name: `MCP:${item.server}/${item.tool}`, input: item.arguments, callId: item.id };
|
|
2208
|
+
break;
|
|
2209
|
+
case 'dynamicToolCall':
|
|
2210
|
+
yield { type: 'tool_use', name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool, input: item.arguments, callId: item.id };
|
|
2211
|
+
break;
|
|
2212
|
+
case 'fileChange': {
|
|
2213
|
+
const editEvent = this.buildCodexEditEvent(item.id, item.changes);
|
|
2214
|
+
if (editEvent) {
|
|
2215
|
+
if (item.id)
|
|
2216
|
+
state?.emittedEditCallIds.add(item.id);
|
|
2217
|
+
yield editEvent;
|
|
2218
|
+
}
|
|
2219
|
+
else {
|
|
2220
|
+
const desc = this.normalizeFileChanges(item.changes).map((change) => this.describeFileChange(change)).join(', ');
|
|
2221
|
+
if (desc) {
|
|
2222
|
+
yield { type: 'tool_use', name: 'FileChange', input: { description: desc }, callId: item.id };
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
break;
|
|
2226
|
+
}
|
|
2227
|
+
case 'webSearch':
|
|
2228
|
+
yield {
|
|
2229
|
+
type: 'tool_use',
|
|
2230
|
+
name: 'WebSearch',
|
|
2231
|
+
input: {
|
|
2232
|
+
query: item.query,
|
|
2233
|
+
...(item.action != null ? { action: item.action } : {}),
|
|
2234
|
+
},
|
|
2235
|
+
callId: item.id,
|
|
2236
|
+
};
|
|
2237
|
+
break;
|
|
2238
|
+
case 'collabAgentToolCall':
|
|
2239
|
+
yield {
|
|
2240
|
+
type: 'tool_use',
|
|
2241
|
+
name: `Collab:${item.tool}`,
|
|
2242
|
+
input: {
|
|
2243
|
+
receiverThreadIds: item.receiverThreadIds,
|
|
2244
|
+
...(item.prompt != null ? { prompt: item.prompt } : {}),
|
|
2245
|
+
...(item.model != null ? { model: item.model } : {}),
|
|
2246
|
+
...(item.reasoningEffort != null ? { reasoningEffort: item.reasoningEffort } : {}),
|
|
2247
|
+
},
|
|
2248
|
+
callId: item.id,
|
|
2249
|
+
};
|
|
2250
|
+
break;
|
|
2251
|
+
case 'imageView':
|
|
2252
|
+
yield { type: 'tool_use', name: 'ViewImage', input: { path: item.path }, callId: item.id };
|
|
2253
|
+
break;
|
|
2254
|
+
case 'sleep':
|
|
2255
|
+
yield { type: 'tool_use', name: 'Sleep', input: { durationMs: item.durationMs }, callId: item.id };
|
|
2256
|
+
break;
|
|
2257
|
+
case 'imageGeneration':
|
|
2258
|
+
yield {
|
|
2259
|
+
type: 'tool_use',
|
|
2260
|
+
name: 'ImageGeneration',
|
|
2261
|
+
input: item.revisedPrompt != null ? { prompt: item.revisedPrompt } : {},
|
|
2262
|
+
callId: item.id,
|
|
2263
|
+
};
|
|
2264
|
+
break;
|
|
2265
|
+
case 'subAgentActivity':
|
|
2266
|
+
yield {
|
|
2267
|
+
type: 'task_progress',
|
|
2268
|
+
summary: `Subagent ${item.kind}: ${item.agentPath || item.agentThreadId}`,
|
|
2269
|
+
};
|
|
2270
|
+
break;
|
|
2271
|
+
case 'enteredReviewMode':
|
|
2272
|
+
yield { type: 'task_progress', summary: item.review || 'Review mode entered' };
|
|
2273
|
+
break;
|
|
2274
|
+
case 'exitedReviewMode':
|
|
2275
|
+
yield { type: 'task_progress', summary: item.review || 'Review mode exited' };
|
|
2276
|
+
break;
|
|
2277
|
+
case 'plan':
|
|
2278
|
+
yield { type: 'task_progress', summary: item.text || '计划已更新' };
|
|
2279
|
+
break;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
*mapAppServerItemCompleted(item, state) {
|
|
2283
|
+
if (!item)
|
|
2284
|
+
return;
|
|
2285
|
+
switch (item.type) {
|
|
2286
|
+
case 'agentMessage':
|
|
2287
|
+
{
|
|
2288
|
+
const buffered = item.id ? state.agentMessageDeltaText.get(item.id) : undefined;
|
|
2289
|
+
const text = typeof item.text === 'string' && item.text ? item.text : buffered;
|
|
2290
|
+
if (text)
|
|
2291
|
+
yield { type: 'text', text };
|
|
2292
|
+
if (item.id)
|
|
2293
|
+
state.agentMessageDeltaText.delete(item.id);
|
|
2294
|
+
}
|
|
2295
|
+
break;
|
|
2296
|
+
case 'commandExecution':
|
|
2297
|
+
yield {
|
|
2298
|
+
type: 'tool_result',
|
|
2299
|
+
name: 'Shell',
|
|
2300
|
+
result: item.aggregatedOutput ?? '',
|
|
2301
|
+
isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed',
|
|
2302
|
+
callId: item.id,
|
|
2303
|
+
durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
|
|
2304
|
+
};
|
|
2305
|
+
break;
|
|
2306
|
+
case 'mcpToolCall':
|
|
2307
|
+
yield {
|
|
2308
|
+
type: 'tool_result',
|
|
2309
|
+
name: `MCP:${item.server}/${item.tool}`,
|
|
2310
|
+
result: item.result,
|
|
2311
|
+
isError: item.status === 'failed',
|
|
2312
|
+
error: item.error?.message,
|
|
2313
|
+
callId: item.id,
|
|
2314
|
+
durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
|
|
2315
|
+
};
|
|
2316
|
+
break;
|
|
2317
|
+
case 'dynamicToolCall':
|
|
2318
|
+
yield {
|
|
2319
|
+
type: 'tool_result',
|
|
2320
|
+
name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool,
|
|
2321
|
+
result: item.contentItems,
|
|
2322
|
+
isError: item.success === false || item.status === 'failed',
|
|
2323
|
+
callId: item.id,
|
|
2324
|
+
durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
|
|
2325
|
+
};
|
|
2326
|
+
break;
|
|
2327
|
+
case 'fileChange':
|
|
2328
|
+
if (this.fileChangesHaveProtocolDiff(item.changes)) {
|
|
2329
|
+
if (item.id && !state.emittedEditCallIds.has(item.id)) {
|
|
2330
|
+
const editEvent = this.buildCodexEditEvent(item.id, item.changes);
|
|
2331
|
+
if (editEvent) {
|
|
2332
|
+
state.emittedEditCallIds.add(item.id);
|
|
2333
|
+
yield editEvent;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
yield { type: 'tool_result', name: 'Edit', result: item.changes, isError: item.status === 'failed', callId: item.id };
|
|
2337
|
+
}
|
|
2338
|
+
else {
|
|
2339
|
+
const desc = this.normalizeFileChanges(item.changes).map((change) => this.describeFileChange(change)).join(', ');
|
|
2340
|
+
yield { type: 'tool_use', name: 'FileChange', input: { description: desc }, callId: item.id };
|
|
2341
|
+
yield { type: 'tool_result', name: 'FileChange', result: item.changes, isError: item.status === 'failed', callId: item.id };
|
|
2342
|
+
}
|
|
2343
|
+
break;
|
|
2344
|
+
case 'webSearch':
|
|
2345
|
+
yield {
|
|
2346
|
+
type: 'tool_result',
|
|
2347
|
+
name: 'WebSearch',
|
|
2348
|
+
result: {
|
|
2349
|
+
query: item.query,
|
|
2350
|
+
...(item.action != null ? { action: item.action } : {}),
|
|
2351
|
+
},
|
|
2352
|
+
isError: false,
|
|
2353
|
+
callId: item.id,
|
|
2354
|
+
};
|
|
2355
|
+
break;
|
|
2356
|
+
case 'collabAgentToolCall':
|
|
2357
|
+
yield {
|
|
2358
|
+
type: 'tool_result',
|
|
2359
|
+
name: `Collab:${item.tool}`,
|
|
2360
|
+
result: {
|
|
2361
|
+
receiverThreadIds: item.receiverThreadIds,
|
|
2362
|
+
agentsStates: item.agentsStates,
|
|
2363
|
+
},
|
|
2364
|
+
isError: item.status === 'failed',
|
|
2365
|
+
error: item.status === 'failed' ? 'Collaboration tool call failed' : undefined,
|
|
2366
|
+
callId: item.id,
|
|
2367
|
+
};
|
|
2368
|
+
break;
|
|
2369
|
+
case 'imageView':
|
|
2370
|
+
yield { type: 'tool_result', name: 'ViewImage', result: { path: item.path }, isError: false, callId: item.id };
|
|
2371
|
+
break;
|
|
2372
|
+
case 'sleep':
|
|
2373
|
+
yield {
|
|
2374
|
+
type: 'tool_result',
|
|
2375
|
+
name: 'Sleep',
|
|
2376
|
+
result: { durationMs: item.durationMs },
|
|
2377
|
+
isError: false,
|
|
2378
|
+
callId: item.id,
|
|
2379
|
+
durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
|
|
2380
|
+
};
|
|
2381
|
+
break;
|
|
2382
|
+
case 'imageGeneration':
|
|
2383
|
+
yield {
|
|
2384
|
+
type: 'tool_result',
|
|
2385
|
+
name: 'ImageGeneration',
|
|
2386
|
+
result: {
|
|
2387
|
+
...(item.savedPath != null ? { savedPath: item.savedPath } : {}),
|
|
2388
|
+
...(item.revisedPrompt != null ? { revisedPrompt: item.revisedPrompt } : {}),
|
|
2389
|
+
},
|
|
2390
|
+
isError: item.status === 'failed',
|
|
2391
|
+
error: item.status === 'failed' ? 'Image generation failed' : undefined,
|
|
2392
|
+
callId: item.id,
|
|
2393
|
+
};
|
|
2394
|
+
break;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
*mapAppServerFileChangePatchUpdated(params, state) {
|
|
2398
|
+
const itemId = typeof params.itemId === 'string' ? params.itemId : undefined;
|
|
2399
|
+
if (!itemId || state.emittedEditCallIds.has(itemId))
|
|
2400
|
+
return;
|
|
2401
|
+
const editEvent = this.buildCodexEditEvent(itemId, params.changes);
|
|
2402
|
+
if (!editEvent)
|
|
2403
|
+
return;
|
|
2404
|
+
state.emittedEditCallIds.add(itemId);
|
|
2405
|
+
yield editEvent;
|
|
2406
|
+
}
|
|
2407
|
+
buildCodexEditEvent(callId, changes) {
|
|
2408
|
+
const editInput = this.buildCodexEditInput(changes);
|
|
2409
|
+
if (!editInput)
|
|
2410
|
+
return null;
|
|
2411
|
+
return { type: 'tool_use', name: 'Edit', input: editInput, callId };
|
|
2412
|
+
}
|
|
2413
|
+
buildCodexEditInput(changes) {
|
|
2414
|
+
const normalized = this.normalizeFileChanges(changes)
|
|
2415
|
+
.map((change) => this.normalizeCodexProtocolDiffChange(change))
|
|
2416
|
+
.filter((change) => !!change);
|
|
2417
|
+
if (normalized.length === 0)
|
|
2418
|
+
return null;
|
|
2419
|
+
const first = normalized[0];
|
|
2420
|
+
return {
|
|
2421
|
+
file_path: first.path,
|
|
2422
|
+
unified_diff: normalized.map(change => this.formatCodexUnifiedDiff(change)).join('\n'),
|
|
2423
|
+
codex_file_changes: normalized,
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
normalizeCodexProtocolDiffChange(change) {
|
|
2427
|
+
const filePath = typeof change?.path === 'string' ? change.path : '';
|
|
2428
|
+
const diff = typeof change?.diff === 'string' ? change.diff
|
|
2429
|
+
: typeof change?.unified_diff === 'string' ? change.unified_diff
|
|
2430
|
+
: typeof change?.unifiedDiff === 'string' ? change.unifiedDiff
|
|
2431
|
+
: '';
|
|
2432
|
+
if (!filePath || !diff)
|
|
2433
|
+
return null;
|
|
2434
|
+
return {
|
|
2435
|
+
path: filePath,
|
|
2436
|
+
diff,
|
|
2437
|
+
kind: this.normalizeFileChangeKind(change.kind ?? change.type),
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
fileChangesHaveProtocolDiff(changes) {
|
|
2441
|
+
return this.buildCodexEditInput(changes) !== null;
|
|
2442
|
+
}
|
|
2443
|
+
formatCodexUnifiedDiff(change) {
|
|
2444
|
+
const pathLabel = change.path.replace(/\\/g, '/');
|
|
2445
|
+
const diffPath = pathLabel.replace(/^\/+/, '');
|
|
2446
|
+
const header = change.diff.startsWith('diff ')
|
|
2447
|
+
|| change.diff.startsWith('--- ')
|
|
2448
|
+
|| change.diff.startsWith('+++ ')
|
|
2449
|
+
? ''
|
|
2450
|
+
: `--- a/${diffPath}\n+++ b/${diffPath}\n`;
|
|
2451
|
+
return `${header}${change.diff.trimEnd()}`;
|
|
2452
|
+
}
|
|
2453
|
+
pickNumber(...values) {
|
|
2454
|
+
for (const value of values) {
|
|
2455
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
2456
|
+
return value;
|
|
2457
|
+
}
|
|
2458
|
+
return undefined;
|
|
2459
|
+
}
|
|
2460
|
+
mapCodexTokenUsage(raw) {
|
|
2461
|
+
if (!raw || typeof raw !== 'object')
|
|
2462
|
+
return undefined;
|
|
2463
|
+
const usage = {
|
|
2464
|
+
input_tokens: this.pickNumber(raw.inputTokens, raw.input_tokens),
|
|
2465
|
+
output_tokens: this.pickNumber(raw.outputTokens, raw.output_tokens),
|
|
2466
|
+
cache_read_input_tokens: this.pickNumber(raw.cachedInputTokens, raw.cache_read_input_tokens, raw.cached_input_tokens),
|
|
2467
|
+
cache_creation_input_tokens: this.pickNumber(raw.cacheCreationInputTokens, raw.cache_creation_input_tokens, raw.cache_creation_tokens),
|
|
2468
|
+
};
|
|
2469
|
+
return Object.values(usage).some(value => value !== undefined) ? usage : undefined;
|
|
2470
|
+
}
|
|
2471
|
+
mapCodexContextUsage(raw) {
|
|
2472
|
+
if (!raw || typeof raw !== 'object')
|
|
2473
|
+
return undefined;
|
|
2474
|
+
const totalTokens = this.pickNumber(raw.totalTokens, raw.total_tokens, raw.total);
|
|
2475
|
+
const maxTokens = this.pickNumber(raw.maxTokens, raw.max_tokens, raw.max);
|
|
2476
|
+
const model = typeof raw.model === 'string' ? raw.model : undefined;
|
|
2477
|
+
if (totalTokens === undefined || maxTokens === undefined || !model)
|
|
2478
|
+
return undefined;
|
|
2479
|
+
const percentage = this.pickNumber(raw.percentage) ?? Math.round((totalTokens / maxTokens) * 100);
|
|
2480
|
+
const effort = typeof raw.effort === 'string' ? raw.effort : undefined;
|
|
2481
|
+
return { totalTokens, maxTokens, percentage, model, effort };
|
|
2482
|
+
}
|
|
2483
|
+
mapAppServerTurnComplete(turn, state) {
|
|
2484
|
+
const status = turn.status || 'completed';
|
|
2485
|
+
const tokenUsage = this.mapCodexTokenUsage(state.tokenUsage?.last ?? turn.tokenUsage ?? turn.usage);
|
|
2486
|
+
const contextUsage = this.mapCodexContextUsage(turn.contextUsage ?? state.tokenUsage?.contextUsage ?? state.tokenUsage?.context);
|
|
2487
|
+
const terminalReason = status === 'completed'
|
|
2488
|
+
? undefined
|
|
2489
|
+
: status === 'interrupted'
|
|
2490
|
+
? 'aborted_streaming'
|
|
2491
|
+
: status;
|
|
2492
|
+
return {
|
|
2493
|
+
type: 'complete',
|
|
2494
|
+
subtype: status === 'completed' ? 'success' : status,
|
|
2495
|
+
isError: status === 'failed',
|
|
2496
|
+
errors: turn.error?.message ? [turn.error.message] : undefined,
|
|
2497
|
+
terminalReason,
|
|
2498
|
+
durationMs: typeof turn.durationMs === 'number' ? turn.durationMs : undefined,
|
|
2499
|
+
ttftMs: this.pickNumber(turn.ttftMs, turn.ttft_ms),
|
|
2500
|
+
costUsd: this.pickNumber(turn.costUsd, turn.totalCostUsd, turn.total_cost_usd),
|
|
2501
|
+
sessionTitle: typeof turn.sessionTitle === 'string' ? turn.sessionTitle : typeof turn.session_title === 'string' ? turn.session_title : undefined,
|
|
2502
|
+
numTurns: this.pickNumber(turn.numTurns, turn.num_turns),
|
|
2503
|
+
model: state.model,
|
|
2504
|
+
tokenUsage,
|
|
2505
|
+
contextUsage,
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
managedTempParent() {
|
|
2509
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
2510
|
+
return path.join(resolveProtectedCandidate(os.tmpdir()), `evolcore-codex-${uid}`);
|
|
2511
|
+
}
|
|
2512
|
+
ensurePrivateDirectory(directory) {
|
|
2513
|
+
try {
|
|
2514
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
2515
|
+
}
|
|
2516
|
+
catch (error) {
|
|
2517
|
+
if (error?.code !== 'EEXIST')
|
|
2518
|
+
throw error;
|
|
2519
|
+
}
|
|
2520
|
+
const stat = fs.lstatSync(directory);
|
|
2521
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
2522
|
+
throw new Error(`managed temp path is not a real directory: ${directory}`);
|
|
2523
|
+
}
|
|
2524
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
2525
|
+
throw new Error(`managed temp path is owned by another uid: ${directory}`);
|
|
2526
|
+
}
|
|
2527
|
+
if ((stat.mode & 0o077) !== 0)
|
|
2528
|
+
fs.chmodSync(directory, 0o700);
|
|
2529
|
+
}
|
|
2530
|
+
ensureSessionTempDir(sessionId) {
|
|
2531
|
+
const existing = this.sessionTempDirs.get(sessionId);
|
|
2532
|
+
if (existing) {
|
|
2533
|
+
try {
|
|
2534
|
+
this.ensurePrivateDirectory(this.managedTempParent());
|
|
2535
|
+
this.ensurePrivateDirectory(existing);
|
|
2536
|
+
return existing;
|
|
2537
|
+
}
|
|
2538
|
+
catch (error) {
|
|
2539
|
+
this.sessionTempDirs.delete(sessionId);
|
|
2540
|
+
logger.warn(`[CodexRunner] existing managed TMPDIR is no longer usable: session=${sessionId} path=${existing} error=${error instanceof Error ? error.message : String(error)}`);
|
|
2541
|
+
return undefined;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
try {
|
|
2545
|
+
const parent = this.managedTempParent();
|
|
2546
|
+
this.ensurePrivateDirectory(parent);
|
|
2547
|
+
// Include a per-runner nonce so a daemon restart never reuses contents
|
|
2548
|
+
// left behind by an unclean shutdown for the same EvolCore session id.
|
|
2549
|
+
const digest = createHash('sha256')
|
|
2550
|
+
.update(this.managedTempNonce)
|
|
2551
|
+
.update('\0')
|
|
2552
|
+
.update(sessionId)
|
|
2553
|
+
.digest('hex')
|
|
2554
|
+
.slice(0, 24);
|
|
2555
|
+
const directory = path.join(parent, digest);
|
|
2556
|
+
this.ensurePrivateDirectory(directory);
|
|
2557
|
+
this.sessionTempDirs.set(sessionId, directory);
|
|
2558
|
+
registerManagedTempDirForExitCleanup(directory, parent);
|
|
2559
|
+
return directory;
|
|
2560
|
+
}
|
|
2561
|
+
catch (error) {
|
|
2562
|
+
logger.warn(`[CodexRunner] managed TMPDIR unavailable for session=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2563
|
+
return undefined;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
isTrustedSessionTempDir(directory) {
|
|
2567
|
+
try {
|
|
2568
|
+
const rawStat = fs.lstatSync(directory);
|
|
2569
|
+
if (!rawStat.isDirectory() || rawStat.isSymbolicLink())
|
|
2570
|
+
return false;
|
|
2571
|
+
const parent = resolveProtectedCandidate(this.managedTempParent());
|
|
2572
|
+
const canonical = resolveProtectedCandidate(directory);
|
|
2573
|
+
for (const candidate of [parent, canonical]) {
|
|
2574
|
+
const stat = fs.lstatSync(candidate);
|
|
2575
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
2576
|
+
return false;
|
|
2577
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
|
|
2578
|
+
return false;
|
|
2579
|
+
if ((stat.mode & 0o077) !== 0)
|
|
2580
|
+
return false;
|
|
2581
|
+
}
|
|
2582
|
+
return canonical !== parent && isSameOrDescendant(canonical, parent);
|
|
2583
|
+
}
|
|
2584
|
+
catch {
|
|
2585
|
+
return false;
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
cleanupSessionTempDir(sessionId) {
|
|
2589
|
+
const directory = this.sessionTempDirs.get(sessionId);
|
|
2590
|
+
this.sessionTempDirs.delete(sessionId);
|
|
2591
|
+
if (!directory)
|
|
2592
|
+
return;
|
|
2593
|
+
const parent = resolveProtectedCandidate(this.managedTempParent());
|
|
2594
|
+
const candidate = resolveProtectedCandidate(directory);
|
|
2595
|
+
if (candidate === parent || !isSameOrDescendant(candidate, parent)) {
|
|
2596
|
+
managedTempDirsForExitCleanup.delete(directory);
|
|
2597
|
+
logger.warn(`[CodexRunner] refused to clean unmanaged TMPDIR: session=${sessionId} path=${directory}`);
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
try {
|
|
2601
|
+
fs.rmSync(candidate, { recursive: true, force: true });
|
|
2602
|
+
managedTempDirsForExitCleanup.delete(directory);
|
|
2603
|
+
}
|
|
2604
|
+
catch (error) {
|
|
2605
|
+
logger.warn(`[CodexRunner] failed to clean managed TMPDIR: session=${sessionId} path=${candidate} error=${error instanceof Error ? error.message : String(error)}`);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
managedTempInstruction(sessionId) {
|
|
2609
|
+
if (!this.ensureSessionTempDir(sessionId))
|
|
2610
|
+
return undefined;
|
|
2611
|
+
return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。';
|
|
2612
|
+
}
|
|
2613
|
+
buildEvolcoreShellEnvironmentConfig(sessionId) {
|
|
2614
|
+
const managedTempDir = this.ensureSessionTempDir(sessionId);
|
|
2615
|
+
return {
|
|
2616
|
+
shell_environment_policy: {
|
|
2617
|
+
set: {
|
|
2618
|
+
EVOLCORE_SESSION_ID: sessionId,
|
|
2619
|
+
EVOLCORE_HOME: resolveRoot(),
|
|
2620
|
+
...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
|
|
2621
|
+
},
|
|
2622
|
+
},
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
async dispose() {
|
|
2626
|
+
// Abort all active streams
|
|
2627
|
+
for (const [key, controller] of this.activeAbortControllers) {
|
|
2628
|
+
controller.abort('dispose');
|
|
2629
|
+
}
|
|
2630
|
+
this.activeAbortControllers.clear();
|
|
2631
|
+
this.activeStreams.clear();
|
|
2632
|
+
this.activeSessions.clear();
|
|
2633
|
+
this.activeTurns.clear();
|
|
2634
|
+
this.threadProjectPaths.clear();
|
|
2635
|
+
this.threadExternalToolFingerprints.clear();
|
|
2636
|
+
this.threadExternalToolBoundaries.clear();
|
|
2637
|
+
this.trackedApprovalItems.clear();
|
|
2638
|
+
this.pendingInterrupts.clear();
|
|
2639
|
+
this.permissionContexts.clear();
|
|
2640
|
+
this.chatModes.clear();
|
|
2641
|
+
for (const sessionId of [...this.sessionTempDirs.keys()])
|
|
2642
|
+
this.cleanupSessionTempDir(sessionId);
|
|
2643
|
+
await this.appServerClient?.close();
|
|
2644
|
+
this.appServerClient = null;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
// ── Plugin ──
|
|
2648
|
+
export class CodexAgentPlugin {
|
|
2649
|
+
name = 'codex';
|
|
2650
|
+
isEnabled(agent) {
|
|
2651
|
+
if (!agent.config.baseagents?.codex)
|
|
2652
|
+
return false;
|
|
2653
|
+
if (!isCodexAppServerAvailable())
|
|
2654
|
+
return false;
|
|
2655
|
+
try {
|
|
2656
|
+
const override = agent.config.baseagents.codex;
|
|
2657
|
+
const syntheticConfig = { agents: { codex: override } };
|
|
2658
|
+
const resolved = resolveOpenaiConfig(syntheticConfig, override);
|
|
2659
|
+
return !!resolved.apiKey;
|
|
2660
|
+
}
|
|
2661
|
+
catch {
|
|
2662
|
+
return false;
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
createAgent(agent, callbacks) {
|
|
2666
|
+
const availability = getCodexAppServerAvailability();
|
|
2667
|
+
if (!availability.available) {
|
|
2668
|
+
throw new Error(availability.reason || 'Missing codex CLI with app-server');
|
|
2669
|
+
}
|
|
2670
|
+
const override = agent.config.baseagents?.codex;
|
|
2671
|
+
const merged = {
|
|
2672
|
+
agents: {
|
|
2673
|
+
codex: {
|
|
2674
|
+
...(override || {}),
|
|
2675
|
+
evolcoreAgentAid: agent.config.aid,
|
|
2676
|
+
evolcoreAgentConfig: agent.config,
|
|
2677
|
+
},
|
|
2678
|
+
},
|
|
2679
|
+
};
|
|
2680
|
+
return { evolagentName: agent.name, baseagent: 'codex', agent: new CodexRunner(merged, callbacks) };
|
|
2681
|
+
}
|
|
2682
|
+
}
|