fix(codex): guard native hook relay generation reuse

This commit is contained in:
Peter Steinberger
2026-05-27 19:26:00 +01:00
parent cf32aa8692
commit 668590b0e8
3 changed files with 194 additions and 2 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -69,6 +69,11 @@ export type CodexPluginThreadConfigProvider = {
build: () => Promise<CodexPluginThreadConfig>;
};
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,