yao/sandbox/SPEC.md
Max 6e68efaba3 Implement gRPC support in the Yao SDK
- Add gRPC server configuration to the application, allowing for gRPC communication.
- Introduce new Makefile targets for gRPC unit testing and proto code generation.
- Update CI workflows to include gRPC tests with SQLite as the transport layer.
- Refactor the sandbox design to support multi-node capabilities and improve isolation.
- Enhance the service layer to facilitate internal request forwarding for gRPC APIs.

This commit lays the groundwork for integrating gRPC into the Yao SDK, improving performance and scalability.
2026-03-04 13:17:48 +08:00

17 KiB

Sandbox Functional Specification

Detailed interfaces, types, and behavior for the sandbox refactoring. Architecture and rationale: see DESIGN.md.


1. sandbox.Manager

Replaces the current Docker-only Manager. Backed by a single tai.Client.

Config

type Config struct {
    Image            string        // container image, default "yaoapp/workspace:latest"
    MaxContainers    int           // global limit, default 100
    IdleTimeout      time.Duration // default cleanup interval, default 30m
    MaxMemory        string        // per-container, e.g. "2g"
    MaxCPU           float64       // per-container, e.g. 1.0
    ContainerWorkDir string        // mount target inside container, default "/workspace"
    ContainerUser    string        // empty = image default
}

Environment variable overrides remain the same (YAO_SANDBOX_IMAGE, etc.). WorkspaceRoot and IPCDir are removed — local paths derived from tai.Client.IsLocal() at runtime; remote mode uses tai.Client.Volume().

Constructor

func NewManager(client *tai.Client, cfg *Config) (*Manager, error)
  • Validates client is non-nil and healthy (calls client.Sandbox().List() as connectivity check)
  • Starts background cleanup goroutine
  • Returns ready Manager

Manager struct

type Manager struct {
    client     *tai.Client
    config     *Config
    sandboxes  sync.Map       // name → *Sandbox
    running    atomic.Int32
    ipc        *IPCRouter     // local: Unix socket manager, remote: gRPC stub
    cleanup    *time.Ticker
    done       chan struct{}
}

Public methods

// Lifecycle
func (m *Manager) GetOrCreate(ctx context.Context, opts GetOrCreateOptions) (*Sandbox, error)
func (m *Manager) Get(ctx context.Context, name string) (*Sandbox, error)
func (m *Manager) Start(ctx context.Context, name string) error
func (m *Manager) Stop(ctx context.Context, name string, timeout time.Duration) error
func (m *Manager) Remove(ctx context.Context, name string) error
func (m *Manager) List(ctx context.Context, filter ListFilter) ([]*Sandbox, error)

// Execution
func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts ExecOptions) (*ExecResult, error)
func (m *Manager) Stream(ctx context.Context, name string, cmd []string, opts ExecOptions) (io.ReadCloser, error)
func (m *Manager) KillProcess(ctx context.Context, name string, pattern string) error

// File operations (routes local/remote internally)
func (m *Manager) ReadFile(ctx context.Context, name string, path string) ([]byte, error)
func (m *Manager) WriteFile(ctx context.Context, name string, path string, data []byte) error
func (m *Manager) ListDir(ctx context.Context, name string, path string) ([]FileInfo, error)
func (m *Manager) Stat(ctx context.Context, name string, path string) (*FileInfo, error)
func (m *Manager) MkDir(ctx context.Context, name string, path string) error
func (m *Manager) RemoveFile(ctx context.Context, name string, path string) error
func (m *Manager) CopyToContainer(ctx context.Context, name string, hostPath, containerPath string) error
func (m *Manager) CopyFromContainer(ctx context.Context, name string, containerPath, hostPath string) error

// Info
func (m *Manager) IsLocal() bool
func (m *Manager) Close() error

2. Types

Sandbox

type Sandbox struct {
    Name       string
    UserID     string
    ChatID     string
    Image      string
    Status     Status
    Lifecycle  Lifecycle
    CreatedAt  time.Time
    LastUsedAt time.Time
    IP         string
}

Status

type Status string

const (
    StatusCreated Status = "created"
    StatusRunning Status = "running"
    StatusStopped Status = "stopped"
)

Lifecycle

type Lifecycle string

const (
    LifecycleOneShot     Lifecycle = "one-shot"     // destroyed after execution
    LifecycleSession     Lifecycle = "session"       // alive while user active, idle timeout
    LifecycleLongRunning Lifecycle = "long-running"  // hours/days, recoverable
    LifecyclePersistent  Lifecycle = "persistent"    // never auto-cleaned
)

GetOrCreateOptions

type GetOrCreateOptions struct {
    UserID    string
    ChatID    string
    Image     string            // override Config.Image
    Lifecycle Lifecycle         // default: LifecycleSession
    Env       map[string]string // injected into container
    Cmd       []string          // override entrypoint
    Memory    string            // override Config.MaxMemory
    CPU       float64           // override Config.MaxCPU
}

ExecOptions / ExecResult

type ExecOptions struct {
    WorkDir string
    Env     map[string]string
    Stdin   io.Reader
    Timeout time.Duration
}

type ExecResult struct {
    ExitCode int
    Stdout   string
    Stderr   string
}

ListFilter

type ListFilter struct {
    UserID    string    // empty = all users
    Status    Status    // empty = all statuses
    Lifecycle Lifecycle // empty = all policies
}

FileInfo

type FileInfo struct {
    Name    string
    Path    string
    Size    int64
    Mode    os.FileMode
    ModTime time.Time
    IsDir   bool
}

Errors

var (
    ErrTooManyContainers  = errors.New("sandbox: container limit reached")
    ErrNotFound           = errors.New("sandbox: not found")
    ErrNotRunning         = errors.New("sandbox: not running")
    ErrAlreadyExists      = errors.New("sandbox: already exists")
)

3. Lifecycle State Machine

                    GetOrCreate()
                         │
                         ▼
                    ┌─────────┐
          ┌────────│ Created  │
          │        └────┬────┘
          │   Start()   │
          │             ▼
          │        ┌─────────┐    idle timeout / Stop()
          │        │ Running  │──────────────────┐
          │        └────┬────┘                   │
          │             │                        ▼
          │             │                   ┌─────────┐
          │             │                   │ Stopped  │
          │             │                   └────┬────┘
          │             │            Start()     │
          │             │    ┌───────────────────┘
          │             │    │
          │             ▼    ▼
          │        Remove() from any state
          │             │
          │             ▼
          │        [Destroyed]
          │
          └── one-shot: auto Remove() after Exec/Stream returns

Cleanup rules

Lifecycle Trigger Action
one-shot Exec/Stream completes Manager.Remove() immediately
session IdleTimeout since LastUsedAt Manager.Stop() then Remove()
long-running IdleTimeout * 24 since LastUsedAt Manager.Stop() (not removed, can restart)
persistent Never No automatic action

Background goroutine runs every Config.IdleTimeout / 2, scans sandboxes, applies rules.

Touch

Every Exec, Stream, ReadFile, WriteFile, ListDir call updates LastUsedAt.


4. File Operations Routing

func (m *Manager) ReadFile(ctx context.Context, name string, path string) ([]byte, error) {
    if m.client.IsLocal() {
        hostPath := m.hostPath(name, path)
        return os.ReadFile(hostPath)
    }
    sessionID := m.sessionID(name)
    ws := m.client.Workspace(sessionID)
    f, err := ws.Open(path)
    // ... read and return
}
Operation Local Remote
ReadFile os.ReadFile(hostPath) client.Workspace(session).Open(path)
WriteFile os.WriteFile(hostPath) client.Volume().Write(session, path, data)
ListDir os.ReadDir(hostPath) client.Volume().ReadDir(session, path)
Stat os.Stat(hostPath) client.Volume().Stat(session, path)
MkDir os.MkdirAll(hostPath) client.Volume().MkDir(session, path)
RemoveFile os.RemoveAll(hostPath) client.Volume().Remove(session, path)
CopyToContainer bind mount (noop, already on host) client.Volume().Write() streamed
CopyFromContainer bind mount (direct read) client.Volume().Read() streamed

hostPath = dataDir/{userID}/{chatID}/{containerRelativePath}

sessionID = {userID}/{chatID} (maps to volume session on Tai)


5. IPC Router

Abstracts local Unix socket vs remote gRPC relay. Manager creates the right one based on client.IsLocal().

Interface

type IPCRouter interface {
    Create(sessionID string, tools []MCPTool) (IPCSession, error)
    Get(sessionID string) (IPCSession, error)
    Close(sessionID string) error
    CloseAll() error
}

type IPCSession interface {
    SetTools(tools []MCPTool)
    SetContext(ctx *AgentContext)
    SocketPath() string   // local only, empty for remote
    GRPCAddr() string     // remote only, empty for local
    Close() error
}

Local implementation

Same as current ipc.Manager — creates Unix socket per session, bind-mounts into container, yao-bridge connects to it.

Remote implementation

No socket. Container receives YAO_IPC_MODE=grpc and YAO_IPC_ADDR=tai-host:9100. yao-bridge (yao/tai/bridge/) connects to Tai's gRPC relay, which forwards to Yao gRPC Server. Tai relay upstream is per-container via CreateRequest.GRPCUpstream, not a Tai startup parameter.

Tool registration: remote IPCSession sends tool list to Yao gRPC Server via a registration RPC at session creation.

Container env injection

func (m *Manager) buildContainerEnv(session IPCSession, userEnv map[string]string) map[string]string {
    env := maps.Clone(userEnv)
    if m.client.IsLocal() {
        env["YAO_IPC_MODE"] = "socket"
        env["YAO_IPC_ADDR"] = session.SocketPath()
    } else {
        env["YAO_IPC_MODE"] = "grpc"
        env["YAO_IPC_ADDR"] = session.GRPCAddr()
        env["YAO_TOKEN"] = m.issueAccessToken(session)
        env["YAO_REFRESH_TOKEN"] = m.issueRefreshToken(session)
    }
    return env
}

// CreateRequest also carries GRPCUpstream for Tai relay routing (per-container, not per-Tai)


6. Yao gRPC Server

Proto definition

syntax = "proto3";
package yao.v1;

service Yao {
    rpc Exec(ExecRequest) returns (ExecResponse);
    rpc StreamExec(ExecRequest) returns (stream ExecChunk);

    // MCP tool registration (called by remote IPC sessions)
    rpc RegisterTools(RegisterToolsRequest) returns (RegisterToolsResponse);

    // Health
    rpc Healthz(HealthzRequest) returns (HealthzResponse);
}

message ExecRequest {
    string process = 1;     // e.g. "models.user.Find"
    bytes  args    = 2;     // JSON-encoded arguments
    string session = 3;     // sandbox session ID for context
}

message ExecResponse {
    bytes  result = 1;      // JSON-encoded result
    string error  = 2;
}

message ExecChunk {
    bytes data = 1;
    bool  done = 2;
}

message RegisterToolsRequest {
    string session = 1;
    repeated MCPToolDef tools = 2;
}

message MCPToolDef {
    string name        = 1;
    string description = 2;
    string process     = 3;    // Yao process to call
    bytes  input_schema = 4;   // JSON Schema
}

message RegisterToolsResponse {}

message HealthzRequest {}
message HealthzResponse {
    string status = 1;
}

Server startup

func StartGRPCServer(cfg GRPCConfig) (*grpc.Server, error)

type GRPCConfig struct {
    Listen    string   // "127.0.0.1:9099" or "0.0.0.0:9099"
    AllowCIDR []string // IP allowlist, empty = no restriction
}

Interceptor chain: ipAllowInterceptorauthInterceptor → handler.

Exec handler

func (s *yaoServer) Exec(ctx context.Context, req *pb.ExecRequest) (*pb.ExecResponse, error) {
    claims := claimsFromContext(ctx)
    // ACL check: does this token have permission to call this process?
    
    p := process.New(req.Process)
    var args []interface{}
    json.Unmarshal(req.Args, &args)
    
    result, err := p.Exec(args...)
    if err != nil {
        return &pb.ExecResponse{Error: err.Error()}, nil
    }
    
    data, _ := json.Marshal(result)
    return &pb.ExecResponse{Result: data}, nil
}

7. Agent Layer

Assistant sandbox config

# assistants/coder.yao
sandbox:
  enabled: true
  lifecycle: session
  idle_timeout: 30m
  image: yaoapp/workspace:latest
  command: claude
  memory: "4g"
  cpu: 2.0

Parsed config type

type AssistantSandboxConfig struct {
    Enabled     bool          `json:"enabled"`
    Lifecycle   Lifecycle     `json:"lifecycle"`
    IdleTimeout time.Duration `json:"idle_timeout"`
    Image       string        `json:"image"`
    Command     string        `json:"command"`
    Memory      string        `json:"memory"`
    CPU         float64       `json:"cpu"`
}

Init flow (new)

func (a *Assistant) initSandbox(ctx context.Context) (*agentsandbox.Executor, error) {
    mgr := GetSandboxManager()  // global, initialized with tai.Client at Yao startup
    
    sb, err := mgr.GetOrCreate(ctx, sandbox.GetOrCreateOptions{
        UserID:    a.userID,
        ChatID:    a.chatID,
        Image:     a.config.Sandbox.Image,
        Lifecycle: a.config.Sandbox.Lifecycle,
        Memory:    a.config.Sandbox.Memory,
        CPU:       a.config.Sandbox.CPU,
    })
    // ...
    executor := agentsandbox.New(mgr, sb, a.config.Sandbox.Command)
    return executor, nil
}

Cleanup (new)

func (a *Assistant) sandboxCleanup(executor *agentsandbox.Executor) {
    executor.Disconnect()
    // Manager handles actual removal based on lifecycle policy.
    // one-shot: already removed after Exec.
    // session: will be cleaned up by background goroutine after idle timeout.
    // long-running/persistent: stays.
}

GetSandboxManager (new)

var (
    managerOnce sync.Once
    manager     *sandbox.Manager
)

func GetSandboxManager() *sandbox.Manager {
    managerOnce.Do(func() {
        client := config.GetTaiClient()  // initialized at Yao startup from env/config
        mgr, err := sandbox.NewManager(client, loadSandboxConfig())
        if err != nil {
            log.Fatal("sandbox manager init: %v", err)
        }
        manager = mgr
    })
    return manager
}

Executor factory

// agent/sandbox/executor.go
func New(mgr *sandbox.Manager, sb *sandbox.Sandbox, command string) Executor {
    switch command {
    case "claude":
        return claude.NewExecutor(mgr, sb)
    default:
        return generic.NewExecutor(mgr, sb)
    }
}

Executor interface (unchanged)

type Executor interface {
    Stream(ctx context.Context, opts StreamOptions) (io.ReadCloser, error)
    Disconnect() error

    // Delegated to Manager internally
    ReadFile(ctx context.Context, path string) ([]byte, error)
    WriteFile(ctx context.Context, path string, data []byte) error
    ListDir(ctx context.Context, path string) ([]FileInfo, error)
    Exec(ctx context.Context, cmd []string) (string, error)
    GetWorkDir() string
    GetSandboxID() string
    GetVNCUrl() string
}

Each method delegates to mgr.ReadFile(ctx, sb.Name, path) etc. The executor is a thin wrapper that knows the sandbox name.


8. Naming Convention

Entity Pattern Example
Container/Pod name yao-sb-{userID}-{chatID} yao-sb-u123-c456
Volume session {userID}/{chatID} u123/c456
IPC session {chatID} c456
Host workspace (local) {dataDir}/{userID}/{chatID}/ /data/u123/c456/

Prefix shortened from yao-sandbox- to yao-sb- for K8s DNS name length limit (63 chars).


9. Environment Variables

Yao process

Variable Purpose Default
YAO_TAI_ADDR Tai endpoint, e.g. tai://10.0.0.1 or empty for local Docker "" (local)
YAO_TAI_RUNTIME docker or k8s docker
YAO_TAI_KUBECONFIG Path to kubeconfig (K8s only)
YAO_TAI_NAMESPACE K8s namespace default
YAO_GRPC_LISTEN gRPC server listen address 127.0.0.1:9099
YAO_GRPC_ALLOW CIDR allowlist, comma-separated (empty = no filter)
YAO_SANDBOX_IMAGE Default container image yaoapp/workspace:latest
YAO_SANDBOX_MAX Max containers 100
YAO_SANDBOX_IDLE_TIMEOUT Idle timeout duration 30m
YAO_SANDBOX_MEMORY Memory limit 2g
YAO_SANDBOX_CPU CPU limit 1.0

Container-internal

Variable Purpose Set by
YAO_IPC_MODE socket or grpc Manager at creation
YAO_IPC_ADDR Socket path or gRPC host:port Manager at creation
YAO_TOKEN JWT access token for gRPC auth (remote only, short TTL 15m) Manager at creation
YAO_REFRESH_TOKEN JWT refresh token (remote only, no expiry, revoked on Remove) Manager at creation