Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-ripgrep-cross-device-install.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 33 additions & 4 deletions packages/agent-core/src/tools/support/rg-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -243,15 +243,44 @@ async function downloadAndInstallRg(shareDir: string): Promise<string> {
'CDN content may have changed.',
);
}
await rename(extracted, destination);
await chmod(destination, 0o755);
await installExtractedRg(extracted, destination);
}
return destination;
} finally {
await rm(tmp, { recursive: true, force: true });
}
}

/** @internal for tests — install an extracted rg binary into the persistent bin dir. */
export async function installExtractedRg(extracted: string, destination: string): Promise<void> {
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,
Expand Down
95 changes: 94 additions & 1 deletion packages/agent-core/test/tools/rg-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof FsPromises>('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<typeof FsPromises>('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;
Expand Down