Files
boocode/apps/server/src/services/codecontext_client.ts
indifferentketchup 1a889dcde3 v1.13.18-codecontext-file-path: resolve file_path against project root in codecontext wrappers
Four codecontext sidecar wrappers — get_file_analysis (required
file_path), get_symbol_info, get_dependencies, and get_semantic_neighborhoods
(optional) — forwarded file_path to the HTTP sidecar unchanged. The
sidecar's internal file index is keyed on absolute paths, so any
relative path from the model returned "File not found in graph".
Three back-to-back failures observed in one chat on 2026-05-22
17:56 UTC, ~48 s of wasted tool budget.

## Resolver

Add resolveProjectPath(projectRoot, rawPath) in codecontext_client.ts:
trim check → absolute/relative branch (both go through resolve() so
dot-segments normalise) → realpath with ENOENT fallthrough → escape
check using the realpathed value. Error shape mirrors the existing
target_dir escape error byte-for-byte; only the field name differs.

Wired into callCodecontext at the args-spread site, guarded on
file_path presence + non-empty. All four wrappers benefit from one
call site; wrappers without file_path (overview, framework, watch,
search) are unaffected.

## Schema trim

.trim() added to all four file_path Zod schemas:

  get_file_analysis:                  z.string().trim().min(1)
  get_symbol_info:                    z.string().trim().optional()
  get_dependencies:                   z.string().trim().optional()
  get_semantic_neighborhoods:         z.string().trim().optional()

Absorbs trailing newlines / whitespace from model output before the
resolver sees the value.

## Adversarial review fixes

Adversarial pass surfaced two P2 findings:

1. Absolute path with `..` resolving outside the project root (e.g.
   `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip
   through the literal prefix-check: the raw string starts with
   `<projectRoot>/`. Fix: resolve() the absolute branch's candidate
   too, so dot-segments normalise before the prefix check.

2. No symlink-escape test coverage. Realpath's stated purpose
   (catching in-project symlinks pointing outside the project) was
   never tested. Added: create a tmpdir outside projectRoot,
   symlink projectRoot/evil-link → outside file, assert rejection.

## Tests

codecontext_client.test.ts: 19 tests (10 baseline + 9 new file_path
resolution cases). Cases cover: relative→absolute, absolute-inside,
relative-escape, absolute-outside, ENOENT-fallthrough, empty-string,
wrapper-without-file_path, absolute-with-`..`-ENOENT,
symlink-leaving-root.

codecontext_tools.test.ts: one assertion updated to expect the
resolved-absolute file_path on the wire (previously asserted the raw
relative path passed through, which is exactly the bug being fixed).

Full suite: 301 passed, 7 skipped.

## Affected / unaffected

- get_codebase_overview, get_framework_analysis, watch_changes,
  search_symbols: no file_path arg → resolver guard skips them. No
  behavior change.
- get_semantic_neighborhoods IS in SYNTHESIS_TOOLS — previously-failing
  relative-path calls will now successfully synthesize. Desirable, not
  a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:54:16 +00:00

214 lines
9.5 KiB
TypeScript

// 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<string>();
async function ensureIgnoreFile(projectRoot: string): Promise<void> {
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<string> {
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<string, unknown>;
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<CodecontextResponse> {
// 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<string, unknown> = { ...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 };
}