Skip to content

Commit 3ee48af

Browse files
committed
feat(sandbox): migrate sandbox backend from Blaxel to Daytona
- Daytona REST client (fetch, no SDK) in packages/tools-code/daytona-client.ts - ProjectSandbox DO rewritten on Daytona: run-leases (beginRun/endRun) + keepalive alarm, DO-persisted process records, sandbox-disk persistence (autoDelete=-1), lifecycle-aware metering, get-or-create by label, runCode via execute - New apps/preview-proxy worker: custom preview proxy + HMAC token contract - Webhooks GDPR lifecycle on Daytona; env/secrets + wrangler bindings - Remove @blaxel/core, BL_* code refs, blaxel.toml, sync-blaxel-local-token - Dockerfile drops baked sandbox-api; published as Daytona snapshot (user: node) - Docs: docs/plans/blaxel-to-daytona-migration.md + daytona-rest-reference.md + daytona-egress-broker.md; plan.md S9 + CLAUDE.md updated Typecheck 41/41 and lint 41/41 green.
1 parent 8d9e85d commit 3ee48af

57 files changed

Lines changed: 4976 additions & 4760 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ Direct competitors: Manus (generalist async agent), HappyCapy (GUI workstation +
1212

1313
| Layer | Choice |
1414
|---|---|
15-
| Language | **TypeScript** everywhere. No Python in backend. Python lives only inside the Blaxel sandbox. |
15+
| Language | **TypeScript** everywhere. No Python in backend. Python lives only inside the Daytona sandbox. |
1616
| Backend runtime | **Cloudflare Workers + Durable Objects + Workflows** |
1717
| Frontend | **Next.js 16.2.6 + React 19.2.6 + Tailwind 4.3 + shadcn CLI 4.6 + AI Elements + Streamdown** on Cloudflare Workers via OpenNext |
1818
| Agent framework | **Mastra 1.35** on top of **Vercel AI SDK v6.0.182** |
19-
| Sandbox | Blaxel Sandboxes via `@blaxel/core@0.2.84` — one hosted persistent sandbox per project |
20-
| Browser automation | **Stagehand v3.2 LOCAL mode** inside the Blaxel sandbox image; noVNC for user takeover |
19+
| Sandbox | **Daytona Sandboxes** via REST-over-fetch (no SDK in Workers; `packages/tools-code/daytona-client.ts`) — one persistent sandbox per project (disk is the durable store) |
20+
| Browser automation | **Stagehand v3.2 LOCAL mode** inside the Daytona sandbox image; noVNC for user takeover (via the `preview-proxy` worker) |
2121
| Database | **Supabase Postgres via Cloudflare Hyperdrive** + **Drizzle 0.45.2** (no `service_role` from Workers — uses `app_worker` role) |
2222
| Auth | **Clerk 7.3.4** (Workers JWT verify) |
2323
| Billing | **Polar 0.46.4** (no fixed cost, rev-share only) |
@@ -32,7 +32,8 @@ Direct competitors: Manus (generalist async agent), HappyCapy (GUI workstation +
3232
apps/
3333
web/ Next.js 16 (Cloudflare Workers/OpenNext)
3434
gateway-worker/ Public Hono router + Clerk JWT + rate limit
35-
agent-worker/ Agent loop + AgentRun DO + ProjectSandbox DO + Blaxel adapter
35+
agent-worker/ Agent loop + AgentRun DO + ProjectSandbox DO + Daytona adapter
36+
preview-proxy/ Custom preview proxy (preview.trycheatcode.com) in front of Daytona previews
3637
webhooks-worker/ Clerk, Polar, Composio webhooks + internal ops workflows
3738
3839
packages/
@@ -56,7 +57,7 @@ packages/
5657
biome-config/ Shared biome.jsonc
5758
5859
skills/ 8 curated Anthropic SKILL.md skills
59-
infra/ Wrangler configs, Supabase migrations, Blaxel sandbox Dockerfile
60+
infra/ Wrangler configs, Supabase migrations, Daytona sandbox Dockerfile/snapshot
6061
scripts/ Operational helpers only: build skills, secrets, deploy orchestration, migrations, audit archive
6162
```
6263

@@ -159,15 +160,15 @@ Full details in plan.md Section 12.
159160

160161
Use Mastra Workflows for orchestration. Each subagent is a `ToolLoopAgent` with explicit `stopWhen: stepCountIs(N)` budget caps. The `deep-research-fanout` workflow shows the canonical fanout pattern (see plan.md Section 8.5).
161162

162-
When using subagents for complex tasks, give each their own Blaxel-backed ProjectSandbox if they need isolated filesystem state. Otherwise share the parent's sandbox.
163+
When using subagents for complex tasks, give each their own Daytona-backed ProjectSandbox if they need isolated filesystem state. Otherwise share the parent's sandbox.
163164

164165
## Plan.md is source of truth
165166

166167
If you're proposing a change that contradicts `plan.md`, **update plan.md first** in the same PR. Architectural drift between plan and code is the #1 cause of bit-rot.
167168

168169
## When stuck
169170

170-
- Sandbox not working? `apps/agent-worker/src/durable-objects/project-sandbox.ts` — check Blaxel auth, sandbox name, image, region, and process/file normalization.
171+
- Sandbox not working? `apps/agent-worker/src/durable-objects/project-sandbox.ts` + `packages/tools-code/src/daytona-client.ts` — check Daytona auth (`DAYTONA_API_KEY`), sandbox name/snapshot, `DAYTONA_TARGET` region, and the toolbox/session paths. Reference: `docs/plans/daytona-rest-reference.md`.
171172
- Auth broken? `packages/auth/` — Clerk JWT verify pattern with `@clerk/backend`.
172173
- Skill not triggering? Inspect the bundled skill `description` first — it is
173174
the activation field, not the body. Use manual fixture review plus final UI

apps/agent-worker/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"@cheatcode/observability": "workspace:*",
2222
"@cheatcode/tools-code": "workspace:*",
2323
"@cheatcode/types": "workspace:*",
24-
"@blaxel/core": "catalog:",
2524
"ai": "catalog:",
2625
"hono": "catalog:",
2726
"zod": "catalog:"

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ export function usageFromMastraChunk(chunk: unknown): MastraUsageDelta | null {
9292
}
9393

9494
const usage = usageRecord(record);
95-
const tokensIn = tokenCount(usage, ["inputTokens", "promptTokens"]);
96-
const tokensOut = tokenCount(usage, ["outputTokens", "completionTokens"]);
95+
const tokensIn = tokenCount(usage, ["inputTokens", "promptTokens", "input_tokens"]);
96+
const tokensOut = tokenCount(usage, ["outputTokens", "completionTokens", "output_tokens"]);
9797
if (tokensIn === 0 && tokensOut === 0) {
9898
return null;
9999
}
@@ -167,12 +167,26 @@ export function normalizeMastraStreamError(error: unknown): Error {
167167

168168
function usageRecord(record: Record<string, unknown>): Record<string, unknown> {
169169
const payload = asRecord(record["payload"]);
170-
for (const candidate of [
170+
const metadata = asRecord(payload["metadata"]);
171+
const candidates: Record<string, unknown>[] = [
171172
asRecord(record["usage"]),
172173
asRecord(record["totalUsage"]),
173174
asRecord(payload["usage"]),
174175
asRecord(payload["totalUsage"]),
176+
];
177+
// Mastra 1.x finish chunks nest the raw provider usage under
178+
// payload.metadata.providerMetadata.<provider>.usage (snake_case token fields),
179+
// with no normalized top-level usage — so dig through every provider entry.
180+
for (const providerMetadata of [
181+
asRecord(record["providerMetadata"]),
182+
asRecord(payload["providerMetadata"]),
183+
asRecord(metadata["providerMetadata"]),
175184
]) {
185+
for (const providerValue of Object.values(providerMetadata)) {
186+
candidates.push(asRecord(asRecord(providerValue)["usage"]));
187+
}
188+
}
189+
for (const candidate of candidates) {
176190
if (Object.keys(candidate).length > 0) {
177191
return candidate;
178192
}
Lines changed: 59 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,145 +1,84 @@
1-
import type { SandboxInstance } from "@blaxel/core";
2-
import type { SandboxListFilesResult } from "@cheatcode/tools-code";
3-
import { z } from "zod";
1+
import type { DaytonaClient, DaytonaFileInfo, SandboxListFilesResult } from "@cheatcode/tools-code";
42

53
const MAX_LIST_FILE_ENTRIES = 1_000;
64

7-
const FileListSchema = z
8-
.object({
9-
files: z.array(z.unknown()).default([]),
10-
subdirectories: z.array(z.unknown()).default([]),
11-
})
12-
.passthrough();
13-
14-
interface FileEntryCandidate {
15-
lastModified?: string | undefined;
16-
modifiedAt?: string | undefined;
17-
name?: string | undefined;
18-
path?: string | undefined;
19-
size?: number | undefined;
20-
type?: string | undefined;
21-
}
5+
type FileEntry = SandboxListFilesResult["files"][number];
226

7+
/**
8+
* Recursive workspace listing over Daytona's single-level `listFiles` (the
9+
* toolbox `GET /files` returns one level only). Walks `isDir` entries depth-first
10+
* up to MAX_LIST_FILE_ENTRIES and maps Daytona FileInfo → the SandboxFileEntry
11+
* contract callers already depend on.
12+
*/
2313
export async function listSandboxFiles({
14+
client,
15+
sandboxId,
2416
includeHidden,
2517
path,
2618
recursive,
27-
sandbox,
2819
}: {
20+
client: DaytonaClient;
21+
sandboxId: string;
2922
includeHidden: boolean;
3023
path: string;
3124
recursive: boolean;
32-
sandbox: SandboxInstance;
33-
}): Promise<SandboxListFilesResult["files"]> {
34-
const entries = await listDirectory({
35-
includeHidden,
36-
parentPath: path,
37-
recursive,
38-
rootPath: path,
39-
sandbox,
40-
});
41-
return entries.slice(0, MAX_LIST_FILE_ENTRIES);
25+
}): Promise<FileEntry[]> {
26+
const root = stripTrailingSlash(path);
27+
const out: FileEntry[] = [];
28+
await walk(client, sandboxId, root, root, includeHidden, recursive, out);
29+
return out.slice(0, MAX_LIST_FILE_ENTRIES);
4230
}
4331

44-
async function listDirectory({
45-
includeHidden,
46-
parentPath,
47-
recursive,
48-
rootPath,
49-
sandbox,
50-
}: {
51-
includeHidden: boolean;
52-
parentPath: string;
53-
recursive: boolean;
54-
rootPath: string;
55-
sandbox: SandboxInstance;
56-
}): Promise<SandboxListFilesResult["files"]> {
57-
const listing = FileListSchema.parse(await sandbox.fs.ls(parentPath));
58-
const directories = listing.subdirectories
59-
.map((entry) => toFileEntry(entry, parentPath, "directory", rootPath))
60-
.filter((entry) => shouldIncludeEntry(entry, includeHidden));
61-
const files = listing.files
62-
.map((entry) => toFileEntry(entry, parentPath, "file", rootPath))
63-
.filter((entry) => shouldIncludeEntry(entry, includeHidden));
32+
async function walk(
33+
client: DaytonaClient,
34+
sandboxId: string,
35+
dir: string,
36+
root: string,
37+
includeHidden: boolean,
38+
recursive: boolean,
39+
out: FileEntry[],
40+
): Promise<void> {
41+
if (out.length >= MAX_LIST_FILE_ENTRIES) {
42+
return;
43+
}
44+
const infos = await client.listFiles(sandboxId, dir);
45+
const entries = infos
46+
.map((info) => toFileEntry(info, dir, root))
47+
.filter((entry) => shouldInclude(entry, includeHidden));
48+
for (const entry of entries) {
49+
out.push(entry);
50+
}
6451
if (!recursive) {
65-
return [...directories, ...files];
52+
return;
6653
}
67-
68-
const descendants: SandboxListFilesResult["files"] = [];
69-
for (const directory of directories) {
70-
if (descendants.length + directories.length + files.length >= MAX_LIST_FILE_ENTRIES) {
71-
break;
54+
for (const entry of entries) {
55+
if (entry.type === "directory" && out.length < MAX_LIST_FILE_ENTRIES) {
56+
await walk(client, sandboxId, entry.path, root, includeHidden, recursive, out);
7257
}
73-
descendants.push(
74-
...(await listDirectory({
75-
includeHidden,
76-
parentPath: directory.path,
77-
recursive,
78-
rootPath,
79-
sandbox,
80-
})),
81-
);
8258
}
83-
return [...directories, ...files, ...descendants].slice(0, MAX_LIST_FILE_ENTRIES);
8459
}
8560

86-
function toFileEntry(
87-
value: unknown,
88-
parentPath: string,
89-
fallbackType: "file" | "directory",
90-
rootPath: string,
91-
) {
92-
const candidate: FileEntryCandidate = z
93-
.object({
94-
lastModified: z.string().optional(),
95-
modifiedAt: z.string().optional(),
96-
name: z.string().optional(),
97-
path: z.string().optional(),
98-
size: z.number().int().nonnegative().optional(),
99-
type: z.string().optional(),
100-
})
101-
.passthrough()
102-
.parse(value);
103-
const name = candidate.name ?? basename(candidate.path ?? "");
104-
const path = candidate.path ?? `${parentPath.replace(/\/$/, "")}/${name}`;
105-
const relativePath = relativeSandboxPath(rootPath, path);
61+
function toFileEntry(info: DaytonaFileInfo, parentDir: string, root: string): FileEntry {
62+
const path = `${stripTrailingSlash(parentDir)}/${info.name}`;
10663
return {
107-
modifiedAt: candidate.modifiedAt ?? candidate.lastModified ?? new Date(0).toISOString(),
108-
name,
64+
name: info.name,
10965
path,
110-
relativePath,
111-
size: candidate.size ?? 0,
112-
type: normalizeFileType(candidate.type, fallbackType),
66+
relativePath: relativePath(root, path),
67+
type: info.isDir ? "directory" : "file",
68+
size: info.size,
69+
modifiedAt: info.modifiedAt ?? info.modTime ?? new Date(0).toISOString(),
11370
};
11471
}
11572

116-
function normalizeFileType(
117-
value: string | undefined,
118-
fallbackType: "file" | "directory",
119-
): "file" | "directory" | "symlink" | "other" {
120-
if (value === "file" || value === "directory" || value === "symlink" || value === "other") {
121-
return value;
122-
}
123-
return fallbackType;
124-
}
125-
126-
function basename(path: string): string {
127-
const index = path.lastIndexOf("/");
128-
return index === -1 ? path : path.slice(index + 1);
129-
}
130-
131-
function shouldIncludeEntry(
132-
entry: { name: string; relativePath: string },
133-
includeHidden: boolean,
134-
): boolean {
73+
function shouldInclude(entry: { relativePath: string }, includeHidden: boolean): boolean {
13574
if (includeHidden) {
13675
return true;
13776
}
13877
return !entry.relativePath.split("/").some((part) => part.startsWith("."));
13978
}
14079

141-
function relativeSandboxPath(rootPath: string, path: string): string {
142-
const normalizedRoot = rootPath.replace(/\/$/, "");
80+
function relativePath(root: string, path: string): string {
81+
const normalizedRoot = stripTrailingSlash(root);
14382
if (path === normalizedRoot) {
14483
return basename(path);
14584
}
@@ -148,3 +87,12 @@ function relativeSandboxPath(rootPath: string, path: string): string {
14887
}
14988
return basename(path);
15089
}
90+
91+
function basename(path: string): string {
92+
const index = path.lastIndexOf("/");
93+
return index === -1 ? path : path.slice(index + 1);
94+
}
95+
96+
function stripTrailingSlash(value: string): string {
97+
return value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value;
98+
}

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ export interface SandboxMeteringContext {
1919
storage: DurableObjectStorage;
2020
}
2121

22-
/** Seed the metering checkpoint on first owner registration (idempotent). */
22+
/**
23+
* Seed the metering checkpoint when a run lease opens (idempotent). Lifecycle-aware
24+
* (Codex R1): the checkpoint exists ONLY while ≥1 run lease is active, so we accrue
25+
* running-hours during agent runs and never bill idle/stopped time.
26+
*/
2327
export async function initSandboxMeterCheckpoint(storage: DurableObjectStorage): Promise<void> {
2428
if ((await meterCheckpointMs(storage)) === null) {
2529
await storage.put(SANDBOX_METER_CHECKPOINT_KEY, Date.now());
2630
}
2731
}
2832

29-
/** Drop the metering checkpoint when the sandbox is destroyed. */
33+
/** Drop the metering checkpoint when the last run lease closes / sandbox destroyed. */
3034
export async function clearSandboxMeterCheckpoint(storage: DurableObjectStorage): Promise<void> {
3135
await storage.delete(SANDBOX_METER_CHECKPOINT_KEY);
3236
}
@@ -63,7 +67,7 @@ async function recordSandboxUsage(ctx: SandboxMeteringContext): Promise<void> {
6367
const previousCheckpointMs = await meterCheckpointMs(ctx.storage);
6468
const now = Date.now();
6569
if (previousCheckpointMs === null) {
66-
await ctx.storage.put(SANDBOX_METER_CHECKPOINT_KEY, now);
70+
// No active run lease → not running on our behalf → do not accrue (lifecycle-aware).
6771
return;
6872
}
6973
const hours = (now - previousCheckpointMs) / MILLIS_PER_HOUR;

0 commit comments

Comments
 (0)