Skip to content

Commit 08be5e0

Browse files
committed
harden: per-project preview status/console/wake + remove sandbox_destroy footgun
Sweep of per-sandbox assumptions that break under one-sandbox-per-user (audit found 6, all in agent-worker + the agent tool-set; core plumbing verified clean): - GAP1 (data-loss): drop sandbox_destroy/create/snapshot/restore from the agent tool-set — sandbox_destroy nuked the whole per-user sandbox (every project) and its description invited 'project cleanup'. DO methods kept for deletion routes. - GAP2: preview/status now reports THIS project's dev server (projectPreviewStatus probes app-preview:<slug>'s port without booting the VM), not the sandbox state, so a dead dev server auto-wakes instead of showing a blank preview forever. - GAP3: console dev-server logs keyed by workspaceSlug (was project UUID -> leaked another project's logs). - GAP4: legacy null-slug normalized to 'app' across wake/status/console. - GAP5: port-alloc failure throws instead of falling back to shared 5173 (no cross- project kill via deleteProcessesOnPort). - GAP6: port-bound startProcess requires an explicit per-project processId. Gates: typecheck 39/39, turbo build 22/22, biome clean.
1 parent f3d2af2 commit 08be5e0

12 files changed

Lines changed: 135 additions & 141 deletions

File tree

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ import { signedUrlToExpo } from "./project-sandbox-preview";
2424

2525
export { restoreBestEffortSnapshot, snapshotAppBuilderWorkspace };
2626

27-
// Fallback app dir + ports for legacy/slug-less runs. Per-project runs derive their own dir from
28-
// the project's workspaceSlug and their own stable port from the sandbox's per-project allocator.
27+
// Fallback app dir for legacy/slug-less runs; per-project runs derive their own dir from the
28+
// project's workspaceSlug. Ports are NEVER a fixed fallback in the per-user sandbox — each project
29+
// draws a unique dev-server port from the DO's per-project allocator (see allocateAppPort).
2930
const DEFAULT_APP_BUILDER_DIR = "/workspace/app";
30-
const DEFAULT_WEB_PORT = 5173;
31+
// Informational only: the mobile port hint threaded into an imported project's context note. The
32+
// actual Metro port is allocated per-project by the DO, not fixed to this value.
3133
const DEFAULT_MOBILE_PORT = 8081;
3234
// 24h is Daytona's max signed-preview TTL; the token rides in the subdomain so Expo Go needs no
3335
// header, and we re-mint on every dev-server (re)start so it never serves an expired manifest URL.
@@ -69,9 +71,12 @@ async function allocateAppPort(
6971
mobile: boolean,
7072
logger: AgentRunLogger,
7173
): Promise<number> {
74+
// Per-user sandbox: never fall back to a fixed shared port — two projects on the same fixed port
75+
// would fight over it (a rebuild's deleteProcessesOnPort would kill the other's dev server). If
76+
// the allocator is unavailable, fail the dev-server start loudly instead of sharing a port.
7277
if (!sandbox.allocateProjectPort) {
73-
logger.warn("app_port_alloc_missing_method", { slug });
74-
return mobile ? DEFAULT_MOBILE_PORT : DEFAULT_WEB_PORT;
78+
logger.error("app_port_alloc_missing_method", { slug });
79+
throw appPortAllocationError(slug);
7580
}
7681
try {
7782
const port = await sandbox.allocateProjectPort({
@@ -81,14 +86,27 @@ async function allocateAppPort(
8186
logger.info("app_port_allocated", { mobile, port, slug });
8287
return port;
8388
} catch (error) {
84-
logger.warn("app_port_alloc_failed", {
89+
logger.error("app_port_alloc_failed", {
8590
error: error instanceof Error ? error.message : String(error),
8691
slug,
8792
});
88-
return mobile ? DEFAULT_MOBILE_PORT : DEFAULT_WEB_PORT;
93+
throw appPortAllocationError(slug);
8994
}
9095
}
9196

97+
function appPortAllocationError(slug: string): APIError {
98+
return new APIError(
99+
502,
100+
"sandbox_failed_to_start",
101+
"Could not allocate a per-project dev-server port.",
102+
{
103+
details: { slug },
104+
hint: "Retry the run. If it persists, the project sandbox port allocator is unavailable.",
105+
retriable: true,
106+
},
107+
);
108+
}
109+
92110
async function resolveAppWorkspace(
93111
sandbox: ProjectSandboxStub,
94112
input: AgentRunAppBuilderInput,

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ const SANDBOX_TOOL_NAMES = new Set([
2222
"git_push",
2323
"git_status",
2424
"runCode",
25-
"sandbox_create",
26-
"sandbox_destroy",
27-
"sandbox_restore",
28-
"sandbox_snapshot",
2925
"shell_exec",
3026
"shell_kill_process",
3127
"shell_start_process",

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ export const ProjectWakePreviewInputSchema = z
203203
})
204204
.strict();
205205

206+
// Read-only preview liveness for the status panel. Names which project's dev server to check —
207+
// its ProcessRecord slot is keyed by workspaceSlug (matching start_dev_server + wakePreview).
208+
// Absent/normalized to "app" for legacy slug-less projects; a project-less chat never calls this.
209+
export const ProjectPreviewStatusInputSchema = z
210+
.object({
211+
workspaceSlug: z.string().min(1).max(200).optional(),
212+
})
213+
.strict();
214+
206215
export const ProjectSignedPreviewUrlInputSchema = z
207216
.object({
208217
port: z.number().int().positive().max(65_535),
@@ -257,6 +266,7 @@ export type ProjectAllocatePortInput = z.input<typeof ProjectAllocatePortInputSc
257266
export type ProjectCodeServerInput = z.input<typeof ProjectCodeServerInputSchema>;
258267
export type ProjectUnexposePortInput = z.input<typeof ProjectUnexposePortInputSchema>;
259268
export type ProjectWakePreviewInput = z.input<typeof ProjectWakePreviewInputSchema>;
269+
export type ProjectPreviewStatusInput = z.input<typeof ProjectPreviewStatusInputSchema>;
260270
export type ProjectSignedPreviewUrlInput = z.input<typeof ProjectSignedPreviewUrlInputSchema>;
261271

262272
/** Result of waking a preview: the (possibly restarted) dev-server preview URL + liveness. */

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

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import {
5454
ProjectListFilesInputSchema,
5555
type ProjectPreviewFileInput,
5656
ProjectPreviewFileInputSchema,
57+
type ProjectPreviewStatusInput,
58+
ProjectPreviewStatusInputSchema,
5759
type ProjectReadDevServerLogsInput,
5860
ProjectReadDevServerLogsInputSchema,
5961
type ProjectReadFileInput,
@@ -112,9 +114,15 @@ const APP_PREVIEW_TTL_MS = 24 * 60 * 60 * 1000;
112114
// Daytona's signed preview URL (token in the subdomain) TTL — 24h is Daytona's max. Regenerated on
113115
// every mobile dev-server (re)start so Expo Go always has an unexpired, header-free manifest URL.
114116
const SIGNED_PREVIEW_TTL_SECONDS = 24 * 60 * 60;
115-
// Per-project dev-server slot prefix. Each project's dev server occupies proc:app-preview:<projectId>
117+
// Per-project dev-server slot prefix. Each project's dev server occupies proc:app-preview:<workspaceSlug>
116118
// so multiple projects' servers persist side by side in the one per-user sandbox (bud parity).
117119
const APP_PREVIEW_SLOT_PREFIX = "app-preview:";
120+
// Legacy slug-less projects were built into /workspace/app with the slot "app-preview:app" (the
121+
// app-builder's basename fallback). The status/console/wake routes normalize a null workspaceSlug to
122+
// this so all of them address the same slot instead of the bare "app-preview" default.
123+
const LEGACY_APP_SLUG = "app";
124+
// Single short liveness probe budget for the read-only preview status check (no VM boot).
125+
const PREVIEW_STATUS_PROBE_TIMEOUT_MS = 3_000;
118126
// Per-project dev-server port pools. Web previews start at 5173, mobile (Expo Metro) at 8081, each
119127
// incrementing per new project, unique within the sandbox — the port is per-project, not fixed.
120128
const WEB_PORT_BASE = 5173;
@@ -363,10 +371,20 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
363371

364372
public async startProcess(input: ProjectStartProcessInput): Promise<SandboxProcessResult> {
365373
const parsed = ProjectStartProcessInputSchema.parse(input);
374+
// A port-bound process must carry an explicit processId (its dev-server slot). Without one it
375+
// would land in the shared bare "app-preview" slot and could clobber another project's dev
376+
// server in the per-user sandbox, so refuse it loudly instead of silently sharing a slot.
377+
if (parsed.waitForPort && !parsed.processId) {
378+
throw new APIError(
379+
400,
380+
"invalid_request_body",
381+
"A port-bound sandbox process requires an explicit processId.",
382+
{ retriable: false },
383+
);
384+
}
366385
const id = await this.ensureSandbox();
367386
const client = this.client();
368-
const name =
369-
parsed.processId ?? (parsed.waitForPort ? "app-preview" : `process-${crypto.randomUUID()}`);
387+
const name = parsed.processId ?? `process-${crypto.randomUUID()}`;
370388
const sessionId = `cc-${name}`;
371389
if (parsed.processId || parsed.waitForPort) {
372390
await this.deleteProcessRecord(id, name);
@@ -688,6 +706,36 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
688706
return { sandboxId: existing, state: sandbox?.state ?? "unknown" };
689707
}
690708

709+
// Read-only preview liveness for the status panel: resolve the shared sandbox's lifecycle state
710+
// WITHOUT booting it (no ensureSandbox), then — only when the VM is started — probe THIS project's
711+
// own dev-server port so a dead dev server reads as not-running even while the sandbox is up (an
712+
// idle-stop can kill the dev-server process without stopping the VM). The slot is keyed by
713+
// workspaceSlug (defaulting to "app" for legacy slug-less projects), matching start_dev_server +
714+
// wakePreview, so each project reports on its own server rather than the shared sandbox state.
715+
public async projectPreviewStatus(
716+
input: ProjectPreviewStatusInput,
717+
): Promise<{ running: boolean; state: string }> {
718+
const parsed = ProjectPreviewStatusInputSchema.parse(input);
719+
const runtime = await this.sandboxRuntimeState();
720+
if (runtime.state !== "started" || !runtime.sandboxId) {
721+
return { running: false, state: runtime.state };
722+
}
723+
const slug = parsed.workspaceSlug ?? LEGACY_APP_SLUG;
724+
const record = await this.processRecord(`${APP_PREVIEW_SLOT_PREFIX}${slug}`);
725+
if (!record?.port) {
726+
// Sandbox is up but this project has no tracked dev server (a docs/data project, or one not
727+
// started yet) — nothing is serving a preview.
728+
return { running: false, state: runtime.state };
729+
}
730+
const running = await this.httpPortReady(
731+
runtime.sandboxId,
732+
record.port,
733+
"/",
734+
PREVIEW_STATUS_PROBE_TIMEOUT_MS,
735+
);
736+
return { running, state: runtime.state };
737+
}
738+
691739
public async unexposePort(input: ProjectUnexposePortInput): Promise<void> {
692740
ProjectUnexposePortInputSchema.parse(input);
693741
// Daytona has no per-port preview object to delete; tokens expire on TTL.

apps/agent-worker/src/index.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,9 @@ agentApp.post("/v1/threads/:threadId/sandbox/preview/wake", async (c) => {
650650
// this project's dev server among the sandbox's per-project ones (slot keyed by slug).
651651
const result = await sandbox.wakePreview({
652652
hostname: resolvePreviewHostname(c.env),
653-
...(project?.workspaceSlug ? { workspaceSlug: project.workspaceSlug } : {}),
653+
// A project chat wakes its own dev server (slot keyed by slug, normalized to "app" for legacy
654+
// slug-less projects); a project-less chat has no dev server to revive, so omit the slug.
655+
...(project ? { workspaceSlug: previewWorkspaceSlug(project.workspaceSlug) } : {}),
654656
});
655657
return c.json(SandboxPreviewWakeSchema.parse(result));
656658
});
@@ -665,8 +667,19 @@ agentApp.get("/v1/threads/:threadId/sandbox/preview/status", async (c) => {
665667
const sandbox = project
666668
? await sandboxForProject(c.env, userId, project.id)
667669
: await sandboxForThread(c.env, userId, threadId);
668-
// Prefer the webhook-fed cache (Daytona sandbox.state.updated) keyed by the sandbox UUID —
669-
// no Daytona API call. Fall back to a live read when the cache is cold.
670+
if (project) {
671+
// Per-project liveness: the shared per-user sandbox can be "started" while THIS project's dev
672+
// server is dead (idle-stop killed its process), so probe the project's own dev-server port
673+
// instead of reading only the sandbox state — otherwise the web wake guard never fires and the
674+
// preview stays blank. The slot defaults to "app" for legacy slug-less projects (GAP 4).
675+
const status = await sandbox.projectPreviewStatus({
676+
workspaceSlug: previewWorkspaceSlug(project.workspaceSlug),
677+
});
678+
return c.json(SandboxPreviewStatusSchema.parse(status));
679+
}
680+
// Project-less chat: no dev server to probe — report the raw sandbox lifecycle state, preferring
681+
// the webhook-fed cache (Daytona sandbox.state.updated) keyed by the sandbox UUID (no Daytona API
682+
// call), falling back to a live read when the cache is cold.
670683
const daytonaId = await sandbox.existingDaytonaId();
671684
const cached = daytonaId ? await readSandboxStateCache(c.env, daytonaId) : null;
672685
const runtime = cached ?? (await sandbox.sandboxRuntimeState());
@@ -812,7 +825,9 @@ agentApp.get("/v1/threads/:threadId/sandbox/console", async (c) => {
812825
? await sandboxForProject(c.env, userId, project.id)
813826
: await sandboxForThread(c.env, userId, threadId);
814827
const snapshot = await sandbox.readDevServerLogs(
815-
project ? { ...query, processId: `app-preview:${project.id}` } : query,
828+
project
829+
? { ...query, processId: `app-preview:${previewWorkspaceSlug(project.workspaceSlug)}` }
830+
: query,
816831
);
817832
return c.json(SandboxConsoleSnapshotSchema.parse(snapshot));
818833
});
@@ -835,6 +850,13 @@ function takeoverEmbedUrl(previewUrl: string, password: string): string {
835850
return url.toString();
836851
}
837852

853+
// Legacy slug-less projects were built into /workspace/app with the dev-server slot "app-preview:app"
854+
// (the app-builder's basename fallback). Normalize a null workspaceSlug to "app" so the wake, status,
855+
// and console routes all address that same slot — otherwise they'd miss it and fall back wrongly.
856+
function previewWorkspaceSlug(workspaceSlug: string | null): string {
857+
return workspaceSlug ?? "app";
858+
}
859+
838860
async function terminalProjectForThread(
839861
env: AgentEnv,
840862
userId: string,

apps/gateway-worker/src/metadata-routes.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@ const TOOL_SUMMARIES = [
1515
tool("code", "git_clone", "Clone a git repository into the sandbox."),
1616
tool("code", "git_commit", "Commit sandbox repository changes."),
1717
tool("code", "git_push", "Push sandbox repository changes."),
18-
tool("sandbox", "sandbox_create", "Create or wake the project sandbox."),
19-
tool("sandbox", "sandbox_destroy", "Delete the project sandbox."),
20-
tool("sandbox", "sandbox_snapshot", "Return the project workspace volume handle."),
21-
tool("sandbox", "sandbox_restore", "Reconnect the project workspace volume handle."),
2218
tool("sandbox", "start_dev_server", "Start and expose a sandbox preview server."),
2319
tool("browser", "browser_open", "Open a URL in the sandbox browser."),
2420
tool("browser", "browser_act", "Perform a Stagehand browser action."),

apps/web/src/components/chat/message-parts.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -461,10 +461,6 @@ const TOOL_VERBS: Record<string, ToolVerbSpec> = {
461461
research_fanout: { verb: "Researched", argKeys: ["query", "topic"] },
462462
composio_execute: { verb: "Ran an app action", argKeys: ["tool", "action", "slug"] },
463463
composio_list_tools: { verb: "Listed app actions" },
464-
sandbox_create: { verb: "Created the sandbox" },
465-
sandbox_destroy: { verb: "Tore down the sandbox" },
466-
sandbox_snapshot: { verb: "Snapshotted the sandbox" },
467-
sandbox_restore: { verb: "Restored the sandbox" },
468464
skill_create: { verb: "Created a skill", argKeys: ["name", "slug"] },
469465
skill_invoke: { verb: "Used skill", argKeys: ["skillName", "name", "slug", "skill"] },
470466
skill_read_reference: { verb: "Read a skill reference", argKeys: ["path", "name"] },

packages/agent-core/src/mastra/tools/registry.ts

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,14 @@ import {
1010
CodeRuntimeContextSchema,
1111
DeleteFileInputSchema,
1212
DeleteFileOutputSchema,
13-
executeCreateSnapshot,
1413
executeDeleteFile,
1514
executeGitClone,
1615
executeGitCommit,
1716
executeGitPush,
1817
executeGitStatus,
1918
executeListFiles,
2019
executeReadFile,
21-
executeRestoreSnapshot,
2220
executeRunCode,
23-
executeSandboxCreate,
24-
executeSandboxDestroy,
2521
executeSearchFiles,
2622
executeShellExec,
2723
executeShellKillProcess,
@@ -32,13 +28,8 @@ import {
3228
GitCloneInputSchema,
3329
GitPushInputSchema,
3430
GitStatusInputSchema,
35-
RestoreSnapshotInputSchema,
3631
RunCodeInputSchema,
3732
RunCodeOutputSchema,
38-
SandboxCreateInputSchema,
39-
SandboxCreateOutputSchema,
40-
SandboxDestroyInputSchema,
41-
SandboxDestroyOutputSchema,
4233
SearchFilesInputSchema,
4334
SearchFilesOutputSchema,
4435
ShellExecOutputSchema,
@@ -111,7 +102,6 @@ import {
111102
browserObserveInputSchema,
112103
browserOpenInputSchema,
113104
browserScreenshotInputSchema,
114-
createSnapshotInputSchema,
115105
gitCloneInputSchema,
116106
gitCommitInputSchema,
117107
gitPushInputSchema,
@@ -120,8 +110,6 @@ import {
120110
listFilesOutputSchema,
121111
readFileInputSchema,
122112
readFileOutputSchema,
123-
restoreSnapshotInputSchema,
124-
restoreSnapshotOutputSchema,
125113
runCodeInputSchema,
126114
runCodeOutputSchema,
127115
shellExecInputSchema,
@@ -132,7 +120,6 @@ import {
132120
skillInvokeOutputSchema,
133121
skillReadReferenceInputSchema,
134122
skillReadReferenceOutputSchema,
135-
snapshotHandleSchema,
136123
startDevServerInputSchema,
137124
startDevServerOutputSchema,
138125
workflowResultSchema,
@@ -473,42 +460,6 @@ export const mastraStartDevServer = createTool({
473460
execute: async (input, context) => executeStartDevServer(input, codeRuntimeFromContext(context)),
474461
});
475462

476-
export const mastraSandboxCreate = createTool({
477-
id: "sandbox_create",
478-
description: "Create or wake the project sandbox and return readiness status.",
479-
inputSchema: SandboxCreateInputSchema,
480-
outputSchema: SandboxCreateOutputSchema,
481-
execute: async (input, context) => executeSandboxCreate(input, codeRuntimeFromContext(context)),
482-
});
483-
484-
export const mastraSandboxDestroy = createTool({
485-
id: "sandbox_destroy",
486-
description: "Delete the project sandbox for explicit project cleanup.",
487-
inputSchema: SandboxDestroyInputSchema,
488-
outputSchema: SandboxDestroyOutputSchema,
489-
execute: async (input, context) => executeSandboxDestroy(input, codeRuntimeFromContext(context)),
490-
});
491-
492-
export const mastraSandboxSnapshot = createTool({
493-
id: "sandbox_snapshot",
494-
description: "Return the current project's persistent Daytona sandbox handle.",
495-
inputSchema: createSnapshotInputSchema,
496-
outputSchema: snapshotHandleSchema,
497-
execute: async (input, context) => executeCreateSnapshot(input, codeRuntimeFromContext(context)),
498-
});
499-
500-
export const mastraSandboxRestore = createTool({
501-
id: "sandbox_restore",
502-
description: "Reconnect the sandbox to a previously returned Daytona sandbox handle.",
503-
inputSchema: restoreSnapshotInputSchema,
504-
outputSchema: restoreSnapshotOutputSchema,
505-
execute: async (input, context) =>
506-
executeRestoreSnapshot(
507-
RestoreSnapshotInputSchema.parse(input),
508-
codeRuntimeFromContext(context),
509-
),
510-
});
511-
512463
export const mastraBrowserOpen = createTool({
513464
id: "browser_open",
514465
description:

0 commit comments

Comments
 (0)