Skip to content
Open
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.
11 changes: 11 additions & 0 deletions .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
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 @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1386,7 +1376,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: 6 additions & 6 deletions packages/sandbox-container/tests/services/git-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
Expand Down Expand Up @@ -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'"
);
});

Expand Down Expand Up @@ -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'
);
});
Expand Down Expand Up @@ -336,7 +336,7 @@ describe('GitService', () => {

expect(mockSessionManager.executeInSession).toHaveBeenCalledWith(
'session-123',
'git branch --show-current',
"'git' 'branch' '--show-current'",
'/tmp/repo'
);
});
Expand Down Expand Up @@ -379,7 +379,7 @@ describe('GitService', () => {

expect(mockSessionManager.executeInSession).toHaveBeenCalledWith(
'session-123',
'git branch -a',
"'git' 'branch' '-a'",
'/tmp/repo'
);
});
Expand Down
6 changes: 5 additions & 1 deletion packages/sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

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 @@ -56,7 +67,6 @@ export type {

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

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