From 42e9504114f3b7cb7eae2efba99d79be8626a08a Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Thu, 28 May 2026 04:55:19 +1000 Subject: [PATCH] fix(codex): preserve native hook relay across restarts Fixes #87331.\n\nPersist Codex native hook relay generations for real app-server resumes, keep a bounded legacy-binding grace path, and rotate generation on fresh-thread fallback so stale hook commands stay rejected.\n\nCo-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com> --- .../codex/src/app-server/attempt-startup.ts | 6 +- .../codex/src/app-server/native-hook-relay.ts | 6 + .../app-server/run-attempt-test-harness.ts | 25 ++- .../run-attempt.native-hook-relay.test.ts | 198 ++++++++++++++++++ .../codex/src/app-server/run-attempt.ts | 44 ++-- .../src/app-server/session-binding.test.ts | 2 + .../codex/src/app-server/session-binding.ts | 7 + .../codex/src/app-server/thread-lifecycle.ts | 34 ++- src/agents/harness/native-hook-relay.test.ts | 59 ++++++ src/agents/harness/native-hook-relay.ts | 40 +++- 10 files changed, 396 insertions(+), 25 deletions(-) diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index 7cb6fb11c78e..e1bca31d124b 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -84,7 +84,9 @@ export async function startCodexAttemptThread(params: { effectiveWorkspace: string; dynamicTools: CodexDynamicToolSpec[]; developerInstructions: string | undefined; - finalConfigPatch: Parameters[0]["finalConfigPatch"]; + finalConfigPatch?: Parameters[0]["finalConfigPatch"]; + buildFinalConfigPatch?: Parameters[0]["buildFinalConfigPatch"]; + nativeHookRelayGeneration?: string; bundleMcpThreadConfig: CodexBundleMcpThreadConfig; nativeToolSurfaceEnabled: boolean; sandboxExecServerEnabled: boolean; @@ -253,6 +255,8 @@ export async function startCodexAttemptThread(params: { developerInstructions: params.developerInstructions, config: threadConfig, finalConfigPatch: params.finalConfigPatch, + buildFinalConfigPatch: params.buildFinalConfigPatch, + nativeHookRelayGeneration: params.nativeHookRelayGeneration, nativeCodeModeEnabled: params.nativeToolSurfaceEnabled, nativeCodeModeOnlyEnabled: params.appServer.codeModeOnly, userMcpServersEnabled: params.nativeToolSurfaceEnabled, diff --git a/extensions/codex/src/app-server/native-hook-relay.ts b/extensions/codex/src/app-server/native-hook-relay.ts index 84b16f2c2296..a0d3b939c1be 100644 --- a/extensions/codex/src/app-server/native-hook-relay.ts +++ b/extensions/codex/src/app-server/native-hook-relay.ts @@ -95,6 +95,8 @@ export function createCodexNativeHookRelay(params: { gatewayTimeoutMs?: number; } | undefined; + generation?: string; + generationMismatchGraceMs?: number; events: readonly NativeHookRelayEvent[]; agentId: string | undefined; sessionId: string; @@ -117,6 +119,10 @@ export function createCodexNativeHookRelay(params: { sessionId: params.sessionId, sessionKey: params.sessionKey, }), + ...(params.generation ? { generation: params.generation } : {}), + ...(params.generationMismatchGraceMs + ? { generationMismatchGraceMs: params.generationMismatchGraceMs } + : {}), ...(params.agentId ? { agentId: params.agentId } : {}), sessionId: params.sessionId, ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), diff --git a/extensions/codex/src/app-server/run-attempt-test-harness.ts b/extensions/codex/src/app-server/run-attempt-test-harness.ts index e7bcd9e8f32b..fab87c98f522 100644 --- a/extensions/codex/src/app-server/run-attempt-test-harness.ts +++ b/extensions/codex/src/app-server/run-attempt-test-harness.ts @@ -394,6 +394,24 @@ export function createResumeHarness() { } export function extractRelayIdFromThreadRequest(params: unknown): string { + const command = extractNativeHookRelayCommandFromThreadRequest(params); + const match = command.match(/--relay-id ([^ ]+)/); + if (!match?.[1]) { + throw new Error(`relay id missing from command: ${command}`); + } + return match[1]; +} + +export function extractGenerationFromThreadRequest(params: unknown): string { + const command = extractNativeHookRelayCommandFromThreadRequest(params); + const match = command.match(/--generation ([^ ]+)/); + if (!match?.[1]) { + throw new Error(`relay generation missing from command: ${command}`); + } + return match[1]; +} + +function extractNativeHookRelayCommandFromThreadRequest(params: unknown): string { const config = (params as { config?: Record }).config; let command: string | undefined; for (const key of [ @@ -416,11 +434,10 @@ export function extractRelayIdFromThreadRequest(params: unknown): string { break; } } - const match = command?.match(/--relay-id ([^ ]+)/); - if (!match?.[1]) { - throw new Error(`relay id missing from command: ${command}`); + if (!command) { + throw new Error("native hook relay command missing from thread request"); } - return match[1]; + return command; } type RuntimeDynamicToolForTest = Parameters< 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 cd0be1d08b38..48065e728618 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 @@ -10,12 +10,14 @@ import { createParams, createResumeHarness, createStartedThreadHarness, + extractGenerationFromThreadRequest, extractRelayIdFromThreadRequest, runCodexAppServerAttempt, setupRunAttemptTestHooks, tempDir, } from "./run-attempt-test-harness.js"; import { testing } from "./run-attempt.js"; +import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js"; setupRunAttemptTestHooks(); @@ -440,6 +442,202 @@ describe("runCodexAppServerAttempt native hook relay", () => { ).toBeUndefined(); }); + it("persists and reuses Codex native hook relay generations for resumed threads", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const firstHarness = createStartedThreadHarness(); + + const firstRun = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), { + nativeHookRelay: { + enabled: true, + events: ["pre_tool_use"], + }, + }); + await firstHarness.waitForMethod("turn/start"); + const firstStartRequest = firstHarness.requests.find( + (request) => request.method === "thread/start", + ); + const firstRelayId = extractRelayIdFromThreadRequest(firstStartRequest?.params); + const firstGeneration = extractGenerationFromThreadRequest(firstStartRequest?.params); + + await firstHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await firstRun; + expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe( + firstGeneration, + ); + + const secondHarness = createResumeHarness(); + const secondParams = createParams(sessionFile, workspaceDir); + secondParams.runId = "run-2"; + const secondRun = runCodexAppServerAttempt(secondParams, { + nativeHookRelay: { + enabled: true, + events: ["pre_tool_use"], + }, + }); + await secondHarness.waitForMethod("turn/start"); + + const resumeRequest = secondHarness.requests.find( + (request) => request.method === "thread/resume", + ); + expect(extractRelayIdFromThreadRequest(resumeRequest?.params)).toBe(firstRelayId); + expect(extractGenerationFromThreadRequest(resumeRequest?.params)).toBe(firstGeneration); + + await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" }); + await secondRun; + testing.flushPendingCodexNativeHookRelayUnregistersForTests(); + }); + + it("accepts a stale first hook generation when resuming a pre-generation binding", 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", + dynamicToolsFingerprint: "[]", + }); + const harness = createResumeHarness(); + + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), { + nativeHookRelay: { + enabled: true, + events: ["pre_tool_use"], + }, + }); + await harness.waitForMethod("turn/start"); + + const resumeRequest = harness.requests.find((request) => request.method === "thread/resume"); + const relayId = extractRelayIdFromThreadRequest(resumeRequest?.params); + const currentGeneration = extractGenerationFromThreadRequest(resumeRequest?.params); + expect(currentGeneration).not.toBe("legacy-generation-from-running-thread"); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + generation: "legacy-generation-from-running-thread", + event: "pre_tool_use", + requireGeneration: true, + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: "first-tool-after-restart", + tool_input: { command: "pwd" }, + }, + }), + ).resolves.toMatchObject({ exitCode: 0 }); + + await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" }); + await run; + expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe( + currentGeneration, + ); + 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("rotates native hook relay generations when resume fails over to 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", + nativeHookRelayGeneration: "generation-from-failed-resume", + }); + const harness = createStartedThreadHarness(async (method) => { + if (method === "thread/resume") { + throw new Error("resume failed"); + } + return undefined; + }); + + 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-failed-resume"); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + generation: "generation-from-failed-resume", + event: "pre_tool_use", + requireGeneration: true, + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: "failed-resume-stale-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 3a41ac3dd150..8d51ddb076f7 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -796,13 +796,18 @@ export async function runCodexAppServerAttempt( timeoutMs: params.timeoutMs, timeoutFloorMs: options.startupTimeoutFloorMs, }); - try { - emitCodexAppServerEvent(params, { - stream: "codex_app_server.lifecycle", - data: { phase: "startup" }, - }); + const buildNativeHookRelayFinalConfigPatch = ( + decision: { action: "resume"; binding: CodexAppServerThreadBinding } | { action: "start" }, + ) => { + nativeHookRelay?.unregister(); nativeHookRelay = createCodexNativeHookRelay({ options: options.nativeHookRelay, + generation: + decision.action === "resume" ? decision.binding.nativeHookRelayGeneration : undefined, + generationMismatchGraceMs: + decision.action === "resume" && !decision.binding.nativeHookRelayGeneration + ? CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS + : undefined, events: nativeHookRelayEvents, agentId: sessionAgentId, sessionId: params.sessionId, @@ -815,15 +820,24 @@ export async function runCodexAppServerAttempt( turnStartTimeoutMs: params.timeoutMs, signal: runAbortController.signal, }); - const nativeHookRelayConfig = nativeHookRelay - ? buildCodexNativeHookRelayConfig({ - relay: nativeHookRelay, - events: nativeHookRelayEvents, - hookTimeoutSec: options.nativeHookRelay?.hookTimeoutSec, - }) - : options.nativeHookRelay?.enabled === false - ? buildCodexNativeHookRelayDisabledConfig() - : undefined; + return { + configPatch: nativeHookRelay + ? buildCodexNativeHookRelayConfig({ + relay: nativeHookRelay, + events: nativeHookRelayEvents, + hookTimeoutSec: options.nativeHookRelay?.hookTimeoutSec, + }) + : options.nativeHookRelay?.enabled === false + ? buildCodexNativeHookRelayDisabledConfig() + : undefined, + nativeHookRelayGeneration: nativeHookRelay?.generation, + }; + }; + try { + emitCodexAppServerEvent(params, { + stream: "codex_app_server.lifecycle", + data: { phase: "startup" }, + }); const startupResult = await startCodexAttemptThread({ attemptClientFactory, appServer, @@ -839,7 +853,7 @@ export async function runCodexAppServerAttempt( effectiveWorkspace, dynamicTools: toolBridge.specs, developerInstructions: promptBuild.developerInstructions, - finalConfigPatch: nativeHookRelayConfig, + buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch, bundleMcpThreadConfig, nativeToolSurfaceEnabled, sandboxExecServerEnabled, diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 2d1db9e3e8f4..036288139dbc 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -60,6 +60,7 @@ describe("codex app-server session binding", () => { modelProvider: "openai", dynamicToolsFingerprint: "tools-v1", userMcpServersFingerprint: "user-mcp-v1", + nativeHookRelayGeneration: "generation-v1", }); const binding = await readCodexAppServerBinding(sessionFile); @@ -72,6 +73,7 @@ describe("codex app-server session binding", () => { expect(binding?.modelProvider).toBe("openai"); expect(binding?.dynamicToolsFingerprint).toBe("tools-v1"); expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1"); + expect(binding?.nativeHookRelayGeneration).toBe("generation-v1"); const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile)); expect(bindingStat.isFile()).toBe(true); }); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 1b13b35f2297..94f6491d6cfd 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -42,6 +42,7 @@ export type CodexAppServerThreadBinding = { dynamicToolsFingerprint?: string; userMcpServersFingerprint?: string; mcpServersFingerprint?: string; + nativeHookRelayGeneration?: string; pluginAppsFingerprint?: string; pluginAppsInputFingerprint?: string; pluginAppPolicyContext?: PluginAppPolicyContext; @@ -116,6 +117,11 @@ export async function readCodexAppServerBinding( : undefined, mcpServersFingerprint: typeof parsed.mcpServersFingerprint === "string" ? parsed.mcpServersFingerprint : undefined, + nativeHookRelayGeneration: + typeof parsed.nativeHookRelayGeneration === "string" && + parsed.nativeHookRelayGeneration.trim() + ? parsed.nativeHookRelayGeneration + : undefined, pluginAppsFingerprint: typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined, pluginAppsInputFingerprint: @@ -166,6 +172,7 @@ export async function writeCodexAppServerBinding( dynamicToolsFingerprint: binding.dynamicToolsFingerprint, userMcpServersFingerprint: binding.userMcpServersFingerprint, mcpServersFingerprint: binding.mcpServersFingerprint, + nativeHookRelayGeneration: binding.nativeHookRelayGeneration, pluginAppsFingerprint: binding.pluginAppsFingerprint, pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint, pluginAppPolicyContext: binding.pluginAppPolicyContext, diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index de468f146e5b..8a414df4c2b1 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -56,6 +56,15 @@ export type CodexAppServerThreadLifecycleBinding = CodexAppServerThreadBinding & lifecycle: CodexAppServerThreadLifecycle; }; +export type CodexThreadFinalConfigPatchDecision = + | { action: "resume"; binding: CodexAppServerThreadBinding } + | { action: "start" }; + +export type CodexThreadFinalConfigPatchResult = { + configPatch?: JsonObject; + nativeHookRelayGeneration?: string; +}; + export type CodexContextEngineThreadBootstrapProjection = { mode: "thread_bootstrap"; epoch: string; @@ -202,6 +211,10 @@ export async function startOrResumeThread(params: { developerInstructions?: string; config?: JsonObject; finalConfigPatch?: JsonObject; + buildFinalConfigPatch?: ( + decision: CodexThreadFinalConfigPatchDecision, + ) => CodexThreadFinalConfigPatchResult; + nativeHookRelayGeneration?: string; nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean; userMcpServersEnabled?: boolean; @@ -387,10 +400,17 @@ export async function startOrResumeThread(params: { } else { try { const authProfileId = params.params.authProfileId ?? binding.authProfileId; + const finalConfigPatch = params.buildFinalConfigPatch?.({ + action: "resume", + binding, + }) ?? { + configPatch: params.finalConfigPatch, + nativeHookRelayGeneration: params.nativeHookRelayGeneration, + }; const resumeConfig = mergeCodexThreadConfigs( params.config, userMcpServersConfigPatch, - params.finalConfigPatch, + finalConfigPatch.configPatch, ); const resumeParams = lifecycleTiming.measureSync("thread_resume_params", () => buildThreadResumeParams(params.params, { @@ -433,6 +453,8 @@ export async function startOrResumeThread(params: { dynamicToolsFingerprint, userMcpServersFingerprint, mcpServersFingerprint: nextMcpServersFingerprint, + nativeHookRelayGeneration: + finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration, pluginAppsFingerprint: binding.pluginAppsFingerprint, pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint, pluginAppPolicyContext: binding.pluginAppPolicyContext, @@ -475,6 +497,8 @@ export async function startOrResumeThread(params: { dynamicToolsFingerprint, userMcpServersFingerprint, mcpServersFingerprint: nextMcpServersFingerprint, + nativeHookRelayGeneration: + finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration, pluginAppsFingerprint: binding.pluginAppsFingerprint, pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint, pluginAppPolicyContext: binding.pluginAppPolicyContext, @@ -500,12 +524,16 @@ export async function startOrResumeThread(params: { params.pluginThreadConfig?.build(), ))) : undefined; + const finalConfigPatch = params.buildFinalConfigPatch?.({ action: "start" }) ?? { + configPatch: params.finalConfigPatch, + nativeHookRelayGeneration: params.nativeHookRelayGeneration, + }; const config = lifecycleTiming.measureSync("merge_thread_config", () => mergeCodexThreadConfigs( params.config, userMcpServersConfigPatch, pluginThreadConfig?.configPatch, - params.finalConfigPatch, + finalConfigPatch.configPatch, ), ); const startParams = lifecycleTiming.measureSync("thread_start_params", () => @@ -548,6 +576,7 @@ export async function startOrResumeThread(params: { dynamicToolsFingerprint, userMcpServersFingerprint, mcpServersFingerprint: nextMcpServersFingerprint, + nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, pluginAppsFingerprint: pluginThreadConfig?.fingerprint, pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, pluginAppPolicyContext: pluginThreadConfig?.policyContext, @@ -592,6 +621,7 @@ export async function startOrResumeThread(params: { dynamicToolsFingerprint, userMcpServersFingerprint, mcpServersFingerprint: nextMcpServersFingerprint, + nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, pluginAppsFingerprint: pluginThreadConfig?.fingerprint, pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, pluginAppPolicyContext: pluginThreadConfig?.policyContext, diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index 8e9442138090..9313fe57eee2 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -743,6 +743,65 @@ describe("native hook relay registry", () => { }); }); + it("accepts bootstrap generation mismatches during a bounded grace window", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + relayId: "codex-bootstrap-stale-generation", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + generationMismatchGraceMs: 60_000, + }); + + await expect( + invokeNativeHookRelayBridge({ + provider: "codex", + relayId: relay.relayId, + generation: "stale-generation-from-resumed-thread", + event: "pre_tool_use", + timeoutMs: 2_000, + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "pnpm test" }, + }, + }), + ).resolves.toEqual({ stdout: "", stderr: "", exitCode: 0 }); + expect(getOnlyNativeHookRelayInvocation()).toMatchObject({ + relayId: relay.relayId, + runId: "run-1", + event: "pre_tool_use", + }); + }); + + it("rejects bootstrap generation mismatches after the grace window", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + relayId: "codex-expired-bootstrap-stale-generation", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + generationMismatchGraceMs: 1, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await expect( + invokeNativeHookRelayBridge({ + provider: "codex", + relayId: relay.relayId, + generation: "stale-generation-from-resumed-thread", + event: "pre_tool_use", + timeoutMs: 2_000, + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "pnpm test" }, + }, + }), + ).rejects.toThrow("native hook relay bridge stale registration"); + expect(testing.getNativeHookRelayInvocationsForTests()).toStrictEqual([]); + }); + it("renews relay ttl without rotating the direct hook bridge", async () => { const relay = registerNativeHookRelay({ provider: "codex", diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index d7686e2ff31a..380b76e4e046 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -74,6 +74,7 @@ export type NativeHookRelayProcessResponse = { export type NativeHookRelayRegistration = { relayId: string; provider: NativeHookRelayProvider; + generationMismatchGraceExpiresAtMs?: number; agentId?: string; sessionId: string; sessionKey?: string; @@ -96,6 +97,8 @@ export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistration & { export type RegisterNativeHookRelayParams = { provider: NativeHookRelayProvider; relayId?: string; + generation?: string; + generationMismatchGraceMs?: number; agentId?: string; sessionId: string; sessionKey?: string; @@ -351,12 +354,18 @@ export function registerNativeHookRelay( pruneExpiredNativeHookRelays(); pruneNativeHookRelayPermissionAllowAlways(); const relayId = normalizeRelayId(params.relayId) ?? randomUUID(); + const generation = normalizeRelayGeneration(params.generation) ?? randomUUID(); + const generationMismatchGraceMs = normalizePositiveInteger(params.generationMismatchGraceMs, 0); + const now = Date.now(); const allowedEvents = normalizeAllowedEvents(params.allowedEvents); unregisterNativeHookRelay(relayId); const registration: ActiveNativeHookRelayRegistration = { relayId, provider: params.provider, - generation: randomUUID(), + generation, + ...(generationMismatchGraceMs > 0 + ? { generationMismatchGraceExpiresAtMs: now + generationMismatchGraceMs } + : {}), ...(params.agentId ? { agentId: params.agentId } : {}), sessionId: params.sessionId, ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), @@ -364,7 +373,7 @@ export function registerNativeHookRelay( runId: params.runId, ...(params.channelId ? { channelId: params.channelId } : {}), allowedEvents, - expiresAtMs: Date.now() + normalizePositiveInteger(params.ttlMs, DEFAULT_RELAY_TTL_MS), + expiresAtMs: now + normalizePositiveInteger(params.ttlMs, DEFAULT_RELAY_TTL_MS), ...(params.signal ? { signal: params.signal } : {}), }; relays.set(relayId, registration); @@ -425,6 +434,17 @@ function normalizeRelayId(value: string | undefined): string | undefined { return trimmed; } +function normalizeRelayGeneration(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.length > 160 || !/^[A-Za-z0-9._:-]+$/u.test(trimmed)) { + throw new Error("native hook relay generation must be non-empty, compact, and URL-safe"); + } + return trimmed; +} + function resolveNativeHookRelayNicePrefix(value: number | false | undefined): string[] { if (process.platform === "win32" || value === false || value === undefined) { return []; @@ -520,7 +540,14 @@ export async function invokeNativeHookRelay( if (params.requireGeneration) { const generation = readNonEmptyString(params.generation, "generation"); if (generation !== registration.generation) { - throw new Error(NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR); + if (!canAcceptNativeHookRelayGenerationMismatch(registration)) { + throw new Error(NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR); + } + log.debug("native hook relay accepted bootstrap generation mismatch", { + relayId, + event, + runId: registration.runId, + }); } } if (!registration.allowedEvents.includes(event)) { @@ -649,6 +676,13 @@ function removeNativeHookRelayInvocations(relayId: string): void { } } +function canAcceptNativeHookRelayGenerationMismatch( + registration: NativeHookRelayRegistration, +): boolean { + const expiresAtMs = registration.generationMismatchGraceExpiresAtMs; + return typeof expiresAtMs === "number" && Date.now() <= expiresAtMs; +} + function pruneExpiredNativeHookRelays(now = Date.now()): void { for (const [relayId, registration] of relays) { if (now > registration.expiresAtMs) {