Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
33 changes: 1 addition & 32 deletions packages/sandbox-container/src/handlers/file-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,38 +103,7 @@ export class FileHandler extends BaseHandler<Request, Response> {
const body = await this.parseRequestBody<ReadFileRequest>(request);

try {
// Get file metadata first
const metadataResult = await this.fileService.readFile(body.path, {
encoding: 'utf-8'
});

if (!metadataResult.success) {
// Return error as SSE event
const encoder = new TextEncoder();
const errorEvent: FileStreamEvent = {
type: 'error',
error: metadataResult.error.message
};
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)
);
controller.close();
}
});

return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...context.corsHeaders
}
});
}

// Create SSE stream
// Create SSE stream (handles metadata fetching and errors internally)
const stream = await this.fileService.readFileStreamOperation(
body.path,
body.sessionId
Expand Down
235 changes: 215 additions & 20 deletions packages/sandbox-container/src/services/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ export class FileService implements FileSystemOperations {

const fileSize = parseInt(statResult.data.stdout.trim(), 10);

if (Number.isNaN(fileSize)) {
return {
success: false,
error: {
message: `Failed to parse file size for '${path}': invalid stat output`,
code: ErrorCode.FILESYSTEM_ERROR,
details: {
path,
operation: Operation.FILE_READ,
stderr: `Unexpected stat output: ${statResult.data.stdout}`
} satisfies FileSystemContext
}
};
}

// 4. Detect MIME type using file command
const mimeCommand = `file --mime-type -b ${escapedPath}`;
const mimeResult = await this.sessionManager.executeInSession(
Expand Down Expand Up @@ -200,13 +215,7 @@ export class FileService implements FileSystemOperations {
const mimeType = mimeResult.data.stdout.trim();

// 5. Determine if file is binary based on MIME type
// Text MIME types: text/*, application/json, application/xml, application/javascript, etc.
const isBinary =
!mimeType.startsWith('text/') &&
!mimeType.includes('json') &&
!mimeType.includes('xml') &&
!mimeType.includes('javascript') &&
!mimeType.includes('x-empty');
const isBinary = this.isBinaryMimeType(mimeType);

// 6. Read file with appropriate encoding
// Respect user's encoding preference if provided, otherwise use MIME-based detection
Expand Down Expand Up @@ -1062,6 +1071,189 @@ export class FileService implements FileSystemOperations {
}
}

/**
* Get file metadata
* Optimized for scenarios where you need file characteristics
* (size, type, encoding) before processing, without the overhead
* of reading potentially large files. Used by readFileStreamOperation.
*/
async getFileMetadata(
path: string,
sessionId = 'default'
): Promise<ServiceResult<FileMetadata>> {
try {
// 1. Validate path for security
const validation = this.security.validatePath(path);
if (!validation.isValid) {
return {
success: false,
error: {
message: `Invalid path format for '${path}': ${validation.errors.join(', ')}`,
code: ErrorCode.VALIDATION_FAILED,
details: {
validationErrors: validation.errors.map((e) => ({
field: 'path',
message: e,
code: 'INVALID_PATH'
}))
} satisfies ValidationFailedContext
}
};
}

// 2. Check if file exists using session-aware check
const existsResult = await this.exists(path, sessionId);
if (!existsResult.success) {
return {
success: false,
error: existsResult.error
};
}

if (!existsResult.data) {
return {
success: false,
error: {
message: `File not found: ${path}`,
code: ErrorCode.FILE_NOT_FOUND,
details: {
path,
operation: Operation.FILE_READ
} satisfies FileNotFoundContext
}
};
}

// 3. Get file size using stat
const escapedPath = shellEscape(path);
const statCommand = `stat -c '%s' ${escapedPath} 2>/dev/null`;
const statResult = await this.sessionManager.executeInSession(
sessionId,
statCommand
);

if (!statResult.success) {
return {
success: false,
error: {
message: `Failed to get file size for '${path}'`,
code: ErrorCode.FILESYSTEM_ERROR,
details: {
path,
operation: Operation.FILE_READ,
stderr: 'Command execution failed'
} satisfies FileSystemContext
}
};
}

if (statResult.data.exitCode !== 0) {
return {
success: false,
error: {
message: `Failed to get file size for '${path}'`,
code: ErrorCode.FILESYSTEM_ERROR,
details: {
path,
operation: Operation.FILE_READ,
stderr: statResult.data.stderr
} satisfies FileSystemContext
}
};
}

const fileSize = parseInt(statResult.data.stdout.trim(), 10);

if (Number.isNaN(fileSize)) {
return {
success: false,
error: {
message: `Failed to parse file size for '${path}': invalid stat output`,
code: ErrorCode.FILESYSTEM_ERROR,
details: {
path,
operation: Operation.FILE_READ,
stderr: `Unexpected stat output: ${statResult.data.stdout}`
} satisfies FileSystemContext
}
};
}

// 4. Detect MIME type using file command
const mimeCommand = `file --mime-type -b ${escapedPath}`;
const mimeResult = await this.sessionManager.executeInSession(
sessionId,
mimeCommand
);

if (!mimeResult.success) {
return {
success: false,
error: {
message: `Failed to detect MIME type for '${path}'`,
code: ErrorCode.FILESYSTEM_ERROR,
details: {
path,
operation: Operation.FILE_READ,
stderr: 'Command execution failed'
} satisfies FileSystemContext
}
};
}

if (mimeResult.data.exitCode !== 0) {
return {
success: false,
error: {
message: `Failed to detect MIME type for '${path}'`,
code: ErrorCode.FILESYSTEM_ERROR,
details: {
path,
operation: Operation.FILE_READ,
stderr: mimeResult.data.stderr
} satisfies FileSystemContext
}
};
}

const mimeType = mimeResult.data.stdout.trim();

// 5. Determine if file is binary based on MIME type
const isBinary = this.isBinaryMimeType(mimeType);

return {
success: true,
data: {
mimeType,
size: fileSize,
isBinary,
encoding: isBinary ? 'base64' : 'utf-8'
}
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
this.logger.error(
'Failed to get file metadata',
error instanceof Error ? error : undefined,
{ path }
);

return {
success: false,
error: {
message: `Failed to get file metadata for '${path}': ${errorMessage}`,
code: ErrorCode.FILESYSTEM_ERROR,
details: {
path,
operation: Operation.FILE_READ,
stderr: errorMessage
} satisfies FileSystemContext
}
};
}
}

// Convenience methods with ServiceResult wrapper for higher-level operations

async readFile(
Expand Down Expand Up @@ -1366,6 +1558,20 @@ export class FileService implements FileSystemOperations {
};
}

/**
* Determine if a MIME type represents binary content.
* Text MIME types: text/*, application/json, application/xml, application/javascript, etc.
*/
private isBinaryMimeType(mimeType: string): boolean {
return (
!mimeType.startsWith('text/') &&
!mimeType.includes('json') &&
!mimeType.includes('xml') &&
!mimeType.includes('javascript') &&
!mimeType.includes('x-empty')
);
}

/**
* Stream a file using Server-Sent Events (SSE)
* Sends metadata, chunks, and completion events
Expand All @@ -1382,7 +1588,7 @@ export class FileService implements FileSystemOperations {
start: async (controller) => {
try {
// 1. Get file metadata
const metadataResult = await this.read(path, {}, sessionId);
const metadataResult = await this.getFileMetadata(path, sessionId);

if (!metadataResult.success) {
const errorEvent = {
Expand All @@ -1396,18 +1602,7 @@ export class FileService implements FileSystemOperations {
return;
}

const metadata = metadataResult.metadata;
if (!metadata) {
const errorEvent = {
type: 'error',
error: 'Failed to get file metadata'
};
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)
);
controller.close();
return;
}
const metadata = metadataResult.data;

// 2. Send metadata event
const metadataEvent = {
Expand Down
Loading
Loading