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.
This commit is contained in:
Max 2026-03-05 21:28:55 +08:00
parent d25bfefee0
commit dd08881027
2 changed files with 731 additions and 1438 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 14)
Step 5: sandbox/v2 — core module (depends on Steps 04)
Step 6: tests (depends on Steps 5 + 4.5)
```
## Phase 1: Core Module — DONE
Steps 03 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=<id>
sandbox-owner=<owner>
sandbox-pool=<pool>
sandbox-policy=<policy>
workspace-id=<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 14 (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:<port>`. 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 14 |
| 5 | `sandbox/v2` | ~600 | — | Steps 04 |
| 6 | `sandbox/v2` | — | ~400 | Steps 5 + 4.5 |
| **Total** | | **~1000** | **~730** | |
Steps 03 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 |