From dd0888102784298116f3e6d968c23b96ac885783 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 5 Mar 2026 21:28:55 +0800 Subject: [PATCH] Update DESIGN.md and IMPL.md for Sandbox V2 enhancements - Add workspace integration as a new feature in Sandbox V2, allowing for persistent user storage. - Update the Manager API to include new methods for workspace management and image handling. - Refactor the Pool struct to include additional options and improve lifecycle management. - Revise implementation status in IMPL.md to reflect completed phases and optimizations, ensuring clarity on the current state of the project. These changes enhance the functionality and documentation of Sandbox V2, improving user experience and system architecture. --- sandbox/v2/DESIGN.md | 1272 ++++++++++++++++-------------------------- sandbox/v2/IMPL.md | 897 ++++++++--------------------- 2 files changed, 731 insertions(+), 1438 deletions(-) diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index accf1461..15abb2f5 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -10,6 +10,7 @@ Yao Infrastructure ├── store — KV storage ├── fs — host filesystem ├── stream — streaming execution (planned) +├── workspace — persistent user storage ← new in V2 └── sandbox — isolated execution environments ← this module ``` @@ -31,14 +32,18 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. │ sandbox/v2 │ │ │ │ Manager (global singleton) │ -│ ├── Create / Get / Start / Stop / Remove │ -│ ├── List / Cleanup / Close │ +│ ├── Create / Get / GetOrCreate / List / Remove │ +│ ├── Start / Cleanup / Close │ +│ ├── Heartbeat (idle tracking) │ +│ ├── AddPool / RemovePool / Pools │ +│ ├── SetWorkspaceManager (workspace integration)│ +│ ├── EnsureImage / ImageExists / PullImage │ │ └── guard rails (limits, TTL) + Box factory │ │ │ │ Box (per-instance) │ │ ├── Exec(cmd) → ExecResult │ │ ├── Stream(cmd) → ExecStream (real-time I/O) │ -│ ├── Attach(port) → ServiceConn (WS/SSE/TCP) │ +│ ├── Attach(port) → ServiceConn (WS/SSE) │ │ ├── Workspace() → workspace.FS │ │ ├── VNC() → url │ │ ├── Proxy(port) → url │ @@ -50,11 +55,12 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. │ tai.Client pool (lazy-initialized) │ │ ├── "local" → tai.New("local") (Docker) │ │ ├── "gpu" → tai.New("tai://gpu") (Remote) │ -│ ├── "k8s" → tai.New("tai://k8s") (K8s) │ +│ ├── "k8s" → tai.New("tai://k8s",K8s)(K8s) │ │ └── ... │ │ │ │ Each tai.Client provides: │ │ ├── Sandbox() → CRUD + Exec + ExecStream │ +│ ├── Image() → Exists + Pull + Remove + List│ │ ├── Volume() → file I/O (local disk / gRPC) │ │ ├── Workspace() → fs.FS │ │ ├── Proxy() → URL resolve + Connect │ @@ -66,6 +72,7 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. ``` sandbox/v2 → tai ✓ (sole runtime dependency) +sandbox/v2 → workspace ✓ (workspace integration, optional) sandbox/v2 → agent ✗ NEVER sandbox/v2 → docker ✗ NEVER (tai handles it) agent → sandbox/v2 ✓ (consumer, via Manager API) @@ -80,19 +87,15 @@ Global singleton. Manages a **pool of named `tai.Client` connections** — each ### Pool ```go -// Pool defines a named tai.Client endpoint with its own policy. type Pool struct { - Name string // unique name, e.g. "local", "gpu", "k8s-prod" - Addr string // tai.New() address: "local", "tai://host", "docker:///path" - Options []tai.Option // tai.WithPorts(), tai.WithKubeConfig(), etc. - - // Guard rails (per-pool) + Name string + Addr string // tai.New() address: "local", "tai://host", "docker:///path" + Options []tai.Option // tai.K8s, tai.WithKubeConfig(), tai.WithPorts(), etc. MaxPerUser int // max boxes per user on this pool, 0 = unlimited MaxTotal int // max boxes total on this pool, 0 = unlimited - - // Default lifecycle (overridable per-box via CreateOptions) IdleTimeout time.Duration // 0 = no timeout MaxLifetime time.Duration // 0 = no limit + StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout (2s) } ``` @@ -107,9 +110,9 @@ pool: - name: gpu addr: "tai://gpu-server.internal" - max_per_user: 1 # GPU is expensive, 1 per user + max_per_user: 1 max_total: 4 - idle_timeout: 10m # reclaim fast + idle_timeout: 10m max_lifetime: 2h - name: k8s @@ -124,18 +127,10 @@ pool: ### Initialization ```go -package sandbox - var mgr *Manager -// Init initializes the global Manager. -// Config contains everything: pool definitions + guard rails. -// At least one Pool entry is required. The first entry is the default. -// Pass empty Pool list to disable sandbox (methods return ErrNotAvailable). -func Init(cfg Config) error - -// M returns the global Manager. Panics if Init was not called. -func M() *Manager +func Init(cfg Config) error // create Manager from Config; at least one Pool required +func M() *Manager // return global singleton; panics if Init not called ``` Startup sequence in `cmd/start.go`: @@ -150,114 +145,99 @@ grpc.Start // gRPC sandbox.M().Start(ctx) // discover existing containers, start cleanup loop ``` -`Init` creates the Manager from config (pool definitions + guard rails). `Start` connects to pools, discovers existing containers, and starts the cleanup loop. Two-step so that gRPC server is ready before Start (containers may send heartbeats immediately). +`Init` creates the Manager from config (pool definitions + guard rails). `Start` connects to pools, discovers existing containers, and starts the cleanup loop. Two-step so that gRPC server is ready before Start. Pool connections are created lazily on first use and reused across all Box instances. ### Config -Pool definitions only. Guard rails and lifecycle defaults are per-pool. Per-instance settings (image, memory, workdir, etc.) are in `CreateOptions`. - ```go type Config struct { - Pool []Pool // runtime endpoints; first is default + Pool []Pool } ``` -Container gRPC env vars (`YAO_GRPC_ADDR`, `YAO_GRPC_UPSTREAM`, etc.) are derived automatically at creation time — local address from Yao's gRPC config (`config.Conf.GRPC`), remote relay from pool's tai address. No manual configuration needed. - -Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions` by the caller — assistant config, JSAPI parameters, or Process arguments. The Manager doesn't impose defaults for container specs; that's the caller's responsibility. +Container gRPC env vars (`YAO_GRPC_ADDR`, `YAO_GRPC_UPSTREAM`, etc.) are derived automatically at creation time. Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions`. ### Core API ```go type Manager struct { - pool map[string]*tai.Client // name → connection (lazy-initialized) - poolDefs []Pool // pool definitions - defaultPool string // first pool name - config Config - boxes sync.Map // id → *Box - mu sync.Mutex // creation serialization + pool map[string]*tai.Client // name → connection (lazy-initialized) + poolDefs []Pool + defaultPool string // first pool name + config Config + boxes sync.Map // id → *Box + mu sync.Mutex + cancel context.CancelFunc + grpcPort int + wsManager *workspace.Manager // optional workspace integration } // --- Bootstrap --- - -// Start discovers existing containers from all pools, rebuilds the boxes map, -// and starts the cleanup loop. Called once after Init. func (m *Manager) Start(ctx context.Context) error +func (m *Manager) Close() error +func (m *Manager) SetGRPCPort(port int) +func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) // --- Pool management --- - -// AddPool registers a new pool at runtime. Connects lazily on first use. func (m *Manager) AddPool(ctx context.Context, p Pool) error - -// RemovePool removes a pool by name. Fails if any running boxes are on it. -// Use force=true to stop all boxes on the pool first, then remove. func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error - -// Pools returns all registered pool names and their status (connected/disconnected). func (m *Manager) Pools() []PoolInfo -// --- Heartbeat (called by gRPC handler, not by consumers) --- - -// Heartbeat updates the box's last heartbeat timestamp. -// Called by the gRPC Heartbeat handler when a container reports in. -// Returns ErrNotFound if sandbox_id is unknown (container orphaned or already removed). +// --- Heartbeat --- func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) error // --- CRUD --- - -// Create creates and starts a new sandbox. func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) - -// Get returns an existing sandbox by ID. Returns ErrNotFound if not exists. func (m *Manager) Get(ctx context.Context, id string) (*Box, error) - -// GetOrCreate returns existing sandbox or creates a new one. func (m *Manager) GetOrCreate(ctx context.Context, opts CreateOptions) (*Box, error) - -// List returns all sandboxes, optionally filtered. func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Box, error) - -// Remove stops and removes a sandbox. func (m *Manager) Remove(ctx context.Context, id string) error - -// Cleanup removes idle/expired sandboxes. Called periodically. func (m *Manager) Cleanup(ctx context.Context) error -// Close stops the cleanup loop and releases all pool connections. -func (m *Manager) Close() error +// --- Image management --- +func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) +func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan PullProgress, error) +func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error ``` ### CreateOptions -All per-instance settings live here. Caller decides everything about the container. - ```go type CreateOptions struct { - // Identity - ID string // explicit ID; empty = auto-generate - Owner string // user ID for isolation and limits - Labels map[string]string - - // Runtime target - Pool string // which tai.Client to use; empty = default pool + ID string + Owner string + Labels map[string]string + Pool string // which tai.Client to use; empty = default pool // Container spec - Image string // required - WorkDir string // container working directory, default "/workspace" - User string // container user - Env map[string]string // additional env vars - Memory int64 // bytes, 0 = no limit - CPUs float64 // 0 = no limit - VNC bool // enable VNC - Ports []PortMapping // extra port mappings + Image string // required + WorkDir string // default "/workspace" + User string + Env map[string]string + Memory int64 // bytes, 0 = no limit + CPUs float64 // 0 = no limit + VNC bool + Ports []PortMapping // Lifecycle - Policy LifecyclePolicy // default: Session - IdleTimeout time.Duration // override Manager default; 0 = use Manager default -} + Policy LifecyclePolicy // default: Session + IdleTimeout time.Duration // override pool default; 0 = use pool default + StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout + // Workspace integration + WorkspaceID string // workspace to mount; empty = no workspace + MountMode string // "rw" (default) or "ro" + MountPath string // container path; default "/workspace" +} +``` + +When `WorkspaceID` is set, the Manager resolves the workspace's bound node via `workspace.Manager.NodeForWorkspace()` and forces the container onto that node. The workspace directory is bind-mounted into the container at `MountPath`. + +### LifecyclePolicy + +```go type LifecyclePolicy string const ( @@ -266,6 +246,8 @@ const ( LongRunning LifecyclePolicy = "longrunning" // user workspace, extended TTL Persistent LifecyclePolicy = "persistent" // never auto-cleaned ) + +const DefaultStopTimeout = 2 * time.Second ``` ## Box @@ -276,71 +258,51 @@ A `Box` is a single sandbox instance. All operations go through it. type Box struct { id string containerID string - pool string // which tai.Client this box runs on + pool string owner string policy LifecyclePolicy labels map[string]string - lastCall atomic.Int64 // last external API call (Exec/Workspace/VNC/Proxy) - lastHeartbeat atomic.Int64 // last container heartbeat - processCount atomic.Int32 // user processes inside container (from heartbeat) - ws workspace.FS // lazy-initialized, cached + lastCall atomic.Int64 // last external API call + lastHeartbeat atomic.Int64 // last container heartbeat + processCount atomic.Int32 // user processes inside container + idleTimeoutD time.Duration + stopTimeoutD time.Duration + createdAt time.Time + refreshToken string + vnc bool + image string + workspaceID string + ws workspace.FS // lazy-initialized, cached manager *Manager } -// lastActiveTime returns max(lastCall, lastHeartbeat). -func (b *Box) lastActiveTime() time.Time - -// ID returns the sandbox identifier. +// --- Identity --- func (b *Box) ID() string - -// Owner returns the user who owns this sandbox. func (b *Box) Owner() string - -// ContainerID returns the underlying container ID. func (b *Box) ContainerID() string +func (b *Box) Pool() string +func (b *Box) WorkspaceID() string // --- Execution --- - -// Exec runs a command and waits for it to finish. func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) - -// Stream runs a command with real-time streaming I/O. func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) - -// Attach connects to a service running inside the sandbox on the given container port. func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*ServiceConn, error) -// --- Filesystem (fs.FS compatible) --- - -// Workspace returns an fs.FS-compatible filesystem for this sandbox. -// Supports: Open, Stat, ReadFile, ReadDir, WriteFile, Remove, Rename, MkdirAll. -// Internally calls tai.Client.Workspace(box.id) — uses sandbox ID as volume session. +// --- Filesystem --- func (b *Box) Workspace() workspace.FS // --- Network --- - -// VNC returns the VNC WebSocket URL. Error if VNC not enabled. func (b *Box) VNC(ctx context.Context) (string, error) - -// Proxy returns the HTTP URL for a service running on the given port inside the sandbox. func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) // --- Lifecycle --- - -// Start starts a stopped sandbox. func (b *Box) Start(ctx context.Context) error - -// Stop stops the sandbox without removing it. func (b *Box) Stop(ctx context.Context) error - -// Remove stops and removes the sandbox. func (b *Box) Remove(ctx context.Context) error - -// Info returns current sandbox status. func (b *Box) Info(ctx context.Context) (*BoxInfo, error) ``` -### ExecOption / ExecResult +### ExecOption / ExecResult / ExecStream ```go type ExecOption func(*execConfig) @@ -354,87 +316,75 @@ type ExecResult struct { Stdout string Stderr string } -``` -### ExecStream - -```go type ExecStream struct { - Stdout io.ReadCloser // real-time stdout - Stderr io.ReadCloser // real-time stderr - Stdin io.WriteCloser // write to process stdin (nil if not interactive) + Stdout io.ReadCloser + Stderr io.ReadCloser + Stdin io.WriteCloser Wait func() (int, error) // block until exit, return exit code Cancel func() // kill the process } ``` -Usage: - -```go -// Interactive CLI (e.g. Claude) -s, _ := box.Stream(ctx, []string{"claude", "--chat"}) -go io.Copy(os.Stdout, s.Stdout) -s.Stdin.Write([]byte("help\n")) -code, _ := s.Wait() - -// Long-running process (e.g. dev server) -s, _ := box.Stream(ctx, []string{"npm", "run", "dev"}) -go io.Copy(logWriter, s.Stdout) // continuous output -// ... later -s.Cancel() -``` - ### AttachOption / ServiceConn ```go type AttachOption func(*attachConfig) -func WithProtocol(proto string) AttachOption // "ws", "sse", "tcp"; default "ws" -func WithPath(path string) AttachOption // URL path, e.g. "/v1/chat" +func WithProtocol(proto string) AttachOption // "ws", "sse"; default "ws" +func WithPath(path string) AttachOption func WithHeaders(h map[string]string) AttachOption type ServiceConn struct { - // Bidirectional (WebSocket, TCP) - Read func() ([]byte, error) - Write func(data []byte) error - - // Server-push (SSE) - Events <-chan []byte // nil if not SSE mode - - // Common - URL string // resolved URL for reference - Close func() error + Read func() ([]byte, error) // read next message (WS mode) + Write func(data []byte) error + Events <-chan []byte // SSE event channel + URL string + Close func() error } ``` -`port` is the port the service listens on **inside the container** (e.g. 3000 for a Node server). Routing to that port — Docker port mapping (local) or Tai HTTP proxy (remote) — is handled internally. +`port` is the port the service listens on **inside the container**. Routing — Docker port mapping (local) or Tai HTTP proxy (remote) — is handled internally. -**Local mode caveat**: `tai/proxy.NewLocal` resolves host ports via `Inspect()` → `PortMapping`. The container must have the port mapped at creation time (`CreateOptions.Ports`). If the port was not mapped, `Proxy()` and `Attach()` return an error. Remote mode has no such restriction — Tai HTTP proxy routes by container IP directly. - -Usage: +### Image Management ```go -// WebSocket — connect to Cursor Server inside sandbox -conn, _ := box.Attach(ctx, 3000, WithProtocol("ws"), WithPath("/ws")) -conn.Write([]byte(`{"type":"edit","file":"main.go"}`)) -msg, _ := conn.Read() -conn.Close() +type ImagePullOptions struct { + Auth *RegistryAuth +} -// SSE — connect to Claude API inside sandbox -conn, _ := box.Attach(ctx, 8080, WithProtocol("sse"), WithPath("/v1/messages")) -for event := range conn.Events { - fmt.Println(string(event)) +type RegistryAuth struct { + Username string + Password string + Server string } ``` -### PoolInfo +`EnsureImage` first checks `ImageExists`; if not present, calls `PullImage` and blocks until complete. For K8s pools this is a no-op — kubelet manages image pulling natively via `imagePullPolicy`. + +### BoxInfo / PoolInfo ```go +type BoxInfo struct { + ID string + ContainerID string + Pool string + Owner string + Status string // "running", "stopped", "creating" + Policy LifecyclePolicy + Labels map[string]string + Image string + CreatedAt time.Time + LastActive time.Time + ProcessCount int + VNC bool +} + type PoolInfo struct { - Name string // pool name - Addr string // tai address - Connected bool // tai.Client connection established - Boxes int // number of boxes on this pool + Name string + Addr string + Connected bool + Boxes int MaxPerUser int MaxTotal int IdleTimeout time.Duration @@ -442,232 +392,31 @@ type PoolInfo struct { } ``` -### BoxInfo +## Workspace Integration + +Sandbox V2 integrates with the workspace module via `Manager.SetWorkspaceManager()` and `CreateOptions.WorkspaceID`: ```go -type BoxInfo struct { - ID string - ContainerID string - Pool string - Owner string - Status string // "running", "stopped", "creating" - Policy LifecyclePolicy - Labels map[string]string - Image string - CreatedAt time.Time - LastActive time.Time // max(lastCall, lastHeartbeat) - ProcessCount int // user processes inside container (0 = idle) - VNC bool -} -``` +// Link workspace manager at startup +sbm.SetWorkspaceManager(wsm) -## Workspace — fs.FS Interface - -`Box.Workspace()` returns `workspace.FS` from `tai/workspace`. This is the standard Go `fs.FS` interface extended with write operations. - -```go -// tai/workspace.FS — already implemented -type FS interface { - fs.FS // Open(name) (fs.File, error) - fs.StatFS // Stat(name) (fs.FileInfo, error) - fs.ReadFileFS // ReadFile(name) ([]byte, error) - fs.ReadDirFS // ReadDir(name) ([]fs.DirEntry, error) - io.Closer - - WriteFile(name string, data []byte, perm os.FileMode) error - Remove(name string) error - RemoveAll(name string) error - Rename(oldname, newname string) error - MkdirAll(name string, perm os.FileMode) error -} -``` - -100% compatible with Go standard library: - -```go -ws := box.Workspace() - -// Standard fs functions work -data, _ := fs.ReadFile(ws, "main.go") -fs.WalkDir(ws, ".", func(path string, d fs.DirEntry, err error) error { ... }) -info, _ := fs.Stat(ws, "go.mod") - -// Extended write operations -ws.WriteFile("main.go", []byte("package main"), 0644) -ws.MkdirAll("src/pkg", 0755) -ws.Remove("tmp.txt") -ws.Rename("old.go", "new.go") -``` - -Local mode: reads/writes go directly to host disk via bind mount. -Remote mode: reads/writes go through tai Volume gRPC with lz4 compression. -Caller doesn't know or care which mode. - -## Container gRPC (already implemented) - -Container processes communicate with Yao via gRPC. No Unix sockets. - -``` -Local: Container → yao-grpc → Yao gRPC 127.0.0.1:9099 -Remote: Container → yao-grpc → Tai :9100 relay → Yao gRPC :9099 -``` - -Manager injects env vars at container creation: - -``` -# Local -YAO_GRPC_ADDR=127.0.0.1:9099 -YAO_TOKEN= -YAO_REFRESH_TOKEN= -YAO_SANDBOX_ID= - -# Remote (adds Tai relay) -YAO_GRPC_TAI=enable -YAO_GRPC_UPSTREAM=yao-host:9099 -``` - -Token issuance uses existing `openapi/oauth`. Manager creates token pair at container creation, revokes refresh token on Remove. - -## Process Registration - -Sandbox operations are exposed as Yao Processes under the `sandbox` namespace. - -```go -func init() { - process.Register("sandbox", handler) -} -``` - -| Process | Args | Returns | -|---------|------|---------| -| `sandbox.pool.Add` | `pool` (Pool JSON) | PoolInfo | -| `sandbox.pool.Remove` | `name`, `force?` | — | -| `sandbox.pool.List` | — | []PoolInfo | -| `sandbox.Create` | `options` (CreateOptions JSON) | BoxInfo | -| `sandbox.Get` | `id` | BoxInfo | -| `sandbox.GetOrCreate` | `options` | BoxInfo | -| `sandbox.Remove` | `id` | — | -| `sandbox.List` | `options` (ListOptions JSON) | []BoxInfo | -| `sandbox.Start` | `id` | — | -| `sandbox.Stop` | `id` | — | -| `sandbox.Exec` | `id`, `cmd[]`, `options?` | ExecResult | -| `sandbox.Stream` | `id`, `cmd[]`, `options?` | stream (chunked output) | -| `sandbox.Attach` | `id`, `port`, `options?` | ServiceConn info | -| `sandbox.ReadFile` | `id`, `path` | file content (string) | -| `sandbox.WriteFile` | `id`, `path`, `content` | — | -| `sandbox.ListDir` | `id`, `path` | []FileInfo | -| `sandbox.RemoveFile` | `id`, `path` | — | -| `sandbox.MkdirAll` | `id`, `path` | — | -| `sandbox.VNC` | `id` | URL string | -| `sandbox.Proxy` | `id`, `port`, `path?` | URL string | - -This allows any Yao script, Flow, or API to use sandbox: - -```json -{ - "process": "sandbox.Exec", - "args": ["sb-001", ["go", "build", "./..."]] -} -``` - -## JSAPI - -Global constructor function registered in `gou/runtime/v8`, following the `FS()` / `Store()` pattern. - -```javascript -// Pool management -Sandbox.AddPool({ name: "gpu2", addr: "tai://gpu2.internal" }) -Sandbox.RemovePool("gpu2") -var pools = Sandbox.Pools() -// [{ name: "local", addr: "local", connected: true, boxes: 3 }, ...] - -// Get or create a sandbox -var sb = Sandbox("my-workspace", { - image: "yaoapp/workspace:latest", - owner: "user-123" +// Create sandbox with workspace mount +box, err := sbm.Create(ctx, sandbox.CreateOptions{ + Image: "yaoapp/workspace:latest", + WorkspaceID: "ws-abc123", + MountMode: "rw", // default + MountPath: "/workspace", // default }) - -// File operations (fs.FS semantics) -var content = sb.ReadFile("src/main.go") -sb.WriteFile("src/main.go", "package main\n...") -var entries = sb.ListDir("src/") -var info = sb.Stat("src/main.go") -sb.MkdirAll("src/components") -sb.Remove("tmp.txt") -sb.Rename("old.go", "new.go") - -// Command execution — wait for result -var result = sb.Exec(["go", "build", "./..."]) -// result.exit_code, result.stdout, result.stderr - -// Streaming execution — real-time output -sb.Stream(["npm", "run", "dev"], function(chunk) { - log.Info(chunk) // real-time stdout/stderr - return 1 // 1=continue, 0=stop -}) - -// Connect to a service inside the sandbox -var conn = sb.Attach(3000, { protocol: "ws", path: "/ws" }) -conn.Write('{"type":"ping"}') -var msg = conn.Read() -conn.Close() - -// Network -var vncUrl = sb.VNCUrl() -var previewUrl = sb.ProxyUrl(3000, "/") - -// Info -var info = sb.Info() -// info.id, info.status, info.owner, info.created_at - -// Lifecycle -sb.Stop() -sb.Start() -sb.Remove() - -// Properties -sb.id // sandbox ID -sb.workdir // container working directory ``` -Registration in `gou/runtime/v8/isolate.go`: +When `WorkspaceID` is set: +1. Manager calls `workspace.Manager.NodeForWorkspace()` to resolve the workspace's bound node +2. Forces the container onto that node's pool +3. Calls `workspace.Manager.MountPath()` to get the host-side directory +4. Adds a Docker bind mount: `hostPath:mountPath:mode` +5. Box.Workspace() uses the workspace ID as the volume session key -```go -template.Set("Sandbox", sandboxT.New().ExportFunction(iso)) -``` - -Implementation: `gou/runtime/v8/objects/sandbox/sandbox.go` — wraps `sandbox.M().GetOrCreate()` + `Box` methods, using `bridge.GoValue` / `bridge.JsValue` for type conversion. - -## Bootstrap — Manager.Start() - -On `Manager.Start()`, the Manager recovers all existing sandboxes and starts the cleanup loop: - -``` -1. For each pool: - tai.Client.Sandbox().List(labels: {"managed-by": "yao-sandbox"}) - → discover running/stopped containers - -2. For each discovered container: - Parse labels → extract sandbox ID, owner, policy, pool name - Rebuild Box struct, register in boxes map - Set lastCall = now (grace period after restart) - -3. Start cleanupLoop goroutine -``` - -Containers are identified by the label `managed-by=yao-sandbox` plus `sandbox-id=`. Manager injects these labels at creation time. On restart, it queries each pool for containers with `managed-by=yao-sandbox` and rebuilds the in-memory state. - -**What happens to orphaned containers** (created by old Manager, no longer matching any pool): -- If a pool is removed from config, its containers are invisible to the new Manager -- They stay running in Docker/K8s until manually cleaned or TTL-expired by the runtime -- This is by design — Manager only manages containers it can reach - -Startup sequence in `cmd/start.go`: - -``` -sandbox.Init(config.Conf.Sandbox) // create Manager with pool + guard rails -sandbox.M().Start(ctx) // discover existing containers, start cleanup loop -``` +This guarantees that a workspace's container always runs on the same host where its storage lives. ## Container Setup — Manager.Create() @@ -675,404 +424,417 @@ When Manager creates a sandbox, it: 1. Validates `CreateOptions` (Image required) 2. Generates sandbox ID (or uses provided one) -3. Checks user limits (`MaxPerUser`) and total limits (`MaxTotal`) -4. Resolves pool (by name or default) -5. Creates OAuth token pair for container IPC via `openapi/oauth` -6. Builds `tai.sandbox.CreateOptions` from caller's `CreateOptions`: - - Image, Cmd (`sleep infinity`), User — all from caller - - Field name mapping: v2 `WorkDir` → tai `WorkingDir` - - Merges caller's Env with IPC env vars: - - `YAO_GRPC_ADDR`, `YAO_TOKEN`, `YAO_REFRESH_TOKEN`, `YAO_SANDBOX_ID` - - Remote mode: `YAO_GRPC_TAI=enable`, `YAO_GRPC_UPSTREAM` - - Memory/CPU limits, VNC flag, port mappings — all from caller - - Injects management labels: - - `managed-by=yao-sandbox` - - `sandbox-id=` - - `sandbox-owner=` - - `sandbox-pool=` - - `sandbox-policy=` -7. Calls `tai.Client.Sandbox().Create()` then `Start()` -8. Wraps in a `Box`, registers in `boxes` map -9. Starts idle tracking +3. Resolves workspace node binding (if WorkspaceID set) +4. Checks user limits (`MaxPerUser`) and total limits (`MaxTotal`) +5. Resolves pool (by name or default) +6. Creates OAuth token pair for container IPC +7. Builds `tai.sandbox.CreateOptions`: + - Injects management labels: `managed-by`, `sandbox-id`, `sandbox-owner`, `sandbox-pool`, `sandbox-policy`, `workspace-id` + - Sets container CMD to graceful-shutdown-aware sleep: `sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"` + - Merges caller's Env with gRPC env vars (`YAO_SANDBOX_ID`, `YAO_TOKEN`, `YAO_REFRESH_TOKEN`, `YAO_GRPC_ADDR`, etc.) + - Adds workspace bind mount if WorkspaceID is set +8. Calls `tai.Client.Sandbox().Create()` then `Start()` +9. Wraps in a `Box`, registers in `boxes` map ## Lifecycle Management ### Idle Tracking — Dual Source -Idle is determined by two sources, taking the most recent of both: - ```go box.lastActive = max(lastExternalCall, lastHeartbeat) ``` | Source | What it tracks | Updated by | |--------|---------------|------------| -| External call | Caller is using the sandbox | `Box.Exec()`, `Box.Workspace()`, `Box.VNC()`, `Box.Proxy()` | -| Container heartbeat | Processes running inside the container | `yao-grpc` → gRPC `Heartbeat` RPC | - -**Why both**: external calls alone miss "user walked away but `npm run build` is still running". Heartbeat alone misses "user is reading output, hasn't issued a new command yet". Together they cover all cases. - -### Heartbeat — Container Side - -`yao-grpc` (already running inside every container) runs a background goroutine: - -``` -Every 30 seconds: - 1. Count user processes (ps aux, exclude sleep/init/yao-grpc) - 2. Count gRPC calls forwarded in last 30s (internal counter) - 3. If either > 0 → send Heartbeat(sandbox_id, active=true, process_count=N) - else → don't send (silent = idle) -``` - -~30 lines added to `tai/grpc/cmd/main.go`. Zero new dependencies. - -### Heartbeat — Server Side - -New gRPC RPC in `yao.proto`: - -```protobuf -rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); - -message HeartbeatRequest { - string sandbox_id = 1; - bool active = 2; - int32 process_count = 3; -} -message HeartbeatResponse {} -``` - -Handler (~20 lines in `grpc/sandbox/`): looks up Box by `sandbox_id`, updates `lastHeartbeat`. Auth: reuses container's `YAO_TOKEN`, no new scope needed (piggyback on existing `grpc:mcp`). - -### Idle Decision Matrix - -| External calls | Heartbeat | Judgment | Action | -|---------------|-----------|----------|--------| -| Recent | Recent | Active | None | -| Recent | Silent | Active | None (user reading output) | -| None | Recent | Active | None (build/server still running) | -| None | Silent | **Idle** | Policy-based stop/remove | +| External call | Caller is using the sandbox | `Box.Exec()`, `Box.Stream()`, `Box.Workspace()`, `Box.VNC()`, `Box.Proxy()`, `Box.Attach()` | +| Container heartbeat | Processes running inside the container | gRPC `Heartbeat` RPC | ### Cleanup Loop -```go -func (m *Manager) cleanupLoop(ctx context.Context) { - ticker := time.NewTicker(1 * time.Minute) - for { - select { - case <-ticker.C: - m.Cleanup(ctx) - case <-ctx.Done(): - return - } - } -} - -func (m *Manager) Cleanup(ctx context.Context) error { - now := time.Now() - m.boxes.Range(func(key, value any) bool { - box := value.(*Box) - idle := now.Sub(box.lastActiveTime()) // max(external, heartbeat) - - switch box.policy { - case OneShot: - // already removed after Exec - case Session: - if idle > box.idleTimeout() { box.Remove(ctx) } - case LongRunning: - if idle > box.idleTimeout() { box.Stop(ctx) } - if lifetime > box.maxLifetime() { box.Remove(ctx) } - case Persistent: - // never auto-cleaned - } - return true - }) - return nil -} -``` - -### Policy Behavior +Runs every 60 seconds. Policy behavior: | Policy | Idle | Max Lifetime | Auto | |--------|------|-------------|------| | OneShot | — | — | Removed after first Exec completes | -| Session | Stop + Remove | Remove | Default for agent chats | +| Session | Remove | Remove | Default for agent chats | | LongRunning | Stop (keep data) | Remove | User workspaces | | Persistent | Never | Never | User-managed | +### Container Stop Behavior + +`DefaultStopTimeout = 2s`. Docker `ContainerStop` sends SIGTERM, waits the timeout, then SIGKILL. The V2 container CMD (`trap 'exit 0' TERM; ...`) exits immediately on SIGTERM, so actual stop time is near-instant. + +`Manager.Remove()` calls `Sandbox().Remove(force=true)` directly (SIGKILL + delete) — no redundant Stop call. This keeps remove latency under 200ms. + +## Tai SDK Interface + +Sandbox V2 depends on these tai sub-package interfaces: + +### tai.Client + +```go +func New(addr string, opts ...Option) (*Client, error) +func (c *Client) Sandbox() sandbox.Sandbox +func (c *Client) Image() sandbox.Image +func (c *Client) Volume() volume.Volume +func (c *Client) Workspace(sessionID string) workspace.FS +func (c *Client) Proxy() proxy.Proxy +func (c *Client) VNC() vnc.VNC +func (c *Client) DataDir() string +func (c *Client) IsLocal() bool +func (c *Client) Close() error +``` + +Address schemes: `"local"` (Docker default), `"docker://..."` (explicit Docker), `"tai://host"` (remote Tai Server). Remote mode auto-discovers service ports via ServerInfo gRPC, with `WithPorts()` taking precedence. + +### sandbox.Sandbox + +```go +type Sandbox interface { + Create(ctx, opts CreateOptions) (string, error) + Start(ctx, id string) error + Stop(ctx, id string, timeout time.Duration) error + Remove(ctx, id string, force bool) error + Exec(ctx, id string, cmd []string, opts ExecOptions) (*ExecResult, error) + ExecStream(ctx, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) + Inspect(ctx, id string) (*ContainerInfo, error) + List(ctx, opts ListOptions) ([]ContainerInfo, error) + Close() error +} +``` + +Implementations: `docker_core.go` (local Docker), `docker.go` (remote Docker via Tai proxy), `k8s.go` (Kubernetes via Tai proxy). + +### sandbox.Image + +```go +type Image interface { + Exists(ctx, ref string) (bool, error) + Pull(ctx, ref string, opts PullOptions) (<-chan PullProgress, error) + Remove(ctx, ref string, force bool) error + List(ctx) ([]ImageInfo, error) +} +``` + +Docker implementation pulls via Docker SDK with real-time progress streaming. K8s implementation is a no-op — kubelet handles image pulling. + +### proxy.Proxy + +```go +type Proxy interface { + URL(ctx, containerID string, port int, path string) (string, error) + Connect(ctx, containerID string, opts ConnectOptions) (*Connection, error) + Healthz(ctx) error +} +``` + +Local: resolves host ports via `Inspect()`. Remote: routes through Tai HTTP proxy which handles WebSocket upgrade and SSE streaming natively. + +## gRPC Token Injection + +```go +func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) +func RevokeContainerTokens(refresh string) error +func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string +``` + +Environment variables injected into each container: + +``` +# All modes +YAO_SANDBOX_ID= +YAO_TOKEN= +YAO_REFRESH_TOKEN= +YAO_GRPC_ADDR=127.0.0.1:9099 + +# Remote mode (tai://) adds: +YAO_GRPC_TAI=enable +YAO_GRPC_ADDR=:9100 +YAO_GRPC_UPSTREAM=127.0.0.1:9099 +``` + +## Errors + +```go +var ( + ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") + ErrNotFound = errors.New("sandbox: not found") + ErrLimitExceeded = errors.New("sandbox: limit exceeded") + ErrPoolNotFound = errors.New("sandbox: pool not found") + ErrPoolInUse = errors.New("sandbox: pool has running boxes") +) +``` + ## Package Structure ``` sandbox/v2/ -├── sandbox.go // Init, M(), global singleton -├── manager.go // Manager struct, Create/Get/List/Remove/Cleanup -├── box.go // Box struct, Exec/Workspace/VNC/Proxy/lifecycle -├── config.go // Config, env parsing -├── types.go // CreateOptions, ExecResult, BoxInfo, enums -├── errors.go // sentinel errors -├── process.go // Yao Process registration (sandbox.*) -├── grpc.go // token creation + gRPC env var injection for containers -├── jsapi/ -│ └── sandbox.go // V8 JSAPI: Sandbox() constructor (lives in gou) -└── DESIGN.md // this document +├── sandbox.go // Init, M(), global singleton +├── manager.go // Manager: CRUD, pool management, image ops, cleanup +├── box.go // Box: Exec, Stream, Attach, Workspace, VNC, Proxy, lifecycle +├── types.go // CreateOptions, ExecResult, ExecStream, ServiceConn, BoxInfo, etc. +├── config.go // Config struct +├── errors.go // sentinel errors +├── grpc.go // token creation/revocation, gRPC env var injection +├── jsapi/ // (Phase 2) V8 JSAPI Sandbox() constructor +│ └── sandbox.go +├── export_test.go // ResetForTest() for test isolation +├── testutils_test.go // shared test helpers (multi-pool setup) +├── sandbox_test.go // Init/M singleton tests +├── manager_test.go // Manager CRUD tests +├── manager_lifecycle_test.go // Heartbeat, Cleanup, idle tracking tests +├── box_test.go // Box Exec/Workspace/Info tests +├── box_attach_test.go // Attach WS/SSE/VNC tests +├── box_workspace_test.go // Workspace integration tests +├── box_image_test.go // Image Pull API tests +├── bench_test.go // Performance benchmarks +├── grpc_test.go // Token/env building tests +├── DESIGN.md // this document +└── IMPL.md // implementation status and plan ``` -## Tai SDK Changes Required +--- -Sandbox V2 needs changes in `tai/` and `yao/grpc` before Phase 1 can fully work. These are **prerequisites** — the sandbox module itself has zero Docker/K8s awareness, so all runtime capabilities must exist in tai; heartbeat support requires additions to both the gRPC server and the in-container client. +# Workspace Module -### 1. `tai/sandbox` — Add `ExecStream` (streaming exec) +## Positioning -Current `Exec()` buffers all output and returns `ExecResult` after the process exits. `Box.Stream()` needs a streaming variant. +Workspace is a **top-level module** (`workspace/`), parallel to `sandbox/v2`. It provides persistent, user-managed storage that is decoupled from container lifecycle. Workspaces are pinned to a specific Tai node; containers referencing a workspace are automatically routed to that node. -```go -// tai/sandbox — new method on the Sandbox interface -type ExecStream struct { - Stdout io.ReadCloser - Stderr io.ReadCloser - Stdin io.WriteCloser - Wait func() (int, error) // blocks until exit, returns exit code - Cancel func() // kills the exec process -} - -func (s *Sandbox) ExecStream(ctx context.Context, containerID string, cmd []string, opts ...ExecOption) (*ExecStream, error) +``` +┌─────────────────────┐ ┌─────────────────────┐ +│ sandbox/v2 │ │ workspace │ +│ (container runtime) │◄────│ (persistent storage)│ +│ │ │ │ +│ CreateOptions { │ │ CRUD + File I/O │ +│ WorkspaceID ──────┼────►│ Node binding │ +│ } │ │ fs.FS interface │ +└──────────┬───────────┘ └──────────┬───────────┘ + │ │ + └──────────┬─────────────────┘ + ▼ + tai.Client pool ``` -Implementation per runtime: - -| Runtime | How | -|---------|-----| -| **Docker** (`docker_core.go`) | `ContainerExecCreate` + `ContainerExecAttach` — already returns a `HijackedResponse` with a raw stream. Current code pipes it into buffers; change to expose `io.ReadCloser` directly. `Cancel` calls `ContainerExecInspect` loop → kill. ~40 lines changed. | -| **K8s** (`k8s.go`) | `remotecommand.NewSPDYExecutor` + `StreamWithContext` — already supports streaming. Current code passes `bytes.Buffer`; change to pass `io.Pipe()`. ~30 lines changed. | - -Both runtimes already have the raw streaming capability — the change is to **stop buffering** and expose the stream directly. - -### 2. `tai/proxy` — Add `Connect` (bidirectional connection) - -Current `proxy.Proxy` only returns a URL string (`Resolve()`). `Box.Attach()` needs an actual connection. +## Core Types ```go -// tai/proxy — new method -type ConnectOptions struct { - Protocol string // "ws", "sse", "tcp"; default "ws" - Path string // URL path, e.g. "/v1/chat" - Headers map[string]string // extra request headers +type Workspace struct { + ID string + Name string + Owner string + Node string // Tai node this workspace is pinned to + Labels map[string]string + CreatedAt time.Time + UpdatedAt time.Time } -type Connection struct { - Read func() ([]byte, error) // read next message/event - Write func(data []byte) error // send data (no-op for SSE) - Events <-chan []byte // non-nil for SSE mode - URL string // resolved URL for reference - Close func() error -} - -func (p *Proxy) Connect(ctx context.Context, containerID string, port int, opts ConnectOptions) (*Connection, error) -``` - -Implementation: - -| Mode | How | -|------|-----| -| **Local** | Direct dial to `containerIP:port`. WebSocket via `gorilla/websocket` or `nhooyr.io/websocket`. SSE via `http.Get` + chunked read. TCP via `net.Dial`. | -| **Remote** | Dial through Tai HTTP proxy: `http://tai-host:8080/{containerID}:{port}/{path}`. Tai proxy already handles WebSocket upgrade and SSE streaming natively (`http.Hijacker` for WS, `FlushInterval: -1` for SSE). No Tai server changes needed. | - -The Tai HTTP proxy server (`tai/httpproxy/router.go`) already supports: -- **WebSocket**: detects `Upgrade: websocket` header, does TCP-level bidirectional relay -- **SSE**: reverse proxy with `FlushInterval: -1`, streams through transparently -- **Regular HTTP**: standard `httputil.ReverseProxy` - -So the `Connect` implementation in `tai/proxy` is a **client-side** addition only. The server side is ready. - -### 3. `tai/sandbox` — Add `Labels` and `User` to `CreateOptions` - -Current `tai/sandbox.CreateOptions` is missing two fields Manager needs: - -- **`Labels`**: for container discovery on restart (`managed-by=yao-sandbox`, `sandbox-id`, etc.) -- **`User`**: to run container processes as a specific user - -```go -// tai/sandbox — add to existing CreateOptions struct type CreateOptions struct { - // ... existing fields (Name, Image, Cmd, Env, Binds, WorkingDir, Memory, CPUs, VNC, Ports) ... - Labels map[string]string // container/pod labels for discovery and management - User string // container user, e.g. "1000:1000" + ID string // explicit ID; empty = auto-generate (ws-) + Name string + Owner string + Node string // target Tai node (required) + Labels map[string]string +} + +type ListOptions struct { + Owner string + Node string +} + +type UpdateOptions struct { + Name *string // nil = no change + Labels map[string]string // nil = no change; non-nil replaces all labels +} + +type NodeInfo struct { + Name string + Addr string + Online bool +} + +type DirEntry struct { + Name string + IsDir bool + Size int64 } ``` -Implementation: - -| Runtime | Field | How | -|---------|-------|-----| -| **Docker** | `Labels` | Set `cfg.Labels = opts.Labels` in `create()`. ~1 line. | -| **Docker** | `User` | Set `cfg.User = opts.User` in `create()`. ~1 line. | -| **K8s** | `Labels` | Set `pod.ObjectMeta.Labels` in `CreatePod`. ~1 line. | -| **K8s** | `User` | Set `SecurityContext.RunAsUser` in pod spec. ~3 lines. | - -`List` with label filtering is **already implemented** in both runtimes: -- Docker: `filters.NewArgs("label", k+"="+v)` in `docker_core.go:175` -- K8s: `metav1.ListOptions{LabelSelector: ...}` in `k8s.go:255` - -`ListOptions.Labels` field also already exists in `sandbox.go:68`. No changes needed for List. - -Also needed: **`ContainerInfo` must include `Labels`**. Current `ContainerInfo` struct has no `Labels` field. `Manager.Start()` discovers existing containers via `List()` and needs to read labels (`sandbox-id`, `sandbox-owner`, `sandbox-policy`, `sandbox-pool`) to rebuild Box state. +## Manager API ```go -// tai/sandbox — add to existing ContainerInfo struct -type ContainerInfo struct { - // ... existing fields (ID, Name, Image, Status, IP, Ports) ... - Labels map[string]string // container/pod labels +type Manager struct { + pools map[string]*tai.Client + mu sync.RWMutex } + +func NewManager(pools map[string]*tai.Client) *Manager + +// --- CRUD --- +func (m *Manager) Create(ctx, opts CreateOptions) (*Workspace, error) +func (m *Manager) Get(ctx, id string) (*Workspace, error) +func (m *Manager) List(ctx, opts ListOptions) ([]*Workspace, error) +func (m *Manager) Update(ctx, id string, opts UpdateOptions) (*Workspace, error) +func (m *Manager) Delete(ctx, id string, force bool) error + +// --- File I/O --- +func (m *Manager) ReadFile(ctx, id string, path string) ([]byte, error) +func (m *Manager) WriteFile(ctx, id string, path string, data []byte, perm os.FileMode) error +func (m *Manager) ListDir(ctx, id string, path string) ([]DirEntry, error) +func (m *Manager) Remove(ctx, id string, path string) error +func (m *Manager) FS(ctx, id string) (workspace.FS, error) + +// --- Node management --- +func (m *Manager) Nodes() []NodeInfo +func (m *Manager) AddPool(name string, client *tai.Client) +func (m *Manager) RemovePool(name string) + +// --- Sandbox integration --- +func (m *Manager) NodeForWorkspace(ctx, id string) (string, error) +func (m *Manager) MountPath(ctx, id string) (string, error) ``` -| Runtime | How | -|---------|-----| -| **Docker** | `list()`: read `c.Labels` from `ContainerList` response. `inspect()`: read `info.Config.Labels`. ~1 line each. | -| **K8s** | `list()`: read `pod.Labels` from `PodList` response. ~1 line. | +## Metadata Storage -### 4. `yao/grpc` + `tai/grpc` — Heartbeat RPC +Workspace metadata is stored as `.workspace.json` inside the workspace's root directory on the Tai node: -Manager uses dual idle tracking (external API calls + container heartbeat). The heartbeat path requires additions on both sides: the gRPC server (new RPC) and `yao-grpc` in-container client (new background goroutine). - -#### Server side — `yao/grpc` - -New RPC in `grpc/pb/yao.proto`: - -```protobuf -rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); - -message HeartbeatRequest { - string sandbox_id = 1; - bool active = 2; // true if user processes detected - int32 process_count = 3; // number of user processes -} -message HeartbeatResponse {} +``` +/ +├── ws-abc123/ +│ ├── .workspace.json ← metadata (ID, Name, Owner, Node, Labels, timestamps) +│ ├── src/ +│ ├── go.mod +│ └── ... +├── ws-def456/ +│ └── ... ``` -Handler in `grpc/sandbox/` (~20 lines): +This approach collocates metadata with data — no external database required. `List()` scans top-level directories and reads each `.workspace.json`. `Get()` scans all nodes until the workspace is found. + +## Errors ```go -func (s *Server) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) { - box, err := sandbox.M().Get(ctx, req.SandboxId) - if err != nil { - return nil, status.Errorf(codes.NotFound, "sandbox %s not found", req.SandboxId) - } - sandbox.M().Heartbeat(req.SandboxId, req.Active, int(req.ProcessCount)) - return &pb.HeartbeatResponse{}, nil -} +var ( + ErrNotFound = errors.New("workspace: not found") + ErrNodeMissing = errors.New("workspace: node is required") + ErrNodeOffline = errors.New("workspace: node is offline or not configured") + ErrHasMounts = errors.New("workspace: workspace has active container mounts") +) ``` -Auth: reuses container's `YAO_TOKEN` — no new OAuth scope needed. The token is already issued with gRPC access when Manager creates the container. - -#### Client side — `tai/grpc/cmd/main.go` (`yao-grpc`) - -New background goroutine (~30 lines) added to `yao-grpc` startup: - -```go -func heartbeatLoop(ctx context.Context, client *grpc.Client, sandboxID string) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - count := countUserProcesses() // ps aux, exclude sleep/init/yao-grpc - active := count > 0 - if active { - client.Heartbeat(ctx, sandboxID, true, int32(count)) - } - // silent when idle — no heartbeat sent, Manager tracks absence - case <-ctx.Done(): - return - } - } -} - -func countUserProcesses() int { - // exec `ps -eo comm`, filter out known system processes - // (sleep, init, yao-grpc, sh -c sleep) - // return count of remaining user processes -} -``` - -`yao-grpc` reads `YAO_SANDBOX_ID` from env (injected by Manager at container creation). If empty, heartbeat is disabled (container not managed by sandbox). - -#### Heartbeat flow +## Package Structure ``` -Container (every 30s) Yao Server -───────────────────── ────────── -countUserProcesses() - ├── active (count > 0) - │ └── yao-grpc → Heartbeat RPC ──→ grpc/sandbox/Heartbeat() - │ └── sandbox.M().Heartbeat(id, true, N) - │ └── box.lastHeartbeat = now - │ box.processCount = N - └── idle (count == 0) - └── (no RPC sent) Manager sees: no heartbeat in 30s+ - └── combined with no external calls → idle +workspace/ +├── workspace.go // types, metadata marshal/unmarshal +├── manager.go // Manager: CRUD, file I/O, node management +├── errors.go // sentinel errors +├── testutils_test.go // shared test helpers +├── workspace_test.go // CRUD tests (Create/Get/List/Update/Delete/Nodes) +├── fileio_test.go // File I/O + fs.FS tests +├── bench_test.go // Performance benchmarks +└── DESIGN.md // detailed design document ``` -Key behaviors: -- **Only sends when active** — idle containers are silent, reducing gRPC traffic -- **30s interval** — matches Manager cleanup loop granularity (1 min), two missed heartbeats = considered idle -- **Crash-safe** — if `yao-grpc` dies, heartbeats stop, Manager treats it as idle after timeout -- **Zero new dependencies** — `yao-grpc` already has the gRPC client connection; heartbeat piggybacks on it +--- -### Summary +# Testing -| Change | Package | Effort | Blocks | -|--------|---------|--------|--------| -| `ExecStream` | `tai/sandbox` | ~40 lines Docker + ~30 lines K8s | `Box.Stream()` | -| `Connect` | `tai/proxy` | ~80 lines (client-side only, server ready) | `Box.Attach()` | -| `Labels` + `User` in `CreateOptions` | `tai/sandbox` | ~6 lines (Docker + K8s) | `Manager.Create()` labeling + user | -| `Labels` in `ContainerInfo` | `tai/sandbox` | ~3 lines (Docker list/inspect + K8s list) | `Manager.Start()` container discovery | -| `Heartbeat` RPC | `yao/grpc` | ~20 lines handler + 3 lines proto | `Manager.Heartbeat()` | -| Heartbeat goroutine | `tai/grpc` (`yao-grpc`) | ~30 lines | Container → Server heartbeat | +## Test Environment -`List` with label filtering is already implemented in both Docker and K8s runtimes — no changes needed. +Three pool modes configured via environment variables: -All changes are additive (no breaking changes to existing APIs). `Box.Exec()` and `Box.Workspace()` work with current tai — only Stream, Attach, and idle tracking need the new methods. +```bash +# Local — direct Docker daemon (always available) +SANDBOX_TEST_LOCAL_ADDR=local -## Migration Plan +# Remote — via Tai container (Docker backend) +SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1:9100 -### Phase 1: Core module +# K8s — via Tai container (K8s backend) +TAI_TEST_K8S_HOST= +TAI_TEST_KUBECONFIG= +TAI_TEST_K8S_PORT=6443 +TAI_TEST_K8S_NAMESPACE=default -Build `sandbox/v2` as a standalone package. No agent dependency. +# Test image +SANDBOX_TEST_IMAGE=yaoapp/sandbox-v2-test:latest +``` -**Tai / gRPC prerequisites** (do first): +Tests skip unavailable modes via `t.Skip`. Both sandbox/v2 and workspace tests iterate over all available pools. + +## Test Coverage + +### sandbox/v2 + +| File | Coverage | +|------|----------| +| `sandbox_test.go` | `Init()`, `M()`, singleton behavior | +| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool management, limits | +| `manager_lifecycle_test.go` | Start (container discovery), Cleanup, idle tracking, Heartbeat | +| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), lifecycle | +| `box_attach_test.go` | Attach WS, Attach SSE, VNC URL, VNC Connect | +| `box_workspace_test.go` | Workspace file I/O through Box, workspace mount integration | +| `box_image_test.go` | ImageExists, PullImage (with progress), EnsureImage, K8s no-op | +| `grpc_test.go` | Token creation/revocation, env var building | +| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | + +### workspace + +| File | Coverage | +|------|----------| +| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (filter owner/node), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool, RemovePool, MountPath | +| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove) | +| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | + +## CI Integration + +Consolidated into two CI jobs: + +| Job | Contents | +|-----|----------| +| `SandboxV2Test` | Image pre-pull → tai-test → sandbox/v2 (local+remote+k8s) → workspace (local+remote) | +| `BenchmarkSandboxV2` | Performance tests for sandbox/v2 + workspace (parallel with SandboxV2Test) | + +## Benchmark Results (Reference) + +| Benchmark | Local | Remote | K8s | +|-----------|-------|--------|-----| +| ContainerLifecycle | ~300ms | ~200ms | ~10s | +| Create | ~100ms | ~80ms | ~8s | +| Exec | ~30ms | ~50ms | ~150ms | +| Remove | ~180ms | ~120ms | ~220ms | +| Info | ~5ms | ~10ms | ~30ms | +| StopStart | ~2.2s | ~2.2s | N/A (skip) | + +K8s `StopStart` is skipped because K8s `Stop` deletes the Pod; a subsequent `Start` cannot restart a deleted Pod. + +Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits the full timeout before SIGKILL unless PID 1 exits on SIGTERM first. + +--- + +# Migration Plan + +## Phase 1: Core (DONE) + +- tai SDK: Sandbox, ExecStream, Image, Proxy.Connect, Labels, User +- sandbox/v2: Manager, Box, all CRUD + Exec + Stream + Attach + Workspace + VNC + Proxy + Image +- workspace: Manager, CRUD, file I/O, node binding, sandbox integration +- gRPC: Heartbeat RPC (proto + handler) +- Tests: unit + integration + benchmarks +- CI: consolidated SandboxV2Test + BenchmarkSandboxV2 + +## Phase 2: Process + JSAPI (PENDING) | Task | Detail | |------|--------| -| `tai/sandbox`: `ExecStream` | Streaming exec for Docker + K8s (~70 lines total) | -| `tai/proxy`: `Connect` | Client-side WebSocket/SSE/TCP connection (~80 lines) | -| `tai/sandbox`: `Labels` + `User` in `CreateOptions` | Add fields + wire into Docker/K8s create (~6 lines). List filter already done. | -| `tai/sandbox`: `Labels` in `ContainerInfo` | Add field + populate in Docker list/inspect, K8s list (~3 lines) | -| `yao/grpc`: `Heartbeat` RPC | Proto + handler (~20 lines) | -| `tai/grpc` (`yao-grpc`): heartbeat goroutine | Process detection + periodic report (~30 lines) | +| `sandbox/v2/process.go` | Register `sandbox.*` process namespace | +| `sandbox/v2/jsapi/` | V8 `Sandbox()` constructor (registered in gou runtime) | +| `workspace/process.go` | Register `workspace.*` process namespace | +| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` | +| Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke | -**Sandbox V2 module:** - -| Task | Detail | -|------|--------| -| `sandbox.go` | `Init()`, `M()`, singleton lifecycle | -| `config.go` | Config struct, defaults | -| `types.go` | CreateOptions, ExecResult, BoxInfo, LifecyclePolicy, Pool | -| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded | -| `manager.go` | Manager with tai.Client pool. Create/Get/GetOrCreate/List/Remove/Cleanup/Close | -| `box.go` | Box wrapping tai Sandbox/Volume/Workspace/Proxy/VNC. Dual idle tracking (lastCall + lastHeartbeat) | -| `grpc.go` | OAuth token pair creation, gRPC env var injection | -| Tests | Unit + integration (needs Docker for local mode) | - -### Phase 2: Process + JSAPI - -| Task | Detail | -|------|--------| -| `process.go` | Register `sandbox.*` process namespace | -| `jsapi/sandbox.go` | V8 `Sandbox()` constructor in gou | -| Tests | Process handler tests, JSAPI tests | - -### Phase 3: Agent integration - -In the Agent repo (not in sandbox/v2): +## Phase 3: Agent Integration (PENDING) | Task | Detail | |------|--------| @@ -1080,77 +842,29 @@ In the Agent repo (not in sandbox/v2): | Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads | | Agent uses `Box.Exec()` for commands | Replace Docker exec | | Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy | -| Agent injects `Box` as `SandboxExecutor` | `ctx.sandbox` JSAPI unchanged for hooks | -| `BuildMCPConfigForSandbox()` uses Box env vars | No more hardcoded `/tmp/yao.sock` | -### Phase 4: Cutover +## Phase 4: Cutover (PENDING) | Task | Detail | |------|--------| | Move `sandbox/v2` → `sandbox` | Rename package | | Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | -| Delete `DESIGN-REMOTE.md` | Superseded by tai.Client | | Update `cmd/start.go` | Use new init path | -## What Gets Deleted (Phase 4) - -Everything in the current `sandbox/` that is replaced by tai: - -| Old | Replaced by | -|-----|------------| -| `manager.go` (Docker `*client.Client`) | `tai.Client.Sandbox()` | -| `ipc/` (Unix socket manager) | gRPC via `yao/grpc` + `tai/grpc` | -| `bridge/` (stdio→socket bridge) | `yao-grpc` binary (`tai/grpc/cmd`) | -| `vncproxy/` (Docker-based VNC) | `tai.Client.VNC()` | -| `proxy/` (Claude API proxy) | separate concern, not sandbox | -| `docker/` (Dockerfiles) | kept, they're image build files | -| `DESIGN-REMOTE.md` (Runtime interface) | tai.Client is the abstraction | -| `config.go` (old config) | new config in v2 | -| `helpers.go` (Docker helpers) | not needed | - -## Comparison: V1 vs V2 +## V1 vs V2 Comparison | Aspect | V1 (current) | V2 (this design) | |--------|-------------|-------------------| | **Positioning** | Agent's Claude executor | Yao infrastructure module | | **Runtime** | Direct Docker SDK | tai.Client pool (Docker/K8s/Remote) | -| **Execution** | Exec + Stream | Exec + Stream + Attach (service connections) | +| **Execution** | Exec + Stream | Exec + Stream + Attach (WS/SSE) | | **File I/O** | bind mount + Docker Copy | `workspace.FS` (fs.FS compatible) | -| **IPC** | Unix socket + yao-bridge | gRPC (yao-grpc, already done) | +| **IPC** | Unix socket + yao-bridge | gRPC (yao-grpc) | | **Idle detection** | External calls only | Dual: external calls + container heartbeat | | **Lifecycle** | Chat session only | Policy-based (oneshot/session/longrunning/persistent) | -| **Pool** | Single Docker daemon | Multi-pool with per-pool policies, dynamic add/remove | +| **Pool** | Single Docker daemon | Multi-pool with per-pool policies | | **Agent coupling** | Tightly coupled | Zero dependency | -| **JSAPI** | Only `ctx.sandbox` in hooks | Global `Sandbox()` + `ctx.sandbox` | -| **Process** | None | `sandbox.*` namespace | -| **Multi-node** | Local only | Local + Remote via Tai | +| **Workspace** | None | Persistent, node-bound, decoupled from containers | +| **Image management** | None | EnsureImage + Pull with progress | | **K8s** | Not supported | Supported via tai.Client | - -## Workspace - -Workspace is now a **top-level module** (`workspace/`), parallel to `sandbox/v2`. - -See [`workspace/DESIGN.md`](../workspace/DESIGN.md) for the full design document covering: -- Workspace as a first-class, persistent entity decoupled from containers -- Node binding and container scheduling -- Workspace CRUD and file I/O APIs -- Integration with Sandbox `CreateOptions` -- Metadata storage strategy -- Process and JSAPI registration -- Implementation plan - -### Integration point - -`sandbox/v2` integrates with Workspace via `CreateOptions.WorkspaceID`: - -```go -type CreateOptions struct { - // ... existing fields ... - - WorkspaceID string // workspace to mount; empty = no workspace - MountMode MountMode // "rw" (default) or "ro" - MountPath string // container path; default "/workspace" -} -``` - -When `WorkspaceID` is set, the Sandbox Manager resolves the Workspace's bound node and forces the container to be created on that node. See `workspace/DESIGN.md` for full details. +| **Multi-node** | Local only | Local + Remote via Tai | diff --git a/sandbox/v2/IMPL.md b/sandbox/v2/IMPL.md index 7c4a556f..e2357b31 100644 --- a/sandbox/v2/IMPL.md +++ b/sandbox/v2/IMPL.md @@ -1,704 +1,283 @@ -# Sandbox V2 — Implementation Plan - -Phase 1 implementation. Covers tai SDK prerequisites + sandbox/v2 core Go API. -No JSAPI, no Process registration — those are Phase 2. +# Sandbox V2 — Implementation Status Reference: [DESIGN.md](./DESIGN.md) -## Execution Order +--- -``` -Step 0: tai/sandbox — Labels, User, ContainerInfo.Labels (no deps) -Step 1: tai/sandbox — ExecStream (no deps) -Step 2: tai/proxy — Connect (no deps) -Step 3: yao/grpc — Heartbeat RPC (proto + handler) (no deps) -Step 4: tai/grpc — yao-grpc heartbeat goroutine (depends on Step 3 proto) -Step 4.5: docker — build v2 test images (depends on Steps 1–4) -Step 5: sandbox/v2 — core module (depends on Steps 0–4) -Step 6: tests (depends on Steps 5 + 4.5) -``` +## Phase 1: Core Module — DONE -Steps 0–3 are independent and can be parallelized. -Step 4.5 (images) depends on tai SDK + yao-grpc changes being compiled into binaries. +### tai SDK Prerequisites — DONE + +| Step | Package | What | Status | +|------|---------|------|--------| +| 0 | `tai/sandbox` | Labels, User in CreateOptions + ContainerInfo | DONE | +| 1 | `tai/sandbox` | ExecStream (Docker + K8s) | DONE | +| 2 | `tai/proxy` | Connect (WS/SSE, Local + Remote) | DONE | +| 3 | `tai/sandbox` | Image interface (Exists, Pull, Remove, List) | DONE | +| 4 | `tai/tai.go` | Client: Sandbox(), Image(), Proxy(), VNC(), Volume(), Workspace() | DONE | +| 5 | `yao/grpc` | Heartbeat RPC (proto + handler) | DONE | + +### sandbox/v2 Core — DONE + +| File | What | Status | +|------|------|--------| +| `sandbox.go` | `Init()`, `M()`, global singleton | DONE | +| `manager.go` | Manager: Create/Get/GetOrCreate/List/Remove/Cleanup/Close, Start (container recovery), AddPool/RemovePool/Pools, Heartbeat, SetGRPCPort, SetWorkspaceManager, ImageExists/PullImage/EnsureImage | DONE | +| `box.go` | Box: Exec, Stream, Attach, Workspace, VNC, Proxy, Start/Stop/Remove, Info, touch/lastActiveTime/idleTimeout/maxLifetime/stopTimeout | DONE | +| `types.go` | LifecyclePolicy (OneShot/Session/LongRunning/Persistent), Pool, PoolInfo, PortMapping, CreateOptions (with WorkspaceID/MountMode/MountPath), ListOptions, ExecOption/ExecResult/ExecStream, AttachOption/ServiceConn, ImagePullOptions/RegistryAuth, BoxInfo, DefaultStopTimeout | DONE | +| `config.go` | Config struct | DONE | +| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded, ErrPoolNotFound, ErrPoolInUse | DONE | +| `grpc.go` | CreateContainerTokens, RevokeContainerTokens, BuildGRPCEnv | DONE | + +### workspace Module — DONE + +| File | What | Status | +|------|------|--------| +| `workspace.go` | Workspace struct, CreateOptions, ListOptions, UpdateOptions, NodeInfo, MountMode, metadata marshal/unmarshal | DONE | +| `manager.go` | Manager: Create/Get/List/Update/Delete, ReadFile/WriteFile/ListDir/Remove/FS, Nodes/AddPool/RemovePool, NodeForWorkspace/MountPath | DONE | +| `errors.go` | ErrNotFound, ErrNodeMissing, ErrNodeOffline, ErrHasMounts | DONE | + +### Tests — DONE + +| File | Coverage | Status | +|------|----------|--------| +| **sandbox/v2** | | | +| `sandbox_test.go` | Init, M, singleton | DONE | +| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool limits (MaxTotal, MaxPerUser), multi-pool | DONE | +| `manager_lifecycle_test.go` | Start (recovery), Cleanup, idle tracking, Heartbeat | DONE | +| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), status | DONE | +| `box_attach_test.go` | Attach WS echo, Attach SSE events, VNC URL, VNC Connect (RFB handshake) | DONE | +| `box_workspace_test.go` | Workspace mount, file I/O through Box, invalid ID | DONE | +| `box_image_test.go` | ImageExists (Docker+K8s), PullImage (progress+K8s no-op), EnsureImage, bad ref | DONE | +| `grpc_test.go` | Token creation/revocation, env var building (local vs remote) | DONE | +| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | DONE | +| `testutils_test.go` | testPools (local/remote/k8s), setupManager, createTestBox, ensureTestImage | DONE | +| `export_test.go` | ResetForTest | DONE | +| **workspace** | | | +| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (owner/node filter), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool/RemovePool, MountPath | DONE | +| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove, NotFound) | DONE | +| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | DONE | +| `testutils_test.go` | testPools, setupManagerForPool, clientForPool, localClient, setupManagerMultiNode, createWorkspace | DONE | + +### CI — DONE + +| Job | Contents | Status | +|-----|----------|--------| +| `SandboxV2Test` | Consolidated: image pre-pull → tai-test → sandbox/v2 (local+remote+k8s) → workspace (local+remote) | DONE | +| `BenchmarkSandboxV2` | Parallel: performance tests for sandbox/v2 + workspace | DONE | +| `GRPCTest` | Independent: gRPC tests (unchanged) | DONE | + +### Performance Optimizations — DONE + +| Optimization | Before | After | Impact | +|-------------|--------|-------|--------| +| Remove redundant Stop in Manager.Remove() | 2.14s | 177ms | 12x faster Docker remove | +| Container CMD trap SIGTERM | 2s+ stop | near-instant | Graceful shutdown on Stop | +| K8s Start: respect ctx deadline | 30s hardcoded | ctx-aware + 60s default | Proper timeout propagation | +| K8s Pod spec: Args vs Command | CMD overridden | ENTRYPOINT preserved | Correct container behavior | --- -## Step 0: `tai` — Labels, User, ContainerInfo.Labels + `tai.New("local")` +## Phase 2: Process + JSAPI — PENDING -**Files:** `tai/tai.go`, `tai/sandbox/sandbox.go`, `tai/sandbox/docker_core.go`, `tai/sandbox/k8s.go` +| Task | Package | Detail | +|------|---------|--------| +| `process.go` | `sandbox/v2` | Register `sandbox.*` process namespace (sandbox.Create, sandbox.Exec, sandbox.ReadFile, etc.) | +| `process.go` | `workspace` | Register `workspace.*` process namespace | +| `jsapi/sandbox.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` constructor (registered in gou runtime) | +| `cmd/start.go` integration | `yao` | Call `sandbox.Init(config.Conf.Sandbox)` + `sandbox.M().Start(ctx)` in startup sequence | +| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` | +| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls | -### 0.0 `tai.New("")` → error, add `"local"` / `"127.0.0.1"` aliases +### Process Registration (planned) -```go -// tai/tai.go — parseAddr changes: -// - addr == "" → return error ("use local") -// - addr == "local" || addr == "127.0.0.1" → return "docker", "", "" (platform default socket) +``` +sandbox.pool.Add sandbox.pool.Remove sandbox.pool.List +sandbox.Create sandbox.Get sandbox.GetOrCreate +sandbox.Remove sandbox.List +sandbox.Start sandbox.Stop +sandbox.Exec sandbox.Stream sandbox.Attach +sandbox.ReadFile sandbox.WriteFile sandbox.ListDir +sandbox.RemoveFile sandbox.MkdirAll +sandbox.VNC sandbox.Proxy +sandbox.EnsureImage sandbox.ImageExists sandbox.PullImage + +workspace.Create workspace.Get workspace.List +workspace.Update workspace.Delete +workspace.ReadFile workspace.WriteFile workspace.ListDir +workspace.Remove workspace.FS +workspace.Nodes ``` -All callers must use explicit addresses. `"local"` means platform-default Docker daemon. +### JSAPI (planned) -### 0.1 Add `Labels` and `User` to `CreateOptions` +```javascript +// Sandbox +var sb = Sandbox("my-workspace", { + image: "yaoapp/workspace:latest", + owner: "user-123" +}) +sb.Exec(["go", "build", "./..."]) +sb.ReadFile("src/main.go") +sb.WriteFile("src/main.go", "package main\n...") +sb.Stream(["npm", "run", "dev"], function(chunk) { ... }) +var conn = sb.Attach(3000, { protocol: "ws", path: "/ws" }) +sb.Info() +sb.Stop() +sb.Start() +sb.Remove() -```go -// sandbox.go — add two fields to existing struct -type CreateOptions struct { - // ... existing fields ... - Labels map[string]string - User string -} +// Workspace +var ws = Workspace("my-workspace") +ws.ReadFile("src/main.go") +ws.WriteFile("src/main.go", "package main\n...") +ws.ListDir("src/") +ws.Remove("tmp.txt") ``` -### 0.2 Wire into Docker create - -```go -// docker_core.go — in create(), after building cfg: -cfg.Labels = opts.Labels -if opts.User != "" { - cfg.User = opts.User -} -``` - -### 0.3 Wire into K8s create - -```go -// k8s.go — in Create(), set pod labels: -pod.ObjectMeta.Labels = mergeLabels(pod.ObjectMeta.Labels, opts.Labels) - -// For User, parse and set SecurityContext.RunAsUser -``` - -### 0.4 Add `Labels` to `ContainerInfo` - -```go -// sandbox.go -type ContainerInfo struct { - // ... existing fields ... - Labels map[string]string -} -``` - -### 0.5 Populate Labels in Docker list/inspect - -```go -// docker_core.go — in list(): -ci.Labels = c.Labels - -// docker_core.go — in inspect(): -ci.Labels = info.Config.Labels -``` - -### 0.6 Populate Labels in K8s list - -```go -// k8s.go — in List(): -ci.Labels = pod.Labels -``` - -### 0.7 Tests - -- `TestCreateWithLabels` — create container with labels, list with label filter, verify match -- `TestCreateWithUser` — create container with user, exec `whoami`, verify -- `TestListLabels` — create 2 containers with different labels, list with filter, verify count - -**Estimated: ~20 lines code + ~60 lines tests** - --- -## Step 1: `tai/sandbox` — ExecStream +## Phase 3: Agent Integration — PENDING -**Files:** `tai/sandbox/sandbox.go`, `tai/sandbox/docker_core.go`, `tai/sandbox/k8s.go` +| Task | Detail | +|------|--------| +| Agent creates Box via `sandbox.M().GetOrCreate()` | Replace `infraSandbox.Manager` | +| Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads | +| Agent uses `Box.Exec()` for commands | Replace Docker exec | +| Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy | +| Agent injects `Box` as `SandboxExecutor` | `ctx.sandbox` JSAPI unchanged for hooks | -### 1.1 Add to Sandbox interface +--- -```go -// sandbox.go -type ExecStream struct { - Stdout io.ReadCloser - Stderr io.ReadCloser - Stdin io.WriteCloser - Wait func() (int, error) - Cancel func() -} +## Phase 4: Cutover — PENDING -// Add to Sandbox interface: -ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) +| Task | Detail | +|------|--------| +| Move `sandbox/v2` → `sandbox` | Rename package | +| Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | +| Delete `DESIGN-REMOTE.md` | Superseded by tai.Client | +| Update `cmd/start.go` | Use new init path | + +--- + +## Implementation Details + +### Container CMD + +All V2 containers use a SIGTERM-aware sleep as PID 1: + +```bash +sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done" ``` -### 1.2 Docker implementation +This ensures: +- Container stays alive indefinitely (no hardcoded `sleep infinity`) +- Exits immediately on SIGTERM (no 2s wait) +- Works on both Docker and K8s + +### Container Labels + +Manager injects these labels at creation time: + +``` +managed-by=yao-sandbox +sandbox-id= +sandbox-owner= +sandbox-pool= +sandbox-policy= +workspace-id= (if WorkspaceID set) +``` + +Used by `Manager.Start()` to discover and recover existing containers after restart. + +### Workspace Bind Mount + +When `CreateOptions.WorkspaceID` is set: + +``` +1. NodeForWorkspace(wsID) → node name +2. Force pool = node name +3. MountPath(wsID) → hostDir +4. Bind: hostDir:/workspace:rw +``` + +### Multi-Mode Testing + +`testPools()` returns all available pool configurations: ```go -// docker_core.go — new method -func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) { - execCfg := container.ExecOptions{ - Cmd: cmd, - WorkingDir: opts.WorkDir, - Env: envSlice(opts.Env), - AttachStdout: true, - AttachStderr: true, - AttachStdin: true, +func testPools() []poolConfig { + pools := []poolConfig{{Name: "local", Addr: testLocalAddr()}} + if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { + pools = append(pools, poolConfig{Name: "remote", Addr: addr}) } - execResp, err := d.cli.ContainerExecCreate(ctx, id, execCfg) - // ... - resp, err := d.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) - // ... - // Use io.Pipe + stdcopy.StdCopy in a goroutine to demux stdout/stderr - // Wait: poll ContainerExecInspect until Running=false - // Cancel: context cancel → close resp.Conn -} -``` - -Key: `ContainerExecAttach` returns `HijackedResponse` with multiplexed stream. Use `stdcopy.StdCopy` in a goroutine writing to `io.Pipe` pairs for stdout/stderr separation. - -### 1.3 K8s implementation - -```go -// k8s.go — new method -func (s *k8sSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) { - // remotecommand.NewSPDYExecutor - // StreamWithContext using io.Pipe for stdin/stdout/stderr - // Wait: executor returns when process exits - // Cancel: cancel the context -} -``` - -### 1.4 Tests - -- `TestExecStream_ShortCommand` — `echo hello`, read stdout, verify Wait returns 0 -- `TestExecStream_LongRunning` — `sleep 10`, Cancel after 1s, verify cleanup -- `TestExecStream_Stdin` — `cat`, write to stdin, read from stdout, verify echo -- `TestExecStream_ExitCode` — `exit 42`, verify Wait returns 42 - -**Estimated: ~80 lines code + ~100 lines tests** - ---- - -## Step 2: `tai/proxy` — Connect - -**Files:** `tai/proxy/proxy.go`, `tai/proxy/connect.go` (new) - -### 2.1 Add to Proxy interface - -```go -// proxy.go — extend interface -type Proxy interface { - URL(ctx context.Context, containerID string, port int, path string) (string, error) - Connect(ctx context.Context, containerID string, port int, opts ConnectOptions) (*Connection, error) - Healthz(ctx context.Context) error -} - -type ConnectOptions struct { - Protocol string // "ws", "sse", "tcp"; default "ws" - Path string - Headers map[string]string -} - -type Connection struct { - Read func() ([]byte, error) - Write func(data []byte) error - Events <-chan []byte - URL string - Close func() error -} -``` - -### 2.2 Implementation — `connect.go` - -Local: resolve URL via `URL()`, then dial directly. -Remote: resolve URL via `URL()` (points to Tai HTTP proxy), then dial. - -Both modes use the same dialing logic after URL resolution: -- **WebSocket**: `gorilla/websocket.Dialer.DialContext` -- **SSE**: `http.Get` + chunked body reader, parse `data:` lines into Events channel -- **TCP**: `net.Dial` - -### 2.3 Tests - -- `TestConnectWebSocket` — start a WS echo server in container, connect, send/receive -- `TestConnectSSE` — start an SSE server in container, connect, verify events arrive -- Skip TCP for now (less common use case) - -**Estimated: ~120 lines code + ~80 lines tests** - ---- - -## Step 3: `yao/grpc` — Heartbeat RPC - -**Files:** `grpc/pb/yao.proto`, `grpc/sandbox/heartbeat.go` (new), `grpc/api/api.go` - -### 3.1 Proto - -```protobuf -// Add to service Yao: -rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); - -message HeartbeatRequest { - string sandbox_id = 1; - bool active = 2; - int32 process_count = 3; -} -message HeartbeatResponse {} -``` - -Regenerate: `protoc --go_out=. --go-grpc_out=. grpc/pb/yao.proto` - -### 3.2 Handler - -```go -// grpc/sandbox/heartbeat.go -func (s *Server) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) { - err := sandbox.M().Heartbeat(req.SandboxId, req.Active, int(req.ProcessCount)) - if err != nil { - return nil, status.Errorf(codes.NotFound, "sandbox %s: %v", req.SandboxId, err) + if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" { + // ... K8s pool with kubeconfig, namespace, ports + pools = append(pools, poolConfig{Name: "k8s", ...}) } - return &pb.HeartbeatResponse{}, nil + return pools } ``` -### 3.3 Register in server - -Wire into `grpc/api/api.go` server registration (same pattern as Healthz). - -### 3.4 ACL virtual endpoint - -Add to `grpc/auth/endpoints.go`: +Every test iterates over all available pools: ```go -// Heartbeat → POST /grpc/heartbeat (reuse existing container token scope) -``` - -### 3.5 Tests - -- `TestHeartbeat_Success` — create sandbox, send heartbeat, verify no error -- `TestHeartbeat_NotFound` — send heartbeat with unknown sandbox_id, verify NotFound -- `TestHeartbeat_Auth` — verify token auth works (reuse testutils) - -**Estimated: ~40 lines code + ~50 lines tests** - ---- - -## Step 4: `tai/grpc` — yao-grpc heartbeat goroutine - -**Files:** `tai/grpc/cmd/main.go` (or equivalent entry point), `tai/grpc/heartbeat.go` (new) - -### 4.1 Heartbeat loop - -```go -// tai/grpc/heartbeat.go -func heartbeatLoop(ctx context.Context, client *grpc.Client, sandboxID string) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - count := countUserProcesses() - if count > 0 { - client.Heartbeat(ctx, sandboxID, true, int32(count)) - } - case <-ctx.Done(): - return - } +func TestSomething(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + // test logic + }) } } - -func countUserProcesses() int { - // exec: ps -eo comm --no-headers - // filter out: sleep, init, yao-grpc, sh, bash (if parent is sleep) - // return count -} ``` -### 4.2 Wire into main +### Benchmark Helpers ```go -// In main() or NewFromEnv(), after client is connected: -sandboxID := os.Getenv("YAO_SANDBOX_ID") -if sandboxID != "" { - go heartbeatLoop(ctx, client, sandboxID) -} +func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager +func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) +func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box ``` -Note: `heartbeatLoop` calls `client.Heartbeat()` (new public method on `tai/grpc.Client`), not `client.svc` (private). - -### 4.3 Tests - -- `TestCountUserProcesses` — unit test for process filtering logic -- `TestHeartbeatLoop_SendsWhenActive` — mock gRPC client, start background process, verify heartbeat sent -- `TestHeartbeatLoop_SilentWhenIdle` — no user processes, verify no RPC calls - -**Estimated: ~40 lines code + ~40 lines tests** +K8s-specific behavior: +- `BenchmarkStopStart`: skipped (K8s Stop deletes Pod) +- Create/Lifecycle benchmarks: 120s timeout for K8s Pod scheduling --- -## Step 4.5: Docker — V2 Test Images - -**Depends on:** Steps 1–4 (tai SDK ExecStream, proxy Connect, yao-grpc heartbeat) - -Sandbox V2 tests need containers that have `yao-grpc` (with heartbeat) pre-installed. Also need a test-specific image with Nginx for Attach WS/SSE testing. - -**Files:** `sandbox/docker/v2/` (new directory) - -### Image hierarchy - -``` -sandbox-v2-base ← base + yao-grpc + claude-proxy -sandbox-v2-test ← v2-base + nginx (WS echo + SSE endpoint) -``` - -### 4.5.1 `sandbox/docker/v2/Dockerfile.base` - -```dockerfile -FROM yaoapp/sandbox-base:latest - -# Replace yao-bridge with yao-grpc -ARG TARGETARCH -COPY yao-grpc-${TARGETARCH} /usr/local/bin/yao-grpc -RUN chmod +x /usr/local/bin/yao-grpc - -# Claude API proxy (OpenAPI-compatible) -COPY claude-proxy-${TARGETARCH} /usr/local/bin/claude-proxy -RUN chmod +x /usr/local/bin/claude-proxy - -# yao-grpc auto-start: if YAO_SANDBOX_ID is set, start heartbeat + serve -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - -WORKDIR /workspace -USER sandbox -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["sleep", "infinity"] -``` - -### 4.5.2 `sandbox/docker/v2/entrypoint.sh` - -```bash -#!/bin/bash -# Start yao-grpc in background if sandbox env vars are present -if [ -n "$YAO_SANDBOX_ID" ] && [ -n "$YAO_GRPC_ADDR" ]; then - yao-grpc serve & -fi - -# Start claude-proxy if config exists -if [ -n "$CLAUDE_PROXY_BACKEND" ] || [ -f /workspace/.claude-proxy.json ]; then - claude-proxy & -fi - -exec "$@" -``` - -### 4.5.3 `sandbox/docker/v2/Dockerfile.test` - -For unit tests — adds Nginx with a simple WS echo server and SSE endpoint. - -```dockerfile -FROM yaoapp/sandbox-v2-base:latest - -USER root - -# Nginx + test services -RUN apt-get update && apt-get install -y nginx python3 && rm -rf /var/lib/apt/lists/* - -# WS echo server (Python, ~15 lines) -COPY ws-echo.py /opt/test/ws-echo.py - -# SSE endpoint (Python, ~15 lines) -COPY sse-server.py /opt/test/sse-server.py - -# Nginx config — proxy WS on :3000, SSE on :3001 -COPY nginx-test.conf /etc/nginx/sites-available/default - -# Test entrypoint — start nginx + test services + original entrypoint -COPY test-entrypoint.sh /usr/local/bin/test-entrypoint.sh -RUN chmod +x /usr/local/bin/test-entrypoint.sh - -USER sandbox -WORKDIR /workspace -ENTRYPOINT ["/usr/local/bin/test-entrypoint.sh"] -CMD ["sleep", "infinity"] -``` - -### 4.5.4 Test services - -**`ws-echo.py`** — WebSocket echo on port 3000: - -```python -#!/usr/bin/env python3 -import asyncio, websockets -async def echo(ws): - async for msg in ws: - await ws.send(msg) -asyncio.run(websockets.serve(echo, "0.0.0.0", 3000)) -``` - -**`sse-server.py`** — SSE endpoint on port 3001: - -```python -#!/usr/bin/env python3 -from http.server import HTTPServer, BaseHTTPRequestHandler -import time -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - for i in range(5): - self.wfile.write(f"data: event-{i}\n\n".encode()) - self.wfile.flush() - time.sleep(0.1) -HTTPServer(("0.0.0.0", 3001), Handler).serve_forever() -``` - -**`test-entrypoint.sh`**: - -```bash -#!/bin/bash -python3 /opt/test/ws-echo.py & -python3 /opt/test/sse-server.py & -exec /usr/local/bin/entrypoint.sh "$@" -``` - -### 4.5.5 Build script update - -Add `v2` and `v2-test` targets to `sandbox/docker/build.sh`: - -```bash -v2) - echo "=== Building V2 images ===" - # Build yao-grpc binary (replaces yao-bridge) - cd "$SCRIPT_DIR/../../tai/grpc/cmd" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/yao-grpc-amd64" . - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/yao-grpc-arm64" . - cd "$SCRIPT_DIR" - - # Build claude-proxy binary - cd "$SCRIPT_DIR/../proxy/cmd/claude-proxy" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/claude-proxy-amd64" . - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/claude-proxy-arm64" . - cd "$SCRIPT_DIR" - - build_multiarch "sandbox-v2-base" "v2/Dockerfile.base" "$PUSH" - build_multiarch "sandbox-v2-test" "v2/Dockerfile.test" "$PUSH" - ;; -``` - -### 4.5.6 Image usage - -| Image | Purpose | Used by | -|-------|---------|---------| -| `sandbox-v2-base` | Production base for V2 sandboxes. Has `yao-grpc` (heartbeat) + `claude-proxy`. | `Manager.Create()` default image candidate | -| `sandbox-v2-test` | Unit tests. Has WS echo + SSE server for Attach testing. | `SANDBOX_TEST_IMAGE` in CI and local dev | - -### 4.5.7 Env update - -```bash -# env.local.sh — change test image to v2-test -export SANDBOX_TEST_IMAGE="yaoapp/sandbox-v2-test:latest" -``` - -**Estimated: ~5 files (Dockerfiles + scripts + test services), ~100 lines** - ---- - -## Step 5: `sandbox/v2` — Core Module - -**Files:** all in `sandbox/v2/` - -### 5.1 `errors.go` - -```go -var ( - ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") - ErrNotFound = errors.New("sandbox: not found") - ErrLimitExceeded = errors.New("sandbox: limit exceeded") - ErrPoolNotFound = errors.New("sandbox: pool not found") - ErrPoolInUse = errors.New("sandbox: pool has running boxes") -) -``` - -### 5.2 `types.go` - -All type definitions from DESIGN.md: -- `LifecyclePolicy`, `Pool`, `PoolInfo`, `PortMapping` -- `CreateOptions`, `ListOptions` -- `ExecOption`, `ExecResult`, `ExecStream` -- `AttachOption`, `ServiceConn`, `ConnectOptions` -- `BoxInfo` - -### 5.3 `config.go` - -```go -type Config struct { - Pool []Pool -} -``` - -### 5.4 `sandbox.go` — singleton - -```go -var mgr *Manager - -func Init(cfg Config) error { - m, err := newManager(cfg) - if err != nil { return err } - mgr = m - return nil -} - -func M() *Manager { - if mgr == nil { panic("sandbox.Init not called") } - return mgr -} -``` - -### 5.5 `manager.go` - -Core implementation. Key methods: - -| Method | Logic | -|--------|-------| -| `newManager(cfg)` | Parse pool defs, set default pool | -| `Start(ctx)` | For each pool: connect, list containers with `managed-by=yao-sandbox`, rebuild boxes map, start cleanupLoop | -| `Create(ctx, opts)` | Validate → check limits → resolve pool → lazy-connect tai.Client → create OAuth tokens → build tai.CreateOptions (merge env, labels, field mapping) → tai.Create → tai.Start → wrap Box → register | -| `Get(ctx, id)` | Lookup boxes map | -| `GetOrCreate(ctx, opts)` | Get by ID, if not found → Create | -| `List(ctx, opts)` | Filter boxes by owner/pool/labels | -| `Remove(ctx, id)` | Lookup box → tai.Stop → tai.Remove → revoke OAuth token → delete from map | -| `Cleanup(ctx)` | Range boxes, apply policy-based idle/lifetime rules | -| `Close()` | Cancel cleanup loop, close all tai.Clients | -| `Heartbeat(id, active, count)` | Lookup box → update lastHeartbeat + processCount atomics | -| `AddPool(ctx, p)` | Validate name unique → append to poolDefs | -| `RemovePool(ctx, name, force)` | Check no boxes (or force-remove them) → remove from poolDefs → close tai.Client if connected | -| `Pools()` | Return PoolInfo slice | - -Internal helpers: -- `getPool(name)` — lazy-connect tai.Client from poolDefs -- `buildTaiCreateOptions(opts, pool)` — field mapping + env injection + label injection -- `recoverBoxes(ctx, pool, client)` — list + parse labels + rebuild Box structs - -### 5.6 `box.go` - -```go -type Box struct { /* fields from DESIGN.md */ } - -func (b *Box) Exec(ctx, cmd, opts) // b.touch() → tai.Sandbox().Exec(b.containerID, ...) -func (b *Box) Stream(ctx, cmd, opts) // b.touch() → tai.Sandbox().ExecStream(b.containerID, ...) -func (b *Box) Attach(ctx, port, opts) // b.touch() → tai.Proxy().Connect(b.containerID, port, ...) -func (b *Box) Workspace() // lazy-init: tai.Client.Workspace(b.id) -func (b *Box) VNC(ctx) // b.touch() → tai.VNC().URL(b.containerID) -func (b *Box) Proxy(ctx, port, path) // b.touch() → tai.Proxy().URL(b.containerID, port, path) -func (b *Box) Start(ctx) // tai.Sandbox().Start(b.containerID) -func (b *Box) Stop(ctx) // tai.Sandbox().Stop(b.containerID, 10s) -func (b *Box) Remove(ctx) // b.manager.Remove(ctx, b.id) -func (b *Box) Info(ctx) // tai.Sandbox().Inspect + merge with box metadata - -func (b *Box) touch() // b.lastCall.Store(time.Now().UnixMilli()) -func (b *Box) lastActiveTime() Time // max(lastCall, lastHeartbeat) -func (b *Box) idleTimeout() Duration // box-level override or pool default -func (b *Box) maxLifetime() Duration // pool default -``` - -### 5.7 `grpc.go` — OAuth token injection - -```go -func createContainerTokens(sandboxID, owner string) (access, refresh string, err error) -func revokeContainerTokens(refresh string) error -func buildGRPCEnv(pool *Pool, sandboxID, access, refresh string) map[string]string -``` - -Uses `openapi/oauth` to create token pairs. Local mode: `YAO_GRPC_ADDR=127.0.0.1:`. Remote mode: adds `YAO_GRPC_TAI=enable` + `YAO_GRPC_UPSTREAM`. - -**Estimated: ~600 lines code total** - ---- - -## Step 6: Tests - -### 6.1 Test environment - -Two pools configured via env vars (reuse existing CI infrastructure): - -``` -# Local pool — direct Docker -SANDBOX_TEST_LOCAL_ADDR=local (default Docker daemon) - -# Remote pool — via Tai container (same as tai-test job) -SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 (uses TAI_TEST_* ports) -``` - -Skip tests when Docker/Tai unavailable: `t.Skipf`. - -### 6.2 Test files - -| File | Coverage | -|------|----------| -| `sandbox_test.go` | `Init()`, `M()`, singleton behavior | -| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool management | -| `manager_lifecycle_test.go` | Start (container discovery), Cleanup, idle tracking | -| `box_test.go` | Exec, Stream, Workspace (ReadFile/WriteFile/MkdirAll), Proxy, VNC, lifecycle | -| `box_attach_test.go` | Attach with WS/SSE (requires service in container) | -| `grpc_test.go` | Token creation/revocation, env var building | - -### 6.3 Key test scenarios - -| Test | What it verifies | -|------|-----------------| -| `TestCreateAndExec` | Create box → exec `echo hello` → verify stdout → remove | -| `TestCreateWithLabels` | Create → inspect labels → list with label filter | -| `TestWorkspace` | Create → WriteFile → ReadFile → verify content match | -| `TestIdleCleanup` | Create with Session + 1s idle timeout → wait → verify removed | -| `TestStartRecovery` | Create → restart Manager → Start → verify box recovered from labels | -| `TestPoolLimits` | Set MaxTotal=1 → create 1 → create 2nd → verify ErrLimitExceeded | -| `TestHeartbeatUpdates` | Create → call Heartbeat → verify lastActive updated | -| `TestStream` | Create → stream `sh -c "echo a; sleep 0.1; echo b"` → verify chunks arrive | -| `TestMultiPool` | Create on local → create on remote → verify both work | - -### 6.4 CI integration - -Add `sandbox-v2-test` job to `unit-test.yml` (same pattern as existing `sandbox-test` + `tai-test`): - -```yaml -sandbox-v2-test: - runs-on: ubuntu-latest - services: - # MongoDB (for Yao runtime) - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - - name: Start Tai container - run: | - docker run -d --name tai \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -p 2375:2375 -p 9100:9100 -p 8080:8080 \ - yaoapp/tai:latest - - name: Build V2 test image - run: | - cd sandbox/docker - bash build.sh v2 - - name: Run tests - env: - SANDBOX_TEST_LOCAL_ADDR: "local" - SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1" - SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest" - TAI_TEST_HOST: "127.0.0.1" - run: go test -v -count=1 ./sandbox/v2/... -``` - -**Estimated: ~400 lines tests** - ---- - -## Summary - -| Step | Package | Lines (code) | Lines (test) | Depends on | -|------|---------|-------------|-------------|------------| -| 0 | `tai/sandbox` | ~20 | ~60 | — | -| 1 | `tai/sandbox` | ~80 | ~100 | — | -| 2 | `tai/proxy` | ~120 | ~80 | — | -| 3 | `yao/grpc` | ~40 | ~50 | — | -| 4 | `tai/grpc` | ~40 | ~40 | Step 3 | -| 4.5 | `sandbox/docker/v2` | ~100 | — | Steps 1–4 | -| 5 | `sandbox/v2` | ~600 | — | Steps 0–4 | -| 6 | `sandbox/v2` | — | ~400 | Steps 5 + 4.5 | -| **Total** | | **~1000** | **~730** | | - -Steps 0–3 can start in parallel. Step 4 needs Step 3's proto. Step 5 needs all prerequisites done. Step 6 runs after Step 5. +## File Inventory + +### sandbox/v2 (7 source + 10 test = 17 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `sandbox.go` | ~25 | Global singleton | +| `manager.go` | ~620 | Manager implementation | +| `box.go` | ~230 | Box implementation | +| `types.go` | ~170 | Type definitions | +| `config.go` | ~5 | Config struct | +| `errors.go` | ~10 | Error definitions | +| `grpc.go` | ~55 | Token/env injection | +| `testutils_test.go` | ~130 | Test helpers | +| `sandbox_test.go` | ~30 | Singleton tests | +| `manager_test.go` | ~250 | CRUD tests | +| `manager_lifecycle_test.go` | ~120 | Lifecycle tests | +| `box_test.go` | ~200 | Box tests | +| `box_attach_test.go` | ~260 | Attach/VNC tests | +| `box_workspace_test.go` | ~285 | Workspace tests | +| `box_image_test.go` | ~120 | Image tests | +| `grpc_test.go` | ~80 | Token tests | +| `bench_test.go` | ~230 | Benchmarks | + +### workspace (3 source + 4 test = 7 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `workspace.go` | ~80 | Types + metadata | +| `manager.go` | ~320 | Manager implementation | +| `errors.go` | ~10 | Error definitions | +| `testutils_test.go` | ~90 | Test helpers | +| `workspace_test.go` | ~325 | CRUD tests | +| `fileio_test.go` | ~235 | File I/O tests | +| `bench_test.go` | ~150 | Benchmarks |