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>
156 lines
5.7 KiB
TypeScript
156 lines
5.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
import { executeGetCodebaseOverview } from '../tools/codecontext/get_codebase_overview.js';
|
|
import { executeGetFileAnalysis } from '../tools/codecontext/get_file_analysis.js';
|
|
import { executeGetSymbolInfo } from '../tools/codecontext/get_symbol_info.js';
|
|
import { executeSearchSymbols } from '../tools/codecontext/search_symbols.js';
|
|
import { executeGetDependencies } from '../tools/codecontext/get_dependencies.js';
|
|
import { executeWatchChanges } from '../tools/codecontext/watch_changes.js';
|
|
import { executeGetSemanticNeighborhoods } from '../tools/codecontext/get_semantic_neighborhoods.js';
|
|
import { executeGetFrameworkAnalysis } from '../tools/codecontext/get_framework_analysis.js';
|
|
|
|
// ---- fixtures ---------------------------------------------------------------
|
|
|
|
let projectDir: string;
|
|
|
|
beforeEach(async () => {
|
|
projectDir = await mkdtemp(join(tmpdir(), 'codecontext-tools-test-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(projectDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mockJSONResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Stub fetcher that records every call and returns a canned successful body.
|
|
// Each test inspects fetcher.mock.calls[0] to assert URL + body shape.
|
|
function makeStub() {
|
|
return vi.fn().mockResolvedValue(
|
|
mockJSONResponse({ result: 'wrapped ok', error: null }),
|
|
);
|
|
}
|
|
|
|
function parsePOST(fetcher: ReturnType<typeof makeStub>): {
|
|
url: string;
|
|
body: Record<string, unknown>;
|
|
} {
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetcher.mock.calls[0]! as [string, { body: string }];
|
|
return { url, body: JSON.parse(init.body) };
|
|
}
|
|
|
|
// ---- per-wrapper smoke tests -----------------------------------------------
|
|
|
|
describe('codecontext wrappers — toolName + args forwarding', () => {
|
|
it('get_codebase_overview posts to /v1/get_codebase_overview with include_stats default true', async () => {
|
|
const fetcher = makeStub();
|
|
await executeGetCodebaseOverview({}, projectDir, fetcher as unknown as typeof fetch);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/get_codebase_overview$/);
|
|
expect(body).toMatchObject({ include_stats: true, target_dir: projectDir });
|
|
});
|
|
|
|
it('get_file_analysis forwards file_path', async () => {
|
|
const fetcher = makeStub();
|
|
await executeGetFileAnalysis(
|
|
{ file_path: 'apps/server/src/index.ts' },
|
|
projectDir,
|
|
fetcher as unknown as typeof fetch,
|
|
);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/get_file_analysis$/);
|
|
expect(body).toMatchObject({
|
|
file_path: join(projectDir, 'apps/server/src/index.ts'),
|
|
target_dir: projectDir,
|
|
});
|
|
});
|
|
|
|
it('get_symbol_info forwards symbol_name and omits optional fields when unset', async () => {
|
|
const fetcher = makeStub();
|
|
await executeGetSymbolInfo(
|
|
{ symbol_name: 'buildSystemPrompt' },
|
|
projectDir,
|
|
fetcher as unknown as typeof fetch,
|
|
);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/get_symbol_info$/);
|
|
expect(body).toMatchObject({ symbol_name: 'buildSystemPrompt', target_dir: projectDir });
|
|
expect(body).not.toHaveProperty('file_path');
|
|
expect(body).not.toHaveProperty('framework_type');
|
|
});
|
|
|
|
it('search_symbols defaults limit to 20 and forwards filters when set', async () => {
|
|
const fetcher = makeStub();
|
|
await executeSearchSymbols(
|
|
{ query: 'User', symbol_type: 'class' },
|
|
projectDir,
|
|
fetcher as unknown as typeof fetch,
|
|
);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/search_symbols$/);
|
|
expect(body).toMatchObject({
|
|
query: 'User',
|
|
symbol_type: 'class',
|
|
limit: 20,
|
|
target_dir: projectDir,
|
|
});
|
|
});
|
|
|
|
it('get_dependencies defaults direction to "both"', async () => {
|
|
const fetcher = makeStub();
|
|
await executeGetDependencies({}, projectDir, fetcher as unknown as typeof fetch);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/get_dependencies$/);
|
|
expect(body).toMatchObject({ direction: 'both', target_dir: projectDir });
|
|
expect(body).not.toHaveProperty('file_path');
|
|
});
|
|
|
|
it('watch_changes forwards enable=false', async () => {
|
|
const fetcher = makeStub();
|
|
await executeWatchChanges(
|
|
{ enable: false },
|
|
projectDir,
|
|
fetcher as unknown as typeof fetch,
|
|
);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/watch_changes$/);
|
|
expect(body).toMatchObject({ enable: false, target_dir: projectDir });
|
|
});
|
|
|
|
it('get_semantic_neighborhoods defaults max_results to 10', async () => {
|
|
const fetcher = makeStub();
|
|
await executeGetSemanticNeighborhoods(
|
|
{},
|
|
projectDir,
|
|
fetcher as unknown as typeof fetch,
|
|
);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/get_semantic_neighborhoods$/);
|
|
expect(body).toMatchObject({ max_results: 10, target_dir: projectDir });
|
|
});
|
|
|
|
it('get_framework_analysis sends only target_dir when no args are provided', async () => {
|
|
const fetcher = makeStub();
|
|
await executeGetFrameworkAnalysis(
|
|
{},
|
|
projectDir,
|
|
fetcher as unknown as typeof fetch,
|
|
);
|
|
const { url, body } = parsePOST(fetcher);
|
|
expect(url).toMatch(/\/v1\/get_framework_analysis$/);
|
|
expect(body).toMatchObject({ target_dir: projectDir });
|
|
expect(body).not.toHaveProperty('framework');
|
|
expect(body).not.toHaveProperty('include_stats');
|
|
});
|
|
});
|