@treesap/sandbox 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +495 -0
  3. package/dist/api-server.d.ts +41 -0
  4. package/dist/api-server.d.ts.map +1 -0
  5. package/dist/api-server.js +536 -0
  6. package/dist/api-server.js.map +1 -0
  7. package/dist/auth-middleware.d.ts +31 -0
  8. package/dist/auth-middleware.d.ts.map +1 -0
  9. package/dist/auth-middleware.js +35 -0
  10. package/dist/auth-middleware.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +65 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/client.d.ts +137 -0
  16. package/dist/client.d.ts.map +1 -0
  17. package/dist/client.js +412 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/file-service.d.ts +94 -0
  20. package/dist/file-service.d.ts.map +1 -0
  21. package/dist/file-service.js +203 -0
  22. package/dist/file-service.js.map +1 -0
  23. package/dist/http-exposure-service.d.ts +71 -0
  24. package/dist/http-exposure-service.d.ts.map +1 -0
  25. package/dist/http-exposure-service.js +172 -0
  26. package/dist/http-exposure-service.js.map +1 -0
  27. package/dist/index.d.ts +59 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +66 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/sandbox-manager.d.ts +76 -0
  32. package/dist/sandbox-manager.d.ts.map +1 -0
  33. package/dist/sandbox-manager.js +161 -0
  34. package/dist/sandbox-manager.js.map +1 -0
  35. package/dist/sandbox.d.ts +118 -0
  36. package/dist/sandbox.d.ts.map +1 -0
  37. package/dist/sandbox.js +303 -0
  38. package/dist/sandbox.js.map +1 -0
  39. package/dist/server.d.ts +7 -0
  40. package/dist/server.d.ts.map +1 -0
  41. package/dist/server.js +240 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/stream-service.d.ts +35 -0
  44. package/dist/stream-service.d.ts.map +1 -0
  45. package/dist/stream-service.js +136 -0
  46. package/dist/stream-service.js.map +1 -0
  47. package/dist/terminal.d.ts +46 -0
  48. package/dist/terminal.d.ts.map +1 -0
  49. package/dist/terminal.js +264 -0
  50. package/dist/terminal.js.map +1 -0
  51. package/dist/websocket.d.ts +48 -0
  52. package/dist/websocket.d.ts.map +1 -0
  53. package/dist/websocket.js +332 -0
  54. package/dist/websocket.js.map +1 -0
  55. package/package.json +59 -0
  56. package/src/api-server.ts +658 -0
  57. package/src/auth-middleware.ts +65 -0
  58. package/src/cli.ts +71 -0
  59. package/src/client.ts +537 -0
  60. package/src/file-service.ts +273 -0
  61. package/src/http-exposure-service.ts +232 -0
  62. package/src/index.ts +101 -0
  63. package/src/sandbox-manager.ts +202 -0
  64. package/src/sandbox.ts +396 -0
  65. package/src/stream-service.ts +174 -0
  66. package/tsconfig.json +37 -0
@@ -0,0 +1,273 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { Readable } from 'stream';
4
+ import { createReadStream, createWriteStream } from 'fs';
5
+
6
+ export interface FileInfo {
7
+ name: string;
8
+ path: string;
9
+ type: 'file' | 'directory';
10
+ size?: number;
11
+ mtime?: number;
12
+ }
13
+
14
+ export interface ListFilesOptions {
15
+ recursive?: boolean;
16
+ pattern?: string;
17
+ includeHidden?: boolean;
18
+ }
19
+
20
+ export interface ReadFileOptions {
21
+ encoding?: BufferEncoding;
22
+ raw?: boolean;
23
+ }
24
+
25
+ export interface WriteFileOptions {
26
+ encoding?: BufferEncoding;
27
+ mode?: number;
28
+ createDirs?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Service for managing file operations within a sandbox
33
+ * Includes path validation to prevent directory traversal attacks
34
+ */
35
+ export class FileService {
36
+ private basePath: string;
37
+
38
+ constructor(basePath: string) {
39
+ this.basePath = path.resolve(basePath);
40
+ }
41
+
42
+ /**
43
+ * Validate and resolve a path within the sandbox
44
+ * Prevents directory traversal attacks
45
+ */
46
+ private resolvePath(filePath: string): string {
47
+ const resolved = path.resolve(this.basePath, filePath);
48
+
49
+ // Ensure the resolved path is within basePath
50
+ if (!resolved.startsWith(this.basePath)) {
51
+ throw new Error('Path traversal detected - access denied');
52
+ }
53
+
54
+ return resolved;
55
+ }
56
+
57
+ /**
58
+ * Read a file's contents
59
+ */
60
+ async readFile(filePath: string, options: ReadFileOptions = {}): Promise<string | Buffer> {
61
+ const resolved = this.resolvePath(filePath);
62
+
63
+ if (options.raw) {
64
+ return await fs.readFile(resolved);
65
+ }
66
+
67
+ const encoding = options.encoding || 'utf-8';
68
+ return await fs.readFile(resolved, encoding);
69
+ }
70
+
71
+ /**
72
+ * Write content to a file
73
+ */
74
+ async writeFile(
75
+ filePath: string,
76
+ content: string | Buffer,
77
+ options: WriteFileOptions = {}
78
+ ): Promise<void> {
79
+ const resolved = this.resolvePath(filePath);
80
+
81
+ // Create parent directories if requested
82
+ if (options.createDirs) {
83
+ const dir = path.dirname(resolved);
84
+ await fs.mkdir(dir, { recursive: true });
85
+ }
86
+
87
+ const encoding = options.encoding || 'utf-8';
88
+ const writeOptions: any = { encoding };
89
+
90
+ if (options.mode) {
91
+ writeOptions.mode = options.mode;
92
+ }
93
+
94
+ await fs.writeFile(resolved, content, writeOptions);
95
+ }
96
+
97
+ /**
98
+ * List files in a directory
99
+ */
100
+ async listFiles(dirPath: string = '.', options: ListFilesOptions = {}): Promise<FileInfo[]> {
101
+ const resolved = this.resolvePath(dirPath);
102
+
103
+ const files: FileInfo[] = [];
104
+
105
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
106
+
107
+ for (const entry of entries) {
108
+ // Skip hidden files unless requested
109
+ if (!options.includeHidden && entry.name.startsWith('.')) {
110
+ continue;
111
+ }
112
+
113
+ // Apply pattern filter if provided
114
+ if (options.pattern && !this.matchPattern(entry.name, options.pattern)) {
115
+ continue;
116
+ }
117
+
118
+ const fullPath = path.join(resolved, entry.name);
119
+ const relativePath = path.relative(this.basePath, fullPath);
120
+
121
+ const fileInfo: FileInfo = {
122
+ name: entry.name,
123
+ path: relativePath,
124
+ type: entry.isDirectory() ? 'directory' : 'file',
125
+ };
126
+
127
+ // Get file stats for size and mtime
128
+ try {
129
+ const stats = await fs.stat(fullPath);
130
+ fileInfo.size = stats.size;
131
+ fileInfo.mtime = stats.mtimeMs;
132
+ } catch {
133
+ // Ignore stat errors
134
+ }
135
+
136
+ files.push(fileInfo);
137
+
138
+ // Recursively list subdirectories if requested
139
+ if (options.recursive && entry.isDirectory()) {
140
+ const subFiles = await this.listFiles(relativePath, options);
141
+ files.push(...subFiles);
142
+ }
143
+ }
144
+
145
+ return files;
146
+ }
147
+
148
+ /**
149
+ * Delete a file or directory
150
+ */
151
+ async deleteFile(filePath: string, options: { recursive?: boolean } = {}): Promise<void> {
152
+ const resolved = this.resolvePath(filePath);
153
+
154
+ const stats = await fs.stat(resolved);
155
+
156
+ if (stats.isDirectory()) {
157
+ await fs.rm(resolved, { recursive: options.recursive, force: true });
158
+ } else {
159
+ await fs.unlink(resolved);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Check if a file or directory exists
165
+ */
166
+ async exists(filePath: string): Promise<boolean> {
167
+ try {
168
+ const resolved = this.resolvePath(filePath);
169
+ await fs.access(resolved);
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Get file stats
178
+ */
179
+ async stat(filePath: string): Promise<FileInfo> {
180
+ const resolved = this.resolvePath(filePath);
181
+ const stats = await fs.stat(resolved);
182
+ const relativePath = path.relative(this.basePath, resolved);
183
+
184
+ return {
185
+ name: path.basename(resolved),
186
+ path: relativePath,
187
+ type: stats.isDirectory() ? 'directory' : 'file',
188
+ size: stats.size,
189
+ mtime: stats.mtimeMs,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Create a directory
195
+ */
196
+ async mkdir(dirPath: string, options: { recursive?: boolean } = {}): Promise<void> {
197
+ const resolved = this.resolvePath(dirPath);
198
+ await fs.mkdir(resolved, { recursive: options.recursive });
199
+ }
200
+
201
+ /**
202
+ * Copy a file or directory
203
+ */
204
+ async copy(
205
+ sourcePath: string,
206
+ destPath: string,
207
+ options: { overwrite?: boolean } = {}
208
+ ): Promise<void> {
209
+ const resolvedSource = this.resolvePath(sourcePath);
210
+ const resolvedDest = this.resolvePath(destPath);
211
+
212
+ // Check if destination exists
213
+ if (!options.overwrite) {
214
+ try {
215
+ await fs.access(resolvedDest);
216
+ throw new Error('Destination already exists');
217
+ } catch (err: any) {
218
+ if (err.code !== 'ENOENT') {
219
+ throw err;
220
+ }
221
+ }
222
+ }
223
+
224
+ await fs.cp(resolvedSource, resolvedDest, { recursive: true });
225
+ }
226
+
227
+ /**
228
+ * Move/rename a file or directory
229
+ */
230
+ async move(sourcePath: string, destPath: string): Promise<void> {
231
+ const resolvedSource = this.resolvePath(sourcePath);
232
+ const resolvedDest = this.resolvePath(destPath);
233
+
234
+ await fs.rename(resolvedSource, resolvedDest);
235
+ }
236
+
237
+ /**
238
+ * Create a readable stream for a file
239
+ */
240
+ createReadStream(filePath: string): Readable {
241
+ const resolved = this.resolvePath(filePath);
242
+ return createReadStream(resolved);
243
+ }
244
+
245
+ /**
246
+ * Create a writable stream for a file
247
+ */
248
+ createWriteStream(filePath: string): NodeJS.WritableStream {
249
+ const resolved = this.resolvePath(filePath);
250
+ return createWriteStream(resolved);
251
+ }
252
+
253
+ /**
254
+ * Simple glob pattern matching
255
+ */
256
+ private matchPattern(filename: string, pattern: string): boolean {
257
+ // Convert glob pattern to regex
258
+ const regexPattern = pattern
259
+ .replace(/\./g, '\\.')
260
+ .replace(/\*/g, '.*')
261
+ .replace(/\?/g, '.');
262
+
263
+ const regex = new RegExp(`^${regexPattern}$`, 'i');
264
+ return regex.test(filename);
265
+ }
266
+
267
+ /**
268
+ * Get the base path of this file service
269
+ */
270
+ getBasePath(): string {
271
+ return this.basePath;
272
+ }
273
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * HTTP Exposure Service
3
+ * Integrates with Caddy Admin API to dynamically expose sandbox ports
4
+ */
5
+
6
+ export interface HttpExposureConfig {
7
+ /**
8
+ * Caddy Admin API URL (default: http://localhost:2019)
9
+ */
10
+ caddyAdminUrl: string;
11
+
12
+ /**
13
+ * Base domain for sandbox subdomains (e.g., sandbox.yourdomain.com)
14
+ */
15
+ baseDomain: string;
16
+
17
+ /**
18
+ * Protocol to use for generated URLs (default: https)
19
+ */
20
+ protocol: 'http' | 'https';
21
+
22
+ /**
23
+ * Host where sandbox ports are accessible (default: localhost)
24
+ * This is the internal address Caddy will proxy to
25
+ */
26
+ upstreamHost: string;
27
+ }
28
+
29
+ export interface ExposedEndpoint {
30
+ sandboxId: string;
31
+ port: number;
32
+ publicUrl: string;
33
+ routeId: string;
34
+ createdAt: number;
35
+ }
36
+
37
+ const DEFAULT_CONFIG: HttpExposureConfig = {
38
+ caddyAdminUrl: 'http://localhost:2019',
39
+ baseDomain: 'sandbox.localhost',
40
+ protocol: 'https',
41
+ upstreamHost: 'localhost',
42
+ };
43
+
44
+ /**
45
+ * Service for managing HTTP exposure of sandbox ports via Caddy
46
+ */
47
+ export class HttpExposureService {
48
+ private config: HttpExposureConfig;
49
+ private exposures: Map<string, ExposedEndpoint[]> = new Map();
50
+
51
+ constructor(config: Partial<HttpExposureConfig> = {}) {
52
+ this.config = {
53
+ ...DEFAULT_CONFIG,
54
+ ...config,
55
+ // Allow env vars to override
56
+ caddyAdminUrl: config.caddyAdminUrl || process.env.CADDY_ADMIN_URL || DEFAULT_CONFIG.caddyAdminUrl,
57
+ baseDomain: config.baseDomain || process.env.SANDBOX_DOMAIN || DEFAULT_CONFIG.baseDomain,
58
+ protocol: (config.protocol || process.env.SANDBOX_PROTOCOL || DEFAULT_CONFIG.protocol) as 'http' | 'https',
59
+ upstreamHost: config.upstreamHost || process.env.SANDBOX_UPSTREAM_HOST || DEFAULT_CONFIG.upstreamHost,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Generate a unique subdomain for a sandbox port
65
+ */
66
+ private generateSubdomain(sandboxId: string, port: number): string {
67
+ // Use first 8 chars of sandbox ID + port for a shorter, readable subdomain
68
+ const shortId = sandboxId.substring(0, 8);
69
+ return `${shortId}-${port}`;
70
+ }
71
+
72
+ /**
73
+ * Generate a unique route ID for Caddy
74
+ */
75
+ private generateRouteId(sandboxId: string, port: number): string {
76
+ return `sandbox-${sandboxId}-${port}`;
77
+ }
78
+
79
+ /**
80
+ * Expose a sandbox port and return the public URL
81
+ */
82
+ async expose(sandboxId: string, port: number): Promise<string> {
83
+ const subdomain = this.generateSubdomain(sandboxId, port);
84
+ const routeId = this.generateRouteId(sandboxId, port);
85
+ const hostname = `${subdomain}.${this.config.baseDomain}`;
86
+ const publicUrl = `${this.config.protocol}://${hostname}`;
87
+
88
+ // Check if already exposed
89
+ const existing = this.getExposure(sandboxId, port);
90
+ if (existing) {
91
+ return existing.publicUrl;
92
+ }
93
+
94
+ // Create Caddy route configuration
95
+ const route = {
96
+ '@id': routeId,
97
+ match: [{ host: [hostname] }],
98
+ handle: [
99
+ {
100
+ handler: 'reverse_proxy',
101
+ upstreams: [{ dial: `${this.config.upstreamHost}:${port}` }],
102
+ },
103
+ ],
104
+ };
105
+
106
+ try {
107
+ // Add route to Caddy via Admin API
108
+ const response = await fetch(
109
+ `${this.config.caddyAdminUrl}/config/apps/http/servers/srv0/routes`,
110
+ {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify(route),
114
+ }
115
+ );
116
+
117
+ if (!response.ok) {
118
+ const errorText = await response.text();
119
+ throw new Error(`Failed to add Caddy route: ${errorText}`);
120
+ }
121
+
122
+ // Track the exposure
123
+ const endpoint: ExposedEndpoint = {
124
+ sandboxId,
125
+ port,
126
+ publicUrl,
127
+ routeId,
128
+ createdAt: Date.now(),
129
+ };
130
+
131
+ const sandboxExposures = this.exposures.get(sandboxId) || [];
132
+ sandboxExposures.push(endpoint);
133
+ this.exposures.set(sandboxId, sandboxExposures);
134
+
135
+ return publicUrl;
136
+ } catch (error: any) {
137
+ // If Caddy is not available, return a placeholder URL for development
138
+ if (error.cause?.code === 'ECONNREFUSED') {
139
+ console.warn(`Caddy not available at ${this.config.caddyAdminUrl}. Using placeholder URL.`);
140
+
141
+ // Still track the exposure locally
142
+ const endpoint: ExposedEndpoint = {
143
+ sandboxId,
144
+ port,
145
+ publicUrl: `http://${this.config.upstreamHost}:${port}`,
146
+ routeId,
147
+ createdAt: Date.now(),
148
+ };
149
+
150
+ const sandboxExposures = this.exposures.get(sandboxId) || [];
151
+ sandboxExposures.push(endpoint);
152
+ this.exposures.set(sandboxId, sandboxExposures);
153
+
154
+ return endpoint.publicUrl;
155
+ }
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Remove HTTP exposure for a sandbox port (or all ports if port is undefined)
162
+ */
163
+ async unexpose(sandboxId: string, port?: number): Promise<void> {
164
+ const endpoints = this.exposures.get(sandboxId) || [];
165
+ const toRemove = port
166
+ ? endpoints.filter((e) => e.port === port)
167
+ : endpoints;
168
+
169
+ for (const endpoint of toRemove) {
170
+ try {
171
+ // Remove route from Caddy via Admin API
172
+ const response = await fetch(
173
+ `${this.config.caddyAdminUrl}/id/${endpoint.routeId}`,
174
+ { method: 'DELETE' }
175
+ );
176
+
177
+ // Ignore 404 errors (route doesn't exist)
178
+ if (!response.ok && response.status !== 404) {
179
+ console.warn(`Failed to remove Caddy route ${endpoint.routeId}: ${response.status}`);
180
+ }
181
+ } catch (error: any) {
182
+ // Ignore connection errors (Caddy not available)
183
+ if (error.cause?.code !== 'ECONNREFUSED') {
184
+ console.warn(`Error removing Caddy route: ${error.message}`);
185
+ }
186
+ }
187
+ }
188
+
189
+ // Update local tracking
190
+ if (port) {
191
+ this.exposures.set(
192
+ sandboxId,
193
+ endpoints.filter((e) => e.port !== port)
194
+ );
195
+ } else {
196
+ this.exposures.delete(sandboxId);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Get all exposures for a sandbox
202
+ */
203
+ getExposures(sandboxId: string): ExposedEndpoint[] {
204
+ return this.exposures.get(sandboxId) || [];
205
+ }
206
+
207
+ /**
208
+ * Get a specific exposure
209
+ */
210
+ getExposure(sandboxId: string, port: number): ExposedEndpoint | undefined {
211
+ const endpoints = this.exposures.get(sandboxId) || [];
212
+ return endpoints.find((e) => e.port === port);
213
+ }
214
+
215
+ /**
216
+ * Get all exposures across all sandboxes
217
+ */
218
+ getAllExposures(): ExposedEndpoint[] {
219
+ const all: ExposedEndpoint[] = [];
220
+ for (const endpoints of this.exposures.values()) {
221
+ all.push(...endpoints);
222
+ }
223
+ return all;
224
+ }
225
+
226
+ /**
227
+ * Clean up all exposures for a sandbox (called when sandbox is destroyed)
228
+ */
229
+ async cleanup(sandboxId: string): Promise<void> {
230
+ await this.unexpose(sandboxId);
231
+ }
232
+ }
package/src/index.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * TreeSap Sandbox v1.0
3
+ *
4
+ * A self-hosted sandbox API for isolated code execution and file management.
5
+ * Provides a Cloudflare-style API for managing sandboxed environments.
6
+ *
7
+ * @example Server usage
8
+ * ```typescript
9
+ * import { startServer } from '@treesap/sandbox';
10
+ *
11
+ * // Start the sandbox server
12
+ * const { server, manager } = await startServer({
13
+ * port: 3000,
14
+ * basePath: './.sandboxes'
15
+ * });
16
+ * ```
17
+ *
18
+ * @example Client SDK usage
19
+ * ```typescript
20
+ * import { SandboxClient } from '@treesap/sandbox';
21
+ *
22
+ * // Create a new sandbox
23
+ * const sandbox = await SandboxClient.create('http://localhost:3000');
24
+ *
25
+ * // Execute commands
26
+ * const result = await sandbox.exec('npm install');
27
+ * console.log(result.stdout);
28
+ *
29
+ * // File operations
30
+ * await sandbox.writeFile('package.json', JSON.stringify(pkg, null, 2));
31
+ * const files = await sandbox.listFiles();
32
+ *
33
+ * // Process management
34
+ * const server = await sandbox.startProcess('node server.js');
35
+ * const logs = await sandbox.streamProcessLogs(server.id);
36
+ *
37
+ * // Cleanup
38
+ * await sandbox.destroy({ cleanup: true });
39
+ * ```
40
+ *
41
+ * @packageDocumentation
42
+ */
43
+
44
+ // ============================================================================
45
+ // Server API
46
+ // ============================================================================
47
+
48
+ export { startServer, createServer } from './api-server';
49
+ export type { ServerConfig } from './api-server';
50
+
51
+ // ============================================================================
52
+ // Client SDK
53
+ // ============================================================================
54
+
55
+ export { SandboxClient, parseSSEStream } from './client';
56
+ export type { SandboxClientConfig, CreateSandboxResponse } from './client';
57
+
58
+ // ============================================================================
59
+ // Core Components
60
+ // ============================================================================
61
+
62
+ export { Sandbox } from './sandbox';
63
+ export { SandboxManager } from './sandbox-manager';
64
+ export { FileService } from './file-service';
65
+ export { StreamService } from './stream-service';
66
+
67
+ // ============================================================================
68
+ // Type Exports
69
+ // ============================================================================
70
+
71
+ export type {
72
+ SandboxConfig,
73
+ ProcessInfo,
74
+ ExecOptions,
75
+ ExecuteResponse,
76
+ } from './sandbox';
77
+
78
+ export type { SandboxManagerConfig } from './sandbox-manager';
79
+
80
+ export type {
81
+ FileInfo,
82
+ ListFilesOptions,
83
+ ReadFileOptions,
84
+ WriteFileOptions,
85
+ } from './file-service';
86
+
87
+ export type { ExecEvent, ExecEventType, LogEvent } from './stream-service';
88
+
89
+ // ============================================================================
90
+ // Auth & Middleware
91
+ // ============================================================================
92
+
93
+ export { createAuthMiddleware, parseApiKeysFromEnv } from './auth-middleware';
94
+ export type { AuthConfig } from './auth-middleware';
95
+
96
+ // ============================================================================
97
+ // HTTP Exposure
98
+ // ============================================================================
99
+
100
+ export { HttpExposureService } from './http-exposure-service';
101
+ export type { HttpExposureConfig, ExposedEndpoint } from './http-exposure-service';