|
1 | 1 | import { DurableObject } from "cloudflare:workers"; |
2 | 2 | import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env"; |
3 | | -import { APIError, normalizeUnknownError } from "@cheatcode/observability"; |
| 3 | +import { APIError, createLogger, normalizeUnknownError } from "@cheatcode/observability"; |
4 | 4 | import { |
5 | 5 | DaytonaApiError, |
6 | 6 | DaytonaClient, |
@@ -36,6 +36,8 @@ import { |
36 | 36 | commandToShellString, |
37 | 37 | type ProjectAllocatePortInput, |
38 | 38 | ProjectAllocatePortInputSchema, |
| 39 | + type ProjectCleanupWorkspaceInput, |
| 40 | + ProjectCleanupWorkspaceInputSchema, |
39 | 41 | type ProjectCodeServerInput, |
40 | 42 | ProjectCodeServerInputSchema, |
41 | 43 | type ProjectCreateBackupInput, |
@@ -879,6 +881,62 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> { |
879 | 881 | this.startedVerifiedAtMs = 0; |
880 | 882 | } |
881 | 883 |
|
| 884 | + // Best-effort teardown of ONE project's footprint inside the shared per-user sandbox: kills the |
| 885 | + // project's dev server, frees its port allocation, and removes its /workspace/<slug> folder. It |
| 886 | + // deliberately never destroys the sandbox or wipes DO state (that would nuke the user's OTHER |
| 887 | + // projects). Project deletion must not fail on cleanup, so every step is catch-and-log. |
| 888 | + public async cleanupProjectWorkspace(input: ProjectCleanupWorkspaceInput): Promise<void> { |
| 889 | + try { |
| 890 | + const { workspaceSlug } = ProjectCleanupWorkspaceInputSchema.parse(input); |
| 891 | + const id = await this.existingSandboxId(); |
| 892 | + if (!id) { |
| 893 | + // Nothing provisioned — no dev server, port, or folder to reclaim. |
| 894 | + return; |
| 895 | + } |
| 896 | + const slot = `${APP_PREVIEW_SLOT_PREFIX}${workspaceSlug}`; |
| 897 | + const port = (await this.portAllocation()).ports[workspaceSlug]; |
| 898 | + await this.deleteProcessRecord(id, slot); |
| 899 | + if (port !== undefined) { |
| 900 | + await this.deleteProcessesOnPort(id, port, slot); |
| 901 | + await this.unexposePort({ port }); |
| 902 | + } |
| 903 | + await this.freeProjectPort(workspaceSlug); |
| 904 | + await this.removeWorkspaceFolder(id, workspaceSlug); |
| 905 | + } catch (error) { |
| 906 | + createLogger().warn("project_workspace_cleanup_failed", { |
| 907 | + error: error instanceof Error ? error.message : "Unknown cleanup error", |
| 908 | + }); |
| 909 | + } |
| 910 | + } |
| 911 | + |
| 912 | + // Drop a project's dev-server port from the DO allocation table. webNext/mobileNext are left as-is |
| 913 | + // so freed ports are never recycled — a rebuilt project always takes the next fresh port. |
| 914 | + private async freeProjectPort(workspaceSlug: string): Promise<void> { |
| 915 | + const alloc = await this.portAllocation(); |
| 916 | + if (alloc.ports[workspaceSlug] === undefined) { |
| 917 | + return; |
| 918 | + } |
| 919 | + const ports = Object.fromEntries( |
| 920 | + Object.entries(alloc.ports).filter(([slug]) => slug !== workspaceSlug), |
| 921 | + ); |
| 922 | + await this.ctx.storage.put(PORT_ALLOC_KEY, { ...alloc, ports }); |
| 923 | + } |
| 924 | + |
| 925 | + // Best-effort `rm -rf` of a single project's folder. Guarded so the target is always a non-empty |
| 926 | + // child of /workspace and can never resolve to /workspace itself or escape it. |
| 927 | + private async removeWorkspaceFolder(id: string, workspaceSlug: string): Promise<void> { |
| 928 | + if (!isSingleWorkspaceSegment(workspaceSlug)) { |
| 929 | + return; |
| 930 | + } |
| 931 | + const path = `${WORKSPACE_DIR}/${workspaceSlug}`; |
| 932 | + await this.client() |
| 933 | + .execute(id, { |
| 934 | + command: `rm -rf ${shellQuote(path)}`, |
| 935 | + timeout: timeoutSeconds(DEFAULT_EXEC_TIMEOUT_MS), |
| 936 | + }) |
| 937 | + .catch(() => undefined); |
| 938 | + } |
| 939 | + |
882 | 940 | // ----- internals ----- |
883 | 941 |
|
884 | 942 | private client(): DaytonaClient { |
@@ -1417,6 +1475,12 @@ function shellQuote(arg: string): string { |
1417 | 1475 | return `'${arg.replaceAll("'", "'\\''")}'`; |
1418 | 1476 | } |
1419 | 1477 |
|
| 1478 | +// A workspace slug must be a single path segment so `/workspace/<slug>` cannot escape /workspace |
| 1479 | +// or resolve to /workspace itself (rm -rf guard). |
| 1480 | +function isSingleWorkspaceSegment(slug: string): boolean { |
| 1481 | + return slug.length > 0 && !slug.includes("/") && slug !== "." && slug !== ".."; |
| 1482 | +} |
| 1483 | + |
1420 | 1484 | function lowercaseExtension(path: string): string { |
1421 | 1485 | const filename = basename(path).toLowerCase(); |
1422 | 1486 | const dot = filename.lastIndexOf("."); |
|
0 commit comments