Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
38 changes: 38 additions & 0 deletions .opencode/todo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Sandbox Implementation Tasks

## Phase 1 - Core Infrastructure (COMPLETE)
- [x] Add sandbox provider abstraction layer
- [x] Add Modal provider and sandbox configuration
- [x] Add sandbox context and filesystem abstraction
- [x] Add SandboxRuntime for tool execution in remote sandboxes
- [x] Add sandbox support for glob, grep, and list tools
- [x] Add sandbox support for lsp and patch tools

## Phase 2 - Polish (COMPLETE)
- [x] Add Kubernetes provider
- [x] Add unit tests (65 tests)
- [x] Add session sandbox status endpoint
- [x] Add error handling improvements
- [x] Add JSDoc documentation to public APIs
- [x] Create README for sandbox module

## Phase 3 - Release Prep (COMPLETE)
- [x] SDK regeneration for new sandbox endpoint
- [x] Push branch to fork
- [x] Create PR to merge sandbox-isolation into dev

## Phase 4 - Testing (COMPLETE)
- [x] Add Modal integration test stubs (5 tests)
- [x] Add Kubernetes integration test stubs (5 tests)
- [x] Add context and error handling tests (17 tests)
- [x] Add example configuration file

## Status: ALL COMPLETE

**PR:** https://github.com/anomalyco/opencode/pull/8238

- **Branch:** `sandbox-isolation` (17 commits ahead of `dev`)
- **Typecheck:** Passing
- **Tests:** 734 pass, 15 skip (integration tests need credentials)
- **Test files:** 6 sandbox test files with 96 tests total
- **SDK:** Regenerated with sandbox types and endpoint
19 changes: 19 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,25 @@ export namespace Config {
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
})
.optional(),
sandbox: z
.object({
provider: z.enum(["local", "modal", "kubernetes"]).optional().describe("Sandbox provider to use"),
modal: z
.object({
appName: z.string().optional().describe("Modal app name for sandboxes"),
timeout: z.number().optional().describe("Default timeout in seconds"),
image: z.string().optional().describe("Default image for Modal sandboxes"),
})
.optional(),
kubernetes: z
.object({
namespace: z.string().optional().describe("Kubernetes namespace for sandbox pods"),
image: z.string().optional().describe("Default image for Kubernetes sandboxes"),
})
.optional(),
})
.optional()
.describe("Sandbox configuration for isolated code execution"),
experimental: z
.object({
hook: z
Expand Down
92 changes: 92 additions & 0 deletions packages/opencode/src/sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Sandbox Module

The sandbox module provides isolated execution environments for OpenCode sessions. It abstracts away the details of where code runs, allowing seamless switching between local git worktrees and remote cloud sandboxes.

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│ Tool Layer │
│ (read, write, edit, bash, glob, grep, etc.) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ SandboxRuntime │
│ - withSession(sessionId, fn) │
│ - readFile / writeFile / exists / stat / readdir │
│ - exec(command, args) │
│ - isRemote() │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ LocalProvider │ │ ModalProvider │ │ K8sProvider │
│ (git worktrees) │ │ (Modal.com VMs) │ │ (K8s pods) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```

## Providers

### Local Provider (default)
Uses git worktrees to create isolated directories for each session. Runs on the local machine.

### Modal Provider
Uses Modal.com cloud VMs. Requires `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` environment variables.

### Kubernetes Provider
Uses Kubernetes pods. Requires a valid kubeconfig and cluster access.

## Configuration

Add to your `opencode.json`:

```json
{
"sandbox": {
"provider": "local",
"modal": {
"appName": "opencode-sandbox",
"image": "python:3.11-slim",
"timeout": 3600
},
"kubernetes": {
"namespace": "opencode",
"image": "ubuntu:22.04"
}
}
}
```

## Usage in Tools

Tools should use `SandboxRuntime` for all file and exec operations:

```typescript
import { SandboxRuntime } from "@/sandbox/runtime"

// Wrap tool execution in session context
await SandboxRuntime.withSession(sessionId, async () => {
// These automatically route to local or remote
const content = await SandboxRuntime.readFile("/path/to/file")
await SandboxRuntime.writeFile("/path/to/file", "content")

const result = await SandboxRuntime.exec("npm", ["test"])

// Check if running remotely
if (SandboxRuntime.isRemote()) {
// Remote-specific logic
}
})
```

## Files

- `provider.ts` - Core types, interfaces, and provider registry
- `runtime.ts` - Session-aware file/exec operations (main API for tools)
- `context.ts` - Session-to-sandbox lifecycle management
- `local.ts` - Git worktree-based local provider
- `modal.ts` - Modal.com cloud provider
- `kubernetes.ts` - Kubernetes pod provider
- `fs.ts` - Filesystem abstraction layer
139 changes: 139 additions & 0 deletions packages/opencode/src/sandbox/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Context } from "../util/context"
import { Sandbox } from "./provider"
import { createLocalProvider } from "./local"
import { createModalProvider } from "./modal"
import { createKubernetesProvider } from "./kubernetes"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Log } from "../util/log"

const log = Log.create({ service: "sandbox" })

interface SandboxContext {
instance: Sandbox.Instance | null
provider: Sandbox.Provider
}

const context = Context.create<SandboxContext>("sandbox")
const sessionSandboxes = new Map<string, Sandbox.Instance>()

let defaultProvider: Sandbox.Provider | null = null

async function getProvider(): Promise<Sandbox.Provider> {
if (defaultProvider) return defaultProvider

const config = await Config.get()
const providerType = config.sandbox?.provider ?? "local"

switch (providerType) {
case "modal":
defaultProvider = createModalProvider(config.sandbox?.modal?.appName)
break
case "kubernetes":
defaultProvider = createKubernetesProvider({
namespace: config.sandbox?.kubernetes?.namespace,
defaultImage: config.sandbox?.kubernetes?.image,
})
break
case "local":
default:
defaultProvider = createLocalProvider()
break
}

Sandbox.registerProvider(defaultProvider)
return defaultProvider
}

export const SandboxContext = {
async provide<R>(fn: () => R): Promise<R> {
const provider = await getProvider()
return context.provide({ instance: null, provider }, fn)
},

async getOrCreateForSession(sessionId: string): Promise<Sandbox.Instance> {
const existing = sessionSandboxes.get(sessionId)
if (existing) {
try {
const status = await existing.getStatus()
if (status === "running") {
return existing
}
} catch (err) {
log.warn("failed to get sandbox status, will recreate", { sessionId, error: err })
}
sessionSandboxes.delete(sessionId)
}

const provider = await getProvider()
const config = await Config.get()

log.info("creating sandbox for session", { sessionId, provider: provider.type })

try {
const instance = await provider.create({
sessionId,
projectId: Instance.project.id,
workdir: Instance.directory,
timeout: config.sandbox?.modal?.timeout,
image: config.sandbox?.modal?.image ?? config.sandbox?.kubernetes?.image,
} as Sandbox.Config)

sessionSandboxes.set(sessionId, instance)
return instance
} catch (err) {
log.error("failed to create sandbox", { sessionId, provider: provider.type, error: err })
throw new Sandbox.CreateError({
message: `Failed to create sandbox for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
provider: provider.type,
})
}
},

async getForSession(sessionId: string): Promise<Sandbox.Instance | undefined> {
return sessionSandboxes.get(sessionId)
},

async terminateForSession(sessionId: string): Promise<void> {
const instance = sessionSandboxes.get(sessionId)
if (instance) {
log.info("terminating sandbox for session", { sessionId })
try {
await instance.terminate()
} catch (err) {
log.error("failed to terminate sandbox", { sessionId, error: err })
} finally {
sessionSandboxes.delete(sessionId)
}
}
},

async terminateAll(): Promise<void> {
log.info("terminating all sandboxes", { count: sessionSandboxes.size })
for (const [sessionId, instance] of sessionSandboxes) {
try {
await instance.terminate()
} catch (err) {
log.error("failed to terminate sandbox", { sessionId, error: err })
}
}
sessionSandboxes.clear()
},

get provider(): Sandbox.Provider {
return context.use().provider
},

get current(): Sandbox.Instance | null {
return context.use().instance
},

isRemote(): boolean {
try {
const provider = context.use().provider
return provider.type !== "local"
} catch {
return false
}
},
}
Loading