Vercel Logo

In-Memory Implementation

The local sandbox runs real commands on real files. That's great until you want to let the agent explore code without trusting it to not break anything.

just-bash is the answer. It's a virtual filesystem with copy-on-write semantics: the agent reads from real disk, but anything it writes lives in memory and disappears when the sandbox stops. Fast, cheap, safe. Ideal for exploration, testing, and any time you'd rather not let an agent loose on your actual files.

Outcome

createJustBashSandbox(dir) returns a Sandbox backed by just-bash. The harness can switch between local and in-memory backends with an environment variable, and the agent runs the same prompts against either.

Fast Track

  1. Install just-bash with bun add just-bash
  2. Implement createJustBashSandbox(dir) in src/sandbox-just-bash.ts
  3. Map readFile and exec to the just-bash API, paying attention to the virtual mount point
  4. Pick the backend at startup based on process.env.SANDBOX

Hands-on Exercise 4.3

Add the just-bash backend and wire the env-var switch.

Requirements:

  1. createJustBashSandbox(dir: string): Promise<Sandbox> (note the Promise, because creation is async)
  2. Use JustBashSandbox.create({ overlayRoot: dir }) to spin up the virtual FS
  3. Inside readFile and exec, translate paths through the virtual mount point /home/user/project
  4. In index.ts, choose local or just-bash based on process.env.SANDBOX

Implementation hints:

  • JustBashSandbox.create returns a promise. Your factory has to be async too
  • The mount point is the trap. overlayRoot: "/Users/you/project" does not mount at /, it mounts at /home/user/project. Every path inside the sandbox has to be prefixed with that constant
  • runCommand returns a command handle, not a result. Call wait() for the exit code, output() for the combined stdout/stderr

The just-bash API

A quick tour of the parts you'll wrap:

import { Sandbox as JustBashSandbox } from "just-bash";
 
const jb = await JustBashSandbox.create({ overlayRoot: "/path/to/project" });
 
const content = await jb.readFile("/home/user/project/package.json");
 
const cmd = await jb.runCommand("ls", { cwd: "/home/user/project" });
const finished = await cmd.wait();
console.log(await cmd.output());
console.log(finished.exitCode);
The mount point trap

When you pass overlayRoot: "/path/to/project", just-bash mounts that directory at /home/user/project inside the virtual filesystem. Not at /. Not at the original path. Every readFile and runCommand call has to use the virtual mount point. This will trip you up. It trips everyone up.

The implementation

src/sandbox-just-bash.ts
import { Sandbox as JustBashSandbox } from "just-bash";
import type { Sandbox } from "./sandbox";
 
const MOUNT = "/home/user/project";
 
export async function createJustBashSandbox(dir: string): Promise<Sandbox> {
  const jb = await JustBashSandbox.create({ overlayRoot: dir });
 
  return {
    type: "just-bash",
    workingDirectory: dir,
    readFile: async (p) => {
      const virtualPath = `${MOUNT}/${p}`;
      return jb.readFile(virtualPath);
    },
    exec: async (command) => {
      const cmd = await jb.runCommand(command, { cwd: MOUNT });
      const finished = await cmd.wait();
      return {
        stdout: await cmd.output(),
        exitCode: finished.exitCode,
      };
    },
    stop: async () => {},
  };
}

The MOUNT constant is the only thing the just-bash backend cares about that the local one didn't. Every path in, every path out, gets translated through it.

Wire the env-var switch

index.ts
import { createLocalSandbox } from "./src/sandbox-local";
import { createJustBashSandbox } from "./src/sandbox-just-bash";
 
const sandboxType = process.env.SANDBOX || "local";
const sandbox =
  sandboxType === "just-bash"
    ? await createJustBashSandbox(cwd)
    : createLocalSandbox(cwd);
 
console.error(`Sandbox: ${sandbox.type}`);

The factory for local is synchronous, the factory for just-bash is async. The conditional handles that for us. Everything downstream (tools, agent, prompt builder) is the same.

Copy-on-write, in one sentence

Reads come from the real disk. Writes go to memory. The real filesystem is never modified. When the sandbox stops, the virtual filesystem is garbage collected. The agent can read your package.json, then create and delete test.txt a hundred times, and your project on disk is untouched.

Try It

Same prompt, two backends:

Terminal
bun run index.ts . "Read the package.json"
Terminal
SANDBOX=just-bash bun run index.ts . "Read the package.json"

You should get the same answer both times, with Sandbox: local and Sandbox: just-bash printed in the respective runs. That's the interface working.

Try a write-shaped task on the in-memory backend:

Terminal
SANDBOX=just-bash bun run index.ts . "Create a file called scratch.txt with the text 'hello'"

The agent writes the file. Now check the real disk: scratch.txt isn't there. The write happened in the overlay, in memory.

Terminal
npx tsc --noEmit
Not every tool is portable on the first try

Some tools will work identically across both backends. Some will quietly fail because they assumed something about the host. grep is a common offender, since the shell behavior under just-bash is simulated and not always byte-identical to your system's grep. The portability test is real, not theoretical. Plan to fix one or two tools after this swap.

Commit

git add src/sandbox-just-bash.ts index.ts package.json
git commit -m "feat(sandbox): add just-bash backend with in-memory FS"

Done-When

  • just-bash is installed
  • src/sandbox-just-bash.ts exports createJustBashSandbox(dir) that returns a Promise<Sandbox>
  • Paths route through the MOUNT constant
  • SANDBOX=just-bash bun run ... runs the agent against the in-memory backend
  • A write task on just-bash doesn't touch the real filesystem
  • npx tsc --noEmit passes
Find the leaky tool

Pick a prompt that works against the local backend but fails or behaves differently under just-bash. Track down which tool is making the host-specific assumption. Then decide: do you fix the tool, or do you let the interface absorb the difference (for example, by having just-bash provide a shim for that command)? Either is a real design choice. Notice which one keeps the tool simpler.

Solution

src/sandbox-just-bash.ts
import { Sandbox as JustBashSandbox } from "just-bash";
import type { Sandbox } from "./sandbox";
 
const MOUNT = "/home/user/project";
 
export async function createJustBashSandbox(dir: string): Promise<Sandbox> {
  const jb = await JustBashSandbox.create({ overlayRoot: dir });
 
  return {
    type: "just-bash",
    workingDirectory: dir,
    readFile: async (p) => {
      const virtualPath = `${MOUNT}/${p}`;
      return jb.readFile(virtualPath);
    },
    exec: async (command) => {
      const cmd = await jb.runCommand(command, { cwd: MOUNT });
      const finished = await cmd.wait();
      return {
        stdout: await cmd.output(),
        exitCode: finished.exitCode,
      };
    },
    stop: async () => {},
  };
}
index.ts
const sandboxType = process.env.SANDBOX || "local";
const sandbox =
  sandboxType === "just-bash"
    ? await createJustBashSandbox(cwd)
    : createLocalSandbox(cwd);

Was this helpful?

supported.