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 index 67223702..217315cb 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-package-runtime.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-package-runtime.ts @@ -5,6 +5,10 @@ const BASE_NODE_PATH = [ "/opt/cheatcode-skill-runtime/node_modules", ]; const WORKSPACE_PROJECT_PATH = /^\/workspace\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\/|$)/u; +const UNSUPPORTED_PACKAGE_MANAGERS = new Set(["npm", "npx", "yarn", "yarnpkg"]); +const SHELL_EXECUTABLES = new Set(["bash", "sh", "zsh"]); +const SHELL_PACKAGE_MANAGER_COMMAND = + /(?:^|&&|\|\||;|\n|\()\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+)\s+)*(?:command\s+)?(?:sudo\s+)?(?:[^\s;&|()]+\/)?(npm|npx|yarn|yarnpkg)(?=\s|$)/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`; @@ -23,6 +27,20 @@ export function projectLocalRuntimeDir(workspaceSlug: string): string { return `${PROJECT_LOCAL_ROOT}/${workspaceSlug}`; } +/** Prevents package managers that place dependency trees on persistent FUSE. */ +export function unsupportedProjectPackageManager( + cwd: string, + command: readonly string[], +): string | null { + if (!WORKSPACE_PROJECT_PATH.test(cwd)) return null; + const executable = basename(command[0]); + if (UNSUPPORTED_PACKAGE_MANAGERS.has(executable)) return executable; + if (!SHELL_EXECUTABLES.has(executable)) return null; + const commandFlag = command.findIndex((argument) => /^-[a-z]*c[a-z]*$/u.test(argument)); + const shellCommand = commandFlag < 0 ? undefined : command[commandFlag + 1]; + return shellCommand?.match(SHELL_PACKAGE_MANAGER_COMMAND)?.[1] ?? null; +} + /** Keeps generated dependencies and caches off persistent object-store FUSE. */ export function projectPackageEnvironment( cwd: string, @@ -49,3 +67,8 @@ export function projectPackageEnvironment( npm_config_modules_dir: modulesDir, }; } + +function basename(path: string | undefined): string { + if (!path) return ""; + return path.slice(path.lastIndexOf("/") + 1); +} 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 e500ee0a..831e068c 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-processes.ts @@ -9,7 +9,10 @@ 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 { + projectPackageEnvironment, + unsupportedProjectPackageManager, +} from "./project-sandbox-package-runtime"; import { createProcessControl, type ProcessControl } from "./project-sandbox-process-control"; import { emptyConsoleSnapshot, sliceProcessLogs } from "./project-sandbox-process-logs"; import { @@ -163,11 +166,17 @@ async function runCode( async function exec(runtime: ProcessRuntime, input: ProjectExecInput): Promise { const parsed = ProjectExecInputSchema.parse(input); - const id = await runtime.ensureSandbox(); const startedAt = Date.now(); const command = commandToShellString(parsed.command); const cwd = parsed.cwd ?? WORKSPACE_DIR; const timeoutMs = parsed.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS; + const unsupportedManager = unsupportedProjectPackageManager(cwd, parsed.command); + if (unsupportedManager) { + const result = packageManagerPolicyResult(command, unsupportedManager, startedAt); + await recordExecAudit(runtime, parsed.command, cwd, result, result.exitCode, startedAt); + return result; + } + const id = await runtime.ensureSandbox(); const env = projectPackageEnvironment(cwd, parsed.env); try { const completed = await runtime.client().execute(id, { @@ -185,6 +194,23 @@ async function exec(runtime: ProcessRuntime, input: ProjectExecInput): Promise { const parsed = ProjectStartProcessInputSchema.parse(input); assertValidProcessStart(parsed); + assertSupportedProjectPackageManager(parsed.cwd ?? WORKSPACE_DIR, parsed.command); const id = await context.runtime.ensureSandbox(); const name = parsed.processId; const sessionId = `cc-${name}`; @@ -258,6 +285,20 @@ async function startProcess( return { command: record.command, id: name, status: "running" }; } +function assertSupportedProjectPackageManager(cwd: string, command: readonly string[]): void { + const manager = unsupportedProjectPackageManager(cwd, command); + if (!manager) return; + throw new APIError( + 422, + "sandbox_command_failed", + `${manager} is disabled in persistent projects. Use pnpm instead.`, + { + hint: "Use pnpm so dependency trees stay in the sandbox-local project runtime.", + retriable: false, + }, + ); +} + function processPolicy(input: ParsedProcessStartInput): ProcessPolicy { return { keepAliveTimeoutMs: input.keepAliveTimeoutMs ?? 0, diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-runtime-handle.ts b/apps/agent-worker/src/durable-objects/project-sandbox-runtime-handle.ts index 4fa80862..e861bb1b 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-runtime-handle.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-runtime-handle.ts @@ -257,15 +257,17 @@ function setCachedSandboxId(state: RuntimeState, sandboxId: string): void { } async function ensureSandbox(state: RuntimeState, startingRunId?: string): Promise { - return withSandboxMutation(state, async () => { - if ( - state.cache.sandboxId && - Date.now() - state.cache.startedVerifiedAtMs < STARTED_REVERIFY_MS - ) { - return state.cache.sandboxId; - } - return resolveStartedSandbox(state, startingRunId); - }); + if (state.isSandboxRuntimeUpdateInProgress) { + throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT); + } + if (state.cache.sandboxId && Date.now() - state.cache.startedVerifiedAtMs < STARTED_REVERIFY_MS) { + return state.cache.sandboxId; + } + const resolved = await inspectSandbox(state); + if (typeof resolved === "string") { + return resolved; + } + return replaceSandboxRuntime(state, startingRunId); } async function restartSandboxForWorkspaceRecovery( @@ -305,7 +307,9 @@ async function ensureExistingSandboxStarted(state: RuntimeState): Promise( } } -async function resolveStartedSandbox(state: RuntimeState, startingRunId?: string): Promise { - const daytona = await ensureClient(state); - let resolved: DaytonaSandbox; - try { - resolved = await state.provisioning.resolve(daytona); +async function inspectSandbox(state: RuntimeState): Promise { + return withSandboxMutation(state, async () => { + if (state.isSandboxRuntimeUpdateInProgress) { + throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT); + } + if ( + state.cache.sandboxId && + Date.now() - state.cache.startedVerifiedAtMs < STARTED_REVERIFY_MS + ) { + return state.cache.sandboxId; + } + const daytona = await ensureClient(state); + let resolved: DaytonaSandbox; + try { + resolved = await state.provisioning.resolve(daytona); + } catch (error) { + throw toUpstreamError(error, "Daytona sandbox lookup failed.", state.identity.sandboxName()); + } if (!state.provisioning.isDesired(resolved)) { - resolved = await replaceSandboxRuntime(state, daytona, resolved, startingRunId); + return resolved; } - } catch (error) { - throw toUpstreamError(error, "Daytona sandbox lookup failed.", state.identity.sandboxName()); - } + return activateResolvedSandbox(state, daytona, resolved); + }); +} + +async function activateResolvedSandbox( + state: RuntimeState, + daytona: DaytonaClient, + resolved: DaytonaSandbox, +): Promise { state.cache.sandboxId = resolved.id; await state.ctx.storage.put(DAYTONA_ID_KEY, resolved.id); if (!(await state.provisioning.ensureStarted(daytona, resolved))) { @@ -351,43 +374,70 @@ async function resolveStartedSandbox(state: RuntimeState, startingRunId?: string return resolved.id; } -async function replaceSandboxRuntime( - state: RuntimeState, - daytona: DaytonaClient, - current: DaytonaSandbox, - startingRunId?: string, -): Promise { +async function replaceSandboxRuntime(state: RuntimeState, startingRunId?: string): Promise { + if (state.isSandboxRuntimeUpdateInProgress) { + throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT); + } + // Claim the upgrade before awaiting. Operations admitted earlier will observe + // this fence in inspectSandbox; operations admitted later are rejected by the + // lease gate until the replacement completes. state.isSandboxRuntimeUpdateInProgress = true; try { await assertSandboxReplacementAllowed(state, startingRunId); - state.provisioning.assertRuntimeReplacementSafe(current); - await prepareForSandboxReplacement(state); - await state.provisioning.deleteForReplacement(daytona, current); - const replacement = await state.provisioning.create(daytona); - if (!state.provisioning.isDesired(replacement)) { - throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT); - } - createLogger().info("sandbox_runtime_replaced", { - sandboxId: state.identity.sandboxName(), - snapshot: state.env.DAYTONA_SANDBOX_SNAPSHOT, + return withSandboxMutation(state, async () => { + const daytona = await ensureClient(state); + let resolved: DaytonaSandbox; + try { + resolved = await state.provisioning.resolve(daytona); + if (!state.provisioning.isDesired(resolved)) { + resolved = await replaceSandboxRuntimeExclusive(state, daytona, resolved); + } + } catch (error) { + throw toUpstreamError( + error, + "Daytona sandbox lookup failed.", + state.identity.sandboxName(), + ); + } + return activateResolvedSandbox(state, daytona, resolved); }); - return replacement; } finally { state.isSandboxRuntimeUpdateInProgress = false; } } +async function replaceSandboxRuntimeExclusive( + state: RuntimeState, + daytona: DaytonaClient, + current: DaytonaSandbox, +): Promise { + state.provisioning.assertRuntimeReplacementSafe(current); + await prepareForSandboxReplacement(state); + await state.provisioning.deleteForReplacement(daytona, current); + const replacement = await state.provisioning.create(daytona); + if (!state.provisioning.isDesired(replacement)) { + throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT); + } + createLogger().info("sandbox_runtime_replaced", { + sandboxId: state.identity.sandboxName(), + snapshot: state.env.DAYTONA_SANDBOX_SNAPSHOT, + }); + return replacement; +} + async function assertSandboxReplacementAllowed( state: RuntimeState, startingRunId?: string, ): Promise { + // Run leases are the replacement safety boundary. Short-lived Computer-panel + // requests may overlap the swap and retry; they must not reject the user's run. const leases = await runLeases(state.ctx.storage); const active = leases.filter((lease) => Date.now() - lease.startedMs < STALE_RUN_LEASE_MS); if (active.length !== leases.length) { await state.ctx.storage.put(RUN_LEASES_KEY, active); } const otherRuns = active.filter((lease) => lease.runId !== startingRunId); - if (state.activeOperationCount > 1 || otherRuns.length > 0) { + if (otherRuns.length > 0) { throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT); } } diff --git a/packages/agent-core/src/mastra/system-prompt.ts b/packages/agent-core/src/mastra/system-prompt.ts index 6770b7e2..db7ef93b 100644 --- a/packages/agent-core/src/mastra/system-prompt.ts +++ b/packages/agent-core/src/mastra/system-prompt.ts @@ -162,7 +162,7 @@ const CORE_INSTRUCTIONS = [ `## Your computer A Linux sandbox is available when the task genuinely needs it. Ordinary conversation, answers, lookups, browser-only work, and throwaway calculations do not need a project. A project is attached lazily when you first choose a workspace-backed file, document, or chart tool; do not call one merely to create a project. A shell command with no cwd is projectless and is only for browser/skill CLIs or environment inspection. When a shell command reads, creates, or changes persistent project files, set its cwd to \`/workspace\`; that explicit intent attaches the project and maps \`/workspace\` to its persistent folder. Never use shell_terminal for browser or skill CLI commands; use projectless shell_exec argv calls, then fall back to the native browser tools if a browser CLI is unavailable. Browser tools use the sandbox without attaching a project unless the requested outcome also needs persistent files. Once a project is attached, its folder under /workspace is persistent across turns. The sandbox already has: -- Node.js 24 (node, npm, pnpm) and Python 3 (python3, pip3) — install anything else you need from the shell. +- Node.js 24 (node, pnpm) and Python 3 (python3, pip3) — use pnpm for JavaScript dependencies and install anything else you need from the shell. - LibreOffice (headless) plus preinstalled Node libraries for deliverables: pptxgenjs (slides), docx, exceljs, @react-pdf/renderer, recharts, arquero. - A headed Chromium browser you drive to test what you build and to browse the web. - A dev server you expose with code_start_dev_server. Request port 5173 normally; the tool owns the user-visible Computer preview and safely remaps it when another project already uses that port. Never launch a user-facing app with shell_exec, shell_terminal, or shell_start_process because those processes are not registered as the project preview and cannot recover after sandbox idle stops.