Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/host-fs-content-endpoint.md
Original file line number Diff line number Diff line change
@@ -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.
110 changes: 110 additions & 0 deletions packages/agent-core-v2/src/_base/utils/fileMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use the agent-core-v2 header convention

packages/agent-core-v2/AGENTS.md requires package comments to be header-only and to start with <domain> domain (Ln) — .... This new helper's top block starts with a generic description instead, so it violates the documented convention for touched agent-core-v2 files; please rewrite it to the required domain identity header and avoid additional symbol-level comments below.

Useful? React with 👍 / 👎.

* 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<Record<string, string>> = {
'.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<Record<string, string>> = {
'.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()];
}
98 changes: 9 additions & 89 deletions packages/agent-core-v2/src/session/sessionFs/fsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand All @@ -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']);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1017,63 +994,6 @@ function toWireError(err: unknown): { code: number; msg: string } {
};
}

const EXT_TO_MIME: Readonly<Record<string, string>> = {
'.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<Record<string, string>> = {
'.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,
Expand Down
54 changes: 54 additions & 0 deletions packages/kap-server/src/lib/httpRange.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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 };
}
46 changes: 1 addition & 45 deletions packages/kap-server/src/routes/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -589,51 +590,6 @@ function buildValidationEnvelope(
};
}

function pickHeader(
headers: Record<string, unknown>,
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;
Expand Down
Loading
Loading