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
This commit is contained in:
haozhe.yang 2026-06-30 14:12:04 +08:00
parent f6720b8c9e
commit f3ebc1e2a7
12 changed files with 1855 additions and 31 deletions

View file

@ -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<AgentFileStat>;
readdir(path: string): Promise<readonly string[]>;
glob(pattern: string): Promise<readonly string[]>;
mkdir(path: string): Promise<void>;
mkdir(
path: string,
options?: { readonly parents?: boolean; readonly existOk?: boolean },
): Promise<void>;
withCwd(cwd: string): IAgentFileSystem;
}

View file

@ -62,7 +62,12 @@ export class AgentFileSystem implements IAgentFileSystem {
async stat(path: string): Promise<AgentFileStat> {
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<readonly string[]> {
@ -81,8 +86,14 @@ export class AgentFileSystem implements IAgentFileSystem {
return out;
}
mkdir(path: string): Promise<void> {
return this.kaos.backend.mkdir(path, { parents: true, existOk: true });
mkdir(
path: string,
options?: { readonly parents?: boolean; readonly existOk?: boolean },
): Promise<void> {
return this.kaos.backend.mkdir(path, {
parents: options?.parents ?? true,
existOk: options?.existOk ?? true,
});
}
withCwd(cwd: string): IAgentFileSystem {

View file

@ -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',

View file

@ -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<FsListResponse>;
read(req: FsReadRequest): Promise<FsReadResponse>;
listMany(req: FsListManyRequest): Promise<FsListManyResponse>;
stat(req: FsStatRequest): Promise<FsStatResponse>;
statMany(req: FsStatManyRequest): Promise<FsStatManyResponse>;
mkdir(req: FsMkdirRequest): Promise<FsMkdirResponse>;
search(req: FsSearchRequest): Promise<FsSearchResponse>;
grep(req: FsGrepRequest): Promise<FsGrepResponse>;
gitStatus(req: FsGitStatusRequest): Promise<FsGitStatusResponse>;
diff(req: FsDiffRequest): Promise<FsDiffResponse>;
resolvePath(relPath: string): Promise<FsPathResolved>;
resolveDownload(relPath: string): Promise<FsDownloadResolved>;
}
export const IFsService: ServiceIdentifier<IFsService> =

View file

@ -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<FsListResponse> {
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<string, FsEntry[]> = {};
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<FsReadResponse> {
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<FsListManyResponse> {
const results: Record<string, FsEntry[]> = {};
const partialErrors: Record<string, { code: number; msg: string }> = {};
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<FsStatResponse> {
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<FsStatManyResponse> {
const resolved = req.paths.map((p) => {
const abs = this.resolveWithin(p);
return { raw: p, rel: this.toRel(abs), abs };
});
const entries: Record<string, FsEntry | null> = {};
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<FsMkdirResponse> {
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<FsPathResolved> {
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<FsDownloadResolved> {
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<FsSearchResponse> {
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<Record<string, string>> = {
'.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<Record<string, string>> = {
'.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,

View file

@ -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,
],
},
);

View file

@ -44,37 +44,79 @@ function fakeFs(files: Record<string, string>): 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<string> {
// 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<string>();
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' });
});
});

View file

@ -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'

View file

@ -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<string, string | undefined> = 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<void> {
return new Promise<void>((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, string | undefined>): 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("'", "'\\''")}'`;
}

View file

@ -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:<action>` 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<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
get(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; params: unknown; headers: Record<string, unknown> },
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<FsRouteHost['post']>[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<string, unknown>)['*'] 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<ReturnType<IFsService['resolveDownload']>>;
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<FsRouteHost['get']>[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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown>,
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, '\\"');
}

View file

@ -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<typeof registerFilesRoutes>[0], core);
registerFsRoutes(apiV1 as unknown as Parameters<typeof registerFsRoutes>[0], core);
registerToolsRoutes(apiV1 as unknown as Parameters<typeof registerToolsRoutes>[0], core);
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
onShutdown: opts.onShutdown,

View file

@ -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<T> {
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<string> {
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<T>(id: string, action: string, body: unknown): Promise<Envelope<T>> {
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<T>;
}
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<FsEntryWire>(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<null>(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<null>(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<FsEntryWire>(id, 'mkdir', { path: 'sub' });
expect(created.code).toBe(0);
expect(created.data.kind).toBe('directory');
const dup = await postFs<null>(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<string, FsEntryWire | null> }>(
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<null>(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<null>(id, 'bogus', {});
expect(body.code).toBe(ErrorCode.VALIDATION_FAILED);
});
it('maps an unknown session to SESSION_NOT_FOUND', async () => {
const body = await postFs<null>('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<null>(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);
});
});