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 apps/server/src/lib/secure-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ export const {
lstat,
joinPath,
resolvePath,
// Throttling configuration and monitoring
configureThrottling,
getThrottlingConfig,
getPendingOperations,
getActiveOperations,
} = secureFs;
5 changes: 3 additions & 2 deletions libs/git-utils/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ Binary file ${cleanPath} added
`;
}

if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
const sizeKB = Math.round(stats.size / 1024);
const fileSize = Number(stats.size);
if (fileSize > MAX_SYNTHETIC_DIFF_SIZE) {
const sizeKB = Math.round(fileSize / 1024);
return createNewFileDiff(cleanPath, '100644', [`[File too large to display: ${sizeKB}KB]`]);
}

Expand Down
3 changes: 2 additions & 1 deletion libs/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@automaker/types": "^1.0.0"
"@automaker/types": "^1.0.0",
"p-limit": "^6.2.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
Expand Down
186 changes: 166 additions & 20 deletions libs/platform/src/secure-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,149 @@
* All file I/O operations must go through this adapter to enforce
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
* not just at the API layer. This provides defense-in-depth security.
*
* This module also implements:
* - Concurrency limiting via p-limit to prevent ENFILE/EMFILE errors
* - Retry logic with exponential backoff for transient file descriptor errors
*/

import fs from 'fs/promises';
import type { Dirent } from 'fs';
import path from 'path';
import pLimit from 'p-limit';
import { validatePath } from './security.js';

/**
* Configuration for file operation throttling
*/
interface ThrottleConfig {
/** Maximum concurrent file operations (default: 100) */
maxConcurrency: number;
/** Maximum retry attempts for ENFILE/EMFILE errors (default: 3) */
maxRetries: number;
/** Base delay in ms for exponential backoff (default: 100) */
baseDelay: number;
/** Maximum delay in ms for exponential backoff (default: 5000) */
maxDelay: number;
}

const DEFAULT_CONFIG: ThrottleConfig = {
maxConcurrency: 100,
maxRetries: 3,
baseDelay: 100,
maxDelay: 5000,
};

let config: ThrottleConfig = { ...DEFAULT_CONFIG };
let fsLimit = pLimit(config.maxConcurrency);

/**
* Configure the file operation throttling settings
* @param newConfig - Partial configuration to merge with defaults
*/
export function configureThrottling(newConfig: Partial<ThrottleConfig>): void {
const newConcurrency = newConfig.maxConcurrency;

if (newConcurrency !== undefined && newConcurrency !== config.maxConcurrency) {
if (fsLimit.activeCount > 0 || fsLimit.pendingCount > 0) {
throw new Error(
`[SecureFS] Cannot change maxConcurrency while operations are in flight. Active: ${fsLimit.activeCount}, Pending: ${fsLimit.pendingCount}`
);
}
fsLimit = pLimit(newConcurrency);
}

config = { ...config, ...newConfig };
}

/**
* Get the current throttling configuration
*/
export function getThrottlingConfig(): Readonly<ThrottleConfig> {
return { ...config };
}

/**
* Get the number of pending operations in the queue
*/
export function getPendingOperations(): number {
return fsLimit.pendingCount;
}

/**
* Get the number of active operations currently running
*/
export function getActiveOperations(): number {
return fsLimit.activeCount;
}

/**
* Error codes that indicate file descriptor exhaustion
*/
const FILE_DESCRIPTOR_ERROR_CODES = new Set(['ENFILE', 'EMFILE']);

/**
* Check if an error is a file descriptor exhaustion error
*/
function isFileDescriptorError(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
return FILE_DESCRIPTOR_ERROR_CODES.has((error as { code: string }).code);
}
return false;
}

/**
* Calculate delay with exponential backoff and jitter
*/
function calculateDelay(attempt: number): number {
const exponentialDelay = config.baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * config.baseDelay;
return Math.min(exponentialDelay + jitter, config.maxDelay);
}

/**
* Sleep for a specified duration
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Execute a file operation with throttling and retry logic
*/
async function executeWithRetry<T>(operation: () => Promise<T>, operationName: string): Promise<T> {
return fsLimit(async () => {
let lastError: unknown;

for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;

if (isFileDescriptorError(error) && attempt < config.maxRetries) {
const delay = calculateDelay(attempt);
console.warn(
`[SecureFS] ${operationName}: File descriptor error (attempt ${attempt + 1}/${config.maxRetries + 1}), retrying in ${delay}ms`
);
await sleep(delay);
continue;
}

throw error;
}
}

throw lastError;
});
}
Comment on lines +117 to +142
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor: Unreachable code at line 140.

The throw lastError statement on line 140 is unreachable. The loop will always either return successfully (line 123) or throw an error (line 136) before completing all iterations. Every error path within the loop leads to either a continue or throw, so the loop cannot exit normally.

While this doesn't affect behavior, it's a code smell that could confuse maintainers.

🔎 Proposed fix
     for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
       try {
         return await operation();
       } catch (error) {
-        lastError = error;
-
         if (isFileDescriptorError(error) && attempt < config.maxRetries) {
           const delay = calculateDelay(attempt);
           console.warn(
             `[SecureFS] ${operationName}: File descriptor error (attempt ${attempt + 1}/${config.maxRetries + 1}), retrying in ${delay}ms`
           );
           await sleep(delay);
           continue;
         }

         throw error;
       }
     }
-
-    throw lastError;
   });
 }
🤖 Prompt for AI Agents
In libs/platform/src/secure-fs.ts around lines 117 to 142, the final "throw
lastError" is unreachable because every loop iteration either returns,
continues, or throws; remove the unreachable throw statement (or alternatively
replace it with a single explicit throw new Error(`${operationName}: exhausted
retries`) if you want an explicit post-loop failure message) so the function no
longer contains dead code and the control flow is clear.


/**
* Wrapper around fs.access that validates path first
*/
export async function access(filePath: string, mode?: number): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.access(validatedPath, mode);
return executeWithRetry(() => fs.access(validatedPath, mode), `access(${filePath})`);
}

/**
Expand All @@ -27,10 +157,12 @@ export async function readFile(
encoding?: BufferEncoding
): Promise<string | Buffer> {
const validatedPath = validatePath(filePath);
if (encoding) {
return fs.readFile(validatedPath, encoding);
}
return fs.readFile(validatedPath);
return executeWithRetry<string | Buffer>(() => {
if (encoding) {
return fs.readFile(validatedPath, encoding);
}
return fs.readFile(validatedPath);
}, `readFile(${filePath})`);
}

/**
Expand All @@ -42,7 +174,10 @@ export async function writeFile(
encoding?: BufferEncoding
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.writeFile(validatedPath, data, encoding);
return executeWithRetry(
() => fs.writeFile(validatedPath, data, encoding),
`writeFile(${filePath})`
);
}

/**
Expand All @@ -53,7 +188,7 @@ export async function mkdir(
options?: { recursive?: boolean; mode?: number }
): Promise<string | undefined> {
const validatedPath = validatePath(dirPath);
return fs.mkdir(validatedPath, options);
return executeWithRetry(() => fs.mkdir(validatedPath, options), `mkdir(${dirPath})`);
}

/**
Expand All @@ -72,18 +207,20 @@ export async function readdir(
options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
): Promise<string[] | Dirent[]> {
const validatedPath = validatePath(dirPath);
if (options?.withFileTypes === true) {
return fs.readdir(validatedPath, { withFileTypes: true });
}
return fs.readdir(validatedPath);
return executeWithRetry<string[] | Dirent[]>(() => {
if (options?.withFileTypes === true) {
return fs.readdir(validatedPath, { withFileTypes: true });
}
return fs.readdir(validatedPath);
}, `readdir(${dirPath})`);
}

/**
* Wrapper around fs.stat that validates path first
*/
export async function stat(filePath: string): Promise<any> {
export async function stat(filePath: string): Promise<ReturnType<typeof fs.stat>> {
const validatedPath = validatePath(filePath);
return fs.stat(validatedPath);
return executeWithRetry(() => fs.stat(validatedPath), `stat(${filePath})`);
}

/**
Expand All @@ -94,15 +231,15 @@ export async function rm(
options?: { recursive?: boolean; force?: boolean }
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.rm(validatedPath, options);
return executeWithRetry(() => fs.rm(validatedPath, options), `rm(${filePath})`);
}

/**
* Wrapper around fs.unlink that validates path first
*/
export async function unlink(filePath: string): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.unlink(validatedPath);
return executeWithRetry(() => fs.unlink(validatedPath), `unlink(${filePath})`);
}

/**
Expand All @@ -111,7 +248,10 @@ export async function unlink(filePath: string): Promise<void> {
export async function copyFile(src: string, dest: string, mode?: number): Promise<void> {
const validatedSrc = validatePath(src);
const validatedDest = validatePath(dest);
return fs.copyFile(validatedSrc, validatedDest, mode);
return executeWithRetry(
() => fs.copyFile(validatedSrc, validatedDest, mode),
`copyFile(${src}, ${dest})`
);
}

/**
Expand All @@ -123,7 +263,10 @@ export async function appendFile(
encoding?: BufferEncoding
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.appendFile(validatedPath, data, encoding);
return executeWithRetry(
() => fs.appendFile(validatedPath, data, encoding),
`appendFile(${filePath})`
);
}

/**
Expand All @@ -132,16 +275,19 @@ export async function appendFile(
export async function rename(oldPath: string, newPath: string): Promise<void> {
const validatedOldPath = validatePath(oldPath);
const validatedNewPath = validatePath(newPath);
return fs.rename(validatedOldPath, validatedNewPath);
return executeWithRetry(
() => fs.rename(validatedOldPath, validatedNewPath),
`rename(${oldPath}, ${newPath})`
);
}

/**
* Wrapper around fs.lstat that validates path first
* Returns file stats without following symbolic links
*/
export async function lstat(filePath: string): Promise<any> {
export async function lstat(filePath: string): Promise<ReturnType<typeof fs.lstat>> {
const validatedPath = validatePath(filePath);
return fs.lstat(validatedPath);
return executeWithRetry(() => fs.lstat(validatedPath), `lstat(${filePath})`);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions libs/platform/tests/node-finder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ describe('node-finder', () => {
const delimiter = path.delimiter;

it("should return current path unchanged when nodePath is 'node'", () => {
const currentPath = '/usr/bin:/usr/local/bin';
const currentPath = `/usr/bin${delimiter}/usr/local/bin`;
const result = buildEnhancedPath('node', currentPath);

expect(result).toBe(currentPath);
Expand All @@ -144,7 +144,7 @@ describe('node-finder', () => {

it('should prepend node directory to path', () => {
const nodePath = '/opt/homebrew/bin/node';
const currentPath = '/usr/bin:/usr/local/bin';
const currentPath = `/usr/bin${delimiter}/usr/local/bin`;

const result = buildEnhancedPath(nodePath, currentPath);

Expand All @@ -153,7 +153,7 @@ describe('node-finder', () => {

it('should not duplicate node directory if already in path', () => {
const nodePath = '/usr/local/bin/node';
const currentPath = '/usr/local/bin:/usr/bin';
const currentPath = `/usr/local/bin${delimiter}/usr/bin`;

const result = buildEnhancedPath(nodePath, currentPath);

Expand Down
Loading