/** * Native effects for the Pi adapter. * * Nothing in this module knows how CBM stores projects or formats graph * results. It resolves the generated native CBM bridge, invokes the official * installer/CLI, and returns native envelopes for the lifecycle module to * translate. */ import { createHash, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { constants as fsConstants } from "node:fs"; import { access, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { BRIDGE_TOOLS, BRIDGE_VERSION, buildBridgeSource } from "./cbm-bridge.ts"; import { describeError, isAbortError, isRecord, isUsableRelease, OFFICIAL_INSTALLER_URL } from "./cbm-lifecycle.ts"; import type { BinaryIdentity, BridgeStatus, InstallSnapshot, LifecycleRuntime, NativeResult, } from "./cbm-lifecycle.ts"; const MAX_OUTPUT = 8 * 1024 * 1024; const MAX_HOOK_CONTEXT_CHARS = 16_000; const VERSION_CACHE_TTL_MS = 5_000; const DEBUG = /^(1|true)$/i.test(process.env.CBM_HOOKS_DEBUG ?? ""); const versionCache = new Map(); function debug(message: string): void { if (DEBUG) process.stderr.write(`[cbm-native] ${message}\n`); } export interface HookPayload { hook_event_name: "SessionStart" | "SubagentStart" | "PreToolUse" | "PostToolUse"; cwd: string; tool_name?: string; tool_input?: Record; } export interface HookContext { text: string; raw: unknown; } export function createNativeRuntime(): LifecycleRuntime { return { inspect, installFresh, updateManaged, refreshBridge, index, projectStatus, }; } export async function runOfficialHook( payload: HookPayload, signal?: AbortSignal, ): Promise { const snapshot = await inspect(signal); const binary = snapshot.active?.path; if (!binary) return null; if (snapshot.bridge.state !== "ready") return null; if (!isUsableRelease(snapshot.version)) return null; const result = await runProcess(binary, ["hook-augment"], JSON.stringify(payload), signal, false); if (result.code !== 0 || !result.stdout.trim()) return null; const raw = parseJson(result.stdout); if (!isRecord(raw)) return null; const text = extractHookText(raw); return text ? { text: limitHookContext(text), raw } : null; } async function inspect(signal?: AbortSignal, managedDirectory?: string): Promise { const installDirectory = installDir(managedDirectory); const managedPath = join(installDirectory, binaryName()); const managed = await identityFor(managedPath, "managed"); const bridge = await inspectBridge(); let active: BinaryIdentity | null = null; if (bridge.status.state === "ready" && bridge.binaryPath) { active = await identityFor(bridge.binaryPath, "bridge"); } const bridgeStatus: BridgeStatus = bridge.status.state === "ready" && bridge.binaryPath && !active ? { ...bridge.status, state: "incompatible", reason: "embedded official binary is unavailable" } : bridge.status; // A missing or incompatible bridge can be repaired through the managed // target or an explicit CBM_BIN. A ready bridge remains authoritative while // its embedded binary exists. Ignore an incompatible bridge's embedded path // until those safer sources are checked, then retain it only as a last resort // so an external binary is never silently replaced. if (!active) { const envPath = process.env.CBM_BIN; active = envPath ? await identityFor(envPath, "env") : null; if (!active) active = managed ? { ...managed, source: "managed" } : null; if (!active && bridge.binaryPath) active = await identityFor(bridge.binaryPath, "bridge"); } const version = active ? await readVersion(active.path, signal) : null; const externalOwner = active && isExternalToManaged(active, managedPath, managed) ? inferOwner(active.path) : null; return { installDirectory, managed, active, version, bridge: bridgeStatus, externalOwner, }; } function isExternalToManaged(active: BinaryIdentity, managedPath: string, managed: InstallSnapshot["managed"]): boolean { if (active.source === "managed") return false; if (managed && active.realPath === managed.realPath) return false; return active.path !== managedPath; } interface BridgeProbe { status: BridgeStatus; binaryPath: string | null; exists: boolean; } async function inspectBridge(): Promise { // Keep this gate deliberately narrow: it verifies only the generated // adapter markers, one embedded binary identity, and the JSON invocation // required by the supported range. It never parses graph data or coverage // semantics. const path = bridgePath(); let source: string; try { source = await readFile(path, "utf8"); } catch { return { status: { path, state: "missing", reason: "generated bridge not found" }, binaryPath: null, exists: false }; } const binaryDeclarations = source.match(/\bconst\s+BIN\s*=/g)?.length ?? 0; if (binaryDeclarations !== 1) { return { status: { path, state: "incompatible", reason: `generated bridge has ${binaryDeclarations} BIN declarations` }, binaryPath: null, exists: true, }; } const defaultExports = source.match(/\bexport\s+default\b/g)?.length ?? 0; if (defaultExports !== 1) { return { status: { path, state: "incompatible", reason: `generated bridge has ${defaultExports} default exports` }, binaryPath: null, exists: true, }; } const generatedVersion = /pi-cbm-generated-bridge v([0-9.]+)/.exec(source)?.[1]; const toolCount = source.match(/pi\.registerTool\(\{/g)?.length ?? 0; if (generatedVersion && generatedVersion !== BRIDGE_VERSION) { return { status: { path, state: "incompatible", reason: `bridge generator ${generatedVersion} is stale; expected ${BRIDGE_VERSION}`, version: generatedVersion, toolCount }, binaryPath: parseBridgeBinary(source), exists: true, }; } if (generatedVersion && toolCount !== BRIDGE_TOOLS.length) { return { status: { path, state: "incompatible", reason: `bridge registers ${toolCount} tools; expected ${BRIDGE_TOOLS.length}`, version: generatedVersion, toolCount }, binaryPath: parseBridgeBinary(source), exists: true, }; } const binaryPath = parseBridgeBinary(source); if (!binaryPath) { return { status: { path, state: "incompatible", reason: "generated bridge has no embedded official binary path" }, binaryPath: null, exists: true, }; } if (!source.includes("export default") || !source.includes("pi.registerTool")) { return { status: { path, state: "incompatible", reason: "generated bridge is not a default Pi tool adapter" }, binaryPath, exists: true, }; } if (source.includes("JSON arguments for the CBM CLI tool") || !source.includes("toolCallId")) { return { status: { path, state: "incompatible", reason: "generated bridge uses the legacy generic tool contract" }, binaryPath, exists: true, }; } if (!source.includes("--json")) { return { status: { path, state: "incompatible", reason: "generated bridge does not request machine-readable JSON results" }, binaryPath, exists: true, }; } return { status: { path, state: "ready", reason: "", ...(generatedVersion ? { version: generatedVersion } : {}), ...(toolCount ? { toolCount } : {}), }, binaryPath, exists: true, }; } function parseBridgeBinary(source: string): string | null { const match = /const\s+BIN\s*=\s*(['"])((?:\\.|(?!\1).)*)\1/.exec(source); if (!match) return null; return decodeJavaScriptString(match[2]); } function decodeJavaScriptString(value: string): string { return value.replace(/\\(u[0-9a-fA-F]{4}|[\\'"\\bnrtfv0])/g, (match, escaped: string) => { switch (escaped) { case "n": return "\n"; case "r": return "\r"; case "t": return "\t"; case "b": return "\b"; case "f": return "\f"; case "v": return "\v"; case "0": return "\0"; case "\\": return "\\"; case "\"": return "\""; case "'": return "'"; default: return escaped.startsWith("u") ? String.fromCharCode(Number.parseInt(escaped.slice(1), 16)) : match; } }); } export const OFFICIAL_INSTALLER_SHA256 = "2fdd4d6563fc8e540bb32e233c5fdef22ecf05d7ebd5a80657cd4fec953b3475"; function verifyOfficialInstaller(source: string): string | null { const digest = createHash("sha256").update(source, "utf8").digest("hex"); if (digest !== OFFICIAL_INSTALLER_SHA256) { return `official installer checksum mismatch (expected ${OFFICIAL_INSTALLER_SHA256}, got ${digest})`; } if (!source.includes("codebase-memory-mcp") || !source.includes("checksums.txt")) { return "downloaded installer failed the official CBM sanity check"; } return null; } export function officialInstallerArgs(directory: string): string[] { return ["--dir", directory, "--skip-config"]; } async function installFresh(directory: string, signal?: AbortSignal): Promise { if (process.platform === "win32") { return { code: 1, stdout: "", stderr: "Pi bootstrap currently supports macOS/Linux only; use the official PowerShell installer on Windows" }; } let tempDir: string | null = null; try { tempDir = await mkdtemp(join(tmpdir(), "cbm-install-")); const script = join(tempDir, "install.sh"); debug(`downloading official installer from ${OFFICIAL_INSTALLER_URL}`); const response = await fetch(OFFICIAL_INSTALLER_URL, { redirect: "error", signal }); if (!response.ok) return { code: 1, stdout: "", stderr: `official installer download failed (HTTP ${response.status})` }; const source = await response.text(); const verificationError = verifyOfficialInstaller(source); if (verificationError) return { code: 1, stdout: "", stderr: verificationError }; await writeFile(script, source, { mode: 0o755 }); invalidateVersionCache(join(directory, binaryName())); return await runProcess("bash", [script, ...officialInstallerArgs(directory)], undefined, signal, true); } catch (error) { if (isAbortError(error)) throw error; return { code: 1, stdout: "", stderr: describeError(error) }; } finally { if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); } } async function updateManaged(directory: string, signal?: AbortSignal): Promise { if (process.platform === "win32") { return { code: 1, stdout: "", stderr: "managed Windows updates belong to the official install.ps1; run codebase-memory-mcp update directly" }; } const script = join(directory, "install.sh"); try { await access(script, fsConstants.R_OK); const verificationError = verifyOfficialInstaller(await readFile(script, "utf8")); if (verificationError) return { code: 1, stdout: "", stderr: `managed installer verification failed: ${verificationError}` }; } catch { return { code: 1, stdout: "", stderr: `managed binary has no verified adjacent official installer: ${script}` }; } invalidateVersionCache(join(directory, binaryName())); return runProcess("bash", [script, ...officialInstallerArgs(directory)], undefined, signal, true); } /** * Generate the Pi bridge extension (cbmem.ts) that wraps CBM CLI tools. * * The bridge is generated from the adapter's compact copy of the documented * CBM CLI contract. It remains a native Pi extension, while the official * binary remains responsible for graph and indexing semantics. */ async function refreshBridge(binary: string, _signal?: AbortSignal): Promise { const path = bridgePath(); const source = buildBridgeSource(binary); let temporaryPath: string | undefined; try { if (!isGeneratedBridgeSource(source)) { return { code: 1, stdout: "", stderr: "generated bridge failed its structural validation" }; } try { if (await readFile(path, "utf8") === source) return { code: 0, stdout: `bridge already current at ${path}`, stderr: "" }; } catch { // The bridge is being created for the first time. } await mkdir(dirname(path), { recursive: true }); temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temporaryPath, source, { encoding: "utf8", mode: 0o600 }); try { await copyFile(path, `${path}.bak`); } catch (error) { if (!isFileMissingError(error)) throw error; } await rename(temporaryPath, path); temporaryPath = undefined; return { code: 0, stdout: `bridge generated at ${path}`, stderr: "" }; } catch (error) { return { code: 1, stdout: "", stderr: `failed to write bridge: ${describeError(error)}` }; } finally { if (temporaryPath) await rm(temporaryPath, { force: true }).catch(() => undefined); } } function isGeneratedBridgeSource(source: string): boolean { return source.match(/\bconst\s+BIN\s*=/g)?.length === 1 && source.match(/\bexport\s+default\b/g)?.length === 1 && source.includes("pi.registerTool") && source.includes("execute: async (_toolCallId, params") && source.includes("\"--json\""); } function isFileMissingError(error: unknown): boolean { return isRecord(error) && error.code === "ENOENT"; } async function index(binary: string, cwd: string, signal?: AbortSignal): Promise { return runProcess(binary, ["cli", "--json", "index_repository", "--repo-path", cwd], undefined, signal, true); } async function projectStatus(binary: string, cwd: string, signal?: AbortSignal): Promise { // index_status --project is the native canonical resolver. Do not add a // local list_projects/containment fallback; that would diverge on aliases, // worktrees, custom names, and Windows path rules. return runProcess(binary, ["cli", "--json", "index_status", "--project", cwd], undefined, signal, false); } function invalidateVersionCache(binary?: string): void { if (binary) versionCache.delete(binary); else versionCache.clear(); } async function readVersion(binary: string, signal?: AbortSignal): Promise { const cached = versionCache.get(binary); if (cached && cached.expiresAt > Date.now()) return cached.value; try { const result = await runProcess(binary, ["--version"], undefined, signal, false); if (result.code !== 0) { versionCache.set(binary, { value: null, expiresAt: Date.now() + VERSION_CACHE_TTL_MS }); return null; } const match = /^codebase-memory-mcp\s+(\S+)$/m.exec(result.stdout.trim()); const value = match?.[1] ?? null; versionCache.set(binary, { value, expiresAt: Date.now() + VERSION_CACHE_TTL_MS }); return value; } catch (error) { if (isAbortError(error)) throw error; versionCache.set(binary, { value: null, expiresAt: Date.now() + VERSION_CACHE_TTL_MS }); return null; } } async function identityFor(path: string, source: BinaryIdentity["source"]): Promise { try { await access(path, fsConstants.X_OK); return { path, realPath: await realpath(path), source }; } catch { return null; } } function installDir(override?: string): string { if (override) return expandHome(override); if (process.env.CBM_INSTALL_DIR) return expandHome(process.env.CBM_INSTALL_DIR); if (process.platform === "win32") { return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Programs", "codebase-memory-mcp"); } return join(homedir(), ".local", "bin"); } function bridgePath(): string { const configuredAgentDir = process.env.PI_CODING_AGENT_DIR?.trim(); const agentDir = configuredAgentDir ? expandHome(configuredAgentDir) : join(homedir(), ".pi", "agent"); return join(agentDir, "extensions", "cbmem.ts"); } function expandHome(path: string): string { if (path === "~") return homedir(); if (path.startsWith("~/")) return join(homedir(), path.slice(2)); return path; } function binaryName(): string { return process.platform === "win32" ? "codebase-memory-mcp.exe" : "codebase-memory-mcp"; } function inferOwner(path: string): string { const normalized = path.replaceAll("\\", "/"); if (normalized.includes("/opt/homebrew/") || normalized.includes("/Cellar/")) return "Homebrew"; if (normalized.includes("/nix/store/")) return "nix"; if (normalized.includes("/.local/share/mise/") || normalized.includes("/mise/installs/")) return "mise"; return "another package manager or user-managed path"; } async function runProcess( command: string, args: string[], input: string | undefined, signal: AbortSignal | undefined, forwardOutput: boolean, ): Promise { debug(`${command} ${args.join(" ")}`); return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, CBM_LOG_LEVEL: process.env.CBM_LOG_LEVEL ?? "error" }, signal, }); let stdout = ""; let stderr = ""; let settled = false; const settle = (result: NativeResult): void => { if (settled) return; settled = true; resolve(result); }; child.stdout.on("data", (chunk: Buffer | string) => { stdout = appendOutput(stdout, chunk.toString()); if (forwardOutput) process.stdout.write(chunk); }); child.stderr.on("data", (chunk: Buffer | string) => { stderr = appendOutput(stderr, chunk.toString()); if (forwardOutput) process.stderr.write(chunk); }); child.once("error", (error) => { if (isAbortError(error)) { if (!settled) { settled = true; reject(error); } return; } settle({ code: 1, stdout, stderr: describeError(error) }); }); child.once("close", (code) => settle({ code: code ?? 1, stdout, stderr })); if (input !== undefined) child.stdin.end(input); else child.stdin.end(); }); } function appendOutput(previous: string, next: string): string { if (previous.length >= MAX_OUTPUT) return previous; return (previous + next).slice(0, MAX_OUTPUT); } function parseJson(raw: string): unknown { for (const line of raw.trim().split(/\r?\n/).reverse()) { if (!line.trim()) continue; try { return JSON.parse(line); } catch { // Native diagnostics are allowed around the final JSON envelope. } } return null; } function extractHookText(value: Record): string | null { const top = typeof value.additionalContext === "string" ? value.additionalContext : null; if (top) return top; const hookSpecificOutput = value.hookSpecificOutput; if (isRecord(hookSpecificOutput) && typeof hookSpecificOutput.additionalContext === "string") { return hookSpecificOutput.additionalContext; } return null; } function limitHookContext(text: string): string { if (text.length <= MAX_HOOK_CONTEXT_CHARS) return text; const marker = `\n\n[Hook context truncated to ${MAX_HOOK_CONTEXT_CHARS} characters]\n\n`; const available = Math.max(0, MAX_HOOK_CONTEXT_CHARS - marker.length); const headLength = Math.floor(available * 0.8); const tailLength = available - headLength; return `${text.slice(0, headLength)}${marker}${text.slice(-tailLength)}`; }