Skip to content

Commit 87cb237

Browse files
authored
fix(agent): preserve uploaded project files
1 parent d504245 commit 87cb237

11 files changed

Lines changed: 385 additions & 36 deletions

apps/agent-worker/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ small current/version namespace records and mirrors the current version to
4242
An exact replay is idempotent; uploading new bytes at the same path creates a retained version and
4343
updates the working copy. First-run app scaffolding preserves the `uploads/` directory, and restored
4444
template projects reuse a complete persistent dependency installation or repair an interrupted one
45-
instead of rebuilding the workspace. Project deletion removes the namespace during fenced workspace
45+
instead of rebuilding the workspace. The working copy is a read-only cache: every project-bound
46+
run verifies its current file set before model access, restores missing, replaced, or modified files
47+
from the checksum-verified R2 version, and repeats that repair when the run exits. File write/delete
48+
tools reject the reserved directory, while the system contract requires shell work to copy an upload
49+
elsewhere before transforming it. Template scaffolding and repository imports both retain the
50+
reserved directory. Project deletion removes the namespace during fenced workspace
4651
cleanup and the existing resource-deletion prefix sweep removes every immutable object. Account
4752
deletion clears both through the existing account state and R2 lifecycle phases.
4853

apps/agent-worker/src/durable-objects/agent-run-app-builder.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,10 @@ export async function restartMobilePreview(
256256
});
257257
}
258258

259-
// First import run only: clone the public GitHub repo over the empty workspace,
260-
// drop the one-shot marker, best-effort install, and hand control to the agent
261-
// without auto-starting a dev server (framework/port are unknowable). Failure
262-
// throws repo_import_failed, which rides the existing run() failure path.
259+
// First import run only: retain uploads, clone the public GitHub repo through a
260+
// private staging directory, drop the one-shot marker, best-effort install, and
261+
// hand control to the agent without auto-starting a dev server (framework/port
262+
// are unknowable). Failure throws repo_import_failed through the run failure path.
263263
async function importRepoWorkspace(
264264
options: WorkspaceOptions & { repoUrl: string },
265265
): Promise<{ agentContextNote: string }> {
@@ -273,9 +273,11 @@ async function importRepoWorkspace(
273273
}
274274
logger.info("repo_import_started", { repoHost: repoRef.host, repoPath: repoRef.path });
275275
setRunStage(`Cloning ${repoRef.path}.`);
276-
await resetAppBuilderDirectory(sandbox, workspace.dir);
276+
await resetTemplateAppBuilderDirectory(sandbox, workspace.dir);
277277
throwIfRunCanceled(options.abortSignal);
278-
await cloneRepoOrThrow({ dir: workspace.dir, env, input, logger, repoRef, repoUrl, sandbox });
278+
const cloneDir = `${workspace.dir}/.cheatcode-import-${input.runId ?? crypto.randomUUID()}`;
279+
await cloneRepoOrThrow({ dir: cloneDir, env, input, logger, repoRef, repoUrl, sandbox });
280+
await promoteImportedRepo(sandbox, cloneDir, workspace.dir);
279281
throwIfRunCanceled(options.abortSignal);
280282
await markImportedWorkspace(sandbox, workspace.dir);
281283
const installRan = await installImportedDependencies(sandbox, logger, workspace.dir);
@@ -291,6 +293,29 @@ async function importRepoWorkspace(
291293
return { agentContextNote: importedContextNote(workspace, repoUrl) };
292294
}
293295

296+
async function promoteImportedRepo(
297+
sandbox: ProjectSandboxStub,
298+
cloneDir: string,
299+
workspaceDir: string,
300+
): Promise<void> {
301+
await executeShellExec(
302+
{ command: ["rm", "-rf", `${cloneDir}/uploads`], cwd: "/workspace", timeoutMs: 120_000 },
303+
{ sandbox },
304+
);
305+
await executeShellExec(
306+
{
307+
command: ["cp", "-a", `${cloneDir}/.`, `${workspaceDir}/`],
308+
cwd: "/workspace",
309+
timeoutMs: 120_000,
310+
},
311+
{ sandbox },
312+
);
313+
await executeShellExec(
314+
{ command: ["rm", "-rf", cloneDir], cwd: "/workspace", timeoutMs: 120_000 },
315+
{ sandbox },
316+
);
317+
}
318+
294319
// Every follow-up run of an imported project: re-install best-effort, but NEVER
295320
// reset, re-clone, or auto-start the template dev server (prior agent
296321
// edits must survive).
@@ -620,13 +645,6 @@ async function hasInstalledAppBuilderDependencies(
620645
return result.success;
621646
}
622647

623-
async function resetAppBuilderDirectory(sandbox: ProjectSandboxStub, dir: string): Promise<void> {
624-
await executeShellExec(
625-
{ command: ["rm", "-rf", dir], cwd: "/workspace", timeoutMs: 120_000 },
626-
{ sandbox },
627-
);
628-
}
629-
630648
async function resetTemplateAppBuilderDirectory(
631649
sandbox: ProjectSandboxStub,
632650
dir: string,

apps/agent-worker/src/durable-objects/agent-run-lifecycle.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ async function executeActiveRun(execution: RunExecution): Promise<void> {
9393
if (deps.isCanceled()) {
9494
return;
9595
}
96+
await restoreRunProjectFiles(execution);
9697
await deps.append(runTaskStatusChunk("prepare-sandbox", "completed"));
9798
await deps.append(runTaskStatusChunk("run-agent", "running"));
9899
const path = await deps.executeRunPath(
@@ -198,11 +199,31 @@ async function cleanupRun(execution: RunExecution): Promise<void> {
198199
});
199200
});
200201
}
202+
await restoreRunProjectFiles(execution).catch((error: unknown) => {
203+
execution.logger.warn("project_upload_restore_after_run_failed", {
204+
error,
205+
projectId: execution.input.projectId,
206+
});
207+
});
201208
if (execution.runLeaseOpened) {
202209
await execution.sandbox.endRun(execution.input.runId).catch(() => undefined);
203210
}
204211
}
205212

213+
async function restoreRunProjectFiles(execution: RunExecution): Promise<void> {
214+
const { projectId, workspaceSlug } = execution.input;
215+
if (!projectId || !workspaceSlug) {
216+
return;
217+
}
218+
const result = await execution.sandbox.restoreUploadedFiles({ projectId, workspaceSlug });
219+
if (result.restoredFileCount > 0) {
220+
execution.logger.info("project_uploads_restored", {
221+
projectId,
222+
restoredFileCount: result.restoredFileCount,
223+
});
224+
}
225+
}
226+
206227
function logRunStarted(execution: RunExecution): void {
207228
execution.logger.info("agent_run_started", {
208229
mastra_agent_ready: Boolean(mastra.getAgent("general")),

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { APIError } from "@cheatcode/observability";
12
import { PROJECT_ARCHIVE_MAX_OUTPUT_BYTES, type SandboxFilePreview } from "@cheatcode/types";
23
import { shellQuote } from "./project-sandbox-process-support";
34
import {
@@ -12,6 +13,8 @@ export const PREVIEW_DIR = "/workspace/.cheatcode-previews";
1213
export const PROJECT_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024;
1314
export const PROJECT_ARCHIVE_MAX_FILES = 25_000;
1415
export const WORKSPACE_DIR = "/workspace";
16+
const MANAGED_PROJECT_UPLOAD_PATH = /^\/workspace\/[^/]+\/uploads(?:\/|$)/u;
17+
const PROJECT_WORKSPACE_ROOT_PATH = /^\/workspace\/[^/]+\/?$/u;
1518

1619
export const PROJECT_ARCHIVE_SCRIPT = `
1720
import os
@@ -106,6 +109,31 @@ if archive_size > max_output_bytes:
106109

107110
export { PROJECT_ARCHIVE_MAX_OUTPUT_BYTES };
108111

112+
export function assertMutableWorkspacePath(path: string): void {
113+
if (!MANAGED_PROJECT_UPLOAD_PATH.test(path)) {
114+
return;
115+
}
116+
throw new APIError(403, "permission_denied", "Uploaded project files are read-only", {
117+
hint: "Read the uploaded file or copy it to another project path before editing it.",
118+
retriable: false,
119+
});
120+
}
121+
122+
export function assertDeletableWorkspacePath(path: string): void {
123+
if (PROJECT_WORKSPACE_ROOT_PATH.test(path)) {
124+
throw new APIError(
125+
403,
126+
"permission_denied",
127+
"Project roots cannot be deleted with file tools",
128+
{
129+
hint: "Delete individual generated files or use the project deletion action.",
130+
retriable: false,
131+
},
132+
);
133+
}
134+
assertMutableWorkspacePath(path);
135+
}
136+
109137
export function lowercaseExtension(path: string): string {
110138
const filename = basename(path).toLowerCase();
111139
const dot = filename.lastIndexOf(".");

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
codeServerTrustedOrigins,
2121
} from "./project-sandbox-code-server";
2222
import {
23+
assertDeletableWorkspacePath,
24+
assertMutableWorkspacePath,
2325
basename,
2426
buildGrepCommand,
2527
conversionErrorMessage,
@@ -193,6 +195,7 @@ export abstract class ProjectSandboxContent extends ProjectSandboxProjectFiles {
193195

194196
public async writeFile(input: ProjectWriteFileInput): Promise<SandboxWriteFileResult> {
195197
const parsed = ProjectWriteFileInputSchema.parse(input);
198+
assertMutableWorkspacePath(parsed.path);
196199
const id = await this.ensureSandbox();
197200
await this.client().createFolder(id, dirname(parsed.path));
198201
const bytes =
@@ -235,6 +238,7 @@ export abstract class ProjectSandboxContent extends ProjectSandboxProjectFiles {
235238

236239
public async deleteFile(input: ProjectDeleteFileInput): Promise<SandboxDeleteFileResult> {
237240
const parsed = ProjectDeleteFileInputSchema.parse(input);
241+
assertDeletableWorkspacePath(parsed.path);
238242
const id = await this.ensureSandbox();
239243
await this.client().deleteFilePath(id, parsed.path, parsed.recursive);
240244
return { path: parsed.path, success: true };

0 commit comments

Comments
 (0)