preflight-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -50,6 +50,7 @@ export function getConfig() {
50
50
  githubToken: process.env.GITHUB_TOKEN,
51
51
  context7ApiKey: process.env.CONTEXT7_API_KEY,
52
52
  context7McpUrl: process.env.CONTEXT7_MCP_URL ?? 'https://mcp.context7.com/mcp',
53
+ gitCloneTimeoutMs: envNumber('PREFLIGHT_GIT_CLONE_TIMEOUT_MS', 5 * 60_000),
53
54
  maxFileBytes: envNumber('PREFLIGHT_MAX_FILE_BYTES', 512 * 1024),
54
55
  maxTotalBytes: envNumber('PREFLIGHT_MAX_TOTAL_BYTES', 50 * 1024 * 1024),
55
56
  analysisMode,
@@ -19,7 +19,7 @@ export async function connectContext7(cfg) {
19
19
  maxRetries: 1,
20
20
  },
21
21
  });
22
- const client = new Client({ name: 'preflight-context7', version: '0.1.0' });
22
+ const client = new Client({ name: 'preflight-context7', version: '0.1.1' });
23
23
  await client.connect(transport);
24
24
  return {
25
25
  client,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Concurrency limiter to prevent DoS attacks via resource exhaustion.
3
+ * Limits the number of concurrent operations (e.g., bundle creations).
4
+ */
5
+ import { logger } from '../logging/logger.js';
6
+ export class ConcurrencyLimiter {
7
+ activeCount = 0;
8
+ maxConcurrent;
9
+ queue = [];
10
+ queueTimeoutMs;
11
+ constructor(maxConcurrent, queueTimeoutMs = 5 * 60 * 1000) {
12
+ if (maxConcurrent <= 0) {
13
+ throw new Error('maxConcurrent must be positive');
14
+ }
15
+ this.maxConcurrent = maxConcurrent;
16
+ this.queueTimeoutMs = queueTimeoutMs;
17
+ }
18
+ /**
19
+ * Acquire a slot for executing an operation.
20
+ * Waits in queue if all slots are occupied.
21
+ * Throws if waiting exceeds queueTimeoutMs.
22
+ */
23
+ async acquire() {
24
+ if (this.activeCount < this.maxConcurrent) {
25
+ this.activeCount++;
26
+ logger.debug(`Concurrency slot acquired (${this.activeCount}/${this.maxConcurrent})`);
27
+ return;
28
+ }
29
+ // Queue is full, wait for a slot
30
+ logger.info(`Concurrency limit reached (${this.maxConcurrent}), queuing request. Queue size: ${this.queue.length}`);
31
+ return new Promise((resolve, reject) => {
32
+ const timeoutId = setTimeout(() => {
33
+ // Remove from queue
34
+ const index = this.queue.findIndex(item => item.resolve === resolve);
35
+ if (index >= 0) {
36
+ this.queue.splice(index, 1);
37
+ }
38
+ reject(new Error(`Operation timed out waiting in queue (${this.queueTimeoutMs}ms)`));
39
+ }, this.queueTimeoutMs);
40
+ this.queue.push({
41
+ resolve: () => {
42
+ clearTimeout(timeoutId);
43
+ this.activeCount++;
44
+ logger.debug(`Concurrency slot acquired from queue (${this.activeCount}/${this.maxConcurrent})`);
45
+ resolve();
46
+ },
47
+ reject: (err) => {
48
+ clearTimeout(timeoutId);
49
+ reject(err);
50
+ },
51
+ addedAt: Date.now(),
52
+ });
53
+ });
54
+ }
55
+ /**
56
+ * Release a slot after operation completes.
57
+ */
58
+ release() {
59
+ if (this.activeCount <= 0) {
60
+ logger.warn('ConcurrencyLimiter.release() called with no active operations');
61
+ return;
62
+ }
63
+ this.activeCount--;
64
+ logger.debug(`Concurrency slot released (${this.activeCount}/${this.maxConcurrent})`);
65
+ // Process next item in queue
66
+ if (this.queue.length > 0) {
67
+ const next = this.queue.shift();
68
+ if (next) {
69
+ const waitTime = Date.now() - next.addedAt;
70
+ logger.info(`Processing queued request (waited ${waitTime}ms)`);
71
+ next.resolve();
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Execute a function with concurrency control.
77
+ */
78
+ async run(fn) {
79
+ await this.acquire();
80
+ try {
81
+ return await fn();
82
+ }
83
+ finally {
84
+ this.release();
85
+ }
86
+ }
87
+ /**
88
+ * Get current status for monitoring.
89
+ */
90
+ getStatus() {
91
+ return {
92
+ active: this.activeCount,
93
+ max: this.maxConcurrent,
94
+ queued: this.queue.length,
95
+ };
96
+ }
97
+ }
98
+ // Global limiter for bundle creation operations
99
+ // Default: allow 10 concurrent bundle creations
100
+ export const bundleCreationLimiter = new ConcurrencyLimiter(parseInt(process.env.PREFLIGHT_MAX_CONCURRENT_BUNDLES ?? '10', 10));
@@ -96,7 +96,10 @@ export class Scheduler {
96
96
  }
97
97
  jobTask.retryTimeout = setTimeout(() => {
98
98
  jobTask.retryTimeout = undefined;
99
- void this.executeJob(jobName, job, true);
99
+ // Properly handle Promise rejection
100
+ this.executeJob(jobName, job, true).catch((err) => {
101
+ logger.error(`Unhandled error in retry for job ${jobName}`, err instanceof Error ? err : undefined);
102
+ });
100
103
  }, delay);
101
104
  jobTask.retryTimeout.unref?.();
102
105
  }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { Job } from '../core/scheduler.js';
4
+ import { logger } from '../logging/logger.js';
5
+ import { getConfig } from '../config.js';
6
+ /**
7
+ * Cleanup job for temporary directories.
8
+ * Removes temporary checkout directories older than 24 hours.
9
+ */
10
+ export class TmpCleanupJob extends Job {
11
+ maxAgeHours = 24;
12
+ getName() {
13
+ return 'tmp-cleanup';
14
+ }
15
+ getMaxRetries() {
16
+ return 2; // Fewer retries for cleanup jobs
17
+ }
18
+ async run() {
19
+ const cfg = getConfig();
20
+ const checkoutsDir = path.join(cfg.tmpDir, 'checkouts');
21
+ try {
22
+ const exists = await this.pathExists(checkoutsDir);
23
+ if (!exists) {
24
+ logger.debug('Checkouts directory does not exist, skipping cleanup');
25
+ return;
26
+ }
27
+ const entries = await fs.readdir(checkoutsDir, { withFileTypes: true });
28
+ const now = Date.now();
29
+ const maxAgeMs = this.maxAgeHours * 60 * 60 * 1000;
30
+ let cleanedCount = 0;
31
+ let errorCount = 0;
32
+ for (const entry of entries) {
33
+ if (!entry.isDirectory())
34
+ continue;
35
+ const entryPath = path.join(checkoutsDir, entry.name);
36
+ try {
37
+ const stats = await fs.stat(entryPath);
38
+ const ageMs = now - stats.mtimeMs;
39
+ if (ageMs > maxAgeMs) {
40
+ logger.info(`Cleaning up old temporary directory: ${entry.name} (age: ${Math.round(ageMs / 3600000)}h)`);
41
+ await fs.rm(entryPath, { recursive: true, force: true });
42
+ cleanedCount++;
43
+ }
44
+ }
45
+ catch (err) {
46
+ logger.warn(`Failed to cleanup temporary directory ${entry.name}`, err instanceof Error ? err : undefined);
47
+ errorCount++;
48
+ }
49
+ }
50
+ if (cleanedCount > 0) {
51
+ logger.info(`Temporary directory cleanup completed: ${cleanedCount} removed, ${errorCount} errors`);
52
+ }
53
+ else {
54
+ logger.debug('No old temporary directories to clean up');
55
+ }
56
+ }
57
+ catch (err) {
58
+ logger.error('Temporary directory cleanup failed', err instanceof Error ? err : undefined);
59
+ throw err;
60
+ }
61
+ }
62
+ async pathExists(p) {
63
+ try {
64
+ await fs.access(p);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,54 @@
1
+ function msgOf(err) {
2
+ return err instanceof Error ? err.message : String(err);
3
+ }
4
+ function codeOf(err) {
5
+ const anyErr = err;
6
+ const code = anyErr?.code;
7
+ return typeof code === 'string' ? code : undefined;
8
+ }
9
+ function isLikelyIndexProblemMessage(message) {
10
+ const m = message.toLowerCase();
11
+ return ((m.includes('sqlite') || m.includes('fts') || m.includes('database')) &&
12
+ (m.includes('unable to open database file') ||
13
+ m.includes('cannot open') ||
14
+ m.includes('cantopen') ||
15
+ m.includes('no such table') ||
16
+ m.includes('malformed') ||
17
+ m.includes('file is not a database') ||
18
+ m.includes('disk i/o')));
19
+ }
20
+ export function classifyPreflightErrorKind(err) {
21
+ const message = msgOf(err);
22
+ const m = message.toLowerCase();
23
+ const code = codeOf(err);
24
+ if (m.includes('bundle not found'))
25
+ return 'bundle_not_found';
26
+ // Path traversal attempts from safeJoin.
27
+ if (m.includes('unsafe path traversal attempt'))
28
+ return 'invalid_path';
29
+ // Filesystem errors.
30
+ if (code === 'ENOENT')
31
+ return 'file_not_found';
32
+ if (code === 'EACCES' || code === 'EPERM')
33
+ return 'permission_denied';
34
+ // Common string forms when error.code is not preserved.
35
+ if (m.includes('enoent') && m.includes('no such file or directory'))
36
+ return 'file_not_found';
37
+ if ((m.includes('eacces') || m.includes('eperm')) && m.includes('permission'))
38
+ return 'permission_denied';
39
+ if (m.includes('deprecated') && (m.includes('ensurefresh') || m.includes('autorepairindex'))) {
40
+ return 'deprecated_parameter';
41
+ }
42
+ if (isLikelyIndexProblemMessage(message))
43
+ return 'index_missing_or_corrupt';
44
+ return 'unknown';
45
+ }
46
+ export function formatPreflightError(kind, message) {
47
+ // Stable, machine-parseable prefix for UIs.
48
+ return `[preflight_error kind=${kind}] ${message}`;
49
+ }
50
+ export function wrapPreflightError(err) {
51
+ const message = msgOf(err);
52
+ const kind = classifyPreflightErrorKind(err);
53
+ return new Error(formatPreflightError(kind, message));
54
+ }
package/dist/mcp/uris.js CHANGED
@@ -27,19 +27,39 @@ export function normalizeRelativePath(p) {
27
27
  return p.replaceAll('\\', '/').replace(/^\/+/, '');
28
28
  }
29
29
  export function safeJoin(rootDir, relativePath) {
30
- // Block absolute paths BEFORE normalization.
31
- // This catches Unix-style /etc/passwd and Windows-style C:\path.
32
30
  const trimmed = relativePath.trim();
33
- if (trimmed.startsWith('/') || trimmed.startsWith('\\') || /^[a-zA-Z]:/.test(trimmed)) {
34
- throw new Error('Unsafe path traversal attempt');
31
+ // Early rejection of obviously malicious patterns
32
+ if (!trimmed || trimmed.length > 4096) {
33
+ throw new Error('Invalid path: empty or too long');
35
34
  }
36
- // Convert to platform separator for join, but validate containment by resolving.
35
+ // Block UNC paths (\\server\share or \\?\C:\path)
36
+ if (trimmed.startsWith('\\\\')) {
37
+ throw new Error('Unsafe path traversal attempt: UNC path not allowed');
38
+ }
39
+ // Block Windows device paths (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
40
+ const devicePattern = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
41
+ const baseName = path.basename(trimmed);
42
+ if (devicePattern.test(baseName)) {
43
+ throw new Error('Unsafe path: Windows device name not allowed');
44
+ }
45
+ // Normalize FIRST to canonicalize the path
37
46
  const norm = normalizeRelativePath(relativePath);
47
+ // After normalization, check for absolute path indicators
48
+ if (norm.startsWith('/') || /^[a-zA-Z]:/.test(norm)) {
49
+ throw new Error('Unsafe path traversal attempt: absolute path after normalization');
50
+ }
51
+ // Check for null bytes (path injection)
52
+ if (norm.includes('\0')) {
53
+ throw new Error('Unsafe path: null byte not allowed');
54
+ }
55
+ // Resolve and validate containment
38
56
  const joined = path.resolve(rootDir, norm.split('/').join(path.sep));
39
57
  const rootResolved = path.resolve(rootDir);
40
- const rel = path.relative(rootResolved, joined);
41
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
42
- throw new Error('Unsafe path traversal attempt');
58
+ // Ensure normalized form of joined path starts with root
59
+ const joinedNorm = path.normalize(joined);
60
+ const rootNorm = path.normalize(rootResolved);
61
+ if (!joinedNorm.startsWith(rootNorm + path.sep) && joinedNorm !== rootNorm) {
62
+ throw new Error('Unsafe path traversal attempt: path escapes root directory');
43
63
  }
44
64
  return joined;
45
65
  }
@@ -123,43 +123,64 @@ export async function incrementalIndexUpdate(dbPath, files, opts) {
123
123
  const updateTransaction = db.transaction((updateFiles) => {
124
124
  const now = new Date().toISOString();
125
125
  for (const f of updateFiles) {
126
- // Delete old lines
127
- deleteLines.run(f.bundleNormRelativePath);
128
- // Insert new lines
129
- const text = fsSync.readFileSync(f.bundleNormAbsPath, 'utf8');
130
- const lines = text.split('\n');
131
- for (let i = 0; i < lines.length; i++) {
132
- const line = lines[i] ?? '';
133
- if (!line.trim())
134
- continue;
135
- insertLine.run(line, f.bundleNormRelativePath, f.repoId, f.kind, i + 1);
126
+ try {
127
+ // Delete old lines
128
+ deleteLines.run(f.bundleNormRelativePath);
129
+ // Insert new lines
130
+ const text = fsSync.readFileSync(f.bundleNormAbsPath, 'utf8');
131
+ const lines = text.split('\n');
132
+ for (let i = 0; i < lines.length; i++) {
133
+ const line = lines[i] ?? '';
134
+ if (!line.trim())
135
+ continue;
136
+ insertLine.run(line, f.bundleNormRelativePath, f.repoId, f.kind, i + 1);
137
+ }
138
+ // Update metadata
139
+ upsertMeta.run(f.bundleNormRelativePath, f.sha256, now);
140
+ }
141
+ catch (err) {
142
+ // Log error but continue with other files to avoid transaction rollback
143
+ console.error(`Failed to index file ${f.bundleNormRelativePath}:`, err);
144
+ throw err; // Re-throw to trigger transaction rollback and ensure finally block runs
136
145
  }
137
- // Update metadata
138
- upsertMeta.run(f.bundleNormRelativePath, f.sha256, now);
139
146
  }
140
147
  });
141
148
  // Process new files
142
149
  const insertTransaction = db.transaction((newFiles) => {
143
150
  const now = new Date().toISOString();
144
151
  for (const f of newFiles) {
145
- const text = fsSync.readFileSync(f.bundleNormAbsPath, 'utf8');
146
- const lines = text.split('\n');
147
- for (let i = 0; i < lines.length; i++) {
148
- const line = lines[i] ?? '';
149
- if (!line.trim())
150
- continue;
151
- insertLine.run(line, f.bundleNormRelativePath, f.repoId, f.kind, i + 1);
152
+ try {
153
+ const text = fsSync.readFileSync(f.bundleNormAbsPath, 'utf8');
154
+ const lines = text.split('\n');
155
+ for (let i = 0; i < lines.length; i++) {
156
+ const line = lines[i] ?? '';
157
+ if (!line.trim())
158
+ continue;
159
+ insertLine.run(line, f.bundleNormRelativePath, f.repoId, f.kind, i + 1);
160
+ }
161
+ // Insert metadata
162
+ upsertMeta.run(f.bundleNormRelativePath, f.sha256, now);
163
+ }
164
+ catch (err) {
165
+ console.error(`Failed to index file ${f.bundleNormRelativePath}:`, err);
166
+ throw err; // Re-throw to trigger transaction rollback and ensure finally block runs
152
167
  }
153
- // Insert metadata
154
- upsertMeta.run(f.bundleNormRelativePath, f.sha256, now);
155
168
  }
156
169
  });
157
- // Execute transactions
170
+ // Execute transactions in batches to avoid memory overflow
171
+ // Process 100 files at a time to keep memory usage bounded
172
+ const BATCH_SIZE = 100;
158
173
  if (filesToUpdate.length > 0) {
159
- updateTransaction(filesToUpdate);
174
+ for (let i = 0; i < filesToUpdate.length; i += BATCH_SIZE) {
175
+ const batch = filesToUpdate.slice(i, i + BATCH_SIZE);
176
+ updateTransaction(batch);
177
+ }
160
178
  }
161
179
  if (filesToIndex.length > 0) {
162
- insertTransaction(filesToIndex);
180
+ for (let i = 0; i < filesToIndex.length; i += BATCH_SIZE) {
181
+ const batch = filesToIndex.slice(i, i + BATCH_SIZE);
182
+ insertTransaction(batch);
183
+ }
163
184
  }
164
185
  return {
165
186
  added,
@@ -226,21 +247,32 @@ export async function rebuildIndex(dbPathOrFiles, filesOrDbPath, opts) {
226
247
  continue;
227
248
  if (f.kind === 'code' && !opts.includeCode)
228
249
  continue;
229
- // Read file synchronously inside transaction for better performance.
230
- const text = fsSync.readFileSync(f.bundleNormAbsPath, 'utf8');
231
- const lines = text.split('\n');
232
- for (let i = 0; i < lines.length; i++) {
233
- const line = lines[i] ?? '';
234
- // Skip empty lines to keep the index smaller.
235
- if (!line.trim())
236
- continue;
237
- insertLine.run(line, f.bundleNormRelativePath, f.repoId, f.kind, i + 1);
250
+ try {
251
+ // Read file synchronously inside transaction for better performance.
252
+ const text = fsSync.readFileSync(f.bundleNormAbsPath, 'utf8');
253
+ const lines = text.split('\n');
254
+ for (let i = 0; i < lines.length; i++) {
255
+ const line = lines[i] ?? '';
256
+ // Skip empty lines to keep the index smaller.
257
+ if (!line.trim())
258
+ continue;
259
+ insertLine.run(line, f.bundleNormRelativePath, f.repoId, f.kind, i + 1);
260
+ }
261
+ // Store file metadata for incremental updates
262
+ insertMeta.run(f.bundleNormRelativePath, f.sha256, now);
263
+ }
264
+ catch (err) {
265
+ console.error(`Failed to index file ${f.bundleNormRelativePath}:`, err);
266
+ throw err;
238
267
  }
239
- // Store file metadata for incremental updates
240
- insertMeta.run(f.bundleNormRelativePath, f.sha256, now);
241
268
  }
242
269
  });
243
- insertMany(files);
270
+ // Process files in batches to avoid memory overflow
271
+ const BATCH_SIZE = 100;
272
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
273
+ const batch = files.slice(i, i + BATCH_SIZE);
274
+ insertMany(batch);
275
+ }
244
276
  }
245
277
  finally {
246
278
  db.close();
@@ -2,6 +2,7 @@ import { PreflightScheduler } from '../core/scheduler.js';
2
2
  import { BundleAutoUpdateJob } from '../jobs/bundle-auto-update-job.js';
3
3
  import { StorageCleanupJob } from '../jobs/storage-cleanup-job.js';
4
4
  import { HealthCheckJob } from '../jobs/health-check-job.js';
5
+ import { TmpCleanupJob } from '../jobs/tmp-cleanup-job.js';
5
6
  import { getStorageManager } from '../storage/storage-adapter.js';
6
7
  import { compressData, decompressData, detectCompressionType } from '../storage/compression.js';
7
8
  import { logger, createModuleLogger } from '../logging/logger.js';
@@ -85,6 +86,9 @@ export class OptimizedPreflightServer {
85
86
  // 健康检查任务 - 每30分钟执行一次
86
87
  PreflightScheduler.build(HealthCheckJob).schedule('*/30 * * * *');
87
88
  moduleLogger.info('Health check job scheduled (every 30 minutes)');
89
+ // 临时目录清理任务 - 每6小时执行一次
90
+ PreflightScheduler.build(TmpCleanupJob).schedule('0 */6 * * *');
91
+ moduleLogger.info('Temporary directory cleanup job scheduled (every 6 hours)');
88
92
  moduleLogger.info('All scheduled jobs configured', {
89
93
  totalJobs: PreflightScheduler.getAllJobsStatus()
90
94
  });