diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 6b8c286f..32ac710f 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -42,8 +42,9 @@ 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. 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. The working copy is a reserved cache: every project-bound run +template projects reuse immutable snapshot runtimes or restore project-specific dependencies to the +sandbox's local disk instead of copying generated package trees to persistent object-store FUSE. +The working copy is a reserved 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, records the exact workspace materialization separately from the immutable user-facing file metadata, and repeats that repair when the run exits. File write/delete @@ -263,6 +264,8 @@ match. A stale snapshot or target is replaced automatically only when canonical ownership and the persistent mount are unambiguous and no other operation or run lease is active. All other contract mismatches fail closed. New and replacement sandboxes mount the user's isolated volume subpath directly at `/workspace`. +Generated dependency and compiler-cache state lives under the matching project-scoped +`/home/node/.cheatcode/projects//` directory and is deleted with the project. Account deletion clears that subpath before deleting all exactly owned sandboxes, so persistent volume data does not outlive the account. diff --git a/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts b/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts index 184a1c7f..5682039f 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts @@ -9,14 +9,23 @@ import { appBuilderGlobalStylesSource, appBuilderLayoutSource, appBuilderPageSource, + appBuilderTypeScriptConfigSource, + expoTypeScriptConfigSource, } from "./app-builder-template"; import { metroForwardedHostFixScript } from "./expo-metro-forwarded-host"; +import { + EXPO_RUNTIME_BIN, + EXPO_TEMPLATE_DIR, + NEXT_RUNTIME_BIN, + NEXT_TEMPLATE_DIR, +} from "./project-sandbox-package-runtime"; type ProjectSandboxStub = CodeRuntimeContext["sandbox"]; type AgentRunLogger = ReturnType; interface AppBuilderSeedInput { messageText: string; + workspaceSlug: string; } export function writeAppBuilderFiles( @@ -46,9 +55,30 @@ export function writeAppBuilderFiles( }, { sandbox }, ), + executeWriteFile( + { + path: `${dir}/tsconfig.json`, + content: appBuilderTypeScriptConfigSource(input.workspaceSlug), + }, + { sandbox }, + ), ]).then(() => undefined); } +export function writeExpoRuntimeFiles( + sandbox: ProjectSandboxStub, + dir: string, + workspaceSlug: string, +): Promise { + return executeWriteFile( + { + path: `${dir}/tsconfig.json`, + content: expoTypeScriptConfigSource(workspaceSlug), + }, + { sandbox }, + ).then(() => undefined); +} + export async function scaffoldExpoApp( sandbox: ProjectSandboxStub, logger: AgentRunLogger, @@ -59,7 +89,7 @@ export async function scaffoldExpoApp( // workspace dir can win the race), silently yielding a project with no package.json at its root. // `test -f` verifies the baked, lockfile-backed layout. A missing template means the immutable // snapshot is corrupt and must fail explicitly instead of fetching a mutable generator output. - if (await copyTemplateContents(sandbox, "/home/node/cheatcode-expo-template", dir)) { + if (await copyTemplateContents(sandbox, EXPO_TEMPLATE_DIR, dir)) { logger.info("sandbox_expo_template_copied", { targetDir: dir }); return; } @@ -74,7 +104,7 @@ export async function scaffoldAppBuilder( ): Promise { // Copy template CONTENTS into the project dir (see scaffoldExpoApp): `cp -a src dst` nests as // `dst/cheatcode-next-template/` when `dst` already exists, leaving no package.json at the root. - if (await copyTemplateContents(sandbox, "/home/node/cheatcode-next-template", dir)) { + if (await copyTemplateContents(sandbox, NEXT_TEMPLATE_DIR, dir)) { logger.info("sandbox_next_template_copied", { targetDir: dir }); return; } @@ -121,6 +151,23 @@ export async function installAppBuilderDependencies( ); } +export async function ensureAppBuilderRuntime( + sandbox: ProjectSandboxStub, + mobile: boolean, +): Promise { + const runtimeBin = mobile ? EXPO_RUNTIME_BIN : NEXT_RUNTIME_BIN; + const checked = await executeShellTerminal( + { + command: `test -x ${runtimeBin}`, + cwd: "/workspace", + timeoutMs: 10_000, + }, + { sandbox }, + ); + if (checked.success) return; + throw missingBakedRuntimeError(mobile ? "Expo" : "Next.js"); +} + // Expo web (react-native-web) is what makes `expo start --web` render a real page in // the Computer panel iframe. The default template ships react-dom + react-native-web // but NOT @expo/metro-runtime, and the Metro web bundler must be selected — so ensure @@ -130,25 +177,22 @@ export async function ensureExpoWebSupport( sandbox: ProjectSandboxStub, dir: string, ): Promise { - const alreadyInstalled = await executeShellTerminal( - { - command: - "test -d node_modules/react-native-web && test -d node_modules/react-dom && test -d node_modules/@expo/metro-runtime", - cwd: dir, - timeoutMs: 10_000, - }, - { sandbox }, - ); - if (!alreadyInstalled.success) { - throw new APIError( - 503, - "service_maintenance_unavailable", - "Expo web dependencies are unavailable", + try { + await executeShellExec( { - hint: "Rebuild the pinned Daytona snapshot and its offline Expo package store.", - retriable: false, + command: [ + "node", + "-e", + 'for(const name of ["react-native-web","react-dom","@expo/metro-runtime"])require.resolve(name)', + ], + cwd: dir, + env: { CHEATCODE_APP_RUNTIME: "expo" }, + timeoutMs: 10_000, }, + { sandbox }, ); + } catch { + throw missingBakedRuntimeError("Expo"); } // Force the Metro web bundler + single-page output for Expo Router web. `output:"single"` // serves a client-rendered SPA (one index.html) instead of per-request server rendering, @@ -184,7 +228,7 @@ async function copyTemplateContents( { command: `mkdir -p ${dir} && cp -a ${templateDir}/. ${dir}/ && test -f ${dir}/package.json`, cwd: "/workspace", - timeoutMs: 120_000, + timeoutMs: 30_000, }, { sandbox }, ); @@ -223,3 +267,15 @@ function missingBakedTemplateError(template: "Expo" | "Next.js"): APIError { }, ); } + +function missingBakedRuntimeError(runtime: "Expo" | "Next.js"): APIError { + return new APIError( + 503, + "service_maintenance_unavailable", + `${runtime} sandbox runtime is unavailable`, + { + hint: "Rebuild and publish the pinned Daytona snapshot before accepting app-builder runs.", + retriable: false, + }, + ); +} diff --git a/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts b/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts index 13e6052d..2cec63bb 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts @@ -13,12 +13,23 @@ import type { CodeRuntimeContext } from "@cheatcode/sandbox-contracts"; import type { ProjectMode } from "@cheatcode/types/api"; import type { UIMessageChunk } from "ai"; import { + ensureAppBuilderRuntime, ensureExpoWebSupport, installAppBuilderDependencies, scaffoldAppBuilder, scaffoldExpoApp, writeAppBuilderFiles, + writeExpoRuntimeFiles, } from "./agent-run-app-builder-scaffold"; +import { + EXPO_RUNTIME_BIN, + EXPO_TEMPLATE_DIR, + NEXT_RUNTIME_BIN, + NEXT_TEMPLATE_DIR, + projectLocalCacheDir, + projectLocalModulesDir, + projectLocalRuntimeDir, +} from "./project-sandbox-package-runtime"; export type ProjectSandboxStub = CodeRuntimeContext["sandbox"]; export type AgentRunLogger = ReturnType; @@ -182,7 +193,7 @@ function templateContextNote(workspace: AppBuilderWorkspace): string { const mobile = workspace.mobile ? " For this mobile build, that internal address renders the react-native-web preview." : ""; - return `[context] A ${stack} workspace is scaffolded in ${workspace.dir}, and its managed dev server is running internally at http://localhost:${workspace.port}. Build the user's app by editing files under ${workspace.dir}; it hot-reloads on save. Verify it with the sandbox's headed browser at that internal localhost address.${mobile} Never request or paste an external preview or Expo URL.`; + return `[context] A ${stack} workspace is scaffolded in ${workspace.dir}, and its managed dev server is running internally at http://localhost:${workspace.port}. Build the user's app by editing files under ${workspace.dir}; it hot-reloads on save. Use pnpm for dependency changes. Verify it with the sandbox's headed browser at that internal localhost address.${mobile} Never request or paste an external preview or Expo URL.`; } async function prepareTemplateWorkspace( @@ -191,9 +202,10 @@ async function prepareTemplateWorkspace( const { input, logger, sandbox, setRunStage, shouldBootstrap, workspace } = options; const mobile = workspace.mobile; setRunStage(mobile ? "Preparing the Expo workspace." : "Preparing the Next.js workspace."); + await ensureAppBuilderRuntime(sandbox, mobile); if (!shouldBootstrap) { setRunStage("Restoring the app workspace."); - if (!(await hasInstalledAppBuilderDependencies(sandbox, workspace.dir, mobile))) { + if (!(await hasInstalledAppBuilderDependencies(sandbox, workspace))) { await installAppBuilderDependencies(sandbox, logger, workspace.dir, mobile); } if (mobile) { @@ -205,10 +217,10 @@ async function prepareTemplateWorkspace( throwIfRunCanceled(options.abortSignal); if (mobile) { await scaffoldExpoApp(sandbox, logger, workspace.dir); + await writeExpoRuntimeFiles(sandbox, workspace.dir, workspace.slug); } else { await scaffoldAppBuilder(sandbox, logger, workspace.dir); } - await installAppBuilderDependencies(sandbox, logger, workspace.dir, mobile); throwIfRunCanceled(options.abortSignal); if (mobile) { await ensureExpoWebSupport(sandbox, workspace.dir); @@ -403,10 +415,7 @@ async function installImportedDependencies( if (!(await pathExists(sandbox, `${dir}/package.json`))) { return false; } - const usesNpm = await pathExists(sandbox, `${dir}/package-lock.json`); - const command = usesNpm - ? ["npm", "install", "--no-audit", "--no-fund"] - : ["pnpm", "install", "--prefer-offline", "--network-concurrency", "4"]; + const command = ["pnpm", "install", "--prefer-offline", "--network-concurrency", "4"]; try { await executeShellExec({ command, cwd: dir, timeoutMs: 300_000 }, { sandbox }); return true; @@ -473,7 +482,7 @@ function parseGitHubRepo(url: string): GitHubRepoRef | null { function importedContextNote(workspace: AppBuilderWorkspace, repoUrl?: string): string { const origin = repoUrl ? ` from ${repoUrl}` : ""; const mobilePort = DEFAULT_MOBILE_PORT; - return `[context] This project was imported${origin} into ${workspace.dir}. Inspect it, complete any setup, and start the dev server on port ${workspace.port} with code_start_dev_server (Expo on ${mobilePort} for mobile).`; + return `[context] This project was imported${origin} into ${workspace.dir}. Inspect it, use pnpm for dependency changes, complete any setup, and start the dev server on port ${workspace.port} with code_start_dev_server (Expo on ${mobilePort} for mobile).`; } function repoImportError(message: string): APIError { @@ -517,9 +526,7 @@ async function startExpoDevServer( // independent among the other flags): harmless on the initial boot, and what // makes the post-edit restart (restartMobilePreview) re-crawl from a clean slate. command: [ - "pnpm", - "exec", - "expo", + EXPO_RUNTIME_BIN, "start", "-c", "--web", @@ -530,6 +537,7 @@ async function startExpoDevServer( ], cwd: workspace.dir, env: { + CHEATCODE_APP_RUNTIME: "expo", CI: "1", EXPO_NO_TELEMETRY: "1", ...(signedUrl ? { EXPO_PACKAGER_PROXY_URL: signedUrl } : {}), @@ -572,9 +580,7 @@ async function startAppBuilderDevServer( await executeStartDevServer( { command: [ - "pnpm", - "exec", - "next", + NEXT_RUNTIME_BIN, "dev", "--webpack", "--hostname", @@ -584,6 +590,7 @@ async function startAppBuilderDevServer( ], cwd: workspace.dir, env: { + CHEATCODE_APP_RUNTIME: "next", CHOKIDAR_USEPOLLING: "true", WATCHPACK_POLLING: "1000", }, @@ -616,20 +623,29 @@ async function hasExistingAppBuilderWorkspace( async function hasInstalledAppBuilderDependencies( sandbox: ProjectSandboxStub, - dir: string, - mobile: boolean, + workspace: AppBuilderWorkspace, ): Promise { - const requiredPaths = mobile + const templateDir = workspace.mobile ? EXPO_TEMPLATE_DIR : NEXT_TEMPLATE_DIR; + const modulesDir = projectLocalModulesDir(workspace.slug); + const requiredPaths = workspace.mobile ? [ - "node_modules/.pnpm", - "node_modules/@expo/metro-runtime", - "node_modules/react-dom", - "node_modules/react-native-web", + `${modulesDir}/.pnpm`, + `${modulesDir}/@expo/metro-runtime`, + `${modulesDir}/react-dom`, + `${modulesDir}/react-native-web`, ] - : ["node_modules/.pnpm", "node_modules/next", "node_modules/react", "node_modules/react-dom"]; + : [ + `${modulesDir}/.pnpm`, + `${modulesDir}/next`, + `${modulesDir}/react`, + `${modulesDir}/react-dom`, + ]; const result = await executeShellTerminal( { - command: requiredPaths.map((path) => `test -d ${dir}/${path}`).join(" && "), + command: + `(cmp -s ${workspace.dir}/package.json ${templateDir}/package.json && ` + + `cmp -s ${workspace.dir}/pnpm-lock.yaml ${templateDir}/pnpm-lock.yaml) || ` + + `(${requiredPaths.map((path) => `test -d ${path}`).join(" && ")})`, cwd: "/workspace", timeoutMs: 10_000, }, @@ -642,6 +658,15 @@ async function resetTemplateAppBuilderDirectory( sandbox: ProjectSandboxStub, dir: string, ): Promise { + const workspaceSlug = dir.slice(dir.lastIndexOf("/") + 1); + await executeShellExec( + { + command: ["rm", "-rf", projectLocalRuntimeDir(workspaceSlug)], + cwd: "/workspace", + timeoutMs: 30_000, + }, + { sandbox }, + ); await executeShellExec( { command: [ @@ -673,10 +698,11 @@ async function clearBuildCache( dir: string, mobile: boolean, ): Promise { - const cacheDir = mobile ? ".expo" : ".next"; + const workspaceSlug = dir.slice(dir.lastIndexOf("/") + 1); + const cacheDir = mobile ? `${dir}/.expo` : `${projectLocalCacheDir(workspaceSlug)}/next`; await executeShellExec( { - command: ["rm", "-rf", `${dir}/${cacheDir}`], + command: ["rm", "-rf", cacheDir], cwd: "/workspace", timeoutMs: 120_000, }, diff --git a/apps/agent-worker/src/durable-objects/app-builder-template.ts b/apps/agent-worker/src/durable-objects/app-builder-template.ts index 41408940..cae8d96e 100644 --- a/apps/agent-worker/src/durable-objects/app-builder-template.ts +++ b/apps/agent-worker/src/durable-objects/app-builder-template.ts @@ -76,6 +76,59 @@ export default function Home() { `; } +export function appBuilderTypeScriptConfigSource(workspaceSlug: string): string { + const localModules = `/home/node/.cheatcode/projects/${workspaceSlug}/node_modules`; + const runtimeModules = "/home/node/.cheatcode/app-runtimes/next/node_modules"; + return `${JSON.stringify( + { + compilerOptions: { + allowJs: true, + esModuleInterop: true, + incremental: true, + isolatedModules: true, + jsx: "react-jsx", + lib: ["dom", "dom.iterable", "esnext"], + module: "esnext", + moduleResolution: "bundler", + noEmit: true, + paths: { + "@/*": ["./src/*"], + "*": [`${localModules}/*`, `${runtimeModules}/*`], + }, + plugins: [{ name: "next" }], + resolveJsonModule: true, + skipLibCheck: true, + strict: true, + target: "ES2017", + }, + exclude: ["node_modules"], + include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "**/*.mts"], + }, + null, + 2, + )}\n`; +} + +export function expoTypeScriptConfigSource(workspaceSlug: string): string { + const localModules = `/home/node/.cheatcode/projects/${workspaceSlug}/node_modules`; + const runtimeModules = "/home/node/.cheatcode/app-runtimes/expo/node_modules"; + return `${JSON.stringify( + { + extends: `${runtimeModules}/expo/tsconfig.base`, + compilerOptions: { + paths: { + "@/*": ["./*"], + "*": [`${localModules}/*`, `${runtimeModules}/*`], + }, + strict: true, + }, + include: ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], + }, + null, + 2, + )}\n`; +} + function escapeForTsxText(value: string): string { return value.replace(/[<>{}]/g, ""); } 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 a7fc844d..d92217e6 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-content.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-content.ts @@ -31,6 +31,7 @@ import { WORKSPACE_DIR, } from "./project-sandbox-content-support"; import { listSandboxFiles } from "./project-sandbox-files"; +import { projectLocalRuntimeDir } from "./project-sandbox-package-runtime"; import { buildPreviewUrl, signedUrlToExpo } from "./project-sandbox-preview"; import { APP_PREVIEW_SLOT_PREFIX, @@ -542,6 +543,13 @@ async function performProjectWorkspaceCleanup( if (id) { await context.dependencies.process.terminateUntrackedSandboxProcesses(id); await removeWorkspaceFolder(context.runtime, id, workspaceSlug); + await context.runtime + .client() + .deleteFilePath(id, projectLocalRuntimeDir(workspaceSlug), true) + .catch((error: unknown) => { + if (error instanceof DaytonaApiError && error.status === 404) return; + throw context.runtime.toUpstreamError(error, "Project local runtime removal failed."); + }); } await context.dependencies.process.freeProjectPort(workspaceSlug); await context.dependencies.deleteUploadedFileMetadata(projectId); @@ -561,6 +569,7 @@ async function mobileExpoProxy( return { expoUrl: signedUrlToExpo(signed.url), restartEnv: { + CHEATCODE_APP_RUNTIME: "expo", CI: "1", EXPO_NO_TELEMETRY: "1", EXPO_PACKAGER_PROXY_URL: signed.url, diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-package-runtime.ts b/apps/agent-worker/src/durable-objects/project-sandbox-package-runtime.ts new file mode 100644 index 00000000..67223702 --- /dev/null +++ b/apps/agent-worker/src/durable-objects/project-sandbox-package-runtime.ts @@ -0,0 +1,51 @@ +const APP_RUNTIME_ROOT = "/home/node/.cheatcode/app-runtimes"; +const PROJECT_LOCAL_ROOT = "/home/node/.cheatcode/projects"; +const BASE_NODE_PATH = [ + "/opt/cheatcode-doc-runtime/node_modules", + "/opt/cheatcode-skill-runtime/node_modules", +]; +const WORKSPACE_PROJECT_PATH = /^\/workspace\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\/|$)/u; + +export const NEXT_RUNTIME_BIN = `${APP_RUNTIME_ROOT}/next/node_modules/.bin/next`; +export const EXPO_RUNTIME_BIN = `${APP_RUNTIME_ROOT}/expo/node_modules/.bin/expo`; +export const NEXT_TEMPLATE_DIR = "/home/node/cheatcode-next-template"; +export const EXPO_TEMPLATE_DIR = "/home/node/cheatcode-expo-template"; + +export function projectLocalModulesDir(workspaceSlug: string): string { + return `${projectLocalRuntimeDir(workspaceSlug)}/node_modules`; +} + +export function projectLocalCacheDir(workspaceSlug: string): string { + return `${projectLocalRuntimeDir(workspaceSlug)}/cache`; +} + +export function projectLocalRuntimeDir(workspaceSlug: string): string { + return `${PROJECT_LOCAL_ROOT}/${workspaceSlug}`; +} + +/** Keeps generated dependencies and caches off persistent object-store FUSE. */ +export function projectPackageEnvironment( + cwd: string, + requested: Record | undefined, +): Record | undefined { + const workspaceSlug = WORKSPACE_PROJECT_PATH.exec(cwd)?.[1]; + if (!workspaceSlug) return requested; + const modulesDir = projectLocalModulesDir(workspaceSlug); + const requestedNodePath = requested?.["NODE_PATH"]; + const preferredRuntime = requested?.["CHEATCODE_APP_RUNTIME"] === "expo" ? "expo" : "next"; + const fallbackRuntime = preferredRuntime === "expo" ? "next" : "expo"; + return { + ...requested, + CHEATCODE_NEXT_DIST_DIR: `../../home/node/.cheatcode/projects/${workspaceSlug}/cache/next`, + NODE_PATH: [ + modulesDir, + `${APP_RUNTIME_ROOT}/${preferredRuntime}/node_modules`, + `${APP_RUNTIME_ROOT}/${fallbackRuntime}/node_modules`, + ...BASE_NODE_PATH, + requestedNodePath, + ] + .filter(Boolean) + .join(":"), + npm_config_modules_dir: modulesDir, + }; +} diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-process-control.ts b/apps/agent-worker/src/durable-objects/project-sandbox-process-control.ts index a2f098af..6d903c0e 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-process-control.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-process-control.ts @@ -2,6 +2,7 @@ import type { DaytonaSessionExecResponse } from "@cheatcode/agent-core/tools/cod import { APIError } from "@cheatcode/observability"; import { shellQuote, sleep } from "../sandbox-support"; import { WORKSPACE_DIR } from "./project-sandbox-content-support"; +import { projectPackageEnvironment } from "./project-sandbox-package-runtime"; import { SANDBOX_PROCESS_TERMINATION_SCRIPT } from "./project-sandbox-process-cleanup"; import { ENV_FILE_DIR, @@ -125,7 +126,7 @@ async function relaunchDevServer( name, record.cwd, supervisedProcessCommand(record.command, record), - restartEnv ?? restartEnvironment(name, record), + projectPackageEnvironment(record.cwd, restartEnv ?? restartEnvironment(name, record)), ); const relaunched = { ...record, diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-process-support.ts b/apps/agent-worker/src/durable-objects/project-sandbox-process-support.ts index ec7c5f99..342a13ec 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-process-support.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-process-support.ts @@ -200,9 +200,15 @@ export function restartEnvironment( return undefined; } if (record.isMobile) { - return { CI: "1", EXPO_NO_TELEMETRY: "1", PORT: String(record.port) }; + return { + CHEATCODE_APP_RUNTIME: "expo", + CI: "1", + EXPO_NO_TELEMETRY: "1", + PORT: String(record.port), + }; } return { + CHEATCODE_APP_RUNTIME: "next", CHOKIDAR_USEPOLLING: "true", PORT: String(record.port), WATCHPACK_POLLING: "1000", 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 4d26a943..e500ee0a 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts @@ -9,6 +9,7 @@ import type { SandboxConsoleSnapshot } from "@cheatcode/types/api"; import { sandboxExecProcessName } from "./project-sandbox-audit"; import { WORKSPACE_DIR } from "./project-sandbox-content-support"; import { recordSandboxUsageBestEffort } from "./project-sandbox-metering"; +import { projectPackageEnvironment } from "./project-sandbox-package-runtime"; import { createProcessControl, type ProcessControl } from "./project-sandbox-process-control"; import { emptyConsoleSnapshot, sliceProcessLogs } from "./project-sandbox-process-logs"; import { @@ -167,12 +168,13 @@ async function exec(runtime: ProcessRuntime, input: ProjectExecInput): Promise // so multiple projects' servers persist side by side in the one per-user sandbox (Cheatcode parity). const APP_PREVIEW_SLOT_PREFIX = "app-preview:"; +const EXPO_RUNTIME_BIN = "/home/node/.cheatcode/app-runtimes/expo/node_modules/.bin/expo"; export async function executeStartDevServer( input: StartDevServerInput, @@ -134,7 +135,10 @@ export async function executePreparedStartDevServer( // An `expo start …` invocation, however the model spelled it (npx / pnpm exec / bare, any flags). function isExpoStartCommand(command: readonly string[]): boolean { - return command.includes("expo") && command.includes("start"); + return ( + command.some((argument) => argument === "expo" || argument.endsWith("/expo")) && + command.includes("start") + ); } // Restore the curated Expo dependency tree onto the project's package.json. The model routinely @@ -178,7 +182,7 @@ function expoWebCommand(port: number): string[] { const writeScript = `echo ${restoreB64} | base64 -d > /tmp/cc-restore-expo-deps.js`; const heal = "node /tmp/cc-restore-expo-deps.js && rm -f pnpm-lock.yaml package-lock.json && CI=1 EXPO_NO_TELEMETRY=1 pnpm install --prefer-offline"; - const startMetro = `exec pnpm exec expo start -c --web --host lan --port ${port}`; + const startMetro = `exec ${EXPO_RUNTIME_BIN} start -c --web --host lan --port ${port}`; return ["sh", "-lc", `${writeScript}; (${heal}) ; ${startMetro}`]; }