@axiom-lattice/opensandbox-gateway 0.1.2

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.
@@ -0,0 +1,576 @@
1
+ import { ConnectionConfig, Sandbox, type Volume } from "@alibaba-group/opensandbox";
2
+ import { SandboxManager, type SandboxFilter } from "@alibaba-group/opensandbox";
3
+ import { SandboxException } from "@alibaba-group/opensandbox";
4
+ import type { RuntimeService } from "../types/runtime-service";
5
+ import type { EnsureSandboxInput, ListSandboxesQuery, ShellExecInput } from "../schemas/sandbox";
6
+ import type { SandboxLogsInput } from "../schemas/sandbox";
7
+ import type { SandboxRuntimeMetrics } from "../types/runtime-service";
8
+ import { HttpError } from "../lib/errors";
9
+
10
+ type CachedEntry = {
11
+ sandbox: Sandbox;
12
+ sandboxId: string;
13
+ createdAt: string;
14
+ lastUsedAt: number;
15
+ };
16
+
17
+ type VolumeDefinition = NonNullable<EnsureSandboxInput["volumes"]>[string];
18
+
19
+ const NAME_METADATA_KEY = "axiom-lattice/name";
20
+
21
+ function getEnv(key: string, fallback: string): string {
22
+ return process.env[key] ?? fallback;
23
+ }
24
+
25
+ function getEnvNumber(key: string, fallback: number): number {
26
+ const v = process.env[key];
27
+ return v ? Number(v) : fallback;
28
+ }
29
+
30
+ export class OpenSandboxRuntimeService implements RuntimeService {
31
+ private cache = new Map<string, CachedEntry>();
32
+ private pendingCreates = new Map<string, Promise<CachedEntry>>();
33
+ private connectionConfig: ConnectionConfig;
34
+ private defaultImage: string;
35
+ private defaultCpus: number;
36
+ private defaultMemoryMib: number;
37
+ private defaultTimeout: number;
38
+ private idleTimeoutMs: number;
39
+
40
+ constructor() {
41
+ this.connectionConfig = new ConnectionConfig({
42
+ domain: getEnv("OPEN_SANDBOX_DOMAIN", "localhost:8080"),
43
+ protocol: getEnv("OPEN_SANDBOX_PROTOCOL", "http") as "http" | "https",
44
+ apiKey: process.env["OPEN_SANDBOX_API_KEY"],
45
+ useServerProxy: getEnv("OPEN_SANDBOX_USE_SERVER_PROXY", "false") === "true",
46
+ });
47
+
48
+ this.defaultImage = getEnv("OPEN_SANDBOX_DEFAULT_IMAGE", "ubuntu:22.04");
49
+ this.defaultCpus = getEnvNumber("OPEN_SANDBOX_DEFAULT_CPUS", 1);
50
+ this.defaultMemoryMib = getEnvNumber("OPEN_SANDBOX_DEFAULT_MEMORY_MIB", 2048);
51
+ this.defaultTimeout = getEnvNumber("OPEN_SANDBOX_DEFAULT_TIMEOUT", 600);
52
+ this.idleTimeoutMs = getEnvNumber("SANDBOX_IDLE_TIMEOUT_MS", 600_000);
53
+ }
54
+
55
+ private mapVolumes(volumes?: Record<string, VolumeDefinition>): Volume[] {
56
+ if (!volumes) return [];
57
+ return Object.entries(volumes).map(([mountPath, def]) => {
58
+ if (def.type === "bind" && def.source) {
59
+ return { name: mountPath, host: { path: def.source }, mountPath, readOnly: def.readonly };
60
+ }
61
+ if (def.type === "named" && def.name) {
62
+ return { name: mountPath, pvc: { claimName: def.name }, mountPath, readOnly: def.readonly };
63
+ }
64
+ if (def.type === "tmpfs") {
65
+ throw new HttpError(400, "INVALID_REQUEST", "tmpfs volumes are not supported by OpenSandbox");
66
+ }
67
+ throw new HttpError(400, "INVALID_REQUEST", `Unsupported volume type at ${mountPath}`);
68
+ });
69
+ }
70
+
71
+ private getCached(name: string): CachedEntry {
72
+ const entry = this.cache.get(name);
73
+ if (!entry) {
74
+ throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${name}' not found`);
75
+ }
76
+ return entry;
77
+ }
78
+
79
+ private async findByNameViaMetadata(name: string): Promise<CachedEntry | null> {
80
+ try {
81
+ const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
82
+ const result = await manager.listSandboxInfos({
83
+ metadata: { [NAME_METADATA_KEY]: name },
84
+ pageSize: 1,
85
+ });
86
+
87
+ if (result.items.length === 0) {
88
+ return null;
89
+ }
90
+
91
+ const info = result.items[0];
92
+ const sandbox = await Sandbox.resume({
93
+ sandboxId: info.id,
94
+ connectionConfig: this.connectionConfig,
95
+ });
96
+
97
+ const entry: CachedEntry = {
98
+ sandbox,
99
+ sandboxId: info.id,
100
+ createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : new Date().toISOString(),
101
+ lastUsedAt: Date.now(),
102
+ };
103
+ this.cache.set(name, entry);
104
+ return entry;
105
+ } catch (err) {
106
+ if (err instanceof SandboxException && err.error.code === "NOT_FOUND") {
107
+ return null;
108
+ }
109
+ console.warn(`[OpenSandboxRuntimeService] metadata lookup for name=${name} failed:`, err);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ async ensureSandbox(
115
+ name: string,
116
+ input: EnsureSandboxInput
117
+ ): Promise<{ name: string; status: string }> {
118
+ // 1. Check cache
119
+ const cached = this.cache.get(name);
120
+ if (cached) {
121
+ try {
122
+ const info = await cached.sandbox.getInfo();
123
+ if (info.status.state === "Running") {
124
+ cached.lastUsedAt = Date.now();
125
+ await cached.sandbox.renew(this.defaultTimeout).catch(() => {
126
+ });
127
+ return { name, status: "running" };
128
+ }
129
+ this.cache.delete(name);
130
+ } catch {
131
+ this.cache.delete(name);
132
+ }
133
+ }
134
+
135
+ // 2. Dedup concurrent creates for same name
136
+ const inflight = this.pendingCreates.get(name);
137
+ if (inflight) {
138
+ await inflight;
139
+ return { name, status: "running" };
140
+ }
141
+
142
+ // 3. Try metadata recovery (crash recovery)
143
+ const recovered = await this.findByNameViaMetadata(name);
144
+ if (recovered) {
145
+ return { name, status: "running" };
146
+ }
147
+
148
+ // 4. Create new sandbox
149
+ const creation = this.doCreateSandbox(name, input);
150
+ this.pendingCreates.set(name, creation);
151
+ try {
152
+ await creation;
153
+ return { name, status: "running" };
154
+ } finally {
155
+ this.pendingCreates.delete(name);
156
+ }
157
+ }
158
+
159
+ private async doCreateSandbox(
160
+ name: string,
161
+ input: EnsureSandboxInput
162
+ ): Promise<CachedEntry> {
163
+ const sandbox = await Sandbox.create({
164
+ connectionConfig: this.connectionConfig,
165
+ image: input.image ?? this.defaultImage,
166
+ timeoutSeconds: this.defaultTimeout,
167
+ resource: {
168
+ cpu: String(input.cpus ?? this.defaultCpus),
169
+ memory: `${input.memoryMib ?? this.defaultMemoryMib}Mi`,
170
+ },
171
+ env: input.env ?? {},
172
+ metadata: { [NAME_METADATA_KEY]: name },
173
+ volumes: this.mapVolumes(input.volumes),
174
+ entrypoint: ["tail", "-f", "/dev/null"],
175
+ });
176
+
177
+ const info = await sandbox.getInfo();
178
+
179
+ const entry: CachedEntry = {
180
+ sandbox,
181
+ sandboxId: info.id,
182
+ createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : new Date().toISOString(),
183
+ lastUsedAt: Date.now(),
184
+ };
185
+ this.cache.set(name, entry);
186
+ return entry;
187
+ }
188
+
189
+ private wrapError(err: unknown, code: string, message: string): never {
190
+ if (err instanceof HttpError) throw err;
191
+ if (err instanceof SandboxException) {
192
+ throw new HttpError(
193
+ err.error.code === "NOT_FOUND" ? 404
194
+ : err.error.code === "FORBIDDEN" ? 403
195
+ : err.error.code === "CONFLICT" ? 409
196
+ : err.error.code === "INVALID_REQUEST" ? 400
197
+ : 500,
198
+ err.error.code,
199
+ err.error.message ?? message
200
+ );
201
+ }
202
+ throw new HttpError(500, code, message);
203
+ }
204
+
205
+ async startSandbox(name: string): Promise<{ name: string; status: string }> {
206
+ const entry = this.getCached(name);
207
+ try {
208
+ const resumed = await Sandbox.resume({
209
+ sandboxId: entry.sandboxId,
210
+ connectionConfig: this.connectionConfig,
211
+ });
212
+ // Update cache with new Sandbox instance (sandboxId may change on K8s)
213
+ const info = await resumed.getInfo();
214
+ entry.sandbox = resumed;
215
+ entry.sandboxId = info.id;
216
+ entry.lastUsedAt = Date.now();
217
+ return { name, status: "running" };
218
+ } catch (err) {
219
+ throw this.wrapError(err, "SANDBOX_NOT_FOUND", `Failed to start sandbox '${name}'`);
220
+ }
221
+ }
222
+
223
+ async stopSandbox(name: string): Promise<{ name: string; status: string }> {
224
+ const entry = this.getCached(name);
225
+ try {
226
+ await entry.sandbox.pause();
227
+ return { name, status: "stopped" };
228
+ } catch (err) {
229
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to stop sandbox '${name}'`);
230
+ }
231
+ }
232
+
233
+ async killSandbox(name: string): Promise<{ name: string; status: string }> {
234
+ const entry = this.cache.get(name);
235
+ if (!entry) {
236
+ throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${name}' not found`);
237
+ }
238
+ try {
239
+ await entry.sandbox.kill();
240
+ } catch {
241
+ // kill failure is non-fatal
242
+ }
243
+ try {
244
+ await entry.sandbox.close();
245
+ } catch {
246
+ // close failure is non-fatal
247
+ }
248
+ this.cache.delete(name);
249
+ return { name, status: "unknown" };
250
+ }
251
+
252
+ async deleteSandbox(name: string): Promise<{ name: string; status: string }> {
253
+ return this.killSandbox(name);
254
+ }
255
+
256
+ async getStatus(name: string): Promise<{ name: string; status: string }> {
257
+ const entry = this.cache.get(name);
258
+ if (entry) {
259
+ try {
260
+ const info = await entry.sandbox.getInfo();
261
+ const status = info.status.state.toLowerCase();
262
+ return { name, status };
263
+ } catch {
264
+ return { name, status: "unknown" };
265
+ }
266
+ }
267
+ // Try server-side lookup
268
+ try {
269
+ const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
270
+ const result = await manager.listSandboxInfos({
271
+ metadata: { [NAME_METADATA_KEY]: name },
272
+ pageSize: 1,
273
+ });
274
+ if (result.items.length > 0) {
275
+ return { name, status: result.items[0].status.state.toLowerCase() };
276
+ }
277
+ } catch {
278
+ // ignore
279
+ }
280
+ return { name, status: "unknown" };
281
+ }
282
+
283
+ private resolvePath(path: string): string {
284
+ if (path === "~" || path === "~/") return "/";
285
+ if (path.startsWith("~/")) return `/${path.slice(2)}`;
286
+ return path;
287
+ }
288
+
289
+ async readFile(sandboxName: string, path: string): Promise<{ path: string; content: string }> {
290
+ const resolvedPath = this.resolvePath(path);
291
+ const entry = this.getCached(sandboxName);
292
+ try {
293
+ const content = await entry.sandbox.files.readFile(resolvedPath);
294
+ return { path: resolvedPath, content };
295
+ } catch (err) {
296
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to read file '${resolvedPath}' in sandbox '${sandboxName}'`);
297
+ }
298
+ }
299
+
300
+ async writeFile(sandboxName: string, path: string, content: string): Promise<{ path: string }> {
301
+ const resolvedPath = this.resolvePath(path);
302
+ const entry = this.getCached(sandboxName);
303
+ try {
304
+ // Create parent directories if needed
305
+ const parentDir = resolvedPath.split("/").slice(0, -1).join("/") || "/";
306
+ await entry.sandbox.files.createDirectories([{ path: parentDir }]).catch(() => {
307
+ // parent may already exist
308
+ });
309
+ await entry.sandbox.files.writeFiles([{ path: resolvedPath, data: content }]);
310
+ return { path: resolvedPath };
311
+ } catch (err) {
312
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to write file '${resolvedPath}' in sandbox '${sandboxName}'`);
313
+ }
314
+ }
315
+
316
+ async listPath(
317
+ sandboxName: string,
318
+ path: string,
319
+ recursive?: boolean
320
+ ): Promise<{ entries: Array<{ path: string; type: string }> }> {
321
+ const resolvedPath = this.resolvePath(path);
322
+ const entry = this.getCached(sandboxName);
323
+ try {
324
+ const files = await entry.sandbox.files.listDirectory({
325
+ path: resolvedPath,
326
+ depth: recursive ? undefined : 1,
327
+ });
328
+ return {
329
+ entries: files.map((f) => ({
330
+ path: f.path,
331
+ type: f.isDir === true ? "directory" : f.isSymlink === true ? "symlink" : "file",
332
+ })),
333
+ };
334
+ } catch (err) {
335
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to list path '${resolvedPath}' in sandbox '${sandboxName}'`);
336
+ }
337
+ }
338
+
339
+ async findFiles(sandboxName: string, path: string, pattern: string): Promise<{ files: string[] }> {
340
+ const resolvedPath = this.resolvePath(path);
341
+ const entry = this.getCached(sandboxName);
342
+ try {
343
+ const results = await entry.sandbox.files.search({
344
+ path: resolvedPath,
345
+ pattern: pattern || "*",
346
+ });
347
+ return { files: results.map((r) => r.path) };
348
+ } catch (err) {
349
+ // Fallback to shell find command
350
+ try {
351
+ const result = await entry.sandbox.commands.run(
352
+ `find ${resolvedPath} -name '${pattern}' -type f`,
353
+ { timeoutSeconds: 30 }
354
+ );
355
+ return { files: result.logs.stdout.map((s) => s.text).join("").split("\n").filter(Boolean) };
356
+ } catch {
357
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to find files in '${resolvedPath}'`);
358
+ }
359
+ }
360
+ }
361
+
362
+ async searchInFile(
363
+ sandboxName: string,
364
+ path: string,
365
+ query: string
366
+ ): Promise<{ matches: Array<{ line: number; content: string }> }> {
367
+ const resolvedPath = this.resolvePath(path);
368
+ const entry = this.getCached(sandboxName);
369
+ try {
370
+ const result = await entry.sandbox.commands.run(
371
+ `grep -n -E '${query.replace(/'/g, "'\\''")}' ${resolvedPath}`,
372
+ { timeoutSeconds: 30 }
373
+ );
374
+ const stdout = result.logs.stdout.map((s) => s.text).join("");
375
+ if (!stdout.trim()) return { matches: [] };
376
+ return {
377
+ matches: stdout.split("\n").filter(Boolean).map((line) => {
378
+ const idx = line.indexOf(":");
379
+ return { line: Number(line.slice(0, idx)), content: line.slice(idx + 1) };
380
+ }),
381
+ };
382
+ } catch (err: unknown) {
383
+ // grep returns exit code 1 for no matches — may throw or not
384
+ if (err instanceof SandboxException) return { matches: [] };
385
+ if (typeof err === "object" && err !== null && "code" in err && (err as { code: number }).code === 1) {
386
+ return { matches: [] };
387
+ }
388
+ return { matches: [] };
389
+ }
390
+ }
391
+
392
+ async replaceInFile(
393
+ sandboxName: string,
394
+ input: { path: string; search: string; replace: string }
395
+ ): Promise<{ replaced: number }> {
396
+ const resolvedPath = this.resolvePath(input.path);
397
+ const entry = this.getCached(sandboxName);
398
+ try {
399
+ const original = await entry.sandbox.files.readFile(resolvedPath);
400
+ if (!input.search) return { replaced: 0 };
401
+ const occurrences = original.split(input.search).length - 1;
402
+ if (occurrences === 0) return { replaced: 0 };
403
+ const updated = original.split(input.search).join(input.replace);
404
+ await entry.sandbox.files.writeFiles([{ path: resolvedPath, data: updated }]);
405
+ return { replaced: occurrences };
406
+ } catch (err) {
407
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to replace in file '${resolvedPath}'`);
408
+ }
409
+ }
410
+
411
+ async uploadFile(sandboxName: string, path: string, contentBase64: string): Promise<{ path: string }> {
412
+ const resolvedPath = this.resolvePath(path);
413
+ const entry = this.getCached(sandboxName);
414
+ try {
415
+ const data = Buffer.from(contentBase64, "base64").toString("utf-8");
416
+ await entry.sandbox.files.writeFiles([{ path: resolvedPath, data }]);
417
+ return { path: resolvedPath };
418
+ } catch (err) {
419
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to upload file '${resolvedPath}'`);
420
+ }
421
+ }
422
+
423
+ async downloadFile(sandboxName: string, path: string): Promise<{ path: string; contentBase64: string }> {
424
+ const resolvedPath = this.resolvePath(path);
425
+ const entry = this.getCached(sandboxName);
426
+ try {
427
+ const data = await entry.sandbox.files.readBytes(resolvedPath);
428
+ const b64 = Buffer.from(data).toString("base64");
429
+ return { path: resolvedPath, contentBase64: b64 };
430
+ } catch (err) {
431
+ throw this.wrapError(err, "INTERNAL_ERROR", `Failed to download file '${resolvedPath}'`);
432
+ }
433
+ }
434
+
435
+ async execCommand(input: ShellExecInput): Promise<{ stdout: string; stderr: string; exitCode: number }> {
436
+ const entry = this.getCached(input.sandboxName);
437
+ try {
438
+ const result = await entry.sandbox.commands.run(input.command, {
439
+ workingDirectory: input.exec_dir,
440
+ timeoutSeconds: input.timeout ? Math.ceil(input.timeout / 1000) : undefined,
441
+ });
442
+ return {
443
+ stdout: result.logs.stdout.map((s) => s.text).join(""),
444
+ stderr: result.logs.stderr.map((s) => s.text).join(""),
445
+ exitCode: result.exitCode ?? 0,
446
+ };
447
+ } catch (err) {
448
+ throw this.wrapError(err, "INTERNAL_ERROR", `Command execution failed in sandbox '${input.sandboxName}'`);
449
+ }
450
+ }
451
+
452
+ async getSandbox(
453
+ name: string
454
+ ): Promise<{
455
+ name: string;
456
+ status: string;
457
+ image: unknown;
458
+ cpus?: number;
459
+ memoryMib?: number;
460
+ env: unknown;
461
+ volumes: Record<string, unknown>;
462
+ metrics?: SandboxRuntimeMetrics;
463
+ createdAt: string;
464
+ updatedAt: string;
465
+ } | undefined> {
466
+ // Try cache first
467
+ const entry = this.cache.get(name);
468
+ if (entry) {
469
+ try {
470
+ const info = await entry.sandbox.getInfo();
471
+ return {
472
+ name,
473
+ status: info.status.state.toLowerCase(),
474
+ image: info.image?.uri ?? "unknown",
475
+ cpus: undefined,
476
+ memoryMib: undefined,
477
+ env: {},
478
+ volumes: {},
479
+ metrics: undefined,
480
+ createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : entry.createdAt,
481
+ updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : entry.createdAt,
482
+ };
483
+ } catch {
484
+ this.cache.delete(name);
485
+ }
486
+ }
487
+
488
+ // Try server-side lookup via metadata
489
+ try {
490
+ const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
491
+ const result = await manager.listSandboxInfos({
492
+ metadata: { [NAME_METADATA_KEY]: name },
493
+ pageSize: 1,
494
+ });
495
+ if (result.items.length > 0) {
496
+ const info = result.items[0];
497
+ return {
498
+ name,
499
+ status: info.status.state.toLowerCase(),
500
+ image: info.image?.uri ?? "unknown",
501
+ cpus: undefined,
502
+ memoryMib: undefined,
503
+ env: {},
504
+ volumes: {},
505
+ metrics: undefined,
506
+ createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
507
+ updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
508
+ };
509
+ }
510
+ } catch {
511
+ // ignore
512
+ }
513
+
514
+ return undefined;
515
+ }
516
+
517
+ async listSandboxes(_query: ListSandboxesQuery): Promise<{
518
+ items: Array<{
519
+ name: string;
520
+ status: string;
521
+ image: unknown;
522
+ cpus?: number;
523
+ memoryMib?: number;
524
+ envCount: number;
525
+ volumeCount: number;
526
+ createdAt: string;
527
+ updatedAt: string;
528
+ }>;
529
+ total: number;
530
+ }> {
531
+ try {
532
+ const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
533
+ const filter: SandboxFilter = {};
534
+ if (_query.status) filter.states = [_query.status];
535
+ const result = await manager.listSandboxInfos(filter);
536
+
537
+ const items = result.items
538
+ .filter((info) => {
539
+ if (_query.search) {
540
+ const haystack = [info.id, info.image?.uri].filter(Boolean).join(" ").toLowerCase();
541
+ return haystack.includes(_query.search.toLowerCase());
542
+ }
543
+ return true;
544
+ })
545
+ .map((info) => ({
546
+ name: info.id,
547
+ status: info.status.state.toLowerCase(),
548
+ image: info.image?.uri ?? "unknown",
549
+ cpus: undefined,
550
+ memoryMib: undefined,
551
+ envCount: 0,
552
+ volumeCount: 0,
553
+ createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
554
+ updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
555
+ }));
556
+
557
+ return { items, total: items.length };
558
+ } catch {
559
+ return { items: [], total: 0 };
560
+ }
561
+ }
562
+
563
+ async getSandboxLogs(
564
+ _name: string,
565
+ _opts: SandboxLogsInput
566
+ ): Promise<{
567
+ entries: Array<{
568
+ timestamp: string;
569
+ source: string;
570
+ sessionId: number | null;
571
+ text: string;
572
+ }>;
573
+ }> {
574
+ return { entries: [] };
575
+ }
576
+ }
package/src/swagger.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import swagger from "@fastify/swagger";
3
+ import swaggerUi from "@fastify/swagger-ui";
4
+
5
+ const swaggerConfig = {
6
+ openapi: {
7
+ openapi: "3.0.0",
8
+ info: {
9
+ title: "OpenSandbox Gateway API",
10
+ description: "Sandbox lifecycle management backed by OpenSandbox",
11
+ version: "1.0.0",
12
+ },
13
+ servers: [{ url: "http://localhost:4002", description: "Local development" }],
14
+ components: {
15
+ securitySchemes: {
16
+ bearerAuth: { type: "http" as const, scheme: "bearer" as const, bearerFormat: "API Key" },
17
+ },
18
+ },
19
+ tags: [
20
+ { name: "Sandboxes", description: "Sandbox lifecycle" },
21
+ { name: "Files", description: "File operations inside sandboxes" },
22
+ { name: "Shell", description: "Execute commands inside sandboxes" },
23
+ { name: "Images", description: "Image management (stub)" },
24
+ { name: "Volumes", description: "Named volume filesystem operations" },
25
+ { name: "Health", description: "Service health check" },
26
+ ],
27
+ },
28
+ };
29
+
30
+ const swaggerUiConfig = {
31
+ routePrefix: "/api-docs",
32
+ uiConfig: { docExpansion: "list" as const, deepLinking: true },
33
+ staticCSP: true,
34
+ transformStaticCSP: (header: string) => header,
35
+ };
36
+
37
+ export async function configureSwagger(app: FastifyInstance) {
38
+ await app.register(swagger, swaggerConfig);
39
+ await app.register(swaggerUi, swaggerUiConfig);
40
+ }
@@ -0,0 +1,79 @@
1
+ import type { EnsureSandboxInput, ListSandboxesQuery, ShellExecInput } from "../schemas/sandbox";
2
+
3
+ export type SandboxRuntimeMetrics = {
4
+ cpuPercent: number;
5
+ memoryBytes: number;
6
+ memoryLimitBytes: number;
7
+ diskReadBytes: number;
8
+ diskWriteBytes: number;
9
+ netRxBytes: number;
10
+ netTxBytes: number;
11
+ uptimeMs: number;
12
+ timestampMs: number;
13
+ };
14
+
15
+ export interface RuntimeService {
16
+ ensureSandbox(name: string, input: EnsureSandboxInput): Promise<{ name: string; status: string }>;
17
+ startSandbox(name: string): Promise<{ name: string; status: string }>;
18
+ stopSandbox(name: string): Promise<{ name: string; status: string }>;
19
+ killSandbox(name: string): Promise<{ name: string; status: string }>;
20
+ deleteSandbox(name: string): Promise<{ name: string; status: string }>;
21
+ listSandboxes(query: ListSandboxesQuery): Promise<{
22
+ items: Array<{
23
+ name: string;
24
+ status: string;
25
+ image: unknown;
26
+ cpus?: number;
27
+ memoryMib?: number;
28
+ envCount: number;
29
+ volumeCount: number;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ }>;
33
+ total: number;
34
+ }>;
35
+ getSandbox(name: string): Promise<{
36
+ name: string;
37
+ status: string;
38
+ image: unknown;
39
+ cpus?: number;
40
+ memoryMib?: number;
41
+ env: unknown;
42
+ volumes: Record<string, unknown>;
43
+ metrics?: SandboxRuntimeMetrics;
44
+ createdAt: string;
45
+ updatedAt: string;
46
+ } | undefined>;
47
+ getStatus(name: string): Promise<{ name: string; status: string }>;
48
+ readFile(sandboxName: string, path: string): Promise<{ path: string; content: string }>;
49
+ writeFile(sandboxName: string, path: string, content: string): Promise<{ path: string }>;
50
+ listPath(
51
+ sandboxName: string,
52
+ path: string,
53
+ recursive?: boolean
54
+ ): Promise<{ entries: Array<{ path: string; type: string }> }>;
55
+ findFiles(sandboxName: string, path: string, pattern: string): Promise<{ files: string[] }>;
56
+ searchInFile(
57
+ sandboxName: string,
58
+ path: string,
59
+ query: string
60
+ ): Promise<{ matches: Array<{ line: number; content: string }> }>;
61
+ replaceInFile(
62
+ sandboxName: string,
63
+ input: { path: string; search: string; replace: string }
64
+ ): Promise<{ replaced: number }>;
65
+ uploadFile(sandboxName: string, path: string, contentBase64: string): Promise<{ path: string }>;
66
+ downloadFile(sandboxName: string, path: string): Promise<{ path: string; contentBase64: string }>;
67
+ execCommand(input: ShellExecInput): Promise<{ stdout: string; stderr: string; exitCode: number }>;
68
+ getSandboxLogs(
69
+ name: string,
70
+ opts: { tail?: number; since?: string; until?: string; sources?: string[] }
71
+ ): Promise<{
72
+ entries: Array<{
73
+ timestamp: string;
74
+ source: string;
75
+ sessionId: number | null;
76
+ text: string;
77
+ }>;
78
+ }>;
79
+ }