diff --git a/.changeset/host-fs-content-endpoint.md b/.changeset/host-fs-content-endpoint.md new file mode 100644 index 0000000000..2dbcccf5c3 --- /dev/null +++ b/.changeset/host-fs-content-endpoint.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a GET /api/v1/fs:content server endpoint that serves any file on the host by absolute path as raw content with Content-Type, ETag, and Range support. diff --git a/packages/agent-core-v2/src/_base/utils/fileMeta.ts b/packages/agent-core-v2/src/_base/utils/fileMeta.ts new file mode 100644 index 0000000000..f3419c5b35 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/fileMeta.ts @@ -0,0 +1,110 @@ +/** + * File content metadata helpers — binary detection, line counting, etag, and + * extension-based mime / language guessing. + * + * Shared by the fs edge domains (`sessionFs`) and the kap-server fs routes so + * every read-style surface classifies and labels file content the same way. + * Pure functions over bytes, text, and stat-like shapes; no io happens here. + * Binary detection samples the leading `FS_BINARY_SAMPLE_BYTES` of a file and + * flags it as binary when the non-printable fraction exceeds + * `FS_BINARY_NONPRINTABLE_FRACTION`; etags are built from any stat-like shape + * carrying `size` / `mtimeMs` / `ino` (`FileMetaStat`, satisfied by + * `HostFileStat`). + */ + +import { extname } from 'node:path'; + +export const FS_BINARY_SAMPLE_BYTES = 4096; +export const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; + +export interface FileMetaStat { + readonly size: number; + readonly mtimeMs?: number; + readonly ino?: number; +} + +export function detectBinary(buf: Uint8Array): boolean { + if (buf.length === 0) return false; + let nonPrintable = 0; + for (let i = 0; i < buf.length; i++) { + const b = buf[i]!; + if (b === 0) return true; + if (b === 9 || b === 10 || b === 13) continue; + if (b >= 32 && b <= 126) continue; + nonPrintable++; + } + return nonPrintable / buf.length > FS_BINARY_NONPRINTABLE_FRACTION; +} + +export function countLines(text: string): number { + if (text.length === 0) return 0; + let n = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) n++; + } + if (text.charCodeAt(text.length - 1) === 10) n--; + return Math.max(0, n); +} + +export function buildEtag(st: FileMetaStat): string { + const mtime = Math.floor(st.mtimeMs ?? 0); + const ino = st.ino ?? 0; + return [mtime.toString(36), st.size.toString(36), ino.toString(36)].join('-'); +} + +const EXT_TO_MIME: Readonly> = { + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', + '.json': 'application/json', + '.md': 'text/markdown', + '.html': 'text/html', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.pdf': 'application/pdf', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'application/toml', + '.sh': 'text/x-shellscript', + '.py': 'text/x-python', + '.rs': 'text/rust', + '.go': 'text/x-go', +}; + +export function guessMime(path: string, isBinary: boolean): string { + const ext = extname(path).toLowerCase(); + const mapped = EXT_TO_MIME[ext]; + if (mapped !== undefined) return mapped; + return isBinary ? 'application/octet-stream' : 'text/plain'; +} + +const EXT_TO_LANGUAGE: Readonly> = { + '.ts': 'typescript', + '.tsx': 'typescriptreact', + '.js': 'javascript', + '.jsx': 'javascriptreact', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.md': 'markdown', + '.html': 'html', + '.css': 'css', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.sh': 'shellscript', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', +}; + +export function guessLanguageId(path: string): string | undefined { + return EXT_TO_LANGUAGE[extname(path).toLowerCase()]; +} diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index cf70e6e998..f4fec21bd2 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -15,7 +15,7 @@ * a symlink inside the workspace must not steer fs actions to files outside it. */ -import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'; import { type FsDiffRequest, @@ -62,6 +62,14 @@ import ignore, { type Ignore } from 'ignore'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { + buildEtag, + countLines, + detectBinary, + FS_BINARY_SAMPLE_BYTES, + guessLanguageId, + guessMime, +} from '#/_base/utils/fileMeta'; import { ErrorCodes, Error2, isError2, unwrapErrorCause } from '#/errors'; import { IGitService } from '#/app/git/git'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -88,8 +96,6 @@ const GREP_TIMEOUT_MS = 30_000; const WALK_MAX_DEPTH = 64; const FS_READ_MAX_BYTES = 10 * 1024 * 1024; -const FS_BINARY_SAMPLE_BYTES = 4096; -const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; const HIDDEN_NAME_RE = /^\./; const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); @@ -901,12 +907,6 @@ function sortChildren( children.sort(cmp); } -function buildEtag(st: HostFileStat): string { - const mtime = Math.floor(st.mtimeMs ?? 0); - const ino = st.ino ?? 0; - return [mtime.toString(36), st.size.toString(36), ino.toString(36)].join('-'); -} - function buildFsEntry( relPath: string, name: string, @@ -936,29 +936,6 @@ function buildFsEntry( return entry; } -function detectBinary(buf: Uint8Array): boolean { - if (buf.length === 0) return false; - let nonPrintable = 0; - for (let i = 0; i < buf.length; i++) { - const b = buf[i]!; - if (b === 0) return true; - if (b === 9 || b === 10 || b === 13) continue; - if (b >= 32 && b <= 126) continue; - nonPrintable++; - } - return nonPrintable / buf.length > FS_BINARY_NONPRINTABLE_FRACTION; -} - -function countLines(text: string): number { - if (text.length === 0) return 0; - let n = 1; - for (let i = 0; i < text.length; i++) { - if (text.charCodeAt(i) === 10) n++; - } - if (text.charCodeAt(text.length - 1) === 10) n--; - return Math.max(0, n); -} - function errnoCode(err: unknown): string | undefined { const unwrapped = unwrapErrorCause(err); if (typeof unwrapped === 'object' && unwrapped !== null && 'code' in unwrapped) { @@ -1017,63 +994,6 @@ function toWireError(err: unknown): { code: number; msg: string } { }; } -const EXT_TO_MIME: Readonly> = { - '.ts': 'text/typescript', - '.tsx': 'text/typescript', - '.js': 'text/javascript', - '.jsx': 'text/javascript', - '.mjs': 'text/javascript', - '.cjs': 'text/javascript', - '.json': 'application/json', - '.md': 'text/markdown', - '.html': 'text/html', - '.css': 'text/css', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.pdf': 'application/pdf', - '.yaml': 'text/yaml', - '.yml': 'text/yaml', - '.toml': 'application/toml', - '.sh': 'text/x-shellscript', - '.py': 'text/x-python', - '.rs': 'text/rust', - '.go': 'text/x-go', -}; - -function guessMime(relPath: string, isBinary: boolean): string { - const ext = extname(relPath).toLowerCase(); - const mapped = EXT_TO_MIME[ext]; - if (mapped !== undefined) return mapped; - return isBinary ? 'application/octet-stream' : 'text/plain'; -} - -const EXT_TO_LANGUAGE: Readonly> = { - '.ts': 'typescript', - '.tsx': 'typescriptreact', - '.js': 'javascript', - '.jsx': 'javascriptreact', - '.mjs': 'javascript', - '.cjs': 'javascript', - '.json': 'json', - '.md': 'markdown', - '.html': 'html', - '.css': 'css', - '.yaml': 'yaml', - '.yml': 'yaml', - '.toml': 'toml', - '.sh': 'shellscript', - '.py': 'python', - '.rs': 'rust', - '.go': 'go', -}; - -function guessLanguageId(relPath: string): string | undefined { - return EXT_TO_LANGUAGE[extname(relPath).toLowerCase()]; -} - registerScopedService( LifecycleScope.Session, ISessionFsService, diff --git a/packages/kap-server/src/lib/httpRange.ts b/packages/kap-server/src/lib/httpRange.ts new file mode 100644 index 0000000000..dbc7b872eb --- /dev/null +++ b/packages/kap-server/src/lib/httpRange.ts @@ -0,0 +1,54 @@ +/** + * HTTP helpers for raw-content routes — request header access and single-range + * `Range` header parsing shared by the file download / content endpoints. + */ + +export function pickHeader( + headers: Record, + name: string, +): string | undefined { + const v = headers[name]; + if (v === undefined) return undefined; + return Array.isArray(v) ? (v[0] as string | undefined) : (v as string); +} + +/** + * Parse a single-range `bytes=` header against a known size. Returns null for + * absent, malformed, multi-range, or unsatisfiable specs (callers then serve + * the full body with 200). + */ +export function parseRangeHeader( + raw: string | undefined, + size: number, +): { start: number; end: number; length: number } | null { + if (raw === undefined) return null; + if (!raw.startsWith('bytes=')) return null; + const spec = raw.slice('bytes='.length); + if (spec.includes(',')) return null; + const dash = spec.indexOf('-'); + if (dash < 0) return null; + const leftRaw = spec.slice(0, dash); + const rightRaw = spec.slice(dash + 1); + if (leftRaw === '' && rightRaw === '') return null; + let start: number; + let end: number; + if (leftRaw === '') { + const suffix = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(suffix) || suffix <= 0) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + const a = Number.parseInt(leftRaw, 10); + if (!Number.isFinite(a) || a < 0) return null; + start = a; + if (rightRaw === '') { + end = size - 1; + } else { + const b = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(b) || b < a) return null; + end = Math.min(b, size - 1); + } + } + if (start >= size || start > end) return null; + return { start, end, length: end - start + 1 }; +} diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index bd3433217f..4a55cd33d4 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -40,6 +40,7 @@ import { openInAppCommandFor, revealFileCommandFor, } from '../lib/fileLaunch'; +import { parseRangeHeader, pickHeader } from '../lib/httpRange'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ErrorCode } from '../protocol/error-codes'; @@ -589,51 +590,6 @@ function buildValidationEnvelope( }; } -function pickHeader( - headers: Record, - name: string, -): string | undefined { - const v = headers[name]; - if (v === undefined) return undefined; - return Array.isArray(v) ? (v[0] as string | undefined) : (v as string); -} - -function parseRangeHeader( - raw: string | undefined, - size: number, -): { start: number; end: number; length: number } | null { - if (raw === undefined) return null; - if (!raw.startsWith('bytes=')) return null; - const spec = raw.slice('bytes='.length); - if (spec.includes(',')) return null; - const dash = spec.indexOf('-'); - if (dash < 0) return null; - const leftRaw = spec.slice(0, dash); - const rightRaw = spec.slice(dash + 1); - if (leftRaw === '' && rightRaw === '') return null; - let start: number; - let end: number; - if (leftRaw === '') { - const suffix = Number.parseInt(rightRaw, 10); - if (!Number.isFinite(suffix) || suffix <= 0) return null; - start = Math.max(0, size - suffix); - end = size - 1; - } else { - const a = Number.parseInt(leftRaw, 10); - if (!Number.isFinite(a) || a < 0) return null; - start = a; - if (rightRaw === '') { - end = size - 1; - } else { - const b = Number.parseInt(rightRaw, 10); - if (!Number.isFinite(b) || b < a) return null; - end = Math.min(b, size - 1); - } - } - if (start >= size || start > end) return null; - return { start, end, length: end - start + 1 }; -} - function sanitizeFilename(rel: string): string { const segs = rel.split('/'); const base = segs[segs.length - 1] ?? rel; diff --git a/packages/kap-server/src/routes/workspaceFs.ts b/packages/kap-server/src/routes/workspaceFs.ts index 1116bf86d7..5e3863731a 100644 --- a/packages/kap-server/src/routes/workspaceFs.ts +++ b/packages/kap-server/src/routes/workspaceFs.ts @@ -1,21 +1,39 @@ /** - * `/api/v1/fs::browse` + `/api/v1/fs::home` route handlers — server-v2 port. + * `/api/v1/fs::browse` + `/api/v1/fs::home` + `/api/v1/fs::content` route + * handlers — server-v2 port. * - * Mirrors `packages/server/src/routes/workspaceFs.ts` path-for-path: two - * distinct `GET` routes backed by `agent-core-v2`'s native `IHostFolderBrowser` - * (Core scope). The domain service already returns the protocol wire shapes - * (`FsBrowseResponse` / `FsHomeResponse`), so this is a thin adapter that wraps - * results in the project envelope and translates domain errors to protocol - * error codes — no `LegacyService` is needed (server-align.md Case A): + * The folder-picker pair mirrors `packages/server/src/routes/workspaceFs.ts` + * path-for-path: two distinct `GET` routes backed by `agent-core-v2`'s native + * `IHostFolderBrowser` (Core scope), translating its domain errors to wire + * codes (server-align.md Case A): * * - `HostFolderNotAbsoluteError` → 40001 validation.failed * - `HostFolderNotFoundError` → 40409 fs.path_not_found * - `HostFolderPermissionError` → 40411 fs.permission_denied * - * Routes (registered exactly as v1 declares them): + * `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) + * with a best-effort `Content-Type`, `ETag` / `If-None-Match` caching, and + * single-range `Range` support — the same serving semantics as the session + * `fs/{path}:download` route, so browsers can render previews directly. + * All file handling lives here in the transport layer on top of the os + * `IHostFileSystem` primitives — the engine deliberately has no "unconfined + * read" domain Service. The mime / etag helpers are shared with the engine's + * `sessionFs` via `agent-core-v2/_base/utils/fileMeta` so both surfaces label + * content the same way. `IHostFileSystem` failures arrive as coded `os.fs.*` + * errors and are mapped here: * - * GET /fs::browse?path= list sub-directories - * GET /fs::home $HOME + recent workspace roots + * - not absolute → 40001 validation.failed + * - `os.fs.not_found` / `not_directory` → 40409 fs.path_not_found + * - `os.fs.permission_denied` → 40411 fs.permission_denied + * - directory target → 40906 fs.is_directory + * + * Routes: + * + * 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) * * **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 @@ -23,16 +41,24 @@ * as a static/param split, so these registrations are served on the wire as * **single-colon** URLs — `/api/v1/fs:browse` and `/api/v1/fs:home`. That is * byte-for-byte the v1 contract (see `packages/protocol/src/rest/fsBrowse.ts`, - * which documents `GET /v1/fs:browse` / `GET /v1/fs:home`). A single - * `/fs:action` parametric dispatcher is NOT a faithful mirror: it accepts the - * double-colon URL that v1 404s on and rejects the single-colon URL v1 serves. + * which documents `GET /v1/fs:browse` / `GET /v1/fs:home`). `/fs::content` + * follows the same single-colon wire convention. A single `/fs:action` + * parametric dispatcher is NOT a faithful mirror: it accepts the double-colon + * URL that v1 404s on and rejects the single-colon URL v1 serves. */ +import { createReadStream, type ReadStream } from 'node:fs'; +import { isAbsolute } from 'node:path'; + import { + ErrorCodes, HostFolderNotAbsoluteError, HostFolderNotFoundError, HostFolderPermissionError, + IHostFileSystem, IHostFolderBrowser, + isError2, + type HostFileStat, type Scope, } from '@moonshot-ai/agent-core-v2'; import { @@ -40,18 +66,34 @@ import { fsBrowseResponseSchema, fsHomeResponseSchema, } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import { + buildEtag, + detectBinary, + FS_BINARY_SAMPLE_BYTES, + guessMime, +} from '@moonshot-ai/agent-core-v2/_base/utils/fileMeta'; +import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; +import { parseRangeHeader, pickHeader } from '../lib/httpRange'; +import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ErrorCode } from '../protocol/error-codes'; +interface FsContentReply { + type(mime: string): FsContentReply; + header(name: string, value: string | number): FsContentReply; + code(status: number): FsContentReply; + send(payload: unknown): unknown; +} + interface WorkspaceFsRouteHost { get( path: string, options: { preHandler: unknown[]; schema?: Record } | undefined, handler: ( - req: { id: string; query: { path?: string } }, - reply: { send(payload: unknown): unknown }, + req: { id: string; query: { path?: string }; headers: Record }, + reply: FsContentReply, ) => Promise | void, ): unknown; } @@ -79,7 +121,7 @@ export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope app.get( browseRoute.path, browseRoute.options, - browseRoute.handler as Parameters[2], + browseRoute.handler as unknown as Parameters[2], ); const homeRoute = defineRoute( @@ -103,10 +145,176 @@ export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope app.get( homeRoute.path, homeRoute.options, - homeRoute.handler as Parameters[2], + homeRoute.handler as unknown as Parameters[2], + ); + + const contentRoute = defineRoute( + { + method: 'GET', + path: '/fs::content', + querystring: fsContentQuerySchema, + rawResponse: { + 200: { type: 'string', format: 'binary' }, + 206: { type: 'string', format: 'binary' }, + }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_PERMISSION_DENIED]: {}, + [ErrorCode.FS_IS_DIRECTORY]: {}, + }, + description: + 'Serve the raw content of any file on the host filesystem by absolute path. Supports ETag caching and single-range requests.', + tags: ['workspaces'], + operationId: 'fsContent', + }, + async (req, reply) => { + return handleFsContent(core, req, reply as unknown as FsContentReply); + }, + ); + app.get( + contentRoute.path, + contentRoute.options, + contentRoute.handler as unknown as Parameters[2], ); } +// --------------------------------------------------------------------------- +// fs:content — host-side arbitrary file serving, implemented in the transport layer. +// --------------------------------------------------------------------------- + +const fsContentQuerySchema = z.object({ + path: z.string().min(1), +}); + +interface FsContentRequest { + id: string; + query: { path: string }; + headers: Record; +} + +async function handleFsContent( + core: Scope, + req: FsContentRequest, + reply: FsContentReply, +): Promise { + const requestId = req.id; + const { path } = req.query; + if (!isAbsolute(path)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `path must be absolute: ${path}`, requestId), + ); + return; + } + + const hostFs = core.accessor.get(IHostFileSystem); + + let abs: string; + let st: HostFileStat; + try { + abs = await hostFs.realpath(path); + st = await hostFs.stat(abs); + } catch (err) { + sendOsFsError(reply, requestId, err, path); + return; + } + + if (st.isDirectory) { + reply.send( + errEnvelope(ErrorCode.FS_IS_DIRECTORY, `path is a directory: ${path}`, requestId), + ); + return; + } + // Only regular files are served: device nodes (/dev/zero streams forever), + // FIFOs (reads block), sockets, and /proc-style zero-size virtual files + // would otherwise hang or produce malformed responses. + if (!st.isFile) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `path is not a regular file: ${path}`, + requestId, + ), + ); + return; + } + + // Sample the leading bytes only to refine the mime fallback for unknown + // extensions (octet-stream vs text/plain), mirroring session fs downloads. + let isBinary = false; + try { + const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size); + const sample = + sampleSize === 0 ? new Uint8Array() : await hostFs.readBytes(abs, sampleSize); + isBinary = detectBinary(sample); + } catch (err) { + sendOsFsError(reply, requestId, err, path); + return; + } + + const etag = buildEtag(st); + const ifNoneMatch = pickHeader(req.headers, 'if-none-match'); + if (ifNoneMatch !== undefined && ifNoneMatch === etag) { + reply.code(304).header('etag', etag).send(''); + return; + } + + reply.header('etag', etag); + reply.header('last-modified', new Date(st.mtimeMs ?? 0).toUTCString()); + reply.type(guessMime(abs, isBinary)); + + const log = requestLog(req); + const onStreamError = (stream: ReadStream) => (error: unknown) => { + log?.warn({ path, err: error }, 'fs content stream error'); + try { + stream.destroy(); + } catch { + // best-effort + } + }; + + const range = parseRangeHeader(pickHeader(req.headers, 'range'), st.size); + if (range !== null) { + reply + .code(206) + .header('content-length', String(range.length)) + .header('content-range', `bytes ${range.start}-${range.end}/${st.size}`); + const stream = createReadStream(abs, { start: range.start, end: range.end }); + stream.on('error', onStreamError(stream)); + return reply.send(stream) as unknown as void; + } + + reply.code(200).header('content-length', String(st.size)); + const stream = createReadStream(abs); + stream.on('error', onStreamError(stream)); + return reply.send(stream) as unknown as void; +} + +/** Map a coded `os.fs.*` failure from `IHostFileSystem` onto the wire codes. */ +function sendOsFsError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, + path: string, +): void { + if (isError2(err)) { + switch (err.code) { + case ErrorCodes.OS_FS_NOT_FOUND: + case ErrorCodes.OS_FS_NOT_DIRECTORY: + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path not found: ${path}`, requestId), + ); + return; + case ErrorCodes.OS_FS_PERMISSION_DENIED: + reply.send( + errEnvelope(ErrorCode.FS_PERMISSION_DENIED, `permission denied: ${path}`, requestId), + ); + return; + } + } + throw err; +} + function sendMappedError( reply: { send(payload: unknown): unknown }, requestId: string, 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 d6585d9036..eb8c2dad38 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -68,6 +68,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/fs:browse", ], + [ + "GET", + "/api/v1/fs:content", + ], [ "GET", "/api/v1/fs:home", diff --git a/packages/kap-server/test/workspaceFs.test.ts b/packages/kap-server/test/workspaceFs.test.ts index 544e32f258..7ef2bdafe0 100644 --- a/packages/kap-server/test/workspaceFs.test.ts +++ b/packages/kap-server/test/workspaceFs.test.ts @@ -180,3 +180,152 @@ describe('server-v2 /api/v1 fs folder picker', () => { expect(body.data.recent_roots).toContain(root); }); }); + +describe('server-v2 /api/v1 fs:content', () => { + 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-fscontent-')); + instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fscontent-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; + } + }); + + function contentUrl(path: string): string { + return `${base}/api/v1/fs:content?path=${encodeURIComponent(path)}`; + } + + async function getContent( + path: string, + headers: Record = {}, + ): Promise { + // `connection: close` keeps every fetch on its own short-lived socket so + // undici never pools an idle keep-alive connection that would hold + // `server.close()` open in afterEach. + return fetch(contentUrl(path), { + headers: { connection: 'close', ...authHeaders(server as RunningServer), ...headers }, + } as never); + } + + it('serves a text file raw with mime, etag, and length headers', async () => { + const file = join(dir as string, 'hello.md'); + await writeFile(file, '# hi\n'); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/markdown'); + expect(res.headers.get('content-length')).toBe('5'); + expect(typeof res.headers.get('etag')).toBe('string'); + expect(typeof res.headers.get('last-modified')).toBe('string'); + expect(await res.text()).toBe('# hi\n'); + }); + + it('serves an unknown-extension text file as text/plain', async () => { + const file = join(dir as string, 'notes.weird'); + await writeFile(file, 'just text'); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/plain'); + }); + + it('serves binary files byte-for-byte with an octet-stream fallback mime', async () => { + const file = join(dir as string, 'blob.bin'); + const original = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00, 0x10, 0x80]); + await writeFile(file, original); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/octet-stream'); + expect(Buffer.from(await res.arrayBuffer()).equals(original)).toBe(true); + }); + + it('guesses image mime from the extension', async () => { + const file = join(dir as string, 'pic.png'); + await writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01])); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('image/png'); + }); + + it('answers If-None-Match with 304 when the etag matches', async () => { + const file = join(dir as string, 'cached.txt'); + await writeFile(file, 'cache me'); + + const first = await getContent(file); + const etag = first.headers.get('etag') as string; + + const res = await getContent(file, { 'if-none-match': etag }); + expect(res.status).toBe(304); + expect(res.headers.get('etag')).toBe(etag); + expect(await res.text()).toBe(''); + }); + + it('honors single-range requests with 206', async () => { + const file = join(dir as string, 'long.txt'); + await writeFile(file, '0123456789'); + + const res = await getContent(file, { range: 'bytes=2-5' }); + expect(res.status).toBe(206); + expect(res.headers.get('content-range')).toBe('bytes 2-5/10'); + expect(res.headers.get('content-length')).toBe('4'); + expect(await res.text()).toBe('2345'); + }); + + it('rejects a relative path (40001)', async () => { + const res = await getContent('relative/path.txt'); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40001); + }); + + it('rejects a nonexistent path (40409)', async () => { + const res = await getContent(join(dir as string, 'does-not-exist.txt')); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40409); + }); + + it('rejects a directory path (40906)', async () => { + const res = await getContent(dir as string); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40906); + }); + + // /dev/null is a character device, not a regular file. + it.skipIf(process.platform === 'win32')('rejects non-regular files (40001)', async () => { + const res = await getContent('/dev/null'); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40001); + }); + + it('does not serve the double-colon URL', async () => { + const res = await fetch(`${base}/api/v1/fs::content?path=%2Ftmp`, { + headers: authHeaders(server as RunningServer), + } as never); + expect(res.status).toBe(404); + }); +});