feat(server-v2): add fs::browse and fs::home folder picker routes

- implement GET /api/v1/fs::browse and /api/v1/fs::home, matching the
  packages/server v1 wire contract (directory-only entries, git metadata,
  dot-last sorting, parent resolution, $HOME + recent workspace roots)
- complete IHostFolderBrowser in agent-core-v2: realpath resolution, git
  detection, recent_roots from IWorkspaceRegistry, and HostFolder* domain
  errors mapped to protocol codes 40001/40409/40411
- register the routes behind a /fs:action semi-static path because
  find-my-way cannot register the literal /fs::browse / /fs::home paths
This commit is contained in:
haozhe.yang 2026-06-29 21:17:43 +08:00
parent e083b923e5
commit 7d04f4f7dd
5 changed files with 478 additions and 18 deletions

View file

@ -1,26 +1,69 @@
/**
* `hostFolderBrowser` domain (L1) host-side folder picker.
* `hostFolderBrowser` domain (L2) host-side folder picker.
*
* Defines the `IHostFolderBrowser` used by the program side (TUI / server) to
* let the user browse the real local filesystem when choosing a workspace
* folder. Distinct from the Agent-side `agentFs`, which is sandboxed and may
* be remote. Core-scoped.
*
* The wire shapes (`FsBrowseResponse` / `FsHomeResponse`) are sourced from
* `@moonshot-ai/protocol` so the `/api/v1` and `/api/v2` transports share one
* contract. Domain errors (`HostFolder*Error`) carry the failing path and are
* translated to protocol error codes at the transport boundary.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { HostDirEntry } from '#/hostFs';
export interface FsBrowseResponse {
import type { FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
export type { FsBrowseResponse, FsHomeResponse };
/** Thrown by `browse` when the requested path is not absolute. */
export class HostFolderNotAbsoluteError extends Error {
readonly path: string;
readonly entries: readonly HostDirEntry[];
constructor(path: string) {
super(`path must be absolute: ${path}`);
this.name = 'HostFolderNotAbsoluteError';
this.path = path;
}
}
/** Thrown by `browse` when the requested path does not exist or is not a directory. */
export class HostFolderNotFoundError extends Error {
readonly path: string;
constructor(path: string) {
super(`path not found: ${path}`);
this.name = 'HostFolderNotFoundError';
this.path = path;
}
}
/** Thrown by `browse` when the process lacks permission to read the path. */
export class HostFolderPermissionError extends Error {
readonly path: string;
constructor(path: string) {
super(`permission denied: ${path}`);
this.name = 'HostFolderPermissionError';
this.path = path;
}
}
export interface IHostFolderBrowser {
readonly _serviceBrand: undefined;
/**
* List the immediate sub-directories of `absPath` (defaults to `$HOME`),
* annotated with git metadata. The returned `path` is the realpath of the
* target.
*/
browse(absPath?: string): Promise<FsBrowseResponse>;
home(): Promise<string>;
/** `$HOME` plus the most recently opened workspace roots. */
home(): Promise<FsHomeResponse>;
}
export const IHostFolderBrowser: ServiceIdentifier<IHostFolderBrowser> =
createDecorator<IHostFolderBrowser>('hostFolderBrowser');
/** Maximum number of recent workspace roots returned by `home()`. */
export const RECENT_ROOTS_LIMIT = 8;

View file

@ -1,35 +1,152 @@
/**
* `hostFolderBrowser` domain (L1) `IHostFolderBrowser` implementation.
* `hostFolderBrowser` domain (L2) `IHostFolderBrowser` implementation.
*
* Browses the real local filesystem through the program-side `hostFs`
* primitives. Bound at Core scope.
* Browses the real local filesystem through `node:fs/promises` and derives
* `recent_roots` from the process-wide `IWorkspaceRegistry`. Bound at Core
* scope. Mirrors the v1 `WorkspaceFsService` behaviour so the `/api/v1`
* transport stays wire-compatible: realpath resolution, directory-only
* entries, git metadata, dot-last sorting, and `parent` resolution.
*/
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import type { FsBrowseEntry, FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IHostFileSystem } from '#/hostFs';
import { IWorkspaceRegistry } from '#/workspaceRegistry';
import { type FsBrowseResponse, IHostFolderBrowser } from './hostFolderBrowser';
import {
HostFolderNotAbsoluteError,
HostFolderNotFoundError,
HostFolderPermissionError,
IHostFolderBrowser,
RECENT_ROOTS_LIMIT,
} from './hostFolderBrowser';
export class HostFolderBrowser implements IHostFolderBrowser {
declare readonly _serviceBrand: undefined;
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
constructor(@IWorkspaceRegistry private readonly registry: IWorkspaceRegistry) {}
async browse(absPath?: string): Promise<FsBrowseResponse> {
const path = resolve(absPath ?? homedir());
const entries = await this.hostFs.readdir(path);
return { path, entries };
const target = absPath ?? homedir();
if (!isAbsolute(target)) {
throw new HostFolderNotAbsoluteError(target);
}
let realTarget: string;
try {
realTarget = await realpath(target);
} catch (err) {
throw mapFsError(err, target);
}
let dirents;
try {
dirents = await readdir(realTarget, { withFileTypes: true });
} catch (err) {
throw mapFsError(err, realTarget);
}
const dirOnly = dirents.filter((d) => d.isDirectory());
const entries: FsBrowseEntry[] = await Promise.all(
dirOnly.map(async (d) => {
const childAbs = join(realTarget, d.name);
const git = await detectGit(childAbs);
return {
name: d.name,
path: childAbs,
is_dir: true as const,
is_git_repo: git.is_git_repo,
branch: git.branch ?? undefined,
};
}),
);
entries.sort(compareBrowseEntries);
const parent = dirname(realTarget);
return {
path: realTarget,
parent: parent === realTarget ? null : parent,
entries,
};
}
home(): Promise<string> {
return Promise.resolve(homedir());
async home(): Promise<FsHomeResponse> {
const home = homedir();
const workspaces = await this.registry.list();
const recent_roots = workspaces.slice(0, RECENT_ROOTS_LIMIT).map((w) => w.root);
return { home, recent_roots };
}
}
function mapFsError(err: unknown, path: string): Error {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return new HostFolderNotFoundError(path);
}
if (code === 'EACCES' || code === 'EPERM') {
return new HostFolderPermissionError(path);
}
return err instanceof Error ? err : new Error(String(err));
}
function compareBrowseEntries(a: FsBrowseEntry, b: FsBrowseEntry): number {
const aDot = a.name.startsWith('.');
const bDot = b.name.startsWith('.');
if (aDot !== bDot) return aDot ? 1 : -1;
return a.name.localeCompare(b.name);
}
interface GitInfo {
readonly is_git_repo: boolean;
readonly branch: string | null;
}
async function detectGit(root: string): Promise<GitInfo> {
let dotGit;
try {
dotGit = await lstat(join(root, '.git'));
} catch {
return { is_git_repo: false, branch: null };
}
let gitDir: string;
if (dotGit.isDirectory()) {
gitDir = join(root, '.git');
} else if (dotGit.isFile()) {
let text: string;
try {
text = await readFile(join(root, '.git'), 'utf8');
} catch {
return { is_git_repo: false, branch: null };
}
const m = /^gitdir:\s*(.+)$/m.exec(text);
if (m === null) return { is_git_repo: false, branch: null };
const ref = m[1] ?? '';
if (ref === '') return { is_git_repo: false, branch: null };
gitDir = ref.trim();
if (!gitDir.startsWith('/')) {
gitDir = join(root, gitDir);
}
} else {
return { is_git_repo: false, branch: null };
}
let head: string;
try {
head = (await readFile(join(gitDir, 'HEAD'), 'utf8')).trim();
} catch {
return { is_git_repo: true, branch: null };
}
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
return { is_git_repo: true, branch: ref ? (ref[1] ?? null) : null };
}
registerScopedService(
LifecycleScope.Core,
IHostFolderBrowser,

View file

@ -5,7 +5,8 @@
* services from the `agent-core-v2` Core `Scope` instead of the v1 flat
* `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, shutdown).
* model/provider catalog, sessions, messages, approvals, workspaces, the fs
* folder picker, shutdown).
*/
import type { Scope } from '@moonshot-ai/agent-core-v2';
@ -25,6 +26,7 @@ import { registerQuestionsRoutes } from './questions';
import { registerSessionsRoutes } from './sessions';
import { registerShutdownRoutes } from './shutdown';
import { registerToolsRoutes } from './tools';
import { registerWorkspaceFsRoutes } from './workspaceFs';
import { registerWorkspacesRoutes } from './workspaces';
interface ApiV1AppHost {
@ -94,6 +96,10 @@ export async function registerApiV1Routes(
apiV1 as unknown as Parameters<typeof registerWorkspacesRoutes>[0],
core,
);
registerWorkspaceFsRoutes(
apiV1 as unknown as Parameters<typeof registerWorkspaceFsRoutes>[0],
core,
);
registerFilesRoutes(apiV1 as unknown as Parameters<typeof registerFilesRoutes>[0], core);
registerToolsRoutes(apiV1 as unknown as Parameters<typeof registerToolsRoutes>[0], core);
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {

View file

@ -0,0 +1,115 @@
/**
* `/fs::browse` and `/fs::home` route handlers server-v2 port.
*
* Implements the v1 folder-picker wire contract on top of `agent-core-v2`'s
* `IHostFolderBrowser` (Core scope). The domain service owns the filesystem
* work and returns protocol-shaped payloads; this module is a thin facade
* that wraps results in the project envelope and translates domain errors to
* protocol error codes:
*
* - `HostFolderNotAbsoluteError` 40001 validation.failed
* - `HostFolderNotFoundError` 40409 fs.path_not_found
* - `HostFolderPermissionError` 40411 fs.permission_denied
*
* GET /fs::browse?path=<abs-path> list sub-directories (+ git metadata)
* GET /fs::home $HOME + recent workspace roots
*
* **Why a single `/fs:action` semi-static route?** find-my-way (Fastify's
* router) treats `:` as a parameter marker, so the literal v1 paths
* `/fs::browse` / `/fs::home` cannot be registered directly they collapse
* into an unmatchable `/fs:browse` node. Registering `/fs:action` (static
* prefix `fs` + `action` parameter) lets `/fs::browse` arrive with
* `params.action === '::browse'`, which we dispatch on here.
*/
import {
HostFolderNotAbsoluteError,
HostFolderNotFoundError,
HostFolderPermissionError,
IHostFolderBrowser,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import {
ErrorCode,
fsBrowseQuerySchema,
fsBrowseResponseSchema,
fsHomeResponseSchema,
} from '@moonshot-ai/protocol';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
const fsActionParamsSchema = z.object({ action: z.string() });
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
interface WorkspaceFsRouteHost {
get(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> } | undefined,
handler: (
req: { id: string; params: { action: string }; query: { path?: string } },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}
export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope): void {
const route = defineRoute(
{
method: 'GET',
path: '/fs:action',
params: fsActionParamsSchema,
querystring: fsBrowseQuerySchema,
success: { data: z.union([fsBrowseResponseSchema, fsHomeResponseSchema]) },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.FS_PATH_NOT_FOUND]: {},
[ErrorCode.FS_PERMISSION_DENIED]: {},
},
description:
'Folder picker backend. Use GET /fs::browse?path=<abs-path> to list sub-directories, or GET /fs::home for $HOME + recent workspace roots.',
tags: ['workspaces'],
operationId: 'fsAction',
},
async (req, reply) => {
const action = req.params.action;
if (action !== '::browse' && action !== '::home') {
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `unknown fs action: ${action}`, req.id));
return;
}
try {
const browser = core.accessor.get(IHostFolderBrowser);
const data =
action === '::browse'
? await browser.browse(req.query.path)
: await browser.home();
reply.send(okEnvelope(data, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);
}
},
);
app.get(route.path, route.options, route.handler as Parameters<WorkspaceFsRouteHost['get']>[2]);
}
function sendMappedError(
reply: { send(payload: unknown): unknown },
requestId: string,
err: unknown,
): void {
if (err instanceof HostFolderNotAbsoluteError) {
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId));
return;
}
if (err instanceof HostFolderNotFoundError) {
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId));
return;
}
if (err instanceof HostFolderPermissionError) {
reply.send(errEnvelope(ErrorCode.FS_PERMISSION_DENIED, err.message, requestId));
return;
}
throw err;
}

View file

@ -0,0 +1,179 @@
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
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 BrowseEntryWire {
name: string;
path: string;
is_dir: true;
is_git_repo: boolean;
branch?: string;
}
interface BrowseWire {
path: string;
parent: string | null;
entries: BrowseEntryWire[];
}
interface HomeWire {
home: string;
recent_roots: string[];
}
describe('server-v2 /api/v1 fs folder picker', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
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;
}
});
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function postJson<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
const hasBody = body !== undefined;
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: hasBody ? { 'content-type': 'application/json' } : undefined,
body: hasBody ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
it('defaults browse to $HOME when path is omitted', async () => {
const { status, body } = await getJson<BrowseWire>('/api/v1/fs::browse');
expect(status).toBe(200);
expect(body.code).toBe(0);
expect(body.data.path).toBe(await realpath(homedir()));
expect(typeof body.data.parent === 'string' || body.data.parent === null).toBe(true);
expect(Array.isArray(body.data.entries)).toBe(true);
});
it('lists only directories and filters files', async () => {
const root = home as string;
await mkdir(join(root, 'alpha'));
await mkdir(join(root, 'beta'));
await writeFile(join(root, 'README.md'), 'hi');
const { body } = await getJson<BrowseWire>(
`/api/v1/fs::browse?path=${encodeURIComponent(root)}`,
);
expect(body.code).toBe(0);
expect(body.data.path).toBe(await realpath(root));
const names = body.data.entries.map((e) => e.name).sort();
expect(names).toEqual(['alpha', 'beta']);
for (const entry of body.data.entries) {
expect(entry.is_dir).toBe(true);
expect(entry.path).toBe(join(await realpath(root), entry.name));
expect(typeof entry.is_git_repo).toBe('boolean');
}
});
it('annotates git repositories with the current branch', async () => {
const root = home as string;
const repo = join(root, 'repo');
await mkdir(join(repo, '.git'), { recursive: true });
await writeFile(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n');
await mkdir(join(root, 'plain'));
const { body } = await getJson<BrowseWire>(
`/api/v1/fs::browse?path=${encodeURIComponent(root)}`,
);
expect(body.code).toBe(0);
const byName = new Map(body.data.entries.map((e) => [e.name, e]));
expect(byName.get('repo')?.is_git_repo).toBe(true);
expect(byName.get('repo')?.branch).toBe('main');
expect(byName.get('plain')?.is_git_repo).toBe(false);
expect(byName.get('plain')?.branch).toBeUndefined();
});
it('sorts dot-directories after regular ones', async () => {
const root = home as string;
await mkdir(join(root, '.zeta'));
await mkdir(join(root, 'alpha'));
const { body } = await getJson<BrowseWire>(
`/api/v1/fs::browse?path=${encodeURIComponent(root)}`,
);
expect(body.code).toBe(0);
expect(body.data.entries.map((e) => e.name)).toEqual(['alpha', '.zeta']);
});
it('returns parent=null for the filesystem root', async () => {
const { body } = await getJson<BrowseWire>('/api/v1/fs::browse?path=%2F');
expect(body.code).toBe(0);
expect(body.data.path).toBe('/');
expect(body.data.parent).toBeNull();
});
it('rejects a relative path (40001)', async () => {
const { body } = await getJson<null>(
`/api/v1/fs::browse?path=${encodeURIComponent('relative/path')}`,
);
expect(body.code).toBe(40001);
});
it('rejects a nonexistent path (40409)', async () => {
const missing = join(home as string, 'does-not-exist');
const { body } = await getJson<null>(
`/api/v1/fs::browse?path=${encodeURIComponent(missing)}`,
);
expect(body.code).toBe(40409);
});
it('returns an empty recent_roots when no workspaces are registered', async () => {
const { status, body } = await getJson<HomeWire>('/api/v1/fs::home');
expect(status).toBe(200);
expect(body.code).toBe(0);
expect(body.data.home).toBe(homedir());
expect(body.data.recent_roots).toEqual([]);
});
it('reflects registered workspace roots in recent_roots', async () => {
const root = home as string;
const created = await postJson<{ id: string }>('/api/v1/workspaces', { root });
expect(created.body.code).toBe(0);
const { body } = await getJson<HomeWire>('/api/v1/fs::home');
expect(body.code).toBe(0);
expect(body.data.recent_roots).toContain(root);
});
});