// 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 { realpath } from 'node:fs/promises'; import { truncateIfNeeded } from './truncate.js'; 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); 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. const argsToSend = { ...req.args, target_dir: resolvedTarget }; // 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 }; }