yao/agent/sandbox/DESIGN.md
Max cd5cf32a20 Enhance Sandbox Integration and CI Workflow for AI Tests
- Added steps to pull necessary Docker images for sandbox testing in both CI workflows.
- Updated the AI test execution to utilize sandbox configurations, ensuring proper environment setup.
- Introduced sandbox initialization in the Assistant's Stream method, allowing for execution of coding agents like Claude and Cursor.
- Enhanced context management to support sandbox execution, improving flexibility in handling agent operations.
2026-01-30 17:41:06 +08:00

39 KiB

Agent Sandbox Design

Inject Coding Agent capabilities (Claude CLI, Cursor CLI) into Yao's LLM request pipeline via Docker-based sandbox execution.

1. Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                    Yao LLM Pipeline                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │              Before Hooks / Auth / Logging                │  │
│  └─────────────────────────┬─────────────────────────────────┘  │
│                            │                                    │
│                            ▼                                    │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │                  LLM Request Handler                      │  │
│  │                                                           │  │
│  │   sandbox: nil     → Direct LLM API call (default)       │  │
│  │   sandbox: config  → Sandbox + Claude CLI                │  │ ← Inject here
│  │   sandbox: config  → Sandbox + Cursor CLI (future)       │  │
│  │                                                           │  │
│  └─────────────────────────┬─────────────────────────────────┘  │
│                            │                                    │
│                            ▼                                    │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │              After Hooks / Billing / Audit                │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

2. Design Benefits

Aspect Standalone Executor Pipeline Injection
Hooks Need re-implementation Fully reused
Auth Need re-implementation Fully reused
Logging Need re-implementation Fully reused
Billing Need re-implementation Fully reused
Errors Need re-implementation Fully reused
Code size Large (full executor) Small (one branch)
Extend One executor per agent Just add agent type

3. Configuration

3.1 Assistant Configuration

Sandbox is configured at the Assistant level (not Connector), because:

  • Same Connector can be shared by multiple Assistants
  • Some Assistants need Sandbox (Coding), others don't (Q&A)
  • Assistant defines "behavior", Connector defines "connection"
// assistants/coder/package.yao
{
  "name": "Coder Assistant",
  "connector": "deepseek.v3",

  // Sandbox configuration
  "sandbox": {
    "command": "claude",                         // claude | cursor (future)
    "image": "yaoapp/sandbox-claude:latest",     // Optional, auto-selected by agent
    "max_memory": "4g",                          // Optional
    "max_cpu": 2.0,                              // Optional
    "timeout": "10m",                            // Optional, execution timeout
    "arguments": {
      // Command-specific arguments (passed to Claude CLI)
      "max_turns": 20,
      "permission_mode": "acceptEdits"
      // Different commands may have different arguments
    }
  },

  // MCP servers
  "mcp": {
    "servers": [
      {
        "server_id": "filesystem",
        "tools": ["read_file", "write_file", "list_directory"]
      }
    ]
  }
}

3.2 Directory Structure

Skills are auto-discovered from skills/ directory following the Agent Skills open standard:

assistants/coder/
├── package.yao       # Assistant config
├── prompts.yml       # Prompts
├── mcps/             # MCP server definitions
└── skills/           # Skills directory (auto-discovered)
    ├── code-review/
    │   ├── SKILL.md       # Required: instructions + metadata
    │   ├── scripts/       # Optional: executable code (Python, Bash, JS)
    │   ├── references/    # Optional: additional documentation
    │   └── assets/        # Optional: templates, images, data files
    └── deploy/
        ├── SKILL.md
        └── scripts/
            └── deploy.sh

3.3 SKILL.md Format

Each skill must have a SKILL.md file with YAML frontmatter:

---
name: code-review                    # Required: must match parent directory name
description: >                       # Required: when to use this skill
  Review code for bugs, security issues, and best practices.
  Use when the user asks to review, audit, or analyze code quality.
license: Apache-2.0                  # Optional
compatibility: Requires git          # Optional: environment requirements
metadata:                            # Optional: arbitrary key-value pairs
  author: yao-team
  version: "1.0"
allowed-tools: Bash(git:*) Read      # Optional: pre-approved tools (experimental)
---

# Code Review

## When to use this skill
Use this skill when the user asks to review code...

## Steps
1. Check for security vulnerabilities
2. Review code style and best practices
3. Identify potential bugs
...
Field Required Description
name Yes 1-64 chars, lowercase + hyphens, must match directory name
description Yes 1-1024 chars, describes what skill does and when to use it
license No License name or reference to bundled LICENSE file
compatibility No Environment requirements (tools, network access, etc.)
metadata No Arbitrary key-value pairs for additional properties
allowed-tools No Space-delimited list of pre-approved tools (experimental)

3.4 Skills Progressive Disclosure

Skills use progressive disclosure to manage context efficiently:

  1. Discovery: At startup, agent loads only name and description of each skill
  2. Activation: When task matches a skill's description, agent reads full SKILL.md
  3. Execution: Agent follows instructions, loading scripts/, references/, assets/ as needed

3.5 No Sandbox (Default)

If sandbox is not configured, Assistant uses direct LLM API calls:

// assistants/qa/package.yao
{
  "name": "QA Assistant",
  "connector": "deepseek.v3"
  // No sandbox config → direct API call
}

4. Implementation

4.1 Package Structure

yao/
├── sandbox/                    # Low-level sandbox infrastructure
│   ├── manager.go              # Container management (✅ Done)
│   ├── config.go               # Configuration (✅ Done)
│   ├── ipc/                    # IPC communication (✅ Done)
│   │   ├── manager.go
│   │   ├── session.go
│   │   └── types.go
│   ├── bridge/                 # yao-bridge binary (✅ Done)
│   │   └── main.go
│   └── docker/                 # Docker images (✅ Done)
│       ├── base/
│       ├── claude/
│       └── build.sh
│
└── agent/
    └── sandbox/                # Agent-level sandbox integration (NEW)
        ├── DESIGN.md           # This document
        ├── types.go            # Common types and interfaces
        ├── executor.go         # Factory function and registry
        ├── claude/             # Claude CLI agent
        │   ├── types.go        # Claude-specific types (StreamMessage, ToolCall, etc.)
        │   ├── executor.go     # Executor implementation
        │   ├── command.go      # CLI command builder
        │   ├── stream.go       # Stream output parser
        │   ├── environment.go  # Container environment setup
        │   └── executor_test.go
        ├── cursor/             # Cursor CLI agent (future)
        │   └── README.md       # Placeholder for future implementation
        └── sandbox_test.go     # Integration tests

4.2 Types and Interfaces

// agent/sandbox/types.go
package sandbox

import (
    "time"

    "github.com/yaoapp/yao/agent/context"
    "github.com/yaoapp/yao/agent/output/message"
)

// Executor executes LLM requests in sandbox
type Executor interface {
    // Execute runs the request and returns response
    Execute(ctx *context.Context, messages []context.Message, opts *Options) (*context.CompletionResponse, error)

    // Stream runs the request with streaming output
    Stream(ctx *context.Context, messages []context.Message, opts *Options, handler message.StreamFunc) (*context.CompletionResponse, error)

    // Filesystem operations (for Hooks)
    ReadFile(ctx context.Context, path string) ([]byte, error)
    WriteFile(ctx context.Context, path string, content []byte) error
    ListDir(ctx context.Context, path string) ([]os.FileInfo, error)

    // Command execution (for Hooks)
    Exec(ctx context.Context, cmd []string) (string, error)

    // Close releases container resources
    Close() error
}

// Options for sandbox execution
type Options struct {
    // Command type (claude, cursor)
    Command string `json:"command"`

    // Docker image (optional, auto-selected by agent)
    Image string `json:"image,omitempty"`

    // Resource limits
    MaxMemory string  `json:"max_memory,omitempty"`
    MaxCPU    float64 `json:"max_cpu,omitempty"`

    // Execution timeout
    Timeout time.Duration `json:"timeout,omitempty"`

    // Command-specific arguments (passed to CLI)
    Arguments map[string]interface{} `json:"arguments,omitempty"`

    // ========================================
    // Internal fields (auto-resolved by Yao)
    // Do NOT set these in package.yao config
    // ========================================

    // MCP configuration - auto-loaded from assistants/{name}/mcps/
    MCPConfig []byte `json:"-"`

    // Skills directory - auto-resolved to assistants/{name}/skills/
    SkillsDir string `json:"-"`

    // Connector settings - auto-resolved from connector config file
    // e.g., connectors/deepseek/v3.conn.yao → host, key, model
    ConnectorHost string `json:"-"`
    ConnectorKey  string `json:"-"`
    Model         string `json:"-"`
}

4.3 Executor Factory

// agent/sandbox/executor.go
package sandbox

import (
    "fmt"

    "github.com/yaoapp/yao/agent/sandbox/claude"
    "github.com/yaoapp/yao/agent/sandbox/cursor"
    "github.com/yaoapp/yao/sandbox"
)

// New creates an executor based on agent type
func New(manager *sandbox.Manager, opts *Options) (Executor, error) {
    if opts == nil {
        return nil, fmt.Errorf("options cannot be nil")
    }

    // Set default image if not specified
    if opts.Image == "" {
        opts.Image = DefaultImage(opts.Command)
    }

    switch opts.Command {
    case "claude":
        return claude.New(manager, opts)
    case "cursor":
        return cursor.New(manager, opts)
    default:
        return nil, fmt.Errorf("unknown command type: %s", opts.Command)
    }
}

// DefaultImage returns the default Docker image for a command type
func DefaultImage(command string) string {
    switch command {
    case "claude":
        return "yaoapp/sandbox-claude:latest"
    case "cursor":
        return "yaoapp/sandbox-cursor:latest"
    default:
        return ""
    }
}

4.4 Claude Agent Implementation

The Claude agent is split into multiple files for maintainability:

4.4.1 Executor (claude/executor.go)

// agent/sandbox/claude/executor.go
package claude

import (
    "fmt"

    "github.com/yaoapp/yao/agent/context"
    "github.com/yaoapp/yao/agent/output/message"
    sandbox "github.com/yaoapp/yao/agent/sandbox"
    infra "github.com/yaoapp/yao/sandbox"
)

// Executor executes requests via Claude CLI in sandbox
type Executor struct {
    manager *infra.Manager
    opts    *sandbox.Options
}

// New creates a new Claude executor
func New(manager *infra.Manager, opts *sandbox.Options) (*Executor, error) {
    return &Executor{
        manager: manager,
        opts:    opts,
    }, nil
}

// Stream executes with streaming output
func (e *Executor) Stream(
    ctx *context.Context,
    messages []context.Message,
    opts *sandbox.Options,
    handler message.StreamFunc,
) (*context.CompletionResponse, error) {

    // 1. Get or create container
    container, err := e.manager.GetOrCreate(ctx.Context(), ctx.Authorized.UserID, ctx.ChatID)
    if err != nil {
        return nil, fmt.Errorf("failed to get container: %w", err)
    }

    // 2. Prepare environment
    workDir := fmt.Sprintf("/workspace/%s/chat-%s", ctx.Authorized.UserID, ctx.ChatID)
    if err := e.prepareEnvironment(ctx, container, workDir); err != nil {
        return nil, fmt.Errorf("failed to prepare environment: %w", err)
    }

    // 3. Build CLI command
    cmd, env := BuildCommand(messages, opts, workDir)

    // 4. Execute in container with streaming
    reader, err := e.manager.Stream(ctx.Context(), container.Name, cmd, &infra.ExecOptions{
        Env:        env,
        WorkingDir: workDir,
    })
    if err != nil {
        return nil, fmt.Errorf("failed to execute: %w", err)
    }
    defer reader.Close()

    // 5. Parse streaming output
    return ParseStream(reader, handler)
}

// Execute runs without streaming (wraps Stream)
func (e *Executor) Execute(ctx *context.Context, messages []context.Message, opts *sandbox.Options) (*context.CompletionResponse, error) {
    var result *context.CompletionResponse
    _, err := e.Stream(ctx, messages, opts, func(msg *message.Message) error {
        // Collect final result
        return nil
    })
    return result, err
}

// Close releases resources
func (e *Executor) Close() error {
    return nil
}

4.4.2 Environment Setup (claude/environment.go)

// agent/sandbox/claude/environment.go
package claude

import (
    "path/filepath"

    "github.com/yaoapp/yao/agent/context"
    sandbox "github.com/yaoapp/yao/agent/sandbox"
    infra "github.com/yaoapp/yao/sandbox"
)

// prepareEnvironment sets up the container environment
func (e *Executor) prepareEnvironment(ctx *context.Context, container *infra.Container, workDir string) error {
    // Create work directory
    if err := e.manager.MkDir(ctx.Context(), container.Name, workDir); err != nil {
        return err
    }

    // Write MCP config
    if len(e.opts.MCPConfig) > 0 {
        mcpPath := filepath.Join(workDir, ".mcp.json")
        if err := e.manager.WriteFile(ctx.Context(), container.Name, mcpPath, e.opts.MCPConfig); err != nil {
            return err
        }
    }

    // Copy skills if configured
    if e.opts.SkillsDir != "" {
        claudeDir := filepath.Join(workDir, ".claude")
        if err := e.manager.MkDir(ctx.Context(), container.Name, claudeDir); err != nil {
            return err
        }
        targetSkillsDir := filepath.Join(claudeDir, "skills")
        if err := e.manager.CopyToContainer(ctx.Context(), container.Name, e.opts.SkillsDir, targetSkillsDir); err != nil {
            return err
        }
    }

    return nil
}

4.4.3 Command Builder (claude/command.go)

// agent/sandbox/claude/command.go
package claude

import (
    "fmt"
    "strconv"
    "strings"

    "github.com/yaoapp/yao/agent/context"
    sandbox "github.com/yaoapp/yao/agent/sandbox"
)

// BuildCommand constructs the Claude CLI command
func BuildCommand(messages []context.Message, opts *sandbox.Options, workDir string) ([]string, map[string]string) {
    cmd := []string{
        "claude",
        "--print",
        "--output-format", "stream-json",
    }

    // Model
    if opts.Model != "" {
        cmd = append(cmd, "--model", opts.Model)
    }

    // Agent-specific options from sandbox.options
    if opts.Arguments != nil {
        if maxTurns, ok := opts.Arguments["max_turns"].(int); ok && maxTurns > 0 {
            cmd = append(cmd, "--max-turns", strconv.Itoa(maxTurns))
        }
        if permMode, ok := opts.Arguments["permission_mode"].(string); ok && permMode != "" {
            cmd = append(cmd, "--permission-mode", permMode)
        }
    }

    // MCP config
    cmd = append(cmd, "--mcp-config", ".mcp.json")

    // System prompt (with history)
    systemPrompt := buildSystemPrompt(messages)
    if systemPrompt != "" {
        cmd = append(cmd, "--system-prompt", systemPrompt)
    }

    // User prompt (last user message)
    prompt := extractUserPrompt(messages)
    cmd = append(cmd, prompt)

    // Environment variables
    env := map[string]string{}
    if opts.ConnectorHost != "" {
        env["ANTHROPIC_BASE_URL"] = opts.ConnectorHost
    }
    if opts.ConnectorKey != "" {
        env["ANTHROPIC_API_KEY"] = opts.ConnectorKey
    }

    return cmd, env
}

// buildSystemPrompt builds system prompt with conversation history
func buildSystemPrompt(messages []context.Message) string {
    var systemParts []string
    var history []string

    for i, msg := range messages {
        if msg.Role == "system" {
            if content, ok := msg.Content.(string); ok {
                systemParts = append(systemParts, content)
            }
            continue
        }

        // Skip last user message (it becomes the prompt)
        if i == len(messages)-1 && msg.Role == "user" {
            continue
        }

        // Add to history
        if content, ok := msg.Content.(string); ok {
            history = append(history, fmt.Sprintf("[%s]: %s", msg.Role, content))
        }
    }

    if len(history) > 0 {
        systemParts = append(systemParts,
            "\n## Conversation History:\n"+strings.Join(history, "\n\n"))
    }

    return strings.Join(systemParts, "\n")
}

// extractUserPrompt gets the last user message
func extractUserPrompt(messages []context.Message) string {
    for i := len(messages) - 1; i >= 0; i-- {
        if messages[i].Role == "user" {
            if content, ok := messages[i].Content.(string); ok {
                return content
            }
        }
    }
    return ""
}

4.4.4 Types (claude/types.go)

// agent/sandbox/claude/types.go
package claude

// StreamMessage represents a Claude CLI stream-json message
type StreamMessage struct {
    Type    string `json:"type"`
    Message struct {
        Content []struct {
            Type string `json:"type"`
            Text string `json:"text"`
        } `json:"content"`
    } `json:"message,omitempty"`
    Result       string  `json:"result,omitempty"`
    TotalCostUSD float64 `json:"total_cost_usd,omitempty"`
    NumTurns     int     `json:"num_turns,omitempty"`
    DurationMs   int64   `json:"duration_ms,omitempty"`
    Usage        *struct {
        InputTokens  int `json:"input_tokens"`
        OutputTokens int `json:"output_tokens"`
    } `json:"usage,omitempty"`
}

// ToolCall represents a tool invocation from Claude CLI
type ToolCall struct {
    ID        string                 `json:"id"`
    Name      string                 `json:"name"`
    Arguments map[string]interface{} `json:"arguments"`
}

// ToolResult represents a tool execution result
type ToolResult struct {
    ID      string `json:"id"`
    Content string `json:"content"`
    IsError bool   `json:"is_error,omitempty"`
}

4.4.5 Stream Parser (claude/stream.go)

// agent/sandbox/claude/stream.go
package claude

import (
    "bufio"
    "encoding/json"
    "io"
    "strings"

    "github.com/yaoapp/yao/agent/context"
    "github.com/yaoapp/yao/agent/output/message"
)

// ParseStream parses Claude CLI stream-json output
func ParseStream(reader io.ReadCloser, handler message.StreamFunc) (*context.CompletionResponse, error) {
    resp := &context.CompletionResponse{}
    var fullContent strings.Builder

    scanner := bufio.NewScanner(reader)
    for scanner.Scan() {
        line := scanner.Text()
        if line == "" {
            continue
        }

        var msg StreamMessage
        if err := json.Unmarshal([]byte(line), &msg); err != nil {
            continue
        }

        switch msg.Type {
        case "assistant":
            // Extract text content
            for _, content := range msg.Message.Content {
                if content.Type == "text" && content.Text != "" {
                    fullContent.WriteString(content.Text)

                    // Send to stream handler
                    if handler != nil {
                        handler(&message.Message{
                            Type: "text",
                            Data: content.Text,
                        })
                    }
                }
            }

        case "result":
            // Final result
            if msg.Result != "" {
                resp.Content = msg.Result
            }
            if msg.Usage != nil {
                resp.Usage = &context.Usage{
                    PromptTokens:     msg.Usage.InputTokens,
                    CompletionTokens: msg.Usage.OutputTokens,
                    TotalTokens:      msg.Usage.InputTokens + msg.Usage.OutputTokens,
                }
            }
            resp.Extra = map[string]interface{}{
                "total_cost_usd": msg.TotalCostUSD,
                "num_turns":      msg.NumTurns,
                "duration_ms":    msg.DurationMs,
            }
        }
    }

    if err := scanner.Err(); err != nil {
        return nil, err
    }

    if resp.Content == "" {
        resp.Content = fullContent.String()
    }

    return resp, nil
}

4.5 Sandbox Lifecycle

4.5.1 Design Principles

The sandbox follows a stateless container + persistent workspace model:

Component Lifecycle Storage
Container Per-request, disposable None (stateless)
Workspace Persistent across requests {YAO_DATA_ROOT}/sandbox/workspace/{user}/chat-{chat_id}/
Message History Managed by Yao Yao's session store

This means:

  • Container can be recreated anytime without losing state
  • All files are preserved in the mounted workspace
  • Conversation history is passed in each request (not stored in container)

4.5.2 Container Lifecycle

Request Start
     │
     ▼
┌─────────────────────────────────────────────────────────────┐
│  1. Create Executor (docker run --rm)                       │
│     - Mount workspace directory                             │
│     - Set resource limits                                   │
│     - ~500ms-1s cold start                                  │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│  2. Before Hook (can access Executor)                       │
│     - Write config files                                    │
│     - Check/prepare environment                             │
│     - Can reject request (container auto-cleaned)           │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│  3. LLM Execute (Claude CLI in container)                   │
│     - Run with full message history                         │
│     - Stream output to handler                              │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│  4. After Hook (can access Executor)                        │
│     - Read generated files                                  │
│     - Execute post-commands (git commit, etc.)              │
│     - Cleanup temp files                                    │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│  5. Close Executor (defer)                                  │
│     - Container removed (--rm)                              │
│     - Workspace persists                                    │
└─────────────────────────────────────────────────────────────┘

4.5.3 Workspace Cleanup

Workspace cleanup is separate from container lifecycle:

// Global cleanup configuration
type CleanupConfig struct {
    // Workspace retention period (default: 7 days)
    WorkspaceRetention time.Duration `json:"workspace_retention"`
    
    // Run cleanup on schedule (default: daily at 3am)
    CleanupSchedule string `json:"cleanup_schedule"`
}

// Cleanup worker
func (m *Manager) cleanupStaleWorkspaces() {
    // Find workspaces older than retention period
    // Delete directories not accessed within the period
}

4.6 Hook Integration (via JSAPI)

Hooks interact with the sandbox via Context JSAPI, not Go code. The Executor methods are exposed to JavaScript through ctx.sandbox:

4.6.1 Context JSAPI Extension

// Extension to Context interface (see agent/context/JSAPI.md)
interface Context {
  // ... existing properties ...
  
  // Sandbox operations (only available when sandbox is configured)
  sandbox?: {
    // Filesystem operations
    ReadFile(path: string): string;              // Read file content
    WriteFile(path: string, content: string): void;  // Write file
    ListDir(path: string): FileInfo[];           // List directory
    
    // Command execution
    Exec(cmd: string[]): string;                 // Execute command, return output
    
    // Workspace info
    workdir: string;                             // Container workspace path
  };
}

interface FileInfo {
  name: string;
  size: number;
  is_dir: boolean;
  mod_time: string;
}

4.6.2 Create Hook Examples (JavaScript)

// assistants/coder/src/index.ts

/**
 * Create Hook - runs after container created, before LLM execution
 */
function Create(ctx, messages, options) {
  // Check if sandbox is available
  if (!ctx.sandbox) {
    return { messages };
  }

  // Send loading message to user
  const loadingId = ctx.SendStream({
    type: "loading",
    props: { message: "Preparing sandbox environment..." }
  });

  // Write project configuration
  ctx.sandbox.WriteFile(".env", "DEBUG=true\nNODE_ENV=development");
  
  // Check environment prerequisites
  try {
    const nodeVersion = ctx.sandbox.Exec(["node", "--version"]);
    log.Info(`Node.js version: ${nodeVersion}`);
  } catch (e) {
    log.Error("Node.js not available");
    ctx.End(loadingId);
    throw new Error("Node.js is required");
  }
  
  // List existing files
  const files = ctx.sandbox.ListDir(".");
  log.Debug(`Workspace files: ${files.map(f => f.name).join(", ")}`);
  
  // Update loading message and end it
  ctx.Replace(loadingId, {
    type: "text",
    props: { content: "Sandbox ready" }
  });
  ctx.End(loadingId);
  
  return { messages };
}

4.6.3 Next Hook Examples (JavaScript)

/**
 * Next Hook - runs after LLM execution, before container cleanup
 */
function Next(ctx, payload, options) {
  const { completion, error } = payload;
  
  if (error || !ctx.sandbox) {
    return null;
  }

  // Read generated files
  try {
    const result = ctx.sandbox.ReadFile("output/result.json");
    log.Info(`Generated output: ${result}`);
    ctx.memory.context.Set("generated_result", JSON.parse(result));
  } catch (e) {
    log.Debug("No result file generated");
  }
  
  // List generated source files
  const srcFiles = ctx.sandbox.ListDir("src/");
  for (const file of srcFiles) {
    log.Debug(`Generated: ${file.name} (${file.size} bytes)`);
  }
  
  // Execute git commit if files changed
  try {
    ctx.sandbox.Exec(["git", "add", "."]);
    ctx.sandbox.Exec(["git", "commit", "-m", "auto-commit by sandbox"]);
    log.Info("Changes committed");
  } catch (e) {
    log.Debug(`git commit skipped: ${e.message}`);
  }
  
  // Cleanup temporary files
  try {
    ctx.sandbox.Exec(["rm", "-rf", "tmp/"]);
  } catch (e) {
    // Ignore cleanup errors
  }
  
  return null;  // Use default response
}

4.6.4 Go Implementation (Internal)

The Go layer only handles exposing the Executor to JSAPI:

// In agent/context/sandbox.go - expose Executor to JS runtime

// SandboxJSAPI wraps Executor for JavaScript access
type SandboxJSAPI struct {
    executor sandbox.Executor
    workdir  string
}

// Methods are called from JavaScript via v8go bindings
func (s *SandboxJSAPI) ReadFile(path string) (string, error) {
    content, err := s.executor.ReadFile(context.Background(), path)
    return string(content), err
}

func (s *SandboxJSAPI) WriteFile(path string, content string) error {
    return s.executor.WriteFile(context.Background(), path, []byte(content))
}

func (s *SandboxJSAPI) ListDir(path string) ([]FileInfo, error) {
    return s.executor.ListDir(context.Background(), path)
}

func (s *SandboxJSAPI) Exec(cmd []string) (string, error) {
    return s.executor.Exec(context.Background(), cmd)
}

4.7 Integration with Assistant

The sandbox integration is separated into two files:

  • agent/assistant/sandbox.go - Sandbox handler (new file, contains all sandbox logic)
  • agent/assistant/llm.go - Add sandbox detection (minimal change)

4.7.1 Sandbox Detection (agent/assistant/llm.go)

// In agent/assistant/llm.go - add sandbox detection

func (ast *Assistant) executeLLMStream(...) (*context.CompletionResponse, error) {
    // Check if sandbox is configured
    if ast.Sandbox != nil && ast.Sandbox.Command != "" {
        return ast.executeSandboxStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
    }

    // Default: direct LLM API call
    // ... existing code ...
}

4.7.2 Sandbox Handler (agent/assistant/sandbox.go)

// In agent/assistant/sandbox.go - sandbox execution logic

func (ast *Assistant) executeSandboxStream(...) (*context.CompletionResponse, error) {
    // Get sandbox manager (singleton)
    manager := sandbox.GetManager()
    if manager == nil {
        return nil, fmt.Errorf("sandbox manager not initialized")
    }

    // Build executor options
    execOpts := &agentsandbox.Options{
        Command:      ast.Sandbox.Command,
        Image:        ast.Sandbox.Image,
        MaxMemory:    ast.Sandbox.MaxMemory,
        MaxCPU:       ast.Sandbox.MaxCPU,
        Timeout:      ast.Sandbox.Timeout,
        Arguments:    ast.Sandbox.Arguments,
        SkillsDir:    filepath.Join(ast.Path, "skills"),
    }

    // Get connector settings (resolved from connector config)
    conn, _, err := ast.GetConnector(ctx, opts)
    if err != nil {
        return nil, err
    }
    setting := conn.Setting()
    if host, ok := setting["host"].(string); ok {
        execOpts.ConnectorHost = host
    }
    if key, ok := setting["key"].(string); ok {
        execOpts.ConnectorKey = key
    }
    if model, ok := setting["model"].(string); ok {
        execOpts.Model = model
    }

    // Build MCP config
    execOpts.MCPConfig = ast.buildMCPConfig(ctx)

    // 1. Create executor (container starts here)
    ctx.Trace().Info("Creating sandbox container...")
    executor, err := agentsandbox.New(manager, execOpts)
    if err != nil {
        ctx.Trace().Error(fmt.Sprintf("Sandbox creation failed: %v", err))
        return nil, err
    }
    ctx.Trace().Info("Sandbox container ready")
    defer executor.Close()  // 5. Container cleanup on exit

    // 2. Before Hook - can access executor
    if err := ast.runBeforeHook(ctx, executor); err != nil {
        return nil, err  // Container cleaned by defer
    }

    // 3. LLM Execute
    resp, err := executor.Stream(ctx, completionMessages, execOpts, streamHandler)
    if err != nil {
        return nil, err
    }

    // 4. After Hook - can access executor
    if err := ast.runAfterHook(ctx, executor, resp); err != nil {
        log.Printf("after hook error: %v", err)  // Log but don't fail
    }

    return resp, nil
}

5. IPC Communication (Yao ↔ Sandbox)

The sandbox can call Yao processes via MCP:

┌─────────────────────────────────────────────────────────────────┐
│  Sandbox Container                                              │
│  ┌─────────────────┐    ┌──────────────────┐                   │
│  │   Claude CLI    │───▶│    yao-bridge    │                   │
│  │                 │    │                  │                   │
│  │  MCP: tools/call│    │  Unix Socket     │                   │
│  │  "yao.process"  │    │  /tmp/yao.sock   │                   │
│  └─────────────────┘    └────────┬─────────┘                   │
└──────────────────────────────────┼─────────────────────────────┘
                                   │ JSON-RPC
                                   ▼
┌──────────────────────────────────────────────────────────────────┐
│  Yao Host                                                        │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                    IPC Manager                              │ │
│  │                                                             │ │
│  │  Session: user123-chat456                                   │ │
│  │  Socket: /data/sandbox/ipc/user123-chat456.sock             │ │
│  │                                                             │ │
│  │  Handlers:                                                  │ │
│  │    tools/list  → Return authorized tool list                │ │
│  │    tools/call  → Execute Yao Process                        │ │
│  └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘

6. Skills Support

Skills follow Agent Skills standard:

---
name: code-review
description: "Review code for security, performance, and best practices"
allowed-tools:
  - Read
  - Grep
  - Glob
---

# Code Review

When reviewing code:

## Security
- Check for SQL injection, XSS, CSRF
- Verify input validation

## Performance
- Look for N+1 queries
- Check for unnecessary re-renders

Skills are auto-copied to container at {workDir}/.claude/skills/.

7. Docker Images

Available Images

Image Contents Size
yaoapp/sandbox-base Ubuntu 24.04, git, vim, network tools, yao-bridge ~200MB
yaoapp/sandbox-claude base + Node.js 22, Python 3.12, Claude CLI, CCR ~900MB
yaoapp/sandbox-claude-full claude + Go 1.23 ~1.2GB

Included Tools

  • Editors: vim, less, tree
  • Network: ping, netstat, nslookup, nc, telnet
  • Compression: zip, unzip, tar, gzip
  • System: htop, ps, sed, awk, grep
  • Development: git, Node.js, Python, Claude CLI, CCR

8. Implementation Status

See PLAN.md for detailed implementation tasks and testing requirements.

Completed (sandbox package)

  • Container management (sandbox/manager.go)
  • Configuration (sandbox/config.go)
  • IPC communication (sandbox/ipc/)
  • yao-bridge binary (sandbox/bridge/)
  • Docker images (sandbox/docker/)

To Implement (agent/sandbox package)

  • Types and interfaces
  • Executor factory
  • Claude executor (claude/)
  • Context JSAPI bindings
  • Assistant integration
  • Workspace cleanup

9. Testing

See PLAN.md for complete testing strategy.

Quick test:

# Test Docker image
docker run --rm yaoapp/sandbox-claude:latest bash -c "
  node --version && python3 --version && claude --version
"

# Run integration tests
source /Users/max/Yao/yao/env.local.sh
go test -v ./agent/sandbox/...

10. Usage Example

# Test script at yao-dev-app/agents/claude/run.sh
./run.sh "Hello, what is 1+1?"                    # Default: deepseek/v3
./run.sh -c deepseek/r1 "Hello"                   # Use R1 model
./run.sh -c claude/sonnet-4_0 "Hello"             # Use Claude