Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions packages/kap-server/src/routes/workspaceFs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -48,6 +55,7 @@
*/

import { createReadStream, type ReadStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { isAbsolute } from 'node:path';

import {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the API surface snapshot for fs:mkdir

Adding this documented POST route changes /openapi.json, but packages/kap-server/test/apiSurface.snapshot.test.ts derives the route table from that document and the checked-in snapshot still only contains the existing fs:browse, fs:content, and fs:home entries. In CI, that snapshot assertion will fail until the new POST /api/v1/fs:mkdir route is recorded intentionally.

Useful? React with 👍 / 👎.

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],
);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 },
Expand Down
101 changes: 101 additions & 0 deletions packages/kap-server/test/workspaceFs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading