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
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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,
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -163,11 +166,17 @@ async function runCode(

async function exec(runtime: ProcessRuntime, input: ProjectExecInput): Promise<SandboxExecResult> {
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, {
Expand All @@ -185,6 +194,23 @@ async function exec(runtime: ProcessRuntime, input: ProjectExecInput): Promise<S
}
}

function packageManagerPolicyResult(
command: string,
manager: string,
startedAt: number,
): SandboxExecResult {
return {
command,
durationMs: Date.now() - startedAt,
exitCode: 64,
stderr:
`${manager} is disabled in persistent projects because it writes dependencies to object storage. ` +
"Use pnpm; dependencies are installed in the sandbox-local project runtime.",
stdout: "",
success: false,
};
}

function execResult(
command: string,
completed: { exitCode: number; result?: string | null | undefined },
Expand Down Expand Up @@ -238,6 +264,7 @@ async function startProcess(
): Promise<SandboxProcessResult> {
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}`;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,15 +257,17 @@ function setCachedSandboxId(state: RuntimeState, sandboxId: string): void {
}

async function ensureSandbox(state: RuntimeState, startingRunId?: string): Promise<string> {
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(
Expand Down Expand Up @@ -305,7 +307,9 @@ async function ensureExistingSandboxStarted(state: RuntimeState): Promise<string
}
state.cache.sandboxId = existing.id;
await state.ctx.storage.put(DAYTONA_ID_KEY, existing.id);
state.cache.startedVerifiedAtMs = Date.now();
// Cleanup callers may start a stale snapshot. Only the desired-runtime path can
// populate the short-lived verified cache used by regular sandbox operations.
state.cache.startedVerifiedAtMs = 0;
return existing.id;
});
}
Expand All @@ -328,17 +332,36 @@ async function withSandboxMutation<Result>(
}
}

async function resolveStartedSandbox(state: RuntimeState, startingRunId?: string): Promise<string> {
const daytona = await ensureClient(state);
let resolved: DaytonaSandbox;
try {
resolved = await state.provisioning.resolve(daytona);
async function inspectSandbox(state: RuntimeState): Promise<string | DaytonaSandbox> {
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<string> {
state.cache.sandboxId = resolved.id;
await state.ctx.storage.put(DAYTONA_ID_KEY, resolved.id);
if (!(await state.provisioning.ensureStarted(daytona, resolved))) {
Expand All @@ -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<DaytonaSandbox> {
async function replaceSandboxRuntime(state: RuntimeState, startingRunId?: string): Promise<string> {
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<DaytonaSandbox> {
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<void> {
// 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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/mastra/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down