Skip to content

Commit c8ada44

Browse files
committed
fix: harden per-user migration — 9 audit-confirmed defects
Adversarial re-review of the shipped migration found 9 correctness bugs (all in agent-worker + db + tools-docs). Fixes: HIGH - concurrency: free-tier users could not create a 2nd project — the shared per-user sandbox counted against maxConcurrentSandboxes. Reuse of an already-attached sandbox no longer consumes a concurrent slot. - console: processRecordsForRead exact-only — no more sibling-project log leak. - slug: reserve 'app' so a new 'app'-named project can't collide with the /workspace/app legacy sentinel (delete would rm -rf the shared folder). MEDIUM - db: partial unique index (user_id, workspace_slug) + createProject 23505 retry; migration 0012 backfills legacy null slugs first (p-<id>). - tools-docs: write the in-sandbox deliverable to runtimeContext.workspaceDir (was a name heuristic over the whole /workspace -> wrong project's folder). - mobile: guard the post-build Metro restart (transient error no longer fails a successful build). LOW / hygiene - slug-less general/direct-code runs use /workspace/app (slot parity w/ wake). - IDE file list scoped to the project's folder (was all of /workspace). - account deletion also tears down pre-migration per-project sandboxes. Gate: turbo lint typecheck build 64/64 green; migration 0012 verified locally.
1 parent c93792e commit c8ada44

13 files changed

Lines changed: 2172 additions & 109 deletions

File tree

apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ export async function runMastraStream(options: MastraStreamOptions): Promise<voi
6363
{
6464
artifacts: options.artifactRuntime,
6565
sandbox,
66-
workspaceDir: input.workspaceSlug ? `/workspace/${input.workspaceSlug}` : "/workspace",
66+
// Slug-less fallback is "/workspace/app" (not "/workspace") so deriveWorkspaceSlug
67+
// yields "app", matching what wake/status/console normalize a null slug to.
68+
workspaceDir: input.workspaceSlug
69+
? `/workspace/${input.workspaceSlug}`
70+
: "/workspace/app",
6771
},
6872
{
6973
agentDisplayName: input.agentDisplayName,

apps/agent-worker/src/durable-objects/agent-run-run-code-fallback.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export async function runRunCodeFallback({
2626
setRunStage("Running Python in the sandbox after model timeout.");
2727
const result = await executeRunCodeTool(createSandboxReadinessRunCodeInput(input.messageText), {
2828
sandbox,
29-
workspaceDir: input.workspaceSlug ? `/workspace/${input.workspaceSlug}` : "/workspace",
29+
// Slug-less fallback is "/workspace/app" so the slot matches wake/status/console (null → "app").
30+
workspaceDir: input.workspaceSlug ? `/workspace/${input.workspaceSlug}` : "/workspace/app",
3031
});
3132
await append({
3233
type: "text-delta",

apps/agent-worker/src/durable-objects/agent-run.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,10 @@ export class AgentRun extends DurableObject<AgentRunEnv> {
498498
logger,
499499
sandbox,
500500
setRunStage: (stage) => this.setRunStage(stage),
501-
...(input.workspaceSlug ? { workspaceDir: `/workspace/${input.workspaceSlug}` } : {}),
501+
// Slug-less fallback is "/workspace/app" so the dev-server slot matches wake/status/console.
502+
workspaceDir: input.workspaceSlug
503+
? `/workspace/${input.workspaceSlug}`
504+
: "/workspace/app",
502505
},
503506
directRunCodeInput,
504507
);
@@ -588,14 +591,22 @@ export class AgentRun extends DurableObject<AgentRunEnv> {
588591
// stream is done to re-crawl the finished app onto the (unchanged) preview URL. Web/Next.js
589592
// hot-reloads via polling and needs no restart.
590593
if (input.projectMode === "app-builder-mobile") {
591-
await restartMobilePreview({
592-
append: (chunk) => this.append(chunk),
593-
env: this.env,
594-
input,
595-
logger,
596-
sandbox,
597-
setRunStage: (stage) => this.setRunStage(stage),
598-
});
594+
// Best-effort: the answer already streamed and the preview URL is stable, so a transient
595+
// Daytona control-plane error here must not flip an already-successful mobile run to failed.
596+
try {
597+
await restartMobilePreview({
598+
append: (chunk) => this.append(chunk),
599+
env: this.env,
600+
input,
601+
logger,
602+
sandbox,
603+
setRunStage: (stage) => this.setRunStage(stage),
604+
});
605+
} catch (error) {
606+
logger.warn("mobile_preview_restart_failed", {
607+
error: error instanceof Error ? error.message : String(error),
608+
});
609+
}
599610
}
600611
await snapshotAppBuilderWorkspace({
601612
env: this.env,

apps/agent-worker/src/durable-objects/project-sandbox.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,25 +1396,12 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
13961396
return parsed.success ? parsed.data : null;
13971397
}
13981398

1399+
// Exact slot only: the console route always sends a fully-qualified `app-preview:<slug>` (null
1400+
// slugs normalized to `app-preview:app`), so any sibling-slot fallback would leak another
1401+
// project's dev-server logs into this project's Console. A missing record yields an empty snapshot.
13991402
private async processRecordsForRead(name: string): Promise<NamedProcessRecord[]> {
14001403
const exact = await this.processRecord(name);
1401-
const records = await this.ctx.storage.list({ prefix: PROC_PREFIX });
1402-
const fallback: NamedProcessRecord[] = [];
1403-
for (const [key, value] of records) {
1404-
const parsed = ProcessRecordSchema.safeParse(value);
1405-
if (!parsed.success) {
1406-
continue;
1407-
}
1408-
const candidate = { name: key.slice(PROC_PREFIX.length), record: parsed.data };
1409-
if (candidate.name === CODE_SERVER_PROCESS_ID) {
1410-
continue;
1411-
}
1412-
if (candidate.name !== name) {
1413-
fallback.push(candidate);
1414-
}
1415-
}
1416-
fallback.sort((left, right) => compareProcessRecords(left.record, right.record));
1417-
return exact ? [{ name, record: exact }, ...fallback] : fallback;
1404+
return exact ? [{ name, record: exact }] : [];
14181405
}
14191406

14201407
private async deleteProcessRecord(id: string, name: string): Promise<void> {
@@ -1499,10 +1486,6 @@ function isFailedState(state: string): boolean {
14991486
return state === "error" || state === "build_failed";
15001487
}
15011488

1502-
function compareProcessRecords(left: ProcessRecord, right: ProcessRecord): number {
1503-
return (right.startedAtMs ?? 0) - (left.startedAtMs ?? 0);
1504-
}
1505-
15061489
function isMissingDaytonaProcessError(error: unknown): boolean {
15071490
return error instanceof DaytonaApiError && (error.status === 404 || error.status === 410);
15081491
}

apps/agent-worker/src/index.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AgentWorkerEnvSchema, type WorkerSecret } from "@cheatcode/env";
1212
import {
1313
type AnalyticsBindings,
1414
APIError,
15+
createLogger,
1516
emitErrorEvent,
1617
emitPerformanceMetric,
1718
toAPIError,
@@ -77,6 +78,7 @@ import { parseCreateRunRequestBody } from "./run-request";
7778
import {
7879
GatewayUserIdSchema,
7980
isUuidRouteParam,
81+
legacyProjectSandboxName,
8082
parseRunRouteParam,
8183
parseThreadRouteParam,
8284
readGatewayUserId,
@@ -303,6 +305,9 @@ agentApp.post("/internal/users/:userId/delete-state", async (c) => {
303305
if (body.scope === "account") {
304306
await sandbox.destroySandbox();
305307
await sandbox.deleteDurableState();
308+
// Also reclaim any pre-migration per-project sandboxes (keyed by the old projectSandboxName)
309+
// so a deleted user's code/files never persist on Daytona after the one-sandbox-per-user cutover.
310+
await destroyLegacyProjectSandboxes(c.env, userId, body.projects);
306311
projectStatesDeleted = body.projects.length;
307312
} else {
308313
for (const project of body.projects) {
@@ -323,6 +328,29 @@ agentApp.post("/internal/users/:userId/delete-state", async (c) => {
323328
);
324329
});
325330

331+
// Best-effort teardown of pre-migration per-project sandboxes on account deletion. Addressing a
332+
// legacy DO that never existed just yields a fresh idle stub whose destroy is a no-op; a missing
333+
// Daytona sandbox is tolerated so one failure can't block reclaiming the others.
334+
async function destroyLegacyProjectSandboxes(
335+
env: AgentEnv,
336+
userId: string,
337+
deletedProjects: ReadonlyArray<{ id: string }>,
338+
): Promise<void> {
339+
for (const project of deletedProjects) {
340+
try {
341+
const legacyName = await legacyProjectSandboxName(userId, project.id);
342+
const legacy = env.PROJECT_SANDBOX.get(env.PROJECT_SANDBOX.idFromName(legacyName));
343+
await legacy.destroySandbox();
344+
await legacy.deleteDurableState();
345+
} catch (error) {
346+
createLogger().warn("legacy_project_sandbox_teardown_failed", {
347+
error: error instanceof Error ? error.message : String(error),
348+
projectId: project.id,
349+
});
350+
}
351+
}
352+
}
353+
326354
agentApp.get("/v1/outputs/:outputId/download", async (c) => {
327355
const parsedOutputId = OutputIdSchema.safeParse(c.req.param("outputId"));
328356
if (!parsedOutputId.success) {
@@ -609,16 +637,18 @@ agentApp.get("/v1/threads/:threadId/sandbox/ide", async (c) => {
609637
const sandbox = project
610638
? await sandboxForProject(c.env, userId, project.id)
611639
: await sandboxForThread(c.env, userId, threadId);
612-
const files = await sandbox.listFiles({
613-
includeHidden: false,
614-
path: SANDBOX_WORKSPACE_ROOT,
615-
recursive: true,
616-
});
617640
// Per-user "computer": a project chat opens its own folder (/workspace/<slug>, header = slug);
618641
// a project-less chat opens the whole computer root (/workspace, relabelled "COMPUTER").
619642
const workspacePath = project?.workspaceSlug
620643
? workspacePathForSlug(project.workspaceSlug)
621644
: SANDBOX_WORKSPACE_ROOT;
645+
// List only the opened project's folder — a recursive walk of the whole /workspace root would let
646+
// a sibling project's node_modules exhaust the entry cap before reaching this project's source.
647+
const files = await sandbox.listFiles({
648+
includeHidden: false,
649+
path: workspacePath,
650+
recursive: true,
651+
});
622652
const initialFilePath = project
623653
? selectInitialCodeServerFile(files.files, workspacePath)
624654
: undefined;

apps/agent-worker/src/tenancy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,13 @@ export async function userSandboxName(userId: string): Promise<string> {
6868
);
6969
return `${SANDBOX_ID_PREFIX}-${toHex(digest).slice(0, SANDBOX_ID_HEX_LENGTH)}`;
7070
}
71+
72+
// Pre-migration per-PROJECT sandbox name (one sandbox per project). Retained solely so account
73+
// deletion can also tear down sandboxes provisioned before the one-sandbox-per-user migration.
74+
export async function legacyProjectSandboxName(userId: string, projectId: string): Promise<string> {
75+
const digest = await crypto.subtle.digest(
76+
"SHA-256",
77+
textEncoder.encode(JSON.stringify(["project-sandbox", userId, projectId])),
78+
);
79+
return `${SANDBOX_ID_PREFIX}-${toHex(digest).slice(0, SANDBOX_ID_HEX_LENGTH)}`;
80+
}

apps/web/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Backfill legacy null slugs FIRST so (a) the partial unique index can build and (b) pre-migration
2+
-- projects stop collapsing onto the shared /workspace/app sentinel. 'p-' + the row's own uuid (hex,
3+
-- dashless) is globally unique, so no per-user collision and no clash with name-derived slugs.
4+
UPDATE "v2_projects" SET "workspace_slug" = 'p-' || replace("id"::text, '-', '') WHERE "workspace_slug" IS NULL;
5+
--> statement-breakpoint
6+
CREATE UNIQUE INDEX "v2_projects_user_workspace_slug_uidx" ON "v2_projects" USING btree ("user_id","workspace_slug") WHERE workspace_slug is not null;

0 commit comments

Comments
 (0)