Merge pull request #1451 from trheyi/main
Fix sandbox compatibility, claude-proxy streaming
This commit is contained in:
commit
8a582ce149
41 changed files with 3784 additions and 404 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
636
data/bindata.go
636
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
119
openapi/sandbox/sandbox.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1498
sandbox/DESIGN-PLAYWRIGHT-VNC.md
Normal file
1498
sandbox/DESIGN-PLAYWRIGHT-VNC.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
sandbox/docker/browser/Dockerfile
Normal file
104
sandbox/docker/browser/Dockerfile
Normal 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"]
|
||||
BIN
sandbox/docker/browser/config/yao-logo.png
Normal file
BIN
sandbox/docker/browser/config/yao-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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
|
||||
|
|
|
|||
115
sandbox/docker/desktop/Dockerfile
Normal file
115
sandbox/docker/desktop/Dockerfile
Normal 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"]
|
||||
|
|
@ -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
|
||||
160
sandbox/docker/desktop/config/setup-xfce.sh
Normal file
160
sandbox/docker/desktop/config/setup-xfce.sh
Normal 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"
|
||||
9
sandbox/docker/desktop/config/workspace.desktop
Normal file
9
sandbox/docker/desktop/config/workspace.desktop
Normal 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;
|
||||
BIN
sandbox/docker/desktop/config/yao-logo-128.png
Normal file
BIN
sandbox/docker/desktop/config/yao-logo-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
sandbox/docker/desktop/config/yao-logo-256.png
Normal file
BIN
sandbox/docker/desktop/config/yao-logo-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
sandbox/docker/desktop/config/yao-logo-48.png
Normal file
BIN
sandbox/docker/desktop/config/yao-logo-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
16
sandbox/docker/desktop/config/yao-logo.svg
Normal file
16
sandbox/docker/desktop/config/yao-logo.svg
Normal 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 |
40
sandbox/docker/vnc/entrypoint-vnc.sh
Normal file
40
sandbox/docker/vnc/entrypoint-vnc.sh
Normal 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 "$@"
|
||||
132
sandbox/docker/vnc/start-vnc.sh
Normal file
132
sandbox/docker/vnc/start-vnc.sh
Normal 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
|
||||
|
|
@ -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 == ".." {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
81
sandbox/vncproxy/config.go
Normal file
81
sandbox/vncproxy/config.go
Normal 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
569
sandbox/vncproxy/proxy.go
Normal 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")
|
||||
}
|
||||
90
sandbox/vncproxy/proxy_test.go
Normal file
90
sandbox/vncproxy/proxy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue