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 <cursoragent@cursor.com>
This commit is contained in:
Max 2026-02-05 21:46:07 +08:00
parent 55164c5665
commit 0a965c383d
9 changed files with 200 additions and 31 deletions

View file

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

View file

@ -88,6 +88,17 @@ func (e *realSandboxExecutor) GetWorkDir() string {
return e.workDir 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 // TestJsSandboxNotAvailable tests ctx.sandbox when not configured
func TestJsSandboxNotAvailable(t *testing.T) { func TestJsSandboxNotAvailable(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)

View file

@ -79,7 +79,12 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er
// Create or get container // Create or get container
// Note: IPC session is created by manager.createContainer, socket is already bind mounted // Note: IPC session is created by manager.createContainer, socket is already bind mounted
ctx := context.Background() 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 { if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err) return nil, fmt.Errorf("failed to create container: %w", err)
} }
@ -1073,6 +1078,36 @@ func (e *Executor) GetWorkDir() string {
return e.workDir 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 // Close releases the executor resources and removes the container
// Note: IPC session is managed by sandbox.Manager.Remove() // Note: IPC session is managed by sandbox.Manager.Remove()
func (e *Executor) Close() error { func (e *Executor) Close() error {

View file

@ -32,6 +32,13 @@ type Executor interface {
// GetWorkDir returns the container workspace directory // GetWorkDir returns the container workspace directory
GetWorkDir() string 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 releases container resources
Close() error Close() error
} }

View file

@ -161,6 +161,7 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
app.Attach(group.Group("/app"), openapi.OAuth) app.Attach(group.Group("/app"), openapi.OAuth)
// Sandbox handlers (VNC proxy for visual browser automation) // Sandbox handlers (VNC proxy for visual browser automation)
sandbox.SetPathPrefix(baseURL)
sandbox.Attach(group.Group("/sandbox"), openapi.OAuth) sandbox.Attach(group.Group("/sandbox"), openapi.OAuth)
// Custom handlers (Defined by developer) // Custom handlers (Defined by developer)

View file

@ -1,7 +1,9 @@
package sandbox package sandbox
import ( import (
"fmt"
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/types" "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 // Initialize VNC proxy lazily on first request
// This avoids startup errors if Docker is not available // This avoids startup errors if Docker is not available
// VNC status endpoint (requires auth) // VNC status endpoint
group.GET("/:id/vnc", oauth.Guard, handleVNCStatus) group.GET("/:id/vnc", oauth.Guard, handleVNCStatus)
// VNC client page (requires auth) // VNC client page
group.GET("/:id/vnc/client", oauth.Guard, handleVNCClient) group.GET("/:id/vnc/client", oauth.Guard, handleVNCClient)
// VNC WebSocket proxy (requires auth) // VNC WebSocket proxy
// Note: WebSocket upgrade happens after auth middleware
group.GET("/:id/vnc/ws", oauth.Guard, handleVNCWebSocket) group.GET("/:id/vnc/ws", oauth.Guard, handleVNCWebSocket)
} }
@ -96,3 +97,23 @@ func Close() error {
} }
return nil return nil
} }
// pathPrefix stores the router path prefix for sandbox endpoints
var pathPrefix string = "/v1/sandbox"
// SetPathPrefix sets the path prefix for sandbox URLs
// Called during router setup with the actual OpenAPI base URL
func SetPathPrefix(prefix string) {
pathPrefix = strings.TrimSuffix(prefix, "/") + "/sandbox"
}
// GetVNCClientURL returns the API VNC client page URL
// sandboxID is the sandbox identifier (userID-chatID)
// Returns the URL path like "/v1/sandbox/{id}/vnc/client"
// Note: For CUI navigation, use "$dashboard/sandbox/{id}" directly with sandbox_id
func GetVNCClientURL(sandboxID string) string {
if sandboxID == "" {
return ""
}
return fmt.Sprintf("%s/%s/vnc/client", pathPrefix, sandboxID)
}

View file

@ -185,9 +185,17 @@ func (m *Manager) Close() error {
} }
// GetOrCreate returns existing container or creates new one // 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) 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) // Check if container already exists (fast path)
if c, ok := m.containers.Load(name); ok { if c, ok := m.containers.Load(name); ok {
cont := c.(*Container) cont := c.(*Container)
@ -243,7 +251,7 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
} }
// Create new container // Create new container
cont, err := m.createContainer(ctx, userID, chatID) cont, err := m.createContainer(ctx, createOpts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -256,11 +264,19 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
} }
// createContainer creates a new Docker container // 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) 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 // 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 return nil, err
} }
@ -283,7 +299,7 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*
// Container configuration // Container configuration
containerConfig := &container.Config{ containerConfig := &container.Config{
Image: m.config.Image, Image: image,
Cmd: []string{"sleep", "infinity"}, Cmd: []string{"sleep", "infinity"},
WorkingDir: m.config.ContainerWorkDir, WorkingDir: m.config.ContainerWorkDir,
User: m.config.ContainerUser, // Empty string uses image default 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) // VNC port mapping for Docker Desktop (macOS/Windows)
// Only enable for VNC-capable images (playwright/desktop) when config is enabled // 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 // Expose VNC ports in container config
containerConfig.ExposedPorts = nat.PortSet{ containerConfig.ExposedPorts = nat.PortSet{
"6080/tcp": struct{}{}, // noVNC websockify "6080/tcp": struct{}{}, // noVNC websockify

View file

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

View file

@ -172,15 +172,21 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
return return
} }
// Upgrade HTTP to WebSocket // Upgrade HTTP to WebSocket (client side)
clientConn, err := p.upgrader.Upgrade(w, r, nil) clientConn, err := p.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
return // Upgrader already sent error response return // Upgrader already sent error response
} }
defer clientConn.Close() defer clientConn.Close()
// Connect to container's websockify // Connect to container's websockify via WebSocket (not raw TCP)
targetConn, err := net.DialTimeout("tcp", targetAddr, 5*time.Second) // 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 { if err != nil {
clientConn.WriteMessage(websocket.CloseMessage, clientConn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "VNC connection failed")) websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "VNC connection failed"))
@ -188,8 +194,8 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
} }
defer targetConn.Close() defer targetConn.Close()
// Bidirectional proxy // Bidirectional WebSocket proxy
done := make(chan struct{}) done := make(chan struct{}, 2)
// Client -> Container // Client -> Container
go func() { go func() {
@ -199,24 +205,21 @@ func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
return return
} }
if messageType == websocket.BinaryMessage { if err := targetConn.WriteMessage(messageType, data); err != nil {
if _, err := targetConn.Write(data); err != nil {
return return
} }
} }
}
}() }()
// Container -> Client // Container -> Client
go func() { go func() {
defer func() { done <- struct{}{} }() defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for { for {
n, err := targetConn.Read(buf) messageType, data, err := targetConn.ReadMessage()
if err != nil { if err != nil {
return return
} }
if err := clientConn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { if err := clientConn.WriteMessage(messageType, data); err != nil {
return return
} }
} }
@ -390,7 +393,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VNC - %s</title> <title>Sandbox - %s</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%%; height: 100%%; overflow: hidden; background: #1e1e1e; } html, body { width: 100%%; height: 100%%; overflow: hidden; background: #1e1e1e; }
@ -420,7 +423,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
<body> <body>
<div id="loading"> <div id="loading">
<div class="spinner"></div> <div class="spinner"></div>
<div id="status">正在连接 VNC...</div> <div id="status">正在连接 Sandbox...</div>
<div id="retry-count"></div> <div id="retry-count"></div>
<div id="error"></div> <div id="error"></div>
</div> </div>
@ -428,7 +431,8 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
<div id="screen"></div> <div id="screen"></div>
<script type="module"> <script type="module">
import RFB from 'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/lib/rfb.js'; // noVNC RFB class for VNC connections
import RFB from 'https://cdn.skypack.dev/novnc-core';
const sandboxID = '%s'; const sandboxID = '%s';
const wsPath = '%s'; const wsPath = '%s';
@ -450,15 +454,15 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
const data = await res.json(); const data = await res.json();
if (data.status === 'ready') { if (data.status === 'ready') {
status.textContent = '正在初始化 VNC 客户端...'; status.textContent = '正在初始化显示...';
connectVNC(); connectVNC();
return; return;
} }
if (data.status === 'starting') { if (data.status === 'starting') {
status.textContent = 'VNC 服务启动中...'; status.textContent = 'Sandbox 启动中...';
} else if (data.status === 'not_supported') { } else if (data.status === 'not_supported') {
showError('容器不支持 VNC'); showError(' Sandbox 不支持可视化');
return; return;
} else { } else {
status.textContent = '等待容器就绪...'; status.textContent = '等待容器就绪...';
@ -514,9 +518,9 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
screen.style.display = 'none'; screen.style.display = 'none';
modeIndicator.style.display = 'none'; modeIndicator.style.display = 'none';
if (e.detail.clean) { if (e.detail.clean) {
status.textContent = 'VNC 连接已关闭'; status.textContent = '连接已关闭';
} else { } else {
showError('VNC 连接断开'); showError('连接断开');
} }
}); });
} }