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:
Haozhe 2026-07-28 10:50:39 +08:00 committed by GitHub
parent 77618e38c3
commit 7e30add445
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 226 additions and 3 deletions

View file

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

View file

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

View file

@ -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());

View file

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