1379 lines
44 KiB
Markdown
1379 lines
44 KiB
Markdown
# 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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)
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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)
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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.
|
|
|
|
```go
|
|
// 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
|
|
|
|
```dockerfile
|
|
# 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
|
|
|
|
```dockerfile
|
|
# 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
|
|
```
|
|
|
|
```dockerfile
|
|
# 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
|
|
|
|
```bash
|
|
#!/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
|
|
|
|
```go
|
|
// 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)
|
|
|
|
```yaml
|
|
sandbox:
|
|
image: "yao/sandbox-claude:full"
|
|
max_memory: "4g"
|
|
```
|
|
|
|
### 9.4 Assistant-level Configuration (package.yao)
|
|
|
|
```yaml
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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 |
|