- 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.
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
clientis non-nil and healthy (callsclient.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: ipAllowInterceptor → authInterceptor → 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 |