Support MCP hooks in the Codex harness (#71707)

* codex harness mcp hook parity

* tighten codex hook parity floor

* prove security-style mcp hook blocking

* bound native hook relay key handling

* clarify permission relay defers to provider

* harden native hook relay approvals

* fix(agents): bound native hook relay JSON work budget

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
pashpashpash
2026-04-25 13:35:47 -07:00
committed by GitHub
parent e2fd3dcee9
commit 34fb96622e
19 changed files with 717 additions and 86 deletions

View File

@@ -35,7 +35,7 @@ function threadStartResult() {
status: { type: "idle" },
path: null,
cwd: "/tmp/openclaw-agent",
cliVersion: "0.118.0",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,

View File

@@ -43,7 +43,7 @@ function threadStartResult(threadId = "thread-auth-contract") {
status: { type: "idle" },
path: null,
cwd: "",
cliVersion: "0.118.0",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,

View File

@@ -118,7 +118,7 @@ describe("CodexAppServerClient", () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
result: { userAgent: "openclaw/0.125.0 (macOS; test)" },
});
await expect(initializing).resolves.toBeUndefined();
@@ -140,15 +140,63 @@ describe("CodexAppServerClient", () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.117.9 (macOS; test)" },
result: { userAgent: "openclaw/0.124.9 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.117.9`,
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.124.9`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks same-version Codex app-server prereleases below the stable floor", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.125.0-alpha.2 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.125.0-alpha.2`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks same-version Codex app-server build metadata below the stable floor", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.125.0+alpha.2 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.125.0+alpha.2`,
);
expect(harness.writes).toHaveLength(1);
});
it("accepts newer Codex app-server prereleases", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.126.0-alpha.1 (macOS; test)" },
});
await expect(initializing).resolves.toBeUndefined();
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({ method: "initialized" });
});
it("accepts newer Codex app-server builds", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.126.0+custom (macOS; test)" },
});
await expect(initializing).resolves.toBeUndefined();
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({ method: "initialized" });
});
it("blocks app-server initialize responses without a version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({ id: outbound.id, result: {} });
@@ -217,14 +265,14 @@ describe("CodexAppServerClient", () => {
});
it("reads the Codex version from the app-server user agent", () => {
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("codex_cli_rs/0.118.1-dev (linux; test)")).toBe(
"0.118.1-dev",
expect(readCodexVersionFromUserAgent("Codex Desktop/0.125.0")).toBe("0.125.0");
expect(readCodexVersionFromUserAgent("openclaw/0.125.0 (macOS; test)")).toBe("0.125.0");
expect(readCodexVersionFromUserAgent("codex_cli_rs/0.125.0-dev (linux; test)")).toBe(
"0.125.0-dev",
);
expect(readCodexVersionFromUserAgent("Codex Desktop/not-a-version")).toBeUndefined();
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118")).toBeUndefined();
expect(readCodexVersionFromUserAgent("openclaw/0.118.0abc")).toBeUndefined();
expect(readCodexVersionFromUserAgent("Codex Desktop/0.124")).toBeUndefined();
expect(readCodexVersionFromUserAgent("openclaw/0.125.0abc")).toBeUndefined();
expect(readCodexVersionFromUserAgent("missing-version")).toBeUndefined();
});

View File

@@ -18,7 +18,7 @@ import { createStdioTransport } from "./transport-stdio.js";
import { createWebSocketTransport } from "./transport-websocket.js";
import { closeCodexAppServerTransport, type CodexAppServerTransport } from "./transport.js";
export const MIN_CODEX_APP_SERVER_VERSION = "0.118.0";
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
type PendingRequest = {
@@ -413,8 +413,10 @@ export function readCodexVersionFromUserAgent(userAgent: string | undefined): st
}
function compareVersions(left: string, right: string): number {
const leftParts = numericVersionParts(left);
const rightParts = numericVersionParts(right);
const leftVersion = parseVersionForComparison(left);
const rightVersion = parseVersionForComparison(right);
const leftParts = leftVersion.parts;
const rightParts = rightVersion.parts;
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
@@ -422,17 +424,30 @@ function compareVersions(left: string, right: string): number {
return leftPart < rightPart ? -1 : 1;
}
}
if (leftVersion.unstableSuffix && !rightVersion.unstableSuffix) {
return -1;
}
if (!leftVersion.unstableSuffix && rightVersion.unstableSuffix) {
return 1;
}
return 0;
}
function numericVersionParts(version: string): number[] {
// Pre-release/build tags do not affect our minimum gate; 0.118.0-dev should
// satisfy the same protocol floor as 0.118.0.
return version
.split(/[+-]/, 1)[0]
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
function parseVersionForComparison(version: string): { parts: number[]; unstableSuffix: boolean } {
// Same-version prerelease or build-suffixed versions do not satisfy a stable
// protocol floor because important app-server contract changes can land
// between alpha cuts and custom builds.
const hasBuildMetadata = version.includes("+");
const [withoutBuild = version] = version.split("+", 1);
const prereleaseIndex = withoutBuild.indexOf("-");
const numeric = prereleaseIndex >= 0 ? withoutBuild.slice(0, prereleaseIndex) : withoutBuild;
return {
parts: numeric
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0)),
unstableSuffix: prereleaseIndex >= 0 || hasBuildMetadata,
};
}
function redactCodexAppServerLinePreview(value: string): string {

View File

@@ -50,7 +50,7 @@ describe("listCodexAppServerModels", () => {
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
result: { userAgent: "openclaw/0.125.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string };
@@ -112,7 +112,7 @@ describe("listCodexAppServerModels", () => {
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
result: { userAgent: "openclaw/0.125.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const firstList = JSON.parse(harness.writes[2] ?? "{}") as {
@@ -193,7 +193,7 @@ describe("listCodexAppServerModels", () => {
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
result: { userAgent: "openclaw/0.125.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const firstList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };

View File

@@ -92,6 +92,16 @@ describe("Codex native hook relay config", () => {
});
});
it("leaves matchers open so Codex MCP tool names reach the relay", () => {
const config = buildCodexNativeHookRelayConfig({
relay: createRelay(),
events: ["pre_tool_use", "post_tool_use"],
});
expect(config["hooks.PreToolUse"]).toEqual([expect.objectContaining({ matcher: null })]);
expect(config["hooks.PostToolUse"]).toEqual([expect.objectContaining({ matcher: null })]);
});
it("builds deterministic clearing config when the relay is disabled", () => {
expect(buildCodexNativeHookRelayDisabledConfig()).toEqual({
"features.codex_hooks": false,

View File

@@ -73,7 +73,7 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir || "/tmp/openclaw-codex-test",
cliVersion: "0.118.0",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,

View File

@@ -65,7 +65,7 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir || "/tmp/openclaw-codex-test",
cliVersion: "0.118.0",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,

View File

@@ -60,7 +60,7 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.118.0",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,

View File

@@ -87,7 +87,7 @@ describe("shared Codex app-server client", () => {
expect(first.process.kill).toHaveBeenCalledTimes(1);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.118.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -112,7 +112,7 @@ describe("shared Codex app-server client", () => {
timeoutMs: 1000,
authProfileId: "openai-codex:work",
});
await sendInitializeResult(harness, "openclaw/0.118.0 (macOS; test)");
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
@@ -147,7 +147,7 @@ describe("shared Codex app-server client", () => {
headers: {},
},
});
await sendInitializeResult(first, "openclaw/0.118.0 (macOS; test)");
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
@@ -162,7 +162,7 @@ describe("shared Codex app-server client", () => {
headers: {},
},
});
await sendInitializeResult(second, "openclaw/0.118.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -206,7 +206,7 @@ describe("shared Codex app-server client", () => {
await expect(firstFailure).resolves.toBeInstanceOf(Error);
await sendInitializeResult(second, "openclaw/0.118.0 (macOS; test)");
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
@@ -222,7 +222,7 @@ describe("shared Codex app-server client", () => {
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
if (message.method === "initialize") {
socket.send(
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.118.0" } }),
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.125.0" } }),
);
return;
}

View File

@@ -33,7 +33,7 @@ describe("Codex app-server websocket transport", () => {
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
if (message.method === "initialize") {
socket.send(
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.118.0" } }),
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.125.0" } }),
);
return;
}