From f3ebc1e2a74ff8951baa28c9cce75d858f86810b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 14:12:04 +0800 Subject: [PATCH] feat(server-v2): port v1 session fs routes - expose /api/v1/sessions/{sid}/fs:* mirror routes backed by the Session-scoped IFsService (list, read, list_many, stat, stat_many, mkdir, search, grep, git_status, diff, open, open-in, reveal) plus the fs/{path}:download stream - extend agent-core-v2 IFsService with list/read/stat/mkdir/resolve methods and add fs.is_directory/is_binary/too_large/already_exists error codes (protocol KimiErrorCode + FsErrors) - seed IWorkspaceContext with the session work dir in session-lifecycle so workspace-relative services resolve paths against the session root - port fileLaunch lib for open/reveal/open-in --- packages/agent-core-v2/src/agentFs/agentFs.ts | 9 +- .../src/agentFs/agentFsService.ts | 17 +- packages/agent-core-v2/src/agentFs/errors.ts | 4 + packages/agent-core-v2/src/agentFs/fs.ts | 37 ++ .../agent-core-v2/src/agentFs/fsService.ts | 524 +++++++++++++++- .../sessionLifecycleService.ts | 6 + .../test/agentFs/fsService.test.ts | 247 +++++++- packages/protocol/src/events.ts | 4 + packages/server-v2/src/lib/fileLaunch.ts | 225 +++++++ packages/server-v2/src/routes/fs.ts | 583 ++++++++++++++++++ .../src/routes/registerApiV1Routes.ts | 4 +- packages/server-v2/test/fs.test.ts | 226 +++++++ 12 files changed, 1855 insertions(+), 31 deletions(-) create mode 100644 packages/server-v2/src/lib/fileLaunch.ts create mode 100644 packages/server-v2/src/routes/fs.ts create mode 100644 packages/server-v2/test/fs.test.ts diff --git a/packages/agent-core-v2/src/agentFs/agentFs.ts b/packages/agent-core-v2/src/agentFs/agentFs.ts index 06a5a2d4f..ac376cbb6 100644 --- a/packages/agent-core-v2/src/agentFs/agentFs.ts +++ b/packages/agent-core-v2/src/agentFs/agentFs.ts @@ -12,6 +12,10 @@ export interface AgentFileStat { readonly isFile: boolean; readonly isDirectory: boolean; readonly size: number; + /** Last-modified time in epoch milliseconds, when the backend exposes it. */ + readonly mtimeMs?: number; + /** Inode number, when the backend exposes it (`0` on backends without inodes). */ + readonly ino?: number; } export interface IAgentFileSystem { @@ -30,7 +34,10 @@ export interface IAgentFileSystem { stat(path: string): Promise; readdir(path: string): Promise; glob(pattern: string): Promise; - mkdir(path: string): Promise; + mkdir( + path: string, + options?: { readonly parents?: boolean; readonly existOk?: boolean }, + ): Promise; withCwd(cwd: string): IAgentFileSystem; } diff --git a/packages/agent-core-v2/src/agentFs/agentFsService.ts b/packages/agent-core-v2/src/agentFs/agentFsService.ts index f9e175ef0..46441eb9d 100644 --- a/packages/agent-core-v2/src/agentFs/agentFsService.ts +++ b/packages/agent-core-v2/src/agentFs/agentFsService.ts @@ -62,7 +62,12 @@ export class AgentFileSystem implements IAgentFileSystem { async stat(path: string): Promise { const s = await this.kaos.backend.stat(path); - return { ...statKind(s), size: s.stSize }; + return { + ...statKind(s), + size: s.stSize, + mtimeMs: Math.floor(s.stMtime * 1000), + ino: s.stIno, + }; } async readdir(path: string): Promise { @@ -81,8 +86,14 @@ export class AgentFileSystem implements IAgentFileSystem { return out; } - mkdir(path: string): Promise { - return this.kaos.backend.mkdir(path, { parents: true, existOk: true }); + mkdir( + path: string, + options?: { readonly parents?: boolean; readonly existOk?: boolean }, + ): Promise { + return this.kaos.backend.mkdir(path, { + parents: options?.parents ?? true, + existOk: options?.existOk ?? true, + }); } withCwd(cwd: string): IAgentFileSystem { diff --git a/packages/agent-core-v2/src/agentFs/errors.ts b/packages/agent-core-v2/src/agentFs/errors.ts index bc69be83a..bcbe10514 100644 --- a/packages/agent-core-v2/src/agentFs/errors.ts +++ b/packages/agent-core-v2/src/agentFs/errors.ts @@ -9,6 +9,10 @@ export const FsErrors = { FS_PATH_NOT_FOUND: 'fs.path_not_found', FS_PERMISSION_DENIED: 'fs.permission_denied', FS_PATH_ESCAPES: 'fs.path_escapes', + FS_IS_DIRECTORY: 'fs.is_directory', + FS_IS_BINARY: 'fs.is_binary', + FS_TOO_LARGE: 'fs.too_large', + FS_ALREADY_EXISTS: 'fs.already_exists', FS_TOO_MANY_RESULTS: 'fs.too_many_results', FS_GREP_TIMEOUT: 'fs.grep_timeout', FS_GIT_UNAVAILABLE: 'fs.git_unavailable', diff --git a/packages/agent-core-v2/src/agentFs/fs.ts b/packages/agent-core-v2/src/agentFs/fs.ts index 899df7948..4914dff1f 100644 --- a/packages/agent-core-v2/src/agentFs/fs.ts +++ b/packages/agent-core-v2/src/agentFs/fs.ts @@ -17,17 +17,54 @@ import type { FsGitStatusResponse, FsGrepRequest, FsGrepResponse, + FsListManyRequest, + FsListManyResponse, + FsListRequest, + FsListResponse, + FsMkdirRequest, + FsMkdirResponse, + FsReadRequest, + FsReadResponse, FsSearchRequest, FsSearchResponse, + FsStatManyRequest, + FsStatManyResponse, + FsStatRequest, + FsStatResponse, } from '@moonshot-ai/protocol'; +/** Absolute + workspace-relative path resolution for a session file. */ +export interface FsPathResolved { + readonly absolute: string; + readonly relative: string; + readonly isDirectory: boolean; +} + +/** Metadata needed by the download route to stream a session file. */ +export interface FsDownloadResolved { + readonly absolute: string; + readonly relative: string; + readonly size: number; + readonly etag: string; + readonly mime: string; + readonly modifiedAt: Date; +} + export interface IFsService { readonly _serviceBrand: undefined; + list(req: FsListRequest): Promise; + read(req: FsReadRequest): Promise; + listMany(req: FsListManyRequest): Promise; + stat(req: FsStatRequest): Promise; + statMany(req: FsStatManyRequest): Promise; + mkdir(req: FsMkdirRequest): Promise; search(req: FsSearchRequest): Promise; grep(req: FsGrepRequest): Promise; gitStatus(req: FsGitStatusRequest): Promise; diff(req: FsDiffRequest): Promise; + resolvePath(relPath: string): Promise; + resolveDownload(relPath: string): Promise; } export const IFsService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/agentFs/fsService.ts b/packages/agent-core-v2/src/agentFs/fsService.ts index 927d20ca0..93ca6b079 100644 --- a/packages/agent-core-v2/src/agentFs/fsService.ts +++ b/packages/agent-core-v2/src/agentFs/fsService.ts @@ -10,21 +10,35 @@ * follow symlinks, matching the rest of v2 (`_base/tools/policies/path-access.ts`). */ -import { isAbsolute, relative, sep } from 'node:path'; +import { basename, extname, isAbsolute, relative, sep } from 'node:path'; -import type { - FsDiffRequest, - FsDiffResponse, - FsGitStatusRequest, - FsGitStatusResponse, - FsGrepFileHit, - FsGrepMatch, - FsGrepRequest, - FsGrepResponse, - FsPullRequest, - FsSearchHit, - FsSearchRequest, - FsSearchResponse, +import { + ErrorCode, + type FsDiffRequest, + type FsDiffResponse, + type FsEntry, + type FsGitStatusRequest, + type FsGitStatusResponse, + type FsGrepFileHit, + type FsGrepMatch, + type FsGrepRequest, + type FsGrepResponse, + type FsListManyRequest, + type FsListManyResponse, + type FsListRequest, + type FsListResponse, + type FsMkdirRequest, + type FsMkdirResponse, + type FsPullRequest, + type FsReadRequest, + type FsReadResponse, + type FsSearchHit, + type FsSearchRequest, + type FsSearchResponse, + type FsStatManyRequest, + type FsStatManyResponse, + type FsStatRequest, + type FsStatResponse, } from '@moonshot-ai/protocol'; import ignore, { type Ignore } from 'ignore'; @@ -34,8 +48,8 @@ import { ErrorCodes, KimiError } from '#/errors'; import { IProcessRunner } from '#/process'; import { IWorkspaceContext } from '#/workspaceContext'; -import { IAgentFileSystem } from './agentFs'; -import { IFsService } from './fs'; +import { type AgentFileStat, IAgentFileSystem } from './agentFs'; +import { type FsDownloadResolved, type FsPathResolved, IFsService } from './fs'; import { parseNumstat, parsePorcelain, parsePullRequest } from './fsGit'; import { runCommand } from './fsProcess'; import { @@ -56,6 +70,16 @@ const DIFF_MAX_BYTES = 1_048_576; const PR_SPAWN_TIMEOUT_MS = 5_000; const PULL_REQUEST_TTL_MS = 60_000; +/** Hard cap for `fs:read` payloads (10 MiB). */ +const FS_READ_MAX_BYTES = 10 * 1024 * 1024; +/** Sample size used to sniff binary content. */ +const FS_BINARY_SAMPLE_BYTES = 4096; +/** Fraction of non-printable bytes above which a sample is treated as binary. */ +const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; + +const HIDDEN_NAME_RE = /^\./; +const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); + export class FsService implements IFsService { declare readonly _serviceBrand: undefined; @@ -72,6 +96,295 @@ export class FsService implements IFsService { @IProcessRunner private readonly runner: IProcessRunner, ) {} + async list(req: FsListRequest): Promise { + const abs = this.resolveWithin(req.path); + const rel = this.toRel(abs); + + let topStat: AgentFileStat; + try { + topStat = await this.fs.stat(rel); + } catch (err) { + throw mapFsError(err, req.path); + } + if (!topStat.isDirectory) { + throw new KimiError(ErrorCodes.FS_PATH_NOT_FOUND, `path not found: ${req.path}`, { + details: { path: req.path }, + }); + } + + const gitignore = req.follow_gitignore ? await this.matcher() : undefined; + + const items: FsEntry[] = []; + const childrenByPath: Record = {}; + let truncated = false; + + interface QueueEntry { + readonly relPath: string; + readonly depthRemaining: number; + } + const queue: QueueEntry[] = [ + { relPath: rel === '.' ? '' : rel, depthRemaining: req.depth }, + ]; + + interface Child { + readonly name: string; + readonly relPath: string; + readonly stat: AgentFileStat; + } + + while (queue.length > 0) { + const entry = queue.shift()!; + let names: readonly string[]; + try { + names = await this.fs.readdir(entry.relPath === '' ? '.' : entry.relPath); + } catch (err) { + if (entry.relPath === (rel === '.' ? '' : rel)) { + throw mapFsError(err, req.path); + } + continue; + } + + const visible: Child[] = []; + for (const name of names) { + if (!req.show_hidden && isHidden(name)) continue; + const childRel = entry.relPath === '' ? name : `${entry.relPath}/${name}`; + if (gitignore && (gitignore.ignores(childRel) || gitignore.ignores(`${childRel}/`))) { + continue; + } + if (req.exclude_globs && matchesAnyGlob(childRel, req.exclude_globs)) continue; + const st = await this.fs.stat(childRel).catch(() => undefined); + if (st === undefined) continue; + visible.push({ name, relPath: childRel, stat: st }); + } + + sortChildren(visible, req.sort); + + const parentKey = entry.relPath === '' ? '.' : entry.relPath; + const bucket: FsEntry[] = []; + for (const child of visible) { + if (items.length >= req.limit && entry.depthRemaining === req.depth) { + truncated = true; + break; + } + const fsEntry = buildFsEntry(child.relPath, child.name, child.stat, false); + if (entry.depthRemaining === req.depth) { + items.push(fsEntry); + } + bucket.push(fsEntry); + if (child.stat.isDirectory && entry.depthRemaining > 1) { + queue.push({ relPath: child.relPath, depthRemaining: entry.depthRemaining - 1 }); + } + } + + if (entry.depthRemaining < req.depth) { + childrenByPath[parentKey] = bucket; + } + } + + const response: FsListResponse = { items, truncated }; + if (Object.keys(childrenByPath).length > 0) { + response.children_by_path = childrenByPath; + } + return response; + } + + async read(req: FsReadRequest): Promise { + const abs = this.resolveWithin(req.path); + const rel = this.toRel(abs); + + let st: AgentFileStat; + try { + st = await this.fs.stat(rel); + } catch (err) { + throw mapFsError(err, req.path); + } + if (st.isDirectory) { + throw new KimiError(ErrorCodes.FS_IS_DIRECTORY, `path is a directory: ${req.path}`, { + details: { path: req.path }, + }); + } + if (st.size > FS_READ_MAX_BYTES) { + throw new KimiError( + ErrorCodes.FS_TOO_LARGE, + `file too large: ${req.path} (${st.size} bytes > ${FS_READ_MAX_BYTES})`, + { details: { path: req.path, size: st.size } }, + ); + } + + const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size); + const sample = + sampleSize === 0 ? new Uint8Array() : await this.fs.readBytes(rel, sampleSize); + const isBinary = detectBinary(sample); + + if (isBinary && req.encoding === 'utf-8') { + throw new KimiError(ErrorCodes.FS_IS_BINARY, `file is binary: ${req.path}`, { + details: { path: req.path }, + }); + } + + const effectiveLength = Math.min(req.length, st.size - req.offset); + let bytes: Uint8Array; + if (effectiveLength <= 0) { + bytes = new Uint8Array(); + } else { + const window = await this.fs.readBytes(rel, req.offset + effectiveLength); + bytes = window.subarray(req.offset, req.offset + effectiveLength); + } + + const encoding: 'utf-8' | 'base64' = + req.encoding === 'base64' || (req.encoding === 'auto' && isBinary) ? 'base64' : 'utf-8'; + const content = + encoding === 'utf-8' + ? Buffer.from(bytes).toString('utf-8') + : Buffer.from(bytes).toString('base64'); + const truncated = req.offset + effectiveLength < st.size; + + const out: FsReadResponse = { + path: rel, + content, + encoding, + size: st.size, + truncated, + etag: buildEtag(st), + mime: guessMime(rel, isBinary), + is_binary: isBinary, + }; + const languageId = encoding === 'utf-8' ? guessLanguageId(rel) : undefined; + if (languageId !== undefined) out.language_id = languageId; + if (encoding === 'utf-8') out.line_count = countLines(content); + return out; + } + + async listMany(req: FsListManyRequest): Promise { + const results: Record = {}; + const partialErrors: Record = {}; + const truncatedPaths: string[] = []; + + await Promise.all( + req.paths.map(async (p) => { + try { + const sub = await this.list({ + path: p, + depth: req.depth, + limit: req.limit, + show_hidden: req.show_hidden, + follow_gitignore: req.follow_gitignore, + exclude_globs: req.exclude_globs, + sort: req.sort, + include_git_status: req.include_git_status, + }); + results[p] = sub.items; + if (sub.truncated) truncatedPaths.push(p); + } catch (err) { + if (err instanceof KimiError && err.code === ErrorCodes.FS_PATH_ESCAPES) throw err; + partialErrors[p] = toWireError(err); + } + }), + ); + + const out: FsListManyResponse = { results }; + if (truncatedPaths.length > 0) out.truncated_paths = truncatedPaths; + if (Object.keys(partialErrors).length > 0) out.partial_errors = partialErrors; + return out; + } + + async stat(req: FsStatRequest): Promise { + const abs = this.resolveWithin(req.path); + const rel = this.toRel(abs); + let st: AgentFileStat; + try { + st = await this.fs.stat(rel); + } catch (err) { + throw mapFsError(err, req.path); + } + const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs); + return buildFsEntry(rel, name, st, true); + } + + async statMany(req: FsStatManyRequest): Promise { + const resolved = req.paths.map((p) => { + const abs = this.resolveWithin(p); + return { raw: p, rel: this.toRel(abs), abs }; + }); + + const entries: Record = {}; + await Promise.all( + resolved.map(async ({ raw, rel, abs }) => { + try { + const st = await this.fs.stat(rel); + const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs); + entries[raw] = buildFsEntry(rel, name, st, false); + } catch { + entries[raw] = null; + } + }), + ); + return { entries }; + } + + async mkdir(req: FsMkdirRequest): Promise { + const abs = this.resolveWithin(req.path); + const rel = this.toRel(abs); + try { + await this.fs.mkdir(rel, { parents: req.recursive, existOk: req.recursive }); + } catch (err) { + const code = errnoCode(err); + if (code === 'EEXIST') { + throw new KimiError(ErrorCodes.FS_ALREADY_EXISTS, `path already exists: ${req.path}`, { + details: { path: req.path }, + }); + } + if (code === 'ENOENT' || code === 'ENOTDIR') { + throw new KimiError(ErrorCodes.FS_PATH_NOT_FOUND, `parent not found: ${req.path}`, { + details: { path: req.path }, + }); + } + throw err; + } + const st = await this.fs.stat(rel); + return buildFsEntry(rel, basename(abs), st, false); + } + + async resolvePath(relPath: string): Promise { + const abs = this.resolveWithin(relPath); + const rel = this.toRel(abs); + let st: AgentFileStat; + try { + st = await this.fs.stat(rel); + } catch (err) { + throw mapFsError(err, relPath); + } + return { absolute: abs, relative: rel, isDirectory: st.isDirectory }; + } + + async resolveDownload(relPath: string): Promise { + const abs = this.resolveWithin(relPath); + const rel = this.toRel(abs); + let st: AgentFileStat; + try { + st = await this.fs.stat(rel); + } catch (err) { + throw mapFsError(err, relPath); + } + if (st.isDirectory) { + throw new KimiError(ErrorCodes.FS_IS_DIRECTORY, `path is a directory: ${relPath}`, { + details: { path: relPath }, + }); + } + const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size); + const sample = + sampleSize === 0 ? new Uint8Array() : await this.fs.readBytes(rel, sampleSize); + const isBinary = detectBinary(sample); + return { + absolute: abs, + relative: rel, + size: st.size, + etag: buildEtag(st), + mime: guessMime(rel, isBinary), + modifiedAt: new Date(st.mtimeMs ?? 0), + }; + } + async search(req: FsSearchRequest): Promise { const matcher = req.follow_gitignore ? await this.matcher() : undefined; const candidates: FsSearchHit[] = []; @@ -580,6 +893,185 @@ function parseRgJsonOutput( return { files, files_scanned: filesScanned, truncated, elapsed_ms: elapsedMs }; } +// --------------------------------------------------------------------------- +// Helpers shared by the list/read/stat/mkdir methods. Ported from the v1 +// `FsService` so the `/api/v1` mirror stays byte-compatible. +// --------------------------------------------------------------------------- + +function isHidden(name: string): boolean { + return HIDDEN_NAME_RE.test(name) || MACOS_NOISE.has(name); +} + +function sortChildren( + children: { name: string; stat: AgentFileStat }[], + sort: FsListRequest['sort'], +): void { + const cmp = { + type_first: (a: { name: string; stat: AgentFileStat }, b: { name: string; stat: AgentFileStat }) => { + const ad = a.stat.isDirectory ? 0 : 1; + const bd = b.stat.isDirectory ? 0 : 1; + if (ad !== bd) return ad - bd; + return a.name.localeCompare(b.name); + }, + name_asc: (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name), + name_desc: (a: { name: string }, b: { name: string }) => b.name.localeCompare(a.name), + // v1 does not implement mtime/size ordering; keep the same name fallback. + mtime_desc: (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name), + size_desc: (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name), + }[sort]; + children.sort(cmp); +} + +function buildEtag(st: AgentFileStat): string { + const mtime = Math.floor(st.mtimeMs ?? 0); + const ino = st.ino ?? 0; + return [mtime.toString(36), st.size.toString(36), ino.toString(36)].join('-'); +} + +function buildFsEntry( + relPath: string, + name: string, + st: AgentFileStat, + withMime: boolean, +): FsEntry { + const kind: FsEntry['kind'] = st.isDirectory ? 'directory' : 'file'; + const entry: FsEntry = { + path: relPath, + name, + kind, + modified_at: new Date(st.mtimeMs ?? 0).toISOString(), + etag: buildEtag(st), + }; + if (kind === 'file') { + entry.size = st.size; + } + if (withMime && kind === 'file') { + entry.mime = guessMime(relPath, false); + const lang = guessLanguageId(relPath); + if (lang !== undefined) entry.language_id = lang; + } + return entry; +} + +function detectBinary(buf: Uint8Array): boolean { + if (buf.length === 0) return false; + let nonPrintable = 0; + for (let i = 0; i < buf.length; i++) { + const b = buf[i]!; + if (b === 0) return true; + if (b === 9 || b === 10 || b === 13) continue; + if (b >= 32 && b <= 126) continue; + nonPrintable++; + } + return nonPrintable / buf.length > FS_BINARY_NONPRINTABLE_FRACTION; +} + +function countLines(text: string): number { + if (text.length === 0) return 0; + let n = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) n++; + } + if (text.charCodeAt(text.length - 1) === 10) n--; + return Math.max(0, n); +} + +function errnoCode(err: unknown): string | undefined { + if (typeof err === 'object' && err !== null && 'code' in err) { + const c = (err as { code: unknown }).code; + return typeof c === 'string' ? c : undefined; + } + return undefined; +} + +function mapFsError(err: unknown, inputPath: string): Error { + const code = errnoCode(err); + if (code === 'ENOENT' || code === 'ENOTDIR') { + return new KimiError(ErrorCodes.FS_PATH_NOT_FOUND, `path not found: ${inputPath}`, { + details: { path: inputPath }, + }); + } + return err instanceof Error ? err : new Error(String(err)); +} + +function toWireError(err: unknown): { code: number; msg: string } { + if (err instanceof KimiError) { + switch (err.code) { + case ErrorCodes.FS_PATH_NOT_FOUND: + return { code: ErrorCode.FS_PATH_NOT_FOUND, msg: err.message }; + case ErrorCodes.FS_IS_DIRECTORY: + return { code: ErrorCode.FS_IS_DIRECTORY, msg: err.message }; + case ErrorCodes.FS_IS_BINARY: + return { code: ErrorCode.FS_IS_BINARY, msg: err.message }; + case ErrorCodes.FS_TOO_LARGE: + return { code: ErrorCode.FS_TOO_LARGE, msg: err.message }; + case ErrorCodes.FS_TOO_MANY_RESULTS: + return { code: ErrorCode.FS_TOO_MANY_RESULTS, msg: err.message }; + } + } + return { + code: ErrorCode.INTERNAL_ERROR, + msg: err instanceof Error ? err.message : 'internal error', + }; +} + +const EXT_TO_MIME: Readonly> = { + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', + '.json': 'application/json', + '.md': 'text/markdown', + '.html': 'text/html', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.pdf': 'application/pdf', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'application/toml', + '.sh': 'text/x-shellscript', + '.py': 'text/x-python', + '.rs': 'text/rust', + '.go': 'text/x-go', +}; + +function guessMime(relPath: string, isBinary: boolean): string { + const ext = extname(relPath).toLowerCase(); + const mapped = EXT_TO_MIME[ext]; + if (mapped !== undefined) return mapped; + return isBinary ? 'application/octet-stream' : 'text/plain'; +} + +const EXT_TO_LANGUAGE: Readonly> = { + '.ts': 'typescript', + '.tsx': 'typescriptreact', + '.js': 'javascript', + '.jsx': 'javascriptreact', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.md': 'markdown', + '.html': 'html', + '.css': 'css', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.sh': 'shellscript', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', +}; + +function guessLanguageId(relPath: string): string | undefined { + return EXT_TO_LANGUAGE[extname(relPath).toLowerCase()]; +} + registerScopedService( LifecycleScope.Session, IFsService, diff --git a/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts index 4bac2fd6c..2d59c3c67 100644 --- a/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts @@ -23,6 +23,7 @@ import { IBootstrapService } from '#/bootstrap'; import { NotImplementedError } from '#/errors'; import { IKaos, IKaosFactory } from '#/kaos'; import { sessionLogSeed } from '#/log'; +import { IWorkspaceContext, WorkspaceContextService } from '#/workspaceContext'; import { ISessionService } from '#/session'; import { type ISessionContext, sessionContextSeed } from '#/session-context'; import { ISessionMetadata } from '#/session-metadata'; @@ -56,6 +57,10 @@ export class SessionLifecycleService implements ISessionLifecycleService { metaScope, }; const kaos = await this.kaosFactory.createLocal(opts.workDir); + // Keep `IWorkspaceContext` aligned with the session work dir. Without this + // seed it would default to `process.cwd()`, so workspace-relative services + // (fs, git, terminal, skills) would resolve paths against the wrong root. + const workspace = new WorkspaceContextService(opts.workDir); const handle = createScopedChildHandle( this.instantiation, LifecycleScope.Session, @@ -65,6 +70,7 @@ export class SessionLifecycleService implements ISessionLifecycleService { ...sessionContextSeed(ctx), ...sessionLogSeed(opts.sessionId, sessionDir), [IKaos, kaos] as const, + [IWorkspaceContext, workspace] as const, ], }, ); diff --git a/packages/agent-core-v2/test/agentFs/fsService.test.ts b/packages/agent-core-v2/test/agentFs/fsService.test.ts index f04a139ba..f98b282ac 100644 --- a/packages/agent-core-v2/test/agentFs/fsService.test.ts +++ b/packages/agent-core-v2/test/agentFs/fsService.test.ts @@ -44,37 +44,79 @@ function fakeFs(files: Record): IAgentFileSystem { dirSet.add(parts.slice(0, i).join('/')); } } + const isDir = (p: string): boolean => p === '' || p === '.' || dirSet.has(p); + const enoent = (p: string): NodeJS.ErrnoException => { + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return err; + }; return { _serviceBrand: undefined, cwd: WORK_DIR, readText: async (p) => { const c = fileMap.get(p); - if (c === undefined) throw new Error(`ENOENT: ${p}`); + if (c === undefined) throw enoent(p); return c; }, writeText: async () => {}, - readBytes: async () => new Uint8Array(), + readBytes: async (p, n) => { + const c = fileMap.get(p); + if (c === undefined) throw enoent(p); + const buf = Buffer.from(c); + return buf.subarray(0, n ?? buf.length); + }, + readLines: async function* (): AsyncGenerator { + // not needed by the fs surface under test + }, writeBytes: async () => {}, stat: async (p) => { if (fileMap.has(p)) { - return { isFile: true, isDirectory: false, size: fileMap.get(p)!.length }; + return { + isFile: true, + isDirectory: false, + size: fileMap.get(p)!.length, + mtimeMs: 1000, + ino: 1, + }; } - if (dirSet.has(p)) return { isFile: false, isDirectory: true, size: 0 }; - throw new Error(`ENOENT: ${p}`); + if (isDir(p)) { + return { isFile: false, isDirectory: true, size: 0, mtimeMs: 1000, ino: 1 }; + } + throw enoent(p); }, readdir: async (p) => { const prefix = p === '.' || p === '' ? '' : `${p}/`; const children = new Set(); - for (const f of fileMap.keys()) { - if (!f.startsWith(prefix)) continue; - const rest = f.slice(prefix.length); + const addChild = (key: string): void => { + if (key === '' || key === p) return; + if (!key.startsWith(prefix)) return; + const rest = key.slice(prefix.length); const first = rest.split('/')[0]; if (first !== undefined && first.length > 0) children.add(first); - } + }; + for (const f of fileMap.keys()) addChild(f); + for (const d of dirSet) addChild(d); return [...children]; }, glob: async () => [], - mkdir: async () => {}, + mkdir: async (p, options) => { + const existOk = options?.existOk ?? true; + if ((dirSet.has(p) || fileMap.has(p)) && !existOk) { + const err = new Error(`EEXIST: ${p}`) as NodeJS.ErrnoException; + err.code = 'EEXIST'; + throw err; + } + const parents = options?.parents ?? true; + if (!parents) { + const parent = p.split('/').slice(0, -1).join('/'); + if (parent !== '' && !isDir(parent)) { + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + } + dirSet.add(p); + }, withCwd: () => { throw new Error('not implemented'); }, @@ -269,3 +311,188 @@ describe('FsService.grep', () => { expect(result.files[0]?.matches[0]?.text).toBe('hello world'); }); }); + +describe('FsService.list', () => { + it('lists files and directories with kinds', async () => { + const fs = makeSession( + { 'src/a.ts': '', 'src/sub/b.ts': '', 'README.md': '' }, + emptyHandler, + ); + const result = await fs.list({ + path: '.', + depth: 1, + limit: 200, + show_hidden: false, + follow_gitignore: false, + sort: 'name_asc', + include_git_status: false, + }); + const names = result.items.map((i) => i.name).sort(); + expect(names).toEqual(['README.md', 'src']); + expect(result.items.find((i) => i.name === 'src')?.kind).toBe('directory'); + }); + + it('returns children_by_path for depth > 1', async () => { + const fs = makeSession({ 'src/a.ts': '', 'src/sub/b.ts': '' }, emptyHandler); + const result = await fs.list({ + path: '.', + depth: 2, + limit: 200, + show_hidden: false, + follow_gitignore: false, + sort: 'name_asc', + include_git_status: false, + }); + expect(result.children_by_path?.['src']?.map((i) => i.name).sort()).toEqual([ + 'a.ts', + 'sub', + ]); + }); + + it('rejects paths that escape the workspace', async () => { + const fs = makeSession({}, emptyHandler); + await expect( + fs.list({ + path: '../etc', + depth: 1, + limit: 200, + show_hidden: false, + follow_gitignore: false, + sort: 'name_asc', + include_git_status: false, + }), + ).rejects.toMatchObject({ code: 'fs.path_escapes' }); + }); +}); + +describe('FsService.read', () => { + it('reads utf-8 content with metadata', async () => { + const fs = makeSession({ 'src/a.ts': 'hello\nworld\n' }, emptyHandler); + const result = await fs.read({ + path: 'src/a.ts', + offset: 0, + length: 1024, + encoding: 'utf-8', + }); + expect(result.content).toBe('hello\nworld\n'); + expect(result.encoding).toBe('utf-8'); + expect(result.size).toBe('hello\nworld\n'.length); + expect(result.line_count).toBe(2); + expect(result.mime).toBe('text/typescript'); + expect(result.is_binary).toBe(false); + expect(result.truncated).toBe(false); + }); + + it('honors offset and length and sets truncated', async () => { + const fs = makeSession({ 'a.txt': 'hello world' }, emptyHandler); + const result = await fs.read({ path: 'a.txt', offset: 0, length: 5, encoding: 'utf-8' }); + expect(result.content).toBe('hello'); + expect(result.truncated).toBe(true); + }); + + it('returns base64 for binary content in auto mode', async () => { + const fs = makeSession({ 'bin.dat': 'abc\x00def' }, emptyHandler); + const result = await fs.read({ path: 'bin.dat', offset: 0, length: 1024, encoding: 'auto' }); + expect(result.encoding).toBe('base64'); + expect(result.is_binary).toBe(true); + expect(result.content).toBe(Buffer.from('abc\x00def').toString('base64')); + }); + + it('throws fs.is_binary for binary content in utf-8 mode', async () => { + const fs = makeSession({ 'bin.dat': 'abc\x00def' }, emptyHandler); + await expect( + fs.read({ path: 'bin.dat', offset: 0, length: 1024, encoding: 'utf-8' }), + ).rejects.toMatchObject({ code: 'fs.is_binary' }); + }); + + it('throws fs.is_directory for a directory', async () => { + const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); + await expect( + fs.read({ path: 'src', offset: 0, length: 1024, encoding: 'auto' }), + ).rejects.toMatchObject({ code: 'fs.is_directory' }); + }); +}); + +describe('FsService.stat', () => { + it('returns a file entry with mime', async () => { + const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler); + const entry = await fs.stat({ path: 'src/a.ts' }); + expect(entry.kind).toBe('file'); + expect(entry.size).toBe('content'.length); + expect(entry.mime).toBe('text/typescript'); + expect(entry.name).toBe('a.ts'); + }); + + it('throws fs.path_not_found for a missing path', async () => { + const fs = makeSession({}, emptyHandler); + await expect(fs.stat({ path: 'nope' })).rejects.toMatchObject({ code: 'fs.path_not_found' }); + }); +}); + +describe('FsService.statMany', () => { + it('returns null per missing path and entries for present ones', async () => { + const fs = makeSession({ 'a.txt': 'hi' }, emptyHandler); + const result = await fs.statMany({ paths: ['a.txt', 'missing.txt'] }); + expect(result.entries['a.txt']?.kind).toBe('file'); + expect(result.entries['missing.txt']).toBeNull(); + }); +}); + +describe('FsService.listMany', () => { + it('returns results per path and partial_errors for failures', async () => { + const fs = makeSession({ 'a.txt': '' }, emptyHandler); + const result = await fs.listMany({ + paths: ['.', 'missing'], + depth: 1, + limit: 200, + show_hidden: false, + follow_gitignore: false, + sort: 'name_asc', + include_git_status: false, + }); + expect(result.results['.']?.map((i) => i.name)).toContain('a.txt'); + expect(result.partial_errors?.['missing']).toMatchObject({ code: 40409 }); + }); +}); + +describe('FsService.mkdir', () => { + it('creates a directory and returns its entry', async () => { + const fs = makeSession({}, emptyHandler); + const entry = await fs.mkdir({ path: 'newdir', recursive: false }); + expect(entry.kind).toBe('directory'); + expect(entry.name).toBe('newdir'); + }); + + it('throws fs.already_exists when the directory exists (non-recursive)', async () => { + const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); + await expect(fs.mkdir({ path: 'src', recursive: false })).rejects.toMatchObject({ + code: 'fs.already_exists', + }); + }); +}); + +describe('FsService.resolvePath', () => { + it('returns absolute, relative, and isDirectory', async () => { + const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); + const res = await fs.resolvePath('src/a.ts'); + expect(res.relative).toBe('src/a.ts'); + expect(res.isDirectory).toBe(false); + expect(res.absolute).toContain('src/a.ts'); + }); +}); + +describe('FsService.resolveDownload', () => { + it('returns size, etag, mime, modifiedAt', async () => { + const fs = makeSession({ 'a.txt': 'hello' }, emptyHandler); + const res = await fs.resolveDownload('a.txt'); + expect(res.size).toBe('hello'.length); + expect(res.mime).toBe('text/plain'); + expect(res.etag).toBeTypeOf('string'); + expect(res.modifiedAt).toBeInstanceOf(Date); + }); + + it('throws fs.is_directory for a directory', async () => { + const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); + await expect(fs.resolveDownload('src')).rejects.toMatchObject({ code: 'fs.is_directory' }); + }); +}); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index f418d9a1f..f72a901f9 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -235,6 +235,10 @@ export type KimiErrorCode = | 'fs.path_not_found' | 'fs.permission_denied' | 'fs.path_escapes' + | 'fs.is_directory' + | 'fs.is_binary' + | 'fs.too_large' + | 'fs.already_exists' | 'fs.too_many_results' | 'fs.grep_timeout' | 'fs.git_unavailable' diff --git a/packages/server-v2/src/lib/fileLaunch.ts b/packages/server-v2/src/lib/fileLaunch.ts new file mode 100644 index 000000000..a2071cdbf --- /dev/null +++ b/packages/server-v2/src/lib/fileLaunch.ts @@ -0,0 +1,225 @@ +import { existsSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import path from 'node:path'; + +export interface LaunchCommand { + readonly command: string; + readonly args: readonly string[]; + readonly shell?: boolean; +} + +export function openFileCommandFor( + absolutePath: string, + line?: number, + env: Record = process.env, + platform: NodeJS.Platform = process.platform, +): LaunchCommand { + const editor = resolveEditorCommand(env); + if (editor !== undefined) { + const target = supportsLineTarget(editor) && line !== undefined + ? `${absolutePath}:${line}` + : absolutePath; + return { + command: `${editor} ${quoteShellArg(target, platform)}`, + args: [], + shell: true, + }; + } + + switch (platform) { + case 'darwin': + return { command: 'open', args: [absolutePath] }; + case 'win32': + return { command: 'cmd', args: ['/c', 'start', '""', absolutePath] }; + default: + return { command: 'xdg-open', args: [absolutePath] }; + } +} + +export function revealFileCommandFor( + absolutePath: string, + platform: NodeJS.Platform = process.platform, +): LaunchCommand { + switch (platform) { + case 'darwin': + return { command: 'open', args: ['-R', absolutePath] }; + case 'win32': + return { command: 'explorer.exe', args: [`/select,${absolutePath}`] }; + default: + return { command: 'xdg-open', args: [path.dirname(absolutePath)] }; + } +} + +export type OpenInAppId = + | 'finder' + | 'cursor' + | 'vscode' + | 'iterm' + | 'terminal'; + +export const OPEN_IN_APP_IDS: readonly OpenInAppId[] = [ + 'finder', + 'cursor', + 'vscode', + 'iterm', + 'terminal', +]; + +export interface OpenInAppOptions { + readonly line?: number; + readonly isDirectory?: boolean; +} + +export function openInAppCommandFor( + appId: OpenInAppId, + absolutePath: string, + options: OpenInAppOptions = {}, + platform: NodeJS.Platform = process.platform, +): LaunchCommand { + switch (appId) { + case 'vscode': + return openInVsCodeLike('code', absolutePath, options.line, platform); + case 'cursor': + return openInVsCodeLike('cursor', absolutePath, options.line, platform); + case 'finder': + return openInFinder(absolutePath, options.isDirectory, platform); + case 'iterm': + return openInMacApp('iTerm', absolutePath, platform); + case 'terminal': + return openInMacApp('Terminal', absolutePath, platform); + } +} + +export function getAvailableOpenInApps( + platform: NodeJS.Platform = process.platform, +): readonly OpenInAppId[] { + return OPEN_IN_APP_IDS.filter((appId) => isOpenInAppAvailable(appId, platform)); +} + +function isOpenInAppAvailable( + appId: OpenInAppId, + platform: NodeJS.Platform, +): boolean { + switch (appId) { + case 'finder': + case 'terminal': + return platform === 'darwin'; + case 'iterm': + if (platform !== 'darwin') return false; + return ( + existsSync('/Applications/iTerm.app') || + existsSync(`${process.env['HOME'] ?? ''}/Applications/iTerm.app`) + ); + case 'vscode': + return commandExists('code', platform); + case 'cursor': + return commandExists('cursor', platform); + } +} + +function commandExists(command: string, platform: NodeJS.Platform): boolean { + try { + if (platform === 'win32') { + const result = spawnSync('cmd', ['/c', 'where', command], { + stdio: 'ignore', + }); + return result.status === 0; + } + const result = spawnSync('command', ['-v', command], { + stdio: 'ignore', + shell: true, + }); + return result.status === 0; + } catch { + return false; + } +} + +function openInVsCodeLike( + binary: string, + absolutePath: string, + line: number | undefined, + platform: NodeJS.Platform, +): LaunchCommand { + const target = line !== undefined ? `${absolutePath}:${line}` : absolutePath; + const flag = line !== undefined ? '-g ' : ''; + return { + command: `${binary} ${flag}${quoteShellArg(target, platform)}`, + args: [], + shell: true, + }; +} + +function openInFinder( + absolutePath: string, + isDirectory: boolean | undefined, + platform: NodeJS.Platform, +): LaunchCommand { + switch (platform) { + case 'darwin': + return isDirectory + ? { command: 'open', args: [absolutePath] } + : { command: 'open', args: ['-R', absolutePath] }; + case 'win32': + return isDirectory + ? { command: 'explorer.exe', args: [absolutePath] } + : { command: 'explorer.exe', args: [`/select,${absolutePath}`] }; + default: + return { + command: 'xdg-open', + args: [isDirectory ? absolutePath : path.dirname(absolutePath)], + }; + } +} + +function openInMacApp( + appName: string, + absolutePath: string, + platform: NodeJS.Platform, +): LaunchCommand { + if (platform === 'darwin') { + return { command: 'open', args: ['-a', appName, absolutePath] }; + } + // These apps are macOS-only in the UI; fall back to the platform default. + return openFileCommandFor(absolutePath, undefined, process.env, platform); +} + +export async function launchDetached(cmd: LaunchCommand): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const child = spawn(cmd.command, cmd.args, { + detached: true, + stdio: 'ignore', + shell: cmd.shell, + }); + child.once('error', (err) => { + if (settled) return; + settled = true; + reject(err); + }); + child.once('spawn', () => { + if (settled) return; + settled = true; + child.unref(); + resolve(); + }); + }); +} + +function resolveEditorCommand(env: Record): string | undefined { + for (const key of ['KIMI_CODE_EDITOR', 'VISUAL', 'EDITOR']) { + const value = env[key]; + if (typeof value === 'string' && value.trim().length > 0) return value.trim(); + } + return undefined; +} + +function supportsLineTarget(command: string): boolean { + const first = command.trim().split(/\s+/)[0] ?? ''; + return /(?:^|\/)(code|cursor|windsurf)(?:\.cmd|\.exe)?$/i.test(first); +} + +function quoteShellArg(value: string, platform: NodeJS.Platform): string { + if (platform === 'win32') return `"${value.replaceAll('"', '\\"')}"`; + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/packages/server-v2/src/routes/fs.ts b/packages/server-v2/src/routes/fs.ts new file mode 100644 index 000000000..0d7532b00 --- /dev/null +++ b/packages/server-v2/src/routes/fs.ts @@ -0,0 +1,583 @@ +/** + * `/api/v1` session filesystem routes — server-v2 port. + * + * Mirrors `packages/server/src/routes/fs.ts` path-for-path and schema-for-schema + * so existing v1 clients keep working against server-v2. Backed by the v2 + * Session-scoped `IFsService` (`agent-core-v2/src/agentFs`): the route resolves + * the session from the URL, then dispatches `fs:` to the matching + * `IFsService` method. The wire schema is reused from `@moonshot-ai/protocol`. + */ + +import { createReadStream } from 'node:fs'; + +import { + ErrorCodes, + IFsService, + ISessionLifecycleService, + isKimiError, + KimiError, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { + ErrorCode, + fsDiffRequestSchema, + fsGitStatusRequestSchema, + fsGrepRequestSchema, + fsListManyRequestSchema, + fsListRequestSchema, + fsMkdirRequestSchema, + fsOpenInRequestSchema, + fsOpenRequestSchema, + fsReadRequestSchema, + fsRevealRequestSchema, + fsSearchRequestSchema, + fsStatManyRequestSchema, + fsStatRequestSchema, +} from '@moonshot-ai/protocol'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { + launchDetached, + openFileCommandFor, + openInAppCommandFor, + revealFileCommandFor, +} from '../lib/fileLaunch'; +import { defineRoute } from '../middleware/defineRoute'; + +interface FsRouteHost { + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown; headers: Record }, + reply: FsDownloadReply, + ) => unknown, + ): unknown; +} + +interface FsDownloadReply { + type(mime: string): FsDownloadReply; + header(name: string, value: string | number): FsDownloadReply; + code(status: number): FsDownloadReply; + send(payload: unknown): unknown; +} + +const sessionIdAndTailParamSchema = z.object({ + session_id: z.string().min(1), + tail: z.string().min(1), +}); + +const FS_ACTIONS = [ + 'list', + 'read', + 'list_many', + 'stat', + 'stat_many', + 'mkdir', + 'search', + 'grep', + 'git_status', + 'diff', + 'open', + 'open-in', + 'reveal', +] as const; +type FsAction = (typeof FS_ACTIONS)[number]; +const FS_TAIL_PREFIX = 'fs:'; + +function resolveFs(core: Scope, sessionId: string): IFsService { + const session = core.accessor.get(ISessionLifecycleService).get(sessionId); + if (session === undefined) { + throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + return session.accessor.get(IFsService); +} + +export function registerFsRoutes(app: FsRouteHost, core: Scope): void { + const fsActionRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/{tail}', + params: sessionIdAndTailParamSchema, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_IS_DIRECTORY]: {}, + [ErrorCode.FS_IS_BINARY]: {}, + [ErrorCode.FS_TOO_LARGE]: {}, + [ErrorCode.FS_TOO_MANY_RESULTS]: {}, + [ErrorCode.FS_PATH_ESCAPES_SESSION]: {}, + [ErrorCode.FS_GREP_TIMEOUT]: {}, + [ErrorCode.FS_GIT_UNAVAILABLE]: {}, + [ErrorCode.FS_ALREADY_EXISTS]: {}, + }, + description: + 'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, mkdir, search, grep, git_status, diff, open, open-in, reveal.', + tags: ['fs'], + operationId: 'fsAction', + }, + async (req, reply) => { + const { session_id, tail } = req.params as { session_id: string; tail: string }; + + if (!tail.startsWith(FS_TAIL_PREFIX)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + + const action = tail.slice(FS_TAIL_PREFIX.length); + if (!(FS_ACTIONS as readonly string[]).includes(action)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + const fsAction = action as FsAction; + + try { + switch (fsAction) { + case 'list': + await handleList(core, session_id, req, reply); + return; + case 'read': + await handleRead(core, session_id, req, reply); + return; + case 'list_many': + await handleListMany(core, session_id, req, reply); + return; + case 'stat': + await handleStat(core, session_id, req, reply); + return; + case 'stat_many': + await handleStatMany(core, session_id, req, reply); + return; + case 'mkdir': + await handleMkdir(core, session_id, req, reply); + return; + case 'search': + await handleSearch(core, session_id, req, reply); + return; + case 'grep': + await handleGrep(core, session_id, req, reply); + return; + case 'git_status': + await handleGitStatus(core, session_id, req, reply); + return; + case 'diff': + await handleDiff(core, session_id, req, reply); + return; + case 'open': + await handleOpen(core, session_id, req, reply); + return; + case 'open-in': + await handleOpenIn(core, session_id, req, reply); + return; + case 'reveal': + await handleReveal(core, session_id, req, reply); + return; + } + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + fsActionRoute.path, + fsActionRoute.options, + fsActionRoute.handler as unknown as Parameters[2], + ); + + const downloadRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/fs/*', + rawResponse: { + 200: { type: 'string', format: 'binary' }, + }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_ESCAPES_SESSION]: {}, + }, + description: 'Download a file from the session workspace', + tags: ['fs'], + operationId: 'downloadFile', + }, + async (req, reply) => { + const { session_id } = req.params as { session_id: string }; + const wildcard = (req.params as Record)['*'] as string; + + const DOWNLOAD_SUFFIX = ':download'; + if (!wildcard.endsWith(DOWNLOAD_SUFFIX)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${wildcard}`, req.id), + ); + return; + } + const relPath = wildcard.slice(0, -DOWNLOAD_SUFFIX.length); + if (relPath.length === 0) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, 'path is empty', req.id)); + return; + } + + let resolved: Awaited>; + try { + resolved = await resolveFs(core, session_id).resolveDownload(relPath); + } catch (err) { + sendMappedError(reply, req.id, err); + return; + } + + const r = reply as unknown as FsDownloadReply; + const headers = req.headers; + + const ifNoneMatch = pickHeader(headers, 'if-none-match'); + if (ifNoneMatch !== undefined && ifNoneMatch === resolved.etag) { + r.code(304).header('etag', resolved.etag).send(''); + return; + } + + r.header('etag', resolved.etag); + r.header('last-modified', resolved.modifiedAt.toUTCString()); + r.header( + 'content-disposition', + `attachment; filename="${sanitizeFilename(resolved.relative)}"`, + ); + r.type(resolved.mime); + + const rangeHeader = pickHeader(headers, 'range'); + const range = parseRangeHeader(rangeHeader, resolved.size); + if (range !== null) { + r.code(206) + .header('content-length', String(range.length)) + .header('content-range', `bytes ${range.start}-${range.end}/${resolved.size}`); + const stream = createReadStream(resolved.absolute, { + start: range.start, + end: range.end, + }); + stream.on('error', () => { + try { + stream.destroy(); + } catch { + // best-effort + } + }); + return r.send(stream) as unknown as void; + } + + r.code(200).header('content-length', String(resolved.size)); + const stream = createReadStream(resolved.absolute); + stream.on('error', () => { + try { + stream.destroy(); + } catch { + // best-effort + } + }); + return r.send(stream) as unknown as void; + }, + ); + app.get( + downloadRoute.path, + downloadRoute.options, + downloadRoute.handler as unknown as Parameters[2], + ); +} + +// --------------------------------------------------------------------------- +// Action handlers — thin adapters: parse body, call IFsService, wrap result. +// --------------------------------------------------------------------------- + +type Req = { id: string; body: unknown }; +type Reply = { send(payload: unknown): unknown }; + +async function handleList(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsListRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).list(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleRead(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsReadRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).read(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleListMany(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsListManyRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).listMany(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleStat(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsStatRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).stat(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleStatMany(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsStatManyRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).statMany(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleMkdir(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsMkdirRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).mkdir(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleSearch(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsSearchRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).search(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleGrep(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsGrepRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).grep(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleGitStatus(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsGitStatusRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).gitStatus(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleDiff(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsDiffRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await resolveFs(core, sessionId).diff(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleOpen(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsOpenRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const resolved = await resolveFs(core, sessionId).resolvePath(parsed.data.path); + await launchDetached(openFileCommandFor(resolved.absolute, parsed.data.line)); + reply.send(okEnvelope({ opened: true as const }, req.id)); +} + +async function handleReveal(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsRevealRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const resolved = await resolveFs(core, sessionId).resolvePath(parsed.data.path); + await launchDetached(revealFileCommandFor(resolved.absolute)); + reply.send(okEnvelope({ revealed: true as const }, req.id)); +} + +async function handleOpenIn(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsOpenInRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body = parsed.data; + const resolved = await resolveFs(core, sessionId).resolvePath(body.path); + try { + await launchDetached( + openInAppCommandFor(body.app_id, resolved.absolute, { + line: body.line, + isDirectory: resolved.isDirectory, + }), + ); + } catch (err) { + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `failed to open in ${body.app_id}: ${err instanceof Error ? err.message : String(err)}`, + req.id, + ), + ); + return; + } + reply.send(okEnvelope({ opened: true as const }, req.id)); +} + +// --------------------------------------------------------------------------- +// Error mapping — domain KimiError codes → protocol wire codes. +// --------------------------------------------------------------------------- + +function sendMappedError(reply: Reply, requestId: string, err: unknown): void { + if (isKimiError(err)) { + switch (err.code) { + case ErrorCodes.FS_PATH_ESCAPES: + reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId)); + return; + case ErrorCodes.FS_PATH_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId)); + return; + case ErrorCodes.FS_IS_DIRECTORY: + reply.send(errEnvelope(ErrorCode.FS_IS_DIRECTORY, err.message, requestId)); + return; + case ErrorCodes.FS_ALREADY_EXISTS: + reply.send(errEnvelope(ErrorCode.FS_ALREADY_EXISTS, err.message, requestId)); + return; + case ErrorCodes.FS_IS_BINARY: + reply.send(errEnvelope(ErrorCode.FS_IS_BINARY, err.message, requestId)); + return; + case ErrorCodes.FS_TOO_LARGE: + reply.send(errEnvelope(ErrorCode.FS_TOO_LARGE, err.message, requestId)); + return; + case ErrorCodes.FS_TOO_MANY_RESULTS: + reply.send(errEnvelope(ErrorCode.FS_TOO_MANY_RESULTS, err.message, requestId)); + return; + case ErrorCodes.FS_GREP_TIMEOUT: + reply.send(errEnvelope(ErrorCode.FS_GREP_TIMEOUT, err.message, requestId)); + return; + case ErrorCodes.FS_GIT_UNAVAILABLE: + reply.send(errEnvelope(ErrorCode.FS_GIT_UNAVAILABLE, err.message, requestId)); + return; + case ErrorCodes.SESSION_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + return; + } + } + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err instanceof Error ? err.message : String(err), + requestId, + ), + ); +} + +function buildValidationEnvelope( + issues: readonly { path: readonly PropertyKey[]; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const details = issues.map((i) => ({ + path: i.path.map((p) => String(p)).join('.'), + message: i.message, + })); + const first = details[0]; + const msg = + first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} + +function pickHeader( + headers: Record, + name: string, +): string | undefined { + const v = headers[name]; + if (v === undefined) return undefined; + return Array.isArray(v) ? (v[0] as string | undefined) : (v as string); +} + +function parseRangeHeader( + raw: string | undefined, + size: number, +): { start: number; end: number; length: number } | null { + if (raw === undefined) return null; + if (!raw.startsWith('bytes=')) return null; + const spec = raw.slice('bytes='.length); + if (spec.includes(',')) return null; + const dash = spec.indexOf('-'); + if (dash < 0) return null; + const leftRaw = spec.slice(0, dash); + const rightRaw = spec.slice(dash + 1); + if (leftRaw === '' && rightRaw === '') return null; + let start: number; + let end: number; + if (leftRaw === '') { + const suffix = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(suffix) || suffix <= 0) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + const a = Number.parseInt(leftRaw, 10); + if (!Number.isFinite(a) || a < 0) return null; + start = a; + if (rightRaw === '') { + end = size - 1; + } else { + const b = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(b) || b < a) return null; + end = Math.min(b, size - 1); + } + } + if (start >= size || start > end) return null; + return { start, end, length: end - start + 1 }; +} + +function sanitizeFilename(rel: string): string { + const segs = rel.split('/'); + const base = segs[segs.length - 1] ?? rel; + return base.replace(/"/g, '\\"'); +} diff --git a/packages/server-v2/src/routes/registerApiV1Routes.ts b/packages/server-v2/src/routes/registerApiV1Routes.ts index 831dde0b1..b6e32db91 100644 --- a/packages/server-v2/src/routes/registerApiV1Routes.ts +++ b/packages/server-v2/src/routes/registerApiV1Routes.ts @@ -6,7 +6,7 @@ * `IInstantiationService`. v0.1 mounts the subset of routes that v2 can serve * end-to-end today (health, meta, auth readiness, OAuth device flow, config, * model/provider catalog, sessions, messages, approvals, workspaces, the fs - * folder picker, shutdown). + * folder picker, the session filesystem, shutdown). */ import type { Scope } from '@moonshot-ai/agent-core-v2'; @@ -17,6 +17,7 @@ import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; import { registerConfigRoutes } from './config'; import { registerFilesRoutes } from './files'; +import { registerFsRoutes } from './fs'; import { registerMessagesRoutes } from './messages'; import { registerMetaRoute } from './meta'; import { registerModelCatalogRoutes } from './modelCatalog'; @@ -103,6 +104,7 @@ export async function registerApiV1Routes( core, ); registerFilesRoutes(apiV1 as unknown as Parameters[0], core); + registerFsRoutes(apiV1 as unknown as Parameters[0], core); registerToolsRoutes(apiV1 as unknown as Parameters[0], core); registerShutdownRoutes(apiV1 as unknown as Parameters[0], { onShutdown: opts.onShutdown, diff --git a/packages/server-v2/test/fs.test.ts b/packages/server-v2/test/fs.test.ts new file mode 100644 index 000000000..d1ebf6c1b --- /dev/null +++ b/packages/server-v2/test/fs.test.ts @@ -0,0 +1,226 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { modelResolverSeed, SingleModelResolver } from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '@moonshot-ai/protocol'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface FsEntryWire { + path: string; + name: string; + kind: string; + size?: number; + modified_at: string; + etag?: string; + mime?: string; +} + +describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { + let server: RunningServer | undefined; + let home: string | undefined; + /** Session work dir — kept separate from the server homeDir so the server's + * own state (session storage under homeDir) does not pollute `fs:list`. */ + let work: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-home-')); + work = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-work-')); + const modelResolver = new SingleModelResolver({ + type: 'openai', + model: 'stub', + apiKey: 'stub', + }); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: modelResolverSeed(modelResolver), + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + if (work !== undefined) { + await rm(work, { recursive: true, force: true }); + work = undefined; + } + }); + + async function createSession(): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: work as string } }), + }); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + async function postFs(id: string, action: string, body: unknown): Promise> { + const res = await fetch(`${base}/api/v1/sessions/${id}/fs:${action}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return (await res.json()) as Envelope; + } + + it('fs:stat returns a file entry with the protocol shape', async () => { + await writeFile(join(work!, 'a.txt'), 'hello'); + const id = await createSession(); + const body = await postFs(id, 'stat', { path: 'a.txt' }); + expect(body.code).toBe(0); + expect(body.data.name).toBe('a.txt'); + expect(body.data.kind).toBe('file'); + expect(body.data.size).toBe(5); + expect(typeof body.data.modified_at).toBe('string'); + expect(typeof body.data.etag).toBe('string'); + }); + + it('fs:stat maps a missing path to FS_PATH_NOT_FOUND', async () => { + const id = await createSession(); + const body = await postFs(id, 'stat', { path: 'nope.txt' }); + expect(body.code).toBe(ErrorCode.FS_PATH_NOT_FOUND); + }); + + it('fs:read returns utf-8 content', async () => { + await writeFile(join(work!, 'a.txt'), 'hello world'); + const id = await createSession(); + const body = await postFs<{ content: string; encoding: string; size: number }>( + id, + 'read', + { path: 'a.txt' }, + ); + expect(body.code).toBe(0); + expect(body.data.content).toBe('hello world'); + expect(body.data.encoding).toBe('utf-8'); + expect(body.data.size).toBe(11); + }); + + it('fs:read maps a directory to FS_IS_DIRECTORY', async () => { + const id = await createSession(); + const body = await postFs(id, 'read', { path: '.' }); + expect(body.code).toBe(ErrorCode.FS_IS_DIRECTORY); + }); + + it('fs:list returns items', async () => { + await writeFile(join(work!, 'a.txt'), ''); + await writeFile(join(work!, 'b.txt'), ''); + const id = await createSession(); + const body = await postFs<{ items: FsEntryWire[]; truncated: boolean }>(id, 'list', {}); + expect(body.code).toBe(0); + const names = body.data.items.map((i) => i.name).sort(); + expect(names).toEqual(['a.txt', 'b.txt']); + expect(body.data.truncated).toBe(false); + }); + + it('fs:mkdir creates a directory and rejects duplicates', async () => { + const id = await createSession(); + const created = await postFs(id, 'mkdir', { path: 'sub' }); + expect(created.code).toBe(0); + expect(created.data.kind).toBe('directory'); + + const dup = await postFs(id, 'mkdir', { path: 'sub' }); + expect(dup.code).toBe(ErrorCode.FS_ALREADY_EXISTS); + }); + + it('fs:stat_many returns null for missing paths', async () => { + await writeFile(join(work!, 'a.txt'), 'hi'); + const id = await createSession(); + const body = await postFs<{ entries: Record }>( + id, + 'stat_many', + { paths: ['a.txt', 'missing.txt'] }, + ); + expect(body.code).toBe(0); + expect(body.data.entries['a.txt']?.kind).toBe('file'); + expect(body.data.entries['missing.txt']).toBeNull(); + }); + + it('fs:search finds files by query', async () => { + await writeFile(join(work!, 'alpha.ts'), ''); + await writeFile(join(work!, 'beta.ts'), ''); + const id = await createSession(); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + id, + 'search', + { query: 'alpha' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('alpha.ts'); + }); + + it('fs:grep finds matching lines', async () => { + await writeFile(join(work!, 'a.txt'), 'hello world\nfoo bar\n'); + const id = await createSession(); + const body = await postFs<{ files: { path: string; matches: unknown[] }[] }>( + id, + 'grep', + { pattern: 'hello' }, + ); + expect(body.code).toBe(0); + expect(body.data.files.length).toBeGreaterThanOrEqual(1); + }); + + it('fs:git_status maps a non-git workspace to FS_GIT_UNAVAILABLE', async () => { + const id = await createSession(); + const body = await postFs(id, 'git_status', {}); + expect(body.code).toBe(ErrorCode.FS_GIT_UNAVAILABLE); + }); + + it('rejects an unknown action with VALIDATION_FAILED', async () => { + const id = await createSession(); + const body = await postFs(id, 'bogus', {}); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + it('maps an unknown session to SESSION_NOT_FOUND', async () => { + const body = await postFs('does-not-exist', 'stat', { path: 'a.txt' }); + expect(body.code).toBe(ErrorCode.SESSION_NOT_FOUND); + }); + + it('rejects a path that escapes the workspace', async () => { + const id = await createSession(); + const body = await postFs(id, 'stat', { path: '../etc/passwd' }); + expect(body.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + }); + + it('GET fs/{path}:download streams the file and honors If-None-Match', async () => { + await writeFile(join(work!, 'a.txt'), 'download-me'); + const id = await createSession(); + + const res = await fetch(`${base}/api/v1/sessions/${id}/fs/a.txt:download`); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toBe('download-me'); + const etag = res.headers.get('etag'); + expect(etag).toBeTruthy(); + + const cached = await fetch(`${base}/api/v1/sessions/${id}/fs/a.txt:download`, { + headers: { 'if-none-match': etag as string }, + }); + expect(cached.status).toBe(304); + }); +});