@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 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
- void entry.native.stop().then(() => {
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
- const handle = await Sandbox.get(name);
108
- if (handle.status === "running") {
109
- try {
110
- const native = await handle.connect();
111
- await native.stop();
112
- } catch {
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 fs2.list("/");
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 = new Set(Object.keys(volumeDefs));
154
- if (existingGuests.size !== requestedGuests.size || ![...requestedGuests].every((g) => existingGuests.has(g))) {
155
- console.log(`[SandboxRegistry] volumes mismatch, recreating...`);
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
- cached.lastAccessedAt = Date.now();
174
- console.log(`[SandboxRegistry] ensure name=${name} CACHE_HIT elapsed=${Date.now() - tStart}ms`);
175
- return cached.native;
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
- try {
195
- const tGet = Date.now();
196
- const handle = await Sandbox.get(name);
197
- console.log(`[SandboxRegistry] Sandbox.get name=${name} status=${handle.status} elapsed=${Date.now() - tGet}ms`);
198
- originalConfig = extractConfig(handle.configJson);
199
- if (handle.status === "running" || handle.status === "stopped") {
200
- const isNewConnection = !cached;
201
- if (isNewConnection && createOptions && this.hasConfigMismatch(originalConfig, createOptions)) {
202
- await this.removeAndDelete(name);
203
- native = void 0;
204
- } else if (handle.status === "running") {
205
- const tConnect = Date.now();
206
- native = await handle.connect();
207
- console.log(`[SandboxRegistry] handle.connect name=${name} elapsed=${Date.now() - tConnect}ms`);
208
- const tFs = Date.now();
209
- await this.waitForFs(native);
210
- console.log(`[SandboxRegistry] waitForFs name=${name} elapsed=${Date.now() - tFs}ms`);
211
- } else {
212
- try {
213
- const tStart2 = Date.now();
214
- native = await Sandbox.startDetached(name);
215
- console.log(`[SandboxRegistry] startDetached name=${name} elapsed=${Date.now() - tStart2}ms`);
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
- } catch {
220
- if (!native) {
221
- await this.removeAndDelete(name);
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
- } else if (handle.status === "crashed" || handle.status === "draining") {
226
- await this.removeAndDelete(name);
227
- } else {
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 builder.create();
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
- this.handles.set(name, { native, lastAccessedAt: Date.now(), createOptions: createOptions ?? cached?.createOptions });
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-IKWYDXRG.mjs.map
1605
+ //# sourceMappingURL=chunk-TRJCQNF4.mjs.map