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
6 changes: 6 additions & 0 deletions .changeset/feedback-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": minor
"@moonshot-ai/kimi-code-sdk": minor
---

Add optional feedback attachments for diagnostic logs and codebase context.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ coverage/
plugins/cdn/
.worktrees/
.kimi-code/local.toml
.kimi-sandbox/

Dockerfile
docker-compose.yml
Expand Down
72 changes: 72 additions & 0 deletions apps/kimi-code/src/feedback/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';

import { getCacheDir } from '../utils/paths';

const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours.

/**
* A file produced for a feedback attachment upload. Both the session log
* archive and the codebase archive share this shape; the generic uploader
* consumes it without caring how the file was produced.
*/
export interface FeedbackArchive {
readonly path: string;
readonly size: number;
readonly sha256: string;
readonly fingerprint: string;
readonly fileCount: number;
/** Directory created exclusively for this archive and safe to remove after upload. */
readonly cleanupDir?: string;
}

export async function createFeedbackArchivePath(filename: string): Promise<{
readonly archivePath: string;
readonly cleanupDir: string;
}> {
const archivePath = await createArchivePath(filename);
return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) };
}

/**
* Remove feedback-upload archive directories older than 24 hours. Packaging
* cleans up its own archive on success and on failure, but a killed process
* or an empty parent dir can still leave leftovers behind; this is a
* best-effort backstop so the cache dir does not grow without bound.
*
* `dir` is injectable for tests; production callers leave it as the default.
*/
export async function removeStaleFeedbackUploads(
options: { readonly now?: number; readonly dir?: string } = {},
): Promise<void> {
const now = options.now ?? Date.now();
const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads');
const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
});
if (entries === null) return;

const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS;
await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory() && !entry.isSymbolicLink()) return;
const target = join(dir, entry.name);
const targetStat = await stat(target).catch(() => null);
if (targetStat === null || targetStat.mtimeMs >= cutoff) return;
await rm(target, { recursive: true, force: true }).catch(() => {});
}),
);
}

async function createArchivePath(filename: string): Promise<string> {
await removeStaleFeedbackUploads();
const root = join(getCacheDir(), 'feedback-uploads');
await mkdir(root, { recursive: true });
const dir = await mkdtemp(join(root, 'upload-'));
return join(dir, filename);
}

function archivePathCleanupDir(archivePath: string): string {
return dirname(archivePath);
}
92 changes: 92 additions & 0 deletions apps/kimi-code/src/feedback/codebase/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
export const DEFAULT_MAX_FILES = 50000;
export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
// Upper bound for the compressed codebase archive, aligned with the backend's
// per-upload limit. The scanner uses cumulative raw file size as a conservative
// estimate so the resulting zip stays within this bound.
export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024;

const IGNORED_DIR_NAMES: ReadonlySet<string> = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.turbo',
'.cache',
'.parcel-cache',
'coverage',
'.nyc_output',
'target',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'.venv',
'venv',
'env',
'.idea',
]);

const SENSITIVE_DIR_NAMES: ReadonlySet<string> = new Set([
'.ssh',
'.gnupg',
'.aws',
'.kube',
'.docker',
]);

const SENSITIVE_FILE_NAMES: ReadonlySet<string> = new Set([
'.env',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'credentials.json',
'service-account.json',
'serviceAccount.json',
'.netrc',
'.htpasswd',
'.pypirc',
'.npmrc',
'.envrc',
'.yarnrc',
'.yarnrc.yml',
]);

const SENSITIVE_FILE_SUFFIXES: readonly string[] = [
'.pem',
'.key',
'.p12',
'.pfx',
'.jks',
'.keystore',
];

const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet<string> = new Set(['.example', '.sample', '.template']);

export function isIgnoredDirName(name: string): boolean {
return IGNORED_DIR_NAMES.has(name);
}

export function isSensitivePath(relativePath: string): boolean {
const segments = relativePath.split('/');
for (let i = 0; i < segments.length - 1; i += 1) {
const segment = segments[i];
if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true;
}

const base = segments.at(-1);
if (base === undefined || base.length === 0) return false;
if (SENSITIVE_FILE_NAMES.has(base)) return true;
if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true;

if (base.startsWith('.env.')) {
const suffix = base.slice('.env'.length);
return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix);
}

return false;
}
3 changes: 3 additions & 0 deletions apps/kimi-code/src/feedback/codebase/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './packager';
export * from './scanner';
export * from './types';
98 changes: 98 additions & 0 deletions apps/kimi-code/src/feedback/codebase/packager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir, rm, stat } from 'node:fs/promises';
import { dirname } from 'node:path';

import { ZipFile } from 'yazl';

import type { FeedbackArchive } from '../archive';
import type { FeedbackCodebaseScanResult } from './types';

interface PackageEntry {
readonly absolutePath: string;
readonly archivePath: string;
readonly size: number;
readonly mtimeMs: number;
}

/**
* Pack the scanned codebase into a zip, with files placed at the zip root.
*/
export async function packageCodebase(
scan: FeedbackCodebaseScanResult,
archivePath: string,
): Promise<FeedbackArchive> {
const entries: PackageEntry[] = scan.files.map((file) => ({
absolutePath: file.absolutePath,
archivePath: file.path,
size: file.size,
mtimeMs: file.mtimeMs,
}));
return packageEntries(entries, archivePath);
}

async function packageEntries(
entries: readonly PackageEntry[],
archivePath: string,
): Promise<FeedbackArchive> {
if (entries.length === 0) {
throw new Error('Cannot package an empty feedback archive.');
}
await mkdir(dirname(archivePath), { recursive: true });

const zip = new ZipFile();
const hash = createHash('sha256');
const output = createWriteStream(archivePath);

try {
const done = new Promise<void>((resolvePromise, rejectPromise) => {
output.on('finish', resolvePromise);
output.on('error', rejectPromise);
zip.outputStream.on('error', rejectPromise);
});

zip.outputStream.on('data', (chunk: Buffer) => {
hash.update(chunk);
});
zip.outputStream.pipe(output);

for (const entry of entries) {
zip.addFile(entry.absolutePath, entry.archivePath, {
mtime: new Date(entry.mtimeMs),
mode: 0o100644,
});
}
zip.end();
await done;

const archiveStat = await stat(archivePath);
return {
path: archivePath,
size: archiveStat.size,
sha256: hash.digest('hex'),
fingerprint: fingerprintEntries(entries),
fileCount: entries.length,
};
} catch (error) {
// A failed zip (e.g. a source file vanished or became unreadable between
// scan and packaging) would otherwise leave a partial archive behind in
// the cache dir. Destroy the stream so the handle is released before we
// remove the file, then best-effort delete it.
output.destroy();
await rm(archivePath, { force: true }).catch(() => {});
throw error;
}
}

function fingerprintEntries(entries: readonly PackageEntry[]): string {
const hash = createHash('sha256');
for (const entry of entries) {
hash.update(entry.archivePath);
hash.update('\0');
hash.update(String(entry.size));
hash.update('\0');
hash.update(String(Math.trunc(entry.mtimeMs)));
hash.update('\n');
}
return hash.digest('hex');
}
Loading
Loading