@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.
- package/CHANGELOG.md +107 -0
- package/README.md +495 -0
- package/dist/api-server.d.ts +41 -0
- package/dist/api-server.d.ts.map +1 -0
- package/dist/api-server.js +536 -0
- package/dist/api-server.js.map +1 -0
- package/dist/auth-middleware.d.ts +31 -0
- package/dist/auth-middleware.d.ts.map +1 -0
- package/dist/auth-middleware.js +35 -0
- package/dist/auth-middleware.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +65 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +137 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +412 -0
- package/dist/client.js.map +1 -0
- package/dist/file-service.d.ts +94 -0
- package/dist/file-service.d.ts.map +1 -0
- package/dist/file-service.js +203 -0
- package/dist/file-service.js.map +1 -0
- package/dist/http-exposure-service.d.ts +71 -0
- package/dist/http-exposure-service.d.ts.map +1 -0
- package/dist/http-exposure-service.js +172 -0
- package/dist/http-exposure-service.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox-manager.d.ts +76 -0
- package/dist/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox-manager.js +161 -0
- package/dist/sandbox-manager.js.map +1 -0
- package/dist/sandbox.d.ts +118 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +303 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +240 -0
- package/dist/server.js.map +1 -0
- package/dist/stream-service.d.ts +35 -0
- package/dist/stream-service.d.ts.map +1 -0
- package/dist/stream-service.js +136 -0
- package/dist/stream-service.js.map +1 -0
- package/dist/terminal.d.ts +46 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +264 -0
- package/dist/terminal.js.map +1 -0
- package/dist/websocket.d.ts +48 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +332 -0
- package/dist/websocket.js.map +1 -0
- package/package.json +59 -0
- package/src/api-server.ts +658 -0
- package/src/auth-middleware.ts +65 -0
- package/src/cli.ts +71 -0
- package/src/client.ts +537 -0
- package/src/file-service.ts +273 -0
- package/src/http-exposure-service.ts +232 -0
- package/src/index.ts +101 -0
- package/src/sandbox-manager.ts +202 -0
- package/src/sandbox.ts +396 -0
- package/src/stream-service.ts +174 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { SandboxManager } from './sandbox-manager';
|
|
4
|
+
import { FileService } from './file-service';
|
|
5
|
+
import { StreamService } from './stream-service';
|
|
6
|
+
import { stream } from 'hono/streaming';
|
|
7
|
+
import { Readable } from 'stream';
|
|
8
|
+
import { createAuthMiddleware, parseApiKeysFromEnv } from './auth-middleware';
|
|
9
|
+
import { HttpExposureService } from './http-exposure-service';
|
|
10
|
+
|
|
11
|
+
export interface ServerConfig {
|
|
12
|
+
port?: number;
|
|
13
|
+
host?: string;
|
|
14
|
+
basePath?: string;
|
|
15
|
+
maxSandboxes?: number;
|
|
16
|
+
cors?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* API keys for authentication. If empty or not provided, authentication is disabled.
|
|
19
|
+
* Can also be set via SANDBOX_API_KEYS environment variable (comma-separated).
|
|
20
|
+
*/
|
|
21
|
+
apiKeys?: string[];
|
|
22
|
+
/**
|
|
23
|
+
* HTTP exposure configuration for Caddy integration
|
|
24
|
+
*/
|
|
25
|
+
httpExposure?: {
|
|
26
|
+
caddyAdminUrl?: string;
|
|
27
|
+
baseDomain?: string;
|
|
28
|
+
protocol?: 'http' | 'https';
|
|
29
|
+
upstreamHost?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create and configure the API server
|
|
35
|
+
*/
|
|
36
|
+
export function createServer(config: ServerConfig = {}) {
|
|
37
|
+
const app = new Hono();
|
|
38
|
+
const manager = new SandboxManager({
|
|
39
|
+
basePath: config.basePath,
|
|
40
|
+
maxSandboxes: config.maxSandboxes,
|
|
41
|
+
});
|
|
42
|
+
const httpExposure = new HttpExposureService(config.httpExposure || {});
|
|
43
|
+
|
|
44
|
+
// CORS middleware if enabled
|
|
45
|
+
if (config.cors) {
|
|
46
|
+
app.use('*', async (c, next) => {
|
|
47
|
+
await next();
|
|
48
|
+
c.header('Access-Control-Allow-Origin', '*');
|
|
49
|
+
c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
50
|
+
c.header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// API key authentication middleware
|
|
55
|
+
const apiKeys = config.apiKeys || parseApiKeysFromEnv();
|
|
56
|
+
if (apiKeys.length > 0) {
|
|
57
|
+
app.use('*', createAuthMiddleware({
|
|
58
|
+
apiKeys,
|
|
59
|
+
excludePaths: ['/'], // Health check doesn't require auth
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Health check
|
|
64
|
+
app.get('/', (c) => {
|
|
65
|
+
return c.json({
|
|
66
|
+
status: 'ok',
|
|
67
|
+
service: 'TreeSap Sandbox',
|
|
68
|
+
version: '1.0.0',
|
|
69
|
+
stats: manager.getStats(),
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Sandbox Management Endpoints
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a new sandbox
|
|
79
|
+
* POST /sandbox
|
|
80
|
+
*/
|
|
81
|
+
app.post('/sandbox', async (c) => {
|
|
82
|
+
try {
|
|
83
|
+
const body = await c.req.json().catch(() => ({}));
|
|
84
|
+
const sandbox = await manager.createSandbox(body);
|
|
85
|
+
|
|
86
|
+
return c.json({
|
|
87
|
+
id: sandbox.id,
|
|
88
|
+
workDir: sandbox.workDir,
|
|
89
|
+
createdAt: sandbox.createdAt,
|
|
90
|
+
}, 201);
|
|
91
|
+
} catch (error: any) {
|
|
92
|
+
return c.json({ error: error.message }, 400);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List all sandboxes
|
|
98
|
+
* GET /sandbox
|
|
99
|
+
*/
|
|
100
|
+
app.get('/sandbox', (c) => {
|
|
101
|
+
const sandboxes = manager.listSandboxes();
|
|
102
|
+
return c.json({ sandboxes });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get sandbox info
|
|
107
|
+
* GET /sandbox/:id
|
|
108
|
+
*/
|
|
109
|
+
app.get('/sandbox/:id', (c) => {
|
|
110
|
+
const id = c.req.param('id');
|
|
111
|
+
const sandbox = manager.getSandbox(id);
|
|
112
|
+
|
|
113
|
+
if (!sandbox) {
|
|
114
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return c.json(sandbox.getStatus());
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Destroy a sandbox
|
|
122
|
+
* DELETE /sandbox/:id
|
|
123
|
+
*/
|
|
124
|
+
app.delete('/sandbox/:id', async (c) => {
|
|
125
|
+
try {
|
|
126
|
+
const id = c.req.param('id');
|
|
127
|
+
const cleanup = c.req.query('cleanup') === 'true';
|
|
128
|
+
|
|
129
|
+
await manager.destroySandbox(id, { cleanup });
|
|
130
|
+
return c.json({ success: true });
|
|
131
|
+
} catch (error: any) {
|
|
132
|
+
return c.json({ error: error.message }, 404);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// Command Execution Endpoints
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Execute a command
|
|
142
|
+
* POST /sandbox/:id/exec
|
|
143
|
+
*/
|
|
144
|
+
app.post('/sandbox/:id/exec', async (c) => {
|
|
145
|
+
try {
|
|
146
|
+
const id = c.req.param('id');
|
|
147
|
+
const sandbox = manager.getSandbox(id);
|
|
148
|
+
|
|
149
|
+
if (!sandbox) {
|
|
150
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const body = await c.req.json();
|
|
154
|
+
const { command, timeout, cwd, env } = body;
|
|
155
|
+
|
|
156
|
+
if (!command) {
|
|
157
|
+
return c.json({ error: 'Command is required' }, 400);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = await sandbox.exec(command, { timeout, cwd, env });
|
|
161
|
+
return c.json(result);
|
|
162
|
+
} catch (error: any) {
|
|
163
|
+
return c.json({ error: error.message }, 500);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Execute a command with streaming output
|
|
169
|
+
* GET /sandbox/:id/exec-stream?command=...
|
|
170
|
+
*/
|
|
171
|
+
app.get('/sandbox/:id/exec-stream', (c) => {
|
|
172
|
+
const id = c.req.param('id');
|
|
173
|
+
const sandbox = manager.getSandbox(id);
|
|
174
|
+
|
|
175
|
+
if (!sandbox) {
|
|
176
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const command = c.req.query('command');
|
|
180
|
+
if (!command) {
|
|
181
|
+
return c.json({ error: 'Command is required' }, 400);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const timeout = c.req.query('timeout');
|
|
185
|
+
const options = timeout ? { timeout: parseInt(timeout) } : {};
|
|
186
|
+
|
|
187
|
+
return stream(c, async (stream) => {
|
|
188
|
+
const execStream = StreamService.createExecStream(sandbox, command, options);
|
|
189
|
+
|
|
190
|
+
// Set SSE headers
|
|
191
|
+
c.header('Content-Type', 'text/event-stream');
|
|
192
|
+
c.header('Cache-Control', 'no-cache');
|
|
193
|
+
c.header('Connection', 'keep-alive');
|
|
194
|
+
|
|
195
|
+
// Pipe the readable stream to the response
|
|
196
|
+
for await (const chunk of execStream) {
|
|
197
|
+
await stream.write(chunk);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// Process Management Endpoints
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Start a background process
|
|
208
|
+
* POST /sandbox/:id/process
|
|
209
|
+
*/
|
|
210
|
+
app.post('/sandbox/:id/process', async (c) => {
|
|
211
|
+
try {
|
|
212
|
+
const id = c.req.param('id');
|
|
213
|
+
const sandbox = manager.getSandbox(id);
|
|
214
|
+
|
|
215
|
+
if (!sandbox) {
|
|
216
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const body = await c.req.json();
|
|
220
|
+
const { command, cwd, env } = body;
|
|
221
|
+
|
|
222
|
+
if (!command) {
|
|
223
|
+
return c.json({ error: 'Command is required' }, 400);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const processInfo = await sandbox.startProcess(command, { cwd, env });
|
|
227
|
+
return c.json(processInfo, 201);
|
|
228
|
+
} catch (error: any) {
|
|
229
|
+
return c.json({ error: error.message }, 500);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* List all processes in a sandbox
|
|
235
|
+
* GET /sandbox/:id/process
|
|
236
|
+
*/
|
|
237
|
+
app.get('/sandbox/:id/process', (c) => {
|
|
238
|
+
const id = c.req.param('id');
|
|
239
|
+
const sandbox = manager.getSandbox(id);
|
|
240
|
+
|
|
241
|
+
if (!sandbox) {
|
|
242
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const processes = sandbox.listProcesses();
|
|
246
|
+
return c.json({ processes });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get process info
|
|
251
|
+
* GET /sandbox/:id/process/:processId
|
|
252
|
+
*/
|
|
253
|
+
app.get('/sandbox/:id/process/:processId', (c) => {
|
|
254
|
+
const id = c.req.param('id');
|
|
255
|
+
const processId = c.req.param('processId');
|
|
256
|
+
const sandbox = manager.getSandbox(id);
|
|
257
|
+
|
|
258
|
+
if (!sandbox) {
|
|
259
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const process = sandbox.getProcess(processId);
|
|
263
|
+
if (!process) {
|
|
264
|
+
return c.json({ error: 'Process not found' }, 404);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return c.json(process);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Kill a process
|
|
272
|
+
* DELETE /sandbox/:id/process/:processId
|
|
273
|
+
*/
|
|
274
|
+
app.delete('/sandbox/:id/process/:processId', async (c) => {
|
|
275
|
+
try {
|
|
276
|
+
const id = c.req.param('id');
|
|
277
|
+
const processId = c.req.param('processId');
|
|
278
|
+
const sandbox = manager.getSandbox(id);
|
|
279
|
+
|
|
280
|
+
if (!sandbox) {
|
|
281
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const signal = c.req.query('signal') || 'SIGTERM';
|
|
285
|
+
await sandbox.killProcess(processId, signal);
|
|
286
|
+
|
|
287
|
+
return c.json({ success: true });
|
|
288
|
+
} catch (error: any) {
|
|
289
|
+
return c.json({ error: error.message }, 404);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Stream process logs
|
|
295
|
+
* GET /sandbox/:id/process/:processId/logs
|
|
296
|
+
*/
|
|
297
|
+
app.get('/sandbox/:id/process/:processId/logs', (c) => {
|
|
298
|
+
const id = c.req.param('id');
|
|
299
|
+
const processId = c.req.param('processId');
|
|
300
|
+
const sandbox = manager.getSandbox(id);
|
|
301
|
+
|
|
302
|
+
if (!sandbox) {
|
|
303
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return stream(c, async (stream) => {
|
|
307
|
+
const logStream = StreamService.createProcessLogStream(sandbox, processId);
|
|
308
|
+
|
|
309
|
+
c.header('Content-Type', 'text/event-stream');
|
|
310
|
+
c.header('Cache-Control', 'no-cache');
|
|
311
|
+
c.header('Connection', 'keep-alive');
|
|
312
|
+
|
|
313
|
+
for await (const chunk of logStream) {
|
|
314
|
+
await stream.write(chunk);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ============================================================================
|
|
320
|
+
// File Operations Endpoints
|
|
321
|
+
// ============================================================================
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* List files in a directory
|
|
325
|
+
* GET /sandbox/:id/files?path=...&recursive=true
|
|
326
|
+
*/
|
|
327
|
+
app.get('/sandbox/:id/files', async (c) => {
|
|
328
|
+
try {
|
|
329
|
+
const id = c.req.param('id');
|
|
330
|
+
const sandbox = manager.getSandbox(id);
|
|
331
|
+
|
|
332
|
+
if (!sandbox) {
|
|
333
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const fileService = new FileService(sandbox.workDir);
|
|
337
|
+
const dirPath = c.req.query('path') || '.';
|
|
338
|
+
const recursive = c.req.query('recursive') === 'true';
|
|
339
|
+
const pattern = c.req.query('pattern');
|
|
340
|
+
const includeHidden = c.req.query('hidden') === 'true';
|
|
341
|
+
|
|
342
|
+
const files = await fileService.listFiles(dirPath, {
|
|
343
|
+
recursive,
|
|
344
|
+
pattern,
|
|
345
|
+
includeHidden,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
return c.json({ files });
|
|
349
|
+
} catch (error: any) {
|
|
350
|
+
return c.json({ error: error.message }, 500);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Read a file
|
|
356
|
+
* GET /sandbox/:id/files/*
|
|
357
|
+
*/
|
|
358
|
+
app.get('/sandbox/:id/files/*', async (c) => {
|
|
359
|
+
try {
|
|
360
|
+
const id = c.req.param('id');
|
|
361
|
+
const sandbox = manager.getSandbox(id);
|
|
362
|
+
|
|
363
|
+
if (!sandbox) {
|
|
364
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Get the file path from the wildcard
|
|
368
|
+
const fullPath = c.req.path;
|
|
369
|
+
const filePath = fullPath.replace(`/sandbox/${id}/files/`, '');
|
|
370
|
+
|
|
371
|
+
if (!filePath) {
|
|
372
|
+
return c.json({ error: 'File path is required' }, 400);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const fileService = new FileService(sandbox.workDir);
|
|
376
|
+
const raw = c.req.query('raw') === 'true';
|
|
377
|
+
|
|
378
|
+
if (raw) {
|
|
379
|
+
// Return raw file as stream
|
|
380
|
+
const stream = fileService.createReadStream(filePath);
|
|
381
|
+
return c.body(stream as any);
|
|
382
|
+
} else {
|
|
383
|
+
// Return file content as JSON
|
|
384
|
+
const content = await fileService.readFile(filePath);
|
|
385
|
+
return c.json({ content });
|
|
386
|
+
}
|
|
387
|
+
} catch (error: any) {
|
|
388
|
+
return c.json({ error: error.message }, 500);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Write a file
|
|
394
|
+
* POST /sandbox/:id/files/*
|
|
395
|
+
*/
|
|
396
|
+
app.post('/sandbox/:id/files/*', async (c) => {
|
|
397
|
+
try {
|
|
398
|
+
const id = c.req.param('id');
|
|
399
|
+
const sandbox = manager.getSandbox(id);
|
|
400
|
+
|
|
401
|
+
if (!sandbox) {
|
|
402
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const fullPath = c.req.path;
|
|
406
|
+
const filePath = fullPath.replace(`/sandbox/${id}/files/`, '');
|
|
407
|
+
|
|
408
|
+
if (!filePath) {
|
|
409
|
+
return c.json({ error: 'File path is required' }, 400);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const body = await c.req.json();
|
|
413
|
+
const { content } = body;
|
|
414
|
+
|
|
415
|
+
if (content === undefined) {
|
|
416
|
+
return c.json({ error: 'Content is required' }, 400);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const fileService = new FileService(sandbox.workDir);
|
|
420
|
+
await fileService.writeFile(filePath, content, { createDirs: true });
|
|
421
|
+
|
|
422
|
+
return c.json({ success: true, path: filePath });
|
|
423
|
+
} catch (error: any) {
|
|
424
|
+
return c.json({ error: error.message }, 500);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Delete a file
|
|
430
|
+
* DELETE /sandbox/:id/files/*
|
|
431
|
+
*/
|
|
432
|
+
app.delete('/sandbox/:id/files/*', async (c) => {
|
|
433
|
+
try {
|
|
434
|
+
const id = c.req.param('id');
|
|
435
|
+
const sandbox = manager.getSandbox(id);
|
|
436
|
+
|
|
437
|
+
if (!sandbox) {
|
|
438
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const fullPath = c.req.path;
|
|
442
|
+
const filePath = fullPath.replace(`/sandbox/${id}/files/`, '');
|
|
443
|
+
|
|
444
|
+
if (!filePath) {
|
|
445
|
+
return c.json({ error: 'File path is required' }, 400);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const fileService = new FileService(sandbox.workDir);
|
|
449
|
+
const recursive = c.req.query('recursive') === 'true';
|
|
450
|
+
|
|
451
|
+
await fileService.deleteFile(filePath, { recursive });
|
|
452
|
+
|
|
453
|
+
return c.json({ success: true });
|
|
454
|
+
} catch (error: any) {
|
|
455
|
+
return c.json({ error: error.message }, 500);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// Environment Variable Endpoints
|
|
461
|
+
// ============================================================================
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Set environment variables
|
|
465
|
+
* POST /sandbox/:id/env
|
|
466
|
+
* Body: { variables: { KEY: "value", ... } }
|
|
467
|
+
*/
|
|
468
|
+
app.post('/sandbox/:id/env', async (c) => {
|
|
469
|
+
try {
|
|
470
|
+
const id = c.req.param('id');
|
|
471
|
+
const sandbox = manager.getSandbox(id);
|
|
472
|
+
|
|
473
|
+
if (!sandbox) {
|
|
474
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const body = await c.req.json();
|
|
478
|
+
const { variables } = body;
|
|
479
|
+
|
|
480
|
+
if (!variables || typeof variables !== 'object') {
|
|
481
|
+
return c.json({ error: 'Variables object is required' }, 400);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
sandbox.setEnvBatch(variables);
|
|
485
|
+
|
|
486
|
+
return c.json({
|
|
487
|
+
success: true,
|
|
488
|
+
count: Object.keys(variables).length,
|
|
489
|
+
});
|
|
490
|
+
} catch (error: any) {
|
|
491
|
+
return c.json({ error: error.message }, 500);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Get environment variable names (not values for security)
|
|
497
|
+
* GET /sandbox/:id/env
|
|
498
|
+
*/
|
|
499
|
+
app.get('/sandbox/:id/env', (c) => {
|
|
500
|
+
const id = c.req.param('id');
|
|
501
|
+
const sandbox = manager.getSandbox(id);
|
|
502
|
+
|
|
503
|
+
if (!sandbox) {
|
|
504
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const keys = sandbox.getEnvKeys();
|
|
508
|
+
return c.json({ variables: keys, count: keys.length });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Unset an environment variable
|
|
513
|
+
* DELETE /sandbox/:id/env/:key
|
|
514
|
+
*/
|
|
515
|
+
app.delete('/sandbox/:id/env/:key', (c) => {
|
|
516
|
+
try {
|
|
517
|
+
const id = c.req.param('id');
|
|
518
|
+
const key = c.req.param('key');
|
|
519
|
+
const sandbox = manager.getSandbox(id);
|
|
520
|
+
|
|
521
|
+
if (!sandbox) {
|
|
522
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const removed = sandbox.unsetEnv(key);
|
|
526
|
+
|
|
527
|
+
if (!removed) {
|
|
528
|
+
return c.json({ error: 'Environment variable not found' }, 404);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return c.json({ success: true, key });
|
|
532
|
+
} catch (error: any) {
|
|
533
|
+
return c.json({ error: error.message }, 500);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ============================================================================
|
|
538
|
+
// HTTP Exposure Endpoints
|
|
539
|
+
// ============================================================================
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Expose a sandbox port via HTTP
|
|
543
|
+
* POST /sandbox/:id/expose
|
|
544
|
+
* Body: { port: 3000 }
|
|
545
|
+
*/
|
|
546
|
+
app.post('/sandbox/:id/expose', async (c) => {
|
|
547
|
+
try {
|
|
548
|
+
const id = c.req.param('id');
|
|
549
|
+
const sandbox = manager.getSandbox(id);
|
|
550
|
+
|
|
551
|
+
if (!sandbox) {
|
|
552
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const body = await c.req.json();
|
|
556
|
+
const { port } = body;
|
|
557
|
+
|
|
558
|
+
if (!port || typeof port !== 'number') {
|
|
559
|
+
return c.json({ error: 'Port number is required' }, 400);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const url = await httpExposure.expose(id, port);
|
|
563
|
+
|
|
564
|
+
return c.json({ url, port, sandboxId: id });
|
|
565
|
+
} catch (error: any) {
|
|
566
|
+
return c.json({ error: error.message }, 500);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Get all exposed endpoints for a sandbox
|
|
572
|
+
* GET /sandbox/:id/expose
|
|
573
|
+
*/
|
|
574
|
+
app.get('/sandbox/:id/expose', (c) => {
|
|
575
|
+
const id = c.req.param('id');
|
|
576
|
+
const sandbox = manager.getSandbox(id);
|
|
577
|
+
|
|
578
|
+
if (!sandbox) {
|
|
579
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const exposures = httpExposure.getExposures(id);
|
|
583
|
+
return c.json({ exposures });
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Remove HTTP exposure for a sandbox port
|
|
588
|
+
* DELETE /sandbox/:id/expose?port=3000
|
|
589
|
+
*/
|
|
590
|
+
app.delete('/sandbox/:id/expose', async (c) => {
|
|
591
|
+
try {
|
|
592
|
+
const id = c.req.param('id');
|
|
593
|
+
const sandbox = manager.getSandbox(id);
|
|
594
|
+
|
|
595
|
+
if (!sandbox) {
|
|
596
|
+
return c.json({ error: 'Sandbox not found' }, 404);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const portStr = c.req.query('port');
|
|
600
|
+
const port = portStr ? parseInt(portStr) : undefined;
|
|
601
|
+
|
|
602
|
+
await httpExposure.unexpose(id, port);
|
|
603
|
+
|
|
604
|
+
return c.json({ success: true });
|
|
605
|
+
} catch (error: any) {
|
|
606
|
+
return c.json({ error: error.message }, 500);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Cleanup on process exit
|
|
611
|
+
process.on('SIGINT', async () => {
|
|
612
|
+
console.log('\nShutting down...');
|
|
613
|
+
console.log('Cleaning up sandboxes...');
|
|
614
|
+
await manager.shutdown({ cleanup: true });
|
|
615
|
+
console.log('✅ Shutdown complete');
|
|
616
|
+
process.exit(0);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
process.on('SIGTERM', async () => {
|
|
620
|
+
console.log('\nShutting down...');
|
|
621
|
+
console.log('Cleaning up sandboxes...');
|
|
622
|
+
await manager.shutdown({ cleanup: true });
|
|
623
|
+
console.log('✅ Shutdown complete');
|
|
624
|
+
process.exit(0);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
return { app, manager, httpExposure };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Start the sandbox server
|
|
632
|
+
*/
|
|
633
|
+
export async function startServer(config: ServerConfig = {}) {
|
|
634
|
+
const port = config.port || 3000;
|
|
635
|
+
const host = config.host || '0.0.0.0';
|
|
636
|
+
|
|
637
|
+
const { app, manager } = createServer(config);
|
|
638
|
+
|
|
639
|
+
console.log(`🌳 TreeSap Sandbox Server starting...`);
|
|
640
|
+
console.log(`📁 Base path: ${manager.getStats().basePath}`);
|
|
641
|
+
console.log(`🚀 Server listening on http://${host}:${port}`);
|
|
642
|
+
|
|
643
|
+
const server = serve({
|
|
644
|
+
fetch: app.fetch,
|
|
645
|
+
port,
|
|
646
|
+
hostname: host,
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
return { server, app, manager };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Start server if this file is run directly
|
|
653
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
654
|
+
startServer().catch((error) => {
|
|
655
|
+
console.error('Failed to start server:', error);
|
|
656
|
+
process.exit(1);
|
|
657
|
+
});
|
|
658
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Context, Next } from 'hono';
|
|
2
|
+
|
|
3
|
+
export interface AuthConfig {
|
|
4
|
+
/**
|
|
5
|
+
* List of valid API keys
|
|
6
|
+
*/
|
|
7
|
+
apiKeys: string[];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Header name to check for API key (default: 'X-API-Key')
|
|
11
|
+
*/
|
|
12
|
+
headerName?: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Paths to exclude from authentication (default: ['/'])
|
|
16
|
+
*/
|
|
17
|
+
excludePaths?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create authentication middleware for API key validation
|
|
22
|
+
*/
|
|
23
|
+
export function createAuthMiddleware(config: AuthConfig) {
|
|
24
|
+
const headerName = config.headerName || 'X-API-Key';
|
|
25
|
+
const excludePaths = config.excludePaths || ['/'];
|
|
26
|
+
|
|
27
|
+
return async (c: Context, next: Next) => {
|
|
28
|
+
// Skip auth for excluded paths (exact match)
|
|
29
|
+
if (excludePaths.includes(c.req.path)) {
|
|
30
|
+
return next();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Get API key from header
|
|
34
|
+
const apiKey = c.req.header(headerName);
|
|
35
|
+
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
return c.json(
|
|
38
|
+
{ error: 'API key required', message: `Missing ${headerName} header` },
|
|
39
|
+
401
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!config.apiKeys.includes(apiKey)) {
|
|
44
|
+
return c.json(
|
|
45
|
+
{ error: 'Invalid API key', message: 'The provided API key is not valid' },
|
|
46
|
+
403
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// API key is valid, continue to the next middleware/handler
|
|
51
|
+
return next();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse API keys from environment variable
|
|
57
|
+
* Supports comma-separated list of keys
|
|
58
|
+
*/
|
|
59
|
+
export function parseApiKeysFromEnv(envVar: string = 'SANDBOX_API_KEYS'): string[] {
|
|
60
|
+
const keysString = process.env[envVar] || '';
|
|
61
|
+
return keysString
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((key) => key.trim())
|
|
64
|
+
.filter((key) => key.length > 0);
|
|
65
|
+
}
|