Merge pull request #1451 from trheyi/main

Fix sandbox compatibility, claude-proxy streaming
This commit is contained in:
Max 2026-02-06 13:17:35 +08:00 committed by GitHub
commit 8a582ce149
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3784 additions and 404 deletions

3
.gitignore vendored
View file

@ -56,8 +56,11 @@ agent/test/MULTI_TURN_DESIGN.md
agent/test/UPGRADE_PLAN.md
introduction/*
!sandbox/docker/build.sh
!sandbox/docker/vnc/*.sh
!sandbox/docker/desktop/config/*.sh
sandbox/docker/yao-bridge-*
sandbox/docker/claude-proxy-*
sandbox/docker/claude/claude-proxy-*
sandbox/proxy/claude-proxy-linux-*
release/*
sandbox/TODO-VNC.md

View file

@ -4,6 +4,7 @@ import (
"context"
"github.com/yaoapp/gou/runtime/v8/bridge"
openapiSandbox "github.com/yaoapp/yao/openapi/sandbox"
infraSandbox "github.com/yaoapp/yao/sandbox"
"rogchap.com/v8go"
)
@ -22,6 +23,12 @@ type SandboxExecutor interface {
// Workspace info
GetWorkDir() string
// Sandbox identification
GetSandboxID() string
// VNC access (returns empty string if not available)
GetVNCUrl() string
}
// SetSandboxExecutor sets the sandbox executor for this context
@ -54,6 +61,8 @@ func (ctx *Context) newSandboxObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
sandboxObj.Set("WriteFile", ctx.sandboxWriteFileMethod(iso))
sandboxObj.Set("ListDir", ctx.sandboxListDirMethod(iso))
sandboxObj.Set("Exec", ctx.sandboxExecMethod(iso))
sandboxObj.Set("GetVNCUrl", ctx.sandboxGetVNCUrlMethod(iso))
sandboxObj.Set("GetSandboxID", ctx.sandboxGetSandboxIDMethod(iso))
return sandboxObj
}
@ -72,6 +81,19 @@ func (ctx *Context) createSandboxInstance(v8ctx *v8go.Context) *v8go.Value {
// Set workdir as a property
sandboxTemplate.Set("workdir", ctx.sandboxExecutor.GetWorkDir())
// Set sandbox_id as a property
sandboxID := ctx.sandboxExecutor.GetSandboxID()
sandboxTemplate.Set("sandbox_id", sandboxID)
// Set vnc_url as a property (empty string if not available)
// GetVNCUrl returns sandbox ID if VNC is supported, empty otherwise
vncSandboxID := ctx.sandboxExecutor.GetVNCUrl()
if vncSandboxID != "" {
sandboxTemplate.Set("vnc_url", openapiSandbox.GetVNCClientURL(vncSandboxID))
} else {
sandboxTemplate.Set("vnc_url", "")
}
instance, err := sandboxTemplate.NewInstance(v8ctx)
if err != nil {
return nil
@ -233,3 +255,48 @@ func (ctx *Context) sandboxExecMethod(iso *v8go.Isolate) *v8go.FunctionTemplate
return jsVal
})
}
// sandboxGetVNCUrlMethod implements ctx.sandbox.GetVNCUrl()
func (ctx *Context) sandboxGetVNCUrlMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
if ctx.sandboxExecutor == nil {
return bridge.JsException(v8ctx, "sandbox executor not available")
}
// GetVNCUrl returns sandbox ID if VNC is supported, empty otherwise
vncSandboxID := ctx.sandboxExecutor.GetVNCUrl()
vncUrl := ""
if vncSandboxID != "" {
vncUrl = openapiSandbox.GetVNCClientURL(vncSandboxID)
}
jsVal, err := v8go.NewValue(iso, vncUrl)
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
return jsVal
})
}
// sandboxGetSandboxIDMethod implements ctx.sandbox.GetSandboxID()
func (ctx *Context) sandboxGetSandboxIDMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
if ctx.sandboxExecutor == nil {
return bridge.JsException(v8ctx, "sandbox executor not available")
}
sandboxID := ctx.sandboxExecutor.GetSandboxID()
jsVal, err := v8go.NewValue(iso, sandboxID)
if err != nil {
return bridge.JsException(v8ctx, err.Error())
}
return jsVal
})
}

View file

@ -88,6 +88,17 @@ func (e *realSandboxExecutor) GetWorkDir() string {
return e.workDir
}
func (e *realSandboxExecutor) GetSandboxID() string {
// Extract sandbox ID from container name (format: yao-sandbox-{userID}-{chatID})
// For tests, just return a mock ID
return "test-user-test-chat"
}
func (e *realSandboxExecutor) GetVNCUrl() string {
// Tests don't use VNC, return empty
return ""
}
// TestJsSandboxNotAvailable tests ctx.sandbox when not configured
func TestJsSandboxNotAvailable(t *testing.T) {
test.Prepare(t, config.Conf)

View file

@ -79,7 +79,12 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er
// Create or get container
// Note: IPC session is created by manager.createContainer, socket is already bind mounted
ctx := context.Background()
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID)
createOpts := infraSandbox.CreateOptions{
UserID: execOpts.UserID,
ChatID: execOpts.ChatID,
Image: execOpts.Image,
}
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID, createOpts)
if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err)
}
@ -556,11 +561,21 @@ func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, hand
switch eventType {
case "content_block_start":
// Check if this is a tool_use block starting
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}}
// Handle new content blocks
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use"|"text",...}}}
if contentBlock, ok := event["content_block"].(map[string]interface{}); ok {
blockType, _ := contentBlock["type"].(string)
if blockType == "tool_use" {
switch blockType {
case "text":
// New text block starting - add paragraph separator if we already have content
// This ensures proper separation between text blocks across tool-use rounds
if textContent.Len() > 0 {
textContent.WriteString("\n\n")
if handler != nil && messageStarted {
handler(message.ChunkText, []byte("\n\n"))
}
}
case "tool_use":
toolName, _ := contentBlock["name"].(string)
blockIndex := 0
if idx, ok := event["index"].(float64); ok {
@ -1073,6 +1088,36 @@ func (e *Executor) GetWorkDir() string {
return e.workDir
}
// GetSandboxID returns the sandbox ID (userID-chatID)
func (e *Executor) GetSandboxID() string {
if e.opts == nil {
return ""
}
return fmt.Sprintf("%s-%s", e.opts.UserID, e.opts.ChatID)
}
// GetVNCUrl returns the VNC preview URL path
// Returns empty string if VNC is not enabled for this sandbox image
func (e *Executor) GetVNCUrl() string {
if e.opts == nil {
return ""
}
// Check if the image supports VNC (playwright or desktop variants)
imageName := e.opts.Image
if imageName == "" {
return ""
}
// VNC is only available for playwright and desktop images
if !strings.Contains(imageName, "playwright") && !strings.Contains(imageName, "desktop") {
return ""
}
// Return only the sandbox ID, the full URL is constructed by openapi/sandbox.GetVNCClientURL()
return e.GetSandboxID()
}
// Close releases the executor resources and removes the container
// Note: IPC session is managed by sandbox.Manager.Remove()
func (e *Executor) Close() error {

View file

@ -32,6 +32,13 @@ type Executor interface {
// GetWorkDir returns the container workspace directory
GetWorkDir() string
// GetSandboxID returns the sandbox ID (userID-chatID)
GetSandboxID() string
// GetVNCUrl returns the VNC preview URL path (e.g., /api/__yao/vnc/{sandboxID}/)
// Returns empty string if VNC is not enabled for this sandbox image
GetVNCUrl() string
// Close releases container resources
Close() error
}

File diff suppressed because one or more lines are too long

View file

@ -93,6 +93,7 @@ func (config *Config) MarshalJSON() ([]byte, error) {
IPBlacklist: config.OAuth.Security.IPBlacklist,
RequireHTTPS: config.OAuth.Security.RequireHTTPS,
DisableUnsecureEndpoints: config.OAuth.Security.DisableUnsecureEndpoints,
SecureCookie: config.OAuth.Security.SecureCookie,
},
Client: TempClientConfig{
DefaultClientType: config.OAuth.Client.DefaultClientType,
@ -215,6 +216,7 @@ func (config *Config) UnmarshalJSON(data []byte) error {
IPBlacklist: tempConfig.OAuth.Security.IPBlacklist,
RequireHTTPS: tempConfig.OAuth.Security.RequireHTTPS,
DisableUnsecureEndpoints: tempConfig.OAuth.Security.DisableUnsecureEndpoints,
SecureCookie: tempConfig.OAuth.Security.SecureCookie,
}
if tempConfig.OAuth.Security.StateParameterLifetime != "" {
if duration, err := parseDuration(tempConfig.OAuth.Security.StateParameterLifetime); err == nil {

View file

@ -109,7 +109,8 @@ func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) {
func (s *Service) getAccessToken(c *gin.Context) string {
token := c.GetHeader("Authorization")
if token == "" {
cookie, err := c.Cookie("__Host-access_token")
cookieName := response.GetCookieName("access_token")
cookie, err := c.Cookie(cookieName)
if err != nil {
return ""
}
@ -176,7 +177,8 @@ func (s *Service) GetAccessToken(c *gin.Context) string {
func (s *Service) getRefreshToken(c *gin.Context) string {
token := c.GetHeader("Authorization")
if token == "" {
cookie, err := c.Cookie("__Host-refresh_token")
cookieName := response.GetCookieName("refresh_token")
cookie, err := c.Cookie(cookieName)
if err != nil {
return ""
}
@ -200,7 +202,8 @@ func (s *Service) getSessionID(c *gin.Context) string {
}
// 1. Try to get Session ID from cookies first
if sid, err := c.Cookie("__Host-session_id"); err == nil && sid != "" {
cookieName := response.GetCookieName("session_id")
if sid, err := c.Cookie(cookieName); err == nil && sid != "" {
return sid
}

View file

@ -179,6 +179,14 @@ func (s *Service) GetStore() store.Store {
return s.store
}
// GetSecurityConfig returns the security configuration for the service
func (s *Service) GetSecurityConfig() types.SecurityConfig {
if s.config == nil {
return types.SecurityConfig{}
}
return s.config.Security
}
// setConfigDefaults sets default values for configuration
func setConfigDefaults(config *Config) error {
// Certificate defaults

View file

@ -575,6 +575,9 @@ type SecurityConfig struct {
IPBlacklist []string `json:"ip_blacklist,omitempty"` // Optional: IP addresses blocked from access (default: [])
RequireHTTPS bool `json:"require_https"` // Optional: Require HTTPS for all endpoints (default: true)
DisableUnsecureEndpoints bool `json:"disable_unsecure_endpoints"` // Optional: Disable non-HTTPS endpoints (default: false)
// Cookie security settings
SecureCookie *bool `json:"secure_cookie,omitempty"` // Optional: Use __Host- prefix and Secure flag for cookies (default: true). Set to false for non-HTTPS dev environments with non-localhost IPs.
}
// TokenClaims represents decoded token claims for both JWT and opaque tokens

View file

@ -21,6 +21,8 @@ import (
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/acl"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/openapi/sandbox"
"github.com/yaoapp/yao/openapi/team"
openapiTrace "github.com/yaoapp/yao/openapi/trace"
"github.com/yaoapp/yao/openapi/user"
@ -63,6 +65,10 @@ func Load(appConfig config.Config) (*OpenAPI, error) {
return nil, err
}
// Set the secure cookie configuration for the response package
// This determines whether to use __Host- prefix and Secure flag for cookies
response.SetSecureCookieEnabled(oauthConfig.Security.SecureCookie)
// Load user configurations
err = user.Load(appConfig)
if err != nil {
@ -154,6 +160,10 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
// App handlers (menu, etc.)
app.Attach(group.Group("/app"), openapi.OAuth)
// Sandbox handlers (VNC proxy for visual browser automation)
sandbox.SetPathPrefix(baseURL)
sandbox.Attach(group.Group("/sandbox"), openapi.OAuth)
// Custom handlers (Defined by developer)
}

View file

@ -8,6 +8,32 @@ import (
"github.com/yaoapp/yao/openapi/oauth/types"
)
// secureCookieEnabled is the global setting for secure cookie behavior
// Default is nil (meaning true/enabled). Set to false to disable __Host- prefix and Secure flag.
// This is set during OAuth initialization based on the secure_cookie config.
var secureCookieEnabled *bool
// SetSecureCookieEnabled sets the global secure cookie setting
// This should be called during OAuth initialization
func SetSecureCookieEnabled(enabled *bool) {
secureCookieEnabled = enabled
}
// IsSecureCookieEnabled returns whether secure cookie is enabled
// Returns true if secureCookieEnabled is nil or true
func IsSecureCookieEnabled() bool {
return secureCookieEnabled == nil || *secureCookieEnabled
}
// GetCookieName returns the correct cookie name based on secure cookie setting
// If secure cookie is enabled, it returns "__Host-" + name, otherwise just name
func GetCookieName(name string) string {
if IsSecureCookieEnabled() {
return "__Host-" + name
}
return name
}
// Type aliases for OAuth types to simplify usage
type (
// Core response types
@ -202,13 +228,14 @@ type SecureCookieOptions struct {
}
// NewSecureCookieOptions creates a new SecureCookieOptions with secure defaults
// The UseHostPrefix is determined by the secure_cookie configuration in openapi.yao
func NewSecureCookieOptions() *SecureCookieOptions {
return &SecureCookieOptions{
MaxAge: 0, // Session cookie by default
Path: "/", // Root path
Domain: "", // Current domain
SameSite: "Lax", // Default SameSite policy
UseHostPrefix: true, // Use most secure __Host- prefix
MaxAge: 0, // Session cookie by default
Path: "/", // Root path
Domain: "", // Current domain
SameSite: "Lax", // Default SameSite policy
UseHostPrefix: IsSecureCookieEnabled(), // Determined by secure_cookie config
}
}
@ -284,12 +311,15 @@ func SendSecureCookieWithOptions(c *gin.Context, key string, value string, optio
cookiePath := options.Path
cookieDomain := options.Domain
if options.UseHostPrefix {
// Use the global secure cookie setting
useSecureCookie := IsSecureCookieEnabled()
if options.UseHostPrefix && useSecureCookie {
// __Host- prefix: Requires Secure flag, no Domain attribute, Path=/
cookieName = "__Host-" + key
cookiePath = "/" // Must be "/" for __Host- prefix
cookieDomain = "" // Must be empty for __Host- prefix
} else if options.UseSecurePrefix {
} else if options.UseSecurePrefix && useSecureCookie {
// __Secure- prefix: Requires Secure flag, allows Domain and Path
cookieName = "__Secure-" + key
}
@ -319,7 +349,7 @@ func SendSecureCookieWithOptions(c *gin.Context, key string, value string, optio
effectiveMaxAge, // maxAge (calculated from Expires if needed)
cookiePath, // path
cookieDomain, // domain
true, // secure (HTTPS only) - required for security prefixes
useSecureCookie, // secure (HTTPS only) - based on secure_cookie config
true, // httpOnly (prevent XSS access)
)

119
openapi/sandbox/sandbox.go Normal file
View file

@ -0,0 +1,119 @@
package sandbox
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/sandbox/vncproxy"
)
var vncProxy *vncproxy.Proxy
// Attach attaches sandbox handlers to the router group
// Routes:
// - GET /sandbox/:id/vnc - Get VNC status
// - GET /sandbox/:id/vnc/client - Get noVNC client page
// - GET /sandbox/:id/vnc/ws - WebSocket proxy to container VNC
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// Initialize VNC proxy lazily on first request
// This avoids startup errors if Docker is not available
// VNC status endpoint
group.GET("/:id/vnc", oauth.Guard, handleVNCStatus)
// VNC client page
group.GET("/:id/vnc/client", oauth.Guard, handleVNCClient)
// VNC WebSocket proxy
group.GET("/:id/vnc/ws", oauth.Guard, handleVNCWebSocket)
}
// ensureProxy ensures the VNC proxy is initialized
func ensureProxy() error {
if vncProxy != nil {
return nil
}
var err error
vncProxy, err = vncproxy.NewProxy(nil)
return err
}
// handleVNCStatus returns VNC status for a sandbox container
func handleVNCStatus(c *gin.Context) {
if err := ensureProxy(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "VNC service not available",
})
return
}
// Rewrite path to match vncproxy expected format
sandboxID := c.Param("id")
c.Request.URL.Path = "/v1/sandbox/" + sandboxID + "/vnc"
vncProxy.HandleVNCStatus(c.Writer, c.Request)
}
// handleVNCClient serves the noVNC client page
func handleVNCClient(c *gin.Context) {
if err := ensureProxy(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "VNC service not available",
})
return
}
// Rewrite path to match vncproxy expected format
sandboxID := c.Param("id")
c.Request.URL.Path = "/v1/sandbox/" + sandboxID + "/vnc/client"
vncProxy.HandleVNCClient(c.Writer, c.Request)
}
// handleVNCWebSocket proxies WebSocket to container VNC
func handleVNCWebSocket(c *gin.Context) {
if err := ensureProxy(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "VNC service not available",
})
return
}
// Rewrite path to match vncproxy expected format
sandboxID := c.Param("id")
c.Request.URL.Path = "/v1/sandbox/" + sandboxID + "/vnc/ws"
vncProxy.HandleVNCWebSocket(c.Writer, c.Request)
}
// Close closes the VNC proxy and releases resources
func Close() error {
if vncProxy != nil {
return vncProxy.Close()
}
return nil
}
// pathPrefix stores the router path prefix for sandbox endpoints
var pathPrefix string = "/v1/sandbox"
// SetPathPrefix sets the path prefix for sandbox URLs
// Called during router setup with the actual OpenAPI base URL
func SetPathPrefix(prefix string) {
pathPrefix = strings.TrimSuffix(prefix, "/") + "/sandbox"
}
// GetVNCClientURL returns the API VNC client page URL
// sandboxID is the sandbox identifier (userID-chatID)
// Returns the URL path like "/v1/sandbox/{id}/vnc/client"
// Note: For CUI navigation, use "$dashboard/sandbox/{id}" directly with sandbox_id
func GetVNCClientURL(sandboxID string) string {
if sandboxID == "" {
return ""
}
return fmt.Sprintf("%s/%s/vnc/client", pathPrefix, sandboxID)
}

View file

@ -92,6 +92,7 @@ type TempSecurityConfig struct {
IPBlacklist []string `json:"ip_blacklist,omitempty"`
RequireHTTPS bool `json:"require_https"`
DisableUnsecureEndpoints bool `json:"disable_unsecure_endpoints"`
SecureCookie *bool `json:"secure_cookie,omitempty"`
}
// TempClientConfig represents client configuration with string duration fields

View file

@ -45,6 +45,9 @@ func getEntryConfig(c *gin.Context) {
// Create public config without sensitive data (deep copy to avoid modifying global config)
publicConfig := createPublicEntryConfig(config)
// Add secure_cookie setting from OAuth config
publicConfig.SecureCookie = response.IsSecureCookieEnabled()
// Return the entry configuration
response.RespondWithSuccess(c, response.StatusOK, publicConfig)
}

View file

@ -112,6 +112,7 @@ type EntryConfig struct {
InviteRequired bool `json:"invite_required,omitempty"` // From register config
Invite *InvitePageConfig `json:"invite,omitempty"` // Invite code page configuration
ThirdParty *ThirdParty `json:"third_party,omitempty"`
SecureCookie bool `json:"secure_cookie"` // Whether secure cookie is enabled (for frontend JWT verification)
}
// MessengerConfig represents the messenger configuration for user registration

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
- IPC communication via Unix sockets
- Resource limits (CPU, memory)
- Security isolation
- **VNC remote desktop** for visual transparency (optional)
## Architecture
@ -26,11 +27,20 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
│ │ │ │
│ └────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴────────────────────────────────┐ │
│ │ VNC Proxy Service │ │
│ │ │ │
│ │ - GET /v1/sandbox/{id}/vnc → VNC status │ │
│ │ - GET /v1/sandbox/{id}/vnc/client → noVNC page │ │
│ │ - GET /v1/sandbox/{id}/vnc/ws → WebSocket proxy │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Container │ │ Container │ │ Container │ │
│ │ (user1) │ │ (user2) │ │ (user3) │ │
│ │ sandbox- │ │ sandbox- │ │ sandbox- │ │
│ │ claude │ │ playwright │ │ desktop │ │
│ │ (No VNC) │ │ (VNC) │ │ (VNC) │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ ──────┴───────────────┴───────────────┴──── │
@ -45,7 +55,16 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
```bash
cd sandbox/docker
# Build base image
./build.sh claude
# Build VNC-enabled images
./build.sh browser # Browser (Playwright) + Fluxbox + VNC
./build.sh desktop # XFCE Desktop + VNC
# Build all images
./build.sh all
```
### Usage
@ -84,23 +103,37 @@ data, err := manager.ReadFile(ctx, container.Name, "/workspace/test.txt")
### Environment Variables
| Variable | Default | Description |
| -------------------------- | ----------------------------------- | ------------------------- |
| `YAO_SANDBOX_IMAGE` | `yao/sandbox-claude:latest` | Docker image |
| `YAO_SANDBOX_WORKSPACE` | `{YAO_DATA_ROOT}/sandbox/workspace` | Workspace directory |
| `YAO_SANDBOX_IPC` | `{YAO_DATA_ROOT}/sandbox/ipc` | IPC socket directory |
| `YAO_SANDBOX_MAX` | `100` | Max concurrent containers |
| `YAO_SANDBOX_IDLE_TIMEOUT` | `30m` | Idle timeout |
| `YAO_SANDBOX_MEMORY` | `2g` | Memory limit |
| `YAO_SANDBOX_CPU` | `1.0` | CPU limit |
| Variable | Default | Description |
| ------------------------------ | ----------------------------------- | ---------------------------------------------- |
| `YAO_SANDBOX_IMAGE` | `yao/sandbox-claude:latest` | Docker image |
| `YAO_SANDBOX_WORKSPACE` | `{YAO_DATA_ROOT}/sandbox/workspace` | Workspace directory |
| `YAO_SANDBOX_IPC` | `{YAO_DATA_ROOT}/sandbox/ipc` | IPC socket directory |
| `YAO_SANDBOX_MAX` | `100` | Max concurrent containers |
| `YAO_SANDBOX_IDLE_TIMEOUT` | `30m` | Idle timeout |
| `YAO_SANDBOX_MEMORY` | `2g` | Memory limit |
| `YAO_SANDBOX_CPU` | `1.0` | CPU limit |
| `YAO_SANDBOX_VNC_PORT_MAPPING` | `false` | Enable VNC port mapping (for Docker Desktop) |
### Docker Desktop (macOS/Windows)
Docker Desktop runs containers in a LinuxKit VM, so container IPs are not directly accessible from the host. Enable VNC port mapping for local development:
```bash
export YAO_SANDBOX_VNC_PORT_MAPPING=true
export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-browser:latest"
```
When enabled, VNC ports (6080, 5900) are automatically mapped to random available host ports on `127.0.0.1`.
## Docker Images
| Image | Description |
| --------------------------- | ------------------------------------- |
| `yao/sandbox-base:latest` | Base image with git, curl, yao-bridge |
| `yao/sandbox-claude:latest` | + Claude CLI, Node.js 20, Python 3.11 |
| `yao/sandbox-claude:full` | + Go 1.23 |
| Image | VNC | Description |
| ------------------------------------------ | --- | ------------------------------------- |
| `yaoapp/sandbox-base:latest` | ❌ | Base image with git, curl, yao-bridge |
| `yaoapp/sandbox-claude:latest` | ❌ | + Claude CLI, Node.js 20, Python 3.11 |
| `yaoapp/sandbox-claude:full` | ❌ | + Go 1.23 |
| `yaoapp/sandbox-claude-browser:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) |
| `yaoapp/sandbox-claude-desktop:latest` | ✅ | + XFCE Desktop, VNC (~3.1GB) |
## IPC Communication
@ -112,6 +145,25 @@ Supported methods:
- `tools/list` - List available tools
- `tools/call` - Execute a tool
## VNC Remote Desktop
VNC-enabled images (playwright, desktop) provide real-time visibility into Claude's operations.
### API Endpoints
| Endpoint | Description |
| ------------------------------- | ---------------------------------- |
| `GET /v1/sandbox/{id}/vnc` | VNC status (ready/starting/unavailable) |
| `GET /v1/sandbox/{id}/vnc/client` | noVNC HTML client page |
| `GET /v1/sandbox/{id}/vnc/ws` | WebSocket proxy to container VNC |
### View Modes
- **Interactive** (default): User can use keyboard and mouse
- **View-only** (`?viewonly=true`): User can only watch
For detailed design, see [DESIGN-PLAYWRIGHT-VNC.md](./DESIGN-PLAYWRIGHT-VNC.md).
## Directory Structure
```
@ -120,11 +172,18 @@ sandbox/
├── docker/ # Dockerfiles and build script
│ ├── base/
│ ├── claude/
│ ├── browser/ # Browser (Playwright) + VNC image
│ ├── desktop/ # XFCE Desktop + VNC image
│ ├── vnc/ # Shared VNC scripts
│ └── build.sh
├── ipc/ # IPC system
│ ├── manager.go
│ ├── session.go
│ └── types.go
├── vncproxy/ # VNC proxy service
│ ├── proxy.go
│ ├── config.go
│ └── proxy_test.go
├── config.go # Configuration
├── errors.go # Error types
├── helpers.go # Helper functions
@ -135,11 +194,17 @@ sandbox/
## Testing
```bash
# Load environment variables first
source env.local.sh
# Unit tests (no Docker required)
go test -v ./sandbox/... -run "^Test.*Validation|^Test.*Generation|^Test.*Parsing"
# All tests (requires Docker)
go test -v ./sandbox/...
# VNC proxy tests only
go test -v ./sandbox/vncproxy/...
```
## Security

View file

@ -21,6 +21,9 @@ type Config struct {
ContainerWorkDir string `json:"container_workdir,omitempty"` // Container working directory, default: /workspace
ContainerIPCSocket string `json:"container_ipc_socket,omitempty"` // Container IPC socket path, default: /tmp/yao.sock
ContainerUser string `json:"container_user,omitempty"` // Container user, default: "" (use image default). Set to "0" for root.
// VNC port mapping (for Docker Desktop on macOS/Windows where container IPs are not directly accessible)
VNCPortMapping bool `json:"vnc_port_mapping,omitempty"` // Enable VNC port mapping to host, default: false
}
// DefaultConfig returns a Config with default values
@ -116,4 +119,9 @@ func (c *Config) Init(dataRoot string) {
if env := os.Getenv("YAO_SANDBOX_CONTAINER_USER"); env != "" {
c.ContainerUser = env
}
// VNC port mapping (for Docker Desktop on macOS/Windows)
if env := os.Getenv("YAO_SANDBOX_VNC_PORT_MAPPING"); env != "" {
c.VNCPortMapping = env == "true" || env == "1" || env == "yes"
}
}

View file

@ -0,0 +1,104 @@
# Claude sandbox with browser automation + VNC preview
# Image: sandbox-claude-browser
# Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI)
# Adds: Xvfb + x11vnc + noVNC + Fluxbox + Playwright/Puppeteer browsers
#
# Lightweight browser environment for web automation tasks
# Supports both amd64 and arm64 architectures
ARG REGISTRY=yaoapp
FROM ${REGISTRY}/sandbox-claude:latest
USER root
# Use MIT mirror (USA) for ARM64
RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || \
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
# Install X11, VNC, and minimal window manager
RUN apt-get update && apt-get install -y --no-install-recommends \
# Sudo for sandbox user
sudo \
# Virtual display
xvfb \
# VNC server
x11vnc \
# noVNC (HTML5 VNC client) and websockify
novnc \
python3-websockify \
# Minimal window manager (lightweight, perfect for Playwright)
fluxbox \
# Background/wallpaper utilities
feh \
imagemagick \
# Fonts (required for proper browser rendering)
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
# X11 utilities
x11-utils \
xdotool \
# Audio (for video playback in browsers, can be disabled)
pulseaudio \
&& rm -rf /var/lib/apt/lists/*
# Configure passwordless sudo for sandbox user
RUN echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/sandbox && \
chmod 0440 /etc/sudoers.d/sandbox
# Install Playwright system dependencies (requires root)
# This installs system libraries needed by Chromium/Firefox
RUN npx playwright install-deps chromium firefox || true
# Install Playwright and browsers as sandbox user
USER sandbox
# Install Playwright for Node.js (global) and Python
RUN npm install -g playwright && \
pip install --user --break-system-packages playwright && \
npx playwright install chromium firefox
USER root
# Create directories for branding assets
RUN mkdir -p /usr/local/share/yao
# Copy VNC startup scripts and branding assets
# Note: Build context should be sandbox/docker/, so paths are relative to that
COPY vnc/start-vnc.sh /usr/local/bin/start-vnc.sh
COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh
COPY browser/config/setup-fluxbox.sh /usr/local/bin/setup-fluxbox.sh
COPY browser/config/yao-logo.png /usr/local/share/yao/yao-logo.png
RUN chmod +x /usr/local/bin/start-vnc.sh /usr/local/bin/entrypoint.sh /usr/local/bin/setup-fluxbox.sh
# Environment variables for VNC
ENV DISPLAY=:99
ENV VNC_PORT=5900
ENV NOVNC_PORT=6080
ENV RESOLUTION=1920x1080x24
ENV SANDBOX_VNC_ENABLED=true
ENV SANDBOX_DESKTOP=fluxbox
# Node.js environment - ensure global modules are accessible
ENV NODE_PATH=/home/sandbox/.npm-global/lib/node_modules
# Expose VNC ports (internal use only, accessed via proxy)
EXPOSE 5900 6080
USER sandbox
WORKDIR /workspace
# Verify installations
RUN echo "=== Verifying installations ===" && \
node --version && \
npm --version && \
python3 --version && \
npx playwright --version && \
python3 -c "from playwright.sync_api import sync_playwright; print('Python Playwright: OK')" && \
which fluxbox && \
which x11vnc && \
which Xvfb && \
echo "=== All installations verified ==="
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["sleep", "infinity"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -105,6 +105,22 @@ case $TOOL in
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
;;
claude-vnc)
echo ""
echo "=== Building Claude VNC images (Browser + Desktop) ==="
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
;;
browser)
echo ""
echo "=== Building Claude Browser image ==="
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
;;
desktop)
echo ""
echo "=== Building Claude Desktop image ==="
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
;;
cursor)
echo ""
echo "=== Building Cursor images ==="
@ -116,14 +132,20 @@ case $TOOL in
# Claude
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
# Claude VNC variants
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
# Cursor (uncomment when ready)
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
;;
*)
echo "Unknown tool: $TOOL"
echo "Usage: $0 [claude|cursor|all] [true|false]"
echo "Usage: $0 [claude|claude-vnc|browser|desktop|cursor|all] [true|false]"
echo " $0 claude # Build Claude images locally"
echo " $0 claude true # Build and push Claude images"
echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)"
echo " $0 browser # Build Claude Browser image only"
echo " $0 desktop # Build Claude Desktop image only"
echo " $0 all true # Build and push all images"
exit 1
;;
@ -142,9 +164,21 @@ if [ "$PUSH" = "true" ]; then
echo " - ${REGISTRY}/sandbox-claude:latest"
echo " - ${REGISTRY}/sandbox-claude-full:latest"
;;
claude-vnc)
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;;
browser)
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
;;
desktop)
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;;
all)
echo " - ${REGISTRY}/sandbox-claude:latest"
echo " - ${REGISTRY}/sandbox-claude-full:latest"
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;;
esac
fi

View file

@ -0,0 +1,115 @@
# Claude sandbox with full XFCE desktop + VNC preview
# Image: sandbox-claude-desktop
# Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI)
# Adds: Xvfb + x11vnc + noVNC + XFCE desktop + File Manager + Terminal
#
# Supports both amd64 and arm64 architectures
ARG REGISTRY=yaoapp
FROM ${REGISTRY}/sandbox-claude:latest
USER root
# Use MIT mirror (USA) for ARM64
RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || \
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
# Install X11, VNC, and XFCE desktop environment
RUN apt-get update && apt-get install -y --no-install-recommends \
# Sudo for sandbox user
sudo \
# Virtual display
xvfb \
# VNC server
x11vnc \
# noVNC (HTML5 VNC client) and websockify
novnc \
python3-websockify \
# D-Bus (required for XFCE)
dbus-x11 \
# XFCE Desktop (full-featured but lightweight)
xfce4 \
xfce4-terminal \
thunar \
# Fonts (required for proper rendering)
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
# X11 utilities
x11-utils \
xdotool \
# Audio
pulseaudio \
# Remove screensaver (causes issues in container)
&& apt-get remove -y xfce4-screensaver xscreensaver || true \
&& rm -rf /var/lib/apt/lists/*
# Configure passwordless sudo for sandbox user
RUN echo "sandbox ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/sandbox && \
chmod 0440 /etc/sudoers.d/sandbox
# Create chromium wrapper script (uses Playwright's Chromium, starts maximized)
RUN echo '#!/bin/bash\nexec /home/sandbox/.cache/ms-playwright/chromium-1208/chrome-linux/chrome --no-sandbox --start-maximized "$@"' > /usr/local/bin/chromium && \
chmod +x /usr/local/bin/chromium
# Optional: Install Playwright system dependencies (requires root)
# Users can run browser automation in desktop mode too
RUN npx playwright install-deps chromium || true
# Optional: Install Playwright for browser automation
USER sandbox
RUN npm install -g playwright && \
pip install --user --break-system-packages playwright && \
npx playwright install chromium || true
USER root
# Copy VNC startup scripts
# Note: Build context should be sandbox/docker/, so paths are relative to that
COPY vnc/start-vnc.sh /usr/local/bin/start-vnc.sh
COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/start-vnc.sh /usr/local/bin/entrypoint.sh
# Copy Yao branding assets
RUN mkdir -p /usr/share/yao
COPY desktop/config/yao-logo-48.png /usr/share/yao/yao-logo-48.png
COPY desktop/config/yao-logo-128.png /usr/share/yao/yao-logo-128.png
COPY desktop/config/yao-logo-256.png /usr/share/yao/yao-logo-256.png
COPY desktop/config/panel-launcher-chromium.desktop /usr/share/yao/panel-launcher-chromium.desktop
COPY desktop/config/workspace.desktop /usr/share/yao/workspace.desktop
COPY desktop/config/setup-xfce.sh /usr/local/bin/setup-xfce.sh
RUN chmod +x /usr/local/bin/setup-xfce.sh
# Environment variables for VNC
ENV DISPLAY=:99
ENV VNC_PORT=5900
ENV NOVNC_PORT=6080
ENV RESOLUTION=1920x1080x24
ENV SANDBOX_VNC_ENABLED=true
ENV SANDBOX_DESKTOP=xfce
# Set hostname for XFCE panel display
ENV HOSTNAME="Yao Sandbox"
# Node.js environment - ensure global modules are accessible
ENV NODE_PATH=/home/sandbox/.npm-global/lib/node_modules
# Expose VNC ports (internal use only, accessed via proxy)
EXPOSE 5900 6080
USER sandbox
WORKDIR /workspace
# Verify installations
RUN echo "=== Verifying installations ===" && \
node --version && \
npm --version && \
python3 --version && \
which startxfce4 && \
which thunar && \
which xfce4-terminal && \
which x11vnc && \
which Xvfb && \
echo "=== All installations verified ==="
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["sleep", "infinity"]

View file

@ -0,0 +1,12 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Chromium
Comment=Access the Internet
GenericName=Web Browser
Exec=/usr/local/bin/chromium %U
Icon=org.xfce.webbrowser
Terminal=false
Categories=Network;WebBrowser;
MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/vnd.mozilla.xul+xml;application/rss+xml;application/rdf+xml;x-scheme-handler/http;x-scheme-handler/https;
StartupNotify=true

View file

@ -0,0 +1,160 @@
#!/bin/bash
# XFCE desktop configuration script
# Runs on container startup to set up Yao branding and default applications
set -e
XFCE_CONFIG_DIR="$HOME/.config/xfce4"
XFDESKTOP_DIR="$HOME/.config/xfce4/xfconf/xfce-perchannel-xml"
ICONS_DIR="$HOME/.local/share/icons/hicolor"
APPS_DIR="$HOME/.local/share/applications"
DESKTOP_DIR="$HOME/Desktop"
# Create necessary directories
mkdir -p "$XFCE_CONFIG_DIR/panel"
mkdir -p "$XFDESKTOP_DIR"
mkdir -p "$ICONS_DIR/48x48/apps"
mkdir -p "$ICONS_DIR/128x128/apps"
mkdir -p "$ICONS_DIR/256x256/apps"
mkdir -p "$APPS_DIR"
mkdir -p "$DESKTOP_DIR"
# Copy Yao logo to user icons directory
if [ -f /usr/share/yao/yao-logo-48.png ]; then
cp /usr/share/yao/yao-logo-48.png "$ICONS_DIR/48x48/apps/yao.png"
cp /usr/share/yao/yao-logo-128.png "$ICONS_DIR/128x128/apps/yao.png"
cp /usr/share/yao/yao-logo-256.png "$ICONS_DIR/256x256/apps/yao.png"
# Also copy to system location for panel icon
sudo cp /usr/share/yao/yao-logo-48.png /usr/share/pixmaps/yao.png 2>/dev/null || true
gtk-update-icon-cache "$ICONS_DIR" 2>/dev/null || true
fi
# Copy Chromium launcher to applications
if [ -f /usr/share/yao/panel-launcher-chromium.desktop ]; then
cp /usr/share/yao/panel-launcher-chromium.desktop "$APPS_DIR/chromium-browser.desktop"
fi
# Configure xfdesktop - hide default icons (File System, Home, Trash), keep only custom shortcuts
cat > "$XFDESKTOP_DIR/xfce4-desktop.xml" << 'XMLEOF'
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-desktop" version="1.0">
<property name="desktop-icons" type="empty">
<property name="style" type="int" value="2"/>
<property name="file-icons" type="empty">
<property name="show-home" type="bool" value="false"/>
<property name="show-filesystem" type="bool" value="false"/>
<property name="show-trash" type="bool" value="false"/>
<property name="show-removable" type="bool" value="false"/>
</property>
</property>
</channel>
XMLEOF
# Copy workspace shortcut to desktop (named "Workspace" with folder icon)
if [ -f /usr/share/yao/workspace.desktop ]; then
cp /usr/share/yao/workspace.desktop "$DESKTOP_DIR/workspace.desktop"
chmod +x "$DESKTOP_DIR/workspace.desktop"
fi
# Set Chromium as default browser
xdg-settings set default-web-browser chromium-browser.desktop 2>/dev/null || true
# Configure XFCE panel - set Applications menu icon to Yao logo
# This will be applied when xfce4-panel starts
cat > "$XFDESKTOP_DIR/xfce4-panel.xml" << 'XMLEOF'
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-panel" version="1.0">
<property name="configver" type="int" value="2"/>
<property name="panels" type="array">
<value type="int" value="1"/>
<value type="int" value="2"/>
<property name="dark-mode" type="bool" value="true"/>
<property name="panel-1" type="empty">
<property name="position" type="string" value="p=6;x=0;y=0"/>
<property name="length" type="uint" value="100"/>
<property name="position-locked" type="bool" value="true"/>
<property name="icon-size" type="uint" value="16"/>
<property name="size" type="uint" value="26"/>
<property name="plugin-ids" type="array">
<value type="int" value="1"/>
<value type="int" value="2"/>
<value type="int" value="3"/>
<value type="int" value="4"/>
<value type="int" value="5"/>
<value type="int" value="6"/>
<value type="int" value="7"/>
<value type="int" value="8"/>
<value type="int" value="9"/>
<value type="int" value="10"/>
<value type="int" value="11"/>
</property>
</property>
<property name="panel-2" type="empty">
<property name="autohide-behavior" type="uint" value="1"/>
<property name="position" type="string" value="p=10;x=960;y=1054"/>
<property name="length" type="uint" value="1"/>
<property name="position-locked" type="bool" value="true"/>
<property name="size" type="uint" value="48"/>
<property name="plugin-ids" type="array">
<value type="int" value="12"/>
<value type="int" value="13"/>
<value type="int" value="14"/>
<value type="int" value="15"/>
<value type="int" value="16"/>
<value type="int" value="17"/>
</property>
</property>
</property>
<property name="plugins" type="empty">
<property name="plugin-1" type="string" value="applicationsmenu">
<property name="button-icon" type="string" value="yao"/>
<property name="button-title" type="string" value=""/>
<property name="show-button-title" type="bool" value="false"/>
</property>
<property name="plugin-2" type="string" value="tasklist">
<property name="grouping" type="uint" value="1"/>
</property>
<property name="plugin-3" type="string" value="separator">
<property name="expand" type="bool" value="true"/>
<property name="style" type="uint" value="0"/>
</property>
<property name="plugin-4" type="string" value="pager"/>
<property name="plugin-5" type="string" value="separator">
<property name="style" type="uint" value="0"/>
</property>
<property name="plugin-6" type="string" value="systray">
<property name="square-icons" type="bool" value="true"/>
</property>
<property name="plugin-7" type="string" value="pulseaudio">
<property name="enable-keyboard-shortcuts" type="bool" value="true"/>
<property name="show-notifications" type="bool" value="true"/>
</property>
<property name="plugin-8" type="string" value="power-manager-plugin"/>
<property name="plugin-9" type="string" value="notification-plugin"/>
<property name="plugin-10" type="string" value="separator">
<property name="style" type="uint" value="0"/>
</property>
<property name="plugin-11" type="string" value="clock"/>
<property name="plugin-12" type="string" value="showdesktop"/>
<property name="plugin-13" type="string" value="separator"/>
<property name="plugin-14" type="string" value="launcher">
<property name="items" type="array">
<value type="string" value="xfce4-terminal.desktop"/>
</property>
</property>
<property name="plugin-15" type="string" value="launcher">
<property name="items" type="array">
<value type="string" value="thunar.desktop"/>
</property>
</property>
<property name="plugin-16" type="string" value="launcher">
<property name="items" type="array">
<value type="string" value="chromium-browser.desktop"/>
</property>
</property>
<property name="plugin-17" type="string" value="separator"/>
</property>
</channel>
XMLEOF
echo "[XFCE Setup] Configuration complete"

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Workspace
Comment=Open Workspace folder
Icon=folder
Exec=thunar /workspace
Terminal=false
Categories=System;FileManager;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="719.664453px" height="610.367745px" viewBox="0 0 719.664453 610.367745" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Yaobots</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Yaobots" transform="translate(-151.5657, -188.764)" fill-rule="nonzero">
<polygon id="Path" points="0 0 1024 0 1024 1024 0 1024"></polygon>
<path d="M486.884,189.207 C495.1615,188.4345 505.165,188.919 513.59,188.864 C599.61,188.302 684.55,217.9475 749.645,274.6415 C820.74,337.335 864.11,425.629 870.28,520.215 C874.69,585.13 864.71,657.9 819.94,708.215 C766.135,768.695 672.935,788.13 595.875,795.7 C576.14,797.64 557.055,797.635 537.68,798.92 C436.9495,800.75 298.6425,791.955 218.4915,723.25 C173.8735,685 156.2745,627.825 152.411,570.625 C145.8345,474.4635 177.8325,379.6425 241.3305,307.127 C292.7155,248.288 362.5635,208.649 439.428,194.7065 C455.6785,191.7125 470.5135,190.32 486.884,189.207 Z" id="Path" fill="#3371FC"></path>
<g id="Eye-left" transform="translate(352.7054, 366.1909)" fill="#FFFFFE">
<path d="M54.3200771,0.00131773874 C90.5575771,-0.297352792 107.046077,50.2461472 109.269077,79.1841472 C111.987077,114.573147 98.0685771,173.204147 55.0560771,176.574147 C19.8370771,176.004147 3.26007709,132.680147 0.574577092,103.311147 C-2.06042291,74.4961472 4.20857709,41.4316472 22.6780771,18.3306472 C30.9835771,7.94264721 41.0875771,1.40214721 54.3200771,0.00131773874 Z M67.8166257,49.886467 L66.8260771,49.8991472 C34.0975771,56.7616472 37.5585771,132.225647 70.5330771,131.761147 C103.215077,124.145147 100.147077,48.7151472 66.8260771,49.8991472 Z" id="Combined-Shape"></path>
</g>
<g id="Eye-right" transform="translate(562.8175, 366.1247)" fill="#FFFFFE">
<path d="M76.7075112,8.55926237 C96.7425112,24.6522624 104.512511,49.8777624 107.982511,74.3482624 C112.957511,109.387262 99.1125112,171.335262 57.0025112,176.520262 C47.7325112,176.880262 37.7225112,173.635262 30.7025112,167.460262 C-8.8974888,132.641762 -8.5574888,58.3082624 22.3475112,18.0877624 C30.2475112,7.80826237 40.7175112,1.45976237 53.2275112,0.0662623674 C61.0975112,-0.590737633 70.7075112,3.73926237 76.7075112,8.55926237 Z M39.6166685,49.9369618 L38.6575112,49.9567624 C5.9375112,57.6252624 10.0475112,133.248262 43.3725112,131.767262 C76.3675112,122.553262 70.8975112,48.5472624 38.6575112,49.9567624 Z" id="Combined-Shape"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,40 @@
#!/bin/bash
# Container entrypoint for VNC-enabled sandbox images
# This extends the original sandbox-claude entrypoint with VNC support
# ============================================
# VNC Services Startup
# ============================================
if [ "$SANDBOX_VNC_ENABLED" = "true" ]; then
echo "[Entrypoint] Starting VNC services..."
/usr/local/bin/start-vnc.sh &
# Wait for VNC to initialize
sleep 3
echo "[Entrypoint] VNC services started in background"
fi
# ============================================
# Original sandbox-claude entrypoint logic
# (from sandbox-claude Dockerfile)
# ============================================
WORKSPACE="${WORKSPACE:-/workspace}"
PORT="${CLAUDE_PROXY_PORT:-3456}"
ENV_FILE="/tmp/claude-proxy-env"
# If proxy env vars are set AND proxy is not running, start it
# This supports docker run -e CLAUDE_PROXY_BACKEND=... usage
if [ -n "$CLAUDE_PROXY_BACKEND" ] && [ -n "$CLAUDE_PROXY_API_KEY" ] && [ -n "$CLAUDE_PROXY_MODEL" ]; then
if ! curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
/usr/local/bin/start-claude-proxy
fi
# Write env vars to a file that can be sourced
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
echo "export ANTHROPIC_BASE_URL=http://127.0.0.1:${PORT}" > "$ENV_FILE"
echo "export ANTHROPIC_API_KEY=dummy" >> "$ENV_FILE"
chmod 644 "$ENV_FILE"
fi
fi
# Execute the command passed to docker run
exec "$@"

View file

@ -0,0 +1,132 @@
#!/bin/bash
# VNC services startup script
# Shared by sandbox-claude-browser and sandbox-claude-desktop
# Starts: Xvfb (virtual display) + Window Manager + x11vnc + websockify (noVNC)
set -e
DISPLAY_NUM="${DISPLAY_NUM:-99}"
RESOLUTION="${RESOLUTION:-1920x1080x24}"
VNC_PORT="${VNC_PORT:-5900}"
NOVNC_PORT="${NOVNC_PORT:-6080}"
VNC_PASSWORD="${VNC_PASSWORD:-}"
DESKTOP="${SANDBOX_DESKTOP:-fluxbox}"
export DISPLAY=:${DISPLAY_NUM}
echo "[VNC] Starting VNC services..."
echo "[VNC] Display: :${DISPLAY_NUM}"
echo "[VNC] Resolution: ${RESOLUTION}"
echo "[VNC] Desktop: ${DESKTOP}"
# Start Xvfb (virtual framebuffer)
echo "[VNC] Starting Xvfb..."
Xvfb :${DISPLAY_NUM} -screen 0 ${RESOLUTION} &
XVFB_PID=$!
sleep 1
if ! kill -0 $XVFB_PID 2>/dev/null; then
echo "[VNC] ERROR: Xvfb failed to start"
exit 1
fi
echo "[VNC] Xvfb started (PID: $XVFB_PID)"
# Start D-Bus session bus (required for XFCE)
if [ "$DESKTOP" = "xfce" ] || [ "$DESKTOP" = "xfce4" ]; then
echo "[VNC] Starting D-Bus session bus..."
if command -v dbus-launch &> /dev/null; then
eval $(dbus-launch --sh-syntax)
export DBUS_SESSION_BUS_ADDRESS
echo "[VNC] D-Bus started: $DBUS_SESSION_BUS_ADDRESS"
else
echo "[VNC] WARNING: dbus-launch not found, XFCE may have limited functionality"
fi
fi
# Start window manager / desktop environment
echo "[VNC] Starting ${DESKTOP}..."
case "$DESKTOP" in
xfce|xfce4)
# Run XFCE setup script if exists (for Yao branding)
if [ -x /usr/local/bin/setup-xfce.sh ]; then
echo "[VNC] Running XFCE setup..."
/usr/local/bin/setup-xfce.sh || true
fi
# XFCE desktop environment
startxfce4 &
;;
fluxbox)
# Run Fluxbox setup script if exists (Yao branding, disable toolbar)
if [ -x /usr/local/bin/setup-fluxbox.sh ]; then
echo "[VNC] Running Fluxbox setup..."
/usr/local/bin/setup-fluxbox.sh || true
fi
# Minimal window manager for Playwright
fluxbox &
sleep 1
# Set wallpaper with feh if available (for Yao branding)
WALLPAPER="$HOME/.local/share/wallpapers/yao-wallpaper.png"
if [ -f "$WALLPAPER" ] && command -v feh &> /dev/null; then
echo "[VNC] Setting wallpaper..."
feh --bg-center "$WALLPAPER" || true
fi
;;
*)
# Default to fluxbox
if [ -x /usr/local/bin/setup-fluxbox.sh ]; then
/usr/local/bin/setup-fluxbox.sh || true
fi
fluxbox &
sleep 1
# Set wallpaper with feh if available
WALLPAPER="$HOME/.local/share/wallpapers/yao-wallpaper.png"
if [ -f "$WALLPAPER" ] && command -v feh &> /dev/null; then
feh --bg-center "$WALLPAPER" || true
fi
;;
esac
sleep 2
# Start x11vnc server
echo "[VNC] Starting x11vnc on port ${VNC_PORT}..."
VNC_ARGS="-display :${DISPLAY_NUM} -forever -shared -rfbport ${VNC_PORT} -noxdamage"
if [ -n "$VNC_PASSWORD" ]; then
mkdir -p ~/.vnc
x11vnc -storepasswd "$VNC_PASSWORD" ~/.vnc/passwd
VNC_ARGS="$VNC_ARGS -rfbauth ~/.vnc/passwd"
else
VNC_ARGS="$VNC_ARGS -nopw"
fi
x11vnc $VNC_ARGS &
X11VNC_PID=$!
sleep 1
if ! kill -0 $X11VNC_PID 2>/dev/null; then
echo "[VNC] ERROR: x11vnc failed to start"
exit 1
fi
echo "[VNC] x11vnc started (PID: $X11VNC_PID)"
# Start websockify (noVNC WebSocket proxy)
echo "[VNC] Starting websockify on port ${NOVNC_PORT}..."
websockify --web=/usr/share/novnc/ ${NOVNC_PORT} localhost:${VNC_PORT} &
WEBSOCKIFY_PID=$!
sleep 1
if ! kill -0 $WEBSOCKIFY_PID 2>/dev/null; then
echo "[VNC] ERROR: websockify failed to start"
exit 1
fi
echo "[VNC] websockify started (PID: $WEBSOCKIFY_PID)"
echo "[VNC] =================================="
echo "[VNC] VNC services started successfully"
echo "[VNC] Desktop: ${DESKTOP}"
echo "[VNC] VNC port: ${VNC_PORT}"
echo "[VNC] noVNC port: ${NOVNC_PORT}"
echo "[VNC] =================================="
# Note: Don't wait here - let the entrypoint continue
# Background processes will keep running

View file

@ -58,8 +58,10 @@ func parseMemory(s string) int64 {
}
}
// parseLS parses ls -la --time-style=+%s output to []FileInfo
func parseLS(output string) []FileInfo {
// parseLS parses ls -la output to []FileInfo
// If hasTimeStyle is true, expects GNU ls output with --time-style=+%s (Unix epoch)
// If hasTimeStyle is false, expects BusyBox/basic ls output (date string format)
func parseLS(output string, hasTimeStyle bool) []FileInfo {
lines := strings.Split(strings.TrimSpace(output), "\n")
var result []FileInfo
@ -69,9 +71,19 @@ func parseLS(output string) []FileInfo {
continue
}
// Parse ls -la output: drwxr-xr-x 2 user group 4096 1234567890 filename
// Parse ls -la output
// GNU with --time-style: drwxr-xr-x 2 user group 4096 1234567890 filename
// BusyBox/basic: drwxr-xr-x 2 user group 4096 Jan 1 12:00 filename
fields := strings.Fields(line)
if len(fields) < 7 {
var minFields int
if hasTimeStyle {
minFields = 7 // mode, links, user, group, size, timestamp, name
} else {
minFields = 9 // mode, links, user, group, size, month, day, time/year, name
}
if len(fields) < minFields {
continue
}
@ -85,12 +97,21 @@ func parseLS(output string) []FileInfo {
// Parse size
size, _ := strconv.ParseInt(fields[4], 10, 64)
// Parse timestamp (Unix epoch)
timestamp, _ := strconv.ParseInt(fields[5], 10, 64)
modTime := time.Unix(timestamp, 0)
// Parse timestamp and get filename
var modTime time.Time
var name string
// Get filename (may contain spaces)
name := strings.Join(fields[6:], " ")
if hasTimeStyle {
// GNU ls with --time-style=+%s: timestamp is Unix epoch in fields[5]
timestamp, _ := strconv.ParseInt(fields[5], 10, 64)
modTime = time.Unix(timestamp, 0)
name = strings.Join(fields[6:], " ")
} else {
// BusyBox/basic ls: date is in fields[5:8] (e.g., "Jan 1 12:00" or "Jan 1 2024")
// Note: time.Now() is used as fallback since BusyBox date parsing is complex
modTime = time.Now()
name = strings.Join(fields[8:], " ")
}
// Skip . and ..
if name == "." || name == ".." {

View file

@ -65,41 +65,83 @@ func TestMapToSlice(t *testing.T) {
}
func TestParseLS(t *testing.T) {
output := `total 8
// Test GNU ls output with --time-style=+%s (Unix epoch timestamp)
t.Run("GNU_ls_with_time_style", func(t *testing.T) {
output := `total 8
drwxr-xr-x 2 sandbox sandbox 4096 1700000000 dir1
-rw-r--r-- 1 sandbox sandbox 100 1700000001 file1.txt
lrwxrwxrwx 1 sandbox sandbox 10 1700000002 link1 -> file1.txt
`
result := parseLS(output)
result := parseLS(output, true)
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
// Check dir1
if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
}
if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory")
}
// Check dir1
if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
}
if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory")
}
// Check file1.txt
if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
}
if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size)
}
if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory")
}
// Check file1.txt
if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
}
if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size)
}
if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory")
}
// Check link1
if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name)
}
// Check link1
if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name)
}
})
// Test BusyBox/basic ls output (Alpine-style)
t.Run("BusyBox_ls_basic", func(t *testing.T) {
output := `total 8
drwxr-xr-x 2 sandbox sandbox 4096 Jan 1 12:00 dir1
-rw-r--r-- 1 sandbox sandbox 100 Jan 1 12:01 file1.txt
lrwxrwxrwx 1 sandbox sandbox 10 Jan 1 12:02 link1 -> file1.txt
`
result := parseLS(output, false)
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
// Check dir1
if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
}
if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory")
}
// Check file1.txt
if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
}
if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size)
}
if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory")
}
// Check link1 (in BusyBox format, symlink target is separate field)
if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name)
}
})
}
func TestParseStat(t *testing.T) {

View file

@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
@ -17,6 +18,7 @@ import (
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
"github.com/yaoapp/yao/sandbox/ipc"
)
@ -183,9 +185,17 @@ func (m *Manager) Close() error {
}
// GetOrCreate returns existing container or creates new one
func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Container, error) {
func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string, opts ...CreateOptions) (*Container, error) {
name := containerName(userID, chatID)
// Extract options if provided
var createOpts CreateOptions
if len(opts) > 0 {
createOpts = opts[0]
}
createOpts.UserID = userID
createOpts.ChatID = chatID
// Check if container already exists (fast path)
if c, ok := m.containers.Load(name); ok {
cont := c.(*Container)
@ -241,7 +251,7 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
}
// Create new container
cont, err := m.createContainer(ctx, userID, chatID)
cont, err := m.createContainer(ctx, createOpts)
if err != nil {
return nil, err
}
@ -254,11 +264,19 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
}
// createContainer creates a new Docker container
func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*Container, error) {
func (m *Manager) createContainer(ctx context.Context, opts CreateOptions) (*Container, error) {
userID := opts.UserID
chatID := opts.ChatID
name := containerName(userID, chatID)
// Use image from options or fall back to config default
image := opts.Image
if image == "" {
image = m.config.Image
}
// Ensure image exists, pull if not
if err := m.ensureImage(ctx, m.config.Image); err != nil {
if err := m.ensureImage(ctx, image); err != nil {
return nil, err
}
@ -281,7 +299,7 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*
// Container configuration
containerConfig := &container.Config{
Image: m.config.Image,
Image: image,
Cmd: []string{"sleep", "infinity"},
WorkingDir: m.config.ContainerWorkDir,
User: m.config.ContainerUser, // Empty string uses image default
@ -306,6 +324,24 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*
CapDrop: []string{"ALL"},
}
// VNC port mapping for Docker Desktop (macOS/Windows)
// Only enable for VNC-capable images (playwright/desktop) when config is enabled
if m.config.VNCPortMapping && isVNCImage(image) {
// Expose VNC ports in container config
containerConfig.ExposedPorts = nat.PortSet{
"6080/tcp": struct{}{}, // noVNC websockify
"5900/tcp": struct{}{}, // VNC
}
// Enable SANDBOX_VNC_ENABLED environment variable
containerConfig.Env = append(containerConfig.Env, "SANDBOX_VNC_ENABLED=true")
// Map to random available ports on 127.0.0.1
hostConfig.PortBindings = nat.PortMap{
"6080/tcp": []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}}, // empty = random port
"5900/tcp": []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}},
}
}
// Create container
resp, err := m.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, name)
if err != nil {
@ -775,12 +811,22 @@ func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, erro
// ListDir lists directory contents in container
func (m *Manager) ListDir(ctx context.Context, name, path string) ([]FileInfo, error) {
// Try GNU ls with --time-style first (for GNU coreutils)
result, err := m.Exec(ctx, name, []string{"ls", "-la", "--time-style=+%s", path}, nil)
if err == nil && result.ExitCode == 0 {
return parseLS(result.Stdout, true), nil
}
// Fall back to basic ls (for BusyBox/Alpine)
result, err = m.Exec(ctx, name, []string{"ls", "-la", path}, nil)
if err != nil {
return nil, err
}
if result.ExitCode != 0 {
return nil, fmt.Errorf("ls failed: %s", result.Stderr)
}
return parseLS(result.Stdout), nil
return parseLS(result.Stdout, false), nil
}
// Stat returns file info
@ -905,3 +951,19 @@ func (m *Manager) fixIPCSocketPermissions(ctx context.Context, containerID strin
// Wait briefly for the chmod to complete
time.Sleep(50 * time.Millisecond)
}
// isVNCImage checks if the image is VNC-capable (playwright or desktop variants)
func isVNCImage(imageName string) bool {
return strings.Contains(imageName, "playwright") || strings.Contains(imageName, "desktop")
}
// findAvailablePort finds an available port on the host
// This is used as a fallback; Docker can auto-assign ports when HostPort is empty
func findAvailablePort() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port, nil
}

View file

@ -313,12 +313,14 @@ func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse {
result.StopReason = &stopReason
}
// Convert usage
// Convert usage (always include - Claude CLI expects usage to be present)
if resp.Usage != nil {
result.Usage = &Usage{
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
}
} else {
result.Usage = &Usage{InputTokens: 0, OutputTokens: 0}
}
return result

View file

@ -310,6 +310,7 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
var toolCalls []*ToolCallAccumulator
var contentIndex int
var finishReason string
var lastUsage *Usage // Track the latest usage data from backend
for scanner.Scan() {
line := scanner.Text()
@ -332,19 +333,13 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
}
if len(chunk.Choices) == 0 {
// Usage update at the end
// Usage update at the end - save it but don't send message_delta yet
// It will be included in the final message_delta below
if chunk.Usage != nil {
usageEvent := AnthropicStreamEvent{
Type: "message_delta",
Delta: &DeltaContent{
StopReason: &finishReason,
},
Usage: &Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
},
lastUsage = &Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
}
s.writeSSE(w, flusher, usageEvent)
}
continue
}
@ -452,15 +447,20 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
s.writeSSE(w, flusher, stopEvent)
}
// Send message_delta with stop reason
// Send message_delta with stop reason and usage
// Claude CLI expects usage to always be present in message_delta
if finishReason == "" {
finishReason = "end_turn"
}
if lastUsage == nil {
lastUsage = &Usage{InputTokens: 0, OutputTokens: 0}
}
deltaEvent := AnthropicStreamEvent{
Type: "message_delta",
Delta: &DeltaContent{
StopReason: &finishReason,
},
Usage: lastUsage,
}
s.writeSSE(w, flusher, deltaEvent)

View file

@ -66,3 +66,10 @@ const (
StatusRunning = "running"
StatusStopped = "stopped"
)
// CreateOptions contains options for creating a container
type CreateOptions struct {
UserID string // User identifier (required)
ChatID string // Chat/session identifier (required)
Image string // Docker image to use (optional, falls back to config default)
}

View file

@ -0,0 +1,81 @@
package vncproxy
import (
"os"
"strconv"
"time"
)
// Config holds VNC proxy configuration
type Config struct {
// Network settings
DockerNetwork string `json:"docker_network,omitempty"` // Docker network name (default: bridge)
ContainerNoVNCPort int `json:"container_novnc_port,omitempty"` // noVNC port inside container (default: 6080)
ContainerVNCPort int `json:"container_vnc_port,omitempty"` // VNC port inside container (default: 5900)
ContainerNamePrefix string `json:"container_name_prefix,omitempty"` // Container name prefix (default: yao-sandbox-)
// Cache settings
IPCacheTTL time.Duration `json:"ip_cache_ttl,omitempty"` // IP cache TTL (default: 30s)
// VNC status check
VNCCheckTimeout time.Duration `json:"vnc_check_timeout,omitempty"` // Timeout for VNC ready check (default: 2s)
}
// DefaultConfig returns default configuration
func DefaultConfig() *Config {
return &Config{
DockerNetwork: "bridge",
ContainerNoVNCPort: 6080,
ContainerVNCPort: 5900,
ContainerNamePrefix: "yao-sandbox-",
IPCacheTTL: 30 * time.Second,
VNCCheckTimeout: 2 * time.Second,
}
}
// Init initializes config from environment variables
func (c *Config) Init() {
if env := os.Getenv("YAO_VNC_DOCKER_NETWORK"); env != "" {
c.DockerNetwork = env
} else if c.DockerNetwork == "" {
c.DockerNetwork = "bridge"
}
if env := os.Getenv("YAO_VNC_CONTAINER_NOVNC_PORT"); env != "" {
if v, err := strconv.Atoi(env); err == nil && v > 0 {
c.ContainerNoVNCPort = v
}
} else if c.ContainerNoVNCPort == 0 {
c.ContainerNoVNCPort = 6080
}
if env := os.Getenv("YAO_VNC_CONTAINER_VNC_PORT"); env != "" {
if v, err := strconv.Atoi(env); err == nil && v > 0 {
c.ContainerVNCPort = v
}
} else if c.ContainerVNCPort == 0 {
c.ContainerVNCPort = 5900
}
if env := os.Getenv("YAO_VNC_CONTAINER_NAME_PREFIX"); env != "" {
c.ContainerNamePrefix = env
} else if c.ContainerNamePrefix == "" {
c.ContainerNamePrefix = "yao-sandbox-"
}
if env := os.Getenv("YAO_VNC_IP_CACHE_TTL"); env != "" {
if v, err := time.ParseDuration(env); err == nil && v > 0 {
c.IPCacheTTL = v
}
} else if c.IPCacheTTL == 0 {
c.IPCacheTTL = 30 * time.Second
}
if env := os.Getenv("YAO_VNC_CHECK_TIMEOUT"); env != "" {
if v, err := time.ParseDuration(env); err == nil && v > 0 {
c.VNCCheckTimeout = v
}
} else if c.VNCCheckTimeout == 0 {
c.VNCCheckTimeout = 2 * time.Second
}
}

569
sandbox/vncproxy/proxy.go Normal file
View file

@ -0,0 +1,569 @@
package vncproxy
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
"github.com/gorilla/websocket"
)
// ipCacheEntry holds cached container IP with expiration
type ipCacheEntry struct {
IP string
ExpiresAt time.Time
}
// Proxy handles VNC proxy requests
type Proxy struct {
config *Config
dockerClient *client.Client
ipCache sync.Map // containerName -> *ipCacheEntry
upgrader websocket.Upgrader
}
// NewProxy creates a new VNC proxy
func NewProxy(config *Config) (*Proxy, error) {
if config == nil {
config = DefaultConfig()
}
config.Init()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
// Verify Docker connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := cli.Ping(ctx); err != nil {
cli.Close()
return nil, fmt.Errorf("Docker not available: %w", err)
}
return &Proxy{
config: config,
dockerClient: cli,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for VNC
},
Subprotocols: []string{"binary"}, // noVNC uses binary subprotocol
},
}, nil
}
// Close closes the proxy and releases resources
func (p *Proxy) Close() error {
return p.dockerClient.Close()
}
// extractSandboxID extracts sandbox ID from request path
// Expected format: /v1/sandbox/{id}/vnc/...
func extractSandboxID(r *http.Request) string {
path := r.URL.Path
// Remove prefix /v1/sandbox/
path = strings.TrimPrefix(path, "/v1/sandbox/")
// Get ID (first segment before next /)
if idx := strings.Index(path, "/"); idx > 0 {
return path[:idx]
}
return path
}
// HandleVNCStatus returns VNC status for a container
// GET /v1/sandbox/{id}/vnc
func (p *Proxy) HandleVNCStatus(w http.ResponseWriter, r *http.Request) {
sandboxID := extractSandboxID(r)
containerName := p.config.ContainerNamePrefix + sandboxID
response := map[string]interface{}{
"sandbox_id": sandboxID,
"container": containerName,
}
// Check if container exists and is running
_, err := p.getContainerIP(r.Context(), containerName)
if err != nil {
response["available"] = false
response["status"] = "unavailable"
response["message"] = "Container not available"
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
// Check if VNC is enabled for this container
if !p.checkVNCEnabled(r.Context(), containerName) {
response["available"] = false
response["status"] = "not_supported"
response["message"] = "VNC not available for this container type"
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
// Check if VNC services are ready (try to connect to websockify port)
if !p.checkVNCReady(r.Context(), containerName) {
response["available"] = false
response["status"] = "starting"
response["message"] = "VNC services are starting..."
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
// VNC is ready
response["available"] = true
response["status"] = "ready"
response["client_url"] = fmt.Sprintf("/v1/sandbox/%s/vnc/client", sandboxID)
response["websocket_url"] = fmt.Sprintf("/v1/sandbox/%s/vnc/ws", sandboxID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleVNCClient serves the noVNC client page
// GET /v1/sandbox/{id}/vnc/client?viewonly=true|false
func (p *Proxy) HandleVNCClient(w http.ResponseWriter, r *http.Request) {
sandboxID := extractSandboxID(r)
containerName := p.config.ContainerNamePrefix + sandboxID
// Verify container exists and is running
_, err := p.getContainerIP(r.Context(), containerName)
if err != nil {
http.Error(w, "Container not available", http.StatusNotFound)
return
}
if !p.checkVNCEnabled(r.Context(), containerName) {
http.Error(w, "VNC not available for this container", http.StatusBadRequest)
return
}
// Get viewonly parameter (default: false = interactive)
viewOnly := r.URL.Query().Get("viewonly") == "true"
// Serve inline noVNC HTML page with status checking
wsPath := fmt.Sprintf("/v1/sandbox/%s/vnc/ws", sandboxID)
p.serveNoVNCPage(w, sandboxID, wsPath, viewOnly)
}
// HandleVNCWebSocket proxies WebSocket connection to container VNC
// GET /v1/sandbox/{id}/vnc/ws
func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
sandboxID := extractSandboxID(r)
containerName := p.config.ContainerNamePrefix + sandboxID
// Get VNC endpoint (uses port mapping if available, otherwise container IP)
targetAddr, err := p.getVNCEndpoint(r.Context(), containerName)
if err != nil {
http.Error(w, "Container not available", http.StatusNotFound)
return
}
// Upgrade HTTP to WebSocket (client side)
clientConn, err := p.upgrader.Upgrade(w, r, nil)
if err != nil {
return // Upgrader already sent error response
}
defer clientConn.Close()
// Connect to container's websockify via WebSocket (not raw TCP)
// websockify expects WebSocket connections at /websockify path with binary subprotocol
wsURL := fmt.Sprintf("ws://%s/websockify", targetAddr)
dialer := websocket.Dialer{
Subprotocols: []string{"binary"},
HandshakeTimeout: 5 * time.Second,
}
targetConn, _, err := dialer.Dial(wsURL, nil)
if err != nil {
clientConn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "VNC connection failed"))
return
}
defer targetConn.Close()
// Bidirectional WebSocket proxy
done := make(chan struct{}, 2)
// Client -> Container
go func() {
defer func() { done <- struct{}{} }()
for {
messageType, data, err := clientConn.ReadMessage()
if err != nil {
return
}
if err := targetConn.WriteMessage(messageType, data); err != nil {
return
}
}
}()
// Container -> Client
go func() {
defer func() { done <- struct{}{} }()
for {
messageType, data, err := targetConn.ReadMessage()
if err != nil {
return
}
if err := clientConn.WriteMessage(messageType, data); err != nil {
return
}
}
}()
// Wait for either direction to close
<-done
}
// getContainerIP gets the IP address of a container, using cache with TTL
func (p *Proxy) getContainerIP(ctx context.Context, containerName string) (string, error) {
// Check cache
if cached, ok := p.ipCache.Load(containerName); ok {
entry := cached.(*ipCacheEntry)
if time.Now().Before(entry.ExpiresAt) {
return entry.IP, nil
}
// Cache expired, delete it
p.ipCache.Delete(containerName)
}
// Get from Docker
info, err := p.dockerClient.ContainerInspect(ctx, containerName)
if err != nil {
return "", fmt.Errorf("container not found: %w", err)
}
if !info.State.Running {
return "", fmt.Errorf("container not running")
}
// Get IP from the specified network or default bridge
var ip string
if info.NetworkSettings != nil && info.NetworkSettings.Networks != nil {
if net, ok := info.NetworkSettings.Networks[p.config.DockerNetwork]; ok {
ip = net.IPAddress
} else {
// Try to get IP from any network
for _, net := range info.NetworkSettings.Networks {
if net.IPAddress != "" {
ip = net.IPAddress
break
}
}
}
}
if ip == "" {
return "", fmt.Errorf("container has no IP address")
}
// Cache the result
p.ipCache.Store(containerName, &ipCacheEntry{
IP: ip,
ExpiresAt: time.Now().Add(p.config.IPCacheTTL),
})
return ip, nil
}
// getVNCEndpoint returns the host:port to connect to for VNC
// It first checks for port mapping (for Docker Desktop), then falls back to container IP
func (p *Proxy) getVNCEndpoint(ctx context.Context, containerName string) (string, error) {
info, err := p.dockerClient.ContainerInspect(ctx, containerName)
if err != nil {
return "", fmt.Errorf("container not found: %w", err)
}
if !info.State.Running {
return "", fmt.Errorf("container not running")
}
// Check for port mapping first (for Docker Desktop on macOS/Windows)
if info.NetworkSettings != nil && info.NetworkSettings.Ports != nil {
portKey := nat.Port(fmt.Sprintf("%d/tcp", p.config.ContainerNoVNCPort))
if bindings, ok := info.NetworkSettings.Ports[portKey]; ok && len(bindings) > 0 {
binding := bindings[0]
if binding.HostPort != "" {
// Use mapped port on localhost
host := binding.HostIP
if host == "" || host == "0.0.0.0" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, binding.HostPort), nil
}
}
}
// Fall back to container IP (works on Linux with native Docker)
ip, err := p.getContainerIP(ctx, containerName)
if err != nil {
return "", err
}
return net.JoinHostPort(ip, fmt.Sprintf("%d", p.config.ContainerNoVNCPort)), nil
}
// checkVNCEnabled checks if container has VNC enabled by checking env vars
func (p *Proxy) checkVNCEnabled(ctx context.Context, containerName string) bool {
info, err := p.dockerClient.ContainerInspect(ctx, containerName)
if err != nil {
return false
}
// Check environment variables for VNC_ENABLED or SANDBOX_VNC_ENABLED
for _, env := range info.Config.Env {
if strings.HasPrefix(env, "SANDBOX_VNC_ENABLED=true") ||
strings.HasPrefix(env, "VNC_ENABLED=true") {
return true
}
}
// Also check if container image is a VNC-enabled variant
imageName := info.Config.Image
if strings.Contains(imageName, "playwright") ||
strings.Contains(imageName, "desktop") {
return true
}
return false
}
// checkVNCReady tests if VNC services are ready
// Uses docker exec to test port connectivity (works across platforms including macOS Docker Desktop)
func (p *Proxy) checkVNCReady(ctx context.Context, containerName string) bool {
// Use docker exec to test port connectivity from inside the container
// This approach works regardless of host network configuration
execConfig := container.ExecOptions{
Cmd: []string{"sh", "-c", fmt.Sprintf("nc -z localhost %d 2>/dev/null || (echo | timeout 1 cat < /dev/tcp/localhost/%d > /dev/null 2>&1)", p.config.ContainerNoVNCPort, p.config.ContainerNoVNCPort)},
AttachStdout: false,
AttachStderr: false,
}
execResp, err := p.dockerClient.ContainerExecCreate(ctx, containerName, execConfig)
if err != nil {
return false
}
err = p.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{})
if err != nil {
return false
}
// Wait for exec to complete and check exit code
for i := 0; i < 10; i++ {
inspect, err := p.dockerClient.ContainerExecInspect(ctx, execResp.ID)
if err != nil {
return false
}
if !inspect.Running {
return inspect.ExitCode == 0
}
time.Sleep(100 * time.Millisecond)
}
return false
}
// serveNoVNCPage serves an inline HTML page with noVNC client
func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, viewOnly bool) {
viewOnlyStr := "false"
modeIndicator := "Interactive"
modeColor := "#4CAF50"
if viewOnly {
viewOnlyStr = "true"
modeIndicator = "View Only"
modeColor = "#FF9800"
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sandbox - %s</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%%; height: 100%%; overflow: hidden; background: #1e1e1e; }
#loading {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: #1e1e1e; color: #fff; font-family: system-ui, sans-serif;
}
.spinner {
width: 50px; height: 50px; border: 4px solid #333;
border-top-color: #4CAF50; border-radius: 50%%;
animation: spin 1s linear infinite; margin-bottom: 20px;
}
@keyframes spin { to { transform: rotate(360deg); } }
#status { font-size: 16px; margin-bottom: 10px; }
#retry-count { font-size: 14px; color: #888; }
#error { color: #f44336; display: none; }
#screen { width: 100%%; height: 100%%; display: none; }
#mode-indicator {
position: fixed; top: 10px; right: 10px; padding: 5px 12px;
background: %s; color: white; border-radius: 4px;
font-family: system-ui, sans-serif; font-size: 12px;
z-index: 1000; opacity: 0.9;
}
</style>
</head>
<body>
<div id="loading">
<div class="spinner"></div>
<div id="status">Connecting to Sandbox...</div>
<div id="retry-count"></div>
<div id="error"></div>
</div>
<div id="mode-indicator">%s</div>
<div id="screen"></div>
<script type="module">
// noVNC RFB class for VNC connections
import RFB from 'https://cdn.skypack.dev/novnc-core';
const sandboxID = '%s';
const wsPath = '%s';
const viewOnly = %s;
const statusAPI = '/v1/sandbox/' + sandboxID + '/vnc';
const maxRetries = 30;
let retryCount = 0;
const loading = document.getElementById('loading');
const screen = document.getElementById('screen');
const status = document.getElementById('status');
const retryCountEl = document.getElementById('retry-count');
const errorEl = document.getElementById('error');
const modeIndicator = document.getElementById('mode-indicator');
async function checkStatus() {
try {
const res = await fetch(statusAPI);
const data = await res.json();
if (data.status === 'ready') {
status.textContent = 'Initializing display...';
connectVNC();
return;
}
if (data.status === 'starting') {
status.textContent = 'Sandbox starting...';
} else if (data.status === 'not_supported') {
showError('This Sandbox does not support visualization');
return;
} else {
status.textContent = 'Waiting for container to be ready...';
}
retryCount++;
retryCountEl.textContent = 'Retry ' + retryCount + '/' + maxRetries;
if (retryCount >= maxRetries) {
showError('Connection timeout, please try again later');
return;
}
setTimeout(checkStatus, 1000);
} catch (err) {
retryCount++;
if (retryCount >= maxRetries) {
showError('Unable to connect to server');
return;
}
setTimeout(checkStatus, 1000);
}
}
function showError(msg) {
status.style.display = 'none';
retryCountEl.style.display = 'none';
document.querySelector('.spinner').style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
function connectVNC() {
loading.style.display = 'none';
screen.style.display = 'block';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsURL = protocol + '//' + window.location.host + wsPath;
const rfb = new RFB(screen, wsURL);
rfb.viewOnly = viewOnly;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
console.log('VNC connected');
modeIndicator.style.display = 'block';
});
rfb.addEventListener('disconnect', (e) => {
console.log('VNC disconnected', e.detail);
loading.style.display = 'flex';
screen.style.display = 'none';
modeIndicator.style.display = 'none';
if (e.detail.clean) {
status.textContent = 'Connection closed';
} else {
showError('Connection lost');
}
});
}
// Start checking status
checkStatus();
</script>
</body>
</html>`, sandboxID, modeColor, modeIndicator, sandboxID, wsPath, viewOnlyStr)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, html)
}
// RegisterRoutes registers VNC proxy routes to an HTTP mux
func (p *Proxy) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/v1/sandbox/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Match /v1/sandbox/{id}/vnc
if strings.HasSuffix(path, "/vnc") {
p.HandleVNCStatus(w, r)
return
}
// Match /v1/sandbox/{id}/vnc/client
if strings.HasSuffix(path, "/vnc/client") {
p.HandleVNCClient(w, r)
return
}
// Match /v1/sandbox/{id}/vnc/ws
if strings.HasSuffix(path, "/vnc/ws") {
p.HandleVNCWebSocket(w, r)
return
}
http.NotFound(w, r)
})
}
// Helper function to check if request requires VNC container
func (p *Proxy) isVNCRequest(r *http.Request) bool {
path := r.URL.Path
return strings.Contains(path, "/vnc")
}

View file

@ -0,0 +1,90 @@
package vncproxy
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestExtractSandboxID(t *testing.T) {
tests := []struct {
name string
path string
expected string
}{
{
name: "VNC status path",
path: "/v1/sandbox/abc123/vnc",
expected: "abc123",
},
{
name: "VNC client path",
path: "/v1/sandbox/user-chat-123/vnc/client",
expected: "user-chat-123",
},
{
name: "VNC websocket path",
path: "/v1/sandbox/test-sandbox-id/vnc/ws",
expected: "test-sandbox-id",
},
{
name: "Complex ID",
path: "/v1/sandbox/user_123-chat_456/vnc",
expected: "user_123-chat_456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
got := extractSandboxID(req)
if got != tt.expected {
t.Errorf("extractSandboxID() = %q, want %q", got, tt.expected)
}
})
}
}
func TestConfigDefaults(t *testing.T) {
config := DefaultConfig()
if config.DockerNetwork != "bridge" {
t.Errorf("DockerNetwork = %q, want %q", config.DockerNetwork, "bridge")
}
if config.ContainerNoVNCPort != 6080 {
t.Errorf("ContainerNoVNCPort = %d, want %d", config.ContainerNoVNCPort, 6080)
}
if config.ContainerVNCPort != 5900 {
t.Errorf("ContainerVNCPort = %d, want %d", config.ContainerVNCPort, 5900)
}
if config.ContainerNamePrefix != "yao-sandbox-" {
t.Errorf("ContainerNamePrefix = %q, want %q", config.ContainerNamePrefix, "yao-sandbox-")
}
}
func TestConfigInit(t *testing.T) {
config := &Config{}
config.Init()
// Should have defaults after Init
if config.DockerNetwork != "bridge" {
t.Errorf("DockerNetwork = %q, want %q", config.DockerNetwork, "bridge")
}
if config.ContainerNoVNCPort != 6080 {
t.Errorf("ContainerNoVNCPort = %d, want %d", config.ContainerNoVNCPort, 6080)
}
}
// Integration tests require Docker - skip if not available
func TestProxyCreation(t *testing.T) {
// This will fail if Docker is not available, which is expected in CI
proxy, err := NewProxy(nil)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
}
defer proxy.Close()
if proxy.config == nil {
t.Error("Proxy config should not be nil")
}
}