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>
This commit is contained in:
Alex Knight
2026-05-28 04:55:19 +10:00
committed by GitHub
parent 6727985365
commit 42e9504114
10 changed files with 396 additions and 25 deletions

View File

@@ -84,7 +84,9 @@ export async function startCodexAttemptThread(params: {
effectiveWorkspace: string;
dynamicTools: CodexDynamicToolSpec[];
developerInstructions: string | undefined;
finalConfigPatch: Parameters<typeof startOrResumeThread>[0]["finalConfigPatch"];
finalConfigPatch?: Parameters<typeof startOrResumeThread>[0]["finalConfigPatch"];
buildFinalConfigPatch?: Parameters<typeof startOrResumeThread>[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,

View File

@@ -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 } : {}),

View File

@@ -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<string, unknown> }).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<

View File

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

View File

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

View File

@@ -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);
});

View File

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

View File

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

View File

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

View File

@@ -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) {