Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https://

## [Unreleased]

## [0.1.90] - 2026-08-12

### Fixed

- **Codex-mangled MCP tool names silently broke the design gate, APEX freshness credit, and doc-cache gate** (`src/runtime/mcp-tool-name.ts`, `src/runtime/normalize.ts`, `src/adapters/codex/index.ts`, `src/runtime/lifecycle/aipilot/{doc-cache-gate,cache-doc,dispatch-aipilot}.ts`) — Codex CLI globally rewrites every `-` to `_` across the qualified MCP tool identifier before it reaches any hook (`sanitize_responses_api_tool_name()`, openai/codex#14605 — Code Mode exposes tools as TypeScript identifiers, where `-` is illegal). This harness compared against the hyphenated form everywhere, so on Codex only: `SHOT_TOOLS.has()` always missed (design pipeline stuck at phase 1), `classifyExplore` never credited `research-expert` (APEX freshness gate), and the doc-cache gate never fired. New `canonicalizeMcpToolName(id, tool)` — a pure function guarded by `id !== "codex"` first, two closed null-prototype tables (`Object.create(null)`, server-scoped `TOOL_ALIASES` to avoid corrupting segments with a legitimate underscore) — wired into all 5 ingestion paths. Verified against a 660-line before/after characterization (15 harness ids × 44 tools): 12 lines changed, all `id: "codex"`, zero regression elsewhere. Confirmed live on Codex 0.147 — a real `design-expert` subagent session now reaches `screenshotsCount: 5` and completes the identity phase.

## [0.1.89] - 2026-08-03

### Security
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fusengine/harness",
"version": "0.1.89",
"version": "0.1.90",
"description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
"type": "module",
"module": "src/index.ts",
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { formatPrompt, type Prompt } from "../../prompt/types";
import { parseApplyPatch } from "./apply-patch";
import { commandToString } from "../../runtime/command-string";
import { canonicalizeCodexShellTool } from "../../runtime/codex-shell-tool";
import { canonicalizeMcpToolName } from "../../runtime/mcp-tool-name";
import { contextResponse, denyResponse, informResponse, type ClaudeHookInput } from "../claude";
import { isBypassPermissions } from "./permission-mode";

Expand Down Expand Up @@ -60,7 +61,7 @@ function resolvePrompt(input: ClaudeHookInput): Prompt | null {
// Code Mode-wrapped exec_command surfaces its raw function-tool name here
// instead of "Bash" (see codex-shell-tool.ts) — canonicalize so the SOLID/
// protected-path/bash-write guards recognize it like a native Bash call.
tool: canonicalizeCodexShellTool("codex", input.tool_name ?? "Write"),
tool: canonicalizeCodexShellTool("codex", canonicalizeMcpToolName("codex", input.tool_name ?? "Write")),
filePath: i?.file_path,
content: i?.content ?? i?.new_string,
command: commandToString(i?.command),
Expand Down
29 changes: 23 additions & 6 deletions src/runtime/lifecycle/aipilot/cache-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readJsonFile, writeJsonFile } from "../../../util/json-io";
import { readText, writeText, pathExists, sleep } from "../../../util/runtime-io";
import { hashText16, cacheDirFor } from "./cache-base";
import { transcriptFilePaths, projectRootFromPaths } from "./transcript";
import { canonicalizeMcpToolName } from "../../mcp-tool-name";
import type { CacheEntry, CacheIndex } from "./types";

const TOOL_PATTERN = /context7__query-docs|exa__get_code_context|exa__web_search/;
Expand All @@ -18,8 +19,13 @@ const MIN_TEXT_SIZE = 200;
const MAX_DOCS = 15;
const RETRY_DELAYS = [500, 1000, 2000];

/** Extract the longest assistant synthesis + queried library ids from a transcript. */
async function extractSynthesis(path: string): Promise<{ text: string; libraries: string[] }> {
/**
* Extract the longest assistant synthesis + queried library ids from a transcript.
* @param id - Harness adapter id, used to canonicalize `block.name` (Codex-mangled
* underscore tool names) before the {@link TOOL_PATTERN} match — see
* {@link cacheDocFromTranscript} for why this is a defensive, unconfirmed-case cover.
*/
async function extractSynthesis(path: string, id: string): Promise<{ text: string; libraries: string[] }> {
const lines = readText(path).split("\n").filter(Boolean);
const libraries: string[] = [];
let synthesis = "";
Expand All @@ -30,7 +36,7 @@ async function extractSynthesis(path: string): Promise<{ text: string; libraries
const contents = entry?.message?.content;
if (!Array.isArray(contents)) continue;
for (const block of contents) {
if (block.type === "tool_use" && TOOL_PATTERN.test(block.name ?? "")) {
if (block.type === "tool_use" && TOOL_PATTERN.test(canonicalizeMcpToolName(id, block.name ?? ""))) {
const lib = block.input?.libraryId ?? block.input?.query ?? "";
if (lib && !libraries.includes(lib)) libraries.push(lib);
}
Expand All @@ -48,19 +54,30 @@ async function extractSynthesis(path: string): Promise<{ text: string; libraries
* @param transcript - Path to the agent JSONL transcript.
* @param cwd - Fallback project root.
* @param home - Home dir (defaults to `~`).
* @param id - Harness adapter id (defaults to "claude-code"). On `"codex"`,
* every `tool_use.name` read from the transcript is canonicalized before the
* {@link TOOL_PATTERN} match, defensively covering the SAME Codex-mangled
* underscore form as `doc-cache-gate.ts`'s `docCacheGate`. UNLIKE that gate,
* this is NOT a confirmed-reproduced bug: the proof that Codex emits the
* underscore form (`sanitize_responses_api_tool_name()`, openai/codex#14605,
* `SCENARIO_DASH_TOOL` in #18385) is about the live PreToolUse HOOK payload,
* never observed here against transcript `tool_use.name` content. The
* canonicalization is a no-op when the transcript already carries the dash
* form (idempotence proven in `test/mcp-tool-name.test.ts`, test 2), so this
* costs nothing if the hypothesis turns out false.
*/
export async function cacheDocFromTranscript(transcript: string | undefined, cwd: string, home: string = homedir()): Promise<void> {
export async function cacheDocFromTranscript(transcript: string | undefined, cwd: string, home: string = homedir(), id: string = "claude-code"): Promise<void> {
if (!transcript || !pathExists(transcript)) return;
const allPaths = await transcriptFilePaths(transcript);
const projPath = projectRootFromPaths(allPaths) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
const cacheDir = cacheDirFor("doc", projPath, home);
const docsDir = join(cacheDir, "docs");

let result = await extractSynthesis(transcript);
let result = await extractSynthesis(transcript, id);
for (const delay of RETRY_DELAYS) {
if (result.text.length >= MIN_TEXT_SIZE && result.libraries.length > 0) break;
await sleep(delay);
result = await extractSynthesis(transcript);
result = await extractSynthesis(transcript, id);
}
const { text, libraries } = result;
if (text.length < MIN_TEXT_SIZE || libraries.length === 0) return;
Expand Down
10 changes: 5 additions & 5 deletions src/runtime/lifecycle/aipilot/dispatch-aipilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ async function onSubagentStart(payload: Record<string, unknown>, cwd: string, no
return combineContext(apex, lessons, typeSpecific);
}

/** SubagentStop routing: transcript-driven cache writers, then the universal SOLID check. */
async function onSubagentStop(payload: Record<string, unknown>, cwd: string, home: string): Promise<string> {
/** SubagentStop routing: transcript-driven cache writers, then the universal SOLID check. `id` selects the harness target (defaults to "claude-code"), threaded into `cacheDocFromTranscript`. */
async function onSubagentStop(payload: Record<string, unknown>, cwd: string, home: string, id: string): Promise<string> {
const agent = agentTypeOf(payload);
const transcript = transcriptOf(payload);
if (agent.includes("research-expert")) await cacheDocFromTranscript(transcript, cwd, home);
if (agent.includes("research-expert")) await cacheDocFromTranscript(transcript, cwd, home, id);
if (agent.includes("sniper")) {
await cacheSniperLessons(transcript, cwd, home);
await cacheTestResults(transcript, cwd, home);
Expand All @@ -87,10 +87,10 @@ async function onSubagentStop(payload: Record<string, unknown>, cwd: string, hom
*/
export async function dispatchAipilot(event: string, payload: Record<string, unknown>, cwd: string, now: number, home: string = homedir(), id: string = "claude-code"): Promise<string | null> {
if (event === "SubagentStart") return onSubagentStart(payload, cwd, now, home, id);
if (event === "SubagentStop") return onSubagentStop(payload, cwd, home);
if (event === "SubagentStop") return onSubagentStop(payload, cwd, home, id);
// Stop too: Codex emits no SessionEnd, so its ai-pilot hooks.json wires Stop here as the sole analytics-flush trigger — reusing the SessionEnd handler verbatim (codex-plugins/docs/reference/hooks.md).
if (event === "SessionEnd" || event === "Stop") { await cacheAnalyticsSave(home, now); return ""; }
if (event === "PreToolUse") return docCacheGate(payload, cwd, now, home);
if (event === "PreToolUse") return docCacheGate(payload, cwd, now, home, id);
return null;
}

Expand Down
9 changes: 7 additions & 2 deletions src/runtime/lifecycle/aipilot/doc-cache-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { pathExists } from "../../../util/runtime-io";
import { denyResponse } from "../../../adapters/claude";
import { cacheDirFor, cacheAge, projectHash, DOC_CACHE_TTL_SECONDS } from "./cache-base";
import { logCacheEvent } from "./analytics";
import { canonicalizeMcpToolName } from "../../mcp-tool-name";
import type { CacheIndex } from "./types";

const GATED_TOOLS = /context7__query-docs|exa__get_code_context|exa__web_search/;
Expand All @@ -39,10 +40,14 @@ function libraryOf(payload: Record<string, unknown>): string {
* @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
* @param now - Clock (defaults to `Date.now()`).
* @param home - Home dir (defaults to `~`).
* @param id - Harness adapter id (defaults to "claude-code"); on `"codex"`,
* `payload.tool_name` is canonicalized before the {@link GATED_TOOLS} match
* so the Codex-mangled underscore form (`mcp__context7__query_docs`) gates
* exactly like the dash form does on every other harness.
* @returns A native deny response, or `null` to fall through to the live call.
*/
export async function docCacheGate(payload: Record<string, unknown>, cwd: string, now: number = Date.now(), home: string = homedir()): Promise<string | null> {
const tool = String(payload.tool_name ?? "");
export async function docCacheGate(payload: Record<string, unknown>, cwd: string, now: number = Date.now(), home: string = homedir(), id: string = "claude-code"): Promise<string | null> {
const tool = canonicalizeMcpToolName(id, String(payload.tool_name ?? ""));
if (!GATED_TOOLS.test(tool)) return null;
const library = libraryOf(payload);
if (!library) return null;
Expand Down
92 changes: 92 additions & 0 deletions src/runtime/mcp-tool-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @module mcp-tool-name
* Canonicalizes a Codex-only MCP `tool_name` alias back to its original
* hyphenated form, so every existing dash-keyed consumer (`SHOT_TOOLS`,
* `RESEARCH_TOOLS`/`classifyExplore`, `docSourceOf`, the design-pipeline
* `NAV`/`SCROLL`/`GEMINI` constants, …) recognizes a Codex MCP call exactly
* like the same call on Claude/Kimi/Cursor/Cline/Gemini — no per-consumer
* change needed.
*
* Codex CLI globally rewrites every `-` to `_` across the FULL qualified MCP
* tool identifier before it ever reaches a hook, in `sanitize_responses_api_tool_name()`
* (`codex-rs/core/src/mcp_connection_manager.rs`, introduced by
* openai/codex#14605 — "Normalize MCP tool names to code-mode safe form",
* code-mode forbids `-` in identifiers). openai/codex#18385's own
* `SCENARIO_DASH_TOOL` test proves the REWRITTEN form is what reaches hooks:
* the `echo-tool` tool of server `rmcp` is delivered to hooks as
* `mcp__rmcp__echo_tool`. Confirmed independently in THIS repo's terrain:
* `~/.codex/config.toml` declares `[mcp_servers.fuse-browser]` (dash) while
* Codex session logs and this harness's own dual-marketplace hook payloads
* (`~/.codex/plugins/.../hooks.json` matchers) observe `mcp__fuse_browser__*`
* (underscore) — Claude never emits that form (0 occurrences across its
* transcripts vs 137x the dash form).
*
* The rewrite is deliberately NOT undone with a generic `_` -> `-` regex:
* openai/codex#15832 and #15565 document it as NON-INVERSIBLE — a server
* literally named `autok_local` collides with a dash-named `autok-local`
* once both pass through the same rewrite, and OpenAI's own fix was a closed
* canonical table, never a general regex. The SAME ambiguity exists on our
* side: a generic reversal would corrupt tool segments that legitimately
* contain an underscore already (`browser_screenshot`, `node_repl`,
* `build_sim`, …). So this module only reverses the FOUR entries proven
* above to matter to this repo's gates — every other segment passes through
* untouched.
* @packageDocumentation
*/

/**
* Codex-mangled MCP server segment -> its real (dash) server name. Closed set
* — see module doc. `Object.create(null)` (never a `{}` literal): `serverKey`
* is an attacker-influenced string (an MCP server can register itself as
* `"__proto__"`/`"constructor"`/`"toString"`), and a `{}`-literal lookup by
* such a key returns the INHERITED `Object.prototype` member instead of
* `undefined` — an object/function, so `?? serverKey` never falls back and
* the result silently corrupts to `"mcp__[object Object]__…"` (proven in
* `test/mcp-tool-name.test.ts`, prototype-pollution-read case). A null-
* prototype object has no inherited members, so every lookup outside the two
* literal keys below is a clean miss.
*/
const SERVER_ALIASES: Readonly<Record<string, string>> = Object.assign(Object.create(null), {
fuse_browser: "fuse-browser",
gemini_design: "gemini-design",
});

/**
* Codex-mangled MCP tool segment -> its real (dash) tool name. Both entries
* are `context7`-exclusive tool names — applied ONLY when the canonical
* server is `"context7"` (see the explicit guard in
* {@link canonicalizeMcpToolName}), never globally. A same-named tool on any
* other server (e.g. a hypothetical `exa__query_docs`) passes through
* untouched: openai/codex#15832 documents exactly this class of collision as
* the reason the rewrite must stay a closed, scoped table, never a blanket
* one. Closed set, same null-prototype rationale as {@link SERVER_ALIASES}.
*/
const TOOL_ALIASES: Readonly<Record<string, string>> = Object.assign(Object.create(null), {
query_docs: "query-docs",
resolve_library_id: "resolve-library-id",
});

/**
* Canonicalize a Codex-only MCP tool-name alias to its dash form. Scoped to
* `id === "codex"` — every other harness id passes through unchanged. The
* server segment is rewritten via the closed {@link SERVER_ALIASES} table;
* the tool segment is rewritten via {@link TOOL_ALIASES} ONLY when the
* (already-resolved) canonical server is `"context7"` — every other server's
* tool segment is left untouched even if it happens to share a name with a
* context7 tool. Every other `mcp__<server>__<tool>` segment (or non-MCP
* tool name) is returned byte-identical (see `test/mcp-tool-name.test.ts`
* for the non-regression proof across ids, trap tool names, and the
* server-scoping cases).
* @param id - Harness adapter id (e.g. "codex", "claude-code", "kimi").
* @param tool - Raw `tool_name` from the hook payload.
*/
export function canonicalizeMcpToolName(id: string, tool: string): string {
if (id !== "codex") return tool;
const parts = tool.split("__");
if (parts.length < 3 || parts[0] !== "mcp") return tool;
const serverKey = parts[1] ?? "";
const toolKey = parts.slice(2).join("__");
const server = SERVER_ALIASES[serverKey] ?? serverKey;
const rest = server === "context7" ? (TOOL_ALIASES[toolKey] ?? toolKey) : toolKey;
return `mcp__${server}__${rest}`;
}
3 changes: 2 additions & 1 deletion src/runtime/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { parseApplyPatch } from "../adapters/codex/apply-patch";
import { commandToString } from "./command-string";
import { canonicalizeCodexShellTool } from "./codex-shell-tool";
import { canonicalizeMcpToolName } from "./mcp-tool-name";

/** One file fanned out of a multi-file edit primitive (Codex `apply_patch`). */
export interface NormalizedFile {
Expand Down Expand Up @@ -59,7 +60,7 @@ export function normalizeEvent(id: string, payload: Record<string, unknown>): No
}
const event = str(payload.hook_event_name) ?? "";
const input = (payload.tool_input as Record<string, unknown> | undefined) ?? payload;
const tool = canonicalizeCodexShellTool(id, str(payload.tool_name) ?? "");
const tool = canonicalizeCodexShellTool(id, canonicalizeMcpToolName(id, str(payload.tool_name) ?? ""));
const base = {
phase: (/post|after/i.test(event) ? "post" : "pre") as "pre" | "post",
tool,
Expand Down
Loading