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

View file

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

View file

@ -79,7 +79,12 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er
// Create or get container
// Note: IPC session is created by manager.createContainer, socket is already bind mounted
ctx := context.Background()
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID)
createOpts := infraSandbox.CreateOptions{
UserID: execOpts.UserID,
ChatID: execOpts.ChatID,
Image: execOpts.Image,
}
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID, createOpts)
if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err)
}
@ -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 {

View file

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

View file

@ -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)

View file

@ -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)
}

View file

@ -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

View file

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

View file

@ -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,
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VNC - %s</title>
<title>Sandbox - %s</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
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>
<div id="loading">
<div class="spinner"></div>
<div id="status">正在连接 VNC...</div>
<div id="status">正在连接 Sandbox...</div>
<div id="retry-count"></div>
<div id="error"></div>
</div>
@ -428,7 +431,8 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
<div id="screen"></div>
<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 wsPath = '%s';
@ -450,15 +454,15 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
const data = await res.json();
if (data.status === 'ready') {
status.textContent = '正在初始化 VNC 客户端...';
status.textContent = '正在初始化显示...';
connectVNC();
return;
}
if (data.status === 'starting') {
status.textContent = 'VNC 服务启动中...';
status.textContent = 'Sandbox 启动中...';
} else if (data.status === 'not_supported') {
showError('容器不支持 VNC');
showError(' Sandbox 不支持可视化');
return;
} else {
status.textContent = '等待容器就绪...';
@ -514,9 +518,9 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
screen.style.display = 'none';
modeIndicator.style.display = 'none';
if (e.detail.clean) {
status.textContent = 'VNC 连接已关闭';
status.textContent = '连接已关闭';
} else {
showError('VNC 连接断开');
showError('连接断开');
}
});
}