diff --git a/README.md b/README.md index 31ef85bb..56763872 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,182 @@ Cheatcode is a TypeScript-first generalist AI agent platform with a Vercel-hoste 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. -## Local setup +## Run Cheatcode locally + +`pnpm dev` is the only supported full-stack local entrypoint. It builds a +reproducible Docker image and starts: + +- the Next.js web app; +- the gateway, agent, webhooks, and preview-proxy Workers in one chained local + Wrangler process; +- local Durable Object, KV, R2, Workflow, and Wrangler state; and +- the shared package build watcher. + +The application processes run locally, but a fully functional stack still uses +real remote services. In particular, local Workers connect to the production +Supabase database through its public session pooler and three isolated runtime +roles, and agents create development sandboxes in Daytona. Local startup never +starts Postgres, applies migrations, or deploys anything to Cloudflare or +Vercel. + +### Prerequisites + +Install: + +- Docker Desktop or Docker Engine with a recent Docker Compose release that + supports `docker compose up --watch`; +- NVM (or another version manager capable of selecting the exact Node version + in `.nvmrc`); and +- Corepack, which supplies the exact pnpm version declared in `package.json`. + +Prepare and verify the host toolchain: ```bash +nvm install nvm use +corepack enable +corepack prepare pnpm@11.15.0 --activate + +node --version +pnpm --version +docker compose version +docker info +``` + +The expected Node and pnpm versions are `v22.22.2` and `11.15.0`. Do not ignore +an engine warning: select or install Node 22.22.2 before installing packages or +running repository commands. Docker must be running before `pnpm dev`. + +### Configure local credentials + +Create the one local application environment file: + +```bash cp .env.example .env.local -# Fill the production Supabase runtime-role URLs plus the Clerk development, -# Daytona, Polar sandbox, and integration values. +chmod 600 .env.local +``` + +Fill every required value in `.env.local`. Keep the following boundaries: + +- `SUPABASE_GATEWAY_DATABASE_URL`, `SUPABASE_AGENT_DATABASE_URL`, and + `SUPABASE_WEBHOOKS_DATABASE_URL` are the production Supabase session-pooler + URLs for `app_gateway`, `app_agent`, and `app_webhooks`. Do not use a direct + database URL, an administrative role, `service_role`, or one role's password + for another role. +- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` must come from the + Clerk development instance and must begin with `pk_test_` and `sk_test_`. + Production Clerk keys are intentionally rejected locally. +- `DAYTONA_API_KEY`, `DAYTONA_SANDBOX_SNAPSHOT`, `DAYTONA_TARGET`, and + `DAYTONA_WORKSPACE_VOLUME` select the development Daytona environment. + `DAYTONA_WORKSPACE_VOLUME` must remain + `cheatcode-workspaces-development`; never point local runs at the production + workspace volume. +- `POLAR_SERVER` must remain `sandbox`. Add the Polar sandbox access token, + webhook secret, and sandbox product IDs to exercise billing locally. +- Each signing secret group in `.env.example` must contain non-placeholder + values of at least 32 UTF-8 bytes. Secrets within a group must be distinct. + The startup runner checks these requirements before launching a Worker. +- Keep `NEXT_PUBLIC_GATEWAY_URL=http://127.0.0.1:8787`, + `NEXT_PUBLIC_PREVIEW_HOSTNAME=localhost`, and + `NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA=development` for the standard local + topology. +- `COMPOSIO_API_KEY`, `COMPOSIO_AUTH_CONFIGS`, and + `COMPOSIO_WEBHOOK_SECRET` are required for connected-tool flows. + `COMPOSIO_AUTH_CONFIGS` is the JSON object that maps each supported toolkit + name to its Composio auth-config ID. +- `DEEPSEEK_PLATFORM_API_KEY` is optional because users may rely entirely on + BYOK. `DAYTONA_ORG_ID`, Clerk webhook verification, and internal alert + delivery are optional only when the corresponding account or callback flow + is not being exercised. + +Do not copy `.env.production` into `.env.local`. Do not put database migration +credentials in this file; authorized operators keep those only in the ignored +`.env.migrate` file. The full variable list and safe local defaults live in +[`.env.example`](./.env.example). + +### Start the stack + +From the repository root: + +```bash pnpm dev ``` -`pnpm dev` is the complete local entrypoint. Docker Compose builds the pinned -Node and pnpm development image and starts Next.js plus the chained Workers. -Local Workers use the production Supabase database through its session pooler -and the same three isolated runtime roles as production Hyperdrive. Local -startup never applies database migrations. Stop the stack with: +The first run builds the pinned Node 22.22.2/pnpm 11.15.0 image, installs the +locked workspace dependencies inside it, builds shared packages, validates +`.env.local`, generates permission-restricted local Wrangler configs, and then +starts the watchers. Subsequent source edits are synchronized into the +container. Changes to package manifests or the lockfile trigger an image +rebuild. + +Wait for the Compose service to report `healthy`. In another terminal: ```bash -pnpm dev:down +docker compose --env-file .env.local ps +docker compose --env-file .env.local logs -f app ``` Expected local endpoints: -- Web: `http://127.0.0.1:3000` +- Web app: `http://localhost:3001` - Gateway and chained Workers: `http://127.0.0.1:8787` -- Wrangler inspector: `http://localhost:9239` +- Gateway health: `http://127.0.0.1:8787/health` +- Wrangler inspector: `http://127.0.0.1:9239` + +The Compose health check verifies both the web app's Cheatcode symbol asset and +the gateway's JSON health response. A healthy gateway also proves that the +service-bound agent and webhooks Workers are reachable. + +### Stop or reset the stack + +Stop all local services cleanly: + +```bash +pnpm dev:down +``` + +The normal shutdown keeps the local Next and Wrangler cache volumes so the next +start is faster. If those generated caches become corrupt, remove only those +local volumes and rebuild: + +```bash +docker compose --env-file .env.local down --volumes --remove-orphans +pnpm dev +``` + +This does not delete production Supabase data or Daytona workspaces. Project and +account deletion must still go through the application so its durable cleanup +workflow can remove remote resources correctly. + +### Troubleshooting + +- **Node engine mismatch:** run `nvm install 22.22.2 && nvm use 22.22.2`, then + confirm `node --version` before retrying. +- **Docker cannot connect:** start Docker Desktop or the Docker daemon and + confirm `docker info` succeeds. +- **A required environment value is missing:** read the startup error, update + the named value in `.env.local`, and rerun `pnpm dev`. The runner also rejects + production Clerk keys, unsafe database targets, reused signing secrets, and + cloud-only credentials in the local file. +- **Port already in use:** release ports `3001`, `8787`, and `9239`; the + supported Compose topology binds all three to loopback. +- **A dependency changed but the image did not rebuild:** run + `docker compose --env-file .env.local build --no-cache app`, then + `pnpm dev`. +- **The UI loads but an external feature fails:** confirm the relevant remote + service credential is populated and active. Supabase, Clerk, Daytona, Polar, + Composio, and provider APIs are not emulated by Compose. +- **A provider webhook is being tested:** the provider must be configured to + reach the local webhooks Worker through a trusted public ingress, and its + signing secret must match `.env.local`. Loopback URLs cannot receive + internet-originated callbacks by themselves. + +### Verify the product Product QA is direct browser operation only: ```bash -agent-browser --auto-connect --session cheatcode-debug open http://127.0.0.1:3000 +agent-browser --auto-connect --session cheatcode-debug open http://localhost:3001 agent-browser --auto-connect --session cheatcode-debug snapshot -i ``` diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 5131f77c..2b332d90 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -32,6 +32,18 @@ existence before minting a one-hour HMAC capability; the public signed download streaming second hop. Expiring capabilities and internal R2 keys are never stored in transcripts or returned by artifact tools. +User uploads are durable project files rather than prompt text. The authenticated project-file +route accepts one bounded raw file at a time, validates its filename, extension, UTF-8 or binary +signature, and tenant/project write state, then derives deterministic file and version UUIDs from +the project path and content digest. R2 stores immutable bytes under the existing +`user/project/` lifecycle prefix with create-only checksum verification. ProjectSandbox stores the +small current/version namespace records and mirrors the current version to +`/workspace//uploads/` on the user's persistent Daytona volume before exposing it. +An exact replay is idempotent; uploading new bytes at the same path creates a retained version and +updates the working copy. Project deletion removes the namespace during fenced workspace cleanup +and the existing resource-deletion prefix sweep removes every immutable object. Account deletion +clears both through the existing account state and R2 lifecycle phases. + Run creation validates the gateway payload with the shared `CreateRunSchema` from `packages/types` before selecting the run-scoped `AgentRun` Durable Object. The database binds a gateway-hashed idempotency key to the exact body and thread. After the diff --git a/apps/agent-worker/src/agent-api-system-routes.ts b/apps/agent-worker/src/agent-api-system-routes.ts index 9bc75e42..f0afd073 100644 --- a/apps/agent-worker/src/agent-api-system-routes.ts +++ b/apps/agent-worker/src/agent-api-system-routes.ts @@ -175,7 +175,7 @@ async function deleteInternalUserState(c: AgentContext): Promise { await sandbox.deleteAccountState(); return deletedStateResponse(c); } - const sandbox = await sandboxForUser(c.env, userId); + const sandbox = await sandboxStubForUser(c.env, userId); await sandbox.cleanupProjectWorkspace({ projectId: body.projectId, workspaceSlug: body.workspaceSlug, diff --git a/apps/agent-worker/src/agent-routing.ts b/apps/agent-worker/src/agent-routing.ts index 63a8586e..ab7bfb4e 100644 --- a/apps/agent-worker/src/agent-routing.ts +++ b/apps/agent-worker/src/agent-routing.ts @@ -9,6 +9,7 @@ import { findActiveAgentRunForThread, findAgentEntitlementByUserId, findAgentRunForUser, + getProject, getProjectWriteState, getThread, type RunPersonalization, @@ -20,7 +21,14 @@ import { emitUserEvent, readBoundedResponseJson, } from "@cheatcode/observability"; -import { AgentRunId, type CreateRun, ThreadId, UserId } from "@cheatcode/types"; +import { + AgentRunId, + type CreateRun, + ProjectId, + type ProjectSummary, + ThreadId, + UserId, +} from "@cheatcode/types"; import { QUOTA_FEATURES, QUOTA_TRACKER_MAX_RESPONSE_BYTES, @@ -120,6 +128,53 @@ export async function requireWritableThreadProject( } } +export async function requireProjectAccess( + env: AgentEnv, + userId: string, + projectId: string, + writable: boolean, +): Promise { + const parsedUserId = UserId(userId); + const { db, close } = createDb(env.HYPERDRIVE, { + audience: "app_agent", + signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT, + }); + try { + return await withUserContext(db, parsedUserId, async (tx) => { + const project = await getProject(tx, { + projectId: ProjectId(projectId), + userId: parsedUserId, + }); + if (!project) { + throw new APIError(404, "not_found_project", "Project not found", { retriable: false }); + } + if (writable && project.readOnly) { + throw new APIError( + 403, + "permission_plan_required", + "Project is read-only after downgrade", + { + details: { + archiveAfter: project.archiveAfter?.toISOString() ?? null, + overQuota: project.overQuota, + }, + hint: "Delete or archive over-limit projects, or upgrade your plan to continue editing this project.", + retriable: false, + }, + ); + } + return { + ...project, + archiveAfter: project.archiveAfter?.toISOString() ?? null, + createdAt: project.createdAt.toISOString(), + updatedAt: project.updatedAt.toISOString(), + }; + }); + } finally { + await close(); + } +} + export function agentRunForRunId(env: AgentEnv, runId: string): DurableObjectStub { return env.AGENT_RUN.get(env.AGENT_RUN.idFromName(runId)); } diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-content.ts b/apps/agent-worker/src/durable-objects/project-sandbox-content.ts index 141735de..07166ffe 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-content.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-content.ts @@ -49,6 +49,7 @@ import { shellQuote, timeoutSeconds, } from "./project-sandbox-process-support"; +import { ProjectSandboxProjectFiles } from "./project-sandbox-project-files"; import { type ProjectArchiveInput, ProjectArchiveInputSchema, @@ -81,7 +82,6 @@ import { type ProjectWriteFileInput, ProjectWriteFileInputSchema, } from "./project-sandbox-runtime"; -import { ProjectSandboxWorkspaceTransition } from "./project-sandbox-workspace-transition"; const PREVIEW_STATUS_PROBE_TIMEOUT_MS = 3_000; const PREVIEW_WAKE_TIMEOUT_MS = 90_000; @@ -91,7 +91,7 @@ const BROWSER_TAKEOVER_PORT_MIN = 60_000; const BROWSER_TAKEOVER_PORT_MAX = 60_999; const BROWSER_TAKEOVER_SCRIPT = "/opt/cheatcode/start-browser-takeover.sh"; -export abstract class ProjectSandboxContent extends ProjectSandboxWorkspaceTransition { +export abstract class ProjectSandboxContent extends ProjectSandboxProjectFiles { public downloadProjectArchive(input: ProjectArchiveInput): Promise { return this.downloadProjectArchiveForRpc(input, () => undefined); } @@ -194,9 +194,7 @@ export abstract class ProjectSandboxContent extends ProjectSandboxWorkspaceTrans public async writeFile(input: ProjectWriteFileInput): Promise { const parsed = ProjectWriteFileInputSchema.parse(input); const id = await this.ensureSandbox(); - await this.client() - .createFolder(id, dirname(parsed.path)) - .catch(() => undefined); + await this.client().createFolder(id, dirname(parsed.path)); const bytes = parsed.encoding === "base64" ? decodeBase64(parsed.content) @@ -393,11 +391,14 @@ export abstract class ProjectSandboxContent extends ProjectSandboxWorkspaceTrans public cleanupProjectWorkspace(input: ProjectCleanupWorkspaceInput): Promise { const parsed = ProjectCleanupWorkspaceInputSchema.parse(input); return this.deleteProjectWorkspace(parsed, () => - this.performProjectWorkspaceCleanup(parsed.workspaceSlug), + this.performProjectWorkspaceCleanup(parsed.projectId, parsed.workspaceSlug), ); } - private async performProjectWorkspaceCleanup(workspaceSlug: string): Promise { + private async performProjectWorkspaceCleanup( + projectId: string, + workspaceSlug: string, + ): Promise { const id = await this.ensureExistingSandboxStarted(); await super.killAllProcesses(); if (id) { @@ -405,6 +406,7 @@ export abstract class ProjectSandboxContent extends ProjectSandboxWorkspaceTrans await this.removeWorkspaceFolder(id, workspaceSlug); } await this.freeProjectPort(workspaceSlug); + await this.deleteUploadedFileMetadata(projectId); } private async mobileExpoProxy( diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts index 490cec42..36b3d689 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle-support.ts @@ -19,6 +19,7 @@ export interface ProjectSandboxEnv { PREVIEW_HOSTNAME: string; QUOTA_TRACKER: DurableObjectNamespace; R2_AUDIT: R2Bucket; + R2_OUTPUTS: R2Bucket; } export const ACCOUNT_DELETION_TOMBSTONE_KEY = "account_deletion_tombstone"; diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts index a45a446c..255e0351 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts @@ -638,6 +638,19 @@ export abstract class ProjectSandboxLifecycle extends DurableObject { + await this.withSandboxMutation(async () => { + const client = await this.ensureClient(); + try { + await this.provisioning.restart(client, sandboxId); + } catch (error) { + throw this.toUpstreamError(error, "Daytona workspace recovery failed."); + } + this.daytonaId = sandboxId; + this.startedVerifiedAtMs = Date.now(); + }); + } + protected async ensureExistingSandboxStarted(): Promise { return this.withSandboxMutation(async () => { const client = await this.ensureClient(); @@ -735,6 +748,16 @@ export abstract class ProjectSandboxLifecycle extends DurableObject { return writeExecAudit(this.env.R2_AUDIT, entry); } diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts b/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts index 1189233d..51cbf3ff 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts @@ -634,7 +634,7 @@ export abstract class ProjectSandboxProcesses extends ProjectSandboxLifecycle { const body = Object.entries(env) .map(([key, value]) => `export ${key}=${shellQuote(value)}`) .join("\n"); - await this.client().createFolder(id, ENV_FILE_DIR, "0700"); + await this.client().createFolder(id, ENV_FILE_DIR, "700"); await this.client().uploadFile(id, envPath, new TextEncoder().encode(`${body}\n`)); const permissions = await this.client().execute(id, { command: `chmod 600 ${shellQuote(envPath)}`, diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-project-files.ts b/apps/agent-worker/src/durable-objects/project-sandbox-project-files.ts new file mode 100644 index 00000000..7a91ee18 --- /dev/null +++ b/apps/agent-worker/src/durable-objects/project-sandbox-project-files.ts @@ -0,0 +1,370 @@ +import { workspacePathForSlug } from "@cheatcode/db"; +import { APIError } from "@cheatcode/observability"; +import { DaytonaApiError } from "@cheatcode/tools-code"; +import { + PROJECT_FILE_MAX_CURRENT_FILES, + type ProjectFile, + ProjectFileListSchema, + ProjectFileRelativePathSchema, + ProjectFileSchema, + type ProjectFileUploadResponse, + ProjectFileUploadResponseSchema, +} from "@cheatcode/types"; +import { z } from "zod"; +import { + type ProjectListUploadedFilesInput, + ProjectListUploadedFilesInputSchema, + type ProjectUploadFileInput, + ProjectUploadFileInputSchema, +} from "./project-sandbox-runtime"; +import { ProjectSandboxWorkspaceTransition } from "./project-sandbox-workspace-transition"; + +const FILE_DIGEST_DOMAIN = "cheatcode:project-file:v2"; +const VERSION_DIGEST_DOMAIN = "cheatcode:project-file-version:v2"; +const FILE_RECORD_PREFIX = "project-file:"; +const VERSION_RECORD_PREFIX = "project-file-version:"; +const DELETE_BATCH_SIZE = 128; + +const ProjectFileVersionSchema = z + .object({ + contentType: z.string().min(1).max(200), + createdAt: z.string().datetime(), + fileId: z.string().uuid(), + name: z.string().min(1).max(200), + path: ProjectFileRelativePathSchema, + projectId: z.string().uuid(), + r2Key: z.string().min(1).max(1_000), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + sizeBytes: z.number().int().positive(), + versionId: z.string().uuid(), + }) + .strict(); + +type ProjectFileVersion = z.infer; + +interface PreparedProjectFile { + contentSha256: string; + fileId: string; + r2Key: string; + versionId: string; +} + +export abstract class ProjectSandboxProjectFiles extends ProjectSandboxWorkspaceTransition { + private projectFileMutationTail: Promise = Promise.resolve(); + + public listUploadedFiles( + input: ProjectListUploadedFilesInput, + ): Promise<{ files: ProjectFile[] }> { + const parsed = ProjectListUploadedFilesInputSchema.parse(input); + return this.listProjectFileRecords(parsed.projectId); + } + + public uploadProjectFile(input: ProjectUploadFileInput): Promise { + const parsed = ProjectUploadFileInputSchema.parse(input); + const operation = this.projectFileMutationTail + .catch(() => undefined) + .then(() => this.persistProjectFile(parsed)); + this.projectFileMutationTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + protected deleteUploadedFileMetadata(projectId: string): Promise { + return Promise.all([ + this.deleteStoragePrefix(fileRecordProjectPrefix(projectId)), + this.deleteStoragePrefix(versionRecordProjectPrefix(projectId)), + ]).then(() => undefined); + } + + private async persistProjectFile( + input: z.output, + ): Promise { + const existing = await this.currentFile(input.projectId, input.path); + await this.enforceFileCount(input.projectId, existing !== null); + const prepared = await prepareProjectFile(input, this.ownerUserId()); + const version = projectFileVersion(input, prepared); + const previousVersion = await this.storedVersion(version); + const status = existing + ? existing.versionId === prepared.versionId + ? "unchanged" + : "updated" + : "created"; + await this.writeAndVerifyObject(input, version); + try { + await this.materializeProjectFile(input, prepared.contentSha256); + const file = await this.commitProjectFile(input, prepared, existing, previousVersion); + return ProjectFileUploadResponseSchema.parse({ file, status }); + } catch (error) { + if (!previousVersion) { + await this.env.R2_OUTPUTS.delete(prepared.r2Key).catch(() => undefined); + } + throw error; + } + } + + private async commitProjectFile( + input: z.output, + prepared: PreparedProjectFile, + existing: ProjectFile | null, + previousVersion: ProjectFileVersion | null, + ): Promise { + const now = new Date().toISOString(); + const file = ProjectFileSchema.parse({ + contentType: input.contentType, + createdAt: existing?.createdAt ?? now, + fileId: prepared.fileId, + name: input.name, + path: input.path, + projectId: input.projectId, + sha256: prepared.contentSha256, + sizeBytes: input.bytes.byteLength, + updatedAt: existing?.versionId === prepared.versionId ? existing.updatedAt : now, + versionCount: (existing?.versionCount ?? 0) + (previousVersion ? 0 : 1), + versionId: prepared.versionId, + }); + await this.ctx.storage.transaction(async (transaction) => { + if (!previousVersion) { + await transaction.put( + versionRecordKey(input.projectId, prepared.fileId, prepared.versionId), + projectFileVersion(input, prepared), + ); + } + await transaction.put(fileRecordKey(input.projectId, prepared.fileId), file); + }); + return file; + } + + private async materializeProjectFile( + input: z.output, + contentSha256: string, + ): Promise { + const sandboxId = await this.ensureSandbox(); + const projectRoot = workspacePathForSlug(input.workspaceSlug); + const workspacePath = `${projectRoot}/${input.path}`; + let written: Uint8Array; + try { + written = await this.writeProjectFileToWorkspace( + sandboxId, + projectRoot, + workspacePath, + input.bytes, + ); + } catch (error) { + if (!isRecoverableWorkspaceMountError(error)) { + throw error; + } + await this.restartSandboxForWorkspaceRecovery(sandboxId); + written = await this.writeProjectFileToWorkspace( + sandboxId, + projectRoot, + workspacePath, + input.bytes, + ); + } + if ( + written.byteLength !== input.bytes.byteLength || + (await sha256Hex(written)) !== contentSha256 + ) { + throw new APIError( + 502, + "upstream_sandbox_failed", + "Project file could not be verified in the workspace", + { retriable: true }, + ); + } + } + + private async writeProjectFileToWorkspace( + sandboxId: string, + projectRoot: string, + workspacePath: string, + bytes: Uint8Array, + ): Promise { + await this.client().createFolder(sandboxId, `${projectRoot}/uploads`); + await this.client().uploadFile(sandboxId, workspacePath, bytes); + return this.client().downloadFile(sandboxId, workspacePath, bytes.byteLength); + } + + private async writeAndVerifyObject( + input: z.output, + version: ProjectFileVersion, + ): Promise { + const stored = await this.env.R2_OUTPUTS.put(version.r2Key, input.bytes, { + customMetadata: { + contentSha256: version.sha256, + fileId: version.fileId, + projectId: version.projectId, + versionId: version.versionId, + }, + httpMetadata: { contentType: version.contentType }, + onlyIf: { etagDoesNotMatch: "*" }, + sha256: version.sha256, + }); + assertStoredProjectFile(stored ?? (await this.env.R2_OUTPUTS.head(version.r2Key)), version); + } + + private async currentFile(projectId: string, path: string): Promise { + const fileId = await deterministicUuid([ + FILE_DIGEST_DOMAIN, + this.ownerUserId(), + projectId, + path, + ]); + const value = await this.ctx.storage.get(fileRecordKey(projectId, fileId)); + const parsed = ProjectFileSchema.safeParse(value); + return parsed.success ? parsed.data : null; + } + + private async storedVersion(version: ProjectFileVersion): Promise { + const value = await this.ctx.storage.get( + versionRecordKey(version.projectId, version.fileId, version.versionId), + ); + const parsed = ProjectFileVersionSchema.safeParse(value); + return parsed.success ? parsed.data : null; + } + + private async enforceFileCount(projectId: string, fileExists: boolean): Promise { + if (fileExists) return; + const files = await this.ctx.storage.list({ prefix: fileRecordProjectPrefix(projectId) }); + if (files.size >= PROJECT_FILE_MAX_CURRENT_FILES) { + throw new APIError( + 409, + "conflict_state_invalid", + "This project has too many uploaded files", + { + hint: "Remove an older project file before uploading another one.", + retriable: false, + }, + ); + } + } + + private async listProjectFileRecords(projectId: string): Promise<{ files: ProjectFile[] }> { + const records = await this.ctx.storage.list({ + prefix: fileRecordProjectPrefix(projectId), + }); + const files = Array.from(records.values()).flatMap((value) => { + const parsed = ProjectFileSchema.safeParse(value); + return parsed.success ? [parsed.data] : []; + }); + files.sort((left, right) => left.path.localeCompare(right.path)); + return ProjectFileListSchema.parse({ files }); + } + + private async deleteStoragePrefix(prefix: string): Promise { + while (true) { + const records = await this.ctx.storage.list({ limit: DELETE_BATCH_SIZE, prefix }); + const keys = [...records.keys()]; + if (keys.length === 0) return; + await this.ctx.storage.delete(keys); + } + } +} + +function isRecoverableWorkspaceMountError(error: unknown): boolean { + return error instanceof DaytonaApiError && error.status === 400; +} + +async function prepareProjectFile( + input: z.output, + userId: string, +): Promise { + const contentSha256 = await sha256Hex(input.bytes); + const fileId = await deterministicUuid([FILE_DIGEST_DOMAIN, userId, input.projectId, input.path]); + const versionId = await deterministicUuid([ + VERSION_DIGEST_DOMAIN, + fileId, + contentSha256, + input.contentType, + String(input.bytes.byteLength), + ]); + return { + contentSha256, + fileId, + r2Key: `${userId}/${input.projectId}/project-files/${fileId}/${versionId}`, + versionId, + }; +} + +function projectFileVersion( + input: z.output, + prepared: PreparedProjectFile, +): ProjectFileVersion { + return ProjectFileVersionSchema.parse({ + contentType: input.contentType, + createdAt: new Date().toISOString(), + fileId: prepared.fileId, + name: input.name, + path: input.path, + projectId: input.projectId, + r2Key: prepared.r2Key, + sha256: prepared.contentSha256, + sizeBytes: input.bytes.byteLength, + versionId: prepared.versionId, + }); +} + +function assertStoredProjectFile(object: R2Object | null, version: ProjectFileVersion): void { + const metadata = object?.customMetadata; + const checksum = object?.checksums.sha256; + if ( + !object || + object.key !== version.r2Key || + object.size !== version.sizeBytes || + object.httpMetadata?.contentType !== version.contentType || + metadata?.["contentSha256"] !== version.sha256 || + metadata["fileId"] !== version.fileId || + metadata["projectId"] !== version.projectId || + metadata["versionId"] !== version.versionId || + !checksum || + bytesToHex(new Uint8Array(checksum)) !== version.sha256 + ) { + throw new APIError(409, "conflict_state_invalid", "Stored project file identity is invalid", { + retriable: false, + }); + } +} + +async function deterministicUuid(parts: string[]): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\0"))); + const bytes = new Uint8Array(digest).slice(0, 16); + const versionByte = bytes[6]; + const variantByte = bytes[8]; + if (versionByte === undefined || variantByte === undefined) { + throw new Error("Project file identity digest was incomplete"); + } + bytes[6] = (versionByte & 0x0f) | 0x80; + bytes[8] = (variantByte & 0x3f) | 0x80; + const hex = bytesToHex(bytes); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const view = + bytes.buffer instanceof ArrayBuffer + ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) + : new Uint8Array(bytes); + return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", view))); +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function fileRecordProjectPrefix(projectId: string): string { + return `${FILE_RECORD_PREFIX}${projectId}:`; +} + +function fileRecordKey(projectId: string, fileId: string): string { + return `${fileRecordProjectPrefix(projectId)}${fileId}`; +} + +function versionRecordProjectPrefix(projectId: string): string { + return `${VERSION_RECORD_PREFIX}${projectId}:`; +} + +function versionRecordKey(projectId: string, fileId: string, versionId: string): string { + return `${versionRecordProjectPrefix(projectId)}${fileId}:${versionId}`; +} diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-provisioning.ts b/apps/agent-worker/src/durable-objects/project-sandbox-provisioning.ts index 0e6065e1..cff2b9d4 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-provisioning.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-provisioning.ts @@ -121,6 +121,44 @@ export class ProjectSandboxProvisioning { ); } + public async restart(client: DaytonaClient, sandboxId: string): Promise { + await client.stopSandbox(sandboxId).catch((error: unknown) => { + throw this.input.toUpstreamError(error, "Daytona sandbox failed to stop for recovery."); + }); + for (let attempt = 0; attempt < ENSURE_STARTED_ATTEMPTS; attempt += 1) { + const current = await client.getSandbox(sandboxId); + if (!current) { + throw new APIError(502, "upstream_sandbox_failed", "Daytona sandbox disappeared", { + retriable: true, + }); + } + if (isStartableState(current.state)) { + if (await this.ensureStarted(client, current)) { + return; + } + break; + } + if (isFailedState(current.state)) { + throw new APIError( + 502, + "upstream_sandbox_failed", + `Daytona sandbox in state ${current.state}`, + { + details: { sandboxId: this.input.sandboxName(), state: current.state }, + retriable: true, + }, + ); + } + await sleep(ENSURE_STARTED_DELAY_MS); + } + throw new APIError( + 504, + "upstream_sandbox_failed", + "Daytona sandbox did not stop for recovery", + { retriable: true }, + ); + } + public async ensureWorkspaceVolume(client: DaytonaClient): Promise { const name = this.input.env.DAYTONA_WORKSPACE_VOLUME; let volume = await client.getVolumeByName(name); diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-runtime-manifest.ts b/apps/agent-worker/src/durable-objects/project-sandbox-runtime-manifest.ts index 88c75a45..7f12e1f7 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-runtime-manifest.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-runtime-manifest.ts @@ -35,7 +35,7 @@ export async function writeSandboxRuntimeManifest( ): Promise { const manifest = buildSandboxRuntimeManifest(records); const temporaryPath = `${SANDBOX_RUNTIME_MANIFEST_PATH}.tmp-${crypto.randomUUID()}`; - await client.createFolder(sandboxId, RUNTIME_DIRECTORY, "0700"); + await client.createFolder(sandboxId, RUNTIME_DIRECTORY, "700"); await client.uploadFile( sandboxId, temporaryPath, diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts b/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts index b11783e4..118ddb2b 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts @@ -1,6 +1,6 @@ import { EnvironmentVariablesSchema } from "@cheatcode/sandbox-contracts"; import { WorkspaceFilePathSchema, WorkspacePathSchema } from "@cheatcode/tools-code"; -import { ProjectId } from "@cheatcode/types"; +import { PROJECT_FILE_MAX_BYTES, ProjectFileRelativePathSchema, ProjectId } from "@cheatcode/types"; import { z } from "zod"; const CommandArgvSchema = z.array(z.string().min(1).max(8_192)).min(1).max(128); @@ -83,6 +83,32 @@ export const ProjectWriteFileInputSchema = z }) .strict(); +export const ProjectUploadFileInputSchema = z + .object({ + bytes: z + .instanceof(Uint8Array) + .refine( + (value) => value.byteLength > 0 && value.byteLength <= PROJECT_FILE_MAX_BYTES, + `Project files must be between 1 byte and ${PROJECT_FILE_MAX_BYTES} bytes.`, + ), + contentType: z.string().trim().min(1).max(200), + name: z.string().trim().min(1).max(200), + path: ProjectFileRelativePathSchema, + projectId: z.string().uuid().toLowerCase().transform(ProjectId), + workspaceSlug: ProjectWorkspaceSlugSchema, + }) + .strict() + .refine( + (input) => input.workspaceSlug.endsWith(`-${input.projectId.toLowerCase()}`), + "Workspace slug does not belong to the requested project.", + ); + +export const ProjectListUploadedFilesInputSchema = z + .object({ + projectId: z.string().uuid().toLowerCase().transform(ProjectId), + }) + .strict(); + export const ProjectListFilesInputSchema = z .object({ path: WorkspacePathSchema, @@ -226,6 +252,8 @@ export type ProjectStartProcessInput = z.input; export type ProjectReadFileInput = z.input; export type ProjectWriteFileInput = z.input; +export type ProjectUploadFileInput = z.input; +export type ProjectListUploadedFilesInput = z.input; export type ProjectListFilesInput = z.input; export type ProjectSearchFilesInput = z.input; export type ProjectDeleteFileInput = z.input; diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-snapshot-upgrade.ts b/apps/agent-worker/src/durable-objects/project-sandbox-snapshot-upgrade.ts index d227a32f..a59ee042 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-snapshot-upgrade.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-snapshot-upgrade.ts @@ -306,7 +306,7 @@ export class ProjectSandboxSnapshotUpgrade { throw error; } const digest = await sha256Hex(bytes); - await this.input.client.createFolder(candidate.id, transferChunksPath(state.upgradeId), "0700"); + await this.input.client.createFolder(candidate.id, transferChunksPath(state.upgradeId), "700"); await this.input.client.uploadFile(candidate.id, sourcePath, bytes); await this.verifyTransferredChunk(candidate.id, sourcePath, bytes.byteLength, digest); const next = SnapshotUpgradeStateSchema.parse({ ...state, nextChunk: state.nextChunk + 1 }); diff --git a/apps/agent-worker/src/durable-objects/project-sandbox.ts b/apps/agent-worker/src/durable-objects/project-sandbox.ts index 64682620..d2a028c6 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox.ts @@ -172,6 +172,20 @@ export class ProjectSandbox extends ProjectSandboxContent { ); } + public override listUploadedFiles( + ...args: Parameters + ): ReturnType { + return this.withActiveSandboxOperation(() => super.listUploadedFiles(...args)); + } + + public override uploadProjectFile( + ...args: Parameters + ): ReturnType { + return this.withActiveProjectWorkspaceOperation(workspaceSlug(args[0].workspaceSlug), () => + super.uploadProjectFile(...args), + ); + } + public override previewFile( ...args: Parameters ): ReturnType { diff --git a/apps/agent-worker/src/project-file-http-routes.ts b/apps/agent-worker/src/project-file-http-routes.ts new file mode 100644 index 00000000..40bc1a99 --- /dev/null +++ b/apps/agent-worker/src/project-file-http-routes.ts @@ -0,0 +1,260 @@ +import { APIError, readBoundedRequestBytes } from "@cheatcode/observability"; +import { + PROJECT_FILE_MAX_BYTES, + ProjectFileListSchema, + ProjectFileUploadResponseSchema, +} from "@cheatcode/types"; +import type { Context, Hono } from "hono"; +import { z } from "zod"; +import type { AgentEnv } from "./agent-env"; +import { requireProjectAccess, sandboxForUser } from "./agent-routing"; +import { readGatewayUserId } from "./tenancy"; + +const ProjectIdParamSchema = z.string().uuid().toLowerCase(); +const FilenameQuerySchema = z.string().trim().min(1).max(255); +const TEXT_EXTENSIONS = new Map([ + [".c", "text/x-c"], + [".cpp", "text/x-c++"], + [".css", "text/css"], + [".csv", "text/csv"], + [".go", "text/x-go"], + [".h", "text/x-c"], + [".html", "text/html"], + [".java", "text/x-java-source"], + [".js", "text/javascript"], + [".json", "application/json"], + [".jsonl", "application/x-ndjson"], + [".jsx", "text/jsx"], + [".log", "text/plain"], + [".md", "text/markdown"], + [".markdown", "text/markdown"], + [".py", "text/x-python"], + [".rb", "text/x-ruby"], + [".rs", "text/x-rust"], + [".sql", "application/sql"], + [".toml", "application/toml"], + [".ts", "text/typescript"], + [".tsv", "text/tab-separated-values"], + [".tsx", "text/tsx"], + [".txt", "text/plain"], + [".xml", "application/xml"], + [".yaml", "application/yaml"], + [".yml", "application/yaml"], +]); +const BINARY_TYPES = new Map([ + [ + ".docx", + { + mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + signature: "zip", + }, + ], + [".gif", { mime: "image/gif", signature: "gif" }], + [".jpeg", { mime: "image/jpeg", signature: "jpeg" }], + [".jpg", { mime: "image/jpeg", signature: "jpeg" }], + [".pdf", { mime: "application/pdf", signature: "pdf" }], + [".png", { mime: "image/png", signature: "png" }], + [ + ".pptx", + { + mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + signature: "zip", + }, + ], + [".webp", { mime: "image/webp", signature: "webp" }], + [ + ".xlsx", + { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", signature: "zip" }, + ], +]); + +type AgentContext = Context<{ Bindings: AgentEnv }>; +type BinarySignature = "gif" | "jpeg" | "pdf" | "png" | "webp" | "zip"; + +export function registerProjectFileHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { + app.get("/v1/projects/:projectId/files", listProjectFiles); + app.post("/v1/projects/:projectId/files", uploadProjectFile); +} + +async function listProjectFiles(c: AgentContext): Promise { + const userId = readGatewayUserId(c.req.raw.headers); + const projectId = parseProjectId(c.req.param("projectId")); + await requireProjectAccess(c.env, userId, projectId, false); + const sandbox = await sandboxForUser(c.env, userId); + return c.json(ProjectFileListSchema.parse(await sandbox.listUploadedFiles({ projectId }))); +} + +async function uploadProjectFile(c: AgentContext): Promise { + const userId = readGatewayUserId(c.req.raw.headers); + const projectId = parseProjectId(c.req.param("projectId")); + const project = await requireProjectAccess(c.env, userId, projectId, true); + const name = sanitizeFilename(FilenameQuerySchema.parse(c.req.query("filename"))); + const bytes = await readBoundedRequestBytes( + c.req.raw, + PROJECT_FILE_MAX_BYTES, + "Project file upload", + ); + const contentType = validateProjectFile(name, bytes); + const sandbox = await sandboxForUser(c.env, userId); + const result = await uploadToProjectWorkspace(sandbox, { + bytes, + contentType, + name, + path: `uploads/${name}`, + projectId, + workspaceSlug: project.workspaceSlug, + }); + return c.json(ProjectFileUploadResponseSchema.parse(result), 201); +} + +async function uploadToProjectWorkspace( + sandbox: Awaited>, + input: Parameters[0], +) { + try { + return await sandbox.uploadProjectFile(input); + } catch (error) { + if (isMaintenanceRpcError(error)) { + throw new APIError( + 503, + "unavailable_maintenance", + "Project files are temporarily unavailable while this computer is being updated.", + { + hint: "Try the upload again after workspace maintenance completes.", + retriable: false, + }, + ); + } + throw error; + } +} + +function isMaintenanceRpcError(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const value = error as Record; + return value["status"] === 503 && value["code"] === "unavailable_maintenance"; +} + +function parseProjectId(value: string | undefined): string { + const parsed = ProjectIdParamSchema.safeParse(value); + if (parsed.success) return parsed.data; + throw new APIError(400, "invalid_path_param", "Invalid project id", { retriable: false }); +} + +function sanitizeFilename(source: string): string { + const normalized = source.normalize("NFC").replaceAll("/", "-").replaceAll("\\", "-").trim(); + const withoutControls = Array.from(normalized, (character) => + isControlCharacter(character) ? "-" : character, + ).join(""); + const collapsed = withoutControls.replace(/\s+/gu, " ").replace(/^\.+/u, "").trim(); + if (!collapsed || collapsed === "." || collapsed === "..") { + throw invalidProjectFile("Choose a file with a valid name."); + } + const extension = extensionOf(collapsed); + if (collapsed.length <= 200) return collapsed; + if (extension.length > 20) return collapsed.slice(0, 200); + const maxBaseLength = Math.max(1, 200 - extension.length); + const base = collapsed.slice(0, collapsed.length - extension.length).slice(0, maxBaseLength); + return `${base}${extension}`.trim(); +} + +function validateProjectFile(name: string, bytes: Uint8Array): string { + if (bytes.byteLength === 0) { + throw invalidProjectFile(`${name} is empty.`); + } + const extension = extensionOf(name); + const textType = TEXT_EXTENSIONS.get(extension); + if (textType) { + assertUtf8Text(name, bytes); + return textType; + } + const binaryType = BINARY_TYPES.get(extension); + if ( + !binaryType || + !matchesSignature(bytes, binaryType.signature) || + !matchesOfficeContainer(extension, bytes) + ) { + throw invalidProjectFile( + binaryType + ? `${name} does not match its file type.` + : `${name} is not a supported document, data, code, or image file.`, + ); + } + return binaryType.mime; +} + +function matchesOfficeContainer(extension: string, bytes: Uint8Array): boolean { + if (extension === ".docx") return containsAscii(bytes, "word/"); + if (extension === ".xlsx") return containsAscii(bytes, "xl/"); + if (extension === ".pptx") return containsAscii(bytes, "ppt/"); + return true; +} + +function assertUtf8Text(name: string, bytes: Uint8Array): void { + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw invalidProjectFile(`${name} must be valid UTF-8 text.`); + } + if (bytes.includes(0)) { + throw invalidProjectFile(`${name} contains binary data that does not match its file type.`); + } +} + +function matchesSignature(bytes: Uint8Array, signature: BinarySignature): boolean { + if (signature === "pdf") return hasAsciiPrefix(bytes, "%PDF-"); + if (signature === "png") return hasBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (signature === "jpeg") return hasBytes(bytes, [0xff, 0xd8, 0xff]); + if (signature === "gif") + return hasAsciiPrefix(bytes, "GIF87a") || hasAsciiPrefix(bytes, "GIF89a"); + if (signature === "webp") { + return hasAsciiPrefix(bytes, "RIFF") && hasAsciiPrefix(bytes.subarray(8), "WEBP"); + } + return ( + hasBytes(bytes, [0x50, 0x4b, 0x03, 0x04]) || + hasBytes(bytes, [0x50, 0x4b, 0x05, 0x06]) || + hasBytes(bytes, [0x50, 0x4b, 0x07, 0x08]) + ); +} + +function hasAsciiPrefix(bytes: Uint8Array, prefix: string): boolean { + return hasBytes(bytes, Array.from(new TextEncoder().encode(prefix))); +} + +function containsAscii(bytes: Uint8Array, value: string): boolean { + const needle = new TextEncoder().encode(value); + const lastStart = bytes.byteLength - needle.byteLength; + for (let start = 0; start <= lastStart; start += 1) { + if (bytes[start] !== needle[0]) continue; + let matches = true; + for (let offset = 1; offset < needle.byteLength; offset += 1) { + if (bytes[start + offset] !== needle[offset]) { + matches = false; + break; + } + } + if (matches) return true; + } + return false; +} + +function hasBytes(bytes: Uint8Array, expected: number[]): boolean { + return expected.every((byte, index) => bytes[index] === byte); +} + +function extensionOf(name: string): string { + const index = name.lastIndexOf("."); + return index <= 0 ? "" : name.slice(index).toLowerCase(); +} + +function isControlCharacter(character: string): boolean { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 32 || codePoint === 127; +} + +function invalidProjectFile(message: string): APIError { + return new APIError(422, "invalid_request_body", message, { + hint: "Upload a supported file up to 20 MB and try again.", + retriable: false, + }); +} diff --git a/apps/agent-worker/src/sandbox-http-routes.ts b/apps/agent-worker/src/sandbox-http-routes.ts index 2c2a48e6..a28b3ce7 100644 --- a/apps/agent-worker/src/sandbox-http-routes.ts +++ b/apps/agent-worker/src/sandbox-http-routes.ts @@ -1,10 +1,12 @@ import type { Hono } from "hono"; import type { AgentEnv } from "./agent-env"; +import { registerProjectFileHttpRoutes } from "./project-file-http-routes"; import { registerSandboxFileHttpRoutes } from "./sandbox-file-http-routes"; import { registerSandboxPreviewHttpRoutes } from "./sandbox-preview-http-routes"; import { registerSandboxTerminalHttpRoutes } from "./sandbox-terminal-http-routes"; export function registerSandboxHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { + registerProjectFileHttpRoutes(app); registerSandboxFileHttpRoutes(app); registerSandboxPreviewHttpRoutes(app); registerSandboxTerminalHttpRoutes(app); diff --git a/apps/gateway-worker/src/authenticate.ts b/apps/gateway-worker/src/authenticate.ts index 2fbf784e..dc3cce68 100644 --- a/apps/gateway-worker/src/authenticate.ts +++ b/apps/gateway-worker/src/authenticate.ts @@ -165,7 +165,7 @@ export function clerkAuthorizedParties( ? configured : env.CHEATCODE_ENVIRONMENT === "production" ? ["https://trycheatcode.com"] - : ["http://localhost:3000", "http://127.0.0.1:3000"]; + : ["http://localhost:3001"]; if (parties.length > 16 || parties.some((value) => !isExactHttpOrigin(value))) { throw new APIError(503, "unavailable_maintenance", "Clerk authorized parties are invalid", { hint: "Configure CLERK_AUTHORIZED_PARTIES as comma-separated exact HTTP(S) origins.", diff --git a/apps/gateway-worker/src/billing-routes.ts b/apps/gateway-worker/src/billing-routes.ts index 51f3ac90..f615eafc 100644 --- a/apps/gateway-worker/src/billing-routes.ts +++ b/apps/gateway-worker/src/billing-routes.ts @@ -50,7 +50,7 @@ const POLAR_PRODUCT_ID_ENV = { const BILLING_REQUEST_MAX_BYTES = 8 * 1024; const PRODUCTION_WEB_ORIGIN = "https://trycheatcode.com"; -const LOCAL_WEB_ORIGIN = "http://localhost:3000"; +const LOCAL_WEB_ORIGIN = "http://localhost:3001"; type BillingContext = Context<{ Bindings: GatewayEnv }>; diff --git a/apps/gateway-worker/src/index.ts b/apps/gateway-worker/src/index.ts index a56390fa..4b7555b0 100644 --- a/apps/gateway-worker/src/index.ts +++ b/apps/gateway-worker/src/index.ts @@ -57,7 +57,7 @@ const GATEWAY_SECURITY_HEADERS = { "'self'", "https://gateway.trycheatcode.com", "https://trycheatcode.com", - "http://localhost:3000", + "http://localhost:3001", "http://localhost:8787", "ws://localhost:8787", "wss://*.trycheatcode.com", diff --git a/apps/gateway-worker/src/openapi-project-routes.ts b/apps/gateway-worker/src/openapi-project-routes.ts index 324d06a5..d02272e5 100644 --- a/apps/gateway-worker/src/openapi-project-routes.ts +++ b/apps/gateway-worker/src/openapi-project-routes.ts @@ -1,6 +1,8 @@ import { CreateProjectSchema, CreateThreadSchema, + ProjectFileListSchema, + ProjectFileUploadResponseSchema, ProjectSummarySchema, ThreadSchema, UIMessageRecordSchema, @@ -23,6 +25,8 @@ export const projectSchemas: Record = { CreateProject: zodJsonSchema(CreateProjectSchema, "input"), CreateThread: zodJsonSchema(CreateThreadSchema, "input"), Project: zodJsonSchema(ProjectSummarySchema), + ProjectFileList: zodJsonSchema(ProjectFileListSchema), + ProjectFileUploadResponse: zodJsonSchema(ProjectFileUploadResponseSchema), Thread: zodJsonSchema(ThreadSchema), UIMessage: zodJsonSchema(UIMessageRecordSchema), UpdateProject: withJsonSchemaConstraints(zodJsonSchema(UpdateProjectSchema, "input"), { @@ -54,6 +58,49 @@ export const projectRoutes: OpenApiRoute[] = [ summary: "Create a project", tags: ["projects"], }, + { + method: "get", + operationId: "listProjectFiles", + path: "/v1/projects/{projectId}/files", + responses: { + "200": jsonResponse("Project files", schemaRef("ProjectFileList")), + "404": jsonResponse("Not found", schemaRef("Error")), + }, + security: [{ bearerAuth: [] }], + summary: "List persistent uploaded project files", + tags: ["projects"], + }, + { + method: "post", + operationId: "uploadProjectFile", + parameters: [ + { + in: "query", + name: "filename", + required: true, + schema: { maxLength: 255, minLength: 1, type: "string" }, + }, + ], + path: "/v1/projects/{projectId}/files", + requestBody: { + content: { + "application/octet-stream": { + schema: { format: "binary", type: "string" }, + }, + }, + required: true, + }, + responses: { + "201": jsonResponse("Stored project file", schemaRef("ProjectFileUploadResponse")), + "403": jsonResponse("Project is read-only", schemaRef("Error")), + "404": jsonResponse("Not found", schemaRef("Error")), + "413": jsonResponse("File is too large", schemaRef("Error")), + "422": jsonResponse("Unsupported or invalid file", schemaRef("Error")), + }, + security: [{ bearerAuth: [] }], + summary: "Upload and version a persistent project file", + tags: ["projects"], + }, { method: "get", operationId: "getProject", diff --git a/apps/gateway-worker/src/project-http-routes.ts b/apps/gateway-worker/src/project-http-routes.ts index 25b72453..5276e856 100644 --- a/apps/gateway-worker/src/project-http-routes.ts +++ b/apps/gateway-worker/src/project-http-routes.ts @@ -73,6 +73,12 @@ function registerProjectItemRoutes(app: GatewayApp): void { } function registerProjectRelatedRoutes(app: GatewayApp): void { + app.get("/v1/projects/:projectId/files", (c) => + forwardAgentRequest(c, "GET /v1/projects/:projectId/files"), + ); + app.post("/v1/projects/:projectId/files", (c) => + forwardAgentRequest(c, "POST /v1/projects/:projectId/files"), + ); app.post("/v1/projects/:projectId/download", (c) => forwardAgentRequest(c, "POST /v1/projects/:projectId/download"), ); diff --git a/apps/web/README.md b/apps/web/README.md index d6cdedb8..f86b4888 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -13,6 +13,15 @@ Deliverable parts contain durable output identity and presentation metadata, nev URL. A download click calls the authenticated gateway mint endpoint, validates its bounded response, and follows the resulting short-lived capability directly to the streaming response. +Composer uploads always land in a writable project. If none is selected, choosing the first +valid file creates and selects a general project named from that file. Files upload sequentially +as raw bounded requests, show per-batch progress and actionable failures, and become durable +`uploads/` files in that project instead of being pasted into message text. The composer inserts +a compact `/uploads/...` reference after each successful save. `/` is exclusively the +persistent project-file browser; +`@` is exclusively the user-skill picker. The file browser reads durable project-file metadata and +does not create or wake Daytona merely because the user opens it. + ## Public exports Framework app only. diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico index f6aa03d1..e1f3cc09 100644 Binary files a/apps/web/src/app/favicon.ico and b/apps/web/src/app/favicon.ico differ diff --git a/apps/web/src/components/chat/prompt-composer-controller.ts b/apps/web/src/components/chat/prompt-composer-controller.ts index c72b7a1b..a737e054 100644 --- a/apps/web/src/components/chat/prompt-composer-controller.ts +++ b/apps/web/src/components/chat/prompt-composer-controller.ts @@ -9,26 +9,23 @@ import { type RefObject, useCallback, useLayoutEffect, - useMemo, useRef, useState, } from "react"; import type { RunStatus } from "@/components/chat/status-pill"; import { - type ComposerStatusTone, type PromptAttachments, usePromptAttachments, } from "@/components/chat/use-prompt-attachments"; import { composePromptWithComposerContext } from "@/components/composer/composer-context-chips"; import type { ComposerMenuItem } from "@/components/composer/composer-popover"; -import { useMentionFileItems } from "@/components/composer/mention-file-source"; +import { useProjectFileItems } from "@/components/composer/project-file-source"; import { slashSkillItems } from "@/components/composer/slash-skill-source"; import { type ComposerTriggers, type TriggerDetector, useComposerTriggers, } from "@/components/composer/use-composer-triggers"; -import { fetchIntegrationCatalog, INTEGRATION_CATALOG_QUERY } from "@/lib/api/integrations"; import { listUserSkills, USER_SKILLS_QUERY } from "@/lib/api/skills"; import { detectMentionToken, detectSlashToken } from "@/lib/input/caret-tokens"; import { useAppStore } from "@/lib/store/app-store"; @@ -51,8 +48,6 @@ export interface PromptComposerProps { interface PromptComposerState { canSubmit: boolean; - composerStatus: string | null; - composerStatusTone: ComposerStatusTone; computerOpen: boolean; isMenuOpen: boolean; isRunning: boolean; @@ -84,22 +79,26 @@ export interface PromptComposerController { } export function usePromptComposerController(props: PromptComposerProps): PromptComposerController { + const { getToken } = useAuth(); const textareaRef = useRef(null); const publisher = usePublishedValue(props.value, props.onChange); const isRunning = props.status === "streaming" || props.status === "submitted"; - const sandboxReady = useAppStore((state) => state.sandboxStatus === "ready"); const computerOpen = useAppStore((state) => state.previewPanelOpen); + const projectSelection = useProjectSelection(props.project); const menu = usePromptComposerMenu({ + getToken, onChange: publisher.publishValue, - sandboxReady, + projectId: projectSelection.selectedProject?.id ?? null, textareaRef, - threadId: props.threadId, value: props.value, }); - const projectSelection = useProjectSelection(props.project); const attachments = usePromptAttachments({ + getToken, latestValueRef: publisher.latestValueRef, onChange: publisher.publishValue, + onProjectCreated: projectSelection.selectProject, + project: projectSelection.selectedProject, + value: props.value, }); return usePromptComposerAssembly({ attachments, @@ -128,60 +127,49 @@ function usePublishedValue(value: string, onChange: (value: string) => void) { } function usePromptComposerMenu({ + getToken, onChange, - sandboxReady, + projectId, textareaRef, - threadId, value, }: { + getToken: () => Promise; onChange: (value: string) => void; - sandboxReady: boolean; + projectId: string | null; textareaRef: RefObject; - threadId: string; value: string; }) { - const { getToken } = useAuth(); const { data: userSkills } = useQuery({ queryFn: ({ signal }) => listUserSkills(getToken, signal), queryKey: USER_SKILLS_QUERY, staleTime: 60_000, }); - const integrationQuery = useQuery({ - queryFn: ({ signal }) => fetchIntegrationCatalog(getToken, signal), - queryKey: INTEGRATION_CATALOG_QUERY, - staleTime: 60_000, - }); const [selectedSkill, setSelectedSkill] = useState(null); const [selectedTool, setSelectedTool] = useState(null); - const sources = useMemo( - () => (sandboxReady ? [SLASH_DETECTOR, MENTION_DETECTOR] : [SLASH_DETECTOR]), - [sandboxReady], - ); const triggers = useComposerTriggers({ onChange, onInsert: (kind, item) => { - if (kind === "slash") selectSlashItem(item, setSelectedSkill, setSelectedTool); + if (kind === "mention") selectSkillItem(item, setSelectedSkill, setSelectedTool); emitComposerEvent( getToken, kind === "mention" ? "composer_mention_inserted" : "composer_slash_inserted", ); }, - sources, + sources: [SLASH_DETECTOR, MENTION_DETECTOR], textareaRef, value, }); - const mentionItems = useMentionFileItems({ - enabled: sandboxReady && triggers.kind === "mention", + const fileItems = useProjectFileItems({ + enabled: triggers.kind === "slash", + projectId, query: triggers.query, - threadId, }); const menuItems = - triggers.kind === "mention" - ? mentionItems - : slashMenuItems({ - isPending: integrationQuery.isPending, + triggers.kind === "slash" + ? fileItems + : skillMenuItems({ + isPending: userSkills === undefined, query: triggers.query, - toolkits: integrationQuery.data?.toolkits ?? [], userSkills: userSkills ?? [], }); return { @@ -194,7 +182,7 @@ function usePromptComposerMenu({ }; } -function selectSlashItem( +function selectSkillItem( item: ComposerMenuItem, setSelectedSkill: (skill: string | null) => void, setSelectedTool: (tool: IntegrationName | null) => void, @@ -208,18 +196,16 @@ function selectSlashItem( } } -function slashMenuItems({ +function skillMenuItems({ isPending, query, - toolkits, userSkills, }: { isPending: boolean; query: string; - toolkits: Parameters[2]; userSkills: Parameters[1]; }): ComposerMenuItem[] { - const items = slashSkillItems(query, userSkills, toolkits); + const items = slashSkillItems(query, userSkills); if (items.length > 0) return items; const label = isPending ? "Loading skills…" : "No matching skills"; return [{ disabled: true, id: `status:${label}`, insert: "", label, visual: "status" }]; @@ -250,7 +236,8 @@ type PromptComposerAssemblyOptions = { function usePromptComposerAssembly(options: PromptComposerAssemblyOptions) { const [openControlMenu, setOpenControlMenu] = useState(null); - const canSubmit = options.props.value.trim().length > 0 && !options.isRunning; + const canSubmit = + options.props.value.trim().length > 0 && !options.isRunning && !options.attachments.isUploading; const submission = createComposerSubmission({ canSubmit, isRunning: options.isRunning, @@ -283,12 +270,10 @@ function createPromptComposerState( ): PromptComposerState { return { canSubmit, - composerStatus: options.attachments.status?.text ?? null, - composerStatusTone: options.attachments.status?.tone ?? "ok", computerOpen: options.computerOpen, isMenuOpen: options.menu.triggers.isActive && options.menu.menuItems.length > 0, isRunning: options.isRunning, - menuAriaLabel: options.menu.triggers.kind === "mention" ? "File mentions" : "Skills", + menuAriaLabel: options.menu.triggers.kind === "slash" ? "Project files" : "Skills", menuItems: options.menu.menuItems, openControlMenu, resolvedModelId: options.props.resolvedModelId, diff --git a/apps/web/src/components/chat/prompt-composer-view.tsx b/apps/web/src/components/chat/prompt-composer-view.tsx index 7454dfb9..86775717 100644 --- a/apps/web/src/components/chat/prompt-composer-view.tsx +++ b/apps/web/src/components/chat/prompt-composer-view.tsx @@ -3,6 +3,7 @@ import { USER_MESSAGE_MAX_CHARACTERS } from "@cheatcode/types"; import { ArrowUp, Paperclip, Square } from "@cheatcode/ui"; import type { PromptComposerController } from "@/components/chat/prompt-composer-controller"; +import { ComposerAttachmentStatus } from "@/components/composer/composer-attachment-status"; import { ComposerContextChips } from "@/components/composer/composer-context-chips"; import { COMPOSER_TEXTAREA_CLASS, ComposerFrame } from "@/components/composer/composer-frame"; import { ComposerPopover } from "@/components/composer/composer-popover"; @@ -26,10 +27,6 @@ export function PromptComposerView({ controller }: { controller: PromptComposerC - @@ -62,6 +59,7 @@ function ComposerInput({ controller }: { controller: PromptComposerController }) skill={controller.state.selectedSkill} tool={controller.state.selectedTool} /> +