Skip to content

Commit da3ce2f

Browse files
authored
feat(files): add durable project uploads (#61)
## Summary - Persist uploaded documents, images, data, and code as durable project files instead of embedding file contents in prompts. - Materialize uploads into each project's Daytona workspace so agents and later chats can reuse them. - Add composer upload state, reusable project-file references, sandbox recovery, observability, and complete local setup documentation. ## What's Included ### Project file API and storage - Adds authenticated upload and listing contracts with extension, size, batch, and file-signature validation. - Stores durable copies in R2 and materializes them under `/workspace/<project>/uploads/`. - Restores files when a sandbox is provisioned, replaced, or recovered. ### Composer integration - Adds upload progress and failure states to the home and thread composers. - Replaces prompt-embedded file contents with stable project-relative references. - Makes existing project files available through the composer reference menu. ### Sandbox reliability - Detects unhealthy workspace mounts and replaces affected sandboxes. - Preserves uploads across chats, reloads, and sandbox lifecycle transitions. - Improves structured upstream HTTP error reporting. ### Local development - Documents the supported Docker Compose stack, required credentials, troubleshooting, and direct browser QA workflow. - Moves the local web endpoint to `http://localhost:3001`. ## Architecture The browser uploads a validated file through the gateway to the project-scoped sandbox Durable Object. The Durable Object writes a durable R2 copy and materializes the same bytes into the active Daytona project workspace. Composer references point to that workspace path, while provisioning and recovery rematerialize durable files when necessary. ## Decisions Made | Decision | Choice | Reasoning | |---|---|---| | Durable source of truth | R2 with workspace materialization | Files survive sandbox replacement without storing binary data in Postgres. | | Prompt representation | Project-relative paths | Avoids prompt bloat and lets agents use native document tooling. | | Validation | Browser allowlist plus server signature checks | Provides fast feedback without trusting client MIME metadata. | | Local port | 3001 | Matches the supported local environment and avoids the existing port collision. | ## Edge Cases Handled | Scenario | Handling | |---|---| | Empty, oversized, unsupported, or spoofed file | Reject before durable persistence. | | Duplicate filename | Safely normalize and resolve the project path. | | Missing or unhealthy workspace mount | Replace the sandbox and rematerialize durable project files. | | Reload or later chat | Reuse the same stored project file without re-uploading. | | Partial batch failure | Surface per-file progress and error state in the composer. | ## Verification - [x] `pnpm typecheck` - [x] `pnpm lint` - [x] `pnpm turbo build --force` under Node 22.22.2 - [x] Direct browser upload and agent-read verification for TXT, CSV, PNG, Markdown, DOCX, PDF, PPTX, and XLSX - [x] SHA-256 equality between local fixtures and Daytona materialized files - [x] Files tree and rich preview verification for DOCX, PDF, PPTX, and XLSX - [x] Hard-reload persistence and clean browser console/service-log review
1 parent 7bef9c3 commit da3ce2f

52 files changed

Lines changed: 1738 additions & 582 deletions

Some content is hidden

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

README.md

Lines changed: 158 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,182 @@ Cheatcode is a TypeScript-first generalist AI agent platform with a Vercel-hoste
44

55
The live source, package READMEs, migrations, and deployment configuration define the current system. The deleted `plan.md` is intentionally not authoritative and must not be restored.
66

7-
## Local setup
7+
## Run Cheatcode locally
8+
9+
`pnpm dev` is the only supported full-stack local entrypoint. It builds a
10+
reproducible Docker image and starts:
11+
12+
- the Next.js web app;
13+
- the gateway, agent, webhooks, and preview-proxy Workers in one chained local
14+
Wrangler process;
15+
- local Durable Object, KV, R2, Workflow, and Wrangler state; and
16+
- the shared package build watcher.
17+
18+
The application processes run locally, but a fully functional stack still uses
19+
real remote services. In particular, local Workers connect to the production
20+
Supabase database through its public session pooler and three isolated runtime
21+
roles, and agents create development sandboxes in Daytona. Local startup never
22+
starts Postgres, applies migrations, or deploys anything to Cloudflare or
23+
Vercel.
24+
25+
### Prerequisites
26+
27+
Install:
28+
29+
- Docker Desktop or Docker Engine with a recent Docker Compose release that
30+
supports `docker compose up --watch`;
31+
- NVM (or another version manager capable of selecting the exact Node version
32+
in `.nvmrc`); and
33+
- Corepack, which supplies the exact pnpm version declared in `package.json`.
34+
35+
Prepare and verify the host toolchain:
836

937
```bash
38+
nvm install
1039
nvm use
40+
corepack enable
41+
corepack prepare pnpm@11.15.0 --activate
42+
43+
node --version
44+
pnpm --version
45+
docker compose version
46+
docker info
47+
```
48+
49+
The expected Node and pnpm versions are `v22.22.2` and `11.15.0`. Do not ignore
50+
an engine warning: select or install Node 22.22.2 before installing packages or
51+
running repository commands. Docker must be running before `pnpm dev`.
52+
53+
### Configure local credentials
54+
55+
Create the one local application environment file:
56+
57+
```bash
1158
cp .env.example .env.local
12-
# Fill the production Supabase runtime-role URLs plus the Clerk development,
13-
# Daytona, Polar sandbox, and integration values.
59+
chmod 600 .env.local
60+
```
61+
62+
Fill every required value in `.env.local`. Keep the following boundaries:
63+
64+
- `SUPABASE_GATEWAY_DATABASE_URL`, `SUPABASE_AGENT_DATABASE_URL`, and
65+
`SUPABASE_WEBHOOKS_DATABASE_URL` are the production Supabase session-pooler
66+
URLs for `app_gateway`, `app_agent`, and `app_webhooks`. Do not use a direct
67+
database URL, an administrative role, `service_role`, or one role's password
68+
for another role.
69+
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` must come from the
70+
Clerk development instance and must begin with `pk_test_` and `sk_test_`.
71+
Production Clerk keys are intentionally rejected locally.
72+
- `DAYTONA_API_KEY`, `DAYTONA_SANDBOX_SNAPSHOT`, `DAYTONA_TARGET`, and
73+
`DAYTONA_WORKSPACE_VOLUME` select the development Daytona environment.
74+
`DAYTONA_WORKSPACE_VOLUME` must remain
75+
`cheatcode-workspaces-development`; never point local runs at the production
76+
workspace volume.
77+
- `POLAR_SERVER` must remain `sandbox`. Add the Polar sandbox access token,
78+
webhook secret, and sandbox product IDs to exercise billing locally.
79+
- Each signing secret group in `.env.example` must contain non-placeholder
80+
values of at least 32 UTF-8 bytes. Secrets within a group must be distinct.
81+
The startup runner checks these requirements before launching a Worker.
82+
- Keep `NEXT_PUBLIC_GATEWAY_URL=http://127.0.0.1:8787`,
83+
`NEXT_PUBLIC_PREVIEW_HOSTNAME=localhost`, and
84+
`NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA=development` for the standard local
85+
topology.
86+
- `COMPOSIO_API_KEY`, `COMPOSIO_AUTH_CONFIGS`, and
87+
`COMPOSIO_WEBHOOK_SECRET` are required for connected-tool flows.
88+
`COMPOSIO_AUTH_CONFIGS` is the JSON object that maps each supported toolkit
89+
name to its Composio auth-config ID.
90+
- `DEEPSEEK_PLATFORM_API_KEY` is optional because users may rely entirely on
91+
BYOK. `DAYTONA_ORG_ID`, Clerk webhook verification, and internal alert
92+
delivery are optional only when the corresponding account or callback flow
93+
is not being exercised.
94+
95+
Do not copy `.env.production` into `.env.local`. Do not put database migration
96+
credentials in this file; authorized operators keep those only in the ignored
97+
`.env.migrate` file. The full variable list and safe local defaults live in
98+
[`.env.example`](./.env.example).
99+
100+
### Start the stack
101+
102+
From the repository root:
103+
104+
```bash
14105
pnpm dev
15106
```
16107

17-
`pnpm dev` is the complete local entrypoint. Docker Compose builds the pinned
18-
Node and pnpm development image and starts Next.js plus the chained Workers.
19-
Local Workers use the production Supabase database through its session pooler
20-
and the same three isolated runtime roles as production Hyperdrive. Local
21-
startup never applies database migrations. Stop the stack with:
108+
The first run builds the pinned Node 22.22.2/pnpm 11.15.0 image, installs the
109+
locked workspace dependencies inside it, builds shared packages, validates
110+
`.env.local`, generates permission-restricted local Wrangler configs, and then
111+
starts the watchers. Subsequent source edits are synchronized into the
112+
container. Changes to package manifests or the lockfile trigger an image
113+
rebuild.
114+
115+
Wait for the Compose service to report `healthy`. In another terminal:
22116

23117
```bash
24-
pnpm dev:down
118+
docker compose --env-file .env.local ps
119+
docker compose --env-file .env.local logs -f app
25120
```
26121

27122
Expected local endpoints:
28123

29-
- Web: `http://127.0.0.1:3000`
124+
- Web app: `http://localhost:3001`
30125
- Gateway and chained Workers: `http://127.0.0.1:8787`
31-
- Wrangler inspector: `http://localhost:9239`
126+
- Gateway health: `http://127.0.0.1:8787/health`
127+
- Wrangler inspector: `http://127.0.0.1:9239`
128+
129+
The Compose health check verifies both the web app's Cheatcode symbol asset and
130+
the gateway's JSON health response. A healthy gateway also proves that the
131+
service-bound agent and webhooks Workers are reachable.
132+
133+
### Stop or reset the stack
134+
135+
Stop all local services cleanly:
136+
137+
```bash
138+
pnpm dev:down
139+
```
140+
141+
The normal shutdown keeps the local Next and Wrangler cache volumes so the next
142+
start is faster. If those generated caches become corrupt, remove only those
143+
local volumes and rebuild:
144+
145+
```bash
146+
docker compose --env-file .env.local down --volumes --remove-orphans
147+
pnpm dev
148+
```
149+
150+
This does not delete production Supabase data or Daytona workspaces. Project and
151+
account deletion must still go through the application so its durable cleanup
152+
workflow can remove remote resources correctly.
153+
154+
### Troubleshooting
155+
156+
- **Node engine mismatch:** run `nvm install 22.22.2 && nvm use 22.22.2`, then
157+
confirm `node --version` before retrying.
158+
- **Docker cannot connect:** start Docker Desktop or the Docker daemon and
159+
confirm `docker info` succeeds.
160+
- **A required environment value is missing:** read the startup error, update
161+
the named value in `.env.local`, and rerun `pnpm dev`. The runner also rejects
162+
production Clerk keys, unsafe database targets, reused signing secrets, and
163+
cloud-only credentials in the local file.
164+
- **Port already in use:** release ports `3001`, `8787`, and `9239`; the
165+
supported Compose topology binds all three to loopback.
166+
- **A dependency changed but the image did not rebuild:** run
167+
`docker compose --env-file .env.local build --no-cache app`, then
168+
`pnpm dev`.
169+
- **The UI loads but an external feature fails:** confirm the relevant remote
170+
service credential is populated and active. Supabase, Clerk, Daytona, Polar,
171+
Composio, and provider APIs are not emulated by Compose.
172+
- **A provider webhook is being tested:** the provider must be configured to
173+
reach the local webhooks Worker through a trusted public ingress, and its
174+
signing secret must match `.env.local`. Loopback URLs cannot receive
175+
internet-originated callbacks by themselves.
176+
177+
### Verify the product
32178

33179
Product QA is direct browser operation only:
34180

35181
```bash
36-
agent-browser --auto-connect --session cheatcode-debug open http://127.0.0.1:3000
182+
agent-browser --auto-connect --session cheatcode-debug open http://localhost:3001
37183
agent-browser --auto-connect --session cheatcode-debug snapshot -i
38184
```
39185

apps/agent-worker/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ existence before minting a one-hour HMAC capability; the public signed download
3232
streaming second hop. Expiring capabilities and internal R2 keys are never stored in transcripts or
3333
returned by artifact tools.
3434

35+
User uploads are durable project files rather than prompt text. The authenticated project-file
36+
route accepts one bounded raw file at a time, validates its filename, extension, UTF-8 or binary
37+
signature, and tenant/project write state, then derives deterministic file and version UUIDs from
38+
the project path and content digest. R2 stores immutable bytes under the existing
39+
`user/project/` lifecycle prefix with create-only checksum verification. ProjectSandbox stores the
40+
small current/version namespace records and mirrors the current version to
41+
`/workspace/<workspaceSlug>/uploads/` on the user's persistent Daytona volume before exposing it.
42+
An exact replay is idempotent; uploading new bytes at the same path creates a retained version and
43+
updates the working copy. Project deletion removes the namespace during fenced workspace cleanup
44+
and the existing resource-deletion prefix sweep removes every immutable object. Account deletion
45+
clears both through the existing account state and R2 lifecycle phases.
46+
3547
Run creation validates the gateway payload with the shared `CreateRunSchema` from
3648
`packages/types` before selecting the run-scoped `AgentRun` Durable Object. The
3749
database binds a gateway-hashed idempotency key to the exact body and thread. After the

apps/agent-worker/src/agent-api-system-routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ async function deleteInternalUserState(c: AgentContext): Promise<Response> {
175175
await sandbox.deleteAccountState();
176176
return deletedStateResponse(c);
177177
}
178-
const sandbox = await sandboxForUser(c.env, userId);
178+
const sandbox = await sandboxStubForUser(c.env, userId);
179179
await sandbox.cleanupProjectWorkspace({
180180
projectId: body.projectId,
181181
workspaceSlug: body.workspaceSlug,

apps/agent-worker/src/agent-routing.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
findActiveAgentRunForThread,
1010
findAgentEntitlementByUserId,
1111
findAgentRunForUser,
12+
getProject,
1213
getProjectWriteState,
1314
getThread,
1415
type RunPersonalization,
@@ -20,7 +21,14 @@ import {
2021
emitUserEvent,
2122
readBoundedResponseJson,
2223
} from "@cheatcode/observability";
23-
import { AgentRunId, type CreateRun, ThreadId, UserId } from "@cheatcode/types";
24+
import {
25+
AgentRunId,
26+
type CreateRun,
27+
ProjectId,
28+
type ProjectSummary,
29+
ThreadId,
30+
UserId,
31+
} from "@cheatcode/types";
2432
import {
2533
QUOTA_FEATURES,
2634
QUOTA_TRACKER_MAX_RESPONSE_BYTES,
@@ -120,6 +128,53 @@ export async function requireWritableThreadProject(
120128
}
121129
}
122130

131+
export async function requireProjectAccess(
132+
env: AgentEnv,
133+
userId: string,
134+
projectId: string,
135+
writable: boolean,
136+
): Promise<ProjectSummary & { workspaceSlug: string }> {
137+
const parsedUserId = UserId(userId);
138+
const { db, close } = createDb(env.HYPERDRIVE, {
139+
audience: "app_agent",
140+
signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT,
141+
});
142+
try {
143+
return await withUserContext(db, parsedUserId, async (tx) => {
144+
const project = await getProject(tx, {
145+
projectId: ProjectId(projectId),
146+
userId: parsedUserId,
147+
});
148+
if (!project) {
149+
throw new APIError(404, "not_found_project", "Project not found", { retriable: false });
150+
}
151+
if (writable && project.readOnly) {
152+
throw new APIError(
153+
403,
154+
"permission_plan_required",
155+
"Project is read-only after downgrade",
156+
{
157+
details: {
158+
archiveAfter: project.archiveAfter?.toISOString() ?? null,
159+
overQuota: project.overQuota,
160+
},
161+
hint: "Delete or archive over-limit projects, or upgrade your plan to continue editing this project.",
162+
retriable: false,
163+
},
164+
);
165+
}
166+
return {
167+
...project,
168+
archiveAfter: project.archiveAfter?.toISOString() ?? null,
169+
createdAt: project.createdAt.toISOString(),
170+
updatedAt: project.updatedAt.toISOString(),
171+
};
172+
});
173+
} finally {
174+
await close();
175+
}
176+
}
177+
123178
export function agentRunForRunId(env: AgentEnv, runId: string): DurableObjectStub<AgentRun> {
124179
return env.AGENT_RUN.get(env.AGENT_RUN.idFromName(runId));
125180
}

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
shellQuote,
5050
timeoutSeconds,
5151
} from "./project-sandbox-process-support";
52+
import { ProjectSandboxProjectFiles } from "./project-sandbox-project-files";
5253
import {
5354
type ProjectArchiveInput,
5455
ProjectArchiveInputSchema,
@@ -81,7 +82,6 @@ import {
8182
type ProjectWriteFileInput,
8283
ProjectWriteFileInputSchema,
8384
} from "./project-sandbox-runtime";
84-
import { ProjectSandboxWorkspaceTransition } from "./project-sandbox-workspace-transition";
8585

8686
const PREVIEW_STATUS_PROBE_TIMEOUT_MS = 3_000;
8787
const PREVIEW_WAKE_TIMEOUT_MS = 90_000;
@@ -91,7 +91,7 @@ const BROWSER_TAKEOVER_PORT_MIN = 60_000;
9191
const BROWSER_TAKEOVER_PORT_MAX = 60_999;
9292
const BROWSER_TAKEOVER_SCRIPT = "/opt/cheatcode/start-browser-takeover.sh";
9393

94-
export abstract class ProjectSandboxContent extends ProjectSandboxWorkspaceTransition {
94+
export abstract class ProjectSandboxContent extends ProjectSandboxProjectFiles {
9595
public downloadProjectArchive(input: ProjectArchiveInput): Promise<Response> {
9696
return this.downloadProjectArchiveForRpc(input, () => undefined);
9797
}
@@ -194,9 +194,7 @@ export abstract class ProjectSandboxContent extends ProjectSandboxWorkspaceTrans
194194
public async writeFile(input: ProjectWriteFileInput): Promise<SandboxWriteFileResult> {
195195
const parsed = ProjectWriteFileInputSchema.parse(input);
196196
const id = await this.ensureSandbox();
197-
await this.client()
198-
.createFolder(id, dirname(parsed.path))
199-
.catch(() => undefined);
197+
await this.client().createFolder(id, dirname(parsed.path));
200198
const bytes =
201199
parsed.encoding === "base64"
202200
? decodeBase64(parsed.content)
@@ -393,18 +391,22 @@ export abstract class ProjectSandboxContent extends ProjectSandboxWorkspaceTrans
393391
public cleanupProjectWorkspace(input: ProjectCleanupWorkspaceInput): Promise<void> {
394392
const parsed = ProjectCleanupWorkspaceInputSchema.parse(input);
395393
return this.deleteProjectWorkspace(parsed, () =>
396-
this.performProjectWorkspaceCleanup(parsed.workspaceSlug),
394+
this.performProjectWorkspaceCleanup(parsed.projectId, parsed.workspaceSlug),
397395
);
398396
}
399397

400-
private async performProjectWorkspaceCleanup(workspaceSlug: string): Promise<void> {
398+
private async performProjectWorkspaceCleanup(
399+
projectId: string,
400+
workspaceSlug: string,
401+
): Promise<void> {
401402
const id = await this.ensureExistingSandboxStarted();
402403
await super.killAllProcesses();
403404
if (id) {
404405
await this.terminateUntrackedSandboxProcesses(id);
405406
await this.removeWorkspaceFolder(id, workspaceSlug);
406407
}
407408
await this.freeProjectPort(workspaceSlug);
409+
await this.deleteUploadedFileMetadata(projectId);
408410
}
409411

410412
private async mobileExpoProxy(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ProjectSandboxEnv {
1919
PREVIEW_HOSTNAME: string;
2020
QUOTA_TRACKER: DurableObjectNamespace;
2121
R2_AUDIT: R2Bucket;
22+
R2_OUTPUTS: R2Bucket;
2223
}
2324

2425
export const ACCOUNT_DELETION_TOMBSTONE_KEY = "account_deletion_tombstone";

0 commit comments

Comments
 (0)