From 0a965c383d31e706a23b64c0a1f1a20af4455bb9 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 5 Feb 2026 21:46:07 +0800 Subject: [PATCH] Add sandbox ID and VNC URL methods to sandbox executor - Implement GetSandboxID method to return a mock sandbox ID for testing. - Add GetVNCUrl method to return an empty string for VNC access in tests. - Update SandboxExecutor interface to include new methods for sandbox identification and VNC URL retrieval. - Enhance context creation to set sandbox ID and VNC URL properties in the sandbox instance. Co-authored-by: Cursor --- agent/context/jsapi_sandbox.go | 67 +++++++++++++++++++++++++++++ agent/context/jsapi_sandbox_test.go | 11 +++++ agent/sandbox/claude/executor.go | 37 +++++++++++++++- agent/sandbox/types.go | 7 +++ openapi/openapi.go | 1 + openapi/sandbox/sandbox.go | 29 +++++++++++-- sandbox/manager.go | 28 +++++++++--- sandbox/types.go | 7 +++ sandbox/vncproxy/proxy.go | 44 ++++++++++--------- 9 files changed, 200 insertions(+), 31 deletions(-) diff --git a/agent/context/jsapi_sandbox.go b/agent/context/jsapi_sandbox.go index 8439d691..9ad1edc6 100644 --- a/agent/context/jsapi_sandbox.go +++ b/agent/context/jsapi_sandbox.go @@ -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 + }) +} diff --git a/agent/context/jsapi_sandbox_test.go b/agent/context/jsapi_sandbox_test.go index 414347f0..b040b413 100644 --- a/agent/context/jsapi_sandbox_test.go +++ b/agent/context/jsapi_sandbox_test.go @@ -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) diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index 158bbfd4..5cdf51ed 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -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) } @@ -1073,6 +1078,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 { diff --git a/agent/sandbox/types.go b/agent/sandbox/types.go index ea17f710..209944a2 100644 --- a/agent/sandbox/types.go +++ b/agent/sandbox/types.go @@ -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 } diff --git a/openapi/openapi.go b/openapi/openapi.go index 3d3da032..32c0e64e 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -161,6 +161,7 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { 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) diff --git a/openapi/sandbox/sandbox.go b/openapi/sandbox/sandbox.go index eecaf437..f0d6e731 100644 --- a/openapi/sandbox/sandbox.go +++ b/openapi/sandbox/sandbox.go @@ -1,7 +1,9 @@ package sandbox import ( + "fmt" "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/yaoapp/yao/openapi/oauth/types" @@ -19,14 +21,13 @@ 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 (requires auth) + // VNC status endpoint group.GET("/:id/vnc", oauth.Guard, handleVNCStatus) - // VNC client page (requires auth) + // VNC client page group.GET("/:id/vnc/client", oauth.Guard, handleVNCClient) - // VNC WebSocket proxy (requires auth) - // Note: WebSocket upgrade happens after auth middleware + // VNC WebSocket proxy group.GET("/:id/vnc/ws", oauth.Guard, handleVNCWebSocket) } @@ -96,3 +97,23 @@ func Close() error { } 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) +} diff --git a/sandbox/manager.go b/sandbox/manager.go index b0fb56d3..80c8a545 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -185,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) @@ -243,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 } @@ -256,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 } @@ -283,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 @@ -310,7 +326,7 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (* // 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(m.config.Image) { + if m.config.VNCPortMapping && isVNCImage(image) { // Expose VNC ports in container config containerConfig.ExposedPorts = nat.PortSet{ "6080/tcp": struct{}{}, // noVNC websockify diff --git a/sandbox/types.go b/sandbox/types.go index 7db7e63c..96f09875 100644 --- a/sandbox/types.go +++ b/sandbox/types.go @@ -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) +} diff --git a/sandbox/vncproxy/proxy.go b/sandbox/vncproxy/proxy.go index 6ec1e4c2..4a671733 100644 --- a/sandbox/vncproxy/proxy.go +++ b/sandbox/vncproxy/proxy.go @@ -172,15 +172,21 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) { return } - // Upgrade HTTP to WebSocket + // 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 - targetConn, err := net.DialTimeout("tcp", targetAddr, 5*time.Second) + // 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")) @@ -188,8 +194,8 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) { } defer targetConn.Close() - // Bidirectional proxy - done := make(chan struct{}) + // Bidirectional WebSocket proxy + done := make(chan struct{}, 2) // Client -> Container go func() { @@ -199,10 +205,8 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) { if err != nil { return } - if messageType == websocket.BinaryMessage { - if _, err := targetConn.Write(data); err != nil { - return - } + if err := targetConn.WriteMessage(messageType, data); err != nil { + return } } }() @@ -210,13 +214,12 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) { // Container -> Client go func() { defer func() { done <- struct{}{} }() - buf := make([]byte, 32*1024) for { - n, err := targetConn.Read(buf) + messageType, data, err := targetConn.ReadMessage() if err != nil { return } - if err := clientConn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + if err := clientConn.WriteMessage(messageType, data); err != nil { return } } @@ -390,7 +393,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, - VNC - %s + Sandbox - %s