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