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
5 changes: 5 additions & 0 deletions .changeset/unify-hostfs-stat-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the web backend ignoring symbolic links when loading AGENTS.md files and reading files.
42 changes: 32 additions & 10 deletions packages/agent-core-v2/src/agent/profile/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@ async function loadAgentsMdForRoots(
): Promise<LoadedAgentsMd> {
const discovered: AgentFile[] = [];
const seen = new Set<string>();
const loadWarnings: string[] = [];
const warnLoad = (message: string): void => {
loadWarnings.push(message);
};

const collect = async (path: string): Promise<boolean> => {
const file = await readAgentFile(deps, path);
const file = await readAgentFile(deps, path, warnLoad);
if (file === undefined) return false;
const key = normalize(file.path);
if (seen.has(key)) return false;
Expand Down Expand Up @@ -122,12 +126,14 @@ async function loadAgentsMdForRoots(

const content = renderAgentFiles(discovered);
const totalBytes = byteLength(content);
const warning =
totalBytes > AGENTS_MD_RECOMMENDED_MAX_BYTES
? `AGENTS.md total ${formatKB(totalBytes)} KB exceeds the recommended ` +
if (totalBytes > AGENTS_MD_RECOMMENDED_MAX_BYTES) {
loadWarnings.push(
`AGENTS.md total ${formatKB(totalBytes)} KB exceeds the recommended ` +
`${formatKB(AGENTS_MD_RECOMMENDED_MAX_BYTES)} KB. Large instruction files ` +
`increase cost and may impact performance; consider trimming.`
: undefined;
`increase cost and may impact performance; consider trimming.`,
);
}
const warning = loadWarnings.length > 0 ? loadWarnings.join('\n') : undefined;
return { content, warning };
}

Expand Down Expand Up @@ -179,25 +185,41 @@ interface AgentFile {
async function readAgentFile(
deps: ProfileContextDeps,
path: string,
warn: (message: string) => void,
): Promise<AgentFile | undefined> {
if (!(await isFile(deps, path))) return undefined;
const content = (await deps.fs.readText(path, { errors: 'ignore' })).trim();
if (!(await isFile(deps, path))) {
if (await entryExists(deps, path)) {
warn(`Instruction file at ${path} exists but is not a readable regular file; skipping.`);
}
return undefined;
}
let content: string;
try {
content = (await deps.fs.readText(path, { errors: 'ignore' })).trim();
} catch {
warn(`Instruction file at ${path} could not be read; skipping.`);
return undefined;
}
if (content.length === 0) return undefined;
return { path, content };
}

async function pathExists(deps: ProfileContextDeps, path: string): Promise<boolean> {
try {
await deps.fs.stat(path);
await deps.fs.lstat(path);
return true;
} catch {
return false;
}
}

async function entryExists(deps: ProfileContextDeps, path: string): Promise<boolean> {
return pathExists(deps, path);
}

async function isFile(deps: ProfileContextDeps, path: string): Promise<boolean> {
try {
const stat = await deps.fs.stat(path, { followSymlinks: true });
const stat = await deps.fs.stat(path);
return stat.isFile;
} catch {
return false;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/app/git/gitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export class GitService implements IGitService {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
if (res.stdout.length === 0 && statusRes.stdout.length === 0) {
const exists = await this.fs.stat(absPath).then(
const exists = await this.fs.lstat(absPath).then(
() => true,
() => false,
);
Expand Down
20 changes: 18 additions & 2 deletions packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,9 @@ export class HostFileSystem implements IHostFileSystem {
}
}

async stat(path: string, options?: { followSymlinks?: boolean }): Promise<HostFileStat> {
async stat(path: string): Promise<HostFileStat> {
try {
const s = options?.followSymlinks === true ? await nodeStat(path) : await lstat(path);
const s = await nodeStat(path);
return {
isFile: s.isFile(),
isDirectory: s.isDirectory(),
Expand All @@ -204,6 +204,22 @@ export class HostFileSystem implements IHostFileSystem {
}
}

async lstat(path: string): Promise<HostFileStat> {
try {
const s = await lstat(path);
return {
isFile: s.isFile(),
isDirectory: s.isDirectory(),
isSymbolicLink: s.isSymbolicLink(),
size: s.size,
mtimeMs: s.mtimeMs,
ino: s.ino,
};
} catch (error) {
throw toHostFsError(error, { path, op: 'lstat' });
}
}

async readdir(path: string): Promise<readonly HostDirEntry[]> {
try {
const entries = await readdir(path, { withFileTypes: true });
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-core-v2/src/os/interface/hostFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ export interface IHostFileSystem {
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): AsyncGenerator<string>;
createExclusive(path: string, data: Uint8Array): Promise<boolean>;
/** Stats the entry itself (Node `lstat` semantics) unless `followSymlinks` resolves links to their target. */
stat(path: string, options?: { followSymlinks?: boolean }): Promise<HostFileStat>;
/** Follows symlinks to the target (Node `stat` semantics). Use {@link lstat} when the link itself matters. */
stat(path: string): Promise<HostFileStat>;
/** Stats the entry itself without following symlinks (Node `lstat` semantics). */
lstat(path: string): Promise<HostFileStat>;
readdir(path: string): Promise<readonly HostDirEntry[]>;
mkdir(path: string, options?: { readonly recursive?: boolean }): Promise<void>;
remove(path: string): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigSer

private async pathExists(filePath: string): Promise<boolean> {
try {
await this.fs.stat(filePath);
await this.fs.lstat(filePath);
return true;
} catch {
return false;
Expand Down
10 changes: 5 additions & 5 deletions packages/agent-core-v2/src/session/sessionFs/fsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export class SessionFsService implements ISessionFsService {
continue;
}
if (req.exclude_globs && matchesAnyGlob(childRel, req.exclude_globs)) continue;
const st = await this.hostFs.stat(this.absOf(childRel)).catch(() => undefined);
const st = await this.hostFs.lstat(this.absOf(childRel)).catch(() => undefined);
if (st === undefined) continue;
visible.push({ name, relPath: childRel, stat: st });
}
Expand Down Expand Up @@ -309,7 +309,7 @@ export class SessionFsService implements ISessionFsService {
const rel = this.toRel(abs);
let st: HostFileStat;
try {
st = await this.hostFs.stat(abs);
st = await this.hostFs.lstat(abs);
} catch (err) {
throw mapFsError(err, req.path);
}
Expand All @@ -329,7 +329,7 @@ export class SessionFsService implements ISessionFsService {
await Promise.all(
resolved.map(async ({ raw, rel, abs }) => {
try {
const st = await this.hostFs.stat(abs);
const st = await this.hostFs.lstat(abs);
const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs);
entries[raw] = buildFsEntry(rel, name, st, false);
} catch {
Expand Down Expand Up @@ -359,7 +359,7 @@ export class SessionFsService implements ISessionFsService {
}
throw err;
}
const st = await this.hostFs.stat(abs);
const st = await this.hostFs.lstat(abs);
return buildFsEntry(rel, basename(abs), st, false);
}

Expand All @@ -368,7 +368,7 @@ export class SessionFsService implements ISessionFsService {
const rel = this.toRel(abs);
let st: HostFileStat;
try {
st = await this.hostFs.stat(abs);
st = await this.hostFs.lstat(abs);
} catch (err) {
throw mapFsError(err, relPath);
}
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-core-v2/test/agent/profile/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ describe('loadAgentsMd symlinked files', () => {
});
});

describe('loadAgentsMd unreadable paths', () => {
it('warns when an instruction file exists but is a dangling symlink', async () => {
const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-brand-'));
extraDirs.push(brandHome);
await symlink(join(workDir, 'missing-target.md'), join(workDir, 'AGENTS.md'));

const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome);

expect(result.agentsMd).toBe('');
expect(result.agentsMdWarning).toBeDefined();
expect(result.agentsMdWarning).toContain('not a readable regular file');
});
});

describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
let brandHome: string;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { HostFileSystem } from '#/os/backends/node-local/hostFsService';

let dir: string;
let fs: HostFileSystem;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'kimi-hostfs-'));
fs = new HostFileSystem();
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

describe('HostFileSystem stat / lstat', () => {
it('stat follows a symlink to a regular file while lstat stats the link', async () => {
const target = join(dir, 'target.txt');
await writeFile(target, 'hello', 'utf-8');
const link = join(dir, 'link.txt');
await symlink(target, link);

const st = await fs.stat(link);
expect(st.isFile).toBe(true);
expect(st.isSymbolicLink).not.toBe(true);

const lst = await fs.lstat(link);
expect(lst.isSymbolicLink).toBe(true);
expect(lst.isFile).toBe(false);
});

it('stat follows a symlink to a directory', async () => {
const target = join(dir, 'subdir');
await mkdir(target);
const link = join(dir, 'dirlink');
await symlink(target, link);

expect((await fs.stat(link)).isDirectory).toBe(true);
expect((await fs.lstat(link)).isDirectory).toBe(false);
});

it('stat rejects a dangling symlink while lstat still stats the link', async () => {
const link = join(dir, 'dangling');
await symlink(join(dir, 'missing'), link);

await expect(fs.stat(link)).rejects.toThrow();
expect((await fs.lstat(link)).isSymbolicLink).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ function createTestFs(kaos: FakeKaos): IHostFileSystem {
readLines: () => notImplemented('readLines'),
createExclusive: () => notImplemented('createExclusive'),
stat: (path) => kaos.stat(path),
lstat: (path) => kaos.stat(path),
readdir: () => notImplemented('readdir'),
mkdir: () => notImplemented('mkdir'),
remove: () => notImplemented('remove'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,16 @@ describe('ReadTool', () => {
});
});

it('stats the resolved target so symlinked files stay readable', async () => {
const { fs, stat } = createSpiedFs('alpha\n');
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);

const result = await execute(tool, { path: '/tmp/a.txt' });

expect(result.isError).not.toBe(true);
expect(stat).toHaveBeenCalledWith('/tmp/a.txt');
});

it('normalizes pure CRLF files to the LF model view', async () => {
const tool = toolWithContent('alpha\r\nbeta\r\n');

Expand Down
40 changes: 25 additions & 15 deletions packages/agent-core-v2/test/session/sessionFs/fsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ function fakeFs(
err.code = 'ENOENT';
return err;
};
const lstatImpl = async (p: string) => {
if (fileMap.has(p)) {
return {
isFile: true,
isDirectory: false,
size: fileMap.get(p)!.length,
mtimeMs: 1000,
ino: 1,
};
}
if (symlinkSet.has(p)) {
return { isFile: false, isDirectory: false, isSymbolicLink: true, size: 0, mtimeMs: 1000, ino: 1 };
}
if (isDir(p)) {
return { isFile: false, isDirectory: true, size: 0, mtimeMs: 1000, ino: 1 };
}
throw enoent(p);
};
return {
_serviceBrand: undefined,
readText: async (p) => {
Expand All @@ -93,23 +111,15 @@ function fakeFs(
},
writeBytes: async () => {},
createExclusive: async () => false,
lstat: lstatImpl,
stat: async (p) => {
if (fileMap.has(p)) {
return {
isFile: true,
isDirectory: false,
size: fileMap.get(p)!.length,
mtimeMs: 1000,
ino: 1,
};
}
if (symlinkSet.has(p)) {
return { isFile: false, isDirectory: false, isSymbolicLink: true, size: 0, mtimeMs: 1000, ino: 1 };
}
if (isDir(p)) {
return { isFile: false, isDirectory: true, size: 0, mtimeMs: 1000, ino: 1 };
let cur = p;
for (let hops = 0; hops < 10 && symlinkSet.has(cur); hops += 1) {
const target = symlinkTargetMap.get(cur);
if (target === undefined) break;
cur = isAbsolute(target) ? target : join(cur, '..', target);
}
throw enoent(p);
return lstatImpl(cur);
},
readdir: async (p) => {
if (!isDir(p)) throw enoent(p);
Expand Down
Loading
Loading