diff --git a/CHANGELOG.md b/CHANGELOG.md index 32af419..a557466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 0823d34..fc149e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/adapters/codex/index.ts b/src/adapters/codex/index.ts index fc41a5d..14bcd10 100644 --- a/src/adapters/codex/index.ts +++ b/src/adapters/codex/index.ts @@ -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"; @@ -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), diff --git a/src/runtime/lifecycle/aipilot/cache-doc.ts b/src/runtime/lifecycle/aipilot/cache-doc.ts index 9a09ba6..0cbdcc2 100644 --- a/src/runtime/lifecycle/aipilot/cache-doc.ts +++ b/src/runtime/lifecycle/aipilot/cache-doc.ts @@ -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/; @@ -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 = ""; @@ -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); } @@ -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 { +export async function cacheDocFromTranscript(transcript: string | undefined, cwd: string, home: string = homedir(), id: string = "claude-code"): Promise { 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; diff --git a/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts b/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts index 83bb042..44a4f0b 100644 --- a/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +++ b/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts @@ -68,11 +68,11 @@ async function onSubagentStart(payload: Record, cwd: string, no return combineContext(apex, lessons, typeSpecific); } -/** SubagentStop routing: transcript-driven cache writers, then the universal SOLID check. */ -async function onSubagentStop(payload: Record, cwd: string, home: string): Promise { +/** 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, cwd: string, home: string, id: string): Promise { 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); @@ -87,10 +87,10 @@ async function onSubagentStop(payload: Record, cwd: string, hom */ export async function dispatchAipilot(event: string, payload: Record, cwd: string, now: number, home: string = homedir(), id: string = "claude-code"): Promise { 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; } diff --git a/src/runtime/lifecycle/aipilot/doc-cache-gate.ts b/src/runtime/lifecycle/aipilot/doc-cache-gate.ts index 3cebd51..42beb9b 100644 --- a/src/runtime/lifecycle/aipilot/doc-cache-gate.ts +++ b/src/runtime/lifecycle/aipilot/doc-cache-gate.ts @@ -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/; @@ -39,10 +40,14 @@ function libraryOf(payload: Record): 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, cwd: string, now: number = Date.now(), home: string = homedir()): Promise { - const tool = String(payload.tool_name ?? ""); +export async function docCacheGate(payload: Record, cwd: string, now: number = Date.now(), home: string = homedir(), id: string = "claude-code"): Promise { + const tool = canonicalizeMcpToolName(id, String(payload.tool_name ?? "")); if (!GATED_TOOLS.test(tool)) return null; const library = libraryOf(payload); if (!library) return null; diff --git a/src/runtime/mcp-tool-name.ts b/src/runtime/mcp-tool-name.ts new file mode 100644 index 0000000..7a0551a --- /dev/null +++ b/src/runtime/mcp-tool-name.ts @@ -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> = 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> = 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____` 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}`; +} diff --git a/src/runtime/normalize.ts b/src/runtime/normalize.ts index 42aeef7..65cdf2d 100644 --- a/src/runtime/normalize.ts +++ b/src/runtime/normalize.ts @@ -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 { @@ -59,7 +60,7 @@ export function normalizeEvent(id: string, payload: Record): No } const event = str(payload.hook_event_name) ?? ""; const input = (payload.tool_input as Record | 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, diff --git a/test/mcp-tool-name.test.ts b/test/mcp-tool-name.test.ts new file mode 100644 index 0000000..e329b2f --- /dev/null +++ b/test/mcp-tool-name.test.ts @@ -0,0 +1,167 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { canonicalizeMcpToolName } from "../src/runtime/mcp-tool-name"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { docCacheGate } from "../src/runtime/lifecycle/aipilot/doc-cache-gate"; +import { cacheDocFromTranscript } from "../src/runtime/lifecycle/aipilot/cache-doc"; +import { cacheDirFor } from "../src/runtime/lifecycle/aipilot/cache-base"; + +/** + * Codex CLI globally rewrites `-` -> `_` across the full qualified MCP tool + * identifier before it reaches a hook (openai/codex#14605, + * `sanitize_responses_api_tool_name()`) — proven to reach hooks by + * openai/codex#18385's `SCENARIO_DASH_TOOL` test. `mcp-tool-name.ts` + * canonicalizes the 4 entries this repo's dash-keyed gates (SHOT_TOOLS, + * RESEARCH_TOOLS, NAV/SCROLL/GEMINI, CONTEXT7_SOURCE) actually depend on, + * via a closed table — never a generic regex (openai/codex#15832/#15565 + * document the rewrite as non-inversible). + */ + +test("1) codex + underscore server/tool -> canonical dash form, all 4 table entries", () => { + expect(canonicalizeMcpToolName("codex", "mcp__fuse_browser__browser_screenshot")).toBe("mcp__fuse-browser__browser_screenshot"); + expect(canonicalizeMcpToolName("codex", "mcp__gemini_design__create_frontend")).toBe("mcp__gemini-design__create_frontend"); + expect(canonicalizeMcpToolName("codex", "mcp__context7__query_docs")).toBe("mcp__context7__query-docs"); + expect(canonicalizeMcpToolName("codex", "mcp__context7__resolve_library_id")).toBe("mcp__context7__resolve-library-id"); +}); + +test("2) codex + already-dash form -> unchanged (idempotent)", () => { + for (const tool of [ + "mcp__fuse-browser__browser_screenshot", + "mcp__gemini-design__create_frontend", + "mcp__context7__query-docs", + "mcp__context7__resolve-library-id", + ]) { + expect(canonicalizeMcpToolName("codex", tool)).toBe(tool); + } +}); + +test("3) non-regression: every other harness id passes underscore forms through UNCHANGED", () => { + for (const id of ["claude-code", "kimi", "cursor", "cline", "gemini-cli", "hermes"]) { + expect(canonicalizeMcpToolName(id, "mcp__fuse_browser__browser_screenshot")).toBe("mcp__fuse_browser__browser_screenshot"); + expect(canonicalizeMcpToolName(id, "mcp__context7__query_docs")).toBe("mcp__context7__query_docs"); + } +}); + +test("4) traps: legitimately-underscored segments are NEVER rewritten, even on codex", () => { + expect(canonicalizeMcpToolName("codex", "mcp__node_repl__run")).toBe("mcp__node_repl__run"); + expect(canonicalizeMcpToolName("codex", "mcp__exa__web_search_exa")).toBe("mcp__exa__web_search_exa"); + expect(canonicalizeMcpToolName("codex", "mcp__XcodeBuildMCP__build_sim")).toBe("mcp__XcodeBuildMCP__build_sim"); +}); + +test("5) degenerate inputs -> unchanged, no throw, on codex", () => { + for (const tool of ["", "mcp__", "mcp__x", "notmcp__a__b", "Bash"]) { + expect(() => canonicalizeMcpToolName("codex", tool)).not.toThrow(); + expect(canonicalizeMcpToolName("codex", tool)).toBe(tool); + } +}); + +test("5b) prototype-pollution-read: Object.prototype member names as server/tool segments never leak an inherited object/function into the result", () => { + for (const key of ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"]) { + const server = canonicalizeMcpToolName("codex", `mcp__${key}__x`); + expect(server).toBe(`mcp__${key}__x`); + const tool = canonicalizeMcpToolName("codex", `mcp__fuse_browser__${key}`); + expect(tool).toBe(`mcp__fuse-browser__${key}`); + } +}); + +test("6) integration via normalizeEvent: codex underscore payload -> dash event.tool; claude-code underscore payload -> unchanged", () => { + const codexEvent = normalizeEvent("codex", { + hook_event_name: "PostToolUse", + tool_name: "mcp__fuse_browser__browser_screenshot", + tool_input: {}, + session_id: "s", + }); + expect(codexEvent.tool).toBe("mcp__fuse-browser__browser_screenshot"); + + const claudeEvent = normalizeEvent("claude-code", { + hook_event_name: "PostToolUse", + tool_name: "mcp__fuse_browser__browser_screenshot", + tool_input: {}, + session_id: "s", + }); + expect(claudeEvent.tool).toBe("mcp__fuse_browser__browser_screenshot"); +}); + +test("7) composition with canonicalizeCodexShellTool: exec_command on codex still yields Bash", () => { + const event = normalizeEvent("codex", { + hook_event_name: "PostToolUse", + tool_name: "exec_command", + tool_input: { command: "cat foo.md" }, + session_id: "s", + }); + expect(event.tool).toBe("Bash"); +}); + +test("8) server scoping: TOOL_ALIASES rewrites ONLY when the canonical server is context7", () => { + expect(canonicalizeMcpToolName("codex", "mcp__context7__query_docs")).toBe("mcp__context7__query-docs"); + expect(canonicalizeMcpToolName("codex", "mcp__context7__resolve_library_id")).toBe("mcp__context7__resolve-library-id"); + expect(canonicalizeMcpToolName("codex", "mcp__exa__query_docs")).toBe("mcp__exa__query_docs"); + expect(canonicalizeMcpToolName("codex", "mcp__shadcn__resolve_library_id")).toBe("mcp__shadcn__resolve_library_id"); + expect(canonicalizeMcpToolName("codex", "mcp__fuse_browser__query_docs")).toBe("mcp__fuse-browser__query_docs"); +}); + +test("9) docCacheGate third ingestion path: underscore form gated on codex via canonicalization, NOT gated on claude-code", async () => { + const project = mkdtempSync(join(tmpdir(), "fh-mcp-gate-")); + const home = mkdtempSync(join(tmpdir(), "fh-mcp-gate-home-")); + const docDir = cacheDirFor("doc", project, home); + mkdirSync(join(docDir, "docs"), { recursive: true }); + writeFileSync(join(docDir, "index.json"), JSON.stringify({ docs: [{ library: "react", hash: "cafe01", timestamp: new Date().toISOString() }] })); + writeFileSync(join(docDir, "docs", "cafe01.md"), "cached body"); + const payload = { tool_name: "mcp__context7__query_docs", tool_input: { libraryId: "react", query: "react" } }; + // Pin CLAUDE_PROJECT_DIR for the call (docCacheGate reads it ahead of `cwd`) — other + // test files leave it set process-wide, so this test must not trust the ambient value. + const prevProjDir = process.env.CLAUDE_PROJECT_DIR; + process.env.CLAUDE_PROJECT_DIR = project; + try { + expect(await docCacheGate(payload, project, Date.now(), home, "codex")).toContain("\"permissionDecision\":\"deny\""); + expect(await docCacheGate(payload, project, Date.now(), home, "claude-code")).toBeNull(); + } finally { + if (prevProjDir === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = prevProjDir; + } +}); + +/** A JSONL transcript with one tool_use (`toolName`, keyed to `library`) + a >200-char assistant text, so `cacheDocFromTranscript` clears BOTH its early-exit conditions in one pass. */ +function transcriptWithToolUse(dir: string, file: string, toolName: string, library: string): string { + const path = join(dir, file); + const line = JSON.stringify({ + type: "assistant", + message: { content: [ + { type: "tool_use", name: toolName, input: { libraryId: library, query: "q" } }, + { type: "text", text: "x".repeat(220) }, + ] }, + }); + writeFileSync(path, `${line}\n`); + return path; +} + +test("10) cacheDocFromTranscript fifth ingestion path: underscore tool_use.name populates the cache on codex via canonicalization, NOT on claude-code (witnessed against a dash-form control)", async () => { + const project = mkdtempSync(join(tmpdir(), "fh-cache-doc-")); + const home = mkdtempSync(join(tmpdir(), "fh-cache-doc-home-")); + const docDir = cacheDirFor("doc", project, home); + const prevProjDir = process.env.CLAUDE_PROJECT_DIR; + process.env.CLAUDE_PROJECT_DIR = project; + try { + // Positive witness FIRST: known-good dash form on claude-code MUST populate the + // cache — proves this fixture/harness actually exercises the write path before + // any negative result below is trusted (a dead probe would "pass" vacuously). + await cacheDocFromTranscript(transcriptWithToolUse(project, "witness.jsonl", "mcp__context7__query-docs", "witness-lib"), project, home, "claude-code"); + let index = JSON.parse(readFileSync(join(docDir, "index.json"), "utf8")); + expect(index.docs.some((d: { library: string }) => d.library === "witness-lib")).toBe(true); + + // codex: underscore form must ALSO populate the cache, via canonicalization. + await cacheDocFromTranscript(transcriptWithToolUse(project, "codex.jsonl", "mcp__context7__query_docs", "codex-lib"), project, home, "codex"); + index = JSON.parse(readFileSync(join(docDir, "index.json"), "utf8")); + expect(index.docs.some((d: { library: string }) => d.library === "codex-lib")).toBe(true); + + // claude-code: the SAME underscore form must NOT populate the cache (non-regression). + await cacheDocFromTranscript(transcriptWithToolUse(project, "claude.jsonl", "mcp__context7__query_docs", "claude-lib"), project, home, "claude-code"); + index = JSON.parse(readFileSync(join(docDir, "index.json"), "utf8")); + expect(index.docs.some((d: { library: string }) => d.library === "claude-lib")).toBe(false); + } finally { + if (prevProjDir === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = prevProjDir; + } +});