@axiom-lattice/microsandbox-service 0.0.51 → 0.0.53
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 +12 -0
- package/dist/{chunk-IKWYDXRG.mjs → chunk-TRJCQNF4.mjs} +321 -50
- package/dist/chunk-TRJCQNF4.mjs.map +1 -0
- package/dist/{chunk-OUK4PEG5.mjs → chunk-ZII3BGB2.mjs} +2 -2
- package/dist/cli.mjs +2 -2
- package/dist/index.d.mts +9 -0
- package/dist/index.mjs +1 -1
- package/dist/server.mjs +2 -2
- package/package.json +1 -1
- package/src/__tests__/MicrosandboxRuntimeService.test.ts +72 -0
- package/src/__tests__/app.test.ts +30 -0
- package/src/__tests__/volume-delete.test.ts +167 -0
- package/src/controllers/sandbox.ts +10 -0
- package/src/controllers/volume-fs.ts +130 -0
- package/src/routes/sandbox.ts +1 -0
- package/src/routes/volume-fs.ts +2 -0
- package/src/schemas/sandbox.ts +2 -0
- package/src/schemas/volume-fs.ts +8 -0
- package/src/services/MicrosandboxRuntimeService.ts +66 -0
- package/src/services/SandboxRegistry.ts +124 -25
- package/src/types/runtime-service.ts +1 -0
- package/dist/chunk-IKWYDXRG.mjs.map +0 -1
- /package/dist/{chunk-OUK4PEG5.mjs.map → chunk-ZII3BGB2.mjs.map} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @axiom-lattice/microsandbox-service
|
|
2
2
|
|
|
3
|
+
## 0.0.53
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e2f0a28: Add native `delete_file` support across filesystem backends, sandbox transports, and React middleware metadata.
|
|
8
|
+
|
|
9
|
+
## 0.0.52
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 18749ee: fix sandbox issue
|
|
14
|
+
|
|
3
15
|
## 0.0.51
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -6,6 +6,9 @@ var DEFAULT_IMAGE = process.env.MICROSANDBOX_IMAGE ?? "daytonaio/sandbox:0.6.0";
|
|
|
6
6
|
var DEFAULT_IDLE_TIMEOUT_MS = Number(process.env.MICROSANDBOX_IDLE_TIMEOUT_MS) || 6e5;
|
|
7
7
|
var DEFAULT_REMOVE_AFTER_PAUSED_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
8
8
|
var SWEEP_INTERVAL_MS = 3e4;
|
|
9
|
+
var SWEEP_STOP_TIMEOUT_MS = 6e4;
|
|
10
|
+
var HEALTH_CHECK_TIMEOUT_MS = 2e3;
|
|
11
|
+
var HEALTH_CHECK_INTERVAL_MS = 3e4;
|
|
9
12
|
var SANDBOX_AGENT_SOCKET_DIR = "/root/.microsandbox/run/agent";
|
|
10
13
|
function extractConfig(configJson) {
|
|
11
14
|
try {
|
|
@@ -37,6 +40,7 @@ var SandboxRegistry = class {
|
|
|
37
40
|
constructor(idleTimeoutMs) {
|
|
38
41
|
this.handles = /* @__PURE__ */ new Map();
|
|
39
42
|
this.creating = /* @__PURE__ */ new Map();
|
|
43
|
+
this.stopping = /* @__PURE__ */ new Set();
|
|
40
44
|
this.idleTimeoutMs = idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
41
45
|
this.cleanupOrphanedSockets();
|
|
42
46
|
if (this.idleTimeoutMs > 0) {
|
|
@@ -48,14 +52,22 @@ var SandboxRegistry = class {
|
|
|
48
52
|
const now = Date.now();
|
|
49
53
|
for (const [name, entry] of this.handles) {
|
|
50
54
|
if (entry.native && now - entry.lastAccessedAt > this.idleTimeoutMs) {
|
|
55
|
+
if (this.stopping.has(name)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
51
58
|
console.log(`[SandboxRegistry] sweeper pausing idle sandbox name=${name} idleMs=${now - entry.lastAccessedAt}`);
|
|
52
|
-
|
|
59
|
+
this.stopping.add(name);
|
|
60
|
+
void Promise.race([
|
|
61
|
+
entry.native.stop(),
|
|
62
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("stop timed out")), SWEEP_STOP_TIMEOUT_MS))
|
|
63
|
+
]).then(() => {
|
|
53
64
|
console.log(`[SandboxRegistry] sweeper paused name=${name}`);
|
|
54
65
|
}).catch((err) => {
|
|
55
66
|
console.log(`[SandboxRegistry] sweeper pause failed name=${name} err=${err.message}`);
|
|
56
67
|
}).finally(() => {
|
|
57
68
|
entry.native = null;
|
|
58
69
|
entry.pausedAt = Date.now();
|
|
70
|
+
this.stopping.delete(name);
|
|
59
71
|
});
|
|
60
72
|
} else if (!entry.native && entry.pausedAt && now - entry.pausedAt > DEFAULT_REMOVE_AFTER_PAUSED_MS) {
|
|
61
73
|
console.log(`[SandboxRegistry] sweeper removing paused sandbox name=${name} pausedMs=${now - entry.pausedAt}`);
|
|
@@ -104,15 +116,30 @@ var SandboxRegistry = class {
|
|
|
104
116
|
}
|
|
105
117
|
}
|
|
106
118
|
async removeAndDelete(name) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
try {
|
|
120
|
+
const handle = await Promise.race([
|
|
121
|
+
Sandbox.get(name),
|
|
122
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Sandbox.get timed out")), SWEEP_STOP_TIMEOUT_MS))
|
|
123
|
+
]);
|
|
124
|
+
if (handle.status === "running") {
|
|
125
|
+
try {
|
|
126
|
+
const native = await Promise.race([
|
|
127
|
+
handle.connect(),
|
|
128
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("handle.connect timed out")), SWEEP_STOP_TIMEOUT_MS))
|
|
129
|
+
]);
|
|
130
|
+
await Promise.race([
|
|
131
|
+
native.stop(),
|
|
132
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("stop timed out")), SWEEP_STOP_TIMEOUT_MS))
|
|
133
|
+
]);
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
113
136
|
}
|
|
137
|
+
await Promise.race([
|
|
138
|
+
Sandbox.remove(name),
|
|
139
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Sandbox.remove timed out")), SWEEP_STOP_TIMEOUT_MS))
|
|
140
|
+
]);
|
|
141
|
+
} catch {
|
|
114
142
|
}
|
|
115
|
-
await Sandbox.remove(name);
|
|
116
143
|
this.remove(name);
|
|
117
144
|
this.cleanupOrphanedSockets();
|
|
118
145
|
}
|
|
@@ -133,11 +160,26 @@ var SandboxRegistry = class {
|
|
|
133
160
|
entry.native = null;
|
|
134
161
|
entry.pausedAt = Date.now();
|
|
135
162
|
}
|
|
163
|
+
async healthCheck(native) {
|
|
164
|
+
const fs2 = native.fs();
|
|
165
|
+
try {
|
|
166
|
+
await Promise.race([
|
|
167
|
+
fs2.list("/"),
|
|
168
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("health check timed out")), HEALTH_CHECK_TIMEOUT_MS))
|
|
169
|
+
]);
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
136
175
|
async waitForFs(native, maxRetries = 10) {
|
|
137
176
|
const fs2 = native.fs();
|
|
138
177
|
for (let i = 0; i < maxRetries; i++) {
|
|
139
178
|
try {
|
|
140
|
-
await
|
|
179
|
+
await Promise.race([
|
|
180
|
+
fs2.list("/"),
|
|
181
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("FS check timed out")), 5e3))
|
|
182
|
+
]);
|
|
141
183
|
return;
|
|
142
184
|
} catch {
|
|
143
185
|
await new Promise((r) => setTimeout(r, 200));
|
|
@@ -150,9 +192,10 @@ var SandboxRegistry = class {
|
|
|
150
192
|
const requestEnv = createOptions.env;
|
|
151
193
|
if (volumeDefs && Object.keys(volumeDefs).length > 0) {
|
|
152
194
|
const existingGuests = new Set((originalConfig?.mounts ?? []).map((m) => m.guest));
|
|
153
|
-
const requestedGuests =
|
|
154
|
-
|
|
155
|
-
|
|
195
|
+
const requestedGuests = Object.keys(volumeDefs);
|
|
196
|
+
const missing = requestedGuests.filter((g) => !existingGuests.has(g));
|
|
197
|
+
if (missing.length > 0) {
|
|
198
|
+
console.log(`[SandboxRegistry] volumes mismatch, missing=${missing.join(",")}, recreating...`);
|
|
156
199
|
return true;
|
|
157
200
|
}
|
|
158
201
|
}
|
|
@@ -169,10 +212,29 @@ var SandboxRegistry = class {
|
|
|
169
212
|
async ensure(name, createOptions) {
|
|
170
213
|
const tStart = Date.now();
|
|
171
214
|
const cached = this.handles.get(name);
|
|
172
|
-
if (cached?.native) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
215
|
+
if (cached?.native && !this.stopping.has(name)) {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
if (!cached.lastCheckedAt || now - cached.lastCheckedAt > HEALTH_CHECK_INTERVAL_MS) {
|
|
218
|
+
const tCheck = Date.now();
|
|
219
|
+
const ok2 = await this.healthCheck(cached.native);
|
|
220
|
+
cached.lastCheckedAt = now;
|
|
221
|
+
if (ok2) {
|
|
222
|
+
cached.lastAccessedAt = now;
|
|
223
|
+
console.log(`[SandboxRegistry] ensure name=${name} CACHE_HIT elapsed=${Date.now() - tStart}ms health_ok elapsed=${Date.now() - tCheck}ms`);
|
|
224
|
+
return cached.native;
|
|
225
|
+
}
|
|
226
|
+
console.log(`[SandboxRegistry] ensure name=${name} HEALTH_FAIL will_recreate elapsed=${Date.now() - tStart}ms`);
|
|
227
|
+
cached.native = null;
|
|
228
|
+
cached.broken = true;
|
|
229
|
+
} else {
|
|
230
|
+
cached.lastAccessedAt = now;
|
|
231
|
+
console.log(`[SandboxRegistry] ensure name=${name} CACHE_HIT elapsed=${Date.now() - tStart}ms`);
|
|
232
|
+
return cached.native;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (cached?.native && this.stopping.has(name)) {
|
|
236
|
+
console.log(`[SandboxRegistry] ensure name=${name} STALE_NATIVE (being stopped), forcing revive`);
|
|
237
|
+
cached.native = null;
|
|
176
238
|
}
|
|
177
239
|
if (cached && !cached.native) {
|
|
178
240
|
cached.pausedAt = void 0;
|
|
@@ -191,44 +253,62 @@ var SandboxRegistry = class {
|
|
|
191
253
|
const memoryMib = createOptions?.memoryMib ?? 512;
|
|
192
254
|
const env = createOptions?.env ?? void 0;
|
|
193
255
|
let originalConfig;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
256
|
+
const existing = this.handles.get(name);
|
|
257
|
+
if (existing?.broken) {
|
|
258
|
+
console.log(`[SandboxRegistry] ensure name=${name} BROKEN removing before recreate`);
|
|
259
|
+
await this.removeAndDelete(name);
|
|
260
|
+
this.handles.set(name, { native: null, lastAccessedAt: Date.now(), createOptions: existing.createOptions ?? cached?.createOptions });
|
|
261
|
+
} else {
|
|
262
|
+
try {
|
|
263
|
+
const tGet = Date.now();
|
|
264
|
+
const handle = await Sandbox.get(name);
|
|
265
|
+
console.log(`[SandboxRegistry] Sandbox.get name=${name} status=${handle.status} elapsed=${Date.now() - tGet}ms`);
|
|
266
|
+
originalConfig = extractConfig(handle.configJson);
|
|
267
|
+
if (handle.status === "running" || handle.status === "stopped") {
|
|
268
|
+
const isNewConnection = !cached;
|
|
269
|
+
if (isNewConnection && createOptions && this.hasConfigMismatch(originalConfig, createOptions)) {
|
|
270
|
+
await this.removeAndDelete(name);
|
|
271
|
+
native = void 0;
|
|
272
|
+
} else if (handle.status === "running") {
|
|
273
|
+
const tConnect = Date.now();
|
|
274
|
+
native = await Promise.race([
|
|
275
|
+
handle.connect(),
|
|
276
|
+
new Promise(
|
|
277
|
+
(_, reject) => setTimeout(() => reject(new Error("handle.connect timed out")), SWEEP_STOP_TIMEOUT_MS)
|
|
278
|
+
)
|
|
279
|
+
]);
|
|
280
|
+
console.log(`[SandboxRegistry] handle.connect name=${name} elapsed=${Date.now() - tConnect}ms`);
|
|
216
281
|
const tFs = Date.now();
|
|
217
282
|
await this.waitForFs(native);
|
|
218
283
|
console.log(`[SandboxRegistry] waitForFs name=${name} elapsed=${Date.now() - tFs}ms`);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
284
|
+
} else {
|
|
285
|
+
try {
|
|
286
|
+
const tStart2 = Date.now();
|
|
287
|
+
native = await Promise.race([
|
|
288
|
+
Sandbox.startDetached(name),
|
|
289
|
+
new Promise(
|
|
290
|
+
(_, reject) => setTimeout(() => reject(new Error("startDetached timed out")), SWEEP_STOP_TIMEOUT_MS)
|
|
291
|
+
)
|
|
292
|
+
]);
|
|
293
|
+
console.log(`[SandboxRegistry] startDetached name=${name} elapsed=${Date.now() - tStart2}ms`);
|
|
294
|
+
const tFs = Date.now();
|
|
295
|
+
await this.waitForFs(native);
|
|
296
|
+
console.log(`[SandboxRegistry] waitForFs name=${name} elapsed=${Date.now() - tFs}ms`);
|
|
297
|
+
} catch {
|
|
298
|
+
if (!native) {
|
|
299
|
+
await this.removeAndDelete(name);
|
|
300
|
+
}
|
|
222
301
|
}
|
|
223
302
|
}
|
|
303
|
+
} else if (handle.status === "crashed" || handle.status === "draining") {
|
|
304
|
+
await this.removeAndDelete(name);
|
|
305
|
+
} else {
|
|
306
|
+
await this.removeAndDelete(name);
|
|
224
307
|
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
await this.removeAndDelete(name);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.log(`[SandboxRegistry] ensure name=${name} existing_check failed err=${err.message}`);
|
|
310
|
+
native = void 0;
|
|
229
311
|
}
|
|
230
|
-
} catch {
|
|
231
|
-
native = void 0;
|
|
232
312
|
}
|
|
233
313
|
console.log(`[SandboxRegistry] phase=existing_check name=${name} hasNative=${!!native} elapsed=${Date.now() - tCreate}ms`);
|
|
234
314
|
if (!native) {
|
|
@@ -294,7 +374,12 @@ var SandboxRegistry = class {
|
|
|
294
374
|
}
|
|
295
375
|
applyVolumes(builder);
|
|
296
376
|
const tCreateInner = Date.now();
|
|
297
|
-
const result = await
|
|
377
|
+
const result = await Promise.race([
|
|
378
|
+
builder.create(),
|
|
379
|
+
new Promise(
|
|
380
|
+
(_, reject) => setTimeout(() => reject(new Error("builder.create timed out")), SWEEP_STOP_TIMEOUT_MS)
|
|
381
|
+
)
|
|
382
|
+
]);
|
|
298
383
|
console.log(
|
|
299
384
|
`[SandboxRegistry] builder.create name=${name} image=${resolvedImage} elapsed=${Date.now() - tCreateInner}ms envKeys=${resolvedEnv ? Object.keys(resolvedEnv).join(",") : "none"} volumes=${volumeDefs ? Object.keys(volumeDefs).join(",") : "none"}`
|
|
300
385
|
);
|
|
@@ -316,7 +401,13 @@ var SandboxRegistry = class {
|
|
|
316
401
|
console.log(`[SandboxRegistry] waitForFs name=${name} elapsed=${Date.now() - tFs}ms`);
|
|
317
402
|
}
|
|
318
403
|
const totalElapsed = Date.now() - tStart;
|
|
319
|
-
|
|
404
|
+
const entry = {
|
|
405
|
+
native,
|
|
406
|
+
lastAccessedAt: Date.now(),
|
|
407
|
+
lastCheckedAt: Date.now(),
|
|
408
|
+
createOptions: createOptions ?? cached?.createOptions
|
|
409
|
+
};
|
|
410
|
+
this.handles.set(name, entry);
|
|
320
411
|
console.log(`[SandboxRegistry] ensure DONE name=${name} total=${totalElapsed}ms idleTimeout=${Math.round(this.idleTimeoutMs / 1e3)}s`);
|
|
321
412
|
return native;
|
|
322
413
|
})();
|
|
@@ -418,6 +509,24 @@ function parseMsbInspectEnvVolumes(stdout) {
|
|
|
418
509
|
}
|
|
419
510
|
return { env, volumes };
|
|
420
511
|
}
|
|
512
|
+
function isFileNotFoundError(error) {
|
|
513
|
+
if (typeof error === "string") {
|
|
514
|
+
const message2 = error.toLowerCase();
|
|
515
|
+
return message2.includes("not found") || message2.includes("no such file");
|
|
516
|
+
}
|
|
517
|
+
if (!error || typeof error !== "object") {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
const candidate = error;
|
|
521
|
+
if (candidate.code === "ENOENT") {
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
if (typeof candidate.message !== "string") {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
const message = candidate.message.toLowerCase();
|
|
528
|
+
return message.includes("not found") || message.includes("no such file");
|
|
529
|
+
}
|
|
421
530
|
var MicrosandboxRuntimeService = class {
|
|
422
531
|
constructor(deps = {}) {
|
|
423
532
|
this.deps = deps;
|
|
@@ -434,6 +543,13 @@ var MicrosandboxRuntimeService = class {
|
|
|
434
543
|
}
|
|
435
544
|
return path2;
|
|
436
545
|
}
|
|
546
|
+
resolveDeletePath(path2) {
|
|
547
|
+
const resolvedPath = this.resolvePath(path2);
|
|
548
|
+
if (resolvedPath.replace(/\\/g, "/").split("/").includes("..")) {
|
|
549
|
+
throw new HttpError(403, "PATH_TRAVERSAL", `Path traversal denied: ${path2}`);
|
|
550
|
+
}
|
|
551
|
+
return resolvedPath;
|
|
552
|
+
}
|
|
437
553
|
toListItem(info) {
|
|
438
554
|
const config = parseConfigJson(info.configJson);
|
|
439
555
|
const env = config.env ? Array.isArray(config.env) ? config.env : config.env : [];
|
|
@@ -639,6 +755,40 @@ var MicrosandboxRuntimeService = class {
|
|
|
639
755
|
await native.fs().write(resolvedPath, Buffer.from(content));
|
|
640
756
|
return { path: resolvedPath };
|
|
641
757
|
}
|
|
758
|
+
async deleteFile(sandboxName, path2) {
|
|
759
|
+
const resolvedPath = this.resolveDeletePath(path2);
|
|
760
|
+
const native = await this.getOrEnsureNative(sandboxName);
|
|
761
|
+
const sandboxFs = native.fs();
|
|
762
|
+
let metadata;
|
|
763
|
+
try {
|
|
764
|
+
metadata = await sandboxFs.stat(resolvedPath);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (isFileNotFoundError(error)) {
|
|
767
|
+
throw new HttpError(404, "FILE_NOT_FOUND", `File '${path2}' not found`);
|
|
768
|
+
}
|
|
769
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
770
|
+
throw new HttpError(500, "FILE_STAT_ERROR", `Failed to inspect '${path2}': ${message}`);
|
|
771
|
+
}
|
|
772
|
+
if (metadata.kind === "symlink") {
|
|
773
|
+
throw new HttpError(400, "FILE_NOT_REGULAR", `Cannot delete '${path2}': symlinks are not allowed`);
|
|
774
|
+
}
|
|
775
|
+
if (metadata.kind === "directory") {
|
|
776
|
+
throw new HttpError(400, "FILE_NOT_REGULAR", `Cannot delete '${path2}': target is a directory`);
|
|
777
|
+
}
|
|
778
|
+
if (metadata.kind !== "file") {
|
|
779
|
+
throw new HttpError(400, "FILE_NOT_REGULAR", `Cannot delete '${path2}': target is not a regular file`);
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
await sandboxFs.remove(resolvedPath);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
if (isFileNotFoundError(error)) {
|
|
785
|
+
throw new HttpError(404, "FILE_NOT_FOUND", `File '${path2}' not found`);
|
|
786
|
+
}
|
|
787
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
788
|
+
throw new HttpError(500, "FILE_DELETE_ERROR", `Failed to delete '${path2}': ${message}`);
|
|
789
|
+
}
|
|
790
|
+
return { path: resolvedPath };
|
|
791
|
+
}
|
|
642
792
|
async listPath(sandboxName, path2, recursive) {
|
|
643
793
|
const resolvedPath = this.resolvePath(path2);
|
|
644
794
|
const native = await this.getOrEnsureNative(sandboxName);
|
|
@@ -862,6 +1012,7 @@ var readFileSchema = sandboxAndPathSchema;
|
|
|
862
1012
|
var writeFileSchema = sandboxAndPathSchema.extend({
|
|
863
1013
|
content: z2.string()
|
|
864
1014
|
});
|
|
1015
|
+
var deleteFileSchema = sandboxAndPathSchema;
|
|
865
1016
|
var listPathSchema = sandboxAndPathSchema.extend({
|
|
866
1017
|
recursive: z2.boolean().optional()
|
|
867
1018
|
});
|
|
@@ -942,6 +1093,13 @@ function createSandboxController(runtimeService) {
|
|
|
942
1093
|
const body = writeFileSchema.parse(request.body ?? {});
|
|
943
1094
|
return reply.send(ok(await runtimeService.writeFile(body.sandboxName, body.path, body.content)));
|
|
944
1095
|
},
|
|
1096
|
+
deleteFile: async (request, reply) => {
|
|
1097
|
+
const parsed = deleteFileSchema.safeParse(request.body ?? {});
|
|
1098
|
+
if (!parsed.success) {
|
|
1099
|
+
throw new HttpError(400, "VALIDATION_ERROR", parsed.error.message);
|
|
1100
|
+
}
|
|
1101
|
+
return reply.send(ok(await runtimeService.deleteFile(parsed.data.sandboxName, parsed.data.path)));
|
|
1102
|
+
},
|
|
945
1103
|
listPath: async (request, reply) => {
|
|
946
1104
|
const body = listPathSchema.parse(request.body ?? {});
|
|
947
1105
|
return reply.send(ok(await runtimeService.listPath(body.sandboxName, body.path, body.recursive)));
|
|
@@ -996,6 +1154,7 @@ function registerSandboxRoutes(app, runtimeService) {
|
|
|
996
1154
|
app.post("/api/sandboxes/:name/logs", controller.getSandboxLogs);
|
|
997
1155
|
app.post("/api/files/read", controller.readFile);
|
|
998
1156
|
app.post("/api/files/write", controller.writeFile);
|
|
1157
|
+
app.post("/api/files/delete", controller.deleteFile);
|
|
999
1158
|
app.post("/api/files/list", controller.listPath);
|
|
1000
1159
|
app.post("/api/files/find", controller.findFiles);
|
|
1001
1160
|
app.post("/api/files/search", controller.searchInFile);
|
|
@@ -1021,6 +1180,9 @@ var volumeFsWriteSchema = z3.object({
|
|
|
1021
1180
|
path: z3.string(),
|
|
1022
1181
|
content: z3.string()
|
|
1023
1182
|
});
|
|
1183
|
+
var volumeFsDeleteSchema = z3.object({
|
|
1184
|
+
path: z3.string().min(1)
|
|
1185
|
+
});
|
|
1024
1186
|
var volumeFsListSchema = z3.object({
|
|
1025
1187
|
path: z3.string()
|
|
1026
1188
|
});
|
|
@@ -1031,12 +1193,46 @@ var volumeFsUploadSchema = z3.object({
|
|
|
1031
1193
|
var volumeFsDownloadSchema = z3.object({
|
|
1032
1194
|
path: z3.string()
|
|
1033
1195
|
});
|
|
1196
|
+
var volumeFsMkdirSchema = z3.object({
|
|
1197
|
+
path: z3.string()
|
|
1198
|
+
});
|
|
1034
1199
|
|
|
1035
1200
|
// src/controllers/volume-fs.ts
|
|
1036
1201
|
var MSB_DATA_DIR = path.join(os.homedir(), ".microsandbox");
|
|
1202
|
+
var VOLUME_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
1203
|
+
function assertVolumeName(name) {
|
|
1204
|
+
if (name === "." || name === ".." || !VOLUME_NAME_PATTERN.test(name)) {
|
|
1205
|
+
throw new HttpError(400, "INVALID_VOLUME_NAME", "Invalid volume name");
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function assertRegularVolumeFile(guestPath, stat) {
|
|
1209
|
+
if (stat.isSymbolicLink()) {
|
|
1210
|
+
throw new HttpError(
|
|
1211
|
+
400,
|
|
1212
|
+
"VOLUME_FILE_NOT_REGULAR",
|
|
1213
|
+
`Cannot delete '${guestPath}': symlinks are not allowed`
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
if (stat.isDirectory()) {
|
|
1217
|
+
throw new HttpError(
|
|
1218
|
+
400,
|
|
1219
|
+
"VOLUME_FILE_NOT_REGULAR",
|
|
1220
|
+
`Cannot delete '${guestPath}': target is a directory`
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
if (!stat.isFile()) {
|
|
1224
|
+
throw new HttpError(
|
|
1225
|
+
400,
|
|
1226
|
+
"VOLUME_FILE_NOT_REGULAR",
|
|
1227
|
+
`Cannot delete '${guestPath}': target is not a regular file`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1037
1231
|
async function resolveVolumeHostPath(name) {
|
|
1232
|
+
assertVolumeName(name);
|
|
1038
1233
|
try {
|
|
1039
1234
|
const handle = await Volume2.get(name);
|
|
1235
|
+
assertVolumeName(handle.name);
|
|
1040
1236
|
const vol = await Volume2.builder(handle.name).create();
|
|
1041
1237
|
return vol.path;
|
|
1042
1238
|
} catch {
|
|
@@ -1056,6 +1252,23 @@ function resolveGuestPath(hostRoot, guestPath) {
|
|
|
1056
1252
|
}
|
|
1057
1253
|
return resolved;
|
|
1058
1254
|
}
|
|
1255
|
+
function resolveDeleteGuestPath(hostRoot, guestPath) {
|
|
1256
|
+
const pathSegments = guestPath.replace(/\\/g, "/").split("/");
|
|
1257
|
+
if (pathSegments.includes("..")) {
|
|
1258
|
+
throw new HttpError(403, "PATH_TRAVERSAL", "Path traversal detected");
|
|
1259
|
+
}
|
|
1260
|
+
return resolveGuestPath(hostRoot, guestPath);
|
|
1261
|
+
}
|
|
1262
|
+
async function assertDeleteParentContained(hostRoot, fullPath) {
|
|
1263
|
+
const [rootPath, parentPath] = await Promise.all([
|
|
1264
|
+
fs.realpath(hostRoot),
|
|
1265
|
+
fs.realpath(path.dirname(fullPath))
|
|
1266
|
+
]);
|
|
1267
|
+
const relative = path.relative(rootPath, parentPath);
|
|
1268
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
1269
|
+
throw new HttpError(403, "PATH_TRAVERSAL", "Path traversal detected");
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1059
1272
|
function createVolumeFsController() {
|
|
1060
1273
|
return {
|
|
1061
1274
|
readFile: async (request, reply) => {
|
|
@@ -1091,6 +1304,46 @@ function createVolumeFsController() {
|
|
|
1091
1304
|
throw new HttpError(500, "VOLUME_WRITE_ERROR", `Failed to write to volume '${name}': ${String(err)}`);
|
|
1092
1305
|
}
|
|
1093
1306
|
},
|
|
1307
|
+
deleteFile: async (request, reply) => {
|
|
1308
|
+
const { name } = request.params;
|
|
1309
|
+
try {
|
|
1310
|
+
const { path: guestPath } = volumeFsDeleteSchema.parse(request.body ?? {});
|
|
1311
|
+
const hostRoot = await resolveVolumeHostPath(name);
|
|
1312
|
+
const fullPath = resolveDeleteGuestPath(hostRoot, guestPath);
|
|
1313
|
+
await assertDeleteParentContained(hostRoot, fullPath);
|
|
1314
|
+
const stat = await fs.lstat(fullPath);
|
|
1315
|
+
assertRegularVolumeFile(guestPath, stat);
|
|
1316
|
+
const currentStat = await fs.lstat(fullPath);
|
|
1317
|
+
assertRegularVolumeFile(guestPath, currentStat);
|
|
1318
|
+
if (currentStat.dev !== stat.dev || currentStat.ino !== stat.ino) {
|
|
1319
|
+
throw new HttpError(
|
|
1320
|
+
409,
|
|
1321
|
+
"VOLUME_FILE_CHANGED",
|
|
1322
|
+
`Cannot delete '${guestPath}': target changed during deletion`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
await assertDeleteParentContained(hostRoot, fullPath);
|
|
1326
|
+
await fs.unlink(fullPath);
|
|
1327
|
+
return reply.send(ok({ path: guestPath }));
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
if (err instanceof z4.ZodError) {
|
|
1330
|
+
throw new HttpError(400, "VALIDATION_ERROR", err.message);
|
|
1331
|
+
}
|
|
1332
|
+
if (err instanceof HttpError) throw err;
|
|
1333
|
+
if (err.code === "ENOENT") {
|
|
1334
|
+
throw new HttpError(
|
|
1335
|
+
404,
|
|
1336
|
+
"VOLUME_FILE_NOT_FOUND",
|
|
1337
|
+
`File not found in volume '${name}'`
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
throw new HttpError(
|
|
1341
|
+
500,
|
|
1342
|
+
"VOLUME_DELETE_ERROR",
|
|
1343
|
+
`Failed to delete from volume '${name}': ${String(err)}`
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
},
|
|
1094
1347
|
listPath: async (request, reply) => {
|
|
1095
1348
|
const { name } = request.params;
|
|
1096
1349
|
try {
|
|
@@ -1147,6 +1400,22 @@ function createVolumeFsController() {
|
|
|
1147
1400
|
if (err instanceof HttpError) throw err;
|
|
1148
1401
|
throw new HttpError(500, "VOLUME_UPLOAD_ERROR", `Failed to upload to volume '${name}': ${String(err)}`);
|
|
1149
1402
|
}
|
|
1403
|
+
},
|
|
1404
|
+
mkdir: async (request, reply) => {
|
|
1405
|
+
const { name } = request.params;
|
|
1406
|
+
try {
|
|
1407
|
+
const { path: guestPath } = volumeFsMkdirSchema.parse(request.body ?? {});
|
|
1408
|
+
const hostRoot = await resolveVolumeHostPath(name);
|
|
1409
|
+
const fullPath = resolveGuestPath(hostRoot, guestPath);
|
|
1410
|
+
await fs.mkdir(fullPath, { recursive: true });
|
|
1411
|
+
return reply.send(ok({ path: guestPath }));
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
if (err instanceof z4.ZodError) {
|
|
1414
|
+
throw new HttpError(400, "VALIDATION_ERROR", err.message);
|
|
1415
|
+
}
|
|
1416
|
+
if (err instanceof HttpError) throw err;
|
|
1417
|
+
throw new HttpError(500, "VOLUME_MKDIR_ERROR", `Failed to create directory in volume '${name}': ${String(err)}`);
|
|
1418
|
+
}
|
|
1150
1419
|
}
|
|
1151
1420
|
};
|
|
1152
1421
|
}
|
|
@@ -1156,9 +1425,11 @@ function registerVolumeFsRoutes(app) {
|
|
|
1156
1425
|
const controller = createVolumeFsController();
|
|
1157
1426
|
app.post("/api/volumes/:name/fs/read", controller.readFile);
|
|
1158
1427
|
app.post("/api/volumes/:name/fs/write", controller.writeFile);
|
|
1428
|
+
app.post("/api/volumes/:name/fs/delete", controller.deleteFile);
|
|
1159
1429
|
app.post("/api/volumes/:name/fs/list", controller.listPath);
|
|
1160
1430
|
app.get("/api/volumes/:name/fs/download", controller.downloadFile);
|
|
1161
1431
|
app.post("/api/volumes/:name/fs/upload", controller.uploadFile);
|
|
1432
|
+
app.post("/api/volumes/:name/fs/mkdir", controller.mkdir);
|
|
1162
1433
|
}
|
|
1163
1434
|
|
|
1164
1435
|
// src/services/ImageService.ts
|
|
@@ -1331,4 +1602,4 @@ export {
|
|
|
1331
1602
|
MicrosandboxRuntimeService,
|
|
1332
1603
|
buildApp
|
|
1333
1604
|
};
|
|
1334
|
-
//# sourceMappingURL=chunk-
|
|
1605
|
+
//# sourceMappingURL=chunk-TRJCQNF4.mjs.map
|