package sandbox import ( "archive/tar" "bufio" "bytes" "context" "fmt" "io" "net" "os" "path/filepath" "strings" "sync" "time" "github.com/docker/docker/api/types/container" "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" ) // vncImageKeywords lists image name keywords that indicate VNC support. // Add new keywords here when adding new VNC-capable sandbox images. var vncImageKeywords = []string{"playwright", "desktop", "chrome"} // execReadCloser wraps a Reader with a Closer type execReadCloser struct { *bufio.Reader closer io.Closer } func (e *execReadCloser) Close() error { if e.closer != nil { return e.closer.Close() } return nil } // demuxReadCloser wraps Docker multiplexed stream and demuxes it to stdout only // It uses a pipe to feed demuxed stdout to the reader type demuxReadCloser struct { reader io.Reader pipeReader *io.PipeReader pipeWriter *io.PipeWriter closer io.Closer done chan struct{} err error closed bool mu sync.Mutex } // newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream func newDemuxReadCloser(src io.Reader, closer io.Closer) *demuxReadCloser { pr, pw := io.Pipe() d := &demuxReadCloser{ reader: src, pipeReader: pr, pipeWriter: pw, closer: closer, done: make(chan struct{}), } // Start demux goroutine go func() { defer close(d.done) defer pw.Close() // Use stdcopy to demux stdout and stderr // We only care about stdout here, stderr goes to a discard writer _, err := stdcopy.StdCopy(pw, io.Discard, src) if err != nil && err != io.EOF { d.mu.Lock() d.err = err d.mu.Unlock() } }() return d } func (d *demuxReadCloser) Read(p []byte) (int, error) { return d.pipeReader.Read(p) } func (d *demuxReadCloser) Close() error { d.mu.Lock() if d.closed { d.mu.Unlock() return nil } d.closed = true d.mu.Unlock() // Close the pipe writer first to signal EOF to any readers // This will cause pipeReader.Read() to return io.EOF d.pipeWriter.CloseWithError(io.EOF) // Close the source connection to interrupt stdcopy.StdCopy if d.closer != nil { d.closer.Close() } // Close the pipe reader to unblock any pending reads d.pipeReader.Close() // Wait for demux goroutine to finish with a timeout // Don't block forever if stdcopy.StdCopy is stuck select { case <-d.done: // Normal completion case <-time.After(5 * time.Second): // Timeout - goroutine may be stuck, but we've done cleanup } d.mu.Lock() err := d.err d.mu.Unlock() return err } // Manager manages sandbox containers type Manager struct { mu sync.Mutex // Protects creation containers sync.Map // containerName → *Container running int32 // Running container count ipcManager *ipc.Manager // IPC manager dockerClient *client.Client // Docker client config *Config // Configuration } // NewManager creates a new sandbox manager func NewManager(config *Config) (*Manager, error) { if config == nil { config = DefaultConfig() } // Apply defaults for missing container paths if config.ContainerWorkDir == "" { config.ContainerWorkDir = "/workspace" } if config.ContainerIPCSocket == "" { config.ContainerIPCSocket = "/tmp/yao.sock" } // Initialize Docker client cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, fmt.Errorf("failed to create Docker client: %w", err) } // Ping Docker to verify 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("%w: %v", ErrDockerNotAvailable, err) } // Ensure directories exist if err := os.MkdirAll(config.WorkspaceRoot, 0755); err != nil { cli.Close() return nil, fmt.Errorf("failed to create workspace directory: %w", err) } if err := os.MkdirAll(config.IPCDir, 0755); err != nil { cli.Close() return nil, fmt.Errorf("failed to create IPC directory: %w", err) } m := &Manager{ dockerClient: cli, config: config, ipcManager: ipc.NewManager(config.IPCDir), } // Start cleanup loop go m.startCleanupLoop(context.Background()) return m, nil } // Close closes the manager and cleans up resources func (m *Manager) Close() error { m.ipcManager.CloseAll() return m.dockerClient.Close() } // GetOrCreate returns existing container or creates new one 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) cont.LastUsedAt = time.Now() // Verify container actually exists in Docker // (container may have been removed externally or Docker restarted) _, err := m.dockerClient.ContainerInspect(ctx, cont.ID) if err != nil { // Container no longer exists in Docker, remove from cache and recreate m.containers.Delete(name) m.mu.Lock() if m.running > 0 { m.running-- } m.mu.Unlock() // Fall through to create new container } else { // Container exists, ensure IPC session exists (may have been closed) m.ensureIPCSession(ctx, userID, chatID) return cont, nil } } // Use mutex for creation to avoid race condition m.mu.Lock() defer m.mu.Unlock() // Double-check after acquiring lock if c, ok := m.containers.Load(name); ok { cont := c.(*Container) cont.LastUsedAt = time.Now() // Verify container actually exists in Docker _, err := m.dockerClient.ContainerInspect(ctx, cont.ID) if err != nil { // Container no longer exists in Docker, remove from cache m.containers.Delete(name) if m.running > 0 { m.running-- } // Fall through to create new container } else { // Container exists, ensure IPC session exists (may have been closed) m.ensureIPCSession(ctx, userID, chatID) return cont, nil } } // Check running container limit if m.running >= int32(m.config.MaxContainers) { return nil, ErrTooManyContainers } // Create new container cont, err := m.createContainer(ctx, createOpts) if err != nil { return nil, err } // Store and increment counter m.containers.Store(name, cont) m.running++ return cont, nil } // createContainer creates a new Docker container 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, image); err != nil { return nil, err } // Workspace directory workspaceHost := filepath.Join(m.config.WorkspaceRoot, userID, chatID) if err := os.MkdirAll(workspaceHost, 0755); err != nil { return nil, fmt.Errorf("failed to create workspace: %w", err) } // Create IPC session BEFORE container creation // This creates the socket file so it can be bind mounted sessionID := chatID agentCtx := &ipc.AgentContext{UserID: userID, ChatID: chatID} if _, err := m.ipcManager.Create(ctx, sessionID, agentCtx, nil); err != nil { return nil, fmt.Errorf("failed to create IPC session: %w", err) } // Get socket path (uses hash to avoid path length issues) ipcSocketHost := m.ipcManager.GetSocketPath(sessionID) // Container configuration containerConfig := &container.Config{ Image: image, Cmd: []string{"sleep", "infinity"}, WorkingDir: m.config.ContainerWorkDir, User: m.config.ContainerUser, // Empty string uses image default Env: []string{ "YAO_IPC_SOCKET=" + m.config.ContainerIPCSocket, }, } // Host configuration - mount IPC socket (now exists after ipcManager.Create) binds := []string{ workspaceHost + ":" + m.config.ContainerWorkDir, ipcSocketHost + ":" + m.config.ContainerIPCSocket, } hostConfig := &container.HostConfig{ Binds: binds, Resources: container.Resources{ Memory: parseMemory(m.config.MaxMemory), NanoCPUs: int64(m.config.MaxCPU * 1e9), }, SecurityOpt: []string{"no-new-privileges"}, CapDrop: []string{"ALL"}, } // Chrome/browser images need SYS_ADMIN for namespace-based process isolation. // Without it, Chrome renderer/GPU processes crash with error code 5. // Also increase /dev/shm (default 64MB is too small for Chrome rendering). // Set to 1/4 of MaxMemory, minimum 256MB. if IsVNCImage(image) { hostConfig.CapAdd = []string{"SYS_ADMIN"} memLimit := parseMemory(m.config.MaxMemory) shmSize := memLimit / 4 if shmSize < 256*1024*1024 { shmSize = 256 * 1024 * 1024 // minimum 256MB } hostConfig.ShmSize = shmSize } // 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 } containerConfig.Env = append(containerConfig.Env, "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 { return nil, fmt.Errorf("failed to create container: %w", err) } return &Container{ ID: resp.ID, Name: name, UserID: userID, ChatID: chatID, Status: StatusCreated, CreatedAt: time.Now(), LastUsedAt: time.Now(), }, nil } // ensureImage ensures the image exists locally, pulls if not func (m *Manager) ensureImage(ctx context.Context, imageName string) error { // Check if image exists locally _, _, err := m.dockerClient.ImageInspectWithRaw(ctx, imageName) if err == nil { return nil // Image exists } // Image not found, pull it reader, err := m.dockerClient.ImagePull(ctx, imageName, image.PullOptions{}) if err != nil { return fmt.Errorf("failed to pull image %s: %w", imageName, err) } defer reader.Close() // Wait for pull to complete by reading the response _, err = io.Copy(io.Discard, reader) if err != nil { return fmt.Errorf("failed to pull image %s: %w", imageName, err) } return nil } // ensureRunning ensures the container is running func (m *Manager) ensureRunning(ctx context.Context, name string) error { c, ok := m.containers.Load(name) if !ok { return ErrContainerNotFound } cont := c.(*Container) if cont.Status == StatusRunning { return nil } // Start the container if err := m.dockerClient.ContainerStart(ctx, cont.ID, container.StartOptions{}); err != nil { return fmt.Errorf("failed to start container: %w", err) } // Wait for container to be ready (inspect until running) for i := 0; i < 30; i++ { info, err := m.dockerClient.ContainerInspect(ctx, cont.ID) if err != nil { return fmt.Errorf("failed to inspect container: %w", err) } if info.State.Running { break } time.Sleep(100 * time.Millisecond) } // Fix IPC socket permissions inside container // This is needed because macOS Docker Desktop doesn't properly preserve // Unix socket permissions when bind mounting from host m.fixIPCSocketPermissions(ctx, cont.ID) m.mu.Lock() cont.Status = StatusRunning cont.LastUsedAt = time.Now() m.mu.Unlock() return nil } // Stream executes command and returns stdout reader func (m *Manager) Stream(ctx context.Context, name string, cmd []string, opts *ExecOptions) (io.ReadCloser, error) { // Ensure container is running if err := m.ensureRunning(ctx, name); err != nil { return nil, err } // Get container c, ok := m.containers.Load(name) if !ok { return nil, ErrContainerNotFound } cont := c.(*Container) // Update last used time cont.LastUsedAt = time.Now() // Default options if opts == nil { opts = &ExecOptions{} } if opts.WorkDir == "" { opts.WorkDir = "/workspace" } // Create exec instance execConfig := container.ExecOptions{ Cmd: cmd, WorkingDir: opts.WorkDir, Env: mapToSlice(opts.Env), AttachStdout: true, AttachStderr: true, AttachStdin: opts.Stdin != nil, } execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig) if err != nil { return nil, fmt.Errorf("failed to create exec: %w", err) } // Attach to exec attachResp, err := m.dockerClient.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{}) if err != nil { return nil, fmt.Errorf("failed to attach to exec: %w", err) } // Handle stdin if provided if opts.Stdin != nil { go func() { io.Copy(attachResp.Conn, opts.Stdin) attachResp.CloseWrite() }() } // Return demuxed reader that properly handles Docker multiplexed stream // This removes the 8-byte header from each frame and separates stdout from stderr return newDemuxReadCloser(attachResp.Reader, attachResp.Conn), nil } // Exec executes command and waits for completion func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts *ExecOptions) (*ExecResult, error) { if opts == nil { opts = &ExecOptions{} } // Apply timeout if specified if opts.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, opts.Timeout) defer cancel() } // Ensure container is running if err := m.ensureRunning(ctx, name); err != nil { return nil, err } // Get container c, ok := m.containers.Load(name) if !ok { return nil, ErrContainerNotFound } cont := c.(*Container) // Update last used time cont.LastUsedAt = time.Now() // Default options if opts.WorkDir == "" { opts.WorkDir = m.config.ContainerWorkDir } // Create exec instance execConfig := container.ExecOptions{ Cmd: cmd, WorkingDir: opts.WorkDir, Env: mapToSlice(opts.Env), AttachStdout: true, AttachStderr: true, AttachStdin: opts.Stdin != nil, } execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig) if err != nil { return nil, fmt.Errorf("failed to create exec: %w", err) } // Attach to exec attachResp, err := m.dockerClient.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{}) if err != nil { return nil, fmt.Errorf("failed to attach to exec: %w", err) } defer attachResp.Close() // Handle stdin if provided if opts.Stdin != nil { go func() { io.Copy(attachResp.Conn, opts.Stdin) attachResp.CloseWrite() }() } // Read output with context awareness outputCh := make(chan []byte, 1) errCh := make(chan error, 1) // Buffers for demuxed stdout and stderr var stdoutBuf, stderrBuf bytes.Buffer go func() { // Use stdcopy to properly demux Docker multiplexed stream _, err := stdcopy.StdCopy(&stdoutBuf, &stderrBuf, attachResp.Reader) if err != nil && err != io.EOF { errCh <- err return } outputCh <- nil }() select { case <-ctx.Done(): return nil, ctx.Err() case err := <-errCh: return nil, fmt.Errorf("failed to read output: %w", err) case <-outputCh: // Output received } // Wait for exec to complete and get exit code var exitCode int for i := 0; i < 100; i++ { // Max 10 seconds wait inspect, err := m.dockerClient.ContainerExecInspect(ctx, execResp.ID) if err != nil { return nil, fmt.Errorf("failed to inspect exec: %w", err) } if !inspect.Running { exitCode = inspect.ExitCode break } time.Sleep(100 * time.Millisecond) } return &ExecResult{ ExitCode: exitCode, Stdout: stdoutBuf.String(), Stderr: stderrBuf.String(), }, nil } // Start starts a stopped container func (m *Manager) Start(ctx context.Context, name string) error { return m.ensureRunning(ctx, name) } // Stop stops container but preserves data func (m *Manager) Stop(ctx context.Context, name string) error { c, ok := m.containers.Load(name) if !ok { return nil } cont := c.(*Container) // Only stop if running if cont.Status != StatusRunning { return nil } if err := m.dockerClient.ContainerStop(ctx, cont.ID, container.StopOptions{}); err != nil { // Ignore "not running" error if !strings.Contains(err.Error(), "is not running") { return fmt.Errorf("failed to stop container: %w", err) } } // Update status, decrement running count m.mu.Lock() if cont.Status == StatusRunning { cont.Status = StatusStopped m.running-- } m.mu.Unlock() return nil } // Remove deletes container and its data func (m *Manager) Remove(ctx context.Context, name string) error { // Stop first if running m.Stop(ctx, name) c, ok := m.containers.Load(name) if !ok { return nil } cont := c.(*Container) // Close IPC session m.ipcManager.Close(cont.ChatID) if err := m.dockerClient.ContainerRemove(ctx, cont.ID, container.RemoveOptions{Force: true}); err != nil { return fmt.Errorf("failed to remove container: %w", err) } // Remove from map m.containers.Delete(name) return nil } // KillProcess kills a process inside the container by name pattern // This is used to forcefully stop long-running processes like Claude CLI func (m *Manager) KillProcess(ctx context.Context, name string, processPattern string) error { c, ok := m.containers.Load(name) if !ok { return ErrContainerNotFound } cont := c.(*Container) // Use pkill to kill processes matching the pattern // -f matches against the full command line // Use SIGKILL (-9) to ensure the process is killed immediately cmd := []string{"pkill", "-9", "-f", processPattern} execConfig := container.ExecOptions{ Cmd: cmd, AttachStdout: true, AttachStderr: true, } execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig) if err != nil { return fmt.Errorf("failed to create exec for kill: %w", err) } // Start the exec if err := m.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{}); err != nil { return fmt.Errorf("failed to start exec for kill: %w", err) } return nil } // List returns all containers for a user func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) { var result []*Container prefix := fmt.Sprintf("yao-sandbox-%s-", userID) m.containers.Range(func(key, value interface{}) bool { name := key.(string) if strings.HasPrefix(name, prefix) { result = append(result, value.(*Container)) } return true }) return result, nil } // Cleanup stops idle containers func (m *Manager) Cleanup(ctx context.Context) error { now := time.Now() m.containers.Range(func(key, value interface{}) bool { name := key.(string) c := value.(*Container) idleTime := now.Sub(c.LastUsedAt) // Stop idle containers if c.Status == StatusRunning && idleTime > m.config.IdleTimeout { m.Stop(ctx, name) } return true }) return nil } // startCleanupLoop starts the periodic cleanup loop func (m *Manager) startCleanupLoop(ctx context.Context) { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: m.Cleanup(ctx) } } } // WriteFile writes content to a file in container func (m *Manager) WriteFile(ctx context.Context, name, path string, content []byte) error { c, ok := m.containers.Load(name) if !ok { return ErrContainerNotFound } cont := c.(*Container) // Ensure parent directory exists dir := filepath.Dir(path) if dir != "/" && dir != "." { result, err := m.Exec(ctx, name, []string{"mkdir", "-p", dir}, nil) if err != nil { return fmt.Errorf("failed to create parent directory: %w", err) } if result.ExitCode != 0 { return fmt.Errorf("mkdir failed with exit code %d: %s", result.ExitCode, result.Stdout) } // Verify directory was created verifyResult, err := m.Exec(ctx, name, []string{"test", "-d", dir}, nil) if err != nil { return fmt.Errorf("failed to verify directory: %w", err) } if verifyResult.ExitCode != 0 { return fmt.Errorf("directory %s was not created", dir) } } // Create a tar archive with the file var buf bytes.Buffer tw := tar.NewWriter(&buf) hdr := &tar.Header{ Name: filepath.Base(path), Mode: 0644, Size: int64(len(content)), } if err := tw.WriteHeader(hdr); err != nil { return err } if _, err := tw.Write(content); err != nil { return err } if err := tw.Close(); err != nil { return err } // Copy to container return m.dockerClient.CopyToContainer(ctx, cont.ID, dir, &buf, container.CopyToContainerOptions{}) } // ReadFile reads content from a file in container // Since workspace is bind-mounted, we read directly from host for better performance func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, error) { c, ok := m.containers.Load(name) if !ok { return nil, ErrContainerNotFound } cont := c.(*Container) // Read directly from host bind mount hostPath := m.containerPathToHost(cont, path) if hostPath == "" { return nil, fmt.Errorf("path %s is not within workspace", path) } return os.ReadFile(hostPath) } // 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, false), nil } // Stat returns file info func (m *Manager) Stat(ctx context.Context, name, path string) (*FileInfo, error) { result, err := m.Exec(ctx, name, []string{"stat", "--format=%n|%s|%f|%Y|%F", path}, nil) if err != nil { return nil, err } return parseStat(result.Stdout), nil } // MkDir creates directory in container func (m *Manager) MkDir(ctx context.Context, name, path string) error { _, err := m.Exec(ctx, name, []string{"mkdir", "-p", path}, nil) return err } // RemoveFile removes file or directory in container func (m *Manager) RemoveFile(ctx context.Context, name, path string) error { _, err := m.Exec(ctx, name, []string{"rm", "-rf", path}, nil) return err } // CopyToContainer copies from host to container func (m *Manager) CopyToContainer(ctx context.Context, name, hostPath, containerPath string) error { c, ok := m.containers.Load(name) if !ok { return ErrContainerNotFound } cont := c.(*Container) // Create tar archive from host path archive, err := createTarFromPath(hostPath) if err != nil { return err } defer archive.Close() return m.dockerClient.CopyToContainer(ctx, cont.ID, containerPath, archive, container.CopyToContainerOptions{}) } // CopyFromContainer copies from container to host func (m *Manager) CopyFromContainer(ctx context.Context, name, containerPath, hostPath string) error { c, ok := m.containers.Load(name) if !ok { return ErrContainerNotFound } cont := c.(*Container) reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, containerPath) if err != nil { return err } defer reader.Close() return extractTarToPath(reader, hostPath) } // GetIPCManager returns the IPC manager func (m *Manager) GetIPCManager() *ipc.Manager { return m.ipcManager } // GetConfig returns the configuration func (m *Manager) GetConfig() *Config { return m.config } // ensureIPCSession ensures IPC session exists for the given chatID // This is called when reusing an existing container to handle cases where // the IPC session was closed but the container still exists func (m *Manager) ensureIPCSession(ctx context.Context, userID, chatID string) { sessionID := chatID // Check if session already exists if _, ok := m.ipcManager.Get(sessionID); ok { return } // Create new session (ignore error - container can work without IPC) agentCtx := &ipc.AgentContext{UserID: userID, ChatID: chatID} m.ipcManager.Create(ctx, sessionID, agentCtx, nil) } // containerPathToHost converts a container path to the corresponding host path // Returns empty string if the path is not within a bind-mounted directory func (m *Manager) containerPathToHost(cont *Container, containerPath string) string { // Container workspace is mounted at ContainerWorkDir (e.g., /workspace) // Host path is WorkspaceRoot/{userID}/{chatID} workDir := m.config.ContainerWorkDir if strings.HasPrefix(containerPath, workDir) { relativePath := strings.TrimPrefix(containerPath, workDir) relativePath = strings.TrimPrefix(relativePath, "/") return filepath.Join(m.config.WorkspaceRoot, cont.UserID, cont.ChatID, relativePath) } return "" } // fixIPCSocketPermissions fixes IPC socket permissions inside the container // This is needed because macOS Docker Desktop with gRPC-FUSE doesn't properly // preserve Unix socket permissions when bind mounting from host. // We run chmod as root (using container exec with User override) to make the // socket accessible to the sandbox user. func (m *Manager) fixIPCSocketPermissions(ctx context.Context, containerID string) { // Execute chmod as root to fix socket permissions execConfig := container.ExecOptions{ Cmd: []string{"chmod", "666", m.config.ContainerIPCSocket}, User: "root", // Run as root to be able to change permissions } execResp, err := m.dockerClient.ContainerExecCreate(ctx, containerID, execConfig) if err != nil { // Log but don't fail - container can work without proper IPC return } // Start the exec and wait for completion err = m.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{}) if err != nil { // Log but don't fail return } // Wait briefly for the chmod to complete time.Sleep(50 * time.Millisecond) } // IsVNCImage checks if the image is VNC-capable based on vncImageKeywords. func IsVNCImage(imageName string) bool { for _, kw := range vncImageKeywords { if strings.Contains(imageName, kw) { return true } } return false } // 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 }