Skip to content

Commit d504245

Browse files
authored
fix(sandbox): rotate stale runtimes safely (#86)
## Summary - replace a user's stale Daytona runtime container on the first workspace operation after a snapshot or target promotion - preserve the canonical persistent-volume subpath, project files, uploaded files, and user-installed skills - fence concurrent operations and active run leases during replacement, while returning retriable maintenance responses - clear only ephemeral process reservations and runtime projections after the new container starts - require canonical ownership, matching snapshot attestation, and the exact workspace mount before automatic deletion; ambiguous contracts still fail closed - document the production promotion and recovery contract ## Why Promoting the Node 24 snapshot made every existing sandbox permanently return `unavailable_maintenance`: lookup detected the old snapshot, but no lifecycle path ever replaced it. This change makes immutable snapshot promotion operational without moving or deleting user workspace data. The replacement decision is intentionally narrow. Snapshot or target drift is recoverable only when the existing resource is unambiguously the user's canonical sandbox on the configured persistent volume. Identity, label, mount, and duplicate-resource ambiguity remain operator-visible failures. ## Verification - `pnpm turbo lint typecheck build` under Node 24.18.0 - agent-worker lint, typecheck, and Wrangler dry-run build under Node 24.18.0 - dependency-cruiser architecture check for `apps/agent-worker` - Knip dead-code check for `@cheatcode/agent-worker` - `git diff --check` - exactly one SQL migration exists: `packages/db/drizzle/0000_current_schema.sql` - production migration dry-run reports that migration applied and verifies the schema contract Production upload/persistence acceptance QA will run directly through `agent-browser` after the exact merged revision is deployed.
1 parent dfb2237 commit d504245

6 files changed

Lines changed: 196 additions & 38 deletions

File tree

apps/agent-worker/README.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,17 @@ Each user has one durable Daytona sandbox. Projects are lexically confined to th
138138
folders under `/workspace`, and run leases keep the sandbox active while the agent is
139139
working. Project folders share the sandbox's Unix identity, so this prevents accidental
140140
cross-project access but is not an operating-system security boundary within one user.
141-
Sandbox lookup validates canonical ownership labels before trusting a cached ID; the
142-
physical Daytona name is deliberately not identity because a promoted replacement has a
143-
release-scoped name. A missing/stale Durable Object cache therefore recovers the one
144-
canonical sandbox by labels, while duplicate live canonical matches fail closed. New
145-
sandboxes pin the configured immutable snapshot and mount the environment's shared Daytona
146-
volume at `/workspace` with the user sandbox name as its isolated subpath. Canonical and
147-
candidate checks require the provider's actual mount tuple as well as the matching labels; labels
148-
alone cannot attest durable storage. A noncurrent
149-
sandbox is maintenance-only and cannot serve product work.
141+
Sandbox lookup validates canonical ownership labels before trusting a cached Daytona
142+
resource ID. A missing/stale Durable Object cache therefore recovers the one canonical
143+
sandbox by labels, while duplicate live canonical matches fail closed. New sandboxes pin
144+
the configured immutable snapshot and mount the environment's shared Daytona volume at
145+
`/workspace` with the user sandbox name as its isolated subpath. Canonical and candidate
146+
checks require the provider's actual mount tuple as well as the matching labels; labels
147+
alone cannot attest durable storage. When the configured snapshot or target changes, the
148+
first operation after active work drains replaces only the stale container and remounts
149+
that same volume subpath. New operations are fenced during replacement, stale process
150+
projections are cleared, and project files plus user-installed skills remain durable.
151+
Identity, snapshot-label, or storage-mount ambiguity still fails closed.
150152

151153
Preview URLs carry a 60-second `handoff` capability minted by `@cheatcode/auth`.
152154
The preview-proxy Worker exchanges it for a distinct host-only, HttpOnly
@@ -229,10 +231,12 @@ application secret is required.
229231
Every ProjectSandbox uses the one configured immutable Daytona snapshot and the
230232
one configured shared workspace volume. Existing sandbox identity is accepted
231233
only when its owner, canonical labels, snapshot, volume, and mount contract all
232-
match. Mismatches fail closed instead of running a hidden migration. New
234+
match. A stale snapshot or target is replaced automatically only when canonical
235+
ownership and the persistent mount are unambiguous and no other operation or run
236+
lease is active. All other contract mismatches fail closed. New and replacement
233237
sandboxes mount the user's isolated volume subpath directly at `/workspace`.
234-
Account deletion clears that subpath before deleting all exactly owned
235-
sandboxes, so persistent volume data does not outlive the account.
238+
Account deletion clears that subpath before deleting all exactly owned sandboxes,
239+
so persistent volume data does not outlive the account.
236240

237241
Production deploys bind one immutable `CHEATCODE_RELEASE_SHA`. Health responses
238242
expose that identity so the deployment workflow can verify that service

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,47 @@ export function canonicalSandboxLabels(input: {
2020
}
2121

2222
export function isCanonicalSandbox(sandbox: DaytonaSandbox, sandboxName: string): boolean {
23-
return sandbox.labels["app"] === APP_LABEL && sandbox.labels["sandboxId"] === sandboxName;
23+
return (
24+
sandbox.labels["app"] === APP_LABEL &&
25+
sandbox.labels["role"] === "canonical" &&
26+
sandbox.labels["sandboxId"] === sandboxName &&
27+
sandbox.labels["sandboxOwner"] === sandboxName
28+
);
29+
}
30+
31+
export function isRuntimeReplaceableCanonicalSandbox(
32+
sandbox: DaytonaSandbox,
33+
input: { sandboxName: string; volumeName: string },
34+
): boolean {
35+
const volumeId = sandbox.labels["workspaceVolumeId"];
36+
return (
37+
isCanonicalSandbox(sandbox, input.sandboxName) &&
38+
sandbox.snapshot === sandbox.labels["snapshot"] &&
39+
typeof volumeId === "string" &&
40+
volumeId.length > 0 &&
41+
sandbox.labels["workspaceVolumeName"] === input.volumeName &&
42+
hasWorkspaceMount(sandbox, volumeId, input.sandboxName)
43+
);
2444
}
2545

2646
export function isDesiredCanonicalSandbox(
2747
sandbox: DaytonaSandbox,
28-
input: { sandboxName: string; snapshot: string; volumeId?: string; volumeName: string },
48+
input: {
49+
sandboxName: string;
50+
snapshot: string;
51+
target: string;
52+
volumeId?: string;
53+
volumeName: string;
54+
},
2955
): boolean {
3056
const volumeId = input.volumeId ?? sandbox.labels["workspaceVolumeId"];
3157
return (
3258
isCanonicalSandbox(sandbox, input.sandboxName) &&
3359
sandbox.labels["role"] === "canonical" &&
3460
sandbox.snapshot === input.snapshot &&
3561
sandbox.labels["snapshot"] === input.snapshot &&
62+
sandbox.target === input.target &&
63+
sandbox.user === "node" &&
3664
typeof volumeId === "string" &&
3765
volumeId.length > 0 &&
3866
sandbox.labels["workspaceVolumeId"] === volumeId &&

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

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
setSandboxQuotaPeriod,
3535
} from "./project-sandbox-metering";
3636
import { assertProjectSandboxOwnerActive } from "./project-sandbox-owner-admission";
37+
import { PROC_PREFIX, PROCESS_PORT_ALLOC_KEY } from "./project-sandbox-process-support";
3738
import { ProjectSandboxProvisioning } from "./project-sandbox-provisioning";
3839
import type {
3940
ParsedProjectCleanupWorkspaceInput,
@@ -46,6 +47,9 @@ import {
4647
} from "./project-sandbox-workspace-state";
4748

4849
const ClearWorkspaceEvidenceSchema = z.object({ cleared: z.literal(true) }).strict();
50+
const RUNTIME_MANIFEST_PATH = "/workspace/.cheatcode/runtime.json";
51+
const RUNTIME_RESET_PENDING_KEY = "sandbox_runtime_reset_pending";
52+
const SKILL_RUNTIME_DIRECTORY = "/workspace/.cheatcode/runtime";
4953

5054
export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandboxEnv> {
5155
private accountDeletionCompleted = false;
@@ -56,6 +60,7 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
5660
private daytonaClient: DaytonaClient | undefined;
5761
private daytonaId: string | undefined;
5862
private sandboxMutationTail: Promise<void> = Promise.resolve();
63+
private sandboxRuntimeUpdateInProgress = false;
5964
private startedVerifiedAtMs = 0;
6065
private readonly identityState: ProjectSandboxIdentityState;
6166
private readonly provisioning: ProjectSandboxProvisioning;
@@ -146,13 +151,15 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
146151
operation: () => Promise<T>,
147152
isSharedMutation = false,
148153
shouldLeaseUnknownWorkspace = false,
154+
allowRuntimeUpdate = false,
149155
): Promise<T> {
150156
let release: (() => void) | undefined;
151157
try {
152158
release = this.acquireActiveOperation(
153159
workspaceScope,
154160
isSharedMutation,
155161
shouldLeaseUnknownWorkspace,
162+
allowRuntimeUpdate,
156163
);
157164
return operation().finally(release);
158165
} catch (error) {
@@ -187,7 +194,7 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
187194
protected withActiveSandboxCleanupSignal(operation: () => Promise<void>): Promise<void> {
188195
return this.accountDeletionInProgress || !this.identityState.hasRegisteredOwner()
189196
? Promise.resolve()
190-
: this.withActiveSandboxOperation(operation);
197+
: this.withActiveOperation(null, operation, false, false, true);
191198
}
192199

193200
private async initializeIdentityState(): Promise<void> {
@@ -245,7 +252,7 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
245252
remaining.push({ runId, startedMs: Date.now() });
246253
await this.ctx.storage.put(RUN_LEASES_KEY, remaining);
247254
try {
248-
const id = await this.ensureSandbox();
255+
const id = await this.ensureSandbox(runId);
249256
await this.client()
250257
.setAutoStopInterval(id, 0)
251258
.catch(() => undefined);
@@ -441,10 +448,14 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
441448
private acquireActiveSandboxOperation(
442449
allowUnregisteredOwner = false,
443450
allowWorkspaceCleanup = false,
451+
allowRuntimeUpdate = false,
444452
): () => void {
445453
if (this.accountDeletionInProgress) {
446454
throw accountSandboxDeletedError();
447455
}
456+
if (this.sandboxRuntimeUpdateInProgress && !allowRuntimeUpdate) {
457+
throw sandboxRuntimeUpdatePending(this.env.DAYTONA_SANDBOX_SNAPSHOT);
458+
}
448459
if (!allowUnregisteredOwner && !this.identityState.hasRegisteredOwner()) {
449460
throw accountSandboxDeletedError();
450461
}
@@ -468,8 +479,9 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
468479
workspaceScope: string | readonly string[] | null,
469480
isSharedMutation = false,
470481
shouldLeaseUnknownWorkspace = false,
482+
allowRuntimeUpdate = false,
471483
): () => void {
472-
const releaseSandbox = this.acquireActiveSandboxOperation();
484+
const releaseSandbox = this.acquireActiveSandboxOperation(false, false, allowRuntimeUpdate);
473485
let releaseWorkspace: (() => void) | undefined;
474486
try {
475487
const workspaceSlugs =
@@ -529,12 +541,12 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
529541
this.startedVerifiedAtMs = Date.now();
530542
}
531543

532-
protected async ensureSandbox(): Promise<string> {
544+
protected async ensureSandbox(startingRunId?: string): Promise<string> {
533545
return this.withSandboxMutation(async () => {
534546
if (this.daytonaId && Date.now() - this.startedVerifiedAtMs < STARTED_REVERIFY_MS) {
535547
return this.daytonaId;
536548
}
537-
return this.resolveStartedSandbox();
549+
return this.resolveStartedSandbox(startingRunId);
538550
});
539551
}
540552

@@ -588,11 +600,14 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
588600
}
589601
}
590602

591-
private async resolveStartedSandbox(): Promise<string> {
603+
private async resolveStartedSandbox(startingRunId?: string): Promise<string> {
592604
const client = await this.ensureClient();
593605
let resolved: DaytonaSandbox;
594606
try {
595607
resolved = await this.provisioning.resolve(client);
608+
if (!this.provisioning.isDesired(resolved)) {
609+
resolved = await this.replaceSandboxRuntime(client, resolved, startingRunId);
610+
}
596611
} catch (error) {
597612
throw this.toUpstreamError(error, "Daytona sandbox lookup failed.");
598613
}
@@ -603,10 +618,78 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
603618
retriable: true,
604619
});
605620
}
621+
await this.clearPersistedRuntimeProjection(client, resolved.id);
606622
this.startedVerifiedAtMs = Date.now();
607623
return resolved.id;
608624
}
609625

626+
private async replaceSandboxRuntime(
627+
client: DaytonaClient,
628+
current: DaytonaSandbox,
629+
startingRunId?: string,
630+
): Promise<DaytonaSandbox> {
631+
this.sandboxRuntimeUpdateInProgress = true;
632+
try {
633+
await this.assertSandboxReplacementAllowed(startingRunId);
634+
this.provisioning.assertRuntimeReplacementSafe(current);
635+
await this.prepareForSandboxReplacement();
636+
await this.provisioning.deleteForReplacement(client, current);
637+
const replacement = await this.provisioning.create(client);
638+
if (!this.provisioning.isDesired(replacement)) {
639+
throw sandboxRuntimeUpdatePending(this.env.DAYTONA_SANDBOX_SNAPSHOT);
640+
}
641+
createLogger().info("sandbox_runtime_replaced", {
642+
sandboxId: this.sandboxName(),
643+
snapshot: this.env.DAYTONA_SANDBOX_SNAPSHOT,
644+
});
645+
return replacement;
646+
} finally {
647+
this.sandboxRuntimeUpdateInProgress = false;
648+
}
649+
}
650+
651+
private async assertSandboxReplacementAllowed(startingRunId?: string): Promise<void> {
652+
const leases = await this.runLeases();
653+
const activeRunLeases = leases.filter(
654+
(lease) => Date.now() - lease.startedMs < STALE_RUN_LEASE_MS,
655+
);
656+
if (activeRunLeases.length !== leases.length) {
657+
await this.ctx.storage.put(RUN_LEASES_KEY, activeRunLeases);
658+
}
659+
const otherRunLeases = activeRunLeases.filter((lease) => lease.runId !== startingRunId);
660+
if (this.activeOperationCount > 1 || otherRunLeases.length > 0) {
661+
throw sandboxRuntimeUpdatePending(this.env.DAYTONA_SANDBOX_SNAPSHOT);
662+
}
663+
}
664+
665+
private async prepareForSandboxReplacement(): Promise<void> {
666+
this.daytonaId = undefined;
667+
this.startedVerifiedAtMs = 0;
668+
await this.ctx.storage.delete(DAYTONA_ID_KEY);
669+
await this.ctx.storage.put(RUNTIME_RESET_PENDING_KEY, true);
670+
const processRecords = await this.ctx.storage.list({ prefix: PROC_PREFIX });
671+
if (processRecords.size > 0) {
672+
await this.ctx.storage.delete([...processRecords.keys()]);
673+
}
674+
await this.ctx.storage.delete(PROCESS_PORT_ALLOC_KEY);
675+
}
676+
677+
private async clearPersistedRuntimeProjection(
678+
client: DaytonaClient,
679+
sandboxId: string,
680+
): Promise<void> {
681+
if ((await this.ctx.storage.get(RUNTIME_RESET_PENDING_KEY)) !== true) {
682+
return;
683+
}
684+
try {
685+
await client.deleteFilePath(sandboxId, RUNTIME_MANIFEST_PATH, false);
686+
await client.deleteFilePath(sandboxId, SKILL_RUNTIME_DIRECTORY, true);
687+
await this.ctx.storage.delete(RUNTIME_RESET_PENDING_KEY);
688+
} catch (error) {
689+
throw this.toUpstreamError(error, "Daytona runtime reset failed.");
690+
}
691+
}
692+
610693
protected async existingSandboxId(): Promise<string | null> {
611694
const client = await this.ensureClient();
612695
try {
@@ -708,3 +791,16 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
708791
this.startedVerifiedAtMs = 0;
709792
}
710793
}
794+
795+
function sandboxRuntimeUpdatePending(expectedSnapshot: string): APIError {
796+
return new APIError(
797+
503,
798+
"unavailable_maintenance",
799+
"This computer is updating to the current runtime.",
800+
{
801+
details: { expectedSnapshot },
802+
hint: "Retry after the active operation finishes.",
803+
retriable: true,
804+
},
805+
);
806+
}

0 commit comments

Comments
 (0)