/** * `kb://` URI handler for the omp SDK's InternalUrlRouter. * * Lets the `read` tool resolve `kb://` against the user's configured * KB root (the same root `kb-service.ts` serves over REST). Without this, * the prelude's promise that the harness resolves `kb://` URIs is a lie — * the SDK only ships handlers for `agent://`, `artifact://`, `memory://`, * `skill://`, `rule://`, `mcp://`, `omp://`, `local://`, `issue://`, `pr://`. * * Resolution rules (intentionally narrow — the wider semantic-match surface * lives behind /api/kb/search, this is for the read tool only): * * - `kb://` → markdown index of top-level entries * - `kb:///` → markdown index of entries under * - `kb://.md` → that file verbatim * - `kb://` (no ext) → tries `.md` first; if that's a * directory, falls back to listing it * * Path traversal is rejected the same way `local-protocol.ts` rejects it — * the resolved real path MUST stay under the resolved real KB root. * * Honors `OMP_DECK_KB_ROOT` (same env var `kb-service.ts` reads), so users * who relocate their kb get `kb://` resolution against the new location * automatically. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { InternalResource, InternalUrl, ProtocolHandler, } from "@oh-my-pi/pi-coding-agent/internal-urls"; import { resolveKbRoot } from "./kb-service.ts"; function getContentType(filePath: string): InternalResource["contentType"] { const ext = path.extname(filePath).toLowerCase(); if (ext === ".md" || ext === ".markdown") return "text/markdown"; if (ext === ".json") return "application/json"; return "text/plain"; } function ensureWithinRoot(targetPath: string, rootPath: string): void { if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) { throw new Error("kb:// URL escapes KB root"); } } /** * Pull the path portion from a `kb://...` URL while preserving the original * separators the way `LocalProtocolHandler` does. The URL parser eats the * first segment into `hostname`, so we glue it back to `pathname`. */ function extractRelativePath(url: InternalUrl): string { const host = url.rawHost || url.hostname; const pathname = url.rawPathname ?? url.pathname; const combined = host ? pathname && pathname !== "/" ? `${host}${pathname}` : host : pathname && pathname !== "/" ? pathname.slice(1) : ""; if (!combined) return ""; let decoded: string; try { decoded = decodeURIComponent(combined.replaceAll("\\", "/")); } catch { throw new Error(`Invalid URL encoding in kb:// path: ${url.href}`); } // Reject absolute paths, drive letters, and parent traversal segments. // Mirrors the rules the SDK's `validateRelativePath` enforces for skill:// / // local:// — we can't import that helper from the deck without coupling to // a non-stable surface, so we restate the checks inline. if (decoded.startsWith("/") || /^[A-Za-z]:/.test(decoded)) { throw new Error(`kb:// path must be relative: ${decoded}`); } const segments = decoded.split("/").filter((s) => s.length > 0); for (const seg of segments) { if (seg === "." || seg === "..") { throw new Error(`kb:// path contains traversal segment: ${decoded}`); } } return segments.join("/"); } async function buildDirectoryListing( url: InternalUrl, resolvedDir: string, relativeDir: string, kbRoot: string, ): Promise { const entries = await fs.readdir(resolvedDir, { withFileTypes: true }); const dirs: string[] = []; const files: string[] = []; for (const entry of entries) { const name = entry.name; if (name.startsWith(".")) continue; // hide dotfiles in listings if (entry.isDirectory()) { dirs.push(name); } else if (entry.isFile()) { // Surface markdown by default; skip noise like .DS_Store but keep // .json/.txt for completeness so the listing shows the same set the // /api/kb/tree view does. if (/\.(md|markdown|json|txt)$/i.test(name)) files.push(name); } } dirs.sort((a, b) => a.localeCompare(b)); files.sort((a, b) => a.localeCompare(b)); const prefix = relativeDir ? `${relativeDir}/` : ""; const lines: string[] = []; lines.push(`# kb://${prefix}`); lines.push(""); lines.push(`Root: ${kbRoot}`); lines.push(""); if (dirs.length === 0 && files.length === 0) { lines.push("(empty)"); } else { if (dirs.length > 0) { lines.push("## Subdirectories"); lines.push(""); for (const d of dirs) lines.push(`- [${d}/](kb://${prefix}${d}/)`); lines.push(""); } if (files.length > 0) { lines.push("## Files"); lines.push(""); for (const f of files) lines.push(`- [${f}](kb://${prefix}${f})`); lines.push(""); } } const content = lines.join("\n"); return { url: url.href, content, contentType: "text/markdown", size: Buffer.byteLength(content, "utf-8"), sourcePath: resolvedDir, }; } /** * Resolves `kb://` against the configured KB root. * * Read-only: the handler is intentionally `immutable: true` to suppress the * hashline edit affordance. Edits go through `PUT /api/kb/file` (which * triggers the `kb_changed` watcher broadcast); allowing arbitrary in-band * edits via the read tool would bypass that contract. */ export class KbProtocolHandler implements ProtocolHandler { readonly scheme = "kb"; readonly immutable = true; async resolve(url: InternalUrl): Promise { const kbRoot = path.resolve(resolveKbRoot()); let resolvedRoot: string; try { resolvedRoot = await fs.realpath(kbRoot); } catch { throw new Error( `kb:// unavailable: KB root does not exist (${kbRoot}). Set OMP_DECK_KB_ROOT to point at your wiki.`, ); } const relativePath = extractRelativePath(url); // Bare `kb://` (or `kb:///`) — list the root. if (!relativePath) { return buildDirectoryListing(url, resolvedRoot, "", resolvedRoot); } // Trailing slash → explicit directory listing. const trailingSlash = (url.rawPathname ?? url.pathname).endsWith("/"); const candidates: string[] = []; const direct = path.resolve(resolvedRoot, relativePath); candidates.push(direct); // `.md` ergonomics: when the caller wrote `kb://system/working-voice`, // also try `system/working-voice.md`. Only when no extension is present. if (!path.extname(relativePath)) candidates.push(`${direct}.md`); for (const candidate of candidates) { let realCandidate: string; try { realCandidate = await fs.realpath(candidate); } catch { continue; } ensureWithinRoot(realCandidate, resolvedRoot); const stat = await fs.stat(realCandidate); if (stat.isDirectory()) { if (candidate !== direct) continue; // never list a `.md`-suffix dir return buildDirectoryListing(url, realCandidate, relativePath, resolvedRoot); } if (stat.isFile()) { const content = await fs.readFile(realCandidate, "utf8"); return { url: url.href, content, contentType: getContentType(realCandidate), size: Buffer.byteLength(content, "utf-8"), sourcePath: realCandidate, notes: [ "Read-only via kb://; edit via PUT /api/kb/file?path= to trigger the kb_changed watcher broadcast.", ], }; } } const hint = trailingSlash ? "" : !path.extname(relativePath) ? " (also tried .md suffix)" : ""; throw new Error(`kb resource not found: ${relativePath}${hint}`); } }