diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index 3b8317982..978b0ac41 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -80,6 +80,7 @@ "retry": "0.13.1", "smol-toml": "^1.6.1", "socks": "^2.8.9", + "tar": "^7.5.13", "ulid": "^3.0.1", "undici": "^7.27.1", "yauzl": "^3.3.0", @@ -95,6 +96,7 @@ "@types/react-dom": "^19.1.2", "@types/retry": "0.12.0", "@types/sinon": "^21.0.1", + "@types/tar": "^7.0.87", "@types/yauzl": "^2.10.3", "@types/yazl": "^2.4.6", "@vitejs/plugin-react": "^4.4.1", diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.md b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.md index 6520b64f7..ad299e29a 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.md +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.md @@ -1,6 +1,6 @@ Find files by glob pattern, sorted by modification time (most recent first). -Powered by ripgrep. Respects `.gitignore`, `.ignore`, and `.rgignore` by default — set `include_ignored` to also match ignored files (e.g. build outputs, `node_modules`). Sensitive files (such as `.env`) are always filtered out. +Powered by ripgrep. Respects `.gitignore`, `.ignore`, and `.rgignore` by default — set `include_ignored` to also match ignored files (e.g. build outputs, `node_modules`). Sensitive files (such as `.env`) are always filtered out. Matches are files only — directories themselves are never listed; to find a directory, glob for a file inside it (e.g. `**/fixtures/**`). Good patterns: - `*.ts` — all files matching an extension, at any depth below the search root (a bare pattern without `/` matches recursively) diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts index 9e564cbe7..90c86f6ec 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts @@ -82,13 +82,13 @@ export const GlobInputSchema = z.object({ .string() .optional() .describe( - 'Absolute path to the directory to search in. Defaults to the current working directory.', + 'Directory to search. Accepts an absolute path, or a path relative to the current working directory. Defaults to the current working directory.', ), include_ignored: z .boolean() .optional() .describe( - 'Also match files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. Defaults to false.', + 'Also match files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. VCS metadata directories (`.git` and similar) are always skipped, even when this is true. Defaults to false.', ), include_dirs: z .boolean() diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts index e80fb6d85..e18bff523 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts @@ -1,61 +1,62 @@ /** * `fileTools` domain — shared ripgrep (`rg`) binary locator. * - * Single place that decides which `rg` the Glob and Grep paths run. The lookup - * mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful degradation) - * but is driven through a caller-supplied {@link RgProbe} so it works against - * whatever execution environment the caller has — Glob/Grep probe through the - * host `IHostProcessService`. Both run `rg --version` and treat exit code 0 as - * "available". - * - * Lookup order (first hit wins): - * 1. System `rg` on the execution-environment PATH (`rg --version`). - * 2. Persistent cache at `/bin/rg` — where a - * previously bootstrapped or manually dropped static binary lives. Only - * attempted when `allowCachedFallback` is set (Glob); Grep keeps its own - * pure-node fallback and opts out so its "rg missing → node fallback" - * path stays deterministic. - * - * If nothing resolves, {@link ensureRgPath} throws and callers surface - * {@link rgUnavailableMessage} instead of a naked `spawn rg ENOENT`. - * - * Ported from `session/sessionFs/rgLocator` onto the os tools: the probe is now - * backed by `IHostProcessService` instead of the session process runner. + * Resolves the `rg` command used by Glob and Grep through a caller-supplied + * process probe, preferring the execution-environment PATH, then the vendor + * hook, then the app cache, and finally bootstrapping a pinned ripgrep + * archive into `/bin` when the caller permits it. + * Keeps callers that own a no-download fallback path opt-in-compatible. */ -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { createWriteStream, existsSync } from 'node:fs'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; -/** Where the resolved `rg` came from. Used for fallback telemetry. */ -export type RgResolutionSource = 'system-path' | 'share-bin-cached'; +import { extract as extractTar } from 'tar'; +import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; +import { basename, join } from 'pathe'; + +import { abortable } from '#/_base/utils/abort'; + +const RG_VERSION = '15.0.0'; +const RG_BASE_URL = 'https://code.kimi.com/kimi-code/rg'; +const DOWNLOAD_TIMEOUT_MS = 600_000; +const RG_ARCHIVE_SHA256: Record = { + 'ripgrep-15.0.0-aarch64-apple-darwin.tar.gz': + '98bb2e61e7277ba0ea72d2ae2592497fd8d2940934a16b122448d302a6637e3b', + 'ripgrep-15.0.0-aarch64-pc-windows-msvc.zip': + '572709c8770cb7f9385d725cb06d2bcd9537ec24d4dd17b1be1d65a876f8b591', + 'ripgrep-15.0.0-aarch64-unknown-linux-gnu.tar.gz': + '15f8cc2fab12d88491c54d49f38589922a9d6a7353c29b0a0856727bcdf80754', + 'ripgrep-15.0.0-x86_64-apple-darwin.tar.gz': + '44128c733d127ddbda461e01225a68b5f9997cfe7635242a797f645ca674a71a', + 'ripgrep-15.0.0-x86_64-pc-windows-msvc.zip': + '21a98bf42c4da97ca543c010e764cc6dec8b9b7538d05f8d21874016385e0860', + 'ripgrep-15.0.0-x86_64-unknown-linux-musl.tar.gz': + '253ad0fd5fef0d64cba56c70dccdacc1916d4ed70ad057cc525fcdb0c3bbd2a7', +}; + +export type RgResolutionSource = + | 'system-path' + | 'vendor' + | 'share-bin-cached' + | 'share-bin-downloaded'; export interface RgResolution { - /** Command or absolute path to pass as argv[0] when spawning `rg`. */ readonly path: string; readonly source: RgResolutionSource; } -/** - * Minimal probe surface the locator runs against. Lets the same locator run - * over Glob's and Grep's `IHostProcessService` without depending on it - * directly. - */ export interface RgProbe { - /** Run `argv` and resolve with the process exit code. */ exec(args: readonly string[]): Promise<{ readonly exitCode: number }>; } export interface EnsureRgPathOptions { - /** - * Cancels this caller's wait. Checked between probe steps; an aborted signal - * makes {@link ensureRgPath} throw an `AbortError`. - */ - readonly signal?: AbortSignal; - /** - * When true, fall back to the cached binary at `/bin/rg` if `rg` is - * not on PATH. Defaults to false so callers with their own fallback (Grep's - * node walker) keep deterministic behavior. - */ + readonly shareDir?: string | undefined; + readonly signal?: AbortSignal | undefined; readonly allowCachedFallback?: boolean; } @@ -69,7 +70,6 @@ function getShareDir(): string { return join(homedir(), '.kimi-code'); } -/** Absolute path of the cached `rg` binary, if one has been installed. */ export function getShareBinRgPath(): string { return join(getShareDir(), 'bin', rgBinaryName()); } @@ -80,44 +80,245 @@ function throwIfAborted(signal: AbortSignal | undefined): void { } } -/** - * Resolve a usable `rg`. Probes `rg --version` through `probe`; on a non-zero - * exit (and only when `allowCachedFallback` is set) tries the cached binary - * before giving up. Throws when no working `rg` can be found. - */ export async function ensureRgPath( probe: RgProbe, options: EnsureRgPathOptions = {}, ): Promise { throwIfAborted(options.signal); + const shareDir = options.shareDir ?? getShareDir(); + const resolution = resolveRgPath(probe, shareDir, options); + return options.signal === undefined ? resolution : abortable(resolution, options.signal); +} +async function resolveRgPath( + probe: RgProbe, + shareDir: string, + options: EnsureRgPathOptions, +): Promise { + const existing = await findExistingRg(probe, shareDir, options.allowCachedFallback === true); + if (existing) return existing; + throwIfAborted(options.signal); + if (options.allowCachedFallback === true) { + return downloadRgWithLock(probe, shareDir); + } + throw new Error('ripgrep (rg) is not available on PATH'); +} + +export async function findExistingRg( + probe: RgProbe, + shareDir: string = getShareDir(), + allowCachedFallback = true, +): Promise { const system = await probe.exec(['rg', '--version']).catch(() => ({ exitCode: -1 })); if (system.exitCode === 0) { return { path: 'rg', source: 'system-path' }; } - if (options.allowCachedFallback === true) { - throwIfAborted(options.signal); - const cached = getShareBinRgPath(); - const cachedRun = await probe.exec([cached, '--version']).catch(() => ({ exitCode: -1 })); - if (cachedRun.exitCode === 0) { - return { path: cached, source: 'share-bin-cached' }; + if (allowCachedFallback) { + const vendorPath = getVendorRgPath(rgBinaryName()); + if (vendorPath !== undefined && (await isUsableRg(probe, vendorPath))) { + return { path: vendorPath, source: 'vendor' }; + } + const cachePath = join(shareDir, 'bin', rgBinaryName()); + if (await isUsableRg(probe, cachePath)) { + return { path: cachePath, source: 'share-bin-cached' }; } } - throw new Error('ripgrep (rg) is not available on PATH'); + return undefined; +} + +let downloadPromise: Promise | undefined; +async function downloadRgWithLock(probe: RgProbe, shareDir: string): Promise { + if (downloadPromise !== undefined) return downloadPromise; + downloadPromise = (async () => { + try { + const existing = await findExistingRg(probe, shareDir, true); + if (existing) return existing; + const binPath = await downloadAndInstallRg(shareDir); + return { path: binPath, source: 'share-bin-downloaded' }; + } finally { + downloadPromise = undefined; + } + })(); + return downloadPromise; +} + +function getVendorRgPath(_binName: string): string | undefined { + return undefined; +} + +async function isUsableRg(probe: RgProbe, path: string): Promise { + const run = await probe.exec([path, '--version']).catch(() => ({ exitCode: -1 })); + return run.exitCode === 0; +} + +export function detectTarget(): string | undefined { + const arch = process.arch === 'x64' ? 'x86_64' : process.arch === 'arm64' ? 'aarch64' : undefined; + if (arch === undefined) return undefined; + + if (process.platform === 'darwin') return `${arch}-apple-darwin`; + if (process.platform === 'linux') { + return arch === 'x86_64' ? 'x86_64-unknown-linux-musl' : 'aarch64-unknown-linux-gnu'; + } + if (process.platform === 'win32') return `${arch}-pc-windows-msvc`; + return undefined; +} + +async function downloadAndInstallRg(shareDir: string): Promise { + const target = detectTarget(); + if (target === undefined) { + throw new Error( + `Unsupported platform/arch for ripgrep download: ${process.platform}/${process.arch}`, + ); + } + + const isWindows = target.includes('windows'); + const archiveExt = isWindows ? 'zip' : 'tar.gz'; + const archiveName = `ripgrep-${RG_VERSION}-${target}.${archiveExt}`; + const expectedSha256 = RG_ARCHIVE_SHA256[archiveName]; + if (expectedSha256 === undefined) { + throw new Error(`No pinned SHA-256 is configured for ripgrep archive ${archiveName}`); + } + const url = `${RG_BASE_URL}/${archiveName}`; + + const binDir = join(shareDir, 'bin'); + await mkdir(binDir, { recursive: true }); + const destination = join(binDir, rgBinaryName()); + + const tmp = await mkdtemp(join(tmpdir(), 'kimi-rg-')); + try { + const archivePath = join(tmp, archiveName); + + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => { + controller.abort(); + }, DOWNLOAD_TIMEOUT_MS); + let resp: Response; + try { + resp = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutHandle); + } + if (!resp.ok || resp.body === null) { + throw new Error(`Failed to download ripgrep: HTTP ${String(resp.status)} ${resp.statusText}`); + } + const write = createWriteStream(archivePath); + await pipeline(Readable.fromWeb(resp.body as never), write); + await verifyArchiveChecksum(archivePath, archiveName, expectedSha256); + + if (isWindows) { + await extractRgFromZip(archivePath, destination); + } else { + const extractDir = join(tmp, 'extract'); + await mkdir(extractDir, { recursive: true }); + await extractTar({ + file: archivePath, + cwd: extractDir, + gzip: true, + filter: (entryPath: string) => entryPath.endsWith(`/${rgBinaryName()}`), + }); + const extracted = join(extractDir, `ripgrep-${RG_VERSION}-${target}`, rgBinaryName()); + if (!existsSync(extracted)) { + throw new Error( + `Ripgrep archive did not contain expected binary at ${extracted}. ` + + 'CDN content may have changed.', + ); + } + const installDir = await mkdtemp(join(binDir, '.rg-install-')); + const staged = join(installDir, rgBinaryName()); + try { + await copyFile(extracted, staged); + await chmod(staged, 0o755); + await rename(staged, destination); + } finally { + await rm(installDir, { recursive: true, force: true }); + } + } + return destination; + } finally { + await rm(tmp, { recursive: true, force: true }); + } +} + +export async function verifyArchiveChecksum( + archivePath: string, + archiveName: string, + expectedSha256: string, +): Promise { + const actualSha256 = createHash('sha256') + .update(await readFile(archivePath)) + .digest('hex'); + if (actualSha256 !== expectedSha256) { + throw new Error( + `Ripgrep archive checksum mismatch for ${archiveName}: expected ${expectedSha256}, ` + + `got ${actualSha256}. CDN content may have changed.`, + ); + } +} + +export async function extractRgFromZip(archivePath: string, destination: string): Promise { + const buf = await readFile(archivePath); + const binName = rgBinaryName(); + await new Promise((resolve, reject) => { + yauzlFromBuffer(buf, { lazyEntries: true }, (openErr, zipfile) => { + if (openErr !== null || zipfile === undefined) { + reject(new Error(`Failed to open ripgrep archive: ${openErr?.message ?? 'unknown error'}`)); + return; + } + let found = false; + const onEntry = (entry: Entry): void => { + if (basename(entry.fileName) !== binName) { + zipfile.readEntry(); + return; + } + found = true; + zipfile.openReadStream(entry, (streamErr, stream) => { + if (streamErr !== null) { + reject( + new Error(`Failed to read ${entry.fileName} from archive: ${streamErr.message}`), + ); + zipfile.close(); + return; + } + const out = createWriteStream(destination); + void (async () => { + try { + await pipeline(stream, out); + zipfile.close(); + resolve(); + } catch (error) { + zipfile.close(); + reject(error instanceof Error ? error : new Error(String(error))); + } + })(); + }); + }; + zipfile.on('entry', onEntry); + zipfile.on('end', () => { + if (!found) { + reject( + new Error( + `Ripgrep archive did not contain expected binary '${binName}'. ` + + 'CDN content may have changed.', + ), + ); + } + }); + zipfile.on('error', (err: Error) => { + reject(err); + }); + zipfile.readEntry(); + }); + }); } -/** - * User-facing message when {@link ensureRgPath} throws. Kept in one place so - * the Glob / Grep plumbing surfaces the same actionable hint. - */ export function rgUnavailableMessage(cause: unknown): string { const detail = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : 'unknown error'; const shareBin = getShareBinRgPath(); return ( - `ripgrep (rg) is not available.\n` + + `ripgrep (rg) is not available and the automatic bootstrap failed.\n` + `\n` + `Error: ${detail}\n` + `\n` + diff --git a/packages/agent-core-v2/test/fileTools/rgLocator.test.ts b/packages/agent-core-v2/test/fileTools/rgLocator.test.ts new file mode 100644 index 000000000..523005abc --- /dev/null +++ b/packages/agent-core-v2/test/fileTools/rgLocator.test.ts @@ -0,0 +1,468 @@ +/** + * Covers: rg-locator (ripgrep hybrid binary resolution). + * + * Pure-lookup pins (no real CDN download): + * - `findExistingRg` returns undefined when PATH + share-bin are both empty + * - Resolves from `/bin/rg` when that binary exists + * - Prefers system PATH over share-dir cache when both are available + * - `rgUnavailableMessage` surfaces the underlying cause + install hints + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +import { extract as extractTar } from 'tar'; +import { ZipFile } from 'yazl'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + detectTarget, + ensureRgPath, + extractRgFromZip, + findExistingRg, + rgUnavailableMessage, + verifyArchiveChecksum, + type RgProbe, +} from '#/os/backends/node-local/tools/rgLocator'; + +vi.mock('tar', () => ({ extract: vi.fn() })); + +function probeWith( + resolveExitCode: (args: readonly string[]) => number, +): RgProbe & { exec: ReturnType } { + return { + exec: vi.fn(async (args: readonly string[]) => ({ exitCode: resolveExitCode(args) })), + }; +} + +function noRgProbe(): RgProbe & { exec: ReturnType } { + return probeWith(() => -1); +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('findExistingRg', () => { + let fakeShare: string; + beforeEach(() => { + fakeShare = join(tmpdir(), `kimi-rg-${String(Date.now())}-${String(Math.random()).slice(2)}`); + mkdirSync(join(fakeShare, 'bin'), { recursive: true }); + }); + afterEach(() => { + rmSync(fakeShare, { recursive: true, force: true }); + }); + + it('returns undefined when no rg anywhere', async () => { + const result = await findExistingRg(noRgProbe(), fakeShare); + expect(result).toBeUndefined(); + }); + + it('resolves from share-dir when cached', async () => { + const cached = join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg'); + const probe = probeWith((args) => (args[0] === cached ? 0 : -1)); + const result = await findExistingRg(probe, fakeShare); + + expect(result).toEqual({ path: cached, source: 'share-bin-cached' }); + }); + + it('prefers system PATH over share-dir when both are available', async () => { + const probe = probeWith((args) => (args[0] === 'rg' ? 0 : -1)); + const result = await findExistingRg(probe, fakeShare); + + expect(result).toEqual({ path: 'rg', source: 'system-path' }); + expect(probe.exec).toHaveBeenCalledTimes(1); + }); +}); + +describe('detectTarget', () => { + let savedArch: string; + let savedPlatform: string; + beforeEach(() => { + savedArch = process.arch; + savedPlatform = process.platform; + }); + afterEach(() => { + Object.defineProperty(process, 'arch', { value: savedArch }); + Object.defineProperty(process, 'platform', { value: savedPlatform }); + }); + + function setPlatform(arch: string, platform: string): void { + Object.defineProperty(process, 'arch', { value: arch }); + Object.defineProperty(process, 'platform', { value: platform }); + } + + it('darwin arm64 -> aarch64-apple-darwin', () => { + setPlatform('arm64', 'darwin'); + expect(detectTarget()).toBe('aarch64-apple-darwin'); + }); + it('darwin x64 -> x86_64-apple-darwin', () => { + setPlatform('x64', 'darwin'); + expect(detectTarget()).toBe('x86_64-apple-darwin'); + }); + it('linux x64 -> x86_64-unknown-linux-musl', () => { + setPlatform('x64', 'linux'); + expect(detectTarget()).toBe('x86_64-unknown-linux-musl'); + }); + it('linux arm64 -> aarch64-unknown-linux-gnu', () => { + setPlatform('arm64', 'linux'); + expect(detectTarget()).toBe('aarch64-unknown-linux-gnu'); + }); + it('win32 x64 -> x86_64-pc-windows-msvc', () => { + setPlatform('x64', 'win32'); + expect(detectTarget()).toBe('x86_64-pc-windows-msvc'); + }); + it('unsupported arch -> undefined', () => { + setPlatform('mips', 'linux'); + expect(detectTarget()).toBeUndefined(); + }); +}); + +describe('rgUnavailableMessage', () => { + it('surfaces the underlying cause and install hints', () => { + const msg = rgUnavailableMessage(new Error('fetch failed')); + expect(msg).toContain('automatic bootstrap failed'); + expect(msg).toContain('fetch failed'); + expect(msg).toContain('brew install ripgrep'); + expect(msg).toContain('https://github.com/BurntSushi/ripgrep'); + }); + + it('handles non-Error causes (string, unknown)', () => { + const a = rgUnavailableMessage('boom'); + expect(a).toContain('boom'); + const b = rgUnavailableMessage(42); + expect(b).toContain('unknown error'); + }); +}); + +describe('verifyArchiveChecksum', () => { + let fakeDir: string; + beforeEach(() => { + fakeDir = join(tmpdir(), `kimi-rg-sha-${String(Date.now())}-${String(Math.random()).slice(2)}`); + mkdirSync(fakeDir, { recursive: true }); + }); + afterEach(() => { + rmSync(fakeDir, { recursive: true, force: true }); + }); + + it('accepts a file whose SHA-256 matches the expected digest', async () => { + const archivePath = join(fakeDir, 'archive.tar.gz'); + const payload = Buffer.from('trusted archive bytes', 'utf8'); + writeFileSync(archivePath, payload); + const expectedSha256 = createHash('sha256').update(payload).digest('hex'); + + await expect( + verifyArchiveChecksum(archivePath, 'archive.tar.gz', expectedSha256), + ).resolves.toBeUndefined(); + }); + + it('rejects a file whose SHA-256 differs from the expected digest', async () => { + const archivePath = join(fakeDir, 'archive.tar.gz'); + writeFileSync(archivePath, 'tampered archive bytes'); + + await expect( + verifyArchiveChecksum(archivePath, 'archive.tar.gz', '0'.repeat(64)), + ).rejects.toThrow(/checksum mismatch/); + }); +}); + +describe('ensureRgPath download branch', () => { + let fakeShare: string; + let savedFetch: typeof globalThis.fetch | undefined; + beforeEach(() => { + fakeShare = join( + tmpdir(), + `kimi-rg-dl-${String(Date.now())}-${String(Math.random()).slice(2)}`, + ); + mkdirSync(join(fakeShare, 'bin'), { recursive: true }); + savedFetch = globalThis.fetch; + }); + afterEach(() => { + rmSync(fakeShare, { recursive: true, force: true }); + if (savedFetch === undefined) { + delete (globalThis as unknown as { fetch?: typeof fetch }).fetch; + } else { + globalThis.fetch = savedFetch; + } + vi.restoreAllMocks(); + }); + + it('does not bootstrap when allowCachedFallback is false', async () => { + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(ensureRgPath(noRgProbe(), { shareDir: fakeShare })).rejects.toThrow(/on PATH/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('surfaces a network error when fetch rejects', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('network unreachable')) as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(/network unreachable/); + }); + + it('does not start bootstrap work when the caller is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { + shareDir: fakeShare, + signal: controller.signal, + allowCachedFallback: true, + }), + ).rejects.toHaveProperty('name', 'AbortError'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not start bootstrap work when aborted after lookup misses', async () => { + const controller = new AbortController(); + const fetchMock = vi.fn(() => new Promise(() => {})); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const cacheProbe = deferred<{ readonly exitCode: number }>(); + const probe: RgProbe & { exec: ReturnType } = { + exec: vi + .fn() + .mockResolvedValueOnce({ exitCode: -1 }) + .mockReturnValueOnce(cacheProbe.promise), + }; + + const resultPromise = ensureRgPath(probe, { + shareDir: fakeShare, + signal: controller.signal, + allowCachedFallback: true, + }); + + await vi.waitFor(() => { + expect(probe.exec).toHaveBeenCalledTimes(2); + }); + controller.abort(); + await expect(resultPromise).rejects.toHaveProperty('name', 'AbortError'); + + cacheProbe.resolve({ exitCode: -1 }); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('aborts the current caller wait while shared bootstrap work continues', async () => { + const controller = new AbortController(); + const fetchResponse = deferred<{ + readonly ok: false; + readonly status: number; + readonly statusText: string; + readonly body: null; + }>(); + globalThis.fetch = vi.fn(() => fetchResponse.promise) as unknown as typeof fetch; + + const resultPromise = ensureRgPath(noRgProbe(), { + shareDir: fakeShare, + signal: controller.signal, + allowCachedFallback: true, + }); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + controller.abort(); + await expect(resultPromise).rejects.toHaveProperty('name', 'AbortError'); + + fetchResponse.resolve({ ok: false, status: 499, statusText: 'Client Closed', body: null }); + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(/HTTP 499 Client Closed/); + }); + + it('surfaces HTTP failure (non-2xx response) with status + statusText', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + body: null, + }) as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(/HTTP 404 Not Found/); + }); + + it('fetches ripgrep over HTTPS', async () => { + const body = bodyFromBuffer(Buffer.from('not a real archive', 'utf8')); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body, + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(new URL(url).protocol).toBe('https:'); + }); + + it('rejects archives that do not match the pinned SHA-256 before extraction', async () => { + const tarMock = vi.mocked(extractTar); + tarMock.mockClear(); + const body = bodyFromBuffer(Buffer.from('tampered archive', 'utf8')); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body, + }) as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(/checksum/i); + + expect(tarMock).not.toHaveBeenCalled(); + expect(existsSync(join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg'))).toBe( + false, + ); + }); +}); + +function buildFixtureZip(entries: Array<{ name: string; content: Buffer }>): Promise { + return new Promise((resolve, reject) => { + const zip = new ZipFile(); + for (const { name, content } of entries) { + zip.addBuffer(content, name); + } + zip.end(); + const chunks: Buffer[] = []; + zip.outputStream.on('data', (c: Buffer) => { + chunks.push(c); + }); + zip.outputStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + zip.outputStream.on('error', reject); + }); +} + +function bodyFromBuffer(buf: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buf)); + controller.close(); + }, + }); +} + +describe('ensureRgPath Windows download branch', () => { + let fakeShare: string; + let savedFetch: typeof globalThis.fetch | undefined; + let savedArch: string; + let savedPlatform: string; + beforeEach(() => { + fakeShare = join( + tmpdir(), + `kimi-rg-win-${String(Date.now())}-${String(Math.random()).slice(2)}`, + ); + mkdirSync(join(fakeShare, 'bin'), { recursive: true }); + savedFetch = globalThis.fetch; + savedArch = process.arch; + savedPlatform = process.platform; + Object.defineProperty(process, 'arch', { value: 'x64' }); + Object.defineProperty(process, 'platform', { value: 'win32' }); + }); + afterEach(() => { + rmSync(fakeShare, { recursive: true, force: true }); + if (savedFetch === undefined) { + delete (globalThis as unknown as { fetch?: typeof fetch }).fetch; + } else { + globalThis.fetch = savedFetch; + } + Object.defineProperty(process, 'arch', { value: savedArch }); + Object.defineProperty(process, 'platform', { value: savedPlatform }); + vi.restoreAllMocks(); + }); + + it('fetches the .zip URL (not .tar.gz) on Windows target', async () => { + const zipBuf = await buildFixtureZip([ + { + name: 'ripgrep-15.0.0-x86_64-pc-windows-msvc/rg.exe', + content: Buffer.from('MZfake-pe-bytes', 'utf8'), + }, + ]); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: bodyFromBuffer(zipBuf), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(/checksum mismatch/); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toMatch(/ripgrep-15\.0\.0-x86_64-pc-windows-msvc\.zip$/); + }); + + it('extracts rg.exe into /bin/rg.exe', async () => { + const payload = Buffer.from('MZfake-pe-bytes-extracted', 'utf8'); + const zipBuf = await buildFixtureZip([ + { + name: 'ripgrep-15.0.0-x86_64-pc-windows-msvc/rg.exe', + content: payload, + }, + ]); + + const archivePath = join(fakeShare, 'fixture.zip'); + const installed = join(fakeShare, 'bin', 'rg.exe'); + writeFileSync(archivePath, zipBuf); + + await extractRgFromZip(archivePath, installed); + + expect(existsSync(installed)).toBe(true); + expect(readFileSync(installed)).toEqual(payload); + }); + + it('throws with "CDN content may have changed" when the zip omits rg.exe', async () => { + const zipBuf = await buildFixtureZip([{ name: 'README.md', content: Buffer.from('readme') }]); + const archivePath = join(fakeShare, 'fixture.zip'); + const installed = join(fakeShare, 'bin', 'rg.exe'); + writeFileSync(archivePath, zipBuf); + + await expect(extractRgFromZip(archivePath, installed)).rejects.toThrow( + /CDN content may have changed/, + ); + }); + + it('surfaces HTTP failure on Windows with status + statusText', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + body: null, + }) as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(/HTTP 502 Bad Gateway/); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e71a63c20..a75d204a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,6 +547,9 @@ importers: socks: specifier: ^2.8.9 version: 2.8.9 + tar: + specifier: ^7.5.13 + version: 7.5.13 ulid: specifier: ^3.0.1 version: 3.0.2 @@ -587,6 +590,9 @@ importers: '@types/sinon': specifier: ^21.0.1 version: 21.0.1 + '@types/tar': + specifier: ^7.0.87 + version: 7.0.87 '@types/yauzl': specifier: ^2.10.3 version: 2.10.3