Commit cfded3af by Jon Committed by GitHub

feat(sandbox): introduce unified sandbox adapter architecture (#6362)

Introduces a new, extensible sandbox adapter architecture to abstract
various sandbox providers behind a unified ISandbox interface. This
design utilizes an adapter pattern with a BaseSandboxAdapter, enabling
easy integration of providers like OpenSandboxAdapter and
MinimalProviderAdapter. It ensures consistent functionality across
environments through capability-driven polyfills for missing
features. This provides a scalable and maintainable foundation for
different execution environments.
parent 64f70a41
# @fastgpt/sandbox
A unified, high-level abstraction layer for cloud sandbox providers. It offers a consistent, vendor-agnostic interface for creating, managing, and interacting with sandboxed environments like OpenSandbox.
> This package is ESM-only (`"type": "module"`) and requires Node.js **>= 20**.
## Installation
```bash
pnpm add @fastgpt/sandbox
```
## Quick Start
The following example demonstrates the complete lifecycle of a sandbox: creating, executing commands, managing files, and finally, deleting it.
```ts
import { createSandbox } from '@fastgpt/sandbox';
async function main() {
// 1. Create a sandbox with the OpenSandbox provider
const sandbox = createSandbox({
provider: 'opensandbox',
connection: {
apiKey: process.env.OPEN_SANDBOX_API_KEY,
baseUrl: 'http://127.0.0.1:8080', // Your OpenSandbox server
runtime: 'kubernetes',
},
});
console.log(`Provider: ${sandbox.provider}`);
console.log(`Native filesystem support: ${sandbox.capabilities.nativeFileSystem}`);
try {
// 2. Create the sandbox instance with a specific image
await sandbox.create({
image: { repository: 'nginx', tag: 'latest' },
timeout: 3600, // Expiration in seconds
});
console.log(`Sandbox created: ${sandbox.id}`);
// 3. Wait until the sandbox is fully ready
await sandbox.waitUntilReady(60000); // 60-second timeout
console.log('Sandbox is ready.');
// 4. Execute a simple command
const version = await sandbox.execute('nginx -v');
console.log(`Nginx version: ${version.stdout || version.stderr}`);
// 5. Execute a command with streaming output
console.log('--- Streaming Execution ---');
await sandbox.executeStream('for i in 1 2 3; do echo "Line $i"; sleep 0.5; done', {
onStdout: (msg) => console.log(` [stdout] ${msg.text}`),
onStderr: (msg) => console.log(` [stderr] ${msg.text}`),
onComplete: (result) => console.log(` [done] Exit code: ${result.exitCode}`),
});
// 6. Work with the filesystem
console.log('\n--- Filesystem Operations ---');
// Write a file
await sandbox.writeFiles([
{
path: '/app/hello.js',
data: `console.log('Hello from sandbox!');`,
},
]);
console.log('Written /app/hello.js');
// Read the file back
const [file] = await sandbox.readFiles(['/app/hello.js']);
if (file && !file.error) {
const content = new TextDecoder().decode(file.content);
console.log(`Read content: "${content}"`);
}
// List directory
const entries = await sandbox.listDirectory('/app');
console.log('Directory listing for /app:', entries.map(e => e.name));
// 7. Stop and delete the sandbox
console.log('\n--- Cleanup ---');
await sandbox.stop();
console.log('Sandbox stopped.');
if (sandbox.runtime !== 'kubernetes') {
await sandbox.delete();
console.log('Sandbox deleted.');
}
} catch (error) {
console.error('An error occurred:', error);
} finally {
// 8. Close the connection
await sandbox.close();
console.log('Connection closed.');
}
}
main();
```
## API (`ISandbox`)
The `createSandbox(options)` function returns an instance that implements the `ISandbox` interface.
### Lifecycle Management
- **`create(options)`**: Creates a new sandbox instance.
- **`getInfo()`**: Retrieves detailed information about the sandbox.
- **`waitUntilReady(timeout)`**: Waits for the sandbox to become fully operational.
- **`renewExpiration(seconds)`**: Extends the sandbox's lifetime.
- **`pause()` / `resume()`**: Pauses and resumes a running sandbox (if supported).
- **`stop()`**: Stops the sandbox gracefully.
- **`delete()`**: Deletes the sandbox instance.
- **`close()`**: Closes the connection to the provider.
### Command Execution
- **`execute(command)`**: Executes a command and returns the result after completion.
- **`executeStream(command, handlers)`**: Executes a command and streams `stdout` and `stderr` in real-time.
- **`executeBackground(command)`**: Starts a command in the background and returns a session handle.
### Filesystem Operations
- **`writeFiles(files)`**: Writes one or more files to the sandbox.
- **`readFiles(paths)`**: Reads one or more files from the sandbox.
- **`listDirectory(path)`**: Lists the contents of a directory.
- **`createDirectories(paths)`**: Creates directories.
- **`deleteFiles(paths)`**: Deletes files.
- **`moveFiles(files)`**: Moves or renames files.
### Health and Metrics
- **`ping()`**: Performs a quick health check.
- **`getMetrics()`**: Retrieves CPU and memory usage statistics.
## Provider Capabilities
Different sandbox providers have different native capabilities. The SDK uses polyfills to provide a consistent API, but performance may vary.
| Feature | OpenSandbox | MinimalProvider |
|---------|-------------|-----------------|
| Native Filesystem | ✅ | ❌ (polyfilled) |
| Streaming Output | ✅ | ❌ (fallback) |
| Background Exec | ✅ | ⚠️ (simulated) |
| Pause/Resume | ✅ | ❌ |
| Health Check | ✅ | ⚠️ (polyfilled) |
| Metrics | ✅ | ⚠️ (polyfilled) |
| File Search | ✅ | ⚠️ (polyfilled) |
## Error Handling
The SDK exports specific error types to facilitate robust error handling:
- `SandboxException`
- `FeatureNotSupportedError`
- `FileOperationError`
- `CommandExecutionError`
- `TimeoutError`
Example:
```ts
import { FileOperationError } from '@fastgpt/sandbox';
try {
await sandbox.readFiles(['/nonexistent-file']);
} catch (error) {
if (error instanceof FileOperationError) {
console.error(`File operation failed: ${error.message}`);
}
}
```
{
"name": "@fastgpt/sandbox",
"version": "0.1.0",
"description": "Unified abstraction layer for cloud sandbox providers with adapter pattern and feature polyfilling",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run --config ./vitest.config.mts",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage"
},
"keywords": [
"sandbox",
"cloud",
"adapter",
"abstraction"
],
"author": "",
"license": "MIT",
"dependencies": {
"@alibaba-group/opensandbox": "^0.1.3"
},
"devDependencies": {
"vitest": "^3.0.9",
"@vitest/coverage-v8": "^3.0.9",
"typescript": "^5.1.3",
"husky": "^9.1.7",
"lint-staged": "^16.2.7"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}
export { BaseSandboxAdapter } from './BaseSandboxAdapter';
export type { MinimalProviderConnection } from './MinimalProviderAdapter';
export { MinimalProviderAdapter } from './MinimalProviderAdapter';
export type { OpenSandboxConnectionConfig, SandboxRuntimeType } from './OpenSandboxAdapter';
export { OpenSandboxAdapter } from './OpenSandboxAdapter';
import { SandboxException } from './SandboxException';
/**
* Thrown when command execution fails.
*/
export class CommandExecutionError extends SandboxException {
public readonly exitCode?: number;
public readonly stdout?: string;
public readonly stderr?: string;
public readonly commandError?: Error;
constructor(
message: string,
public readonly command: string,
exitCodeOrCause?: number | Error,
stdout?: string,
stderr?: string
) {
super(
message,
'COMMAND_FAILED',
exitCodeOrCause instanceof Error ? exitCodeOrCause : undefined
);
this.name = 'CommandExecutionError';
Object.setPrototypeOf(this, CommandExecutionError.prototype);
if (exitCodeOrCause instanceof Error) {
this.commandError = exitCodeOrCause;
} else {
this.exitCode = exitCodeOrCause;
this.stdout = stdout;
this.stderr = stderr;
}
}
/**
* Returns the combined output (stdout + stderr).
*/
getCombinedOutput(): string {
let output = this.stdout || '';
if (this.stderr) {
output += output ? `\n${this.stderr}` : this.stderr;
}
return output;
}
}
import { SandboxException } from './SandboxException';
/**
* Thrown when connection to a sandbox fails.
*/
export class ConnectionError extends SandboxException {
constructor(
message: string,
public readonly endpoint?: string,
cause?: unknown
) {
super(message, 'CONNECTION_ERROR', cause);
this.name = 'ConnectionError';
Object.setPrototypeOf(this, ConnectionError.prototype);
}
}
import { SandboxException } from './SandboxException';
/**
* Thrown when a provider does not natively support a feature
* and no polyfill is available.
*/
export class FeatureNotSupportedError extends SandboxException {
constructor(
message: string,
public readonly feature: string,
public readonly provider: string
) {
super(`Feature not supported by ${provider}: ${message}`, 'FEATURE_NOT_SUPPORTED');
this.name = 'FeatureNotSupportedError';
Object.setPrototypeOf(this, FeatureNotSupportedError.prototype);
}
}
import { SandboxException } from './SandboxException';
/**
* Error codes specific to file operations.
*/
export type FileErrorCode =
| 'FILE_NOT_FOUND'
| 'FILE_ALREADY_EXISTS'
| 'PERMISSION_DENIED'
| 'PATH_IS_DIRECTORY'
| 'PATH_NOT_DIRECTORY'
| 'INVALID_PATH'
| 'QUOTA_EXCEEDED'
| 'TRANSFER_ERROR';
/**
* Thrown when a file operation fails.
*/
export class FileOperationError extends SandboxException {
constructor(
message: string,
public readonly path: string,
public readonly fileErrorCode: FileErrorCode,
cause?: unknown
) {
super(message, fileErrorCode, cause);
this.name = 'FileOperationError';
Object.setPrototypeOf(this, FileOperationError.prototype);
}
}
/**
* Base exception class for all sandbox-related errors.
* Provides structured error information with codes and optional metadata.
*/
export class SandboxException extends Error {
constructor(
message: string,
public readonly code: SandboxErrorCode = 'INTERNAL_UNKNOWN_ERROR',
cause?: unknown
) {
// @ts-expect-error - cause is a valid Error option in ES2022
super(message, { cause });
this.name = 'SandboxException';
Object.setPrototypeOf(this, SandboxException.prototype);
}
/**
* Returns a structured representation of the error for logging.
*/
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
code: this.code,
cause: this.cause,
stack: this.stack
};
}
}
/**
* Error codes for sandbox exceptions.
* Extensible via string intersection.
*/
export type SandboxErrorCode =
| 'INTERNAL_UNKNOWN_ERROR'
| 'CONNECTION_ERROR'
| 'TIMEOUT'
| 'READY_TIMEOUT'
| 'UNHEALTHY'
| 'INVALID_ARGUMENT'
| 'UNEXPECTED_RESPONSE'
| 'FEATURE_NOT_SUPPORTED'
| 'SANDBOX_NOT_FOUND'
| 'PERMISSION_DENIED'
| 'FILE_NOT_FOUND'
| 'FILE_ALREADY_EXISTS'
| 'COMMAND_FAILED'
| (string & {});
import { SandboxException } from './SandboxException';
/**
* Thrown when an operation is attempted in an invalid sandbox state.
*/
export class SandboxStateError extends SandboxException {
constructor(
message: string,
public readonly currentState: string,
public readonly requiredState?: string
) {
super(
`Invalid sandbox state: ${message} (current: ${currentState}${requiredState ? `, required: ${requiredState}` : ''})`,
'INVALID_STATE'
);
this.name = 'SandboxStateError';
Object.setPrototypeOf(this, SandboxStateError.prototype);
}
}
import { SandboxException } from './SandboxException';
/**
* Thrown when an operation times out.
*/
export class TimeoutError extends SandboxException {
constructor(
message: string,
public readonly timeoutMs: number,
public readonly operation: string
) {
super(message, 'TIMEOUT');
this.name = 'TimeoutError';
Object.setPrototypeOf(this, TimeoutError.prototype);
}
}
/**
* Thrown when waiting for sandbox readiness times out.
*/
export class SandboxReadyTimeoutError extends SandboxException {
constructor(sandboxId: string, timeoutMs: number) {
super(`Sandbox ${sandboxId} did not become ready within ${timeoutMs}ms`, 'READY_TIMEOUT');
this.name = 'SandboxReadyTimeoutError';
Object.setPrototypeOf(this, SandboxReadyTimeoutError.prototype);
}
}
export { CommandExecutionError } from './CommandExecutionError';
export { ConnectionError } from './ConnectionError';
export { FeatureNotSupportedError } from './FeatureNotSupportedError';
export { type FileErrorCode, FileOperationError } from './FileOperationError';
export { type SandboxErrorCode, SandboxException } from './SandboxException';
export { SandboxStateError } from './SandboxStateError';
export { SandboxReadyTimeoutError, TimeoutError } from './TimeoutError';
import { MinimalProviderAdapter, OpenSandboxAdapter } from '../adapters';
import type { ISandbox } from '../interfaces';
/**
* Configuration for creating a sandbox provider.
*/
export interface ProviderConfig {
/** Provider type */
provider: 'opensandbox' | 'minimal' | string;
/** Connection configuration (provider-specific) */
connection?: {
baseUrl?: string;
apiKey?: string;
[key: string]: unknown;
};
/** Provider-specific options */
options?: Record<string, unknown>;
}
/**
* Factory for creating sandbox provider instances.
*
* Following the Factory Pattern, this centralizes provider
* creation and configuration.
*
* Example:
* ```typescript
* const sandbox = await SandboxProviderFactory.create({
* provider: 'opensandbox',
* connection: { apiKey: 'xxx' }
* });
*
* await sandbox.create({ image: { repository: 'node', tag: '18' } });
* ```
*/
const customProviders = new Map<string, (config: ProviderConfig) => ISandbox>();
/**
* Create a sandbox provider instance.
*
* @param config Provider configuration
* @returns Configured sandbox instance
* @throws Error if provider type is unknown
*/
function createProvider(config: ProviderConfig): ISandbox {
switch (config.provider) {
case 'opensandbox':
return new OpenSandboxAdapter({
baseUrl: config.connection?.baseUrl,
apiKey: config.connection?.apiKey,
runtime: config.connection?.runtime as 'docker' | 'kubernetes' | undefined
});
case 'minimal':
return new MinimalProviderAdapter();
default: {
// Check custom providers
const customFactory = customProviders.get(config.provider);
if (customFactory) {
return customFactory(config);
}
throw new Error(`Unknown provider: ${config.provider}`);
}
}
}
/**
* Register a custom provider adapter.
*
* @param name Provider name
* @param factory Function that creates the adapter
*/
function registerProvider(name: string, factory: (config: ProviderConfig) => ISandbox): void {
customProviders.set(name, factory);
}
/**
* Get list of available providers.
*/
function getAvailableProviders(): string[] {
return ['opensandbox', 'minimal', ...customProviders.keys()];
}
/**
* Factory for creating sandbox provider instances.
*
* Following the Factory Pattern, this centralizes provider
* creation and configuration.
*
* Example:
* ```typescript
* const sandbox = await SandboxProviderFactory.create({
* provider: 'opensandbox',
* connection: { apiKey: 'xxx' }
* });
*
* await sandbox.create({ image: { repository: 'node', tag: '18' } });
* ```
*/
export const SandboxProviderFactory = {
create: createProvider,
registerProvider,
getAvailableProviders
};
/**
* Convenience function for creating sandboxes.
*
* Shorthand for SandboxProviderFactory.create()
*/
export function createSandbox(config: ProviderConfig): ISandbox {
return SandboxProviderFactory.create(config);
}
export type { ProviderConfig } from './SandboxProviderFactory';
export { createSandbox, SandboxProviderFactory } from './SandboxProviderFactory';
// Export adapters
export * from './adapters';
// Export errors
export * from './errors';
// Export factory
export * from './factory';
// Export interfaces
export * from './interfaces';
// Export polyfill services
export * from './polyfill';
// Export types
export * from './types';
// Export utilities
export * from './utils';
import type { ExecuteOptions, ExecuteResult, StreamHandlers } from '../types';
/**
* Interface for command execution within a sandbox.
* Follows Interface Segregation Principle.
*/
export interface ICommandExecution {
/**
* Execute a command and wait for completion.
* @param command The command to execute
* @param options Execution options
* @returns Execution result with stdout, stderr, and exit code
* @throws {CommandExecutionError} If command fails
* @throws {TimeoutError} If execution times out
*/
execute(command: string, options?: ExecuteOptions): Promise<ExecuteResult>;
/**
* Execute a command with streaming output.
* Provides real-time access to stdout/stderr via handlers.
* @param command The command to execute
* @param handlers Stream handlers for output
* @param options Execution options
* @throws {CommandExecutionError} If command fails
*/
executeStream(command: string, handlers: StreamHandlers, options?: ExecuteOptions): Promise<void>;
/**
* Execute a command in the background.
* Returns immediately with a handle to control the execution.
* @param command The command to execute
* @param options Execution options
* @returns Handle for background execution
*/
executeBackground(
command: string,
options?: ExecuteOptions
): Promise<{ sessionId: string; kill(): Promise<void> }>;
/**
* Interrupt/kill a running command session.
* @param sessionId The session ID from executeBackground
*/
interrupt(sessionId: string): Promise<void>;
}
import type {
ContentReplaceEntry,
DirectoryEntry,
FileDeleteResult,
FileInfo,
FileReadResult,
FileWriteEntry,
FileWriteResult,
MoveEntry,
PermissionEntry,
ReadFileOptions,
SearchResult
} from '../types';
/**
* Interface for filesystem operations within a sandbox.
* Follows Interface Segregation Principle.
*
* All methods support batch operations for efficiency.
* Providers without native batch support will have operations
* automatically parallelized by the base adapter.
*/
export interface IFileSystem {
// ==================== File Operations ====================
/**
* Read files from the sandbox.
* @param paths Array of file paths to read
* @param options Read options
* @returns Array of results (one per path, may include errors)
*/
readFiles(paths: string[], options?: ReadFileOptions): Promise<FileReadResult[]>;
/**
* Write files to the sandbox.
* Supports strings, bytes, and streams.
* @param entries Files to write
* @returns Array of results with bytes written
*/
writeFiles(entries: FileWriteEntry[]): Promise<FileWriteResult[]>;
/**
* Delete files from the sandbox.
* @param paths Files to delete
* @returns Array of results
*/
deleteFiles(paths: string[]): Promise<FileDeleteResult[]>;
/**
* Move/rename files within the sandbox.
* @param entries Move operations to perform
*/
moveFiles(entries: MoveEntry[]): Promise<void>;
/**
* Replace content within files.
* @param entries Replacement operations
*/
replaceContent(entries: ContentReplaceEntry[]): Promise<void>;
// ==================== Streaming Operations ====================
/**
* Read a file as a stream.
* Efficient for large files.
* @param path File path
* @returns Async iterable of file chunks
*/
readFileStream(path: string): AsyncIterable<Uint8Array>;
/**
* Write a file from a stream.
* Efficient for large files.
* @param path File path
* @param stream Data stream
*/
writeFileStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void>;
// ==================== Directory Operations ====================
/**
* Create directories.
* Creates parent directories as needed.
* @param paths Directories to create
* @param options Directory options (mode, owner, group)
*/
createDirectories(
paths: string[],
options?: { mode?: number; owner?: string; group?: string }
): Promise<void>;
/**
* Delete directories and their contents.
* @param paths Directories to delete
* @param options Options (recursive, force)
*/
deleteDirectories(
paths: string[],
options?: { recursive?: boolean; force?: boolean }
): Promise<void>;
/**
* List directory contents.
* @param path Directory path
* @returns Array of directory entries
*/
listDirectory(path: string): Promise<DirectoryEntry[]>;
// ==================== Metadata Operations ====================
/**
* Get file/directory information.
* @param paths Paths to query
* @returns Map of path to file info
*/
getFileInfo(paths: string[]): Promise<Map<string, FileInfo>>;
/**
* Set file permissions.
* @param entries Permission changes to apply
*/
setPermissions(entries: PermissionEntry[]): Promise<void>;
// ==================== Search Operations ====================
/**
* Search for files matching a pattern.
* @param pattern Search pattern (glob or regex, provider-dependent)
* @param path Directory to search in
* @returns Array of matching results
*/
search(pattern: string, path?: string): Promise<SearchResult[]>;
}
import type { SandboxMetrics } from '../types';
/**
* Interface for health checking and metrics.
* Follows Interface Segregation Principle.
*/
export interface IHealthCheck {
/**
* Check if the sandbox is healthy and responsive.
* @returns true if healthy, false otherwise
*/
ping(): Promise<boolean>;
/**
* Get current resource metrics.
* @returns Current metrics (CPU, memory usage)
*/
getMetrics(): Promise<SandboxMetrics>;
/**
* Stream metrics in real-time.
* Not all providers support this.
* @returns Async iterable of metric snapshots
*/
streamMetrics?(): AsyncIterable<SandboxMetrics>;
}
import type { ProviderCapabilities } from '../types';
import type { ICommandExecution } from './ICommandExecution';
import type { IFileSystem } from './IFileSystem';
import type { IHealthCheck } from './IHealthCheck';
import type { ISandboxLifecycle } from './ISandboxLifecycle';
/**
* Unified sandbox interface.
* Composes all sandbox capabilities into a single interface.
*
* This is the primary interface that consumers interact with.
* All concrete adapters must implement this interface.
*
* Following Interface Segregation Principle, this interface
* is composed of smaller, focused interfaces.
*/
export interface ISandbox extends ISandboxLifecycle, ICommandExecution, IFileSystem, IHealthCheck {
/** Provider name (e.g., 'opensandbox') */
readonly provider: string;
/** Provider capability flags */
readonly capabilities: ProviderCapabilities;
/**
* Close the connection and release resources.
* Should be called when done with the sandbox.
*/
close(): Promise<void>;
}
import type { SandboxConfig, SandboxId, SandboxInfo, SandboxStatus } from '../types';
/**
* Interface for sandbox lifecycle operations.
* Follows Interface Segregation Principle - only lifecycle methods.
*/
export interface ISandboxLifecycle {
/** Unique identifier for this sandbox */
readonly id: SandboxId;
/** Current status of the sandbox */
readonly status: SandboxStatus;
/**
* Create a new sandbox with the given configuration.
* The sandbox ID is assigned after creation.
*/
create(config: SandboxConfig): Promise<void>;
/**
* Start a stopped sandbox.
*/
start(): Promise<void>;
/**
* Stop a running sandbox (graceful shutdown).
*/
stop(): Promise<void>;
/**
* Pause a running sandbox.
* Not all providers support this.
*/
pause(): Promise<void>;
/**
* Resume a paused sandbox.
* Not all providers support this.
*/
resume(): Promise<void>;
/**
* Delete the sandbox permanently.
*/
delete(): Promise<void>;
/**
* Get detailed information about the sandbox.
*/
getInfo(): Promise<SandboxInfo>;
/**
* Wait until the sandbox is ready (healthy and responsive).
* @param timeoutMs Maximum time to wait in milliseconds
* @throws {SandboxReadyTimeoutError} If timeout is exceeded
*/
waitUntilReady(timeoutMs?: number): Promise<void>;
/**
* Renew the sandbox expiration, extending its lifetime.
* Not all providers support this.
* @param additionalSeconds Seconds to extend
*/
renewExpiration(additionalSeconds: number): Promise<void>;
}
export type { ICommandExecution } from './ICommandExecution';
export type { IFileSystem } from './IFileSystem';
export type { IHealthCheck } from './IHealthCheck';
export type { ISandbox } from './ISandbox';
export type { ISandboxLifecycle } from './ISandboxLifecycle';
import type { ProviderCapabilities } from '../types';
/**
* Detects and reports on provider capabilities.
*
* This class can perform runtime capability detection by testing
* specific features, or use static capability declarations.
*/
export class CapabilityDetector {
/**
* Create a static detector with known capabilities.
*/
static fromCapabilities(capabilities: ProviderCapabilities): CapabilityDetector {
return new CapabilityDetector(capabilities);
}
constructor(private readonly capabilities: ProviderCapabilities) {}
/**
* Get the full capability set.
*/
getCapabilities(): ProviderCapabilities {
return { ...this.capabilities };
}
/**
* Check if a specific capability is supported.
*/
hasCapability<K extends keyof ProviderCapabilities>(capability: K): ProviderCapabilities[K] {
return this.capabilities[capability];
}
/**
* Check if filesystem operations need polyfilling.
*/
needsFileSystemPolyfill(): boolean {
return !this.capabilities.nativeFileSystem;
}
/**
* Check if health check needs polyfilling.
*/
needsHealthCheckPolyfill(): boolean {
return !this.capabilities.nativeHealthCheck;
}
/**
* Check if metrics need polyfilling.
*/
needsMetricsPolyfill(): boolean {
return !this.capabilities.nativeMetrics;
}
/**
* Check if search needs polyfilling.
*/
needsSearchPolyfill(): boolean {
return !this.capabilities.supportsSearch;
}
/**
* Get a summary of which features are native vs polyfilled.
*/
getFeatureSummary(): {
native: string[];
polyfilled: string[];
unsupported: string[];
} {
const native: string[] = [];
const polyfilled: string[] = [];
const unsupported: string[] = [];
const checkCapability = (name: keyof ProviderCapabilities, needsPolyfill?: () => boolean) => {
if (this.capabilities[name]) {
native.push(name);
} else if (needsPolyfill?.()) {
polyfilled.push(name);
} else if (needsPolyfill) {
unsupported.push(name);
}
};
checkCapability('supportsPauseResume');
checkCapability('supportsRenews');
checkCapability('supportsStreamingOutput');
checkCapability('supportsBackgroundExecution');
checkCapability('nativeFileSystem', () => this.needsFileSystemPolyfill());
checkCapability('supportsBatchOperations');
checkCapability('supportsStreamingTransfer');
checkCapability('supportsPermissions');
checkCapability('supportsSearch', () => this.needsSearchPolyfill());
checkCapability('nativeHealthCheck', () => this.needsHealthCheckPolyfill());
checkCapability('nativeMetrics', () => this.needsMetricsPolyfill());
return { native, polyfilled, unsupported };
}
}
export { CapabilityDetector } from './CapabilityDetector';
export { CommandPolyfillService } from './CommandPolyfillService';
/**
* Provider capability flags.
* Used for feature detection and polyfill routing.
*/
export interface ProviderCapabilities {
/** Provider supports pausing and resuming sandboxes */
supportsPauseResume: boolean;
/** Provider supports extending sandbox expiration */
supportsRenews: boolean;
/** Provider supports real-time streaming command output */
supportsStreamingOutput: boolean;
/** Provider supports background/long-running execution */
supportsBackgroundExecution: boolean;
/** Provider has native filesystem API (not just command-based) */
nativeFileSystem: boolean;
/** Provider supports batch file operations */
supportsBatchOperations: boolean;
/** Provider supports streaming file transfers */
supportsStreamingTransfer: boolean;
/** Provider supports file permission operations */
supportsPermissions: boolean;
/** Provider supports file search functionality */
supportsSearch: boolean;
/** Provider has native health check endpoint */
nativeHealthCheck: boolean;
/** Provider has native metrics endpoint */
nativeMetrics: boolean;
}
/**
* Helper to create full capability set (for fully-featured providers).
*/
export function createFullCapabilities(): ProviderCapabilities {
return {
supportsPauseResume: true,
supportsRenews: true,
supportsStreamingOutput: true,
supportsBackgroundExecution: true,
nativeFileSystem: true,
supportsBatchOperations: true,
supportsStreamingTransfer: true,
supportsPermissions: true,
supportsSearch: true,
nativeHealthCheck: true,
nativeMetrics: true
};
}
/**
* Helper to create minimal capability set (command-only providers).
*/
export function createMinimalCapabilities(): ProviderCapabilities {
return {
supportsPauseResume: false,
supportsRenews: false,
supportsStreamingOutput: false,
supportsBackgroundExecution: false,
nativeFileSystem: false,
supportsBatchOperations: false,
supportsStreamingTransfer: false,
supportsPermissions: false,
supportsSearch: false,
nativeHealthCheck: false,
nativeMetrics: false
};
}
/**
* Options for executing commands.
*/
export interface ExecuteOptions {
/** Working directory for execution */
workingDirectory?: string;
/** Run in background (don't wait for completion) */
background?: boolean;
/** Timeout in milliseconds */
timeoutMs?: number;
/** Environment variables to set */
env?: Record<string, string>;
/** Abort signal for cancellation */
signal?: AbortSignal;
}
/**
* Result of command execution.
*/
export interface ExecuteResult {
/** Standard output */
stdout: string;
/** Standard error */
stderr: string;
/** Exit code (null if not completed) */
exitCode: number | null;
/** Whether output was truncated */
truncated?: boolean;
/** Execution duration in milliseconds */
durationMs?: number;
}
/**
* Output message from streaming execution.
*/
export interface OutputMessage {
/** Message content */
text: string;
/** Timestamp (Unix milliseconds) */
timestamp?: number;
}
/**
* Handlers for streaming command output.
*/
export interface StreamHandlers {
/** Called for each stdout message */
onStdout?: (msg: OutputMessage) => void | Promise<void>;
/** Called for each stderr message */
onStderr?: (msg: OutputMessage) => void | Promise<void>;
/** Called when execution completes */
onComplete?: (result: ExecuteResult) => void | Promise<void>;
/** Called on error */
onError?: (error: Error) => void | Promise<void>;
}
/**
* Background execution handle.
*/
export interface BackgroundExecution {
/** Session ID for the background execution */
sessionId: string;
/** Kill the background execution */
kill(): Promise<void>;
}
/**
* File information/metadata.
*/
export interface FileInfo {
path: string;
size?: number;
modifiedAt?: Date;
createdAt?: Date;
mode?: number;
owner?: string;
group?: string;
isDirectory?: boolean;
isFile?: boolean;
isSymlink?: boolean;
}
/**
* Directory entry.
*/
export interface DirectoryEntry {
name: string;
path: string;
isDirectory: boolean;
isFile: boolean;
size?: number;
modifiedAt?: Date;
}
/**
* Entry for writing a file.
*/
export interface FileWriteEntry {
/** File path */
path: string;
/** File content (various types supported) */
data: string | Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>;
/** File permissions (octal) */
mode?: number;
/** Owner */
owner?: string;
/** Group */
group?: string;
}
/**
* Entry for permission changes.
*/
export interface PermissionEntry {
path: string;
mode?: number;
owner?: string;
group?: string;
}
/**
* Result of reading a file.
*/
export interface FileReadResult {
path: string;
content: Uint8Array;
error: Error | null;
}
/**
* Result of writing a file.
*/
export interface FileWriteResult {
path: string;
bytesWritten: number;
error: Error | null;
}
/**
* Result of deleting a file.
*/
export interface FileDeleteResult {
path: string;
success: boolean;
error: Error | null;
}
/**
* Search result.
*/
export interface SearchResult {
path: string;
isDirectory?: boolean;
isFile?: boolean;
}
/**
* Move/rename entry.
*/
export interface MoveEntry {
source: string;
destination: string;
}
/**
* Content replacement entry.
*/
export interface ContentReplaceEntry {
path: string;
oldContent: string;
newContent: string;
}
/**
* File read options.
*/
export interface ReadFileOptions {
/** Character encoding (default: binary/Uint8Array) */
encoding?: 'utf-8' | 'base64' | 'binary';
/** Byte range to read (format: "start-end" or "start-") */
range?: string;
}
// Re-export from capabilities
export type { ProviderCapabilities } from './capabilities';
export { createFullCapabilities, createMinimalCapabilities } from './capabilities';
// Re-export from execution
export type {
BackgroundExecution,
ExecuteOptions,
ExecuteResult,
OutputMessage,
StreamHandlers
} from './execution';
// Re-export from filesystem
export type {
ContentReplaceEntry,
DirectoryEntry,
FileDeleteResult,
FileInfo,
FileReadResult,
FileWriteEntry,
FileWriteResult,
MoveEntry,
PermissionEntry,
ReadFileOptions,
SearchResult
} from './filesystem';
// Re-export from sandbox
export type {
Endpoint,
ImageSpec,
NetworkPolicy,
ResourceLimits,
SandboxConfig,
SandboxId,
SandboxInfo,
SandboxMetrics,
SandboxState,
SandboxStatus
} from './sandbox';
/**
* Unique identifier for a sandbox.
*/
export type SandboxId = string;
/**
* Sandbox status states.
*/
export type SandboxState =
| 'Creating'
| 'Running'
| 'Pausing'
| 'Paused'
| 'Resuming'
| 'Deleting'
| 'Deleted'
| 'Error'
| string; // Extensible for provider-specific states
/**
* Sandbox status information.
*/
export interface SandboxStatus {
state: SandboxState;
reason?: string;
message?: string;
}
/**
* Resource limits for a sandbox.
*/
export interface ResourceLimits {
cpuCount?: number;
memoryMiB?: number;
diskGiB?: number;
}
/**
* Image specification for sandbox creation.
*/
export interface ImageSpec {
repository: string;
tag?: string;
digest?: string;
}
/**
* Network policy for sandbox.
*/
export interface NetworkPolicy {
allowEgress?: boolean;
allowedHosts?: string[];
}
/**
* Configuration for creating a sandbox.
*/
export interface SandboxConfig {
/** Container image specification */
image: ImageSpec;
/** Entrypoint command */
entrypoint?: string[];
/** Timeout in seconds (0 for no timeout) */
timeout?: number;
/** Resource limits */
resourceLimits?: ResourceLimits;
/** Environment variables */
env?: Record<string, string>;
/** Metadata for the sandbox */
metadata?: Record<string, string>;
/** Network access policy */
networkPolicy?: NetworkPolicy;
/** Provider-specific extensions */
extensions?: Record<string, unknown>;
}
/**
* Information about a sandbox.
*/
export interface SandboxInfo {
id: SandboxId;
image: ImageSpec;
entrypoint: string[];
metadata?: Record<string, string>;
status: SandboxStatus;
createdAt: Date;
expiresAt?: Date;
resourceLimits?: ResourceLimits;
}
/**
* Sandbox metrics.
*/
export interface SandboxMetrics {
cpuCount: number;
cpuUsedPercentage: number;
memoryTotalMiB: number;
memoryUsedMiB: number;
timestamp: number;
}
/**
* Endpoint information for accessing sandbox services.
*/
export interface Endpoint {
host: string;
port: number;
protocol: 'http' | 'https';
url: string;
}
/**
* Base64 encoding/decoding utilities.
* Works in both Node.js and browser environments.
*/
/**
* Encode a Uint8Array to base64 string.
*/
export function bytesToBase64(bytes: Uint8Array): string {
// Use built-in btoa for browser compatibility
const binary = Array.from(bytes)
.map((b) => String.fromCharCode(b))
.join('');
return btoa(binary);
}
/**
* Decode a base64 string to Uint8Array.
*/
export function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64.trim());
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Encode a string to base64.
*/
export function stringToBase64(str: string): string {
return btoa(str);
}
/**
* Decode a base64 string to utf-8 string.
*/
export function base64ToString(base64: string): string {
return atob(base64.trim());
}
export { base64ToBytes, base64ToString, bytesToBase64, stringToBase64 } from './base64';
export {
asyncIterableToBuffer,
bufferToReadableStream,
readableStreamToAsyncIterable,
streamToString,
stringToReadableStream
} from './streams';
/**
* Stream utilities for working with ReadableStream and AsyncIterable.
*/
/**
* Convert an AsyncIterable to a Uint8Array.
* Collects all chunks into a single buffer.
*/
export async function asyncIterableToBuffer(
iterable: AsyncIterable<Uint8Array>
): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let totalLength = 0;
for await (const chunk of iterable) {
chunks.push(chunk);
totalLength += chunk.length;
}
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
/**
* Convert a Uint8Array to a ReadableStream.
*/
export function bufferToReadableStream(buffer: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(buffer);
controller.close();
}
});
}
/**
* Convert a string to a ReadableStream.
*/
export function stringToReadableStream(str: string): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return bufferToReadableStream(encoder.encode(str));
}
/**
* Convert a ReadableStream to an AsyncIterable.
* (Native ReadableStream is already async iterable in modern environments,
* but this ensures compatibility.)
*/
export function readableStreamToAsyncIterable(
stream: ReadableStream<Uint8Array>
): AsyncIterable<Uint8Array> {
// If stream already has Symbol.asyncIterator, use it
if (stream[Symbol.asyncIterator]) {
return stream as AsyncIterable<Uint8Array>;
}
// Otherwise create an async iterable
return {
[Symbol.asyncIterator]: async function* () {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (value) {
yield value;
}
}
} finally {
reader.releaseLock();
}
}
};
}
/**
* Read a stream and convert to string.
*/
export async function streamToString(
stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
): Promise<string> {
const iterable =
stream instanceof ReadableStream ? readableStreamToAsyncIterable(stream) : stream;
const buffer = await asyncIterableToBuffer(iterable);
return new TextDecoder().decode(buffer);
}
import { describe, expect, it } from 'vitest';
import {
MinimalProviderAdapter,
type MinimalProviderConnection
} from '../../src/adapters/MinimalProviderAdapter';
interface MockExecutionResult {
stdout: string;
stderr: string;
exitCode: number;
}
function handlePing(): MockExecutionResult {
return { stdout: 'PING', stderr: '', exitCode: 0 };
}
function handleMkdir(): MockExecutionResult {
return { stdout: '', stderr: '', exitCode: 0 };
}
function handleHeredoc(command: string, mockFs: Map<string, string>): MockExecutionResult {
const pathMatch = command.match(/cat > "(.+?)" << 'POLYFILL_EOF'/);
if (pathMatch) {
const path = pathMatch[1];
const lines = command.split('\n');
const contentLines: string[] = [];
let inContent = false;
for (const line of lines) {
if (line.includes("<< 'POLYFILL_EOF'")) {
inContent = true;
continue;
}
if (line.trim() === 'POLYFILL_EOF') {
break;
}
if (inContent) {
contentLines.push(line);
}
}
mockFs.set(path, contentLines.join('\n'));
}
return { stdout: '', stderr: '', exitCode: 0 };
}
function handleBase64Read(command: string, mockFs: Map<string, string>): MockExecutionResult {
const match = command.match(/cat "(.+?)" \| base64 -w 0/);
const path = match?.[1]?.replace(/\\"/g, '"');
if (path && mockFs.has(path)) {
const content = mockFs.get(path);
if (!content) {
return { stdout: '', stderr: 'cat: No such file', exitCode: 1 };
}
const binary = Array.from(content)
.map((b) => String.fromCharCode(b.charCodeAt(0)))
.join('');
const base64 = btoa(binary);
return { stdout: base64, stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: 'cat: No such file', exitCode: 1 };
}
function handleBase64Write(command: string, mockFs: Map<string, string>): MockExecutionResult {
const pathMatch = command.match(/> "(.+?)"$/);
const dataMatch = command.match(/echo "(.+?)" \| base64 -d/);
if (pathMatch && dataMatch) {
const path = pathMatch[1]?.replace(/\\"/g, '"');
const base64Data = dataMatch[1] || '';
try {
const decoded = atob(base64Data);
mockFs.set(path, decoded);
} catch {
// Invalid base64, ignore
}
}
return { stdout: '', stderr: '', exitCode: 0 };
}
function handleLs(command: string, mockFs: Map<string, string>): MockExecutionResult {
const match = command.match(/ls -la "([^"]+)"/);
const path = match?.[1] || '.';
const entries = Array.from(mockFs.keys())
.filter((f) => f.startsWith(path))
.map((f) => f.slice(path.length + 1).split('/')[0])
.filter((f) => f);
const uniqueEntries = [...new Set(entries)];
const output = uniqueEntries
.map((e) => `-rw-r--r-- 1 user group 100 2024-01-15T10:00:00 ${e}`)
.join('\n');
return { stdout: output, stderr: '', exitCode: 0 };
}
function createMockConnection(mockFs: Map<string, string>): MinimalProviderConnection {
return {
id: 'integration-test-sandbox',
async execute(command: string) {
if (command.includes('echo "PING"')) {
return handlePing();
}
if (command.includes('mkdir -p')) {
return handleMkdir();
}
if (command.includes("<< 'POLYFILL_EOF'")) {
return handleHeredoc(command, mockFs);
}
if (command.includes('base64 -w 0')) {
return handleBase64Read(command, mockFs);
}
if (command.includes('base64 -d')) {
return handleBase64Write(command, mockFs);
}
if (command.includes('ls -la')) {
return handleLs(command, mockFs);
}
return { stdout: `Executed: ${command}`, stderr: '', exitCode: 0 };
},
async getStatus() {
return { state: 'Running' as const };
},
async close() {
// No-op
}
};
}
/**
* Integration test demonstrating end-to-end usage of MinimalProviderAdapter.
*
* This test simulates a real minimal provider (e.g., SSH connection)
* and verifies that filesystem operations work via polyfills.
*/
describe('MinimalProvider Integration', () => {
it('should perform full workflow with polyfilled filesystem', async () => {
// Simulate a minimal connection
const mockFs = new Map<string, string>();
const connection = createMockConnection(mockFs);
// Create adapter and connect
const adapter = new MinimalProviderAdapter();
await adapter.connect(connection);
// Verify capabilities
expect(adapter.capabilities.nativeFileSystem).toBe(false);
expect(adapter.provider).toBe('minimal');
// Test ping
const pingResult = await adapter.ping();
expect(pingResult).toBe(true);
// Test file write (via polyfill)
const writeResults = await adapter.writeFiles([
{ path: '/workspace/test.txt', data: 'Hello, Integration Test!' }
]);
expect(writeResults[0].error).toBeNull();
// Test file read (via polyfill)
const readResults = await adapter.readFiles(['/workspace/test.txt']);
expect(readResults[0].error).toBeNull();
const content = new TextDecoder().decode(readResults[0].content);
expect(content).toBe('Hello, Integration Test!');
// Test directory listing (via polyfill)
const entries = await adapter.listDirectory('/workspace');
expect(entries.length).toBeGreaterThan(0);
expect(entries[0].name).toBe('test.txt');
// Cleanup
await adapter.close();
});
it('should demonstrate feature parity between adapters', async () => {
/**
* This test demonstrates that both OpenSandbox (native) and
* MinimalProvider (polyfilled) expose the same interface,
* enabling provider-agnostic code.
*/
// Both adapters implement ISandbox
const providers = [
{
name: 'minimal',
adapter: new MinimalProviderAdapter(),
expectedNativeFs: false
}
];
for (const { name, adapter, expectedNativeFs } of providers) {
// Same interface, different implementations
expect(adapter.provider).toBe(name);
expect(adapter.capabilities.nativeFileSystem).toBe(expectedNativeFs);
// All ISandbox methods are available
expect(typeof adapter.execute).toBe('function');
expect(typeof adapter.readFiles).toBe('function');
expect(typeof adapter.writeFiles).toBe('function');
expect(typeof adapter.ping).toBe('function');
}
});
});
import type { ICommandExecution } from '../../src/interfaces';
import type { ExecuteOptions, ExecuteResult, StreamHandlers } from '../../src/types';
/**
* Mock implementation of ICommandExecution for testing.
*/
export class MockCommandExecution implements ICommandExecution {
private commands: Map<string, ExecuteResult> = new Map();
private executedCommands: { command: string; options?: ExecuteOptions }[] = [];
/**
* Register a mock response for a command.
*/
mockCommand(command: string, result: ExecuteResult): void {
this.commands.set(command, result);
}
/**
* Get list of executed commands for verification.
*/
getExecutedCommands(): { command: string; options?: ExecuteOptions }[] {
return [...this.executedCommands];
}
/**
* Clear all mock commands and execution history.
*/
clear(): void {
this.commands.clear();
this.executedCommands = [];
}
async execute(command: string, options?: ExecuteOptions): Promise<ExecuteResult> {
this.executedCommands.push({ command, options });
// Check for exact match
if (this.commands.has(command)) {
const result = this.commands.get(command);
if (result) {
return result;
}
}
// Check for partial match (for commands with dynamic parts)
for (const [key, result] of this.commands) {
if (command.includes(key) || key.includes(command)) {
return result;
}
}
// Default response
return {
stdout: '',
stderr: '',
exitCode: 0
};
}
async executeStream(
command: string,
handlers: StreamHandlers,
options?: ExecuteOptions
): Promise<void> {
const result = await this.execute(command, options);
if (handlers.onStdout && result.stdout) {
await handlers.onStdout({ text: result.stdout });
}
if (handlers.onStderr && result.stderr) {
await handlers.onStderr({ text: result.stderr });
}
if (handlers.onComplete) {
await handlers.onComplete(result);
}
}
async executeBackground(
command: string,
options?: ExecuteOptions
): Promise<{ sessionId: string; kill(): Promise<void> }> {
await this.execute(command, options);
return {
sessionId: `mock-${Date.now()}`,
kill: async () => {
// No-op
}
};
}
async interrupt(_sessionId: string): Promise<void> {
// No-op in mock
}
}
export { MockCommandExecution } from './MockCommandExecution';
export { MockSandboxAdapter } from './MockSandboxAdapter';
import { beforeEach, describe, expect, it } from 'vitest';
import { FeatureNotSupportedError } from '../../../src/errors';
import { createFullCapabilities, createMinimalCapabilities } from '../../../src/types';
import { MockSandboxAdapter } from '../../mocks/MockSandboxAdapter';
describe('BaseSandboxAdapter', () => {
describe('with full capabilities (native filesystem)', () => {
let adapter: MockSandboxAdapter;
beforeEach(() => {
adapter = new MockSandboxAdapter(createFullCapabilities());
adapter.setFile('/test.txt', new TextEncoder().encode('Hello'));
});
it('should report full capabilities', () => {
expect(adapter.capabilities.nativeFileSystem).toBe(true);
expect(adapter.capabilities.supportsStreamingOutput).toBe(true);
expect(adapter.capabilities.supportsBatchOperations).toBe(true);
});
it('should use native readFiles', async () => {
const results = await adapter.readFiles(['/test.txt']);
expect(results).toHaveLength(1);
expect(results[0].error).toBeNull();
expect(new TextDecoder().decode(results[0].content)).toBe('Hello');
});
it('should use native writeFiles', async () => {
const results = await adapter.writeFiles([{ path: '/new.txt', data: 'World' }]);
expect(results[0].error).toBeNull();
expect(results[0].bytesWritten).toBe(5);
const readBack = await adapter.readFiles(['/new.txt']);
expect(new TextDecoder().decode(readBack[0].content)).toBe('World');
});
it('should execute commands natively', async () => {
const result = await adapter.execute('echo test');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('echo test');
});
it('should support streaming when capability is present', async () => {
const stdoutChunks: string[] = [];
await adapter.executeStream('echo streaming', {
onStdout: (msg) => stdoutChunks.push(msg.text)
});
expect(stdoutChunks.length).toBeGreaterThan(0);
});
it('should throw FeatureNotSupportedError for unsupported pause', async () => {
// Create adapter with pause disabled
const caps = createFullCapabilities();
caps.supportsPauseResume = false;
const limitedAdapter = new MockSandboxAdapter(caps);
try {
await limitedAdapter.pause();
expect(false).toBe(true); // Should not reach here
} catch (error) {
expect(error).toBeInstanceOf(FeatureNotSupportedError);
expect((error as FeatureNotSupportedError).feature).toBe('pause');
}
});
it('should throw FeatureNotSupportedError for unsupported background execution', async () => {
const caps = createFullCapabilities();
caps.supportsBackgroundExecution = false;
const limitedAdapter = new MockSandboxAdapter(caps);
try {
await limitedAdapter.executeBackground('sleep 10');
expect(false).toBe(true);
} catch (error) {
expect(error).toBeInstanceOf(FeatureNotSupportedError);
}
});
});
describe('with minimal capabilities (polyfilled filesystem)', () => {
let adapter: MockSandboxAdapter;
beforeEach(() => {
adapter = new MockSandboxAdapter(createMinimalCapabilities());
});
it('should report no native filesystem', () => {
expect(adapter.capabilities.nativeFileSystem).toBe(false);
expect(adapter.capabilities.supportsStreamingTransfer).toBe(false);
});
it('should route readFiles through polyfill', async () => {
// With minimal capabilities, polyfill service should be used
// The polyfill will try to execute cat commands
const result = await adapter.readFiles(['/any.txt']);
// Polyfill will fail because no mock command is set up
expect(result[0].error).not.toBeNull();
});
it('should route writeFiles through polyfill', async () => {
const results = await adapter.writeFiles([{ path: '/test.txt', data: 'content' }]);
// Polyfill will fail because no mock command is set up
expect(results[0].error).not.toBeNull();
});
it('should use fallback for streaming when not supported', async () => {
const stdoutChunks: string[] = [];
await adapter.executeStream('echo test', {
onStdout: (msg) => stdoutChunks.push(msg.text)
});
// Should still work via fallback (execute + call handlers)
expect(stdoutChunks.length).toBeGreaterThan(0);
});
it('should ping via polyfill', async () => {
// With minimal capabilities, ping goes through polyfill
const result = await adapter.ping();
// Should work via the echo PING fallback
expect(typeof result).toBe('boolean');
});
});
describe('waitUntilReady', () => {
it('should resolve when sandbox is ready', async () => {
const adapter = new MockSandboxAdapter();
// Mock adapter's nativePing always returns true
await adapter.waitUntilReady(5000);
// Should not throw
expect(true).toBe(true);
});
});
describe('capabilities', () => {
it('should allow checking individual capabilities', () => {
const adapter = new MockSandboxAdapter();
expect(adapter.capabilities.nativeFileSystem).toBe(true);
expect(adapter.capabilities.nativeHealthCheck).toBe(true);
});
});
});
import { beforeEach, describe, expect, it } from 'vitest';
import {
MinimalProviderAdapter,
type MinimalProviderConnection
} from '../../../src/adapters/MinimalProviderAdapter';
import { FeatureNotSupportedError } from '../../../src/errors';
// Mock connection for testing
class MockConnection implements MinimalProviderConnection {
id = 'mock-minimal-id';
private shouldFail = false;
setShouldFail(fail: boolean): void {
this.shouldFail = fail;
}
async execute(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
if (this.shouldFail) {
return { stdout: '', stderr: 'Connection failed', exitCode: 1 };
}
// Simulate various command responses
if (command.includes('echo PING')) {
return { stdout: 'PING', stderr: '', exitCode: 0 };
}
if (command.includes('nproc')) {
return { stdout: '2', stderr: '', exitCode: 0 };
}
if (command.includes('/proc/meminfo')) {
const stdout = 'MemTotal: 4096000 kB\nMemFree: 2048000 kB\nMemAvailable: 3072000 kB';
return {
stdout,
stderr: '',
exitCode: 0
};
}
if (command.includes('cat ')) {
// Simulate file read via base64
if (command.includes('test.txt')) {
// "Hello" in base64
return { stdout: 'SGVsbG8=', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: 'cat: No such file', exitCode: 1 };
}
if (command.includes('mkdir -p')) {
return { stdout: '', stderr: '', exitCode: 0 };
}
if (command.includes('base64 -d')) {
// Simulate write success
return { stdout: '', stderr: '', exitCode: 0 };
}
if (command.includes('ls -la')) {
return {
stdout: `total 8
drwxr-xr-x 2 user group 4096 2024-01-15T10:00:00 .
drwxr-xr-x 3 user group 4096 2024-01-15T10:00:00 ..
-rw-r--r-- 1 user group 100 2024-01-15T10:30:00 file.txt`,
stderr: '',
exitCode: 0
};
}
// Default response
return { stdout: `Executed: ${command}`, stderr: '', exitCode: 0 };
}
async getStatus() {
return { state: 'Running' as const };
}
async close(): Promise<void> {
// No-op
}
}
describe('MinimalProviderAdapter', () => {
let adapter: MinimalProviderAdapter;
let mockConnection: MockConnection;
beforeEach(() => {
mockConnection = new MockConnection();
adapter = new MinimalProviderAdapter();
});
describe('capabilities', () => {
it('should report minimal capabilities', () => {
expect(adapter.capabilities.nativeFileSystem).toBe(false);
expect(adapter.capabilities.supportsStreamingOutput).toBe(false);
expect(adapter.capabilities.supportsBackgroundExecution).toBe(false);
expect(adapter.capabilities.nativeHealthCheck).toBe(false);
expect(adapter.capabilities.nativeMetrics).toBe(false);
});
});
describe('connect', () => {
it('should connect and initialize polyfill', async () => {
await adapter.connect(mockConnection);
expect(adapter.id).toBe('mock-minimal-id');
expect(adapter.status.state).toBe('Running');
});
});
describe('execute', () => {
beforeEach(async () => {
await adapter.connect(mockConnection);
});
it('should execute commands through connection', async () => {
const result = await adapter.execute('echo hello');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Executed');
});
it('should handle workingDirectory option', async () => {
const result = await adapter.execute('pwd', { workingDirectory: '/tmp' });
expect(result.exitCode).toBe(0);
});
});
describe('filesystem operations (via polyfill)', () => {
beforeEach(async () => {
await adapter.connect(mockConnection);
});
it('should read files via polyfill', async () => {
const results = await adapter.readFiles(['/test.txt']);
// The polyfill will execute cat + base64
expect(results).toBeDefined();
});
it('should list directories via polyfill', async () => {
const entries = await adapter.listDirectory('/home');
expect(entries.length).toBeGreaterThan(0);
expect(entries[0].name).toBe('file.txt');
});
it('should write files via polyfill', async () => {
const results = await adapter.writeFiles([{ path: '/test.txt', data: 'content' }]);
// Polyfill service handles the write
expect(results).toBeDefined();
});
});
describe('unsupported operations', () => {
beforeEach(async () => {
await adapter.connect(mockConnection);
});
it('should throw FeatureNotSupportedError for pause', async () => {
try {
await adapter.pause();
expect(false).toBe(true);
} catch (error) {
expect(error).toBeInstanceOf(FeatureNotSupportedError);
expect((error as FeatureNotSupportedError).feature).toBe('pause');
}
});
it('should throw FeatureNotSupportedError for resume', async () => {
try {
await adapter.resume();
expect(false).toBe(true);
} catch (error) {
expect(error).toBeInstanceOf(FeatureNotSupportedError);
expect((error as FeatureNotSupportedError).feature).toBe('resume');
}
});
it('should throw FeatureNotSupportedError for renewExpiration', async () => {
try {
await adapter.renewExpiration(3600);
expect(false).toBe(true);
} catch (error) {
expect(error).toBeInstanceOf(FeatureNotSupportedError);
}
});
});
describe('health check (via polyfill)', () => {
beforeEach(async () => {
await adapter.connect(mockConnection);
});
it('should ping via polyfill', async () => {
const result = await adapter.ping();
expect(result).toBe(true);
});
it('should get metrics via polyfill', async () => {
const metrics = await adapter.getMetrics();
expect(metrics.cpuCount).toBe(2);
expect(metrics.memoryTotalMiB).toBe(4000);
});
});
describe('executeStream fallback', () => {
beforeEach(async () => {
await adapter.connect(mockConnection);
});
it('should fallback to execute when streaming not supported', async () => {
const stdoutChunks: string[] = [];
await adapter.executeStream('echo test', {
onStdout: (msg) => stdoutChunks.push(msg.text)
});
expect(stdoutChunks.length).toBeGreaterThan(0);
});
});
});
import { describe, expect, it } from 'vitest';
import { MinimalProviderAdapter, OpenSandboxAdapter } from '../../../src/adapters';
import { createSandbox, SandboxProviderFactory } from '../../../src/factory/SandboxProviderFactory';
describe('SandboxProviderFactory', () => {
describe('create', () => {
it('should create OpenSandbox adapter', () => {
const sandbox = SandboxProviderFactory.create({
provider: 'opensandbox',
connection: {
baseUrl: 'http://localhost:8080',
apiKey: 'test-key'
}
});
expect(sandbox).toBeInstanceOf(OpenSandboxAdapter);
expect(sandbox.provider).toBe('opensandbox');
expect(sandbox.capabilities.nativeFileSystem).toBe(false);
});
it('should create minimal provider adapter', () => {
const sandbox = SandboxProviderFactory.create({
provider: 'minimal'
});
expect(sandbox).toBeInstanceOf(MinimalProviderAdapter);
expect(sandbox.provider).toBe('minimal');
expect(sandbox.capabilities.nativeFileSystem).toBe(false);
});
it('should throw error for unknown provider', () => {
try {
SandboxProviderFactory.create({
provider: 'unknown'
});
expect(false).toBe(true); // Should not reach here
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('Unknown provider');
}
});
});
describe('getAvailableProviders', () => {
it('should list available providers', () => {
const providers = SandboxProviderFactory.getAvailableProviders();
expect(providers).toContain('opensandbox');
expect(providers).toContain('minimal');
});
});
describe('registerProvider', () => {
it('should allow registering custom providers', () => {
const customFactory = () => new MinimalProviderAdapter();
SandboxProviderFactory.registerProvider('custom', customFactory);
const sandbox = SandboxProviderFactory.create({
provider: 'custom'
});
expect(sandbox).toBeDefined();
// Should now be in available providers
const providers = SandboxProviderFactory.getAvailableProviders();
expect(providers).toContain('custom');
});
});
});
describe('createSandbox convenience function', () => {
it('should work as shorthand for factory.create', () => {
const sandbox = createSandbox({
provider: 'opensandbox'
});
expect(sandbox).toBeInstanceOf(OpenSandboxAdapter);
});
});
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// This configuration runs the sandbox tests in isolation,
// without the global setup (e.g., MongoDB connection) from the root config.
dir: 'tests',
testTimeout: 30000,
},
});
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment