# How to run an AI code migration safely in sandboxes Record which tests pass on the old toolchain, let the agent port the code on the new one, and accept a port only if none of them fail. **On Runtime the old toolchain and the new one are two pinned images, and the agent can try three migration plans at once.** A fork copies a running sandbox, files, memory and processes, into up to 10 running sandboxes in one call, so each plan starts from the same prepared machine. Porting a 60-module codebase three ways costs $3.26 in sandbox time at Runtime's rates, worked out below ([pricing](/docs/pricing), 25 September 2026). ## What makes a migration safe to automate An upgrade such as Python 3.8 to 3.12, Node 18 to 24, or one framework major version to the next is mostly mechanical, which is why agents are good at it. It is also where a plausible change breaks something nobody tests. Three rules keep an agent honest: 1. **Measure before changing.** Run the existing tests on the old toolchain and record exactly which pass. That list is the contract. 2. **Change on the new toolchain only.** The agent edits and tests where the code will run, never on the old runtime and never on your machine. 3. **Accept by comparison.** A port is done when every test that passed before still passes, not when the agent says so. ## Pin both toolchains Build the old runtime from its public image and the new one from Runtime's default base. Each build is a numbered version, so a rerun next month uses the same tools: ```ts check import { Runtime } from "withruntime"; const runtime = new Runtime(); await runtime.images.build({ name: "legacy-py38", recipe: { base: "python:3.8-slim", pip: ["pytest"] }, }); await runtime.images.build({ name: "target-py312", recipe: { pip: ["pytest"] } }); ``` ```python check from withruntime import Runtime runtime = Runtime() runtime.images.build(name="legacy-py38", recipe={"base": "python:3.8-slim", "pip": ["pytest"]}) runtime.images.build(name="target-py312", recipe={"pip": ["pytest"]}) ``` The default base has Python 3.12, so the target needs only the test runner. Use `legacy-py38@1` and `target-py312@1` below to pin the exact versions. ## Record the baseline Run the suite on the old toolchain with the internet off, and keep the names of the tests that pass: ```ts check import { Runtime } from "withruntime"; const runtime = new Runtime(); export async function passingTests(image: string, codeDir: string): Promise> { await using sbx = await runtime.sandboxes.create({ image, network: { internet: false } }); await sbx.files.upload(codeDir, "/workspace/code"); await sbx.exec("python -m pytest -q -rA > /workspace/report.txt 2>&1", { cwd: "/workspace/code", timeoutMs: 1_800_000, }); const report = await sbx.files.readText("/workspace/report.txt"); return new Set([...report.matchAll(/^PASSED (\S+)/gm)].map((m) => m[1]!)); } export const baseline = () => passingTests("legacy-py38@1", "./code"); ``` `-rA` makes pytest list every test with its outcome. The report goes to a file, so a suite with thousands of tests is not cut at the 64 KiB a command result holds. ## Try several plans at once Prepare one sandbox on the new toolchain with the code in it, then fork it. Each copy gets its own instruction and its own agent: ```ts check import { Runtime } from "withruntime"; import type { Sandbox } from "withruntime"; type Porter = (sbx: Sandbox, plan: string) => Promise; // your agent, editing files in sbx const runtime = new Runtime(); const PLANS = [ "Change the fewest lines that make the code run on Python 3.12.", "Port to 3.12 and replace deprecated standard-library calls.", "Port to 3.12 and add type hints to every public function you touch.", ]; export async function migrate(codeDir: string, before: Set, porter: Porter) { await using base = await runtime.sandboxes.create({ image: "target-py312@1", network: { internet: false }, }); await base.files.upload(codeDir, "/workspace/code"); await base.exec( "git init -q && git add -A && git -c user.name=agent -c user.email=agent@example.com commit -qm baseline", { cwd: "/workspace/code", }, ); const copies = await base.fork({ count: PLANS.length }); const results = await Promise.all( copies.map(async (copy, i) => { await porter(copy, PLANS[i]!); await copy.exec("python -m pytest -q -rA > /workspace/report.txt 2>&1", { cwd: "/workspace/code", timeoutMs: 1_800_000, }); const report = await copy.files.readText("/workspace/report.txt"); const after = new Set([...report.matchAll(/^PASSED (\S+)/gm)].map((m) => m[1]!)); const lost = [...before].filter((name) => !after.has(name)); const diff = (await copy.exec("git diff --stat", { cwd: "/workspace/code" })).stdout; const patch = (await copy.exec("git diff", { cwd: "/workspace/code" })).stdout; await copy.stop(); return { plan: PLANS[i]!, lost, passed: after.size, diff, patch }; }), ); return results.filter((r) => r.lost.length === 0).sort((a, b) => a.diff.length - b.diff.length); } ``` The winner is the smallest change that lost no test. Its `patch` is a plain `git diff` for a person to review and apply. For a patch larger than 64 KiB, write it to a file in the sandbox and read it with `files.readText`. ## What a migration needs | Need | How Runtime covers it | | --------------------------------- | -------------------------------------------------------------------------- | | The old toolchain, still runnable | An image from any public image, such as `python:3.8-slim` | | The new toolchain, pinned | A versioned custom image, named `name@version` | | Several plans from one start | `fork({ count })`: 1 to 10 running copies with files, memory and processes | | Code that cannot phone home | `network: { internet: false }`, enforced on the host | | Long test suites | `timeoutMs` up to 24 hours per command | | A reviewable result | `git diff` read back from the winning copy | | Large codebases | `files.upload` sends a folder as one compressed archive | | Other ecosystems | Java, Go, Rust and more through `apt`, a public image or a Dockerfile | ## What it costs Take a codebase ported module by module: 60 modules, three plans each, 180 sandbox runs. Each keeps a 2 vCPU, 4 GiB sandbox running for 30 minutes while the agent reads, edits and tests, at 0.25 of a vCPU on average: ``` CPU: 180 × 0.5 h × 0.25 vCPU × $0.025 = $0.56 Memory: 180 × 0.5 h × 4 GiB × $0.0075 = $2.70 Total: $3.26 ``` Most of each half hour is the agent waiting on its model, which Runtime bills at memory plus a CPU floor of a twentieth of a vCPU. The forks' own snapshot is deleted when the fork ends and is not billed. Model tokens are billed by your model provider. New accounts get 50 free sandbox hours, no card. ## Start ```bash no-run npx withruntime sandbox run --trial -- python3 --version ``` The first run prints a link to approve in your browser. Then build the two images with the SDK as above; [custom images](/docs/images) covers Dockerfiles and private registries for older toolchains. Related: [have an agent set up any repository](/use-cases/repo-onboarding), [a coding agent sandbox](/use-cases/coding-agent-sandbox), [sandbox forks](/glossary/sandbox-fork), [agent evals and SWE-bench](/use-cases/agent-evals-and-swe-bench). Facts on this page were checked on 25 September 2026.