diff --git a/.changeset/bucket-mounting.md b/.changeset/bucket-mounting.md new file mode 100644 index 00000000..659a66d4 --- /dev/null +++ b/.changeset/bucket-mounting.md @@ -0,0 +1,7 @@ +--- +'@cloudflare/sandbox': minor +--- + +Add S3-compatible bucket mounting + +Enable mounting S3-compatible buckets (R2, S3, GCS, MinIO, etc.) as local filesystem paths using s3fs-fuse. Supports automatic credential detection from environment variables and intelligent provider detection from endpoint URLs. diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 15739875..1f3f88ca 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -136,6 +136,14 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy --name ${{ steps.env-name.outputs.worker_name }} workingDirectory: tests/e2e/test-worker + secrets: | + AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + CLOUDFLARE_ACCOUNT_ID + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # Construct worker URL from worker name - name: Get deployment URL @@ -149,6 +157,9 @@ jobs: env: TEST_WORKER_URL: ${{ steps.get-url.outputs.worker_url }} CI: true + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # Cleanup: Delete test worker and container (only for PR environments) - name: Cleanup test deployment diff --git a/packages/sandbox-container/src/services/file-service.ts b/packages/sandbox-container/src/services/file-service.ts index d3b8454e..a37d4b2b 100644 --- a/packages/sandbox-container/src/services/file-service.ts +++ b/packages/sandbox-container/src/services/file-service.ts @@ -1,4 +1,5 @@ import type { FileInfo, ListFilesOptions, Logger } from '@repo/shared'; +import { shellEscape } from '@repo/shared'; import type { FileNotFoundContext, FileSystemContext, @@ -69,17 +70,6 @@ export class FileService implements FileSystemOperations { this.manager = new FileManager(); } - /** - * Escape path for safe shell usage - * Uses single quotes to prevent variable expansion and command substitution - */ - private escapePath(path: string): string { - // Single quotes prevent all expansion ($VAR, `cmd`, etc.) - // To include a literal single quote, we end the quoted string, add an escaped quote, and start a new quoted string - // Example: path="it's" becomes 'it'\''s' - return `'${path.replace(/'/g, "'\\''")}'`; - } - async read( path: string, options: ReadOptions = {}, @@ -131,7 +121,7 @@ export class FileService implements FileSystemOperations { } // 3. Get file size using stat - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); const statCommand = `stat -c '%s' ${escapedPath} 2>/dev/null`; const statResult = await this.sessionManager.executeInSession( sessionId, @@ -374,7 +364,7 @@ export class FileService implements FileSystemOperations { } // 2. Write file using SessionManager with proper encoding handling - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); const encoding = options.encoding || 'utf-8'; let command: string; @@ -528,7 +518,7 @@ export class FileService implements FileSystemOperations { } // 4. Delete file using SessionManager with rm command - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); const command = `rm ${escapedPath}`; const execResult = await this.sessionManager.executeInSession( @@ -630,8 +620,8 @@ export class FileService implements FileSystemOperations { } // 3. Rename file using SessionManager with mv command - const escapedOldPath = this.escapePath(oldPath); - const escapedNewPath = this.escapePath(newPath); + const escapedOldPath = shellEscape(oldPath); + const escapedNewPath = shellEscape(newPath); const command = `mv ${escapedOldPath} ${escapedNewPath}`; const execResult = await this.sessionManager.executeInSession( @@ -732,8 +722,8 @@ export class FileService implements FileSystemOperations { // 3. Move file using SessionManager with mv command // mv is atomic on same filesystem, automatically handles cross-filesystem moves - const escapedSource = this.escapePath(sourcePath); - const escapedDest = this.escapePath(destinationPath); + const escapedSource = shellEscape(sourcePath); + const escapedDest = shellEscape(destinationPath); const command = `mv ${escapedSource} ${escapedDest}`; const execResult = await this.sessionManager.executeInSession( @@ -821,7 +811,7 @@ export class FileService implements FileSystemOperations { const args = this.manager.buildMkdirArgs(path, options); // 3. Build command string from args (skip 'mkdir' at index 0) - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); let command = 'mkdir'; if (options.recursive) { command += ' -p'; @@ -910,7 +900,7 @@ export class FileService implements FileSystemOperations { } // 2. Check if file/directory exists using SessionManager - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); const command = `test -e ${escapedPath}`; const execResult = await this.sessionManager.executeInSession( @@ -1006,7 +996,7 @@ export class FileService implements FileSystemOperations { const statCmd = this.manager.buildStatArgs(path); // 4. Build command string (stat with format argument) - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); const command = `stat ${statCmd.args[0]} ${statCmd.args[1]} ${escapedPath}`; // 5. Get file stats using SessionManager @@ -1208,7 +1198,7 @@ export class FileService implements FileSystemOperations { } // 4. Build find command to list files - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); const basePath = path.endsWith('/') ? path.slice(0, -1) : path; // Use find with appropriate flags @@ -1386,7 +1376,7 @@ export class FileService implements FileSystemOperations { sessionId = 'default' ): Promise> { const encoder = new TextEncoder(); - const escapedPath = this.escapePath(path); + const escapedPath = shellEscape(path); return new ReadableStream({ start: async (controller) => { diff --git a/packages/sandbox-container/src/services/git-service.ts b/packages/sandbox-container/src/services/git-service.ts index b003c464..2360555f 100644 --- a/packages/sandbox-container/src/services/git-service.ts +++ b/packages/sandbox-container/src/services/git-service.ts @@ -1,7 +1,7 @@ // Git Operations Service import type { Logger } from '@repo/shared'; -import { sanitizeGitData } from '@repo/shared'; +import { sanitizeGitData, shellEscape } from '@repo/shared'; import type { GitErrorContext, ValidationFailedContext @@ -29,17 +29,10 @@ export class GitService { /** * Build a shell command string from an array of arguments - * Quotes arguments that contain spaces for safe shell execution + * Escapes all arguments to prevent command injection */ private buildCommand(args: string[]): string { - return args - .map((arg) => { - if (arg.includes(' ')) { - return `"${arg}"`; - } - return arg; - }) - .join(' '); + return args.map((arg) => shellEscape(arg)).join(' '); } /** diff --git a/packages/sandbox-container/src/shell-escape.ts b/packages/sandbox-container/src/shell-escape.ts deleted file mode 100644 index 983a0752..00000000 --- a/packages/sandbox-container/src/shell-escape.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Secure shell command utilities to prevent injection attacks - */ - -/** - * Escapes a string for safe use in shell commands. - * This follows POSIX shell escaping rules to prevent command injection. - * - * @param str - The string to escape - * @returns The escaped string safe for shell use - */ -export function escapeShellArg(str: string): string { - // If string is empty, return empty quotes - if (str === '') { - return "''"; - } - - // Check if string contains any characters that need escaping - // Safe characters: alphanumeric, dash, underscore, dot, slash - if (/^[a-zA-Z0-9._\-/]+$/.test(str)) { - return str; - } - - // For strings with special characters, use single quotes and escape single quotes - // Single quotes preserve all characters literally except the single quote itself - // To include a single quote, we end the quoted string, add an escaped quote, and start a new quoted string - return `'${str.replace(/'/g, "'\\''")}'`; -} - -/** - * Escapes a file path for safe use in shell commands. - * - * @param path - The file path to escape - * @returns The escaped path safe for shell use - */ -export function escapeShellPath(path: string): string { - // Normalize path to prevent issues with multiple slashes - const normalizedPath = path.replace(/\/+/g, '/'); - - // Apply standard shell escaping - return escapeShellArg(normalizedPath); -} diff --git a/packages/sandbox-container/tests/services/git-service.test.ts b/packages/sandbox-container/tests/services/git-service.test.ts index 6b278427..593b6a48 100644 --- a/packages/sandbox-container/tests/services/git-service.test.ts +++ b/packages/sandbox-container/tests/services/git-service.test.ts @@ -105,14 +105,14 @@ describe('GitService', () => { expect(mockSessionManager.executeInSession).toHaveBeenNthCalledWith( 1, 'default', - 'git clone https://github.com/user/repo.git /workspace/repo' + "'git' 'clone' 'https://github.com/user/repo.git' '/workspace/repo'" ); // Verify SessionManager was called for getting current branch expect(mockSessionManager.executeInSession).toHaveBeenNthCalledWith( 2, 'default', - 'git branch --show-current', + "'git' 'branch' '--show-current'", '/workspace/repo' ); }); @@ -157,7 +157,7 @@ describe('GitService', () => { expect(mockSessionManager.executeInSession).toHaveBeenNthCalledWith( 1, 'session-123', - 'git clone --branch develop https://github.com/user/repo.git /tmp/custom-target' + "'git' 'clone' '--branch' 'develop' 'https://github.com/user/repo.git' '/tmp/custom-target'" ); }); @@ -273,7 +273,7 @@ describe('GitService', () => { // Verify SessionManager was called with correct parameters expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( 'session-123', - 'git checkout develop', + "'git' 'checkout' 'develop'", '/tmp/repo' ); }); @@ -336,7 +336,7 @@ describe('GitService', () => { expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( 'session-123', - 'git branch --show-current', + "'git' 'branch' '--show-current'", '/tmp/repo' ); }); @@ -379,7 +379,7 @@ describe('GitService', () => { expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( 'session-123', - 'git branch -a', + "'git' 'branch' '-a'", '/tmp/repo' ); }); diff --git a/packages/sandbox/Dockerfile b/packages/sandbox/Dockerfile index 19da6525..d377646f 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -113,17 +113,21 @@ ENV DEBIAN_FRONTEND=noninteractive # Set the sandbox version as an environment variable for version checking ENV SANDBOX_VERSION=${SANDBOX_VERSION} -# Install runtime packages and Python runtime libraries +# Install runtime packages and S3FS-FUSE for bucket mounting RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ rm -f /etc/apt/apt.conf.d/docker-clean && \ echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache && \ apt-get update && apt-get install -y --no-install-recommends \ + s3fs fuse \ ca-certificates curl wget procps git unzip zip jq file \ libssl3 zlib1g libbz2-1.0 libreadline8 libsqlite3-0 \ libncursesw6 libtinfo6 libxml2 libxmlsec1 libffi8 liblzma5 libtk8.6 && \ update-ca-certificates +# Enable FUSE in container - allow non-root users to use FUSE +RUN sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf + # Copy pre-built Python from python-builder stage COPY --from=python-builder /usr/local/python /usr/local/python diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 2d7b6c16..5f9a56c7 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -17,20 +17,31 @@ export { getSandbox, Sandbox } from './sandbox'; // Export core SDK types for consumers export type { BaseExecOptions, + BucketCredentials, + BucketProvider, + CodeContext, + CreateContextOptions, ExecEvent, ExecOptions, ExecResult, + ExecutionResult, + ExecutionSession, FileChunk, FileMetadata, FileStreamEvent, + GitCheckoutResult, ISandbox, + ListFilesOptions, LogEvent, + MountBucketOptions, Process, ProcessOptions, ProcessStatus, + RunCodeOptions, + SandboxOptions, + SessionOptions, StreamOptions } from '@repo/shared'; -export * from '@repo/shared'; // Export type guards for runtime validation export { isExecResult, isProcess, isProcessStatus } from '@repo/shared'; // Export all client types from new architecture @@ -56,7 +67,6 @@ export type { // Git client types GitCheckoutRequest, - GitCheckoutResult, // Base client types HttpClientOptions as SandboxClientOptions, @@ -102,3 +112,10 @@ export { parseSSEStream, responseToAsyncIterable } from './sse-parser'; +// Export bucket mounting errors +export { + BucketMountError, + InvalidMountConfigError, + MissingCredentialsError, + S3FSMountError +} from './storage-mount/errors'; diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index f64bbbf0..fda5ec4c 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1,6 +1,7 @@ -import type { DurableObject } from 'cloudflare:workers'; import { Container, getContainer, switchPort } from '@cloudflare/containers'; import type { + BucketCredentials, + BucketProvider, CodeContext, CreateContextOptions, ExecEvent, @@ -9,6 +10,7 @@ import type { ExecutionResult, ExecutionSession, ISandbox, + MountBucketOptions, Process, ProcessOptions, ProcessStatus, @@ -21,6 +23,7 @@ import { createLogger, runWithLogger, type SessionDeleteResult, + shellEscape, TraceContext } from '@repo/shared'; import { type ExecuteResponse, SandboxClient } from './clients'; @@ -30,6 +33,16 @@ import { CodeInterpreter } from './interpreter'; import { isLocalhostPattern } from './request-handler'; import { SecurityError, sanitizeSandboxId, validatePort } from './security'; import { parseSSEStream } from './sse-parser'; +import { + detectCredentials, + detectProviderFromUrl, + resolveS3fsOptions +} from './storage-mount'; +import { + InvalidMountConfigError, + S3FSMountError +} from './storage-mount/errors'; +import type { MountInfo } from './storage-mount/types'; import { SDK_VERSION } from './version'; export function getSandbox( @@ -101,6 +114,7 @@ export class Sandbox extends Container implements ISandbox { envVars: Record = {}; private logger: ReturnType; private keepAliveEnabled: boolean = false; + private activeMounts: Map = new Map(); constructor(ctx: DurableObjectState<{}>, env: Env) { super(ctx, env); @@ -216,11 +230,296 @@ export class Sandbox extends Container implements ISandbox { } } + /** + * Mount an S3-compatible bucket as a local directory using S3FS-FUSE + * + * Requires explicit endpoint URL. Credentials are auto-detected from environment + * variables or can be provided explicitly. + * + * @param bucket - Bucket name + * @param mountPath - Absolute path in container to mount at + * @param options - Configuration options with required endpoint + * @throws MissingCredentialsError if no credentials found in environment + * @throws S3FSMountError if S3FS mount command fails + * @throws InvalidMountConfigError if bucket name, mount path, or endpoint is invalid + */ + async mountBucket( + bucket: string, + mountPath: string, + options: MountBucketOptions + ): Promise { + this.logger.info(`Mounting bucket ${bucket} to ${mountPath}`); + + // Validate options + this.validateMountOptions(bucket, mountPath, options); + + // Detect provider from explicit option or URL pattern + const provider: BucketProvider | null = + options.provider || detectProviderFromUrl(options.endpoint); + + this.logger.debug(`Detected provider: ${provider || 'unknown'}`, { + explicitProvider: options.provider + }); + + // Detect credentials + const credentials = detectCredentials(options, this.envVars); + + // Generate unique password file path + const passwordFilePath = this.generatePasswordFilePath(); + + // Reserve mount path immediately to prevent race conditions + // (two concurrent mount calls would both pass validation otherwise) + this.activeMounts.set(mountPath, { + bucket, + mountPath, + endpoint: options.endpoint, + provider, + passwordFilePath, + mounted: false + }); + + try { + // Create password file with credentials + await this.createPasswordFile(passwordFilePath, bucket, credentials); + + // Create mount directory + await this.exec(`mkdir -p ${shellEscape(mountPath)}`); + + // Execute S3FS mount with password file + await this.executeS3FSMount( + bucket, + mountPath, + options, + provider, + passwordFilePath + ); + + // Mark as successfully mounted + this.activeMounts.set(mountPath, { + bucket, + mountPath, + endpoint: options.endpoint, + provider, + passwordFilePath, + mounted: true + }); + + this.logger.info(`Successfully mounted bucket ${bucket} to ${mountPath}`); + } catch (error) { + // Clean up password file on failure + await this.deletePasswordFile(passwordFilePath); + + // Clean up reservation on failure + this.activeMounts.delete(mountPath); + throw error; + } + } + + /** + * Manually unmount a bucket filesystem + * + * @param mountPath - Absolute path where the bucket is mounted + * @throws InvalidMountConfigError if mount path doesn't exist or isn't mounted + */ + async unmountBucket(mountPath: string): Promise { + this.logger.info(`Unmounting bucket from ${mountPath}`); + + // Look up mount by path + const mountInfo = this.activeMounts.get(mountPath); + + // Throw error if mount doesn't exist + if (!mountInfo) { + throw new InvalidMountConfigError( + `No active mount found at path: ${mountPath}` + ); + } + + // Unmount the filesystem + try { + await this.exec(`fusermount -u ${shellEscape(mountPath)}`); + mountInfo.mounted = false; + + // Only remove from tracking if unmount succeeded + this.activeMounts.delete(mountPath); + } finally { + // Always cleanup password file, even if unmount fails + await this.deletePasswordFile(mountInfo.passwordFilePath); + } + + this.logger.info(`Successfully unmounted bucket from ${mountPath}`); + } + + /** + * Validate mount options + */ + private validateMountOptions( + bucket: string, + mountPath: string, + options: MountBucketOptions + ): void { + // Require endpoint field + if (!options.endpoint) { + throw new InvalidMountConfigError( + 'Endpoint is required. Provide the full S3-compatible endpoint URL.' + ); + } + + // Basic URL validation + try { + new URL(options.endpoint); + } catch (error) { + throw new InvalidMountConfigError( + `Invalid endpoint URL: "${options.endpoint}". Must be a valid HTTP(S) URL.` + ); + } + + // Validate bucket name (S3-compatible naming rules) + const bucketNameRegex = /^[a-z0-9]([a-z0-9.-]{0,61}[a-z0-9])?$/; + if (!bucketNameRegex.test(bucket)) { + throw new InvalidMountConfigError( + `Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, ` + + `lowercase alphanumeric, dots, or hyphens, and cannot start/end with dots or hyphens.` + ); + } + + // Validate mount path is absolute + if (!mountPath.startsWith('/')) { + throw new InvalidMountConfigError( + `Mount path must be absolute (start with /): "${mountPath}"` + ); + } + + // Check for duplicate mount path + if (this.activeMounts.has(mountPath)) { + const existingMount = this.activeMounts.get(mountPath); + throw new InvalidMountConfigError( + `Mount path "${mountPath}" is already in use by bucket "${existingMount?.bucket}". ` + + `Unmount the existing bucket first or use a different mount path.` + ); + } + } + + /** + * Generate unique password file path for s3fs credentials + */ + private generatePasswordFilePath(): string { + const uuid = crypto.randomUUID(); + return `/tmp/.passwd-s3fs-${uuid}`; + } + + /** + * Create password file with s3fs credentials + * Format: bucket:accessKeyId:secretAccessKey + */ + private async createPasswordFile( + passwordFilePath: string, + bucket: string, + credentials: BucketCredentials + ): Promise { + const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`; + + await this.writeFile(passwordFilePath, content); + + await this.exec(`chmod 0600 ${shellEscape(passwordFilePath)}`); + + this.logger.debug(`Created password file: ${passwordFilePath}`); + } + + /** + * Delete password file + */ + private async deletePasswordFile(passwordFilePath: string): Promise { + try { + await this.exec(`rm -f ${shellEscape(passwordFilePath)}`); + this.logger.debug(`Deleted password file: ${passwordFilePath}`); + } catch (error) { + this.logger.warn(`Failed to delete password file ${passwordFilePath}`, { + error: error instanceof Error ? error.message : String(error) + }); + } + } + + /** + * Execute S3FS mount command + */ + private async executeS3FSMount( + bucket: string, + mountPath: string, + options: MountBucketOptions, + provider: BucketProvider | null, + passwordFilePath: string + ): Promise { + // Resolve s3fs options (provider defaults + user overrides) + const resolvedOptions = resolveS3fsOptions(provider, options.s3fsOptions); + + // Build s3fs mount command + const s3fsArgs: string[] = []; + + // Add password file option FIRST + s3fsArgs.push(`passwd_file=${passwordFilePath}`); + + // Add resolved provider-specific and user options + s3fsArgs.push(...resolvedOptions); + + // Add read-only flag if requested + if (options.readOnly) { + s3fsArgs.push('ro'); + } + + // Add endpoint URL + s3fsArgs.push(`url=${options.endpoint}`); + + // Build final command with escaped options + const optionsStr = shellEscape(s3fsArgs.join(',')); + const mountCmd = `s3fs ${shellEscape(bucket)} ${shellEscape(mountPath)} -o ${optionsStr}`; + + this.logger.debug('Executing s3fs mount', { + bucket, + mountPath, + provider, + resolvedOptions + }); + + // Execute mount command + const result = await this.exec(mountCmd); + + if (result.exitCode !== 0) { + throw new S3FSMountError( + `S3FS mount failed: ${result.stderr || result.stdout || 'Unknown error'}` + ); + } + + this.logger.debug('Mount command executed successfully'); + } + /** * Cleanup and destroy the sandbox container */ override async destroy(): Promise { this.logger.info('Destroying sandbox container'); + + // Unmount all mounted buckets and cleanup password files + for (const [mountPath, mountInfo] of this.activeMounts.entries()) { + if (mountInfo.mounted) { + try { + this.logger.info( + `Unmounting bucket ${mountInfo.bucket} from ${mountPath}` + ); + await this.exec(`fusermount -u ${shellEscape(mountPath)}`); + mountInfo.mounted = false; + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error); + this.logger.warn( + `Failed to unmount bucket ${mountInfo.bucket} from ${mountPath}: ${errorMsg}` + ); + } + } + + // Always cleanup password file + await this.deletePasswordFile(mountInfo.passwordFilePath); + } + await super.destroy(); } @@ -1249,7 +1548,12 @@ export class Sandbox extends Container implements ISandbox { this.codeInterpreter.runCodeStream(code, options), listCodeContexts: () => this.codeInterpreter.listCodeContexts(), deleteCodeContext: (contextId) => - this.codeInterpreter.deleteCodeContext(contextId) + this.codeInterpreter.deleteCodeContext(contextId), + + // Bucket mounting - sandbox-level operations + mountBucket: (bucket, mountPath, options) => + this.mountBucket(bucket, mountPath, options), + unmountBucket: (mountPath) => this.unmountBucket(mountPath) }; } diff --git a/packages/sandbox/src/storage-mount/credential-detection.ts b/packages/sandbox/src/storage-mount/credential-detection.ts new file mode 100644 index 00000000..e01032ec --- /dev/null +++ b/packages/sandbox/src/storage-mount/credential-detection.ts @@ -0,0 +1,41 @@ +import type { BucketCredentials, MountBucketOptions } from '@repo/shared'; +import { MissingCredentialsError } from './errors'; + +/** + * Detect credentials for bucket mounting from environment variables + * Priority order: + * 1. Explicit options.credentials + * 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY + * 3. Error: no credentials found + * + * @param options - Mount options + * @param envVars - Environment variables + * @returns Detected credentials + * @throws MissingCredentialsError if no credentials found + */ +export function detectCredentials( + options: MountBucketOptions, + envVars: Record +): BucketCredentials { + // Priority 1: Explicit credentials in options + if (options.credentials) { + return options.credentials; + } + + // Priority 2: Standard AWS env vars + const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID; + const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY; + + if (awsAccessKeyId && awsSecretAccessKey) { + return { + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey + }; + } + + // No credentials found - throw error with helpful message + throw new MissingCredentialsError( + `No credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY ` + + `environment variables, or pass explicit credentials in options.` + ); +} diff --git a/packages/sandbox/src/storage-mount/errors.ts b/packages/sandbox/src/storage-mount/errors.ts new file mode 100644 index 00000000..bce38fb2 --- /dev/null +++ b/packages/sandbox/src/storage-mount/errors.ts @@ -0,0 +1,51 @@ +/** + * Bucket mounting error classes + * + * These are SDK-side validation errors that follow the same pattern as SecurityError. + * They are thrown before any container interaction occurs. + */ + +import { ErrorCode } from '@repo/shared/errors'; + +/** + * Base error for bucket mounting operations + */ +export class BucketMountError extends Error { + public readonly code: ErrorCode; + + constructor(message: string, code: ErrorCode = ErrorCode.BUCKET_MOUNT_ERROR) { + super(message); + this.name = 'BucketMountError'; + this.code = code; + } +} + +/** + * Thrown when S3FS mount command fails + */ +export class S3FSMountError extends BucketMountError { + constructor(message: string) { + super(message, ErrorCode.S3FS_MOUNT_ERROR); + this.name = 'S3FSMountError'; + } +} + +/** + * Thrown when no credentials found in environment + */ +export class MissingCredentialsError extends BucketMountError { + constructor(message: string) { + super(message, ErrorCode.MISSING_CREDENTIALS); + this.name = 'MissingCredentialsError'; + } +} + +/** + * Thrown when bucket name, mount path, or options are invalid + */ +export class InvalidMountConfigError extends BucketMountError { + constructor(message: string) { + super(message, ErrorCode.INVALID_MOUNT_CONFIG); + this.name = 'InvalidMountConfigError'; + } +} diff --git a/packages/sandbox/src/storage-mount/index.ts b/packages/sandbox/src/storage-mount/index.ts new file mode 100644 index 00000000..aed05d12 --- /dev/null +++ b/packages/sandbox/src/storage-mount/index.ts @@ -0,0 +1,17 @@ +/** + * Bucket mounting functionality + */ + +export { detectCredentials } from './credential-detection'; +export { + BucketMountError, + InvalidMountConfigError, + MissingCredentialsError, + S3FSMountError +} from './errors'; +export { + detectProviderFromUrl, + getProviderFlags, + resolveS3fsOptions +} from './provider-detection'; +export type { MountInfo } from './types'; diff --git a/packages/sandbox/src/storage-mount/provider-detection.ts b/packages/sandbox/src/storage-mount/provider-detection.ts new file mode 100644 index 00000000..765a40ea --- /dev/null +++ b/packages/sandbox/src/storage-mount/provider-detection.ts @@ -0,0 +1,93 @@ +/** + * Provider detection and s3fs flag configuration + * + * Based on s3fs-fuse documentation: + * https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3 + */ + +import type { BucketProvider } from '@repo/shared'; + +/** + * Detect provider from endpoint URL using pattern matching + */ +export function detectProviderFromUrl(endpoint: string): BucketProvider | null { + try { + const url = new URL(endpoint); + const hostname = url.hostname.toLowerCase(); + + if (hostname.endsWith('.r2.cloudflarestorage.com')) { + return 'r2'; + } + + // Match AWS S3: *.amazonaws.com or s3.amazonaws.com + if ( + hostname.endsWith('.amazonaws.com') || + hostname === 's3.amazonaws.com' + ) { + return 's3'; + } + + if (hostname === 'storage.googleapis.com') { + return 'gcs'; + } + + return null; + } catch { + return null; + } +} + +/** + * Get s3fs flags for a given provider + * + * Based on s3fs-fuse wiki recommendations: + * https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3 + */ +export function getProviderFlags(provider: BucketProvider | null): string[] { + if (!provider) { + return ['use_path_request_style']; + } + + switch (provider) { + case 'r2': + return ['nomixupload']; + + case 's3': + return []; + + case 'gcs': + return []; + + default: + return ['use_path_request_style']; + } +} + +/** + * Resolve s3fs options by combining provider defaults with user overrides + */ +export function resolveS3fsOptions( + provider: BucketProvider | null, + userOptions?: string[] +): string[] { + const providerFlags = getProviderFlags(provider); + + if (!userOptions || userOptions.length === 0) { + return providerFlags; + } + + // Merge provider flags with user options + // User options take precedence (come last in the array) + const allFlags = [...providerFlags, ...userOptions]; + + // Deduplicate flags (keep last occurrence) + const flagMap = new Map(); + + for (const flag of allFlags) { + // Split on '=' to get the flag name + const [flagName] = flag.split('='); + flagMap.set(flagName, flag); + } + + return Array.from(flagMap.values()); +} diff --git a/packages/sandbox/src/storage-mount/types.ts b/packages/sandbox/src/storage-mount/types.ts new file mode 100644 index 00000000..cc8e3ddc --- /dev/null +++ b/packages/sandbox/src/storage-mount/types.ts @@ -0,0 +1,17 @@ +/** + * Internal bucket mounting types + */ + +import type { BucketProvider } from '@repo/shared'; + +/** + * Internal tracking information for active mounts + */ +export interface MountInfo { + bucket: string; + mountPath: string; + endpoint: string; + provider: BucketProvider | null; + passwordFilePath: string; + mounted: boolean; +} diff --git a/packages/sandbox/tests/storage-mount/credential-detection.test.ts b/packages/sandbox/tests/storage-mount/credential-detection.test.ts new file mode 100644 index 00000000..8276ea87 --- /dev/null +++ b/packages/sandbox/tests/storage-mount/credential-detection.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { detectCredentials } from '../../src/storage-mount/credential-detection'; + +describe('Credential Detection', () => { + it('should use explicit credentials from options', () => { + const envVars = {}; + const options = { + endpoint: 'https://test.r2.cloudflarestorage.com', + credentials: { + accessKeyId: 'explicit-key', + secretAccessKey: 'explicit-secret' + } + }; + + const credentials = detectCredentials(options, envVars); + + expect(credentials.accessKeyId).toBe('explicit-key'); + expect(credentials.secretAccessKey).toBe('explicit-secret'); + }); + + it('should detect standard AWS env vars', () => { + const envVars = { + AWS_ACCESS_KEY_ID: 'aws-key', + AWS_SECRET_ACCESS_KEY: 'aws-secret' + }; + const options = { endpoint: 'https://s3.us-west-2.amazonaws.com' }; + + const credentials = detectCredentials(options, envVars); + + expect(credentials.accessKeyId).toBe('aws-key'); + expect(credentials.secretAccessKey).toBe('aws-secret'); + }); + + it('should ignore session token in environment', () => { + const envVars = { + AWS_ACCESS_KEY_ID: 'aws-key', + AWS_SECRET_ACCESS_KEY: 'aws-secret', + AWS_SESSION_TOKEN: 'session-token' + }; + const options = { endpoint: 'https://s3.us-west-2.amazonaws.com' }; + + const credentials = detectCredentials(options, envVars); + + expect(credentials.accessKeyId).toBe('aws-key'); + expect(credentials.secretAccessKey).toBe('aws-secret'); + }); + + it('should prioritize explicit credentials over env vars', () => { + const envVars = { + AWS_ACCESS_KEY_ID: 'env-key', + AWS_SECRET_ACCESS_KEY: 'env-secret' + }; + const options = { + endpoint: 'https://test.r2.cloudflarestorage.com', + credentials: { + accessKeyId: 'explicit-key', + secretAccessKey: 'explicit-secret' + } + }; + + const credentials = detectCredentials(options, envVars); + + expect(credentials.accessKeyId).toBe('explicit-key'); + expect(credentials.secretAccessKey).toBe('explicit-secret'); + }); + + it('should throw error when no credentials found', () => { + const envVars = {}; + const options = { endpoint: 'https://test.r2.cloudflarestorage.com' }; + + expect(() => detectCredentials(options, envVars)).toThrow( + 'No credentials found' + ); + }); + + it('should include helpful error message with env var hints', () => { + const envVars = {}; + const options = { endpoint: 'https://test.r2.cloudflarestorage.com' }; + + let thrownError: Error | null = null; + try { + detectCredentials(options, envVars); + } catch (error) { + thrownError = error as Error; + } + + expect(thrownError).toBeTruthy(); + if (thrownError) { + const message = thrownError.message; + expect(message).toContain('AWS_ACCESS_KEY_ID'); + expect(message).toContain('AWS_SECRET_ACCESS_KEY'); + expect(message).toContain('explicit credentials'); + } + }); + + it('should throw error when only access key is present', () => { + const envVars = { + AWS_ACCESS_KEY_ID: 'aws-key' + // Missing AWS_SECRET_ACCESS_KEY + }; + const options = { endpoint: 'https://test.r2.cloudflarestorage.com' }; + + expect(() => detectCredentials(options, envVars)).toThrow( + 'No credentials found' + ); + }); + + it('should throw error when only secret key is present', () => { + const envVars = { + AWS_SECRET_ACCESS_KEY: 'aws-secret' + // Missing AWS_ACCESS_KEY_ID + }; + const options = { endpoint: 'https://test.r2.cloudflarestorage.com' }; + + expect(() => detectCredentials(options, envVars)).toThrow( + 'No credentials found' + ); + }); +}); diff --git a/packages/sandbox/tests/storage-mount/provider-detection.test.ts b/packages/sandbox/tests/storage-mount/provider-detection.test.ts new file mode 100644 index 00000000..60ebe725 --- /dev/null +++ b/packages/sandbox/tests/storage-mount/provider-detection.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { + detectProviderFromUrl, + getProviderFlags, + resolveS3fsOptions +} from '../../src/storage-mount/provider-detection'; + +describe('Provider Detection', () => { + describe('detectProviderFromUrl', () => { + it.each([ + ['https://abc123.r2.cloudflarestorage.com', 'r2'], + ['https://s3.us-west-2.amazonaws.com', 's3'], + ['https://storage.googleapis.com', 'gcs'] + ])('should detect %s as %s', (url, expectedProvider) => { + expect(detectProviderFromUrl(url)).toBe(expectedProvider); + }); + + it.each([['https://custom.storage.example.com'], ['not-a-url'], ['']])( + 'should return null for unknown/invalid: %s', + (url) => { + expect(detectProviderFromUrl(url)).toBe(null); + } + ); + }); + + describe('getProviderFlags', () => { + it.each([ + ['r2', ['nomixupload']], + ['s3', []], + ['gcs', []] + ])('should return correct flags for %s', (provider, expected) => { + expect(getProviderFlags(provider as any)).toEqual(expected); + }); + + it('should return safe defaults for unknown providers', () => { + expect(getProviderFlags(null)).toEqual(['use_path_request_style']); + }); + }); + + describe('resolveS3fsOptions', () => { + it('should use provider defaults when no user options', () => { + const options = resolveS3fsOptions('r2'); + expect(options).toEqual(['nomixupload']); + }); + + it('should merge provider flags with user options', () => { + const options = resolveS3fsOptions('r2', ['custom_flag']); + expect(options).toContain('nomixupload'); + expect(options).toContain('custom_flag'); + }); + + it('should allow user options to override provider defaults', () => { + const options = resolveS3fsOptions('r2', ['endpoint=us-east']); + expect(options).toContain('nomixupload'); + expect(options).toContain('endpoint=us-east'); + expect(options).not.toContain('endpoint=auto'); + }); + + it('should deduplicate flags keeping last occurrence', () => { + const options = resolveS3fsOptions(null, [ + 'use_path_request_style', + 'custom_flag' + ]); + const count = options.filter( + (o) => o === 'use_path_request_style' + ).length; + expect(count).toBe(1); + expect(options).toContain('custom_flag'); + }); + + it('should use safe defaults for unknown providers', () => { + const options = resolveS3fsOptions(null, ['nomixupload']); + expect(options).toContain('use_path_request_style'); + expect(options).toContain('nomixupload'); + }); + }); +}); diff --git a/packages/shared/src/errors/codes.ts b/packages/shared/src/errors/codes.ts index 345b1d9c..eee2d594 100644 --- a/packages/shared/src/errors/codes.ts +++ b/packages/shared/src/errors/codes.ts @@ -79,6 +79,12 @@ export const ErrorCode = { GIT_CHECKOUT_FAILED: 'GIT_CHECKOUT_FAILED', GIT_OPERATION_FAILED: 'GIT_OPERATION_FAILED', + // Bucket mounting errors + BUCKET_MOUNT_ERROR: 'BUCKET_MOUNT_ERROR', + S3FS_MOUNT_ERROR: 'S3FS_MOUNT_ERROR', + MISSING_CREDENTIALS: 'MISSING_CREDENTIALS', + INVALID_MOUNT_CONFIG: 'INVALID_MOUNT_CONFIG', + // Code Interpreter Errors (503) INTERPRETER_NOT_READY: 'INTERPRETER_NOT_READY', diff --git a/packages/shared/src/errors/contexts.ts b/packages/shared/src/errors/contexts.ts index 935811ca..2b0ed705 100644 --- a/packages/shared/src/errors/contexts.ts +++ b/packages/shared/src/errors/contexts.ts @@ -125,6 +125,29 @@ export interface ValidationFailedContext { }>; } +/** + * Bucket mounting error contexts + */ +export interface BucketMountContext { + bucket: string; + mountPath: string; + endpoint: string; + stderr?: string; + exitCode?: number; +} + +export interface MissingCredentialsContext { + bucket: string; + endpoint: string; +} + +export interface InvalidMountConfigContext { + bucket?: string; + mountPath?: string; + endpoint?: string; + reason?: string; +} + /** * Generic error contexts */ diff --git a/packages/shared/src/errors/index.ts b/packages/shared/src/errors/index.ts index 8fd8a5eb..84d9c666 100644 --- a/packages/shared/src/errors/index.ts +++ b/packages/shared/src/errors/index.ts @@ -33,6 +33,7 @@ export { ErrorCode, type ErrorCode as ErrorCodeType } from './codes'; // Export context interfaces export type { + BucketMountContext, CodeExecutionContext, CommandErrorContext, CommandNotFoundContext, @@ -46,7 +47,9 @@ export type { GitRepositoryNotFoundContext, InternalErrorContext, InterpreterNotReadyContext, + InvalidMountConfigContext, InvalidPortContext, + MissingCredentialsContext, PortAlreadyExposedContext, PortErrorContext, PortNotExposedContext, diff --git a/packages/shared/src/errors/status-map.ts b/packages/shared/src/errors/status-map.ts index 33b3b7e2..370e31a9 100644 --- a/packages/shared/src/errors/status-map.ts +++ b/packages/shared/src/errors/status-map.ts @@ -25,6 +25,8 @@ export const ERROR_STATUS_MAP: Record = { [ErrorCode.INVALID_JSON_RESPONSE]: 400, [ErrorCode.NAME_TOO_LONG]: 400, [ErrorCode.VALIDATION_FAILED]: 400, + [ErrorCode.MISSING_CREDENTIALS]: 400, + [ErrorCode.INVALID_MOUNT_CONFIG]: 400, // 401 Unauthorized [ErrorCode.GIT_AUTH_FAILED]: 401, @@ -61,6 +63,8 @@ export const ERROR_STATUS_MAP: Record = { [ErrorCode.GIT_CHECKOUT_FAILED]: 500, [ErrorCode.GIT_OPERATION_FAILED]: 500, [ErrorCode.CODE_EXECUTION_ERROR]: 500, + [ErrorCode.BUCKET_MOUNT_ERROR]: 500, + [ErrorCode.S3FS_MOUNT_ERROR]: 500, [ErrorCode.UNKNOWN_ERROR]: 500, [ErrorCode.INTERNAL_ERROR]: 500 }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0f90b15d..bab36e5a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -43,9 +43,14 @@ export type { StartProcessRequest, WriteFileRequest } from './request-types.js'; +// Export shell utilities +export { shellEscape } from './shell-escape.js'; // Export all types from types.ts export type { BaseExecOptions, + // Bucket mounting types + BucketCredentials, + BucketProvider, ContextCreateResult, ContextDeleteResult, ContextListResult, @@ -71,6 +76,7 @@ export type { ListFilesResult, LogEvent, MkdirResult, + MountBucketOptions, MoveFileResult, PortCloseResult, // Port management result types diff --git a/packages/shared/src/shell-escape.ts b/packages/shared/src/shell-escape.ts new file mode 100644 index 00000000..c61fe787 --- /dev/null +++ b/packages/shared/src/shell-escape.ts @@ -0,0 +1,8 @@ +/** + * Escapes a string for safe use in shell commands using POSIX single-quote escaping. + * Prevents command injection by wrapping the string in single quotes and escaping + * any single quotes within the string. + */ +export function shellEscape(str: string): string { + return `'${str.replace(/'/g, "'\\''")}'`; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 172c4b89..9a670e43 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -687,6 +687,85 @@ export interface ExecutionSession { ): Promise>; listCodeContexts(): Promise; deleteCodeContext(contextId: string): Promise; + + // Bucket mounting operations + mountBucket( + bucket: string, + mountPath: string, + options: MountBucketOptions + ): Promise; + unmountBucket(mountPath: string): Promise; +} + +// Bucket mounting types +/** + * Supported S3-compatible storage providers + */ +export type BucketProvider = + | 'r2' // Cloudflare R2 + | 's3' // Amazon S3 + | 'gcs'; // Google Cloud Storage + +/** + * Credentials for S3-compatible storage + */ +export interface BucketCredentials { + accessKeyId: string; + secretAccessKey: string; +} + +/** + * Options for mounting an S3-compatible bucket + */ +export interface MountBucketOptions { + /** + * S3-compatible endpoint URL + * + * Examples: + * - R2: 'https://abc123.r2.cloudflarestorage.com' + * - AWS S3: 'https://s3.us-west-2.amazonaws.com' + * - GCS: 'https://storage.googleapis.com' + * + * Required field + */ + endpoint: string; + + /** + * Optional provider hint for automatic s3fs flag configuration + * If not specified, will attempt to detect from endpoint URL. + * + * Examples: + * - 'r2' - Cloudflare R2 (adds nomixupload) + * - 's3' - Amazon S3 (standard configuration) + * - 'gcs' - Google Cloud Storage (no special flags needed) + */ + provider?: BucketProvider; + + /** + * Explicit credentials (overrides env var auto-detection) + */ + credentials?: BucketCredentials; + + /** + * Mount filesystem as read-only + * Default: false + */ + readOnly?: boolean; + + /** + * Advanced: Override or extend s3fs options + * + * These will be merged with provider-specific defaults. + * To override defaults completely, specify all options here. + * + * Common options: + * - 'use_path_request_style' - Use path-style URLs (bucket/path vs bucket.host/path) + * - 'nomixupload' - Disable mixed multipart uploads (needed for some providers) + * - 'nomultipart' - Disable all multipart operations + * - 'sigv2' - Use signature version 2 instead of v4 + * - 'no_check_certificate' - Skip SSL certificate validation (dev/testing only) + */ + s3fsOptions?: string[]; } // Main Sandbox interface @@ -744,6 +823,14 @@ export interface ISandbox { options?: { branch?: string; targetDir?: string } ): Promise; + // Bucket mounting operations + mountBucket( + bucket: string, + mountPath: string, + options: MountBucketOptions + ): Promise; + unmountBucket(mountPath: string): Promise; + // Session management createSession(options?: SessionOptions): Promise; deleteSession(sessionId: string): Promise; diff --git a/tests/e2e/bucket-mounting.test.ts b/tests/e2e/bucket-mounting.test.ts new file mode 100644 index 00000000..a7db3bfb --- /dev/null +++ b/tests/e2e/bucket-mounting.test.ts @@ -0,0 +1,174 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, + vi +} from 'vitest'; +import { + cleanupSandbox, + createSandboxId, + createTestHeaders, + fetchWithStartup +} from './helpers/test-fixtures'; +import { + getTestWorkerUrl, + type WranglerDevRunner +} from './helpers/wrangler-runner'; + +/** + * E2E test for S3-compatible bucket mounting + * + * Requires environment variables: + * CLOUDFLARE_ACCOUNT_ID - Cloudflare account ID + * AWS_ACCESS_KEY_ID - R2 access key ID + * AWS_SECRET_ACCESS_KEY - R2 secret access key + * + * Note: This test requires FUSE device access and only runs in CI. + * Local wrangler dev doesn't expose /dev/fuse to containers. + */ +describe('Bucket Mounting E2E', () => { + // Skip test when running locally (requires FUSE device access only available in CI) + const isCI = !!process.env.TEST_WORKER_URL; + if (!isCI) { + test.skip('Skipping - requires FUSE device access (CI only)', () => { + // Test skipped in local development + }); + return; + } + + describe('local', () => { + let runner: WranglerDevRunner | null; + let workerUrl: string; + let currentSandboxId: string | null = null; + + const TEST_BUCKET = 'sandbox-e2e-test'; + const MOUNT_PATH = '/mnt/test-data'; + const TEST_FILE = `e2e-test-${Date.now()}.txt`; + const TEST_CONTENT = `Bucket mounting E2E test - ${new Date().toISOString()}`; + + beforeAll(async () => { + const result = await getTestWorkerUrl(); + workerUrl = result.url; + runner = result.runner; + }, 30000); + + afterEach(async () => { + if (currentSandboxId) { + await cleanupSandbox(workerUrl, currentSandboxId); + currentSandboxId = null; + } + }); + + afterAll(async () => { + if (runner) { + await runner.stop(); + } + }); + + test('should mount bucket and perform file operations', async () => { + // Verify required credentials are present + const requiredVars = [ + 'CLOUDFLARE_ACCOUNT_ID', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY' + ]; + const missing = requiredVars.filter((v) => !process.env[v]); + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(', ')}` + ); + } + + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Mount the bucket + const mountResponse = await vi.waitFor( + async () => + fetchWithStartup(`${workerUrl}/api/bucket/mount`, { + method: 'POST', + headers, + body: JSON.stringify({ + bucket: TEST_BUCKET, + mountPath: MOUNT_PATH, + options: { + endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com` + } + }) + }), + { timeout: 60000, interval: 2000 } + ); + + expect(mountResponse.ok).toBe(true); + const mountResult = await mountResponse.json(); + expect(mountResult.success).toBe(true); + + // Verify mount point exists + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `test -d ${MOUNT_PATH} && echo "mounted"` + }) + }); + + const verifyResult = await verifyResponse.json(); + expect(verifyResult.stdout?.trim()).toBe('mounted'); + expect(verifyResult.exitCode).toBe(0); + + // Write test file to mounted bucket + const writeResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `echo "${TEST_CONTENT}" > ${MOUNT_PATH}/${TEST_FILE}` + }) + }); + + const writeResult = await writeResponse.json(); + expect(writeResult.exitCode).toBe(0); + + // Read file back + const readResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `cat ${MOUNT_PATH}/${TEST_FILE}` + }) + }); + + const readResult = await readResponse.json(); + expect(readResult.exitCode).toBe(0); + expect(readResult.stdout?.trim()).toBe(TEST_CONTENT); + + // List directory contents + const lsResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `ls -lh ${MOUNT_PATH}/${TEST_FILE}` + }) + }); + + const lsResult = await lsResponse.json(); + expect(lsResult.exitCode).toBe(0); + expect(lsResult.stdout).toContain(TEST_FILE); + + // Cleanup: delete test file + const cleanupResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `rm -f ${MOUNT_PATH}/${TEST_FILE}` + }) + }); + + const cleanupResult = await cleanupResponse.json(); + expect(cleanupResult.exitCode).toBe(0); + }, 120000); // 2 minute timeout + }); +}); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index af2ed5fe..57f56e65 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -9,6 +9,10 @@ export { Sandbox }; interface Env { Sandbox: DurableObjectNamespace; + // R2 credentials for bucket mounting tests + CLOUDFLARE_ACCOUNT_ID?: string; + AWS_ACCESS_KEY_ID?: string; + AWS_SECRET_ACCESS_KEY?: string; } async function parseBody(request: Request): Promise { @@ -303,6 +307,30 @@ console.log('Terminal server on port ' + port); }); } + // Bucket mount + if (url.pathname === '/api/bucket/mount' && request.method === 'POST') { + // Pass R2 credentials from worker env to sandbox env + const sandboxEnvVars: Record = {}; + if (env.CLOUDFLARE_ACCOUNT_ID) { + sandboxEnvVars.CLOUDFLARE_ACCOUNT_ID = env.CLOUDFLARE_ACCOUNT_ID; + } + if (env.AWS_ACCESS_KEY_ID) { + sandboxEnvVars.AWS_ACCESS_KEY_ID = env.AWS_ACCESS_KEY_ID; + } + if (env.AWS_SECRET_ACCESS_KEY) { + sandboxEnvVars.AWS_SECRET_ACCESS_KEY = env.AWS_SECRET_ACCESS_KEY; + } + + if (Object.keys(sandboxEnvVars).length > 0) { + await sandbox.setEnvVars(sandboxEnvVars); + } + + await sandbox.mountBucket(body.bucket, body.mountPath, body.options); + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' } + }); + } + // File read if (url.pathname === '/api/file/read' && request.method === 'POST') { const file = await executor.readFile(body.path); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index f89ba9fd..82867bdb 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,5 +1,9 @@ +import { config } from 'dotenv'; import { defineConfig } from 'vitest/config'; +// Load environment variables from .env file +config(); + /** * E2E test configuration *