mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-18 13:15:51 +00:00
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:
parent
f6720b8c9e
commit
f3ebc1e2a7
12 changed files with 1855 additions and 31 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
225
packages/server-v2/src/lib/fileLaunch.ts
Normal file
225
packages/server-v2/src/lib/fileLaunch.ts
Normal 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("'", "'\\''")}'`;
|
||||
}
|
||||
583
packages/server-v2/src/routes/fs.ts
Normal file
583
packages/server-v2/src/routes/fs.ts
Normal 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, '\\"');
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
226
packages/server-v2/test/fs.test.ts
Normal file
226
packages/server-v2/test/fs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue