- 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.
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:
- Discovery: At startup, agent loads only
nameanddescriptionof each skill - Activation: When task matches a skill's description, agent reads full
SKILL.md - 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