// v1.12 Track B.2: shared HTTP client for the codecontext sidecar. The 8 // per-tool wrappers under tools/codecontext/ all funnel through callCodecontext // — they're thin adapters that supply toolName + args + projectPath. The // client owns: // // 1. target_dir validation. Codecontext's HTTP shim is naive and forwards // any target_dir to codecontext, so without this layer a model that // hallucinated a target_dir could read /opt/anything-on-disk. The // project root is realpath'd and the requested target_dir is constrained // to it (same invariant as path_guard.ts but for the codecontext path). // 2. Inline truncation at 32 kB. Codecontext outputs are markdown reports // that can balloon on large projects; the model can re-narrow via // file_path / file_type / limit. Matches the "inline truncation, no // opaque-id retrieval" decision locked in the 2026-05-21 recon. // 3. Friendly mapping of codecontext's known failure modes — the empty- // file parser bug (upstream issue #37) returns a generic error string, // which we re-surface with a hint to add the file to .codecontextignore. import { access, copyFile, realpath } from 'node:fs/promises'; import { isAbsolute, join, resolve, sep } from 'node:path'; import { truncateIfNeeded } from './truncate.js'; // v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37) // when it can't ignore them. The .codecontextignore.template ships with the // project at /opt/boocode/codecontext/.codecontextignore.template (path inside // the container; the host's /opt is bind-mounted). On the first call to any // project, copy the template in if no per-project ignore exists yet. The user // can subsequently edit the file to customize. Idempotent — once any file is // at the project root we never overwrite. const IGNORE_TEMPLATE_PATH = '/opt/boocode/codecontext/.codecontextignore.template'; const ensuredIgnoreProjects = new Set(); async function ensureIgnoreFile(projectRoot: string): Promise { if (ensuredIgnoreProjects.has(projectRoot)) return; const ignorePath = join(projectRoot, '.codecontextignore'); try { await access(ignorePath); ensuredIgnoreProjects.add(projectRoot); return; } catch { // missing — install the default } try { await copyFile(IGNORE_TEMPLATE_PATH, ignorePath); ensuredIgnoreProjects.add(projectRoot); } catch { // Template missing or project root read-only — proceed without it. The // codecontext call may still crash on empty source files; the model gets // the existing hint-message via the catch below telling it to add to // .codecontextignore manually. } } // v1.13.18: resolve a `file_path` arg to an absolute path anchored within // the (already realpath'd) projectRoot. Contract: // - empty/whitespace-only → INVALID_FILE_PATH error // - relative path → resolve(projectRoot, rawPath) (normalises dot-segments) // - absolute path → resolve(rawPath) (also normalises — e.g. /root/../etc // becomes /etc so the prefix-check below rejects it even in the ENOENT // fallthrough where realpath couldn't canonicalise) // - try realpath; on ENOENT fall through with the (normalised) absolute // (the sidecar issues its own "File not found in graph" that the model // can self-correct on; re-implementing the check here would diverge) // - if the final path doesn't sit inside projectRoot → escape error // (same shape as target_dir escape, only the field name differs) async function resolveProjectPath( projectRoot: string, rawPath: string, ): Promise { if (rawPath.trim() === '') { throw new Error('INVALID_FILE_PATH: file_path must not be empty'); } const candidate = isAbsolute(rawPath) ? resolve(rawPath) : resolve(projectRoot, rawPath); let resolved: string; try { resolved = await realpath(candidate); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { // File doesn't exist yet (or was deleted). Forward the absolute path; // codecontext will return "File not found in graph" which the model // can self-correct on. resolved = candidate; } else { throw err; } } if (resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)) { throw new Error(`file_path ${rawPath} escapes project root ${projectRoot}`); } return resolved; } export interface CodecontextRequest { toolName: string; args: Record; projectPath: string; } export interface CodecontextResponse { result: string; truncated: boolean; // v1.13.5: optional opaque id pointing at the full pre-slice content on // tmpfs. Set when truncated=true and storage succeeded. outputPath?: string; } const CODECONTEXT_BASE_URL = process.env['CODECONTEXT_URL'] ?? 'http://codecontext:8080'; const TRUNCATION_LIMIT = 32_000; const REQUEST_TIMEOUT_MS = 30_000; export async function callCodecontext( req: CodecontextRequest, fetcher: typeof fetch = fetch, ): Promise { // Step 1: realpath the project root, then realpath the requested target_dir // (defaulting to projectPath when the caller didn't pass one — the 8 wrappers // never pass target_dir; tests can override). A non-existent target_dir // throws before we hit the network so the model gets a sharp error. const resolvedProject = await realpath(req.projectPath); // v1.13.12 fix: install the default .codecontextignore on first call to any // project so codecontext doesn't crash on empty node_modules files. One file // written per project, idempotent (set-membership check inside). await ensureIgnoreFile(resolvedProject); const requestedTarget = req.args['target_dir']; const targetDir = typeof requestedTarget === 'string' && requestedTarget.length > 0 ? requestedTarget : req.projectPath; const resolvedTarget = await realpath(targetDir).catch(() => null); if (resolvedTarget === null) { throw new Error(`target_dir does not exist: ${targetDir}`); } if (resolvedTarget !== resolvedProject && !resolvedTarget.startsWith(resolvedProject + '/')) { throw new Error(`target_dir ${targetDir} escapes project root ${resolvedProject}`); } // Step 2: re-build args with the resolved target_dir so codecontext sees // the real absolute path, not a symlink or relative form. // v1.13.18: also resolve file_path when present — the sidecar index is keyed // on absolute paths, so a relative path from the model yields "File not found // in graph". Same escape check as target_dir; ENOENT falls through so the // sidecar produces the canonical "File not found in graph" the model can fix. const argsToSend: Record = { ...req.args, target_dir: resolvedTarget }; if (typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== '') { argsToSend['file_path'] = await resolveProjectPath(resolvedProject, req.args['file_path']); } // Step 3: POST with a hard timeout. AbortController + setTimeout pattern // matches web_fetch.ts; nothing fancier needed. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); let response: Response; try { response = await fetcher(`${CODECONTEXT_BASE_URL}/v1/${req.toolName}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(argsToSend), signal: controller.signal, }); } catch (err) { clearTimeout(timer); if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) { throw new Error(`codecontext request timed out after ${REQUEST_TIMEOUT_MS}ms`); } throw new Error( `codecontext network error: ${err instanceof Error ? err.message : String(err)}`, ); } clearTimeout(timer); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`codecontext HTTP ${response.status}: ${text.slice(0, 200)}`); } const body = (await response.json()) as { result: string | null; error: string | null }; if (body.error) { // Upstream issue #37: empty source files crash codecontext's parser. The // error message reliably contains "content is empty"; surface an // actionable hint instead of the bare codecontext message. if (body.error.includes('content is empty')) { throw new Error( `codecontext parse failure: ${body.error}. ` + `Add the offending path to .codecontextignore in the project root and retry.`, ); } throw new Error(`codecontext error: ${body.error}`); } if (body.result === null) { return { result: '', truncated: false }; } // Step 4: inline truncation. The model gets a clear hint about how to // narrow the next call rather than a silent cut. Mirrors web_fetch.ts. // v1.13.5: stash the full body on tmpfs when truncating so the model can // retrieve more via view_truncated_output(id). if (body.result.length > TRUNCATION_LIMIT) { const truncated = body.result.slice(0, TRUNCATION_LIMIT); const omitted = body.result.length - TRUNCATION_LIMIT; const slicedWithMarker = `${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`; const wrapped = await truncateIfNeeded({ fullContent: body.result, slicedContent: slicedWithMarker, wasTruncated: true, }); return { result: wrapped.content, truncated: wrapped.truncated, ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), }; } return { result: body.result, truncated: false }; }