diff --git a/.changeset/fix-ripgrep-cross-device-install.md b/.changeset/fix-ripgrep-cross-device-install.md new file mode 100644 index 0000000000..2095138af3 --- /dev/null +++ b/.changeset/fix-ripgrep-cross-device-install.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix automatic ripgrep installation when the temporary directory is on a different filesystem. diff --git a/packages/agent-core/src/tools/support/rg-locator.ts b/packages/agent-core/src/tools/support/rg-locator.ts index f8b0ec002f..a5e3c178fb 100644 --- a/packages/agent-core/src/tools/support/rg-locator.ts +++ b/packages/agent-core/src/tools/support/rg-locator.ts @@ -14,9 +14,9 @@ import { createHash } from 'node:crypto'; import { createWriteStream, existsSync } from 'node:fs'; -import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; -import { basename, join } from 'pathe'; +import { basename, dirname, join } from 'pathe'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; @@ -243,8 +243,7 @@ async function downloadAndInstallRg(shareDir: string): Promise { 'CDN content may have changed.', ); } - await rename(extracted, destination); - await chmod(destination, 0o755); + await installExtractedRg(extracted, destination); } return destination; } finally { @@ -252,6 +251,36 @@ async function downloadAndInstallRg(shareDir: string): Promise { } } +/** @internal for tests — install an extracted rg binary into the persistent bin dir. */ +export async function installExtractedRg(extracted: string, destination: string): Promise { + try { + await rename(extracted, destination); + await chmod(destination, 0o755); + return; + } catch (error) { + if (!isCrossDeviceRenameError(error)) throw error; + } + + const stagingDir = await mkdtemp(join(dirname(destination), '.rg-install-')); + const staged = join(stagingDir, basename(destination)); + try { + await copyFile(extracted, staged); + await chmod(staged, 0o755); + await rename(staged, destination); + } finally { + await rm(stagingDir, { recursive: true, force: true }); + } +} + +function isCrossDeviceRenameError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'EXDEV' + ); +} + /** @internal for tests — fail closed before extracting downloaded bytes. */ export async function verifyArchiveChecksum( archivePath: string, diff --git a/packages/agent-core/test/tools/rg-locator.test.ts b/packages/agent-core/test/tools/rg-locator.test.ts index badd679439..105f906163 100644 --- a/packages/agent-core/test/tools/rg-locator.test.ts +++ b/packages/agent-core/test/tools/rg-locator.test.ts @@ -9,7 +9,15 @@ */ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import type * as FsPromises from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -169,6 +177,91 @@ describe('verifyArchiveChecksum', () => { }); }); +describe('installExtractedRg', () => { + let fakeDir: string; + beforeEach(() => { + fakeDir = join( + tmpdir(), + `kimi-rg-install-${String(Date.now())}-${String(Math.random()).slice(2)}`, + ); + mkdirSync(fakeDir, { recursive: true }); + }); + afterEach(() => { + rmSync(fakeDir, { recursive: true, force: true }); + vi.doUnmock('node:fs/promises'); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it('falls back to a target-side staged copy when rename crosses devices', async () => { + const extractDir = join(fakeDir, 'extract'); + const binDir = join(fakeDir, 'bin'); + const extracted = join(extractDir, 'rg'); + const destination = join(binDir, 'rg'); + const payload = '#!/bin/sh\necho ripgrep\n'; + mkdirSync(extractDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync(extracted, payload); + const renameCalls: Array<[string, string]> = []; + + vi.resetModules(); + vi.doMock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + rename: async (from: string, to: string) => { + renameCalls.push([from, to]); + if (from === extracted && to === destination) { + throw Object.assign(new Error('cross-device rename'), { code: 'EXDEV' }); + } + return actual.rename(from, to); + }, + }; + }); + + const { installExtractedRg } = await import('../../src/tools/support/rg-locator'); + + await installExtractedRg(extracted, destination); + + expect(readFileSync(destination, 'utf8')).toBe(payload); + expect(renameCalls).toHaveLength(2); + expect(renameCalls[0]).toEqual([extracted, destination]); + expect(renameCalls[1]?.[0]).toContain(join(binDir, '.rg-install-')); + expect(renameCalls[1]?.[1]).toBe(destination); + if (process.platform !== 'win32') { + expect(statSync(destination).mode & 0o777).toBe(0o755); + } + }); + + it('does not mask non-EXDEV rename failures', async () => { + const extractDir = join(fakeDir, 'extract'); + const binDir = join(fakeDir, 'bin'); + const extracted = join(extractDir, 'rg'); + const destination = join(binDir, 'rg'); + mkdirSync(extractDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync(extracted, 'rg bytes'); + + vi.resetModules(); + vi.doMock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + rename: async () => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + }, + }; + }); + + const { installExtractedRg } = await import('../../src/tools/support/rg-locator'); + + await expect(installExtractedRg(extracted, destination)).rejects.toMatchObject({ + code: 'EACCES', + }); + expect(existsSync(destination)).toBe(false); + }); +}); + describe('ensureRgPath download branch', () => { let fakeShare: string; let savedPath: string | undefined;