@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.
- package/.eslintrc.json +22 -0
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +201 -0
- package/README.md +168 -0
- package/dist/chunk-FTPJIUJT.mjs +1081 -0
- package/dist/chunk-FTPJIUJT.mjs.map +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +11 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +373 -0
- package/dist/index.mjs +11 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +39 -0
- package/src/app.ts +67 -0
- package/src/cli.ts +8 -0
- package/src/controllers/images.ts +38 -0
- package/src/controllers/sandbox.ts +176 -0
- package/src/controllers/volume-fs.ts +163 -0
- package/src/index.ts +3 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/http.ts +11 -0
- package/src/lib/server-cli.ts +65 -0
- package/src/routes/health.ts +6 -0
- package/src/routes/images.ts +12 -0
- package/src/routes/sandbox.ts +31 -0
- package/src/routes/volume-fs.ts +12 -0
- package/src/schemas/images.ts +12 -0
- package/src/schemas/sandbox.ts +98 -0
- package/src/schemas/volume-fs.ts +23 -0
- package/src/server.ts +50 -0
- package/src/services/ImageService.ts +32 -0
- package/src/services/OpenSandboxRuntimeService.ts +576 -0
- package/src/swagger.ts +40 -0
- package/src/types/runtime-service.ts +79 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
// src/services/OpenSandboxRuntimeService.ts
|
|
2
|
+
import { ConnectionConfig, Sandbox } from "@alibaba-group/opensandbox";
|
|
3
|
+
import { SandboxManager } from "@alibaba-group/opensandbox";
|
|
4
|
+
import { SandboxException } from "@alibaba-group/opensandbox";
|
|
5
|
+
|
|
6
|
+
// src/lib/errors.ts
|
|
7
|
+
var HttpError = class extends Error {
|
|
8
|
+
constructor(statusCode, code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.statusCode = statusCode;
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
function toErrorResponse(error) {
|
|
15
|
+
if (error instanceof HttpError) {
|
|
16
|
+
return {
|
|
17
|
+
statusCode: error.statusCode,
|
|
18
|
+
body: {
|
|
19
|
+
success: false,
|
|
20
|
+
error: {
|
|
21
|
+
code: error.code,
|
|
22
|
+
message: error.message
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
statusCode: 500,
|
|
29
|
+
body: {
|
|
30
|
+
success: false,
|
|
31
|
+
error: {
|
|
32
|
+
code: "INTERNAL_ERROR",
|
|
33
|
+
message: error instanceof Error ? error.message : String(error)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/services/OpenSandboxRuntimeService.ts
|
|
40
|
+
var NAME_METADATA_KEY = "axiom-lattice/name";
|
|
41
|
+
function getEnv(key, fallback) {
|
|
42
|
+
return process.env[key] ?? fallback;
|
|
43
|
+
}
|
|
44
|
+
function getEnvNumber(key, fallback) {
|
|
45
|
+
const v = process.env[key];
|
|
46
|
+
return v ? Number(v) : fallback;
|
|
47
|
+
}
|
|
48
|
+
var OpenSandboxRuntimeService = class {
|
|
49
|
+
constructor() {
|
|
50
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
51
|
+
this.pendingCreates = /* @__PURE__ */ new Map();
|
|
52
|
+
this.connectionConfig = new ConnectionConfig({
|
|
53
|
+
domain: getEnv("OPEN_SANDBOX_DOMAIN", "localhost:8080"),
|
|
54
|
+
protocol: getEnv("OPEN_SANDBOX_PROTOCOL", "http"),
|
|
55
|
+
apiKey: process.env["OPEN_SANDBOX_API_KEY"],
|
|
56
|
+
useServerProxy: getEnv("OPEN_SANDBOX_USE_SERVER_PROXY", "false") === "true"
|
|
57
|
+
});
|
|
58
|
+
this.defaultImage = getEnv("OPEN_SANDBOX_DEFAULT_IMAGE", "ubuntu:22.04");
|
|
59
|
+
this.defaultCpus = getEnvNumber("OPEN_SANDBOX_DEFAULT_CPUS", 1);
|
|
60
|
+
this.defaultMemoryMib = getEnvNumber("OPEN_SANDBOX_DEFAULT_MEMORY_MIB", 2048);
|
|
61
|
+
this.defaultTimeout = getEnvNumber("OPEN_SANDBOX_DEFAULT_TIMEOUT", 600);
|
|
62
|
+
this.idleTimeoutMs = getEnvNumber("SANDBOX_IDLE_TIMEOUT_MS", 6e5);
|
|
63
|
+
}
|
|
64
|
+
mapVolumes(volumes) {
|
|
65
|
+
if (!volumes) return [];
|
|
66
|
+
return Object.entries(volumes).map(([mountPath, def]) => {
|
|
67
|
+
if (def.type === "bind" && def.source) {
|
|
68
|
+
return { name: mountPath, host: { path: def.source }, mountPath, readOnly: def.readonly };
|
|
69
|
+
}
|
|
70
|
+
if (def.type === "named" && def.name) {
|
|
71
|
+
return { name: mountPath, pvc: { claimName: def.name }, mountPath, readOnly: def.readonly };
|
|
72
|
+
}
|
|
73
|
+
if (def.type === "tmpfs") {
|
|
74
|
+
throw new HttpError(400, "INVALID_REQUEST", "tmpfs volumes are not supported by OpenSandbox");
|
|
75
|
+
}
|
|
76
|
+
throw new HttpError(400, "INVALID_REQUEST", `Unsupported volume type at ${mountPath}`);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
getCached(name) {
|
|
80
|
+
const entry = this.cache.get(name);
|
|
81
|
+
if (!entry) {
|
|
82
|
+
throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${name}' not found`);
|
|
83
|
+
}
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
async findByNameViaMetadata(name) {
|
|
87
|
+
try {
|
|
88
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
89
|
+
const result = await manager.listSandboxInfos({
|
|
90
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
91
|
+
pageSize: 1
|
|
92
|
+
});
|
|
93
|
+
if (result.items.length === 0) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const info = result.items[0];
|
|
97
|
+
const sandbox = await Sandbox.resume({
|
|
98
|
+
sandboxId: info.id,
|
|
99
|
+
connectionConfig: this.connectionConfig
|
|
100
|
+
});
|
|
101
|
+
const entry = {
|
|
102
|
+
sandbox,
|
|
103
|
+
sandboxId: info.id,
|
|
104
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
|
|
105
|
+
lastUsedAt: Date.now()
|
|
106
|
+
};
|
|
107
|
+
this.cache.set(name, entry);
|
|
108
|
+
return entry;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err instanceof SandboxException && err.error.code === "NOT_FOUND") {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
console.warn(`[OpenSandboxRuntimeService] metadata lookup for name=${name} failed:`, err);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async ensureSandbox(name, input) {
|
|
118
|
+
const cached = this.cache.get(name);
|
|
119
|
+
if (cached) {
|
|
120
|
+
try {
|
|
121
|
+
const info = await cached.sandbox.getInfo();
|
|
122
|
+
if (info.status.state === "Running") {
|
|
123
|
+
cached.lastUsedAt = Date.now();
|
|
124
|
+
await cached.sandbox.renew(this.defaultTimeout).catch(() => {
|
|
125
|
+
});
|
|
126
|
+
return { name, status: "running" };
|
|
127
|
+
}
|
|
128
|
+
this.cache.delete(name);
|
|
129
|
+
} catch {
|
|
130
|
+
this.cache.delete(name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const inflight = this.pendingCreates.get(name);
|
|
134
|
+
if (inflight) {
|
|
135
|
+
await inflight;
|
|
136
|
+
return { name, status: "running" };
|
|
137
|
+
}
|
|
138
|
+
const recovered = await this.findByNameViaMetadata(name);
|
|
139
|
+
if (recovered) {
|
|
140
|
+
return { name, status: "running" };
|
|
141
|
+
}
|
|
142
|
+
const creation = this.doCreateSandbox(name, input);
|
|
143
|
+
this.pendingCreates.set(name, creation);
|
|
144
|
+
try {
|
|
145
|
+
await creation;
|
|
146
|
+
return { name, status: "running" };
|
|
147
|
+
} finally {
|
|
148
|
+
this.pendingCreates.delete(name);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async doCreateSandbox(name, input) {
|
|
152
|
+
const sandbox = await Sandbox.create({
|
|
153
|
+
connectionConfig: this.connectionConfig,
|
|
154
|
+
image: input.image ?? this.defaultImage,
|
|
155
|
+
timeoutSeconds: this.defaultTimeout,
|
|
156
|
+
resource: {
|
|
157
|
+
cpu: String(input.cpus ?? this.defaultCpus),
|
|
158
|
+
memory: `${input.memoryMib ?? this.defaultMemoryMib}Mi`
|
|
159
|
+
},
|
|
160
|
+
env: input.env ?? {},
|
|
161
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
162
|
+
volumes: this.mapVolumes(input.volumes),
|
|
163
|
+
entrypoint: ["tail", "-f", "/dev/null"]
|
|
164
|
+
});
|
|
165
|
+
const info = await sandbox.getInfo();
|
|
166
|
+
const entry = {
|
|
167
|
+
sandbox,
|
|
168
|
+
sandboxId: info.id,
|
|
169
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
|
|
170
|
+
lastUsedAt: Date.now()
|
|
171
|
+
};
|
|
172
|
+
this.cache.set(name, entry);
|
|
173
|
+
return entry;
|
|
174
|
+
}
|
|
175
|
+
wrapError(err, code, message) {
|
|
176
|
+
if (err instanceof HttpError) throw err;
|
|
177
|
+
if (err instanceof SandboxException) {
|
|
178
|
+
throw new HttpError(
|
|
179
|
+
err.error.code === "NOT_FOUND" ? 404 : err.error.code === "FORBIDDEN" ? 403 : err.error.code === "CONFLICT" ? 409 : err.error.code === "INVALID_REQUEST" ? 400 : 500,
|
|
180
|
+
err.error.code,
|
|
181
|
+
err.error.message ?? message
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
throw new HttpError(500, code, message);
|
|
185
|
+
}
|
|
186
|
+
async startSandbox(name) {
|
|
187
|
+
const entry = this.getCached(name);
|
|
188
|
+
try {
|
|
189
|
+
const resumed = await Sandbox.resume({
|
|
190
|
+
sandboxId: entry.sandboxId,
|
|
191
|
+
connectionConfig: this.connectionConfig
|
|
192
|
+
});
|
|
193
|
+
const info = await resumed.getInfo();
|
|
194
|
+
entry.sandbox = resumed;
|
|
195
|
+
entry.sandboxId = info.id;
|
|
196
|
+
entry.lastUsedAt = Date.now();
|
|
197
|
+
return { name, status: "running" };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
throw this.wrapError(err, "SANDBOX_NOT_FOUND", `Failed to start sandbox '${name}'`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async stopSandbox(name) {
|
|
203
|
+
const entry = this.getCached(name);
|
|
204
|
+
try {
|
|
205
|
+
await entry.sandbox.pause();
|
|
206
|
+
return { name, status: "stopped" };
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to stop sandbox '${name}'`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async killSandbox(name) {
|
|
212
|
+
const entry = this.cache.get(name);
|
|
213
|
+
if (!entry) {
|
|
214
|
+
throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${name}' not found`);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
await entry.sandbox.kill();
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
await entry.sandbox.close();
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
224
|
+
this.cache.delete(name);
|
|
225
|
+
return { name, status: "unknown" };
|
|
226
|
+
}
|
|
227
|
+
async deleteSandbox(name) {
|
|
228
|
+
return this.killSandbox(name);
|
|
229
|
+
}
|
|
230
|
+
async getStatus(name) {
|
|
231
|
+
const entry = this.cache.get(name);
|
|
232
|
+
if (entry) {
|
|
233
|
+
try {
|
|
234
|
+
const info = await entry.sandbox.getInfo();
|
|
235
|
+
const status = info.status.state.toLowerCase();
|
|
236
|
+
return { name, status };
|
|
237
|
+
} catch {
|
|
238
|
+
return { name, status: "unknown" };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
243
|
+
const result = await manager.listSandboxInfos({
|
|
244
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
245
|
+
pageSize: 1
|
|
246
|
+
});
|
|
247
|
+
if (result.items.length > 0) {
|
|
248
|
+
return { name, status: result.items[0].status.state.toLowerCase() };
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
return { name, status: "unknown" };
|
|
253
|
+
}
|
|
254
|
+
resolvePath(path2) {
|
|
255
|
+
if (path2 === "~" || path2 === "~/") return "/";
|
|
256
|
+
if (path2.startsWith("~/")) return `/${path2.slice(2)}`;
|
|
257
|
+
return path2;
|
|
258
|
+
}
|
|
259
|
+
async readFile(sandboxName, path2) {
|
|
260
|
+
const resolvedPath = this.resolvePath(path2);
|
|
261
|
+
const entry = this.getCached(sandboxName);
|
|
262
|
+
try {
|
|
263
|
+
const content = await entry.sandbox.files.readFile(resolvedPath);
|
|
264
|
+
return { path: resolvedPath, content };
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to read file '${resolvedPath}' in sandbox '${sandboxName}'`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async writeFile(sandboxName, path2, content) {
|
|
270
|
+
const resolvedPath = this.resolvePath(path2);
|
|
271
|
+
const entry = this.getCached(sandboxName);
|
|
272
|
+
try {
|
|
273
|
+
const parentDir = resolvedPath.split("/").slice(0, -1).join("/") || "/";
|
|
274
|
+
await entry.sandbox.files.createDirectories([{ path: parentDir }]).catch(() => {
|
|
275
|
+
});
|
|
276
|
+
await entry.sandbox.files.writeFiles([{ path: resolvedPath, data: content }]);
|
|
277
|
+
return { path: resolvedPath };
|
|
278
|
+
} catch (err) {
|
|
279
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to write file '${resolvedPath}' in sandbox '${sandboxName}'`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async listPath(sandboxName, path2, recursive) {
|
|
283
|
+
const resolvedPath = this.resolvePath(path2);
|
|
284
|
+
const entry = this.getCached(sandboxName);
|
|
285
|
+
try {
|
|
286
|
+
const files = await entry.sandbox.files.listDirectory({
|
|
287
|
+
path: resolvedPath,
|
|
288
|
+
depth: recursive ? void 0 : 1
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
entries: files.map((f) => ({
|
|
292
|
+
path: f.path,
|
|
293
|
+
type: f.isDir === true ? "directory" : f.isSymlink === true ? "symlink" : "file"
|
|
294
|
+
}))
|
|
295
|
+
};
|
|
296
|
+
} catch (err) {
|
|
297
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to list path '${resolvedPath}' in sandbox '${sandboxName}'`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async findFiles(sandboxName, path2, pattern) {
|
|
301
|
+
const resolvedPath = this.resolvePath(path2);
|
|
302
|
+
const entry = this.getCached(sandboxName);
|
|
303
|
+
try {
|
|
304
|
+
const results = await entry.sandbox.files.search({
|
|
305
|
+
path: resolvedPath,
|
|
306
|
+
pattern: pattern || "*"
|
|
307
|
+
});
|
|
308
|
+
return { files: results.map((r) => r.path) };
|
|
309
|
+
} catch (err) {
|
|
310
|
+
try {
|
|
311
|
+
const result = await entry.sandbox.commands.run(
|
|
312
|
+
`find ${resolvedPath} -name '${pattern}' -type f`,
|
|
313
|
+
{ timeoutSeconds: 30 }
|
|
314
|
+
);
|
|
315
|
+
return { files: result.logs.stdout.map((s) => s.text).join("").split("\n").filter(Boolean) };
|
|
316
|
+
} catch {
|
|
317
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to find files in '${resolvedPath}'`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async searchInFile(sandboxName, path2, query) {
|
|
322
|
+
const resolvedPath = this.resolvePath(path2);
|
|
323
|
+
const entry = this.getCached(sandboxName);
|
|
324
|
+
try {
|
|
325
|
+
const result = await entry.sandbox.commands.run(
|
|
326
|
+
`grep -n -E '${query.replace(/'/g, "'\\''")}' ${resolvedPath}`,
|
|
327
|
+
{ timeoutSeconds: 30 }
|
|
328
|
+
);
|
|
329
|
+
const stdout = result.logs.stdout.map((s) => s.text).join("");
|
|
330
|
+
if (!stdout.trim()) return { matches: [] };
|
|
331
|
+
return {
|
|
332
|
+
matches: stdout.split("\n").filter(Boolean).map((line) => {
|
|
333
|
+
const idx = line.indexOf(":");
|
|
334
|
+
return { line: Number(line.slice(0, idx)), content: line.slice(idx + 1) };
|
|
335
|
+
})
|
|
336
|
+
};
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (err instanceof SandboxException) return { matches: [] };
|
|
339
|
+
if (typeof err === "object" && err !== null && "code" in err && err.code === 1) {
|
|
340
|
+
return { matches: [] };
|
|
341
|
+
}
|
|
342
|
+
return { matches: [] };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async replaceInFile(sandboxName, input) {
|
|
346
|
+
const resolvedPath = this.resolvePath(input.path);
|
|
347
|
+
const entry = this.getCached(sandboxName);
|
|
348
|
+
try {
|
|
349
|
+
const original = await entry.sandbox.files.readFile(resolvedPath);
|
|
350
|
+
if (!input.search) return { replaced: 0 };
|
|
351
|
+
const occurrences = original.split(input.search).length - 1;
|
|
352
|
+
if (occurrences === 0) return { replaced: 0 };
|
|
353
|
+
const updated = original.split(input.search).join(input.replace);
|
|
354
|
+
await entry.sandbox.files.writeFiles([{ path: resolvedPath, data: updated }]);
|
|
355
|
+
return { replaced: occurrences };
|
|
356
|
+
} catch (err) {
|
|
357
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to replace in file '${resolvedPath}'`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async uploadFile(sandboxName, path2, contentBase64) {
|
|
361
|
+
const resolvedPath = this.resolvePath(path2);
|
|
362
|
+
const entry = this.getCached(sandboxName);
|
|
363
|
+
try {
|
|
364
|
+
const data = Buffer.from(contentBase64, "base64").toString("utf-8");
|
|
365
|
+
await entry.sandbox.files.writeFiles([{ path: resolvedPath, data }]);
|
|
366
|
+
return { path: resolvedPath };
|
|
367
|
+
} catch (err) {
|
|
368
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to upload file '${resolvedPath}'`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async downloadFile(sandboxName, path2) {
|
|
372
|
+
const resolvedPath = this.resolvePath(path2);
|
|
373
|
+
const entry = this.getCached(sandboxName);
|
|
374
|
+
try {
|
|
375
|
+
const data = await entry.sandbox.files.readBytes(resolvedPath);
|
|
376
|
+
const b64 = Buffer.from(data).toString("base64");
|
|
377
|
+
return { path: resolvedPath, contentBase64: b64 };
|
|
378
|
+
} catch (err) {
|
|
379
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to download file '${resolvedPath}'`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async execCommand(input) {
|
|
383
|
+
const entry = this.getCached(input.sandboxName);
|
|
384
|
+
try {
|
|
385
|
+
const result = await entry.sandbox.commands.run(input.command, {
|
|
386
|
+
workingDirectory: input.exec_dir,
|
|
387
|
+
timeoutSeconds: input.timeout ? Math.ceil(input.timeout / 1e3) : void 0
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
stdout: result.logs.stdout.map((s) => s.text).join(""),
|
|
391
|
+
stderr: result.logs.stderr.map((s) => s.text).join(""),
|
|
392
|
+
exitCode: result.exitCode ?? 0
|
|
393
|
+
};
|
|
394
|
+
} catch (err) {
|
|
395
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Command execution failed in sandbox '${input.sandboxName}'`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async getSandbox(name) {
|
|
399
|
+
const entry = this.cache.get(name);
|
|
400
|
+
if (entry) {
|
|
401
|
+
try {
|
|
402
|
+
const info = await entry.sandbox.getInfo();
|
|
403
|
+
return {
|
|
404
|
+
name,
|
|
405
|
+
status: info.status.state.toLowerCase(),
|
|
406
|
+
image: info.image?.uri ?? "unknown",
|
|
407
|
+
cpus: void 0,
|
|
408
|
+
memoryMib: void 0,
|
|
409
|
+
env: {},
|
|
410
|
+
volumes: {},
|
|
411
|
+
metrics: void 0,
|
|
412
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : entry.createdAt,
|
|
413
|
+
updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : entry.createdAt
|
|
414
|
+
};
|
|
415
|
+
} catch {
|
|
416
|
+
this.cache.delete(name);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
421
|
+
const result = await manager.listSandboxInfos({
|
|
422
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
423
|
+
pageSize: 1
|
|
424
|
+
});
|
|
425
|
+
if (result.items.length > 0) {
|
|
426
|
+
const info = result.items[0];
|
|
427
|
+
return {
|
|
428
|
+
name,
|
|
429
|
+
status: info.status.state.toLowerCase(),
|
|
430
|
+
image: info.image?.uri ?? "unknown",
|
|
431
|
+
cpus: void 0,
|
|
432
|
+
memoryMib: void 0,
|
|
433
|
+
env: {},
|
|
434
|
+
volumes: {},
|
|
435
|
+
metrics: void 0,
|
|
436
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
|
|
437
|
+
updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : ""
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
return void 0;
|
|
443
|
+
}
|
|
444
|
+
async listSandboxes(_query) {
|
|
445
|
+
try {
|
|
446
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
447
|
+
const filter = {};
|
|
448
|
+
if (_query.status) filter.states = [_query.status];
|
|
449
|
+
const result = await manager.listSandboxInfos(filter);
|
|
450
|
+
const items = result.items.filter((info) => {
|
|
451
|
+
if (_query.search) {
|
|
452
|
+
const haystack = [info.id, info.image?.uri].filter(Boolean).join(" ").toLowerCase();
|
|
453
|
+
return haystack.includes(_query.search.toLowerCase());
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
}).map((info) => ({
|
|
457
|
+
name: info.id,
|
|
458
|
+
status: info.status.state.toLowerCase(),
|
|
459
|
+
image: info.image?.uri ?? "unknown",
|
|
460
|
+
cpus: void 0,
|
|
461
|
+
memoryMib: void 0,
|
|
462
|
+
envCount: 0,
|
|
463
|
+
volumeCount: 0,
|
|
464
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
|
|
465
|
+
updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : ""
|
|
466
|
+
}));
|
|
467
|
+
return { items, total: items.length };
|
|
468
|
+
} catch {
|
|
469
|
+
return { items: [], total: 0 };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async getSandboxLogs(_name, _opts) {
|
|
473
|
+
return { entries: [] };
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// src/app.ts
|
|
478
|
+
import cors from "@fastify/cors";
|
|
479
|
+
import multipart from "@fastify/multipart";
|
|
480
|
+
import sensible from "@fastify/sensible";
|
|
481
|
+
import fastify from "fastify";
|
|
482
|
+
|
|
483
|
+
// src/lib/http.ts
|
|
484
|
+
function ok(data) {
|
|
485
|
+
return {
|
|
486
|
+
success: true,
|
|
487
|
+
data
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/routes/health.ts
|
|
492
|
+
function registerHealthRoutes(app) {
|
|
493
|
+
app.get("/health", async () => ok({ status: "ok" }));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/controllers/images.ts
|
|
497
|
+
import { ZodError } from "zod";
|
|
498
|
+
|
|
499
|
+
// src/schemas/images.ts
|
|
500
|
+
import z from "zod";
|
|
501
|
+
var imageRefQuerySchema = z.object({
|
|
502
|
+
ref: z.string().min(1)
|
|
503
|
+
});
|
|
504
|
+
var pullImageSchema = z.object({
|
|
505
|
+
ref: z.string().min(1)
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// src/controllers/images.ts
|
|
509
|
+
function parseOrThrow(parse) {
|
|
510
|
+
try {
|
|
511
|
+
return parse();
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error instanceof ZodError) {
|
|
514
|
+
throw new HttpError(400, "INVALID_REQUEST", error.issues[0]?.message ?? "Invalid request");
|
|
515
|
+
}
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function createImageController(imageService) {
|
|
520
|
+
return {
|
|
521
|
+
listImages: async (_request, reply) => {
|
|
522
|
+
return reply.send(ok(await imageService.listImages()));
|
|
523
|
+
},
|
|
524
|
+
pullImage: async (request, reply) => {
|
|
525
|
+
const body = parseOrThrow(() => pullImageSchema.parse(request.body ?? {}));
|
|
526
|
+
return reply.send(ok(await imageService.pullImage(body)));
|
|
527
|
+
},
|
|
528
|
+
getImage: async (request, reply) => {
|
|
529
|
+
const query = parseOrThrow(() => imageRefQuerySchema.parse(request.query ?? {}));
|
|
530
|
+
return reply.send(ok(await imageService.getImage(query.ref)));
|
|
531
|
+
},
|
|
532
|
+
deleteImage: async (request, reply) => {
|
|
533
|
+
const query = parseOrThrow(() => imageRefQuerySchema.parse(request.query ?? {}));
|
|
534
|
+
return reply.send(ok(await imageService.deleteImage(query.ref)));
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/routes/images.ts
|
|
540
|
+
function registerImageRoutes(app, imageService) {
|
|
541
|
+
const controller = createImageController(imageService);
|
|
542
|
+
app.get("/api/images", controller.listImages);
|
|
543
|
+
app.post("/api/images/pull", controller.pullImage);
|
|
544
|
+
app.get("/api/images/detail", controller.getImage);
|
|
545
|
+
app.delete("/api/images/detail", controller.deleteImage);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/schemas/sandbox.ts
|
|
549
|
+
import z2 from "zod";
|
|
550
|
+
var bindMountSchema = z2.object({
|
|
551
|
+
type: z2.literal("bind"),
|
|
552
|
+
source: z2.string().min(1),
|
|
553
|
+
readonly: z2.boolean().optional()
|
|
554
|
+
});
|
|
555
|
+
var namedMountSchema = z2.object({
|
|
556
|
+
type: z2.literal("named"),
|
|
557
|
+
name: z2.string().min(1),
|
|
558
|
+
readonly: z2.boolean().optional()
|
|
559
|
+
});
|
|
560
|
+
var tmpfsMountSchema = z2.object({
|
|
561
|
+
type: z2.literal("tmpfs"),
|
|
562
|
+
sizeMib: z2.number().int().positive().optional()
|
|
563
|
+
});
|
|
564
|
+
var volumeSchema = z2.union([
|
|
565
|
+
bindMountSchema,
|
|
566
|
+
namedMountSchema,
|
|
567
|
+
tmpfsMountSchema
|
|
568
|
+
]);
|
|
569
|
+
var ensureSandboxSchema = z2.object({
|
|
570
|
+
image: z2.string().optional(),
|
|
571
|
+
cpus: z2.number().int().positive().optional(),
|
|
572
|
+
memoryMib: z2.number().int().positive().optional(),
|
|
573
|
+
env: z2.record(z2.string()).optional(),
|
|
574
|
+
volumes: z2.record(volumeSchema).optional()
|
|
575
|
+
});
|
|
576
|
+
var sandboxNameParamsSchema = z2.object({
|
|
577
|
+
name: z2.string().min(1)
|
|
578
|
+
});
|
|
579
|
+
var listSandboxesQuerySchema = z2.object({
|
|
580
|
+
status: z2.enum(["running", "stopped", "crashed", "unknown"]).optional(),
|
|
581
|
+
image: z2.string().min(1).optional(),
|
|
582
|
+
search: z2.string().min(1).optional()
|
|
583
|
+
});
|
|
584
|
+
var sandboxAndPathSchema = z2.object({
|
|
585
|
+
sandboxName: z2.string().min(1),
|
|
586
|
+
path: z2.string().min(1)
|
|
587
|
+
});
|
|
588
|
+
var readFileSchema = sandboxAndPathSchema;
|
|
589
|
+
var writeFileSchema = sandboxAndPathSchema.extend({
|
|
590
|
+
content: z2.string()
|
|
591
|
+
});
|
|
592
|
+
var listPathSchema = sandboxAndPathSchema.extend({
|
|
593
|
+
recursive: z2.boolean().optional()
|
|
594
|
+
});
|
|
595
|
+
var findFilesSchema = sandboxAndPathSchema.extend({
|
|
596
|
+
pattern: z2.string().min(1)
|
|
597
|
+
});
|
|
598
|
+
var searchInFileSchema = sandboxAndPathSchema.extend({
|
|
599
|
+
query: z2.string().min(1)
|
|
600
|
+
});
|
|
601
|
+
var replaceInFileSchema = sandboxAndPathSchema.extend({
|
|
602
|
+
search: z2.string(),
|
|
603
|
+
replace: z2.string()
|
|
604
|
+
});
|
|
605
|
+
var uploadFileSchema = sandboxAndPathSchema.and(
|
|
606
|
+
z2.union([
|
|
607
|
+
z2.object({ contentBase64: z2.string() }),
|
|
608
|
+
z2.object({ content: z2.string().transform((content) => Buffer.from(content).toString("base64")) })
|
|
609
|
+
])
|
|
610
|
+
);
|
|
611
|
+
var downloadFileSchema = sandboxAndPathSchema;
|
|
612
|
+
var shellExecSchema = z2.object({
|
|
613
|
+
sandboxName: z2.string().min(1),
|
|
614
|
+
command: z2.string().min(1),
|
|
615
|
+
exec_dir: z2.string().optional(),
|
|
616
|
+
timeout: z2.number().int().positive().optional()
|
|
617
|
+
});
|
|
618
|
+
var sandboxLogsSchema = z2.object({
|
|
619
|
+
tail: z2.number().int().positive().optional(),
|
|
620
|
+
since: z2.string().optional(),
|
|
621
|
+
until: z2.string().optional(),
|
|
622
|
+
sources: z2.array(z2.string()).optional()
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// src/controllers/sandbox.ts
|
|
626
|
+
function createSandboxController(runtimeService) {
|
|
627
|
+
return {
|
|
628
|
+
ensureSandbox: async (request, reply) => {
|
|
629
|
+
const body = ensureSandboxSchema.parse(request.body ?? {});
|
|
630
|
+
const result = await runtimeService.ensureSandbox(request.params.name, body);
|
|
631
|
+
return reply.send(ok(result));
|
|
632
|
+
},
|
|
633
|
+
startSandbox: async (request, reply) => {
|
|
634
|
+
return reply.send(ok(await runtimeService.startSandbox(request.params.name)));
|
|
635
|
+
},
|
|
636
|
+
stopSandbox: async (request, reply) => {
|
|
637
|
+
return reply.send(ok(await runtimeService.stopSandbox(request.params.name)));
|
|
638
|
+
},
|
|
639
|
+
killSandbox: async (request, reply) => {
|
|
640
|
+
return reply.send(ok(await runtimeService.killSandbox(request.params.name)));
|
|
641
|
+
},
|
|
642
|
+
deleteSandbox: async (request, reply) => {
|
|
643
|
+
return reply.send(ok(await runtimeService.deleteSandbox(request.params.name)));
|
|
644
|
+
},
|
|
645
|
+
listSandboxes: async (request, reply) => {
|
|
646
|
+
const query = listSandboxesQuerySchema.parse(request.query ?? {});
|
|
647
|
+
return reply.send(ok(await runtimeService.listSandboxes(query)));
|
|
648
|
+
},
|
|
649
|
+
getSandbox: async (request, reply) => {
|
|
650
|
+
const params = sandboxNameParamsSchema.parse(request.params ?? {});
|
|
651
|
+
const sandbox = await runtimeService.getSandbox(params.name);
|
|
652
|
+
if (!sandbox) {
|
|
653
|
+
throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${params.name}' not found`);
|
|
654
|
+
}
|
|
655
|
+
return reply.send(ok(sandbox));
|
|
656
|
+
},
|
|
657
|
+
getStatus: async (request, reply) => {
|
|
658
|
+
return reply.send(ok(await runtimeService.getStatus(request.params.name)));
|
|
659
|
+
},
|
|
660
|
+
getSandboxLogs: async (request, reply) => {
|
|
661
|
+
const body = sandboxLogsSchema.parse(request.body ?? {});
|
|
662
|
+
return reply.send(ok(await runtimeService.getSandboxLogs(request.params.name, body)));
|
|
663
|
+
},
|
|
664
|
+
readFile: async (request, reply) => {
|
|
665
|
+
const body = readFileSchema.parse(request.body ?? {});
|
|
666
|
+
return reply.send(ok(await runtimeService.readFile(body.sandboxName, body.path)));
|
|
667
|
+
},
|
|
668
|
+
writeFile: async (request, reply) => {
|
|
669
|
+
const body = writeFileSchema.parse(request.body ?? {});
|
|
670
|
+
return reply.send(ok(await runtimeService.writeFile(body.sandboxName, body.path, body.content)));
|
|
671
|
+
},
|
|
672
|
+
listPath: async (request, reply) => {
|
|
673
|
+
const body = listPathSchema.parse(request.body ?? {});
|
|
674
|
+
return reply.send(ok(await runtimeService.listPath(body.sandboxName, body.path, body.recursive)));
|
|
675
|
+
},
|
|
676
|
+
findFiles: async (request, reply) => {
|
|
677
|
+
const body = findFilesSchema.parse(request.body ?? {});
|
|
678
|
+
return reply.send(ok(await runtimeService.findFiles(body.sandboxName, body.path, body.pattern)));
|
|
679
|
+
},
|
|
680
|
+
searchInFile: async (request, reply) => {
|
|
681
|
+
const body = searchInFileSchema.parse(request.body ?? {});
|
|
682
|
+
return reply.send(ok(await runtimeService.searchInFile(body.sandboxName, body.path, body.query)));
|
|
683
|
+
},
|
|
684
|
+
replaceInFile: async (request, reply) => {
|
|
685
|
+
const body = replaceInFileSchema.parse(request.body ?? {});
|
|
686
|
+
return reply.send(
|
|
687
|
+
ok(
|
|
688
|
+
await runtimeService.replaceInFile(body.sandboxName, {
|
|
689
|
+
path: body.path,
|
|
690
|
+
search: body.search,
|
|
691
|
+
replace: body.replace
|
|
692
|
+
})
|
|
693
|
+
)
|
|
694
|
+
);
|
|
695
|
+
},
|
|
696
|
+
uploadFile: async (request, reply) => {
|
|
697
|
+
const body = uploadFileSchema.parse(request.body ?? {});
|
|
698
|
+
const contentBase64 = "contentBase64" in body ? body.contentBase64 : Buffer.from(body.content).toString("base64");
|
|
699
|
+
return reply.send(ok(await runtimeService.uploadFile(body.sandboxName, body.path, contentBase64)));
|
|
700
|
+
},
|
|
701
|
+
downloadFile: async (request, reply) => {
|
|
702
|
+
const query = downloadFileSchema.parse(request.query ?? {});
|
|
703
|
+
return reply.send(ok(await runtimeService.downloadFile(query.sandboxName, query.path)));
|
|
704
|
+
},
|
|
705
|
+
execCommand: async (request, reply) => {
|
|
706
|
+
const body = shellExecSchema.parse(request.body ?? {});
|
|
707
|
+
return reply.send(ok(await runtimeService.execCommand(body)));
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/routes/sandbox.ts
|
|
713
|
+
function registerSandboxRoutes(app, runtimeService) {
|
|
714
|
+
const controller = createSandboxController(runtimeService);
|
|
715
|
+
app.get("/api/sandboxes", controller.listSandboxes);
|
|
716
|
+
app.get("/api/sandboxes/:name", controller.getSandbox);
|
|
717
|
+
app.put("/api/sandboxes/:name", controller.ensureSandbox);
|
|
718
|
+
app.post("/api/sandboxes/:name/start", controller.startSandbox);
|
|
719
|
+
app.post("/api/sandboxes/:name/stop", controller.stopSandbox);
|
|
720
|
+
app.post("/api/sandboxes/:name/kill", controller.killSandbox);
|
|
721
|
+
app.delete("/api/sandboxes/:name", controller.deleteSandbox);
|
|
722
|
+
app.get("/api/sandboxes/:name/status", controller.getStatus);
|
|
723
|
+
app.post("/api/sandboxes/:name/logs", controller.getSandboxLogs);
|
|
724
|
+
app.post("/api/files/read", controller.readFile);
|
|
725
|
+
app.post("/api/files/write", controller.writeFile);
|
|
726
|
+
app.post("/api/files/list", controller.listPath);
|
|
727
|
+
app.post("/api/files/find", controller.findFiles);
|
|
728
|
+
app.post("/api/files/search", controller.searchInFile);
|
|
729
|
+
app.post("/api/files/replace", controller.replaceInFile);
|
|
730
|
+
app.post("/api/files/upload", controller.uploadFile);
|
|
731
|
+
app.get("/api/files/download", controller.downloadFile);
|
|
732
|
+
app.post("/api/shell/exec", controller.execCommand);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/controllers/volume-fs.ts
|
|
736
|
+
import fs from "fs/promises";
|
|
737
|
+
import os from "os";
|
|
738
|
+
import path from "path";
|
|
739
|
+
import z4 from "zod";
|
|
740
|
+
|
|
741
|
+
// src/schemas/volume-fs.ts
|
|
742
|
+
import z3 from "zod";
|
|
743
|
+
var volumeFsReadSchema = z3.object({
|
|
744
|
+
path: z3.string()
|
|
745
|
+
});
|
|
746
|
+
var volumeFsWriteSchema = z3.object({
|
|
747
|
+
path: z3.string(),
|
|
748
|
+
content: z3.string()
|
|
749
|
+
});
|
|
750
|
+
var volumeFsListSchema = z3.object({
|
|
751
|
+
path: z3.string()
|
|
752
|
+
});
|
|
753
|
+
var volumeFsUploadSchema = z3.object({
|
|
754
|
+
path: z3.string(),
|
|
755
|
+
contentBase64: z3.string().min(1)
|
|
756
|
+
});
|
|
757
|
+
var volumeFsDownloadSchema = z3.object({
|
|
758
|
+
path: z3.string()
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// src/controllers/volume-fs.ts
|
|
762
|
+
function getVolumeBasePath() {
|
|
763
|
+
return process.env.VOLUME_BASE_PATH ?? path.join(os.homedir(), ".opensandbox", "volumes");
|
|
764
|
+
}
|
|
765
|
+
function resolveVolumeHostPath(name) {
|
|
766
|
+
return path.join(getVolumeBasePath(), name);
|
|
767
|
+
}
|
|
768
|
+
function resolveGuestPath(hostRoot, guestPath) {
|
|
769
|
+
const normalized = guestPath === "~" ? "" : guestPath.replace(/^~\//, "");
|
|
770
|
+
const resolved = path.join(
|
|
771
|
+
hostRoot,
|
|
772
|
+
path.normalize(normalized).replace(/^(\.\.(\/|\\|$))+/, "")
|
|
773
|
+
);
|
|
774
|
+
if (!resolved.startsWith(hostRoot + path.sep) && resolved !== hostRoot) {
|
|
775
|
+
throw new HttpError(403, "PATH_TRAVERSAL", "Path traversal detected");
|
|
776
|
+
}
|
|
777
|
+
return resolved;
|
|
778
|
+
}
|
|
779
|
+
function createVolumeFsController() {
|
|
780
|
+
return {
|
|
781
|
+
readFile: async (request, reply) => {
|
|
782
|
+
const { name } = request.params;
|
|
783
|
+
try {
|
|
784
|
+
const { path: guestPath } = volumeFsReadSchema.parse(request.body ?? {});
|
|
785
|
+
const hostRoot = resolveVolumeHostPath(name);
|
|
786
|
+
const fullPath = resolveGuestPath(hostRoot, guestPath);
|
|
787
|
+
const content = await fs.readFile(fullPath, "utf-8");
|
|
788
|
+
return reply.send(ok({ path: guestPath, content }));
|
|
789
|
+
} catch (err) {
|
|
790
|
+
if (err instanceof z4.ZodError) {
|
|
791
|
+
throw new HttpError(400, "VALIDATION_ERROR", err.message);
|
|
792
|
+
}
|
|
793
|
+
if (err instanceof HttpError) throw err;
|
|
794
|
+
throw new HttpError(404, "VOLUME_READ_ERROR", `Failed to read from volume '${name}': ${String(err)}`);
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
writeFile: async (request, reply) => {
|
|
798
|
+
const { name } = request.params;
|
|
799
|
+
try {
|
|
800
|
+
const { path: guestPath, content } = volumeFsWriteSchema.parse(request.body ?? {});
|
|
801
|
+
const hostRoot = resolveVolumeHostPath(name);
|
|
802
|
+
const fullPath = resolveGuestPath(hostRoot, guestPath);
|
|
803
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
804
|
+
await fs.writeFile(fullPath, content, "utf-8");
|
|
805
|
+
return reply.send(ok({ path: guestPath }));
|
|
806
|
+
} catch (err) {
|
|
807
|
+
if (err instanceof z4.ZodError) {
|
|
808
|
+
throw new HttpError(400, "VALIDATION_ERROR", err.message);
|
|
809
|
+
}
|
|
810
|
+
if (err instanceof HttpError) throw err;
|
|
811
|
+
throw new HttpError(500, "VOLUME_WRITE_ERROR", `Failed to write to volume '${name}': ${String(err)}`);
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
listPath: async (request, reply) => {
|
|
815
|
+
const { name } = request.params;
|
|
816
|
+
try {
|
|
817
|
+
const { path: guestPath } = volumeFsListSchema.parse(request.body ?? {});
|
|
818
|
+
const hostRoot = resolveVolumeHostPath(name);
|
|
819
|
+
const fullPath = resolveGuestPath(hostRoot, guestPath);
|
|
820
|
+
const dirents = await fs.readdir(fullPath, { withFileTypes: true });
|
|
821
|
+
const entries = dirents.map((d) => ({
|
|
822
|
+
path: guestPath ? `${guestPath}/${d.name}` : d.name,
|
|
823
|
+
kind: d.isDirectory() ? "directory" : d.isSymbolicLink() ? "symlink" : "file",
|
|
824
|
+
size: 0,
|
|
825
|
+
mode: 0
|
|
826
|
+
}));
|
|
827
|
+
return reply.send(ok({ entries }));
|
|
828
|
+
} catch (err) {
|
|
829
|
+
if (err instanceof z4.ZodError) {
|
|
830
|
+
throw new HttpError(400, "VALIDATION_ERROR", err.message);
|
|
831
|
+
}
|
|
832
|
+
if (err instanceof HttpError) throw err;
|
|
833
|
+
throw new HttpError(404, "VOLUME_LIST_ERROR", `Failed to list volume '${name}': ${String(err)}`);
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
downloadFile: async (request, reply) => {
|
|
837
|
+
const { name } = request.params;
|
|
838
|
+
try {
|
|
839
|
+
const { path: guestPath } = volumeFsDownloadSchema.parse(request.query ?? {});
|
|
840
|
+
const hostRoot = resolveVolumeHostPath(name);
|
|
841
|
+
const fullPath = resolveGuestPath(hostRoot, guestPath);
|
|
842
|
+
const buf = await fs.readFile(fullPath);
|
|
843
|
+
const contentBase64 = buf.toString("base64");
|
|
844
|
+
return reply.send(ok({ path: guestPath, contentBase64 }));
|
|
845
|
+
} catch (err) {
|
|
846
|
+
if (err instanceof z4.ZodError) {
|
|
847
|
+
throw new HttpError(400, "VALIDATION_ERROR", err.message);
|
|
848
|
+
}
|
|
849
|
+
if (err instanceof HttpError) throw err;
|
|
850
|
+
throw new HttpError(404, "VOLUME_DOWNLOAD_ERROR", `Failed to download from volume '${name}': ${String(err)}`);
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
uploadFile: async (request, reply) => {
|
|
854
|
+
const { name } = request.params;
|
|
855
|
+
try {
|
|
856
|
+
const { path: guestPath, contentBase64 } = volumeFsUploadSchema.parse(request.body ?? {});
|
|
857
|
+
const hostRoot = resolveVolumeHostPath(name);
|
|
858
|
+
const fullPath = resolveGuestPath(hostRoot, guestPath);
|
|
859
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
860
|
+
const data = Buffer.from(contentBase64, "base64");
|
|
861
|
+
await fs.writeFile(fullPath, data);
|
|
862
|
+
return reply.send(ok({ path: guestPath }));
|
|
863
|
+
} catch (err) {
|
|
864
|
+
if (err instanceof z4.ZodError) {
|
|
865
|
+
throw new HttpError(400, "VALIDATION_ERROR", err.message);
|
|
866
|
+
}
|
|
867
|
+
if (err instanceof HttpError) throw err;
|
|
868
|
+
throw new HttpError(500, "VOLUME_UPLOAD_ERROR", `Failed to upload to volume '${name}': ${String(err)}`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/routes/volume-fs.ts
|
|
875
|
+
function registerVolumeFsRoutes(app) {
|
|
876
|
+
const controller = createVolumeFsController();
|
|
877
|
+
app.post("/api/volumes/:name/fs/read", controller.readFile);
|
|
878
|
+
app.post("/api/volumes/:name/fs/write", controller.writeFile);
|
|
879
|
+
app.post("/api/volumes/:name/fs/list", controller.listPath);
|
|
880
|
+
app.get("/api/volumes/:name/fs/download", controller.downloadFile);
|
|
881
|
+
app.post("/api/volumes/:name/fs/upload", controller.uploadFile);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/services/ImageService.ts
|
|
885
|
+
var ImageService = class {
|
|
886
|
+
async listImages() {
|
|
887
|
+
return { items: [], total: 0 };
|
|
888
|
+
}
|
|
889
|
+
async pullImage(_input) {
|
|
890
|
+
throw new Error("Image pull not supported in OpenSandbox gateway");
|
|
891
|
+
}
|
|
892
|
+
async getImage(ref) {
|
|
893
|
+
return {
|
|
894
|
+
ref,
|
|
895
|
+
sourceType: "oci",
|
|
896
|
+
cached: false
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
async deleteImage(ref) {
|
|
900
|
+
return { ref };
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
// src/swagger.ts
|
|
905
|
+
import swagger from "@fastify/swagger";
|
|
906
|
+
import swaggerUi from "@fastify/swagger-ui";
|
|
907
|
+
var swaggerConfig = {
|
|
908
|
+
openapi: {
|
|
909
|
+
openapi: "3.0.0",
|
|
910
|
+
info: {
|
|
911
|
+
title: "OpenSandbox Gateway API",
|
|
912
|
+
description: "Sandbox lifecycle management backed by OpenSandbox",
|
|
913
|
+
version: "1.0.0"
|
|
914
|
+
},
|
|
915
|
+
servers: [{ url: "http://localhost:4002", description: "Local development" }],
|
|
916
|
+
components: {
|
|
917
|
+
securitySchemes: {
|
|
918
|
+
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "API Key" }
|
|
919
|
+
}
|
|
920
|
+
},
|
|
921
|
+
tags: [
|
|
922
|
+
{ name: "Sandboxes", description: "Sandbox lifecycle" },
|
|
923
|
+
{ name: "Files", description: "File operations inside sandboxes" },
|
|
924
|
+
{ name: "Shell", description: "Execute commands inside sandboxes" },
|
|
925
|
+
{ name: "Images", description: "Image management (stub)" },
|
|
926
|
+
{ name: "Volumes", description: "Named volume filesystem operations" },
|
|
927
|
+
{ name: "Health", description: "Service health check" }
|
|
928
|
+
]
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
var swaggerUiConfig = {
|
|
932
|
+
routePrefix: "/api-docs",
|
|
933
|
+
uiConfig: { docExpansion: "list", deepLinking: true },
|
|
934
|
+
staticCSP: true,
|
|
935
|
+
transformStaticCSP: (header) => header
|
|
936
|
+
};
|
|
937
|
+
async function configureSwagger(app) {
|
|
938
|
+
await app.register(swagger, swaggerConfig);
|
|
939
|
+
await app.register(swaggerUi, swaggerUiConfig);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/app.ts
|
|
943
|
+
async function buildApp({
|
|
944
|
+
apiKey
|
|
945
|
+
} = {}) {
|
|
946
|
+
const runtimeService = new OpenSandboxRuntimeService();
|
|
947
|
+
const imageService = new ImageService();
|
|
948
|
+
const app = fastify({
|
|
949
|
+
logger: false,
|
|
950
|
+
bodyLimit: Number(process.env["BODY_LIMIT"]) || 100 * 1024 * 1024
|
|
951
|
+
});
|
|
952
|
+
await app.register(cors, {
|
|
953
|
+
delegator: (request, callback) => {
|
|
954
|
+
callback(null, {
|
|
955
|
+
origin: true,
|
|
956
|
+
methods: request.headers["access-control-request-method"] ?? "*",
|
|
957
|
+
allowedHeaders: request.headers["access-control-request-headers"]
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
await app.register(sensible);
|
|
962
|
+
await app.register(multipart);
|
|
963
|
+
if (apiKey) {
|
|
964
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
965
|
+
if (request.method === "OPTIONS" || !request.url.startsWith("/api/")) {
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (request.headers.authorization !== `Bearer ${apiKey}`) {
|
|
969
|
+
await reply.code(401).send({
|
|
970
|
+
success: false,
|
|
971
|
+
error: { code: "UNAUTHORIZED", message: "Unauthorized" }
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
registerHealthRoutes(app);
|
|
977
|
+
registerSandboxRoutes(app, runtimeService);
|
|
978
|
+
registerImageRoutes(app, imageService);
|
|
979
|
+
registerVolumeFsRoutes(app);
|
|
980
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
981
|
+
const { statusCode, body } = toErrorResponse(error);
|
|
982
|
+
reply.status(statusCode).send(body);
|
|
983
|
+
});
|
|
984
|
+
await configureSwagger(app);
|
|
985
|
+
return app;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/lib/server-cli.ts
|
|
989
|
+
function parsePort(raw, source) {
|
|
990
|
+
const value = Number(raw);
|
|
991
|
+
if (!Number.isInteger(value) || value <= 0 || value > 65535) {
|
|
992
|
+
throw new Error(`Invalid value for ${source}: ${raw}`);
|
|
993
|
+
}
|
|
994
|
+
return value;
|
|
995
|
+
}
|
|
996
|
+
function isMissingOptionValue(raw) {
|
|
997
|
+
return !raw || raw.startsWith("--");
|
|
998
|
+
}
|
|
999
|
+
function parseServerArgs(argv) {
|
|
1000
|
+
const parsed = {};
|
|
1001
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1002
|
+
const token = argv[index];
|
|
1003
|
+
if (token === "--port") {
|
|
1004
|
+
const raw = argv[index + 1];
|
|
1005
|
+
if (isMissingOptionValue(raw)) {
|
|
1006
|
+
throw new Error("Missing value for --port");
|
|
1007
|
+
}
|
|
1008
|
+
parsed.port = parsePort(raw, "--port");
|
|
1009
|
+
index += 1;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (token === "--host") {
|
|
1013
|
+
const raw = argv[index + 1];
|
|
1014
|
+
if (isMissingOptionValue(raw)) {
|
|
1015
|
+
throw new Error("Missing value for --host");
|
|
1016
|
+
}
|
|
1017
|
+
parsed.host = raw;
|
|
1018
|
+
index += 1;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return parsed;
|
|
1022
|
+
}
|
|
1023
|
+
function resolveServerConfig({
|
|
1024
|
+
args,
|
|
1025
|
+
env
|
|
1026
|
+
}) {
|
|
1027
|
+
return {
|
|
1028
|
+
host: args.host ?? env.HOST ?? "0.0.0.0",
|
|
1029
|
+
port: args.port ?? (env.PORT ? parsePort(env.PORT, "PORT") : 4002),
|
|
1030
|
+
apiKey: env.GATEWAY_API_KEY ?? env.MICROSANDBOX_API_KEY
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/server.ts
|
|
1035
|
+
async function startServer({
|
|
1036
|
+
argv = process.argv.slice(2),
|
|
1037
|
+
env = process.env,
|
|
1038
|
+
app
|
|
1039
|
+
} = {}) {
|
|
1040
|
+
const config = resolveServerConfig({ args: parseServerArgs(argv), env });
|
|
1041
|
+
const resolvedApp = app ?? await buildApp({ apiKey: config.apiKey });
|
|
1042
|
+
const removeSignalHandlers = () => {
|
|
1043
|
+
process.off("SIGINT", onSigInt);
|
|
1044
|
+
process.off("SIGTERM", onSigTerm);
|
|
1045
|
+
};
|
|
1046
|
+
const close = async () => {
|
|
1047
|
+
removeSignalHandlers();
|
|
1048
|
+
return resolvedApp.close();
|
|
1049
|
+
};
|
|
1050
|
+
const onSigInt = () => {
|
|
1051
|
+
void shutdown(0);
|
|
1052
|
+
};
|
|
1053
|
+
const onSigTerm = () => {
|
|
1054
|
+
void shutdown(0);
|
|
1055
|
+
};
|
|
1056
|
+
const shutdown = async (code) => {
|
|
1057
|
+
try {
|
|
1058
|
+
await close();
|
|
1059
|
+
process.exit(code);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
console.error("Failed to shutdown opensandbox-gateway cleanly", error);
|
|
1062
|
+
process.exit(1);
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
process.once("SIGINT", onSigInt);
|
|
1066
|
+
process.once("SIGTERM", onSigTerm);
|
|
1067
|
+
try {
|
|
1068
|
+
await resolvedApp.listen({ port: config.port, host: config.host });
|
|
1069
|
+
return { listen: resolvedApp.listen.bind(resolvedApp), close };
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
removeSignalHandlers();
|
|
1072
|
+
throw error;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export {
|
|
1077
|
+
OpenSandboxRuntimeService,
|
|
1078
|
+
buildApp,
|
|
1079
|
+
startServer
|
|
1080
|
+
};
|
|
1081
|
+
//# sourceMappingURL=chunk-FTPJIUJT.mjs.map
|