mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 21:55:18 +00:00
fix(codex): guard native hook relay generation reuse
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user