Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/agent-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ small current/version namespace records and mirrors the current version to
An exact replay is idempotent; uploading new bytes at the same path creates a retained version and
updates the working copy. First-run app scaffolding preserves the `uploads/` directory, and restored
template projects reuse a complete persistent dependency installation or repair an interrupted one
instead of rebuilding the workspace. Project deletion removes the namespace during fenced workspace
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
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.

Expand Down
44 changes: 31 additions & 13 deletions apps/agent-worker/src/durable-objects/agent-run-app-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,10 @@ export async function restartMobilePreview(
});
}

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

async function promoteImportedRepo(
sandbox: ProjectSandboxStub,
cloneDir: string,
workspaceDir: string,
): Promise<void> {
await executeShellExec(
{ command: ["rm", "-rf", `${cloneDir}/uploads`], cwd: "/workspace", timeoutMs: 120_000 },
{ sandbox },
);
await executeShellExec(
{
command: ["cp", "-a", `${cloneDir}/.`, `${workspaceDir}/`],
cwd: "/workspace",
timeoutMs: 120_000,
},
{ sandbox },
);
await executeShellExec(
{ command: ["rm", "-rf", cloneDir], cwd: "/workspace", timeoutMs: 120_000 },
{ sandbox },
);
}

// Every follow-up run of an imported project: re-install best-effort, but NEVER
// reset, re-clone, or auto-start the template dev server (prior agent
// edits must survive).
Expand Down Expand Up @@ -620,13 +645,6 @@ async function hasInstalledAppBuilderDependencies(
return result.success;
}

async function resetAppBuilderDirectory(sandbox: ProjectSandboxStub, dir: string): Promise<void> {
await executeShellExec(
{ command: ["rm", "-rf", dir], cwd: "/workspace", timeoutMs: 120_000 },
{ sandbox },
);
}

async function resetTemplateAppBuilderDirectory(
sandbox: ProjectSandboxStub,
dir: string,
Expand Down
21 changes: 21 additions & 0 deletions apps/agent-worker/src/durable-objects/agent-run-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ async function executeActiveRun(execution: RunExecution): Promise<void> {
if (deps.isCanceled()) {
return;
}
await restoreRunProjectFiles(execution);
await deps.append(runTaskStatusChunk("prepare-sandbox", "completed"));
await deps.append(runTaskStatusChunk("run-agent", "running"));
const path = await deps.executeRunPath(
Expand Down Expand Up @@ -198,11 +199,31 @@ async function cleanupRun(execution: RunExecution): Promise<void> {
});
});
}
await restoreRunProjectFiles(execution).catch((error: unknown) => {
execution.logger.warn("project_upload_restore_after_run_failed", {
error,
projectId: execution.input.projectId,
});
});
if (execution.runLeaseOpened) {
await execution.sandbox.endRun(execution.input.runId).catch(() => undefined);
}
}

async function restoreRunProjectFiles(execution: RunExecution): Promise<void> {
const { projectId, workspaceSlug } = execution.input;
if (!projectId || !workspaceSlug) {
return;
}
const result = await execution.sandbox.restoreUploadedFiles({ projectId, workspaceSlug });
if (result.restoredFileCount > 0) {
execution.logger.info("project_uploads_restored", {
projectId,
restoredFileCount: result.restoredFileCount,
});
}
}

function logRunStarted(execution: RunExecution): void {
execution.logger.info("agent_run_started", {
mastra_agent_ready: Boolean(mastra.getAgent("general")),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { APIError } from "@cheatcode/observability";
import { PROJECT_ARCHIVE_MAX_OUTPUT_BYTES, type SandboxFilePreview } from "@cheatcode/types";
import { shellQuote } from "./project-sandbox-process-support";
import {
Expand All @@ -12,6 +13,8 @@ export const PREVIEW_DIR = "/workspace/.cheatcode-previews";
export const PROJECT_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024;
export const PROJECT_ARCHIVE_MAX_FILES = 25_000;
export const WORKSPACE_DIR = "/workspace";
const MANAGED_PROJECT_UPLOAD_PATH = /^\/workspace\/[^/]+\/uploads(?:\/|$)/u;
const PROJECT_WORKSPACE_ROOT_PATH = /^\/workspace\/[^/]+\/?$/u;

export const PROJECT_ARCHIVE_SCRIPT = `
import os
Expand Down Expand Up @@ -106,6 +109,31 @@ if archive_size > max_output_bytes:

export { PROJECT_ARCHIVE_MAX_OUTPUT_BYTES };

export function assertMutableWorkspacePath(path: string): void {
if (!MANAGED_PROJECT_UPLOAD_PATH.test(path)) {
return;
}
throw new APIError(403, "permission_denied", "Uploaded project files are read-only", {
hint: "Read the uploaded file or copy it to another project path before editing it.",
retriable: false,
});
}

export function assertDeletableWorkspacePath(path: string): void {
if (PROJECT_WORKSPACE_ROOT_PATH.test(path)) {
throw new APIError(
403,
"permission_denied",
"Project roots cannot be deleted with file tools",
{
hint: "Delete individual generated files or use the project deletion action.",
retriable: false,
},
);
}
assertMutableWorkspacePath(path);
}

export function lowercaseExtension(path: string): string {
const filename = basename(path).toLowerCase();
const dot = filename.lastIndexOf(".");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
codeServerTrustedOrigins,
} from "./project-sandbox-code-server";
import {
assertDeletableWorkspacePath,
assertMutableWorkspacePath,
basename,
buildGrepCommand,
conversionErrorMessage,
Expand Down Expand Up @@ -193,6 +195,7 @@ export abstract class ProjectSandboxContent extends ProjectSandboxProjectFiles {

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

public async deleteFile(input: ProjectDeleteFileInput): Promise<SandboxDeleteFileResult> {
const parsed = ProjectDeleteFileInputSchema.parse(input);
assertDeletableWorkspacePath(parsed.path);
const id = await this.ensureSandbox();
await this.client().deleteFilePath(id, parsed.path, parsed.recursive);
return { path: parsed.path, success: true };
Expand Down
Loading