mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-17 20:55:34 +00:00
feat(kap-server): add global fs:mkdir endpoint (#2281)
* feat(kap-server): add global fs:mkdir endpoint Add POST /api/v1/fs:mkdir to create a directory on the host filesystem by absolute path, backing the folder picker's "new folder" action. Implemented directly on node:fs/promises.mkdir in the transport layer for now, non-recursive by design, with wire errors mapped to the existing fs.* codes (40001/40409/40411/40919). * test(kap-server): update api surface snapshot for fs:mkdir * test(kap-server): stop export tests from holding server.close() open The export download tests reused pooled undici keep-alive connections, so afterEach's server.close() could wait out fastify's 72s default keepAliveTimeout and die on the 10s hook timeout (flaky on CI). Send connection: close on the streamed export requests, matching the fs:content tests.
This commit is contained in:
parent
77618e38c3
commit
7e30add445
4 changed files with 226 additions and 3 deletions
|
|
@ -11,6 +11,12 @@
|
|||
* - `HostFolderNotFoundError` → 40409 fs.path_not_found
|
||||
* - `HostFolderPermissionError` → 40411 fs.permission_denied
|
||||
*
|
||||
* `fs::mkdir` is another server-v2 addition with no v1 counterpart: it creates
|
||||
* a directory by absolute path (the folder picker's "new folder" backend). It
|
||||
* is TEMPORARILY implemented directly on `node:fs/promises.mkdir` here in the
|
||||
* transport layer; the engine deliberately has no "unconfined write" domain
|
||||
* Service, same as the read side.
|
||||
*
|
||||
* `fs::content` is a server-v2 addition with no v1 counterpart: it serves ANY
|
||||
* absolute path on the host as a raw byte stream, so the global bearer auth
|
||||
* is its only access gate. The response is plain file content (no envelope)
|
||||
|
|
@ -34,6 +40,7 @@
|
|||
* GET /fs::browse?path=<abs-path> list sub-directories (v1 mirror)
|
||||
* GET /fs::home $HOME + recent workspace roots (v1 mirror)
|
||||
* GET /fs::content?path=<abs-path> raw content of any host file (server-v2 addition)
|
||||
* POST /fs::mkdir { path } create a directory by absolute path (server-v2 addition)
|
||||
*
|
||||
* **Wire path vs source path.** The source path strings carry a double colon
|
||||
* (`/fs::browse`, `/fs::home`) because that is the v1 declaration this mirror
|
||||
|
|
@ -48,6 +55,7 @@
|
|||
*/
|
||||
|
||||
import { createReadStream, type ReadStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import {
|
||||
|
|
@ -96,6 +104,14 @@ interface WorkspaceFsRouteHost {
|
|||
reply: FsContentReply,
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
post(
|
||||
path: string,
|
||||
options: { preHandler: unknown[]; schema?: Record<string, unknown> } | undefined,
|
||||
handler: (
|
||||
req: { id: string; body: unknown },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope): void {
|
||||
|
|
@ -177,6 +193,33 @@ export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope
|
|||
contentRoute.options,
|
||||
contentRoute.handler as unknown as Parameters<WorkspaceFsRouteHost['get']>[2],
|
||||
);
|
||||
|
||||
const mkdirRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/fs::mkdir',
|
||||
body: fsMkdirBodySchema,
|
||||
success: { data: fsMkdirResponseSchema },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: {},
|
||||
[ErrorCode.FS_PATH_NOT_FOUND]: {},
|
||||
[ErrorCode.FS_PERMISSION_DENIED]: {},
|
||||
[ErrorCode.FS_ALREADY_EXISTS]: {},
|
||||
},
|
||||
description:
|
||||
'Create a directory on the host filesystem by absolute path (folder-picker "new folder" backend). Non-recursive: the parent directory must already exist.',
|
||||
tags: ['workspaces'],
|
||||
operationId: 'fsMkdir',
|
||||
},
|
||||
async (req, reply) => {
|
||||
return handleFsMkdir(req, reply);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
mkdirRoute.path,
|
||||
mkdirRoute.options,
|
||||
mkdirRoute.handler as unknown as Parameters<WorkspaceFsRouteHost['post']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -290,6 +333,68 @@ async function handleFsContent(
|
|||
return reply.send(stream) as unknown as void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fs:mkdir — host-side directory creation, temporarily on node fs directly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fsMkdirBodySchema = z.object({
|
||||
path: z.string().min(1),
|
||||
});
|
||||
|
||||
const fsMkdirResponseSchema = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
interface FsMkdirRequest {
|
||||
id: string;
|
||||
body: { path: string };
|
||||
}
|
||||
|
||||
async function handleFsMkdir(
|
||||
req: FsMkdirRequest,
|
||||
reply: { send(payload: unknown): unknown },
|
||||
): Promise<void> {
|
||||
const requestId = req.id;
|
||||
const { path } = req.body;
|
||||
if (!isAbsolute(path)) {
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.VALIDATION_FAILED, `path must be absolute: ${path}`, requestId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-recursive on purpose: the folder picker creates one level at a time,
|
||||
// and a missing parent surfacing as fs.path_not_found beats silently
|
||||
// creating a deep tree the user mistyped.
|
||||
try {
|
||||
await mkdir(path);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
switch (code) {
|
||||
case 'EEXIST':
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.FS_ALREADY_EXISTS, `path already exists: ${path}`, requestId),
|
||||
);
|
||||
return;
|
||||
case 'ENOENT':
|
||||
case 'ENOTDIR':
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `parent path not found: ${path}`, requestId),
|
||||
);
|
||||
return;
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.FS_PERMISSION_DENIED, `permission denied: ${path}`, requestId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
reply.send(okEnvelope({ path }, requestId));
|
||||
}
|
||||
|
||||
/** Map a coded `os.fs.*` failure from `IHostFileSystem` onto the wire codes. */
|
||||
function sendOsFsError(
|
||||
reply: { send(payload: unknown): unknown },
|
||||
|
|
|
|||
|
|
@ -264,6 +264,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e
|
|||
"POST",
|
||||
"/api/v1/files",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/fs:mkdir",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/gui/store/clear",
|
||||
|
|
|
|||
|
|
@ -152,9 +152,16 @@ describe('server-v2 /api/v1/sessions', () => {
|
|||
JSON.stringify({ event: 'prompt.submitted', time: 2 }),
|
||||
].join('\n');
|
||||
|
||||
// `connection: close` keeps the streamed download on a short-lived socket
|
||||
// so undici never pools a keep-alive connection that would hold
|
||||
// `server.close()` open in afterEach (fastify's default keepAliveTimeout
|
||||
// is 72s, far beyond the hook timeout).
|
||||
const res = await fetch(`${base}/api/v1/sessions/${id}/export`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
||||
headers: authHeaders(server as RunningServer, {
|
||||
'content-type': 'application/json',
|
||||
connection: 'close',
|
||||
}),
|
||||
body: JSON.stringify({ web_log: webLog }),
|
||||
} as never);
|
||||
const archive = Buffer.from(await res.arrayBuffer());
|
||||
|
|
@ -201,7 +208,10 @@ describe('server-v2 /api/v1/sessions', () => {
|
|||
|
||||
const res = await fetch(`${base}/api/v1/sessions/${id}/export`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
||||
headers: authHeaders(server as RunningServer, {
|
||||
'content-type': 'application/json',
|
||||
connection: 'close',
|
||||
}),
|
||||
body: '{}',
|
||||
} as never);
|
||||
const reader = res.body?.getReader();
|
||||
|
|
@ -241,7 +251,10 @@ describe('server-v2 /api/v1/sessions', () => {
|
|||
|
||||
const res = await fetch(`${base}/api/v1/sessions/${id}/export`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
||||
headers: authHeaders(server as RunningServer, {
|
||||
'content-type': 'application/json',
|
||||
connection: 'close',
|
||||
}),
|
||||
body: JSON.stringify({ desktop: true }),
|
||||
} as never);
|
||||
const archive = Buffer.from(await res.arrayBuffer());
|
||||
|
|
|
|||
|
|
@ -181,6 +181,107 @@ describe('server-v2 /api/v1 fs folder picker', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('server-v2 /api/v1 fs:mkdir', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let dir: string | undefined;
|
||||
let instancesDir: string | undefined;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-'));
|
||||
instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-instances-'));
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: dir,
|
||||
instancesDir,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (dir !== undefined) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
}
|
||||
if (instancesDir !== undefined) {
|
||||
await rm(instancesDir, { recursive: true, force: true });
|
||||
instancesDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function postJson<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
} as never);
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
it('creates a directory that fs:browse then lists', async () => {
|
||||
const target = join(dir as string, 'fresh-folder');
|
||||
|
||||
const { status, body } = await postJson<{ path: string }>('/api/v1/fs:mkdir', {
|
||||
path: target,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.path).toBe(target);
|
||||
|
||||
const browse = await fetch(
|
||||
`${base}/api/v1/fs:browse?path=${encodeURIComponent(dir as string)}`,
|
||||
{ headers: authHeaders(server as RunningServer) } as never,
|
||||
);
|
||||
const browseBody = (await browse.json()) as Envelope<BrowseWire>;
|
||||
expect(browseBody.data.entries.map((e) => e.name)).toContain('fresh-folder');
|
||||
});
|
||||
|
||||
it('rejects a relative path (40001)', async () => {
|
||||
const { body } = await postJson<null>('/api/v1/fs:mkdir', { path: 'relative/folder' });
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('rejects an existing directory (40919)', async () => {
|
||||
const target = join(dir as string, 'already-here');
|
||||
await mkdir(target);
|
||||
|
||||
const { body } = await postJson<null>('/api/v1/fs:mkdir', { path: target });
|
||||
expect(body.code).toBe(40919);
|
||||
});
|
||||
|
||||
it('rejects an existing file (40919)', async () => {
|
||||
const target = join(dir as string, 'file.txt');
|
||||
await writeFile(target, 'hi');
|
||||
|
||||
const { body } = await postJson<null>('/api/v1/fs:mkdir', { path: target });
|
||||
expect(body.code).toBe(40919);
|
||||
});
|
||||
|
||||
it('rejects a missing parent (40409)', async () => {
|
||||
const target = join(dir as string, 'no-such-parent', 'child');
|
||||
const { body } = await postJson<null>('/api/v1/fs:mkdir', { path: target });
|
||||
expect(body.code).toBe(40409);
|
||||
});
|
||||
|
||||
it('does not serve the double-colon URL', async () => {
|
||||
const res = await fetch(`${base}/api/v1/fs::mkdir`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
||||
body: JSON.stringify({ path: join(dir as string, 'x') }),
|
||||
} as never);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-v2 /api/v1 fs:content', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let dir: string | undefined;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue