diff --git a/.changeset/unify-hostfs-stat-semantics.md b/.changeset/unify-hostfs-stat-semantics.md new file mode 100644 index 0000000000..0bec9db0ce --- /dev/null +++ b/.changeset/unify-hostfs-stat-semantics.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web backend ignoring symbolic links when loading AGENTS.md files and reading files. diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index 9a3ad74669..ebd5619acf 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -84,9 +84,13 @@ async function loadAgentsMdForRoots( ): Promise { const discovered: AgentFile[] = []; const seen = new Set(); + const loadWarnings: string[] = []; + const warnLoad = (message: string): void => { + loadWarnings.push(message); + }; const collect = async (path: string): Promise => { - 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; @@ -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 }; } @@ -179,25 +185,41 @@ interface AgentFile { async function readAgentFile( deps: ProfileContextDeps, path: string, + warn: (message: string) => void, ): Promise { - 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 { try { - await deps.fs.stat(path); + await deps.fs.lstat(path); return true; } catch { return false; } } +async function entryExists(deps: ProfileContextDeps, path: string): Promise { + return pathExists(deps, path); +} + async function isFile(deps: ProfileContextDeps, path: string): Promise { try { - const stat = await deps.fs.stat(path, { followSymlinks: true }); + const stat = await deps.fs.stat(path); return stat.isFile; } catch { return false; diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 73b7e4bc33..3b38cb25e5 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -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, ); diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index b4db3dce2d..b806fa1e56 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -188,9 +188,9 @@ export class HostFileSystem implements IHostFileSystem { } } - async stat(path: string, options?: { followSymlinks?: boolean }): Promise { + async stat(path: string): Promise { try { - const s = options?.followSymlinks === true ? await nodeStat(path) : await lstat(path); + const s = await nodeStat(path); return { isFile: s.isFile(), isDirectory: s.isDirectory(), @@ -204,6 +204,22 @@ export class HostFileSystem implements IHostFileSystem { } } + async lstat(path: string): Promise { + 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 { try { const entries = await readdir(path, { withFileTypes: true }); diff --git a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts index abcbd71945..0e35262ddf 100644 --- a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts +++ b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts @@ -44,8 +44,10 @@ export interface IHostFileSystem { options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, ): AsyncGenerator; createExclusive(path: string, data: Uint8Array): Promise; - /** Stats the entry itself (Node `lstat` semantics) unless `followSymlinks` resolves links to their target. */ - stat(path: string, options?: { followSymlinks?: boolean }): Promise; + /** Follows symlinks to the target (Node `stat` semantics). Use {@link lstat} when the link itself matters. */ + stat(path: string): Promise; + /** Stats the entry itself without following symlinks (Node `lstat` semantics). */ + lstat(path: string): Promise; readdir(path: string): Promise; mkdir(path: string, options?: { readonly recursive?: boolean }): Promise; remove(path: string): Promise; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts index f757377209..4af6d91ab2 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts @@ -228,7 +228,7 @@ export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigSer private async pathExists(filePath: string): Promise { try { - await this.fs.stat(filePath); + await this.fs.lstat(filePath); return true; } catch { return false; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index cf4349d3fb..cf70e6e998 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -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 }); } @@ -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); } @@ -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 { @@ -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); } @@ -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); } diff --git a/packages/agent-core-v2/test/agent/profile/context.test.ts b/packages/agent-core-v2/test/agent/profile/context.test.ts index 15fbfd770b..802ed1d552 100644 --- a/packages/agent-core-v2/test/agent/profile/context.test.ts +++ b/packages/agent-core-v2/test/agent/profile/context.test.ts @@ -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; diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts new file mode 100644 index 0000000000..846b87da01 --- /dev/null +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts @@ -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); + }); +}); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index b5c69f2a99..56f7b92a8a 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -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'), diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 2296480fa9..292861964a 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -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'); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index 7349ebc2ba..32de5cef24 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -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) => { @@ -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); diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts index 5354a54e33..b61afc68ae 100644 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts @@ -38,6 +38,7 @@ class MemoryHostFs implements IHostFileSystem { declare readonly _serviceBrand: undefined; readonly files = new Map(); readonly dirs = new Set(); + readonly danglingSymlinks = new Set(); readonly statErrors = new Map(); readonly readErrors = new Map(); readonly readsDuringPausedWrite: string[] = []; @@ -114,6 +115,7 @@ class MemoryHostFs implements IHostFileSystem { async stat(path: string): Promise { const error = this.statErrors.get(path); if (error !== undefined) throw error; + if (this.danglingSymlinks.has(path)) throw enoent(path); if (this.files.has(path)) { return { isFile: true, isDirectory: false, size: this.files.get(path)?.length ?? 0 }; } @@ -121,6 +123,13 @@ class MemoryHostFs implements IHostFileSystem { throw enoent(path); } + async lstat(path: string): Promise { + if (this.danglingSymlinks.has(path)) { + return { isFile: false, isDirectory: false, isSymbolicLink: true, size: 0 }; + } + return this.stat(path); + } + async readdir(): Promise { throw new Error('not implemented'); } @@ -364,6 +373,20 @@ describe('SessionWorkspaceCommandService', () => { expect(fs.files.get(`${projectRoot}/.kimi-code/local.toml`)).toContain(sharedDir); }); + it('keeps a dangling .git symlink as the local project-root marker', async () => { + const ancestorRoot = '/repo/project'; + const workDir = `${ancestorRoot}/apps/foo`; + const sharedDir = `${workDir}/shared`; + const { svc, fs } = build([sharedDir], true, workDir, `${ancestorRoot}/.git`); + fs.danglingSymlinks.add(`${workDir}/.git`); + + const result = await svc.addAdditionalDir({ path: 'shared', persist: true }); + + expect(result.projectRoot).toBe(workDir); + expect(result.configPath).toBe(`${workDir}/.kimi-code/local.toml`); + expect(fs.files.get(`${workDir}/.kimi-code/local.toml`)).toContain(sharedDir); + }); + it('resolves session-only relative dirs against the session workDir when project root is above it', async () => { const projectRoot = '/repo/project'; const workDir = `${projectRoot}/apps/foo`; diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts index f8c952f05c..13c37262be 100644 --- a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts +++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts @@ -26,6 +26,7 @@ export function createFakeHostFs(overrides: Partial = {}): IHos readLines: () => notImplemented('FakeHostFs.readLines'), createExclusive: () => notImplemented('FakeHostFs.createExclusive'), stat: () => notImplemented('FakeHostFs.stat'), + lstat: () => notImplemented('FakeHostFs.lstat'), readdir: () => notImplemented('FakeHostFs.readdir'), mkdir: () => notImplemented('FakeHostFs.mkdir'), remove: () => notImplemented('FakeHostFs.remove'),