diff --git a/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts b/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts index 2c6c7c23e73..ee5f5e9b2f0 100644 --- a/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts +++ b/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts @@ -536,6 +536,55 @@ describe("runCodexAppServerAttempt native hook relay", () => { testing.flushPendingCodexNativeHookRelayUnregistersForTests(); }); + it("rotates native hook relay generations when an existing binding starts a fresh thread", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-existing", + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + userMcpServersFingerprint: "stale-user-mcp-fingerprint", + nativeHookRelayGeneration: "generation-from-stale-thread", + }); + const harness = createStartedThreadHarness(); + + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), { + nativeHookRelay: { + enabled: true, + events: ["pre_tool_use"], + }, + }); + await harness.waitForMethod("turn/start"); + + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + const relayId = extractRelayIdFromThreadRequest(startRequest?.params); + const currentGeneration = extractGenerationFromThreadRequest(startRequest?.params); + expect(currentGeneration).not.toBe("generation-from-stale-thread"); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + generation: "generation-from-stale-thread", + event: "pre_tool_use", + requireGeneration: true, + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: "stale-thread-tool", + tool_input: { command: "pwd" }, + }, + }), + ).rejects.toThrow("native hook relay bridge stale registration"); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe( + currentGeneration, + ); + testing.flushPendingCodexNativeHookRelayUnregistersForTests(); + }); + it("builds deterministic opaque Codex native hook relay ids", () => { const relayId = testing.buildCodexNativeHookRelayId({ agentId: "dev-codex", diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index eb918208a01..5e5f1a582c8 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -113,6 +113,7 @@ import { isCodexAppServerApprovalPolicyAllowedByRequirements, isCodexSandboxExecServerEnabled, readCodexPluginConfig, + resolveCodexPluginsPolicy, resolveCodexComputerUseConfig, resolveCodexAppServerRuntimeOptions, shouldAutoApproveCodexAppServerApprovals, @@ -126,6 +127,7 @@ import { import { buildDynamicTools, createCodexDynamicToolBuildStageTracker, + disableCodexPluginThreadConfig, filterCodexDynamicToolsForAllowlist, formatCodexDynamicToolBuildStageSummary, includeForcedCodexDynamicToolAllow, @@ -179,6 +181,11 @@ import { } from "./native-hook-relay.js"; import { registerCodexNativeSubagentMonitor } from "./native-subagent-monitor.js"; import { describeCodexNotificationCorrelation } from "./notification-correlation.js"; +import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js"; +import { + buildCodexPluginThreadConfigInputFingerprint, + shouldBuildCodexPluginThreadConfig, +} from "./plugin-thread-config.js"; import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js"; import { assertCodexTurnStartResponse, @@ -207,6 +214,7 @@ import { buildTurnCollaborationMode, buildTurnStartParams, codexDynamicToolsFingerprint, + resolveCodexNativeHookRelayBindingReuse, type CodexAppServerThreadLifecycleBinding, type CodexContextEngineThreadBootstrapProjection, } from "./thread-lifecycle.js"; @@ -794,6 +802,48 @@ export async function runCodexAppServerAttempt( timeoutMs: params.timeoutMs, timeoutFloorMs: options.startupTimeoutFloorMs, }); + const nativeHookRelayPluginThreadConfigRequired = + !nativeToolSurfaceEnabled || shouldBuildCodexPluginThreadConfig(pluginConfig); + const nativeHookRelayPluginThreadConfigPluginConfig = nativeToolSurfaceEnabled + ? pluginConfig + : disableCodexPluginThreadConfig(pluginConfig); + const nativeHookRelayPluginAppCacheKey = nativeHookRelayPluginThreadConfigRequired + ? buildCodexPluginAppCacheKey({ + appServer, + agentDir, + authProfileId: startupAuthProfileId, + accountId: startupAuthAccountCacheKey, + envApiKeyFingerprint: startupEnvApiKeyCacheKey, + }) + : undefined; + const nativeHookRelayResolvedPluginPolicy = nativeHookRelayPluginThreadConfigRequired + ? resolveCodexPluginsPolicy(nativeHookRelayPluginThreadConfigPluginConfig) + : undefined; + const nativeHookRelayBindingReuse = resolveCodexNativeHookRelayBindingReuse({ + binding: startupBinding, + attemptParams: buildActiveRunAttemptParams(), + agentId: sessionAgentId, + dynamicTools: toolBridge.specs, + nativeCodeModeEnabled: nativeToolSurfaceEnabled, + userMcpServersEnabled: nativeToolSurfaceEnabled, + mcpServersFingerprint: bundleMcpThreadConfig.fingerprint, + mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated, + environmentSelection: undefined, + contextEngineProjection, + pluginThreadConfig: nativeHookRelayPluginThreadConfigRequired + ? { + enabled: true, + inputFingerprint: buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: nativeHookRelayPluginThreadConfigPluginConfig, + appCacheKey: nativeHookRelayPluginAppCacheKey!, + }), + enabledPluginConfigKeys: nativeHookRelayResolvedPluginPolicy?.pluginPolicies + .filter((plugin) => plugin.enabled) + .map((plugin) => plugin.configKey) + .toSorted(), + } + : undefined, + }); try { emitCodexAppServerEvent(params, { stream: "codex_app_server.lifecycle", @@ -801,9 +851,9 @@ export async function runCodexAppServerAttempt( }); nativeHookRelay = createCodexNativeHookRelay({ options: options.nativeHookRelay, - generation: startupBinding?.nativeHookRelayGeneration, + generation: nativeHookRelayBindingReuse.generation, generationMismatchGraceMs: - startupBinding && !startupBinding.nativeHookRelayGeneration + nativeHookRelayBindingReuse.canReuseBinding && !nativeHookRelayBindingReuse.generation ? CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS : undefined, events: nativeHookRelayEvents, diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index a2fb6468eed..cf4fa6051bb 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -69,6 +69,11 @@ export type CodexPluginThreadConfigProvider = { build: () => Promise; }; +export type CodexNativeHookRelayBindingReuseDecision = { + canReuseBinding: boolean; + generation?: string; +}; + export const CODEX_NATIVE_PERSONALITY_NONE = "none"; export const CODEX_CODE_MODE_THREAD_CONFIG: JsonObject = { @@ -613,6 +618,94 @@ export async function startOrResumeThread(params: { }; } +export function resolveCodexNativeHookRelayBindingReuse(params: { + binding: CodexAppServerThreadBinding | undefined; + attemptParams: EmbeddedRunAttemptParams; + agentId?: string; + dynamicTools: CodexDynamicToolSpec[]; + nativeCodeModeEnabled?: boolean; + userMcpServersEnabled?: boolean; + mcpServersFingerprint?: string; + mcpServersFingerprintEvaluated?: boolean; + environmentSelection?: CodexTurnEnvironmentParams[]; + pluginThreadConfig?: Pick< + CodexPluginThreadConfigProvider, + "enabled" | "inputFingerprint" | "enabledPluginConfigKeys" + >; + contextEngineProjection?: CodexContextEngineThreadBootstrapProjection; +}): CodexNativeHookRelayBindingReuseDecision { + const binding = params.binding; + if (!binding?.threadId || params.nativeCodeModeEnabled === false) { + return { canReuseBinding: false }; + } + + const contextEngineBinding = buildContextEngineBinding( + params.attemptParams, + params.contextEngineProjection, + ); + if (binding.contextEngine || contextEngineBinding) { + if ( + !contextEngineBinding || + !isContextEngineBindingCompatible(binding.contextEngine, contextEngineBinding) + ) { + return { canReuseBinding: false }; + } + } + + const userMcpServersConfigPatch = + params.userMcpServersEnabled === false + ? undefined + : buildCodexUserMcpServersThreadConfigPatch(params.attemptParams.config, { + agentId: params.agentId ?? params.attemptParams.agentId, + }); + if ( + binding.userMcpServersFingerprint !== + fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch) + ) { + return { canReuseBinding: false }; + } + + if ( + binding.environmentSelectionFingerprint !== + fingerprintEnvironmentSelection(params.environmentSelection) + ) { + return { canReuseBinding: false }; + } + + if ( + params.mcpServersFingerprintEvaluated === true && + binding.mcpServersFingerprint !== params.mcpServersFingerprint + ) { + return { canReuseBinding: false }; + } + + if ( + isCodexPluginThreadBindingStale({ + codexPluginsEnabled: params.pluginThreadConfig?.enabled ?? false, + bindingFingerprint: binding.pluginAppsFingerprint, + bindingInputFingerprint: binding.pluginAppsInputFingerprint, + currentInputFingerprint: params.pluginThreadConfig?.inputFingerprint, + hasBindingPolicyContext: Boolean(binding.pluginAppPolicyContext), + }) || + shouldRecheckRecoverablePluginBinding({ + binding, + pluginThreadConfig: params.pluginThreadConfig as CodexPluginThreadConfigProvider | undefined, + }) + ) { + return { canReuseBinding: false }; + } + + const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools); + if ( + binding.dynamicToolsFingerprint && + !areDynamicToolFingerprintsCompatible(binding.dynamicToolsFingerprint, dynamicToolsFingerprint) + ) { + return { canReuseBinding: false }; + } + + return { canReuseBinding: true, generation: binding.nativeHookRelayGeneration }; +} + export function buildContextEngineBinding( params: EmbeddedRunAttemptParams, projection?: CodexContextEngineThreadBootstrapProjection,