diff --git a/packages/kap-server/src/routes/workspaceFs.ts b/packages/kap-server/src/routes/workspaceFs.ts index 5e3863731..f10725c54 100644 --- a/packages/kap-server/src/routes/workspaceFs.ts +++ b/packages/kap-server/src/routes/workspaceFs.ts @@ -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= list sub-directories (v1 mirror) * GET /fs::home $HOME + recent workspace roots (v1 mirror) * GET /fs::content?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, ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | 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[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[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 { + 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 }, diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 4203a1750..844b2f304 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -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", diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 0073418f3..b6a2db27d 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -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()); diff --git a/packages/kap-server/test/workspaceFs.test.ts b/packages/kap-server/test/workspaceFs.test.ts index 7ef2bdafe..a581ede98 100644 --- a/packages/kap-server/test/workspaceFs.test.ts +++ b/packages/kap-server/test/workspaceFs.test.ts @@ -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( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + 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 }; + } + + 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; + expect(browseBody.data.entries.map((e) => e.name)).toContain('fresh-folder'); + }); + + it('rejects a relative path (40001)', async () => { + const { body } = await postJson('/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('/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('/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('/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;