Skip to content
Open
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-runtime-path.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
---

Make packaged runtimes provide a deterministic ripgrep binary for search tools.
7 changes: 6 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
nativeBuildInputs = [
nodejs
pnpm
pkgs.makeWrapper
(pkgs.pnpmConfigHook.override { inherit pnpm; })
]
# The SEA inject step (postject) invalidates the macOS code
Expand Down Expand Up @@ -183,7 +184,10 @@

install -Dm755 \
"apps/kimi-code/dist-native/bin/${nativeTarget}/kimi" \
"$out/bin/kimi"
"$out/libexec/kimi-code/kimi"

makeWrapper "$out/libexec/kimi-code/kimi" "$out/bin/kimi" \
--set-default KIMI_CODE_RG_PATH "${pkgs.ripgrep}/bin/rg"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid forcing local rg path for SSH workspaces

When the Nix-packaged CLI is used with an SSHKaos workspace, Grep resolves KIMI_CODE_RG_PATH locally and then passes that absolute path into kaos.exec; SSHKaos turns those args into a command executed on the remote host. This wrapper now sets the variable to the client-side Nix store path, so remotes that have rg on their own PATH but not the exact same /nix/store/.../bin/rg will run a nonexistent command and all Grep searches fail instead of using the remote binary.

Useful? React with 👍 / 👎.


runHook postInstall
'';
Expand Down Expand Up @@ -221,6 +225,7 @@
packages = [
nodejs
pnpm
pkgs.ripgrep
];
};
});
Expand Down
52 changes: 38 additions & 14 deletions packages/agent-core/src/tools/support/rg-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,30 @@
* rg-locator — hybrid ripgrep binary resolution.
*
* Lookup order (first hit wins):
* 1. System PATH (`which rg`) — fastest, respects developer setup
* 2. Bundled vendor binary (hook; not wired yet — `getVendorRgPath` is a stub)
* 3. `<KIMI_CODE_HOME>/bin/rg` — persistent cache for this app.
* 4. CDN download to <KIMI_CODE_HOME>/bin/ — one-off bootstrap
* 1. `KIMI_CODE_RG_PATH` — deterministic runtime/vendor override
* 2. System PATH (`which rg`) — fastest, respects developer setup
* 3. Bundled vendor binary (hook; not wired yet — `getVendorRgPath` is a stub)
* 4. `<KIMI_CODE_HOME>/bin/rg` — persistent cache for this app.
* 5. CDN download to <KIMI_CODE_HOME>/bin/ — one-off bootstrap
*
* If steps 1-4 all fail, callers receive a structured error they can
* If steps 1-5 all fail, callers receive a structured error they can
* turn into a user-facing "install ripgrep" hint instead of the naked
* `spawn rg ENOENT`.
*/

import { createHash } from 'node:crypto';
import { createWriteStream, existsSync } from 'node:fs';
import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises';
import { constants as fsConstants, createWriteStream, existsSync } from 'node:fs';
import {
access,
chmod,
copyFile,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
} from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, join } from 'pathe';
import { Readable } from 'node:stream';
Expand All @@ -28,6 +39,7 @@ import { abortable } from '../../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_PATH_ENV = 'KIMI_CODE_RG_PATH';
const RG_ARCHIVE_SHA256: Record<string, string> = {
'ripgrep-15.0.0-aarch64-apple-darwin.tar.gz':
'98bb2e61e7277ba0ea72d2ae2592497fd8d2940934a16b122448d302a6637e3b',
Expand All @@ -44,6 +56,7 @@ const RG_ARCHIVE_SHA256: Record<string, string> = {
};

export type RgResolutionSource =
| 'env-path'
| 'system-path'
| 'vendor'
| 'share-bin-cached'
Expand Down Expand Up @@ -91,6 +104,11 @@ async function resolveRgPath(
*/
export async function findExistingRg(shareDir: string): Promise<RgResolution | undefined> {
const binName = rgBinaryName();
const envRg = getEnvRgPath();
if (envRg !== undefined) {
if (await isExecutableFile(envRg)) return { path: envRg, source: 'env-path' };
throw new Error(`${RG_PATH_ENV} points to ${envRg}, but it is not an executable file`);
}
const systemRg = await whichRg();
if (systemRg !== undefined) return { path: systemRg, source: 'system-path' };
const vendorPath = getVendorRgPath(binName);
Expand Down Expand Up @@ -134,27 +152,31 @@ function getVendorRgPath(_binName: string): string | undefined {
return undefined;
}

function getEnvRgPath(): string | undefined {
const override = process.env[RG_PATH_ENV];
if (override === undefined || override === '') return undefined;
return override;
}

async function whichRg(): Promise<string | undefined> {
const pathEnv = process.env['PATH'] ?? '';
const sep = process.platform === 'win32' ? ';' : ':';
const binName = rgBinaryName();
for (const dir of pathEnv.split(sep)) {
if (dir === '') continue;
const candidate = join(dir, binName);
try {
const st = await stat(candidate);
if (st.isFile()) return candidate;
} catch {
/* not here, try next */
}
if (await isExecutableFile(candidate)) return candidate;
}
return undefined;
}

async function isExecutableFile(p: string): Promise<boolean> {
try {
const st = await stat(p);
return st.isFile();
if (!st.isFile()) return false;
if (process.platform === 'win32') return true;
await access(p, fsConstants.X_OK);
return true;
} catch {
return false;
}
Expand Down Expand Up @@ -365,6 +387,8 @@ export function rgUnavailableMessage(cause: unknown): string {
` Ubuntu: sudo apt-get install ripgrep\n` +
` Other: https://github.com/BurntSushi/ripgrep#installation\n` +
`\n` +
`For packaged runtimes, set ${RG_PATH_ENV}=/absolute/path/to/rg.\n` +
`\n` +
`Alternatively, drop a static rg binary at ${shareBin}`
);
}
38 changes: 38 additions & 0 deletions packages/agent-core/test/tools/rg-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*
* Pure-lookup pins (no real CDN download):
* - `findExistingRg` returns undefined when PATH + share-bin are both empty
* - `KIMI_CODE_RG_PATH` wins over PATH/cache and fails fast when invalid
* - Resolves from `<shareDir>/bin/rg` when that binary exists
* - Prefers system PATH over share-dir cache when both are available
* - `rgUnavailableMessage` surfaces the underlying cause + install hints
Expand Down Expand Up @@ -35,24 +36,61 @@ vi.mock('tar', () => ({ extract: vi.fn() }));
describe('findExistingRg', () => {
let fakeShare: string;
let savedPath: string | undefined;
let savedRgPath: string | undefined;
beforeEach(() => {
fakeShare = join(tmpdir(), `kimi-rg-${String(Date.now())}-${String(Math.random()).slice(2)}`);
mkdirSync(join(fakeShare, 'bin'), { recursive: true });
savedPath = process.env['PATH'];
savedRgPath = process.env['KIMI_CODE_RG_PATH'];
// Empty PATH → rules out step 1 (system-path) for the default case.
process.env['PATH'] = '';
delete process.env['KIMI_CODE_RG_PATH'];
});
afterEach(() => {
rmSync(fakeShare, { recursive: true, force: true });
if (savedPath === undefined) delete process.env['PATH'];
else process.env['PATH'] = savedPath;
if (savedRgPath === undefined) delete process.env['KIMI_CODE_RG_PATH'];
else process.env['KIMI_CODE_RG_PATH'] = savedRgPath;
});

it('returns undefined when no rg anywhere', async () => {
const result = await findExistingRg(fakeShare);
expect(result).toBeUndefined();
});

it('prefers KIMI_CODE_RG_PATH over system PATH and share-dir cache', async () => {
const binName = process.platform === 'win32' ? 'rg.exe' : 'rg';
const envDir = join(fakeShare, 'env');
const pathDir = join(fakeShare, 'path');
mkdirSync(envDir, { recursive: true });
mkdirSync(pathDir, { recursive: true });
const fromEnv = join(envDir, binName);
const onPath = join(pathDir, binName);
const cached = join(fakeShare, 'bin', binName);
writeFileSync(fromEnv, '#!/bin/sh\n');
writeFileSync(onPath, '#!/bin/sh\n');
writeFileSync(cached, '#!/bin/sh\n');
chmodSync(fromEnv, 0o755);
chmodSync(onPath, 0o755);
chmodSync(cached, 0o755);
process.env['KIMI_CODE_RG_PATH'] = fromEnv;
process.env['PATH'] = pathDir;

const result = await findExistingRg(fakeShare);

expect(result).toEqual({ path: fromEnv, source: 'env-path' });
});

it('throws when KIMI_CODE_RG_PATH points at a missing binary', async () => {
const missing = join(fakeShare, 'missing-rg');
process.env['KIMI_CODE_RG_PATH'] = missing;

await expect(findExistingRg(fakeShare)).rejects.toThrow(
/KIMI_CODE_RG_PATH points to .*missing-rg.*not an executable file/,
);
});

it('resolves from share-dir when cached', async () => {
const cached = join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg');
writeFileSync(cached, '#!/bin/sh\necho ripgrep 15.0.0\n');
Expand Down
Loading