Bridge Codex native hooks into OpenClaw

Bridge Codex-native tool events into the OpenClaw plugin hook surface, including native permission approval routing, bounded relay payloads, approval spam protection, and docs/changelog updates.\n\nCo-authored-by: pashpashpash <nik@vault77.ai>
This commit is contained in:
pashpashpash
2026-04-24 08:48:26 -07:00
committed by GitHub
parent 3a64aa49a9
commit 7a958d920c
25 changed files with 2379 additions and 10 deletions

View File

@@ -0,0 +1,118 @@
import type { NativeHookRelayRegistrationHandle } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it } from "vitest";
import {
buildCodexNativeHookRelayConfig,
buildCodexNativeHookRelayDisabledConfig,
} from "./native-hook-relay.js";
describe("Codex native hook relay config", () => {
it("builds deterministic Codex config overrides with command hooks", () => {
const config = buildCodexNativeHookRelayConfig({
relay: createRelay(),
hookTimeoutSec: 7,
});
expect(config).toEqual({
"features.codex_hooks": true,
"hooks.PreToolUse": [
{
matcher: null,
hooks: [
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event pre_tool_use",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
],
"hooks.PostToolUse": [
{
matcher: null,
hooks: [
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event post_tool_use",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
],
"hooks.PermissionRequest": [
{
matcher: null,
hooks: [
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
],
});
expect(JSON.stringify(config)).not.toContain("timeoutSec");
expect(config).not.toHaveProperty("hooks.SessionStart");
expect(config).not.toHaveProperty("hooks.UserPromptSubmit");
expect(config).not.toHaveProperty("hooks.Stop");
});
it("includes only requested hook events", () => {
expect(
buildCodexNativeHookRelayConfig({
relay: createRelay(),
events: ["permission_request"],
}),
).toEqual({
"features.codex_hooks": true,
"hooks.PermissionRequest": [
{
matcher: null,
hooks: [
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
],
});
});
it("builds deterministic clearing config when the relay is disabled", () => {
expect(buildCodexNativeHookRelayDisabledConfig()).toEqual({
"features.codex_hooks": false,
"hooks.PreToolUse": [],
"hooks.PostToolUse": [],
"hooks.PermissionRequest": [],
});
});
});
function createRelay(): NativeHookRelayRegistrationHandle {
return {
relayId: "relay-1",
provider: "codex",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
allowedEvents: ["pre_tool_use", "post_tool_use", "permission_request"],
expiresAtMs: Date.now() + 1000,
commandForEvent: (event) =>
`openclaw hooks relay --provider codex --relay-id relay-1 --event ${event}`,
unregister: () => undefined,
};
}

View File

@@ -0,0 +1,61 @@
import type {
NativeHookRelayEvent,
NativeHookRelayRegistrationHandle,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { JsonObject, JsonValue } from "./protocol.js";
export const CODEX_NATIVE_HOOK_RELAY_EVENTS = [
"pre_tool_use",
"post_tool_use",
"permission_request",
] as const satisfies readonly NativeHookRelayEvent[];
type CodexHookEventName = "PreToolUse" | "PostToolUse" | "PermissionRequest";
const CODEX_HOOK_EVENT_BY_NATIVE_EVENT: Record<NativeHookRelayEvent, CodexHookEventName> = {
pre_tool_use: "PreToolUse",
post_tool_use: "PostToolUse",
permission_request: "PermissionRequest",
};
export function buildCodexNativeHookRelayConfig(params: {
relay: NativeHookRelayRegistrationHandle;
events?: readonly NativeHookRelayEvent[];
hookTimeoutSec?: number;
}): JsonObject {
const events = params.events?.length ? params.events : CODEX_NATIVE_HOOK_RELAY_EVENTS;
const config: JsonObject = {
"features.codex_hooks": true,
};
for (const event of events) {
const codexEvent = CODEX_HOOK_EVENT_BY_NATIVE_EVENT[event];
config[`hooks.${codexEvent}`] = [
{
matcher: null,
hooks: [
{
type: "command",
command: params.relay.commandForEvent(event),
timeout: normalizeHookTimeoutSec(params.hookTimeoutSec),
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
] satisfies JsonValue;
}
return config;
}
export function buildCodexNativeHookRelayDisabledConfig(): JsonObject {
return {
"features.codex_hooks": false,
"hooks.PreToolUse": [],
"hooks.PostToolUse": [],
"hooks.PermissionRequest": [],
};
}
function normalizeHookTimeoutSec(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.ceil(value) : 5;
}

View File

@@ -8,6 +8,7 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __testing as nativeHookRelayTesting } from "../../../../src/agents/harness/native-hook-relay.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
@@ -188,7 +189,7 @@ function expectResumeRequest(
expect.arrayContaining([
{
method: "thread/resume",
params,
params: expect.objectContaining(params),
},
]),
);
@@ -258,6 +259,21 @@ function createMessageDynamicTool(
};
}
function extractRelayIdFromThreadRequest(params: unknown): string {
const command = (
params as {
config?: {
"hooks.PreToolUse"?: Array<{ hooks?: Array<{ command?: string }> }>;
};
}
).config?.["hooks.PreToolUse"]?.[0]?.hooks?.[0]?.command;
const match = command?.match(/--relay-id ([^ ]+)/);
if (!match?.[1]) {
throw new Error(`relay id missing from command: ${command}`);
}
return match[1];
}
describe("runCodexAppServerAttempt", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-"));
@@ -265,6 +281,7 @@ describe("runCodexAppServerAttempt", () => {
afterEach(async () => {
__testing.resetCodexAppServerClientFactoryForTests();
nativeHookRelayTesting.clearNativeHookRelaysForTests();
resetGlobalHookRunner();
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
@@ -406,6 +423,111 @@ describe("runCodexAppServerAttempt", () => {
);
});
it("registers native hook relay config for an enabled Codex turn and cleans it up", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
nativeHookRelay: {
enabled: true,
events: ["pre_tool_use"],
gatewayTimeoutMs: 4321,
hookTimeoutSec: 9,
},
});
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const startRequest = harness.requests.find((request) => request.method === "thread/start");
expect(startRequest?.params).toEqual(
expect.objectContaining({
config: expect.objectContaining({
"features.codex_hooks": true,
"hooks.PreToolUse": [
expect.objectContaining({
hooks: [
expect.objectContaining({
type: "command",
timeout: 9,
command: expect.stringContaining("--event pre_tool_use --timeout 4321"),
}),
],
}),
],
}),
}),
);
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("sends clearing Codex native hook config when the relay is disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
nativeHookRelay: { enabled: false },
});
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const startRequest = harness.requests.find((request) => request.method === "thread/start");
expect(startRequest?.params).toEqual(
expect.objectContaining({
config: {
"features.codex_hooks": false,
"hooks.PreToolUse": [],
"hooks.PostToolUse": [],
"hooks.PermissionRequest": [],
},
}),
);
});
it("cleans up native hook relay state when turn/start fails", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw new Error("turn start exploded");
}
return undefined;
});
await expect(
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
nativeHookRelay: { enabled: true },
}),
).rejects.toThrow("turn start exploded");
const startRequest = harness.requests.find((request) => request.method === "thread/start");
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("cleans up native hook relay state when the Codex turn aborts", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
nativeHookRelay: { enabled: true },
});
await harness.waitForMethod("turn/start");
const startRequest = harness.requests.find((request) => request.method === "thread/start");
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
expect(abortAgentHarnessRun("session-1")).toBe(true);
const result = await run;
expect(result.aborted).toBe(true);
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("fires agent_end with failure metadata when the codex turn fails", async () => {
const agentEnd = vi.fn();
initializeGlobalHookRunner(
@@ -1170,6 +1292,58 @@ describe("runCodexAppServerAttempt", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("passes native hook relay config on thread start and resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-existing");
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const config = {
"features.codex_hooks": true,
"hooks.PreToolUse": [],
};
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config,
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config,
});
expect(request.mock.calls).toEqual([
[
"thread/start",
expect.objectContaining({
config,
}),
],
[
"thread/resume",
expect.objectContaining({
config,
}),
],
]);
});
it("starts a new Codex thread when dynamic tool schemas change", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -25,10 +25,13 @@ import {
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
runHarnessContextEngineMaintenance,
registerNativeHookRelay,
setActiveEmbeddedRun,
supportsModelTools,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import {
@@ -41,6 +44,11 @@ import { projectContextEngineAssemblyForCodex } from "./context-engine-projectio
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
import { CodexAppServerEventProjector } from "./event-projector.js";
import {
buildCodexNativeHookRelayDisabledConfig,
buildCodexNativeHookRelayConfig,
CODEX_NATIVE_HOOK_RELAY_EVENTS,
} from "./native-hook-relay.js";
import {
assertCodexTurnStartResponse,
readCodexDynamicToolCallParams,
@@ -90,7 +98,17 @@ function emitCodexAppServerEvent(
export async function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: { pluginConfig?: unknown; startupTimeoutFloorMs?: number } = {},
options: {
pluginConfig?: unknown;
startupTimeoutFloorMs?: number;
nativeHookRelay?: {
enabled?: boolean;
events?: readonly NativeHookRelayEvent[];
ttlMs?: number;
gatewayTimeoutMs?: number;
hookTimeoutSec?: number;
};
} = {},
): Promise<EmbeddedRunAttemptResult> {
const attemptStartedAt = Date.now();
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
@@ -241,11 +259,29 @@ export async function runCodexAppServerAttempt(
let client: CodexAppServerClient;
let thread: CodexAppServerThreadBinding;
let trajectoryEndRecorded = false;
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
try {
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "startup" },
});
nativeHookRelay = createCodexNativeHookRelay({
options: options.nativeHookRelay,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
signal: runAbortController.signal,
});
const nativeHookRelayConfig = nativeHookRelay
? buildCodexNativeHookRelayConfig({
relay: nativeHookRelay,
events: options.nativeHookRelay?.events,
hookTimeoutSec: options.nativeHookRelay?.hookTimeoutSec,
})
: options.nativeHookRelay?.enabled === false
? buildCodexNativeHookRelayDisabledConfig()
: undefined;
({ client, thread } = await withCodexStartupTimeout({
timeoutMs: params.timeoutMs,
timeoutFloorMs: options.startupTimeoutFloorMs,
@@ -259,6 +295,7 @@ export async function runCodexAppServerAttempt(
dynamicTools: toolBridge.specs,
appServer,
developerInstructions: promptBuild.developerInstructions,
config: nativeHookRelayConfig,
});
return { client: startupClient, thread: startupThread };
},
@@ -268,6 +305,7 @@ export async function runCodexAppServerAttempt(
data: { phase: "thread_ready", threadId: thread.threadId },
});
} catch (error) {
nativeHookRelay?.unregister();
clearSharedCodexAppServerClient();
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
throw error;
@@ -464,6 +502,7 @@ export async function runCodexAppServerAttempt(
});
notificationCleanup();
requestCleanup();
nativeHookRelay?.unregister();
await trajectoryRecorder?.flush();
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
throw error;
@@ -641,12 +680,46 @@ export async function runCodexAppServerAttempt(
clearTimeout(timeout);
notificationCleanup();
requestCleanup();
nativeHookRelay?.unregister();
runAbortController.signal.removeEventListener("abort", abortListener);
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
clearActiveEmbeddedRun(params.sessionId, handle, params.sessionKey);
}
}
function createCodexNativeHookRelay(params: {
options:
| {
enabled?: boolean;
events?: readonly NativeHookRelayEvent[];
ttlMs?: number;
gatewayTimeoutMs?: number;
}
| undefined;
agentId: string | undefined;
sessionId: string;
sessionKey: string | undefined;
runId: string;
signal: AbortSignal;
}): NativeHookRelayRegistrationHandle | undefined {
if (params.options?.enabled === false) {
return undefined;
}
return registerNativeHookRelay({
provider: "codex",
...(params.agentId ? { agentId: params.agentId } : {}),
sessionId: params.sessionId,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
runId: params.runId,
allowedEvents: params.options?.events ?? CODEX_NATIVE_HOOK_RELAY_EVENTS,
ttlMs: params.options?.ttlMs,
signal: params.signal,
command: {
timeoutMs: params.options?.gatewayTimeoutMs,
},
});
}
function interruptCodexTurnBestEffort(
client: CodexAppServerClient,
params: {

View File

@@ -33,6 +33,7 @@ export async function startOrResumeThread(params: {
dynamicTools: CodexDynamicToolSpec[];
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
}): Promise<CodexAppServerThreadBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const binding = await readCodexAppServerBinding(params.params.sessionFile);
@@ -59,6 +60,7 @@ export async function startOrResumeThread(params: {
threadId: binding.threadId,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config: params.config,
}),
),
);
@@ -102,6 +104,7 @@ export async function startOrResumeThread(params: {
sandbox: params.appServer.sandbox,
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
serviceName: "OpenClaw",
...(params.config ? { config: params.config } : {}),
developerInstructions:
params.developerInstructions ?? buildDeveloperInstructions(params.params),
dynamicTools: params.dynamicTools,
@@ -139,6 +142,7 @@ export function buildThreadResumeParams(
threadId: string;
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
},
): CodexThreadResumeParams {
const modelProvider = resolveCodexAppServerModelProvider(params.provider);
@@ -150,6 +154,7 @@ export function buildThreadResumeParams(
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
...(options.config ? { config: options.config } : {}),
developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params),
persistExtendedHistory: true,
};