mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 16:16:05 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user