Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/bucket-mounting.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,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
Expand Down
36 changes: 13 additions & 23 deletions packages/sandbox-container/src/services/file-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FileInfo, ListFilesOptions, Logger } from '@repo/shared';
import { shellEscape } from '@repo/shared';
import type {
FileNotFoundContext,
FileSystemContext,
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -369,7 +359,7 @@ export class FileService implements FileSystemOperations {
// 2. Write file using SessionManager with base64 encoding
// Base64 ensures binary files (images, PDFs, etc.) are written correctly
// and avoids heredoc EOF collision issues
const escapedPath = this.escapePath(path);
const escapedPath = shellEscape(path);
const base64Content = Buffer.from(content, 'utf-8').toString('base64');
const command = `echo '${base64Content}' | base64 -d > ${escapedPath}`;

Expand Down Expand Up @@ -492,7 +482,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(
Expand Down Expand Up @@ -594,8 +584,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(
Expand Down Expand Up @@ -696,8 +686,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(
Expand Down Expand Up @@ -785,7 +775,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';
Expand Down Expand Up @@ -874,7 +864,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(
Expand Down Expand Up @@ -970,7 +960,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
Expand Down Expand Up @@ -1172,7 +1162,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
Expand Down Expand Up @@ -1349,7 +1339,7 @@ export class FileService implements FileSystemOperations {
sessionId = 'default'
): Promise<ReadableStream<Uint8Array>> {
const encoder = new TextEncoder();
const escapedPath = this.escapePath(path);
const escapedPath = shellEscape(path);

return new ReadableStream({
start: async (controller) => {
Expand Down
13 changes: 3 additions & 10 deletions packages/sandbox-container/src/services/git-service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(' ');
}

/**
Expand Down
42 changes: 0 additions & 42 deletions packages/sandbox-container/src/shell-escape.ts

This file was deleted.

12 changes: 12 additions & 0 deletions packages/sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ ENV DEBIAN_FRONTEND=noninteractive
# Set the sandbox version as an environment variable for version checking
ENV SANDBOX_VERSION=${SANDBOX_VERSION}

# Install 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

# Enable FUSE in container - allow non-root users to use FUSE
RUN sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf

# Install runtime packages and Python runtime libraries
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
Expand Down
21 changes: 19 additions & 2 deletions packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,7 +61,6 @@ export type {

// Git client types
GitCheckoutRequest,
GitCheckoutResult,
// Base client types
HttpClientOptions as SandboxClientOptions,

Expand Down Expand Up @@ -98,3 +108,10 @@ export {
parseSSEStream,
responseToAsyncIterable
} from './sse-parser';
// Export bucket mounting errors
export {
BucketMountError,
InvalidMountConfigError,
MissingCredentialsError,
S3FSMountError
} from './storage-mount/errors';
Loading
Loading