Sandbox Design
1. Overview
Sandbox provides persistent Docker containers as isolated execution environments for external CLI agents like Claude Code.
Why Docker?
- Persistence: Claude installs dependencies (npm, pip, apt), which must persist across sessions
- Cross-platform: Works on Linux, macOS, and Windows
- Strong isolation: Process, filesystem, and network isolation
- Mature ecosystem: Well-documented, easy to maintain
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Yao Server │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Sandbox Manager │ │
│ │ │ │
│ │ containers: map[containerName]*Container │ │
│ │ │ │
│ │ - GetOrCreate(userID, chatID) → get or create container │ │
│ │ - Exec(containerName, cmd) → execute command in container │ │
│ │ - Stop(containerName) → stop container (preserve data) │ │
│ │ - Remove(containerName) → delete container │ │
│ │ │ │
│ └──────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Container-A │ │ Container-B │ │ Container-C │ │
│ │ (user1-chat1)│ │ (user1-chat2)│ │ (user2-chat1)│ │
│ │ │ │ │ │ │ │
│ │ - Claude CLI │ │ - Claude CLI │ │ - Claude CLI │ │
│ │ - Node.js │ │ - Python │ │ - Go │ │
│ │ - User code │ │ - User code │ │ - User code │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ───────┴────────────────┴────────────────┴─────── │
│ Unix Socket IPC │
│ (one socket per container) │
│ │
└─────────────────────────────────────────────────────────────────────┘
2. Container Lifecycle
Create ──────────► Running ──────────► Stopped ──────────► Removed
(docker create) (docker start) (docker stop) (docker rm)
│ │ │ │
│ │ │ │
▼ ▼ ▼ ▼
First request Execute tasks Idle timeout Cleanup policy
(persistent) (data preserved) (manual/scheduled)
Container States
| State |
Description |
created |
Container created, not started |
running |
Container running, can execute commands |
stopped |
Container stopped, data preserved, can restart |
removed |
Container deleted |
Naming Convention
yao-sandbox-{userID}-{chatID}
Example: yao-sandbox-u123-c456
3. IPC Communication
Problem
Claude CLI runs inside the sandbox but needs to call Yao's MCP Tools (Yao Processes) which run outside.
Solution: Unix Socket + MCP JSON-RPC
┌────────────────────────────────────────────────────────────────────┐
│ Docker Container │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Claude CLI │ │
│ │ │ │ │
│ │ │ .mcp.json: "yao" → stdio │ │
│ │ ▼ │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ yao-bridge (lightweight binary) │ │ │
│ │ │ stdin/stdout ↔ /tmp/yao.sock │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ /tmp/yao.sock │
│ │ │
└──────────────────────────┼─────────────────────────────────────────┘
│
Unix Socket (bind mount)
│
┌──────────────────────────┼─────────────────────────────────────────┐
│ │ │
│ {YAO_DATA_ROOT}/sandbox/ipc/{sessionID}.sock │
│ │ │
│ ┌───────────────────────▼──────────────────────────────────────┐ │
│ │ IPC Server (goroutine) │ │
│ │ │ │
│ │ MCP JSON-RPC Methods: │ │
│ │ - initialize → handshake │ │
│ │ - tools/list → return authorized Yao MCP tools │ │
│ │ - tools/call → execute process.New(name, args...) │ │
│ │ - resources/list → list Yao resources │ │
│ │ - resources/read → read Yao resource │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ Yao Server │
└────────────────────────────────────────────────────────────────────┘
Protocol
- Format: MCP standard JSON-RPC 2.0 over NDJSON (newline-delimited JSON)
- Transport: Unix Socket
- Bridge:
yao-bridge binary converts stdio ↔ socket
4. Core Interfaces
4.1 Sandbox Manager
// sandbox/manager.go
package sandbox
type Manager struct {
mu sync.Mutex // Protects creation
containers sync.Map // containerName → *Container
running int32 // Running container count
ipcManager *ipc.Manager
dockerClient *docker.Client
config *Config
}
var ErrTooManyContainers = errors.New("sandbox: too many running containers, please try again later")
type Config struct {
Image string // Docker image, default: yao/sandbox:latest
WorkspaceRoot string // Host workspace root directory
IPCDir string // IPC socket directory
MaxContainers int // Maximum concurrent containers
IdleTimeout time.Duration // Idle timeout before stopping container
MaxMemory string // Memory limit, e.g., "2g"
MaxCPU float64 // CPU limit, e.g., 1.0
}
type Container struct {
ID string
Name string // yao-sandbox-{userID}-{chatID}
UserID string
ChatID string
Status string // created, running, stopped
CreatedAt time.Time
LastUsedAt time.Time
IPCSession *ipc.Session
}
4.2 Manager Methods
// GetOrCreate returns existing container or creates new one
// Returns ErrTooManyContainers if limit exceeded
func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Container, error)
// Stream executes command and returns stdout reader
func (m *Manager) Stream(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (io.ReadCloser, error)
// Exec executes command and waits for completion
func (m *Manager) Exec(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (*ExecResult, error)
// Stop stops container but preserves data
func (m *Manager) Stop(ctx context.Context, containerName string) error
// Start starts a stopped container
func (m *Manager) Start(ctx context.Context, containerName string) error
// Remove deletes container and its data
func (m *Manager) Remove(ctx context.Context, containerName string) error
// List returns all containers for a user
func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error)
// Cleanup stops idle containers
func (m *Manager) Cleanup(ctx context.Context) error
4.3 Filesystem Methods
// WriteFile writes content to a file in container
func (m *Manager) WriteFile(ctx context.Context, containerName, path string, content []byte) error
// ReadFile reads content from a file in container
func (m *Manager) ReadFile(ctx context.Context, containerName, path string) ([]byte, error)
// ListDir lists directory contents in container
func (m *Manager) ListDir(ctx context.Context, containerName, path string) ([]FileInfo, error)
// Stat returns file info
func (m *Manager) Stat(ctx context.Context, containerName, path string) (*FileInfo, error)
// MkDir creates directory in container
func (m *Manager) MkDir(ctx context.Context, containerName, path string) error
// Remove removes file or directory in container
func (m *Manager) RemoveFile(ctx context.Context, containerName, path string) error
// CopyToContainer copies file/directory from host to container
func (m *Manager) CopyToContainer(ctx context.Context, containerName, hostPath, containerPath string) error
// CopyFromContainer copies file/directory from container to host
func (m *Manager) CopyFromContainer(ctx context.Context, containerName, containerPath, hostPath string) error
// FileInfo represents file metadata
type FileInfo struct {
Name string
Path string
Size int64
Mode os.FileMode
ModTime time.Time
IsDir bool
}
4.4 ExecOptions
type ExecOptions struct {
WorkDir string // Working directory inside container
Env map[string]string // Environment variables
Stdin io.Reader // Standard input
Timeout time.Duration // Execution timeout
}
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
}
5. IPC System
5.1 IPC Session
// ipc/session.go
package ipc
type Session struct {
ID string // Usually equals chatID
SocketPath string // {IPCDir}/{id}.sock
Listener net.Listener
Conn net.Conn
Context *AgentContext
MCPTools map[string]*MCPTool
cancel context.CancelFunc
}
type AgentContext struct {
UserID string
ChatID string
Locale string
}
type MCPTool struct {
Name string
Description string
Process string // Yao process name
InputSchema json.RawMessage // JSON Schema
}
5.2 IPC Manager
// ipc/manager.go
type Manager struct {
sessions sync.Map // sessionID → *Session
sockDir string // {YAO_DATA_ROOT}/sandbox/ipc/
}
// Create creates new IPC session
func (m *Manager) Create(ctx context.Context, sessionID string, agentCtx *AgentContext, mcpTools map[string]*MCPTool) (*Session, error)
// Close closes IPC session and cleans up
func (m *Manager) Close(sessionID string) error
// Get returns existing session
func (m *Manager) Get(sessionID string) (*Session, bool)
5.3 JSON-RPC Message Handling
// JSON-RPC request structure
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// JSON-RPC response structure
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Result interface{} `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
5.4 Session Message Loop
func (s *Session) serve(ctx context.Context) {
defer s.cleanup()
for {
select {
case <-ctx.Done():
return
default:
}
conn, err := s.Listener.Accept()
if err != nil {
continue
}
s.Conn = conn
s.handleConnection(ctx, conn)
}
}
func (s *Session) handleConnection(ctx context.Context, conn net.Conn) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
line := scanner.Text()
response := s.handleMessage(line)
if response != "" {
conn.Write([]byte(response + "\n"))
}
}
}
func (s *Session) handleMessage(line string) string {
var req JSONRPCRequest
if err := json.Unmarshal([]byte(line), &req); err != nil {
return s.errorResponse(nil, -32700, "Parse error")
}
switch req.Method {
case "initialize":
return s.handleInitialize(req)
case "initialized":
return "" // notification, no response
case "tools/list":
return s.handleListTools(req)
case "tools/call":
return s.handleCallTool(req)
case "resources/list":
return s.handleListResources(req)
case "resources/read":
return s.handleReadResource(req)
default:
return s.errorResponse(req.ID, -32601, "Method not found")
}
}
5.5 Tool Call Handler
func (s *Session) handleCallTool(req JSONRPCRequest) string {
var params struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
json.Unmarshal(req.Params, ¶ms)
// Check authorization
tool, ok := s.MCPTools[params.Name]
if !ok {
return s.errorResponse(req.ID, -32602, "Tool not found or not authorized")
}
// Execute Yao Process
proc := process.New(tool.Process, params.Arguments)
proc.WithContext(s.Context)
if err := proc.Execute(); err != nil {
return s.toolErrorResponse(req.ID, params.Name, err)
}
defer proc.Release()
result := proc.Value()
return s.toolSuccessResponse(req.ID, result)
}
6. Docker Container Management
6.1 NewManager Constructor
func NewManager(config *Config) (*Manager, error) {
// Initialize Docker client
cli, err := docker.NewClientWithOpts(docker.FromEnv, docker.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
// Ping Docker to verify connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := cli.Ping(ctx); err != nil {
return nil, fmt.Errorf("Docker not available: %w", err)
}
// Ensure directories exist
os.MkdirAll(config.WorkspaceRoot, 0755)
os.MkdirAll(config.IPCDir, 0755)
m := &Manager{
dockerClient: cli,
config: config,
ipcManager: ipc.NewManager(config.IPCDir),
}
// Start cleanup loop
go m.startCleanupLoop(context.Background())
return m, nil
}
6.2 GetOrCreate with Limit Check
func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Container, error) {
containerName := fmt.Sprintf("yao-sandbox-%s-%s", userID, chatID)
// Check if container already exists (fast path)
if c, ok := m.containers.Load(containerName); ok {
container := c.(*Container)
container.LastUsedAt = time.Now()
return container, nil
}
// Use mutex for creation to avoid race condition
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring lock
if c, ok := m.containers.Load(containerName); ok {
container := c.(*Container)
container.LastUsedAt = time.Now()
return container, nil
}
// Check running container limit
if m.running >= int32(m.config.MaxContainers) {
return nil, ErrTooManyContainers
}
// Create new container
container, err := m.createContainer(ctx, userID, chatID)
if err != nil {
return nil, err
}
// Store and increment counter
m.containers.Store(containerName, container)
m.running++
return container, nil
}
6.3 Create Container (internal)
func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*Container, error) {
containerName := fmt.Sprintf("yao-sandbox-%s-%s", userID, chatID)
// Ensure image exists, auto-pull if not
if err := m.ensureImage(ctx, m.config.Image); err != nil {
return nil, err
}
// Workspace directory
workspaceHost := filepath.Join(m.config.WorkspaceRoot, userID, chatID)
os.MkdirAll(workspaceHost, 0755)
// IPC socket path
sessionID := chatID
ipcSocketHost := filepath.Join(m.config.IPCDir, sessionID+".sock")
// Create container
resp, err := m.dockerClient.ContainerCreate(ctx,
&container.Config{
Image: m.config.Image,
Cmd: []string{"sleep", "infinity"}, // Keep running
WorkingDir: "/workspace",
Env: []string{
"YAO_IPC_SOCKET=/tmp/yao.sock",
},
},
&container.HostConfig{
Binds: []string{
workspaceHost + ":/workspace",
ipcSocketHost + ":/tmp/yao.sock",
},
Resources: container.Resources{
Memory: parseMemory(m.config.MaxMemory),
NanoCPUs: int64(m.config.MaxCPU * 1e9),
},
SecurityOpt: []string{"no-new-privileges"},
CapDrop: []string{"ALL"},
},
nil, nil, containerName,
)
if err != nil {
return nil, err
}
return &Container{
ID: resp.ID,
Name: containerName,
UserID: userID,
ChatID: chatID,
Status: "created",
CreatedAt: time.Now(),
}, nil
}
// ensureImage ensures the image exists locally, pulls if not
func (m *Manager) ensureImage(ctx context.Context, imageName string) error {
// Check if image exists locally
_, _, err := m.dockerClient.ImageInspectWithRaw(ctx, imageName)
if err == nil {
return nil // Image exists
}
// Image not found, pull it
reader, err := m.dockerClient.ImagePull(ctx, imageName, image.PullOptions{})
if err != nil {
return fmt.Errorf("failed to pull image %s: %w", imageName, err)
}
defer reader.Close()
// Wait for pull to complete
io.Copy(io.Discard, reader)
return nil
}
6.4 Ensure Running
func (m *Manager) ensureRunning(ctx context.Context, containerName string) error {
c, ok := m.containers.Load(containerName)
if !ok {
return fmt.Errorf("container not found: %s", containerName)
}
cont := c.(*Container)
if cont.Status == "running" {
return nil
}
// Start the container
if err := m.dockerClient.ContainerStart(ctx, cont.ID, container.StartOptions{}); err != nil {
return err
}
m.mu.Lock()
cont.Status = "running"
cont.LastUsedAt = time.Now()
m.mu.Unlock()
return nil
}
6.5 Execute Command (Streaming)
func (m *Manager) Stream(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (io.ReadCloser, error) {
// Ensure container is running
if err := m.ensureRunning(ctx, containerName); err != nil {
return nil, err
}
// Get container
c, _ := m.containers.Load(containerName)
cont := c.(*Container)
// Create exec instance
execConfig := container.ExecOptions{
Cmd: cmd,
WorkingDir: opts.WorkDir,
Env: mapToSlice(opts.Env),
AttachStdout: true,
AttachStderr: true,
}
execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig)
if err != nil {
return nil, err
}
// Attach to exec
attachResp, err := m.dockerClient.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
return attachResp.Reader, nil
}
// Exec executes command and waits for completion
func (m *Manager) Exec(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (*ExecResult, error) {
if opts == nil {
opts = &ExecOptions{}
}
reader, err := m.Stream(ctx, containerName, cmd, opts)
if err != nil {
return nil, err
}
defer reader.Close()
// Read all output
output, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
// TODO: Parse stdout/stderr from Docker multiplexed stream
// TODO: Get exit code from ContainerExecInspect
return &ExecResult{
ExitCode: 0,
Stdout: string(output),
Stderr: "",
}, nil
}
6.6 Filesystem Operations
// WriteFile writes content to a file in container using docker cp
func (m *Manager) WriteFile(ctx context.Context, containerName, path string, content []byte) error {
c, ok := m.containers.Load(containerName)
if !ok {
return fmt.Errorf("container not found: %s", containerName)
}
cont := c.(*Container)
// Create a tar archive with the file
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
hdr := &tar.Header{
Name: filepath.Base(path),
Mode: 0644,
Size: int64(len(content)),
}
tw.WriteHeader(hdr)
tw.Write(content)
tw.Close()
// Copy to container
return m.dockerClient.CopyToContainer(ctx, cont.ID, filepath.Dir(path), &buf, container.CopyToContainerOptions{})
}
// ReadFile reads content from a file in container
func (m *Manager) ReadFile(ctx context.Context, containerName, path string) ([]byte, error) {
c, ok := m.containers.Load(containerName)
if !ok {
return nil, fmt.Errorf("container not found: %s", containerName)
}
cont := c.(*Container)
reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, path)
if err != nil {
return nil, err
}
defer reader.Close()
// Extract from tar
tr := tar.NewReader(reader)
_, err = tr.Next()
if err != nil {
return nil, err
}
return io.ReadAll(tr)
}
// ListDir lists directory contents
func (m *Manager) ListDir(ctx context.Context, containerName, path string) ([]FileInfo, error) {
result, err := m.Exec(ctx, containerName, []string{"ls", "-la", "--time-style=+%s", path}, nil)
if err != nil {
return nil, err
}
return parseLS(result.Stdout), nil
}
// Stat returns file info
func (m *Manager) Stat(ctx context.Context, containerName, path string) (*FileInfo, error) {
result, err := m.Exec(ctx, containerName, []string{"stat", "--format=%n|%s|%f|%Y|%F", path}, nil)
if err != nil {
return nil, err
}
return parseStat(result.Stdout), nil
}
// MkDir creates directory in container
func (m *Manager) MkDir(ctx context.Context, containerName, path string) error {
_, err := m.Exec(ctx, containerName, []string{"mkdir", "-p", path}, nil)
return err
}
// RemoveFile removes file or directory in container
func (m *Manager) RemoveFile(ctx context.Context, containerName, path string) error {
_, err := m.Exec(ctx, containerName, []string{"rm", "-rf", path}, nil)
return err
}
// CopyToContainer copies from host to container
func (m *Manager) CopyToContainer(ctx context.Context, containerName, hostPath, containerPath string) error {
c, ok := m.containers.Load(containerName)
if !ok {
return fmt.Errorf("container not found: %s", containerName)
}
cont := c.(*Container)
// Create tar archive from host path
archive, err := createTarFromPath(hostPath)
if err != nil {
return err
}
defer archive.Close()
return m.dockerClient.CopyToContainer(ctx, cont.ID, containerPath, archive, container.CopyToContainerOptions{})
}
// CopyFromContainer copies from container to host
func (m *Manager) CopyFromContainer(ctx context.Context, containerName, containerPath, hostPath string) error {
c, ok := m.containers.Load(containerName)
if !ok {
return fmt.Errorf("container not found: %s", containerName)
}
cont := c.(*Container)
reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, containerPath)
if err != nil {
return err
}
defer reader.Close()
return extractTarToPath(reader, hostPath)
}
6.7 Cleanup Strategy
func (m *Manager) startCleanupLoop(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.Cleanup(ctx)
}
}
}
func (m *Manager) Cleanup(ctx context.Context) error {
now := time.Now()
m.containers.Range(func(key, value interface{}) bool {
containerName := key.(string)
c := value.(*Container)
// Stop idle containers
if c.Status == "running" && now.Sub(c.LastUsedAt) > m.config.IdleTimeout {
m.Stop(ctx, containerName)
}
return true
})
return nil
}
func (m *Manager) Start(ctx context.Context, containerName string) error {
return m.ensureRunning(ctx, containerName)
}
func (m *Manager) Stop(ctx context.Context, containerName string) error {
c, ok := m.containers.Load(containerName)
if !ok {
return nil
}
cont := c.(*Container)
if err := m.dockerClient.ContainerStop(ctx, cont.ID, container.StopOptions{}); err != nil {
return err
}
// Update status, decrement running count
m.mu.Lock()
if cont.Status == "running" {
cont.Status = "stopped"
m.running--
}
m.mu.Unlock()
return nil
}
func (m *Manager) Remove(ctx context.Context, containerName string) error {
// Stop first if running
m.Stop(ctx, containerName)
c, ok := m.containers.Load(containerName)
if !ok {
return nil
}
cont := c.(*Container)
if err := m.dockerClient.ContainerRemove(ctx, cont.ID, container.RemoveOptions{}); err != nil {
return err
}
// Remove from map
m.containers.Delete(containerName)
return nil
}
func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) {
var result []*Container
prefix := fmt.Sprintf("yao-sandbox-%s-", userID)
m.containers.Range(func(key, value interface{}) bool {
containerName := key.(string)
if strings.HasPrefix(containerName, prefix) {
result = append(result, value.(*Container))
}
return true
})
return result, nil
}
6.8 Helper Functions
// mapToSlice converts map to []string for env vars
func mapToSlice(m map[string]string) []string {
if m == nil {
return nil
}
result := make([]string, 0, len(m))
for k, v := range m {
result = append(result, k+"="+v)
}
return result
}
// parseMemory converts string like "2g" to bytes
func parseMemory(s string) int64 {
// Implementation: parse "2g" → 2*1024*1024*1024
// Use Docker's units package or manual parsing
return 0 // placeholder
}
// parseLS parses ls -la output to []FileInfo
func parseLS(output string) []FileInfo {
// Implementation: parse ls output lines
return nil // placeholder
}
// parseStat parses stat output to *FileInfo
func parseStat(output string) *FileInfo {
// Implementation: parse stat --format output
return nil // placeholder
}
// createTarFromPath creates a tar archive from a host path
func createTarFromPath(hostPath string) (io.ReadCloser, error) {
// Implementation: walk directory, create tar entries
return nil, nil // placeholder
}
// extractTarToPath extracts a tar archive to a host path
func extractTarToPath(reader io.Reader, hostPath string) error {
// Implementation: read tar entries, write to disk
return nil // placeholder
}
7. yao-bridge
Lightweight binary inside container that bridges stdio to Unix socket.
// cmd/yao-bridge/main.go
package main
import (
"io"
"net"
"os"
)
func main() {
if len(os.Args) < 2 {
os.Exit(1)
}
sockPath := os.Args[1]
// Connect to Unix socket
conn, err := net.Dial("unix", sockPath)
if err != nil {
os.Exit(1)
}
defer conn.Close()
// stdin → socket
go func() {
io.Copy(conn, os.Stdin)
conn.(*net.UnixConn).CloseWrite()
}()
// socket → stdout
io.Copy(os.Stdout, conn)
}
Build as static binary and include in Docker image.
8. Docker Image
8.1 Image Naming Convention
yao/sandbox-{tool}:{variant}
Examples:
yao/sandbox-claude:latest # Claude CLI + Node.js + Python (default)
yao/sandbox-claude:full # + Go
yao/sandbox-cursor:latest # Cursor CLI + Node.js + Python (future)
8.2 Source Directory Structure
sandbox/
├── docker/
│ ├── base/
│ │ └── Dockerfile.base # Common base image
│ ├── claude/
│ │ ├── Dockerfile # Default: Claude + Node + Python
│ │ └── Dockerfile.full # + Go
│ ├── cursor/ # Future
│ │ └── Dockerfile
│ ├── build.sh
│ └── scripts/
│ └── entrypoint.sh
├── bridge/
│ └── main.go # yao-bridge source
├── ipc/
│ ├── manager.go
│ └── session.go
├── manager.go
├── config.go
└── types.go
8.3 Base Image
# sandbox/docker/base/Dockerfile.base
FROM ubuntu:22.04
# Base tools
RUN apt-get update && apt-get install -y \
curl \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# yao-bridge (common to all tools)
COPY yao-bridge /usr/local/bin/yao-bridge
RUN chmod +x /usr/local/bin/yao-bridge
# Working directory
WORKDIR /workspace
# Non-root user
RUN useradd -m -s /bin/bash sandbox
USER sandbox
CMD ["sleep", "infinity"]
8.4 Claude Tool Images
# sandbox/docker/claude/Dockerfile
# Default image: Claude CLI + Node.js + Python
FROM yao/sandbox-base:latest
USER root
# Claude CLI
RUN curl -fsSL https://claude.ai/install.sh | sh
# Node.js 20
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Python 3.11
RUN apt-get update && apt-get install -y \
python3.11 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
USER sandbox
# sandbox/docker/claude/Dockerfile.full
# Full image: + Go
FROM yao/sandbox-claude:latest
USER root
# Go 1.23
RUN curl -fsSL https://go.dev/dl/go1.23.linux-amd64.tar.gz | tar -C /usr/local -xzf - \
&& ln -s /usr/local/go/bin/go /usr/local/bin/go
USER sandbox
8.5 Build Script
#!/bin/bash
# sandbox/docker/build.sh
set -e
TOOL=${1:-claude}
# Build yao-bridge
cd ../bridge
CGO_ENABLED=0 go build -o ../docker/yao-bridge .
cd ../docker
# Build base image
docker build -t yao/sandbox-base:latest -f base/Dockerfile.base .
# Build tool-specific images
case $TOOL in
claude)
docker build -t yao/sandbox-claude:latest -f claude/Dockerfile .
docker build -t yao/sandbox-claude:full -f claude/Dockerfile.full .
;;
cursor)
docker build -t yao/sandbox-cursor:latest -f cursor/Dockerfile .
;;
all)
$0 claude
$0 cursor
;;
esac
echo "Images built for tool: $TOOL"
8.6 Image Variants
| Image |
Tool |
Size |
Pre-installed |
yao/sandbox-base:latest |
- |
~200MB |
git, curl, yao-bridge |
yao/sandbox-claude:latest |
Claude |
~700MB |
Claude CLI, Node.js 20, Python 3.11 |
yao/sandbox-claude:full |
Claude |
~1.3GB |
+ Go 1.23 |
yao/sandbox-cursor:latest |
Cursor |
~700MB |
Cursor CLI, Node.js 20, Python 3.11 |
Default: yao/sandbox-claude:latest (includes Node + Python)
9. Configuration
9.1 Environment Variables
| Env Variable |
Default |
Description |
YAO_SANDBOX_IMAGE |
yao/sandbox-claude:latest |
Default Docker image |
YAO_SANDBOX_WORKSPACE |
{YAO_DATA_ROOT}/sandbox/workspace |
Workspace root directory |
YAO_SANDBOX_IPC |
{YAO_DATA_ROOT}/sandbox/ipc |
IPC socket directory |
YAO_SANDBOX_MAX |
100 |
Max concurrent containers |
YAO_SANDBOX_IDLE_TIMEOUT |
30m |
Idle timeout |
YAO_SANDBOX_MEMORY |
2g |
Default memory limit |
YAO_SANDBOX_CPU |
1.0 |
Default CPU limit |
9.2 Go Config Struct
// sandbox/config.go
type Config struct {
Image string `json:"image,omitempty" env:"YAO_SANDBOX_IMAGE" envDefault:"yao/sandbox-claude:latest"`
WorkspaceRoot string `json:"workspace_root,omitempty" env:"YAO_SANDBOX_WORKSPACE"`
IPCDir string `json:"ipc_dir,omitempty" env:"YAO_SANDBOX_IPC"`
MaxContainers int `json:"max_containers,omitempty" env:"YAO_SANDBOX_MAX" envDefault:"100"`
IdleTimeout time.Duration `json:"idle_timeout,omitempty" env:"YAO_SANDBOX_IDLE_TIMEOUT" envDefault:"30m"`
MaxMemory string `json:"max_memory,omitempty" env:"YAO_SANDBOX_MEMORY" envDefault:"2g"`
MaxCPU float64 `json:"max_cpu,omitempty" env:"YAO_SANDBOX_CPU" envDefault:"1.0"`
}
// Init sets defaults based on Yao config
func (c *Config) Init(dataRoot string) {
if c.WorkspaceRoot == "" {
c.WorkspaceRoot = filepath.Join(dataRoot, "sandbox", "workspace")
}
if c.IPCDir == "" {
c.IPCDir = filepath.Join(dataRoot, "sandbox", "ipc")
}
}
9.3 app.yao (optional override)
sandbox:
image: "yao/sandbox-claude:full"
max_memory: "4g"
9.4 Assistant-level Configuration (package.yao)
name: "My Coder"
type: claude
sandbox:
image: "yao/sandbox-claude:full" # Override image
max_memory: "4g" # Override memory limit
9.5 Image Resolution
1. If package.yao sandbox.image is set → use it
2. Else if type is set → use yao/sandbox-{type}:latest
3. Else → use YAO_SANDBOX_IMAGE (or app.yao sandbox.image)
10. Data Persistence
Directory Structure
{YAO_DATA_ROOT}/sandbox/
├── workspace/
│ └── {userID}/
│ ├── {chatID-1}/ # Mounted as /workspace in container
│ │ ├── .mcp.json # MCP configuration
│ │ ├── .claude/ # Claude configuration
│ │ │ └── skills/ # Skills symlink
│ │ ├── project/ # User project code
│ │ └── node_modules/ # Installed dependencies
│ │
│ └── {chatID-2}/
│ └── ...
│
└── ipc/
├── {chatID-1}.sock # IPC socket
└── {chatID-2}.sock
What Persists
| Item |
Location |
Persists |
| User code |
/workspace/ |
✅ Yes (host mount) |
| Installed packages |
Container filesystem |
✅ Yes (container persists) |
| Claude config |
/workspace/.claude/ |
✅ Yes |
| IPC socket |
/tmp/yao.sock |
❌ No (recreated each session) |
11. Stability Guarantees
| Concern |
Solution |
| Container isolation |
One container per user+chat |
| IPC isolation |
One socket per session |
| Resource limits |
Docker memory/CPU limits |
| Idle cleanup |
Auto-stop after timeout (preserve data) |
| Data persistence |
Workspace directory mount, container preserves installed packages |
| Connection handling |
Goroutine detects EOF, auto-cleanup |
| Concurrency safety |
sync.Map + dedicated goroutines |
12. Claude Executor Integration
Execution Flow
func (e *ClaudeExecutor) Stream(ctx *context.Context, messages []context.Message, opts ...*context.Options) (*context.Response, error) {
// 1. Get or create sandbox container
container, err := e.SandboxManager.GetOrCreate(ctx, ctx.User.ID, ctx.ChatID)
if err != nil {
return nil, fmt.Errorf("failed to get sandbox: %w", err)
}
// 2. Create IPC session
mcpTools := e.getMCPTools()
ipcSession, err := e.IPCManager.Create(ctx, ctx.ChatID, &AgentContext{
UserID: ctx.User.ID,
ChatID: ctx.ChatID,
Locale: ctx.Locale,
}, mcpTools)
if err != nil {
return nil, fmt.Errorf("failed to create IPC session: %w", err)
}
defer e.IPCManager.Close(ctx.ChatID)
// 3. Generate .mcp.json
if err := e.writeMCPConfig(ctx, container); err != nil {
return nil, err
}
// 4. Setup skills
if err := e.setupSkills(container); err != nil {
return nil, err
}
// 5. Build Claude CLI arguments
args := e.buildArgs(ctx, messages)
// 6. Execute Claude CLI in container
stdout, err := e.SandboxManager.Stream(ctx, container.Name,
append([]string{"claude"}, args...),
&ExecOptions{
WorkDir: "/workspace",
Env: e.buildEnvMap(ctx),
Timeout: e.getTimeout(),
},
)
if err != nil {
return nil, err
}
defer stdout.Close()
// 7. Parse stream-json output
return e.parseClaudeOutput(ctx, stdout)
}
MCP Configuration Generation
func (e *ClaudeExecutor) writeMCPConfig(ctx context.Context, container *Container) error {
config := map[string]interface{}{
"mcpServers": map[string]interface{}{
"yao": map[string]interface{}{
"command": "yao-bridge",
"args": []string{"/tmp/yao.sock"},
},
},
}
// Add other MCP servers (external stdio/sse)
for _, server := range e.Assistant.MCP.Servers {
if server.Transport != "process" {
config["mcpServers"].(map[string]interface{})[server.Name] = server.ToClaudeConfig()
}
}
data, _ := json.MarshalIndent(config, "", " ")
return e.SandboxManager.WriteFile(ctx, container.Name, "/workspace/.mcp.json", data)
}
13. Security Considerations
| Layer |
Measures |
| Filesystem |
Only workspace mounted, host filesystem not accessible |
| Network |
Can be restricted with --network none if needed |
| Privileges |
--cap-drop ALL, no-new-privileges |
| Resources |
Memory and CPU limits |
| User |
Non-root user inside container |
| IPC |
Per-session socket, authorized tools only |
14. Summary
| Aspect |
Description |
| Core approach |
Persistent Docker containers |
| Communication |
Unix Socket + MCP JSON-RPC |
| Container granularity |
One container per user+chat |
| Data persistence |
Workspace mount + container filesystem |
| Dependency persistence |
npm/pip packages persist in container |
| Security isolation |
Full isolation between users and sessions |
| Resource control |
Memory, CPU, idle timeout |
| Estimated code |
~1200 lines |