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

@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
- Providers/Local CLI TTS: add a bundled local command speech provider with file/stdout input, voice-note Opus conversion, and telephony PCM output. (#56239) Thanks @solar2ain.
- Android/Talk Mode: expose Talk Mode in the Voice tab with runtime-owned voice capture modes and microphone foreground-service escalation. Thanks @alex-latitude.
- Providers/LiteLLM: register `litellm` as an image-generation provider so `image_generate model=litellm/...` calls and `agents.defaults.imageGenerationModel.fallbacks` entries resolve through the LiteLLM proxy. Thanks @zqchris.
- Codex harness: require Codex app-server `0.125.0` or newer and cover native MCP `PreToolUse`, `PostToolUse`, and `PermissionRequest` payloads through the OpenClaw hook relay.
### Fixes

View File

@@ -103,7 +103,8 @@ Codex after changing config.
## Requirements
- OpenClaw with the bundled `codex` plugin available.
- Codex app-server `0.118.0` or newer.
- Codex app-server `0.125.0` or newer. Native MCP hook payloads landed in Codex
`0.124.0`; OpenClaw uses `0.125.0` as the tested support floor.
- Codex auth available to the app-server process.
The plugin blocks older or unversioned app-server handshakes. That keeps
@@ -551,7 +552,7 @@ normal turns. On the next message, OpenClaw resumes that Codex thread, passes th
currently selected OpenClaw model into app-server, and keeps extended history
enabled.
The command surface requires Codex app-server `0.118.0` or newer. Individual
The command surface requires Codex app-server `0.125.0` or newer. Individual
control methods are reported as `unsupported by this Codex app-server` if a
future or custom app-server does not expose that JSON-RPC method.
@@ -597,31 +598,30 @@ around that boundary.
Supported in Codex runtime v1:
| Surface | Support | Why |
| --------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
| Native shell and patch block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for the committed native tool surfaces. Blocking is supported; argument rewriting is not. |
| Native permission policy | Supported through the native hook relay | Codex `PermissionRequest` can be routed through OpenClaw policy where the runtime exposes it. |
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
| Surface | Support | Why |
| --------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
| Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. |
| Native permission policy | Supported through the native hook relay | Codex `PermissionRequest` can be routed through OpenClaw policy where the runtime exposes it. If OpenClaw returns no decision, Codex continues through its normal guardian or user approval path. |
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
Not supported in Codex runtime v1:
| Surface | V1 boundary | Future path |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Native tool argument mutation | Codex native pre-tool hooks can block, but OpenClaw does not rewrite Codex-native tool arguments. | Requires Codex hook/schema support for replacement tool input. |
| Editable Codex-native transcript history | Codex owns canonical native thread history. OpenClaw owns a mirror and can project future context, but should not mutate unsupported internals. | Add explicit Codex app-server APIs if native thread surgery is needed. |
| `tool_result_persist` for Codex-native tool records | That hook transforms OpenClaw-owned transcript writes, not Codex-native tool records. | Could mirror transformed records, but canonical rewrite needs Codex support. |
| Rich native compaction metadata | OpenClaw observes compaction start and completion, but does not receive a stable kept/dropped list, token delta, or summary payload. | Needs richer Codex compaction events. |
| Compaction intervention | Current OpenClaw compaction hooks are notification-level in Codex mode. | Add Codex pre/post compaction hooks if plugins need to veto or rewrite native compaction. |
| Stop or final-answer gating | Codex has native stop hooks, but OpenClaw does not expose final-answer gating as a v1 plugin contract. | Future opt-in primitive with loop and timeout safeguards. |
| Native MCP hook parity as a committed v1 surface | The relay is generic, but OpenClaw has not version-gated and tested native MCP pre/post hook behavior end to end. | Add OpenClaw MCP relay tests and docs once the supported app-server protocol floor covers those payloads. |
| Byte-for-byte model API request capture | OpenClaw can capture app-server requests and notifications, but Codex core builds the final OpenAI API request internally. | Needs a Codex model-request tracing event or debug API. |
| Surface | V1 boundary | Future path |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Native tool argument mutation | Codex native pre-tool hooks can block, but OpenClaw does not rewrite Codex-native tool arguments. | Requires Codex hook/schema support for replacement tool input. |
| Editable Codex-native transcript history | Codex owns canonical native thread history. OpenClaw owns a mirror and can project future context, but should not mutate unsupported internals. | Add explicit Codex app-server APIs if native thread surgery is needed. |
| `tool_result_persist` for Codex-native tool records | That hook transforms OpenClaw-owned transcript writes, not Codex-native tool records. | Could mirror transformed records, but canonical rewrite needs Codex support. |
| Rich native compaction metadata | OpenClaw observes compaction start and completion, but does not receive a stable kept/dropped list, token delta, or summary payload. | Needs richer Codex compaction events. |
| Compaction intervention | Current OpenClaw compaction hooks are notification-level in Codex mode. | Add Codex pre/post compaction hooks if plugins need to veto or rewrite native compaction. |
| Stop or final-answer gating | Codex has native stop hooks, but OpenClaw does not expose final-answer gating as a v1 plugin contract. | Future opt-in primitive with loop and timeout safeguards. |
| Byte-for-byte model API request capture | OpenClaw can capture app-server requests and notifications, but Codex core builds the final OpenAI API request internally. | Needs a Codex model-request tracing event or debug API. |
## Tools, media, and compaction
@@ -632,9 +632,15 @@ harness. Text, images, video, music, TTS, approvals, and messaging-tool output
continue through the normal OpenClaw delivery path.
The native hook relay is intentionally generic, but the v1 support contract is
limited to the Codex-native tool and permission paths that OpenClaw tests. Do not
assume every future Codex hook event is an OpenClaw plugin surface until the
runtime contract names it.
limited to the Codex-native tool and permission paths that OpenClaw tests. In
the Codex runtime, that includes shell, patch, and MCP `PreToolUse`,
`PostToolUse`, and `PermissionRequest` payloads. Do not assume every future
Codex hook event is an OpenClaw plugin surface until the runtime contract names
it.
For `PermissionRequest`, OpenClaw only returns explicit allow or deny decisions
when policy decides. A no-decision result is not an allow. Codex treats it as no
hook decision and falls through to its own guardian or user approval path.
Codex MCP tool approval elicitations are routed through OpenClaw's plugin
approval flow when Codex marks `_meta.codex_approval_kind` as
@@ -677,7 +683,9 @@ explicitly set `embeddedHarness.fallback: "pi"`. Once Codex app-server is
selected, its failures surface directly without extra fallback config.
**The app-server is rejected:** upgrade Codex so the app-server handshake
reports version `0.118.0` or newer.
reports version `0.125.0` or newer. Same-version prereleases or build-suffixed
versions such as `0.125.0-alpha.2` or `0.125.0+custom` are rejected because the
stable `0.125.0` protocol floor is what OpenClaw tests.
**Model discovery is slow:** lower `plugins.entries.codex.config.discovery.timeoutMs`
or disable discovery.

View File

@@ -142,9 +142,11 @@ Codex provider and harness for compatibility.
For operator setup, model prefix examples, and Codex-only configs, see
[Codex Harness](/plugins/codex-harness).
OpenClaw requires Codex app-server `0.118.0` or newer. The Codex plugin checks
OpenClaw requires Codex app-server `0.125.0` or newer. The Codex plugin checks
the app-server initialize handshake and blocks older or unversioned servers so
OpenClaw only runs against the protocol surface it has been tested with.
OpenClaw only runs against the protocol surface it has been tested with. The
`0.125.0` floor includes the native MCP hook payload support that landed in
Codex `0.124.0`, while pinning OpenClaw to the newer tested stable line.
### Tool-result middleware

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

View File

@@ -64,7 +64,7 @@ function createBackendEntry(params: {
params.id === "claude-cli"
? "@anthropic-ai/claude-code"
: params.id === "codex-cli"
? "@openai/codex@0.124.0"
? "@openai/codex@0.125.0"
: params.id === "google-gemini-cli"
? "@google/gemini-cli"
: undefined,
@@ -448,7 +448,7 @@ describe("resolveCliBackendLiveTest", () => {
defaultModelRef: "codex-cli/gpt-5.5",
defaultImageProbe: true,
defaultMcpProbe: true,
dockerNpmPackage: "@openai/codex@0.124.0",
dockerNpmPackage: "@openai/codex@0.125.0",
dockerBinaryName: "codex",
});
});

View File

@@ -90,6 +90,34 @@ describe("native hook relay registry", () => {
]);
});
it("retains bounded payload snapshots in invocation history", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["post_tool_use"],
});
await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "post_tool_use",
rawPayload: {
hook_event_name: "PostToolUse",
tool_name: "mcp__filesystem__read_file",
tool_use_id: "large-payload-call",
tool_input: { path: "/repo/large.txt" },
tool_response: "x".repeat(50_000),
},
});
const [recorded] = __testing.getNativeHookRelayInvocationsForTests();
expect(JSON.stringify(recorded?.rawPayload).length).toBeLessThan(25_000);
expect(recorded?.rawPayload).toMatchObject({
tool_response: expect.stringContaining("[truncated]"),
});
});
it("removes retained invocations when a relay is unregistered", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
@@ -204,6 +232,99 @@ describe("native hook relay registry", () => {
).rejects.toThrow("JSON-compatible");
});
it("rejects broad object payloads before reading children beyond the JSON node budget", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["post_tool_use"],
});
const rawPayload: Record<string, unknown> = {};
for (let index = 0; index < 19_999; index += 1) {
rawPayload[`k${index}`] = index;
}
let overBudgetValueRead = false;
Object.defineProperty(rawPayload, "overBudget", {
enumerable: true,
get() {
overBudgetValueRead = true;
return "should not be read";
},
});
await expect(
invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "post_tool_use",
rawPayload,
}),
).rejects.toThrow("JSON-compatible");
expect(overBudgetValueRead).toBe(false);
});
it("rejects payloads beyond the relay string budget", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["post_tool_use"],
});
await expect(
invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "post_tool_use",
rawPayload: {
tool_response: "x".repeat(1_000_001),
},
}),
).rejects.toThrow("JSON-compatible");
});
it("rejects payloads beyond the relay aggregate string budget", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["post_tool_use"],
});
await expect(
invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "post_tool_use",
rawPayload: Array.from({ length: 5 }, () => "x".repeat(900_000)),
}),
).rejects.toThrow("JSON-compatible");
});
it("rejects payloads beyond the relay object key budget", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["permission_request"],
});
await expect(
invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
tool_name: "mcp__shell__run_command",
tool_input: {
["x".repeat(1_000_001)]: "value",
},
},
}),
).rejects.toThrow("JSON-compatible");
});
it("rejects expired relay ids", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-24T12:00:00Z"));
@@ -377,6 +498,217 @@ describe("native hook relay registry", () => {
);
});
it("maps Codex MCP PreToolUse to OpenClaw before_tool_call and can block", async () => {
const beforeToolCall = vi.fn(async () => ({
block: true,
blockReason: "MCP writes require review",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const relay = registerNativeHookRelay({
provider: "codex",
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
});
const response = await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "pre_tool_use",
rawPayload: {
hook_event_name: "PreToolUse",
cwd: "/repo",
model: "gpt-5.4",
tool_name: "mcp__memory__create_entities",
tool_use_id: "mcp-call-1",
tool_input: {
entities: [{ name: "OpenClaw", entityType: "project", observations: ["test"] }],
},
},
});
expect(JSON.parse(response.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "MCP writes require review",
},
});
expect(beforeToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "mcp__memory__create_entities",
params: {
entities: [{ name: "OpenClaw", entityType: "project", observations: ["test"] }],
},
runId: "run-1",
toolCallId: "mcp-call-1",
}),
expect.objectContaining({
toolName: "mcp__memory__create_entities",
toolCallId: "mcp-call-1",
}),
);
});
it("lets security-style plugins block native MCP calls by scanning tool params", async () => {
const beforeToolCall = vi.fn(async (event: unknown) => {
const hookEvent = event as { params?: unknown; toolName?: string };
const serializedParams = JSON.stringify(hookEvent.params ?? {});
if (hookEvent.toolName?.startsWith("mcp__") && serializedParams.includes("rm -rf")) {
return {
block: true,
blockReason: "Blocked by security policy: destructive MCP command detected",
};
}
return undefined;
});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const relay = registerNativeHookRelay({
provider: "codex",
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
});
const response = await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "pre_tool_use",
rawPayload: {
hook_event_name: "PreToolUse",
tool_name: "mcp__shell__run_command",
tool_use_id: "mcp-call-security",
tool_input: {
command: "rm -rf /tmp/openclaw-important-state",
},
},
});
expect(JSON.parse(response.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Blocked by security policy: destructive MCP command detected",
},
});
expect(beforeToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "mcp__shell__run_command",
params: {
command: "rm -rf /tmp/openclaw-important-state",
},
toolCallId: "mcp-call-security",
}),
expect.objectContaining({
toolName: "mcp__shell__run_command",
toolCallId: "mcp-call-security",
}),
);
});
it("maps Codex MCP PostToolUse to OpenClaw after_tool_call observation", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const relay = registerNativeHookRelay({
provider: "codex",
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
});
const response = await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "post_tool_use",
rawPayload: {
hook_event_name: "PostToolUse",
tool_name: "mcp__filesystem__read_file",
tool_use_id: "mcp-call-2",
tool_input: { path: "/repo/package.json" },
tool_response: {
content: [{ type: "text", text: '{ "name": "openclaw" }' }],
structuredContent: { bytes: 22 },
},
},
});
expect(response).toEqual({ stdout: "", stderr: "", exitCode: 0 });
expect(afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "mcp__filesystem__read_file",
params: { path: "/repo/package.json" },
runId: "run-1",
toolCallId: "mcp-call-2",
result: {
content: [{ type: "text", text: '{ "name": "openclaw" }' }],
structuredContent: { bytes: 22 },
},
}),
expect.objectContaining({
toolName: "mcp__filesystem__read_file",
toolCallId: "mcp-call-2",
}),
);
});
it("routes Codex MCP PermissionRequest payloads through OpenClaw approval policy", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
});
const approvalRequester = vi.fn(async () => "allow" as const);
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
const response = await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo",
model: "gpt-5.4",
tool_name: "mcp__github__create_issue",
tool_use_id: "mcp-call-3",
tool_input: {
owner: "openclaw",
repo: "openclaw",
title: "Test issue",
},
},
});
expect(JSON.parse(response.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: { behavior: "allow" },
},
});
expect(approvalRequester).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
toolName: "mcp__github__create_issue",
toolCallId: "mcp-call-3",
toolInput: {
owner: "openclaw",
repo: "openclaw",
title: "Test issue",
},
}),
);
});
it("maps PermissionRequest approval allow and deny decisions to Codex hook output", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
@@ -518,6 +850,63 @@ describe("native hook relay registry", () => {
]);
});
it("does not reuse pending PermissionRequest approvals when a tool call id is reused with different input", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
});
let resolveDecision: ((decision: "allow") => void) | undefined;
const pendingDecision = new Promise<"allow">((resolve) => {
resolveDecision = resolve;
});
const approvalRequester = vi.fn(async (request: { toolInput?: Record<string, unknown> }) => {
return request.toolInput?.command === "git status" ? pendingDecision : "deny";
});
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
const first = invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
tool_name: "Bash",
tool_use_id: "reused-call-id",
tool_input: { command: "git status" },
},
});
const second = invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
tool_name: "Bash",
tool_use_id: "reused-call-id",
tool_input: { command: "rm -rf /tmp/openclaw-important-state" },
},
});
await Promise.resolve();
expect(approvalRequester).toHaveBeenCalledTimes(2);
const secondResponse = await second;
expect(JSON.parse(secondResponse.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: { behavior: "deny", message: "Denied by user" },
},
});
resolveDecision?.("allow");
const firstResponse = await first;
expect(JSON.parse(firstResponse.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: { behavior: "allow" },
},
});
});
it("defers PermissionRequest approvals after the per-relay approval budget is exhausted", async () => {
const relay = registerNativeHookRelay({
provider: "codex",

View File

@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import path from "node:path";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -98,7 +98,7 @@ type NativeHookRelayInvocationMetadata = Partial<
type NativeHookRelayProviderAdapter = {
normalizeMetadata: (rawPayload: JsonValue) => NativeHookRelayInvocationMetadata;
readToolInput: (rawPayload: JsonValue) => Record<string, unknown>;
readToolInput: (rawPayload: JsonValue) => Record<string, JsonValue>;
readToolResponse: (rawPayload: JsonValue) => unknown;
renderNoopResponse: (event: NativeHookRelayEvent) => NativeHookRelayProcessResponse;
renderPreToolUseBlockResponse: (reason: string) => NativeHookRelayProcessResponse;
@@ -114,6 +114,14 @@ const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200;
const MAX_NATIVE_HOOK_RELAY_JSON_DEPTH = 64;
const MAX_NATIVE_HOOK_RELAY_JSON_NODES = 20_000;
const MAX_NATIVE_HOOK_RELAY_STRING_LENGTH = 1_000_000;
const MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH = 4_000_000;
const MAX_NATIVE_HOOK_RELAY_HISTORY_STRING_LENGTH = 4_000;
const MAX_NATIVE_HOOK_RELAY_HISTORY_TOTAL_STRING_LENGTH = 20_000;
const MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS = 50;
const MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS = 50;
const MAX_PERMISSION_FALLBACK_KEYS = 200;
const MAX_PERMISSION_FALLBACK_KEY_CHARS = 240;
const MAX_APPROVAL_TITLE_LENGTH = 80;
const MAX_APPROVAL_DESCRIPTION_LENGTH = 700;
const MAX_PERMISSION_APPROVALS_PER_WINDOW = 12;
@@ -142,7 +150,7 @@ type NativeHookRelayPermissionApprovalRequest = {
toolCallId?: string;
cwd?: string;
model?: string;
toolInput: Record<string, unknown>;
toolInput: Record<string, JsonValue>;
signal?: AbortSignal;
};
@@ -321,7 +329,10 @@ export function renderNativeHookRelayUnavailableResponse(params: {
}
function recordNativeHookRelayInvocation(invocation: NativeHookRelayInvocation): void {
invocations.push(invocation);
invocations.push({
...invocation,
rawPayload: snapshotNativeHookRelayPayload(invocation.rawPayload),
});
if (invocations.length > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) {
invocations.splice(0, invocations.length - MAX_NATIVE_HOOK_RELAY_INVOCATIONS);
}
@@ -443,8 +454,12 @@ async function runNativeHookRelayPermissionRequest(params: {
return params.adapter.renderPermissionDecisionResponse("deny", "Denied by user");
}
} catch (error) {
log.warn(`native hook permission approval failed; deferring: ${String(error)}`);
log.warn(
`native hook permission approval failed; deferring to provider approval path: ${String(error)}`,
);
}
// A PermissionRequest no-op is not an allow decision. Codex interprets it as
// "no hook decision" and falls through to its normal guardian/user approval path.
return params.adapter.renderNoopResponse(params.invocation.event);
}
@@ -455,7 +470,7 @@ async function requestNativeHookRelayPermissionApprovalWithBudget(params: {
}): Promise<NativeHookRelayPermissionApprovalResult> {
if (!consumeNativeHookRelayPermissionBudget(params.registration.relayId)) {
log.warn(
`native hook permission approval rate limit exceeded; deferring: relay=${params.registration.relayId} run=${params.registration.runId}`,
`native hook permission approval rate limit exceeded; deferring to provider approval path: relay=${params.registration.relayId} run=${params.registration.runId}`,
);
return "defer";
}
@@ -473,7 +488,10 @@ function nativeHookRelayPermissionApprovalKey(params: {
return [
params.registration.relayId,
params.registration.runId,
params.request.toolCallId ?? permissionRequestFallbackKey(params.request),
params.request.toolCallId
? `call:${params.request.toolCallId}`
: permissionRequestFallbackKey(params.request),
permissionRequestContentFingerprint(params.request),
].join(":");
}
@@ -482,8 +500,72 @@ function permissionRequestFallbackKey(request: NativeHookRelayPermissionApproval
if (command) {
return `${request.toolName}:command:${truncateText(command, 240)}`;
}
const keys = Object.keys(request.toolInput).toSorted().join(",");
return `${request.toolName}:keys:${truncateText(keys, 240)}`;
return `${request.toolName}:keys:${permissionRequestToolInputKeyFingerprint(request.toolInput)}`;
}
function permissionRequestToolInputKeyFingerprint(toolInput: Record<string, unknown>): string {
let fingerprint = "";
let processed = 0;
for (const key of Object.keys(toolInput).toSorted()) {
if (processed >= MAX_PERMISSION_FALLBACK_KEYS) {
break;
}
const separator = fingerprint ? "," : "";
const remaining = MAX_PERMISSION_FALLBACK_KEY_CHARS - fingerprint.length - separator.length;
if (remaining <= 0) {
break;
}
fingerprint += `${separator}${key.slice(0, remaining)}`;
processed += 1;
}
return fingerprint || "none";
}
function permissionRequestContentFingerprint(
request: NativeHookRelayPermissionApprovalRequest,
): string {
const hash = createHash("sha256");
hash.update(request.toolName);
hash.update("\0");
updateJsonHash(hash, request.toolInput);
return hash.digest("hex");
}
function updateJsonHash(hash: ReturnType<typeof createHash>, value: JsonValue): void {
if (value === null) {
hash.update("null");
return;
}
if (typeof value === "string") {
hash.update("string:");
hash.update(JSON.stringify(value));
return;
}
if (typeof value === "number") {
hash.update(`number:${String(value)}`);
return;
}
if (typeof value === "boolean") {
hash.update(`boolean:${String(value)}`);
return;
}
if (Array.isArray(value)) {
hash.update("[");
for (const item of value) {
updateJsonHash(hash, item);
hash.update(",");
}
hash.update("]");
return;
}
hash.update("{");
for (const key of Object.keys(value).toSorted()) {
hash.update(JSON.stringify(key));
hash.update(":");
updateJsonHash(hash, value[key]);
hash.update(",");
}
hash.update("}");
}
function consumeNativeHookRelayPermissionBudget(relayId: string, now = Date.now()): boolean {
@@ -509,6 +591,55 @@ function removeNativeHookRelayPermissionState(relayId: string): void {
}
}
function snapshotNativeHookRelayPayload(payload: JsonValue): JsonValue {
return snapshotJsonValue(payload, {
remainingStringLength: MAX_NATIVE_HOOK_RELAY_HISTORY_TOTAL_STRING_LENGTH,
});
}
function snapshotJsonValue(value: JsonValue, state: { remainingStringLength: number }): JsonValue {
if (value === null || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
return snapshotString(value, state);
}
if (Array.isArray(value)) {
const items = value
.slice(0, MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS)
.map((item) => snapshotJsonValue(item, state));
if (value.length > MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS) {
items.push("[truncated]");
}
return items;
}
const snapshot: Record<string, JsonValue> = {};
const keys = Object.keys(value);
for (const key of keys.slice(0, MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS)) {
snapshot[snapshotString(key, state)] = snapshotJsonValue(value[key], state);
}
if (keys.length > MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS) {
snapshot["[truncated]"] = keys.length - MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS;
}
return snapshot;
}
function snapshotString(value: string, state: { remainingStringLength: number }): string {
if (state.remainingStringLength <= 0) {
return "[truncated]";
}
const limit = Math.min(
value.length,
MAX_NATIVE_HOOK_RELAY_HISTORY_STRING_LENGTH,
state.remainingStringLength,
);
state.remainingStringLength -= limit;
if (limit >= value.length) {
return value;
}
return `${value.slice(0, limit)}...[truncated]`;
}
function normalizeNativeHookInvocation(params: {
registration: NativeHookRelayRegistration;
event: NativeHookRelayEvent;
@@ -563,16 +694,16 @@ function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvoc
return metadata;
}
function readCodexToolInput(rawPayload: JsonValue): Record<string, unknown> {
function readCodexToolInput(rawPayload: JsonValue): Record<string, JsonValue> {
const payload = isJsonObject(rawPayload) ? rawPayload : {};
const toolInput = payload.tool_input;
if (isJsonObject(toolInput)) {
return toolInput;
return toolInput as Record<string, JsonValue>;
}
if (toolInput === undefined) {
return {};
}
return { value: toolInput };
return { value: toolInput as JsonValue };
}
function readCodexToolResponse(rawPayload: JsonValue): unknown {
@@ -802,6 +933,7 @@ function readOptionalString(value: unknown): string | undefined {
function isJsonValue(value: unknown): value is JsonValue {
const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
let nodes = 0;
let totalStringLength = 0;
while (stack.length) {
const current = stack.pop()!;
nodes += 1;
@@ -811,7 +943,17 @@ function isJsonValue(value: unknown): value is JsonValue {
if (current.depth > MAX_NATIVE_HOOK_RELAY_JSON_DEPTH) {
return false;
}
if (current.value === null || typeof current.value === "string") {
if (current.value === null) {
continue;
}
if (typeof current.value === "string") {
if (current.value.length > MAX_NATIVE_HOOK_RELAY_STRING_LENGTH) {
return false;
}
totalStringLength += current.value.length;
if (totalStringLength > MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH) {
return false;
}
continue;
}
if (typeof current.value === "number") {
@@ -824,8 +966,11 @@ function isJsonValue(value: unknown): value is JsonValue {
continue;
}
if (Array.isArray(current.value)) {
for (const item of current.value) {
stack.push({ value: item, depth: current.depth + 1 });
for (let index = 0; index < current.value.length; index += 1) {
if (nodes + stack.length + 1 > MAX_NATIVE_HOOK_RELAY_JSON_NODES) {
return false;
}
stack.push({ value: current.value[index], depth: current.depth + 1 });
}
continue;
}
@@ -833,8 +978,21 @@ function isJsonValue(value: unknown): value is JsonValue {
return false;
}
try {
for (const item of Object.values(current.value)) {
stack.push({ value: item, depth: current.depth + 1 });
for (const key in current.value) {
if (!Object.prototype.hasOwnProperty.call(current.value, key)) {
continue;
}
if (key.length > MAX_NATIVE_HOOK_RELAY_STRING_LENGTH) {
return false;
}
totalStringLength += key.length;
if (totalStringLength > MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH) {
return false;
}
if (nodes + stack.length + 1 > MAX_NATIVE_HOOK_RELAY_JSON_NODES) {
return false;
}
stack.push({ value: current.value[key], depth: current.depth + 1 });
}
} catch {
return false;

View File

@@ -171,7 +171,7 @@ describe("gateway codex harness live helpers", () => {
"`codex models` didnt return a plain list in this environment; it dropped into the interactive TUI instead.",
"",
"What I could confirm from that session is:",
"- Codex CLI version: `v0.118.0`",
"- Codex CLI version: `v0.125.0`",
"- Current selected model: `local-default-model`",
"- The UI indicates `/model` is the command to change models",
].join("\n");

View File

@@ -125,7 +125,7 @@ describe("plugin install ledger store", () => {
it("updates and removes records without mutating caller state", async () => {
const records: Record<string, PluginInstallRecord> = {
keep: {
source: "npm",
source: "npm" as const,
spec: "keep@1.0.0",
},
} satisfies Record<string, PluginInstallRecord>;