diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 9228cdd6..c6e86baa 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -44,10 +44,12 @@ updates the working copy. First-run app scaffolding preserves the `uploads/` dir template projects reuse a complete persistent dependency installation or repair an interrupted one instead of rebuilding the workspace. The working copy is a read-only cache: every project-bound run verifies its current file set before model access, restores missing, replaced, or modified files -from the checksum-verified R2 version, and repeats that repair when the run exits. File write/delete -tools reject the reserved directory, while the system contract requires shell work to copy an upload -elsewhere before transforming it. Template scaffolding and repository imports both retain the -reserved directory. Project deletion removes the namespace during fenced workspace +from the checksum-verified R2 version, records the exact workspace materialization separately from +the immutable user-facing file metadata, and repeats that repair when the run exits. Daytona's +filesystem API assigns the sandbox runtime user and read-only modes after each toolbox upload. File +write/delete tools reject the reserved directory, while the system contract requires shell work to +copy an upload elsewhere before transforming it. Template scaffolding and repository imports both +retain the reserved directory. 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. @@ -130,9 +132,10 @@ manifest avoids rewriting unchanged packages and limits cleanup to files previou managed by that package, preserving local dependencies and generated output. Curated default skills are immutable snapshot files under `/home/node/.cheatcode/default-skills/`. -ProjectSandbox also writes `/workspace/.cheatcode/runtime.json` as an atomic, -generated projection of managed app-preview processes. The Durable Object process -records remain authoritative; the file is only an inspectable runtime manifest. +ProjectSandbox also writes `/workspace/.cheatcode/runtime.json` as a generated, +mode-restricted projection of managed app-preview processes through Daytona's filesystem API. +The Durable Object process records remain authoritative; the file is only an inspectable runtime +manifest. Managed processes use required stable IDs and a maximum of 32 live metadata slots per user sandbox. Reusing an ID atomically replaces that slot. At capacity, ProjectSandbox reconciles the 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 index c3142f38..42571519 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-project-files.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-project-files.ts @@ -1,5 +1,5 @@ import { workspacePathForSlug } from "@cheatcode/db"; -import { APIError, createLogger } from "@cheatcode/observability"; +import { APIError } from "@cheatcode/observability"; import { DaytonaApiError } from "@cheatcode/tools-code"; import { PROJECT_FILE_MAX_CURRENT_FILES, @@ -25,9 +25,9 @@ import { const FILE_DIGEST_DOMAIN = "cheatcode:project-file:v2"; const VERSION_DIGEST_DOMAIN = "cheatcode:project-file-version:v2"; const FILE_RECORD_PREFIX = "project-file:"; +const MATERIALIZATION_RECORD_PREFIX = "project-file-materialization:"; const VERSION_RECORD_PREFIX = "project-file-version:"; const DELETE_BATCH_SIZE = 128; -const WORKSPACE_TIMESTAMP_TOLERANCE_MS = 2_000; const WORKSPACE_FILE_VISIBILITY_ATTEMPTS = 20; const WORKSPACE_FILE_VISIBILITY_DELAY_MS = 250; @@ -48,6 +48,18 @@ const ProjectFileVersionSchema = z type ProjectFileVersion = z.infer; +const ProjectFileMaterializationSchema = z + .object({ + fileId: z.string().uuid(), + modifiedAt: z.string().datetime({ offset: true }), + projectId: z.string().uuid(), + sizeBytes: z.number().int().positive(), + versionId: z.string().uuid(), + }) + .strict(); + +type ProjectFileMaterialization = z.infer; + interface PreparedProjectFile { contentSha256: string; fileId: string; @@ -57,6 +69,7 @@ interface PreparedProjectFile { interface CurrentProjectFile { file: ProjectFile; + materialization: ProjectFileMaterialization | null; version: ProjectFileVersion; } @@ -85,6 +98,7 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses protected deleteUploadedFileMetadata(projectId: string): Promise { return Promise.all([ this.deleteStoragePrefix(fileRecordProjectPrefix(projectId)), + this.deleteStoragePrefix(materializationRecordProjectPrefix(projectId)), this.deleteStoragePrefix(versionRecordProjectPrefix(projectId)), ]).then(() => undefined); } @@ -96,8 +110,6 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses await this.enforceFileCount(input.projectId, existing !== null); const prepared = await prepareProjectFile(input, this.ownerUserId()); const persistedAt = new Date().toISOString(); - const workspaceTimestamp = - existing?.versionId === prepared.versionId ? existing.updatedAt : persistedAt; const version = projectFileVersion(input, prepared, persistedAt); const previousVersion = await this.storedVersion(version); const status = existing @@ -107,7 +119,7 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses : "created"; await this.writeAndVerifyObject(input, version); try { - await this.materializeProjectFile(input, prepared.contentSha256, workspaceTimestamp); + const materialization = await this.materializeProjectFile(input, prepared); const file = await this.commitProjectFile( input, prepared, @@ -115,6 +127,7 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses previousVersion, version, persistedAt, + materialization, ); return ProjectFileUploadResponseSchema.parse({ file, status }); } catch (error) { @@ -132,6 +145,7 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses previousVersion: ProjectFileVersion | null, version: ProjectFileVersion, persistedAt: string, + materialization: ProjectFileMaterialization, ): Promise { const file = ProjectFileSchema.parse({ contentType: input.contentType, @@ -154,28 +168,31 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses ); } await transaction.put(fileRecordKey(input.projectId, prepared.fileId), file); + await transaction.put( + materializationRecordKey(input.projectId, prepared.fileId), + materialization, + ); }); return file; } private async materializeProjectFile( input: z.output, - contentSha256: string, - workspaceTimestamp: string, - ): Promise { + prepared: PreparedProjectFile, + ): Promise { const sandboxId = await this.ensureSandbox(); const projectRoot = workspacePathForSlug(input.workspaceSlug); + const uploadsPath = `${projectRoot}/uploads`; const workspacePath = `${projectRoot}/${input.path}`; const written = await this.writeProjectFileWithRecovery( sandboxId, projectRoot, workspacePath, input.bytes, - workspaceTimestamp, ); if ( written.byteLength !== input.bytes.byteLength || - (await sha256Hex(written)) !== contentSha256 + (await sha256Hex(written)) !== prepared.contentSha256 ) { throw new APIError( 502, @@ -184,6 +201,7 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses { retriable: true }, ); } + return this.readProjectFileMaterialization(sandboxId, uploadsPath, input, prepared); } private async writeProjectFileWithRecovery( @@ -191,28 +209,15 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses projectRoot: string, workspacePath: string, bytes: Uint8Array, - workspaceTimestamp: string, ): Promise { try { - return await this.writeProjectFileToWorkspace( - sandboxId, - projectRoot, - workspacePath, - bytes, - workspaceTimestamp, - ); + return await this.writeProjectFileToWorkspace(sandboxId, projectRoot, workspacePath, bytes); } catch (error) { if (!isRecoverableWorkspaceMountError(error)) { throw error; } await this.restartSandboxForWorkspaceRecovery(sandboxId); - return this.writeProjectFileToWorkspace( - sandboxId, - projectRoot, - workspacePath, - bytes, - workspaceTimestamp, - ); + return this.writeProjectFileToWorkspace(sandboxId, projectRoot, workspacePath, bytes); } } @@ -221,17 +226,12 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses projectRoot: string, workspacePath: string, bytes: Uint8Array, - workspaceTimestamp: string, ): Promise { const uploadsPath = `${projectRoot}/uploads`; - await this.prepareUploadedFileWrite(sandboxId, uploadsPath, workspacePath); + await this.prepareUploadedFileWrite(sandboxId, uploadsPath); await this.client().uploadFile(sandboxId, workspacePath, bytes); const written = await this.downloadUploadedFile(sandboxId, workspacePath, bytes.byteLength); - await this.protectUploadedFile(sandboxId, uploadsPath, workspacePath, workspaceTimestamp).catch( - (error: unknown) => { - logWorkspaceProtectionFailure(error, "file"); - }, - ); + await this.protectUploadedFile(sandboxId, uploadsPath, workspacePath); return written; } @@ -330,10 +330,11 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses const uploadsPath = `${projectRoot}/uploads`; const workspaceFiles = await this.workspaceUploadedFiles(sandboxId, uploadsPath); const workspaceFilesByName = new Map(workspaceFiles.map((file) => [file.name, file])); + const restoredFiles: CurrentProjectFile[] = []; let restoredFileCount = 0; for (const current of currentFiles) { const workspaceFile = workspaceFilesByName.get(current.file.name); - if (!workspaceProjectFileNeedsRestore(workspaceFile, current.file)) { + if (!workspaceProjectFileNeedsRestore(workspaceFile, current.file, current.materialization)) { continue; } const bytes = await this.readStoredProjectFile(current.version); @@ -342,17 +343,47 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses projectRoot, `${projectRoot}/${current.file.path}`, bytes, - current.file.updatedAt, ); await assertWorkspaceProjectFile(written, current.file); + restoredFiles.push(current); restoredFileCount += 1; } - await this.protectUploadedDirectory(sandboxId, uploadsPath).catch((error: unknown) => { - logWorkspaceProtectionFailure(error, "directory"); - }); + await this.recordRestoredProjectFiles(sandboxId, uploadsPath, restoredFiles); return { restoredFileCount }; } + private async recordRestoredProjectFiles( + sandboxId: string, + uploadsPath: string, + restoredFiles: CurrentProjectFile[], + ): Promise { + if (restoredFiles.length === 0) return; + const workspaceFiles = await this.workspaceUploadedFiles(sandboxId, uploadsPath); + const workspaceFilesByName = new Map(workspaceFiles.map((file) => [file.name, file])); + for (const current of restoredFiles) { + const workspaceFile = workspaceFilesByName.get(current.file.name); + if (!workspaceFile || workspaceFile.isDir || workspaceFile.size !== current.file.sizeBytes) { + throw new APIError( + 502, + "upstream_sandbox_failed", + "Restored project file metadata is invalid", + { retriable: true }, + ); + } + const materialization = ProjectFileMaterializationSchema.parse({ + fileId: current.file.fileId, + modifiedAt: workspaceFile.modifiedAt, + projectId: current.file.projectId, + sizeBytes: current.file.sizeBytes, + versionId: current.file.versionId, + }); + await this.ctx.storage.put( + materializationRecordKey(materialization.projectId, materialization.fileId), + materialization, + ); + } + } + private async currentProjectFiles(projectId: string): Promise { const records = await this.ctx.storage.list({ prefix: fileRecordProjectPrefix(projectId), @@ -365,7 +396,15 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses ); const version = ProjectFileVersionSchema.parse(versionValue); assertCurrentProjectFile(file, version); - currentFiles.push({ file, version }); + const materializationValue = await this.ctx.storage.get( + materializationRecordKey(file.projectId, file.fileId), + ); + const materialization = ProjectFileMaterializationSchema.safeParse(materializationValue); + currentFiles.push({ + file, + materialization: materialization.success ? materialization.data : null, + version, + }); } currentFiles.sort((left, right) => left.file.path.localeCompare(right.file.path)); return currentFiles; @@ -413,75 +452,55 @@ export abstract class ProjectSandboxProjectFiles extends ProjectSandboxProcesses return bytes; } - private async prepareUploadedFileWrite( + private async readProjectFileMaterialization( sandboxId: string, uploadsPath: string, - workspacePath: string, - ): Promise { - await this.client().createFolder(sandboxId, uploadsPath); - const prepared = await this.client().execute(sandboxId, { - command: [ - `chmod 0777 -- ${shellQuote(uploadsPath)}`, - `if test -e ${shellQuote(workspacePath)}; then chmod 0666 -- ${shellQuote(workspacePath)}; fi`, - ].join(" && "), - timeout: 10, - }); - if (prepared.exitCode !== 0) { - logWorkspaceProtectionFailure( - new Error("Workspace cache permissions could not be opened"), - "prepare", - prepared.exitCode, + input: z.output, + prepared: PreparedProjectFile, + ): Promise { + const workspaceFiles = await this.workspaceUploadedFiles(sandboxId, uploadsPath); + const workspaceFile = workspaceFiles.find((file) => file.name === input.name); + if (!workspaceFile || workspaceFile.isDir || workspaceFile.size !== input.bytes.byteLength) { + throw new APIError( + 502, + "upstream_sandbox_failed", + "Project file workspace metadata is invalid", + { retriable: true }, ); } + return ProjectFileMaterializationSchema.parse({ + fileId: prepared.fileId, + modifiedAt: workspaceFile.modifiedAt, + projectId: input.projectId, + sizeBytes: input.bytes.byteLength, + versionId: prepared.versionId, + }); + } + + private async prepareUploadedFileWrite(sandboxId: string, uploadsPath: string): Promise { + await this.client().createFolder(sandboxId, uploadsPath, "0755"); + await this.client().setFilePermissions(sandboxId, uploadsPath, { + group: "node", + mode: "0755", + owner: "node", + }); } private async protectUploadedFile( sandboxId: string, uploadsPath: string, workspacePath: string, - workspaceTimestamp: string, ): Promise { - const protectedFile = await this.client().execute(sandboxId, { - command: [ - "attempt=0", - `while test "$attempt" -lt ${WORKSPACE_FILE_VISIBILITY_ATTEMPTS}; do`, - `if test -f ${shellQuote(workspacePath)}; then`, - `touch -d ${shellQuote(workspaceTimestamp)} -- ${shellQuote(workspacePath)} && chmod 0444 -- ${shellQuote(workspacePath)} && chmod 0555 -- ${shellQuote(uploadsPath)}`, - "exit $?", - "fi", - "attempt=$((attempt + 1))", - `sleep ${WORKSPACE_FILE_VISIBILITY_DELAY_MS / 1_000}`, - "done", - "exit 1", - ].join("\n"), - timeout: 10, + await this.client().setFilePermissions(sandboxId, workspacePath, { + group: "node", + mode: "0444", + owner: "node", }); - if (protectedFile.exitCode !== 0) { - throw new APIError( - 502, - "upstream_sandbox_failed", - "Project file workspace could not be protected", - { retriable: true }, - ); - } - } - - private async protectUploadedDirectory(sandboxId: string, uploadsPath: string): Promise { - const protectedDirectory = await this.client().execute(sandboxId, { - command: [ - `find ${shellQuote(uploadsPath)} -mindepth 1 -maxdepth 1 -type f -exec chmod 0444 -- {} +`, - `chmod 0555 -- ${shellQuote(uploadsPath)}`, - ].join(" && "), - timeout: 10, + await this.client().setFilePermissions(sandboxId, uploadsPath, { + group: "node", + mode: "0555", + owner: "node", }); - if (protectedDirectory.exitCode !== 0) { - throw new APIError( - 502, - "upstream_sandbox_failed", - "Project file workspace could not be protected", - { retriable: true }, - ); - } } private async listProjectFileRecords(projectId: string): Promise<{ files: ProjectFile[] }> { @@ -519,14 +538,21 @@ function workspaceProjectFileNeedsRestore( } | undefined, file: ProjectFile, + materialization: ProjectFileMaterialization | null, ): boolean { if (!workspaceFile || workspaceFile.isDir || workspaceFile.size !== file.sizeBytes) { return true; } - return ( - Date.parse(workspaceFile.modifiedAt) > - Date.parse(file.updatedAt) + WORKSPACE_TIMESTAMP_TOLERANCE_MS - ); + if ( + !materialization || + materialization.fileId !== file.fileId || + materialization.projectId !== file.projectId || + materialization.sizeBytes !== file.sizeBytes || + materialization.versionId !== file.versionId + ) { + return true; + } + return Date.parse(workspaceFile.modifiedAt) !== Date.parse(materialization.modifiedAt); } function assertCurrentProjectFile(file: ProjectFile, version: ProjectFileVersion): void { @@ -560,14 +586,6 @@ async function assertWorkspaceProjectFile(bytes: Uint8Array, file: ProjectFile): ); } -function logWorkspaceProtectionFailure( - error: unknown, - stage: "directory" | "file" | "prepare", - exitCode?: number, -): void { - createLogger().warn("project_upload_workspace_protection_failed", { error, exitCode, stage }); -} - async function prepareProjectFile( input: z.output, userId: string, @@ -655,10 +673,6 @@ function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - function fileRecordProjectPrefix(projectId: string): string { return `${FILE_RECORD_PREFIX}${projectId}:`; } @@ -667,6 +681,14 @@ function fileRecordKey(projectId: string, fileId: string): string { return `${fileRecordProjectPrefix(projectId)}${fileId}`; } +function materializationRecordProjectPrefix(projectId: string): string { + return `${MATERIALIZATION_RECORD_PREFIX}${projectId}:`; +} + +function materializationRecordKey(projectId: string, fileId: string): string { + return `${materializationRecordProjectPrefix(projectId)}${fileId}`; +} + function versionRecordProjectPrefix(projectId: string): string { return `${VERSION_RECORD_PREFIX}${projectId}:`; } 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 250c75b3..e664e308 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 @@ -34,32 +34,22 @@ export async function writeSandboxRuntimeManifest( records: Map, ): Promise { const manifest = buildSandboxRuntimeManifest(records); - const temporaryPath = `/workspace/.cheatcode-runtime-${crypto.randomUUID()}.tmp`; + await client.createFolder(sandboxId, RUNTIME_DIRECTORY, "0700"); + await client.setFilePermissions(sandboxId, RUNTIME_DIRECTORY, { + group: "node", + mode: "0700", + owner: "node", + }); await client.uploadFile( sandboxId, - temporaryPath, + SANDBOX_RUNTIME_MANIFEST_PATH, new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`), ); - const moved = await client.execute(sandboxId, { - command: [ - `install -d -m 0700 ${shellQuote(RUNTIME_DIRECTORY)} || exit 1`, - "attempt=0", - 'while test "$attempt" -lt 20; do', - `if test -f ${shellQuote(temporaryPath)}; then`, - `mv -f ${shellQuote(temporaryPath)} ${shellQuote(SANDBOX_RUNTIME_MANIFEST_PATH)} && chmod 0700 ${shellQuote(RUNTIME_DIRECTORY)} && chmod 0600 ${shellQuote(SANDBOX_RUNTIME_MANIFEST_PATH)}`, - "exit $?", - "fi", - "attempt=$((attempt + 1))", - "sleep 0.25", - "done", - "exit 1", - ].join("\n"), - timeout: 10, + await client.setFilePermissions(sandboxId, SANDBOX_RUNTIME_MANIFEST_PATH, { + group: "node", + mode: "0600", + owner: "node", }); - if (moved.exitCode !== 0) { - await client.deleteFilePath(sandboxId, temporaryPath, false).catch(() => undefined); - throw new Error("Could not publish the sandbox runtime projection."); - } } function buildSandboxRuntimeManifest( @@ -92,7 +82,3 @@ function buildSandboxRuntimeManifest( version: 1, }); } - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} diff --git a/packages/tools-code/README.md b/packages/tools-code/README.md index e33e9b7e..b0c48af0 100644 --- a/packages/tools-code/README.md +++ b/packages/tools-code/README.md @@ -17,8 +17,10 @@ the first page, and file listings require the canonical RFC 3339 `modifiedAt` fi accepting the deprecated `modTime` representation or fabricating timestamps. The same REST-only client resolves/creates shared Daytona volumes, validates their current provider shape and readiness, supplies `volumeId`/`mountPath`/`subpath` only at sandbox creation, -and replaces complete sandbox label sets during release-scoped promotion. Snapshot replacement -does not use the Daytona SDK or assume an in-place snapshot mutation API. +replaces complete sandbox label sets during release-scoped promotion, and uses Daytona's +filesystem-permissions endpoint when a toolbox upload must be reassigned to the sandbox runtime +user with an explicit mode. Snapshot replacement does not use the Daytona SDK or assume an +in-place snapshot mutation API. When a run supplies `/workspace/`, every file, shell, code-execution, Git, and dev-server path is confined lexically to that project root; a bare `/workspace` is remapped to it and sibling-project paths are rejected. diff --git a/packages/tools-code/src/daytona-client.ts b/packages/tools-code/src/daytona-client.ts index 72b3235f..8f590379 100644 --- a/packages/tools-code/src/daytona-client.ts +++ b/packages/tools-code/src/daytona-client.ts @@ -201,6 +201,12 @@ interface ExecuteParams { timeout?: number; } +interface DaytonaFilePermissions { + group?: string; + mode?: string; + owner?: string; +} + // --------------------------------------------------------------------------- // Client // --------------------------------------------------------------------------- @@ -483,6 +489,21 @@ export class DaytonaClient { ); } + async setFilePermissions( + id: string, + path: string, + permissions: DaytonaFilePermissions, + ): Promise { + const query = new URLSearchParams({ path }); + if (permissions.owner !== undefined) query.set("owner", permissions.owner); + if (permissions.group !== undefined) query.set("group", permissions.group); + if (permissions.mode !== undefined) query.set("mode", permissions.mode); + if (query.size === 1) { + throw new TypeError("At least one file permission must be provided"); + } + await this.toolbox("POST", id, `/files/permissions?${query.toString()}`); + } + async deleteFilePath(id: string, path: string, recursive: boolean): Promise { await this.toolbox( "DELETE", diff --git a/packages/tools-code/src/files.ts b/packages/tools-code/src/files.ts index 097e49e8..9ca092ef 100644 --- a/packages/tools-code/src/files.ts +++ b/packages/tools-code/src/files.ts @@ -124,8 +124,8 @@ export const DeleteFileOutputSchema = z type ReadFileInput = z.input; type ReadFileOutput = z.infer; -export type WriteFileInput = z.input; -export type WriteFileOutput = z.infer; +type WriteFileInput = z.input; +type WriteFileOutput = z.infer; type ListFilesInput = z.input; type ListFilesOutput = z.infer; type SearchFilesInput = z.input;