Files
yuan-lab-llm--clawmanager/backend/internal/handlers/instance_handler.go
T
wehub-resource-sync 6d978fe483
Build / Frontend Build (push) Has been cancelled
Build / Backend Build (push) Has been cancelled
Build / Docker Build (push) Has been cancelled
Build / Kubernetes Deploy Smoke Test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:24 +08:00

2109 lines
64 KiB
Go

package handlers
import (
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"clawreef/internal/models"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
// openclawMinArchiveBytes is the minimum acceptable size of an .openclaw
// export archive. A correctly-compressed tar.gz of a single empty file is
// already ~125 bytes, so anything smaller indicates a malformed or empty
// stream from the exec pipeline rather than a real workspace dump.
const openclawMinArchiveBytes = 100
const (
defaultWorkspaceArchiveMaxMiB = int64(500)
workspaceArchiveMaxMiBEnv = "CLAWMANAGER_WORKSPACE_ARCHIVE_MAX_MIB"
maxLiteBatchCreateCount = 20
maxLiteBatchDeleteCount = 100
)
// desktopDirectProxyEnv toggles embedding the instance Service "host:port" into
// the access token so the edge gateway can proxy desktop traffic directly to the
// instance instead of relaying it through this control-plane process.
const desktopDirectProxyEnv = "CLAWMANAGER_DESKTOP_DIRECT_PROXY"
// desktopDirectProxyEnabled reports whether direct desktop proxying is enabled.
func desktopDirectProxyEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(desktopDirectProxyEnv))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func desktopProxyMode(directEnabled bool, upstream string) string {
if !directEnabled {
return "control-plane"
}
if strings.TrimSpace(upstream) == "" {
return "fallback"
}
return "direct"
}
func (h *InstanceHandler) desktopAccessUpstream(c *gin.Context, instance *models.Instance, targetPort int32) (string, bool) {
directProxyEnabled := desktopDirectProxyEnabled()
if !directProxyEnabled {
return "", false
}
if usesRuntimeGateway(instance) {
return "", false
}
if !h.proxyService.IsWebtopInstanceType(instance.Type) {
fmt.Printf("Desktop direct proxy fallback: unsupported desktop instance type instance=%d user=%d type=%s target_port=%d\n",
instance.ID, instance.UserID, instance.Type, targetPort)
return "", true
}
resolved, resolveErr := h.proxyService.ResolveUpstreamHostPort(
c.Request.Context(),
instance.UserID,
instance.ID,
targetPort,
)
if resolveErr != nil {
fmt.Printf("Desktop direct proxy fallback: failed to resolve upstream instance=%d user=%d type=%s target_port=%d error=%v\n",
instance.ID, instance.UserID, instance.Type, targetPort, resolveErr)
return "", true
}
if strings.TrimSpace(resolved) == "" {
fmt.Printf("Desktop direct proxy fallback: resolved empty upstream instance=%d user=%d type=%s target_port=%d\n",
instance.ID, instance.UserID, instance.Type, targetPort)
return "", true
}
fmt.Printf("Desktop direct proxy resolved: instance=%d user=%d type=%s target_port=%d upstream=%s\n",
instance.ID, instance.UserID, instance.Type, targetPort, resolved)
return resolved, true
}
func usesRuntimeGateway(instance *models.Instance) bool {
if instance == nil {
return false
}
if strings.EqualFold(strings.TrimSpace(instance.RuntimeType), services.RuntimeBackendGateway) {
return true
}
if mode, ok := services.NormalizeInstanceMode(instance.InstanceMode); ok && mode == services.InstanceModeLite {
return true
}
return false
}
func workspaceArchiveMaxMiB() int64 {
value := strings.TrimSpace(os.Getenv(workspaceArchiveMaxMiBEnv))
if value == "" {
return defaultWorkspaceArchiveMaxMiB
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return defaultWorkspaceArchiveMaxMiB
}
return parsed
}
func workspaceArchiveMaxBytes() int64 {
return workspaceArchiveMaxMiB() << 20
}
// InstanceHandler handles instance management requests
type InstanceHandler struct {
instanceService services.InstanceService
instanceAgentService services.InstanceAgentService
runtimeStatusService services.InstanceRuntimeStatusService
instanceCommandService services.InstanceCommandService
instanceConfigRevisionService services.InstanceConfigRevisionService
accessService *services.InstanceAccessService
proxyService *services.InstanceProxyService
shellService *services.InstanceShellService
openClawTransferService services.OpenClawTransferService
openClawConfigService services.OpenClawConfigService
skillService services.SkillService
externalAccessService services.InstanceExternalAccessService
}
// NewInstanceHandler creates a new instance handler
func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService, skillService services.SkillService, externalAccessService services.InstanceExternalAccessService, proxyOptions ...services.InstanceProxyServiceOption) *InstanceHandler {
accessService := services.NewInstanceAccessService()
return &InstanceHandler{
instanceService: instanceService,
instanceAgentService: instanceAgentService,
runtimeStatusService: runtimeStatusService,
instanceCommandService: instanceCommandService,
instanceConfigRevisionService: instanceConfigRevisionService,
accessService: accessService,
proxyService: services.NewInstanceProxyService(accessService, proxyOptions...),
shellService: services.NewInstanceShellService(),
openClawTransferService: services.NewOpenClawTransferService(),
openClawConfigService: openClawConfigService,
skillService: skillService,
externalAccessService: externalAccessService,
}
}
// Shutdown releases resources held by the handler (e.g. background goroutines).
func (h *InstanceHandler) Shutdown() {
if h.accessService != nil {
h.accessService.Stop()
}
}
type InstanceRuntimeDetailsResponse struct {
Runtime *services.InstanceRuntimeStatusPayload `json:"runtime,omitempty"`
Agent *services.InstanceAgentPayload `json:"agent,omitempty"`
Commands []services.InstanceCommandPayload `json:"commands,omitempty"`
}
type CreateRuntimeCommandRequest struct {
IdempotencyKey string `json:"idempotency_key,omitempty"`
}
type PublishConfigRevisionRequest struct {
SnapshotID int `json:"snapshot_id" binding:"required,min=1"`
}
type ExternalAccessRequest struct {
ExpiresMode string `json:"expires_mode,omitempty"`
ExpiresPreset string `json:"expires_preset,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// CreateInstanceRequest represents a create instance request
type CreateInstanceRequest struct {
Name string `json:"name" binding:"required,min=3,max=50"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop hermes"`
Mode string `json:"mode" binding:"omitempty,oneof=lite pro"`
InstanceMode string `json:"instance_mode" binding:"omitempty,oneof=lite pro"`
RuntimeType string `json:"runtime_type" binding:"omitempty,oneof=gateway desktop shell"`
DesktopStreamProfile string `json:"desktop_stream_profile,omitempty" binding:"omitempty,oneof=low standard high"`
CPUCores float64 `json:"cpu_cores" binding:"required,min=0.1,max=32"`
MemoryGB int `json:"memory_gb" binding:"required,min=1,max=128"`
DiskGB int `json:"disk_gb" binding:"required,min=10,max=1000"`
GPUEnabled bool `json:"gpu_enabled"`
GPUCount int `json:"gpu_count" binding:"min=0,max=4"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version" binding:"required"`
ImageRegistry *string `json:"image_registry,omitempty"`
ImageTag *string `json:"image_tag,omitempty"`
EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"`
StorageClass string `json:"storage_class"`
OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
SkillIDs []int `json:"skill_ids,omitempty"`
}
type BatchCreateLiteInstanceTemplate struct {
Type string `json:"type,omitempty"`
CPUCores float64 `json:"cpu_cores,omitempty"`
MemoryGB int `json:"memory_gb,omitempty"`
DiskGB int `json:"disk_gb,omitempty"`
OSType string `json:"os_type,omitempty"`
OSVersion string `json:"os_version,omitempty"`
ImageRegistry *string `json:"image_registry,omitempty"`
ImageTag *string `json:"image_tag,omitempty"`
EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"`
StorageClass string `json:"storage_class,omitempty"`
OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
SkillIDs []int `json:"skill_ids,omitempty"`
}
// BatchCreateLiteInstancesRequest represents a lite batch create request
type BatchCreateLiteInstancesRequest struct {
NamePrefix string `json:"name_prefix" binding:"required,min=1,max=40"`
Count int `json:"count" binding:"required,min=1,max=20"`
StartIndex int `json:"start_index" binding:"omitempty,min=0,max=9999"`
Template *BatchCreateLiteInstanceTemplate `json:"template,omitempty"`
}
type BatchCreateLiteInstanceResult struct {
Name string `json:"name"`
Status string `json:"status"`
Instance *models.Instance `json:"instance,omitempty"`
Error string `json:"error,omitempty"`
}
type BatchCreateLiteInstancesResponse struct {
Requested int `json:"requested"`
Created int `json:"created"`
Failed int `json:"failed"`
Results []BatchCreateLiteInstanceResult `json:"results"`
}
type BatchDeleteLiteInstancesRequest struct {
InstanceIDs []int `json:"instance_ids" binding:"required,min=1,max=100,dive,min=1"`
}
type BatchDeleteLiteInstanceResult struct {
InstanceID int `json:"instance_id"`
Name string `json:"name,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type BatchDeleteLiteInstancesResponse struct {
Requested int `json:"requested"`
Deleted int `json:"deleted"`
Failed int `json:"failed"`
Results []BatchDeleteLiteInstanceResult `json:"results"`
}
// UpdateInstanceRequest represents an update instance request
type UpdateInstanceRequest struct {
Name *string `json:"name,omitempty" binding:"omitempty,min=3,max=50"`
Description *string `json:"description,omitempty"`
DesktopStreamProfile *string `json:"desktop_stream_profile,omitempty" binding:"omitempty,oneof=low standard high"`
}
// ListInstancesRequest represents a list instances request
type ListInstancesRequest struct {
Page int `form:"page,default=1"`
Limit int `form:"limit,default=20"`
Status string `form:"status,omitempty"`
}
// ListInstances lists instances owned by the current user (workspace view).
//
// This endpoint is always caller-scoped — the caller's role is intentionally
// not consulted. An admin using /instances sees only instances they personally
// own. Admin-scoped cross-user listing lives on /admin/instances and is gated
// by the admin middleware; see ListAllInstances below.
func (h *InstanceHandler) ListInstances(c *gin.Context) {
userID, _ := c.Get("userID")
var req ListInstancesRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ValidationError(c, err)
return
}
// Calculate offset
offset := (req.Page - 1) * req.Limit
instances, total, err := h.instanceService.GetByUserID(userID.(int), offset, req.Limit)
if err != nil {
utils.HandleError(c, err)
return
}
response := map[string]interface{}{
"instances": instances,
"total": total,
"page": req.Page,
"limit": req.Limit,
}
utils.Success(c, http.StatusOK, "Instances retrieved successfully", response)
}
// ListAllInstances lists every instance across all users (admin console view).
//
// Gated by the admin middleware on the /admin/instances route group. The
// admin role badge only controls which API surface is reachable — it does
// not widen the caller-scoped /instances endpoint.
func (h *InstanceHandler) ListAllInstances(c *gin.Context) {
var req ListInstancesRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ValidationError(c, err)
return
}
offset := (req.Page - 1) * req.Limit
instances, total, err := h.instanceService.GetAllInstances(offset, req.Limit)
if err != nil {
utils.HandleError(c, err)
return
}
response := map[string]interface{}{
"instances": instances,
"total": total,
"page": req.Page,
"limit": req.Limit,
}
utils.Success(c, http.StatusOK, "Instances retrieved successfully", response)
}
// CreateInstance creates a new instance
func (h *InstanceHandler) CreateInstance(c *gin.Context) {
userID, _ := c.Get("userID")
var req CreateInstanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
createReq := instanceCreateRequestToService(req)
instance, err := h.instanceService.Create(userID.(int), createReq)
if err != nil {
utils.HandleError(c, err)
return
}
skillIDs, err := h.resolveCreateInstanceSkillIDs(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
for _, skillID := range skillIDs {
if _, err := h.skillService.AttachSkillToInstance(instance.ID, skillID); err != nil {
utils.HandleError(c, err)
return
}
}
utils.Success(c, http.StatusCreated, "Instance created successfully", instance)
}
func instanceCreateRequestToService(req CreateInstanceRequest) services.CreateInstanceRequest {
return services.CreateInstanceRequest{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Mode: req.Mode,
InstanceMode: req.InstanceMode,
RuntimeType: req.RuntimeType,
DesktopStreamProfile: req.DesktopStreamProfile,
CPUCores: req.CPUCores,
MemoryGB: req.MemoryGB,
DiskGB: req.DiskGB,
GPUEnabled: req.GPUEnabled,
GPUCount: req.GPUCount,
OSType: req.OSType,
OSVersion: req.OSVersion,
ImageRegistry: req.ImageRegistry,
ImageTag: req.ImageTag,
EnvironmentOverrides: req.EnvironmentOverrides,
StorageClass: req.StorageClass,
OpenClawConfigPlan: req.OpenClawConfigPlan,
}
}
func (h *InstanceHandler) BatchCreateLiteInstances(c *gin.Context) {
userID, _ := c.Get("userID")
var req BatchCreateLiteInstancesRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
createRequests, handlerRequests, err := buildLiteBatchCreateRequests(req)
if err != nil {
utils.Error(c, http.StatusBadRequest, err.Error())
return
}
if err := h.instanceService.ValidateCreateRequests(userID.(int), createRequests); err != nil {
utils.HandleError(c, err)
return
}
response := BatchCreateLiteInstancesResponse{
Requested: len(createRequests),
Results: make([]BatchCreateLiteInstanceResult, 0, len(createRequests)),
}
for idx, createReq := range createRequests {
handlerReq := handlerRequests[idx]
result := BatchCreateLiteInstanceResult{
Name: createReq.Name,
Status: "created",
}
instance, err := h.instanceService.Create(userID.(int), createReq)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
response.Failed++
response.Results = append(response.Results, result)
continue
}
result.Instance = instance
skillIDs, err := h.resolveCreateInstanceSkillIDs(userID.(int), handlerReq)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
response.Failed++
response.Results = append(response.Results, result)
continue
}
attachFailed := false
for _, skillID := range skillIDs {
if _, err := h.skillService.AttachSkillToInstance(instance.ID, skillID); err != nil {
result.Status = "failed"
result.Error = err.Error()
response.Failed++
response.Results = append(response.Results, result)
attachFailed = true
break
}
}
if attachFailed {
continue
}
response.Created++
response.Results = append(response.Results, result)
}
status := http.StatusCreated
if response.Failed > 0 {
status = http.StatusMultiStatus
}
utils.Success(c, status, "Lite instances batch create completed", response)
}
func buildLiteBatchCreateRequests(req BatchCreateLiteInstancesRequest) ([]services.CreateInstanceRequest, []CreateInstanceRequest, error) {
count := req.Count
if count <= 0 {
return nil, nil, fmt.Errorf("count is required")
}
if count > maxLiteBatchCreateCount {
return nil, nil, fmt.Errorf("count must be at most %d", maxLiteBatchCreateCount)
}
prefix := strings.TrimSpace(req.NamePrefix)
if prefix == "" {
return nil, nil, fmt.Errorf("name prefix is required")
}
startIndex := req.StartIndex
if startIndex <= 0 {
startIndex = 1
}
template := CreateInstanceRequest{}
if req.Template != nil {
template.Type = req.Template.Type
template.CPUCores = req.Template.CPUCores
template.MemoryGB = req.Template.MemoryGB
template.DiskGB = req.Template.DiskGB
template.OSType = req.Template.OSType
template.OSVersion = req.Template.OSVersion
template.ImageRegistry = req.Template.ImageRegistry
template.ImageTag = req.Template.ImageTag
template.EnvironmentOverrides = req.Template.EnvironmentOverrides
template.StorageClass = req.Template.StorageClass
template.OpenClawConfigPlan = req.Template.OpenClawConfigPlan
template.SkillIDs = req.Template.SkillIDs
}
template.Type = strings.ToLower(strings.TrimSpace(template.Type))
if template.Type == "" {
template.Type = "openclaw"
}
if template.Type != "openclaw" && template.Type != "hermes" {
return nil, nil, fmt.Errorf("lite batch create supports openclaw or hermes instances")
}
if template.CPUCores <= 0 {
template.CPUCores = 2
}
if template.MemoryGB <= 0 {
template.MemoryGB = 4
}
if template.DiskGB <= 0 {
template.DiskGB = 20
}
if strings.TrimSpace(template.OSType) == "" {
template.OSType = template.Type
}
if strings.TrimSpace(template.OSVersion) == "" {
template.OSVersion = "latest"
}
template.Mode = services.InstanceModeLite
template.InstanceMode = services.InstanceModeLite
template.RuntimeType = services.RuntimeBackendGateway
template.GPUEnabled = false
template.GPUCount = 0
template.DesktopStreamProfile = ""
createRequests := make([]services.CreateInstanceRequest, 0, count)
handlerRequests := make([]CreateInstanceRequest, 0, count)
for offset := 0; offset < count; offset++ {
index := startIndex + offset
item := template
item.Name = fmt.Sprintf("%s-%03d", prefix, index)
if len(item.Name) > 50 {
return nil, nil, fmt.Errorf("generated instance name %q is too long", item.Name)
}
handlerRequests = append(handlerRequests, item)
createRequests = append(createRequests, instanceCreateRequestToService(item))
}
return createRequests, handlerRequests, nil
}
func (h *InstanceHandler) resolveCreateInstanceSkillIDs(userID int, req CreateInstanceRequest) ([]int, error) {
seen := map[int]struct{}{}
result := make([]int, 0, len(req.SkillIDs))
for _, skillID := range req.SkillIDs {
if skillID <= 0 {
continue
}
if _, exists := seen[skillID]; exists {
continue
}
seen[skillID] = struct{}{}
result = append(result, skillID)
}
if h.openClawConfigService != nil {
bundleSkillIDs, err := h.openClawConfigService.ResolveBundleSkillIDs(userID, req.OpenClawConfigPlan)
if err != nil {
return nil, err
}
for _, skillID := range bundleSkillIDs {
if skillID <= 0 {
continue
}
if _, exists := seen[skillID]; exists {
continue
}
seen[skillID] = struct{}{}
result = append(result, skillID)
}
}
return result, nil
}
func (h *InstanceHandler) BatchDeleteLiteInstances(c *gin.Context) {
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
var req BatchDeleteLiteInstancesRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if len(req.InstanceIDs) > maxLiteBatchDeleteCount {
utils.Error(c, http.StatusBadRequest, fmt.Sprintf("instance_ids must include at most %d items", maxLiteBatchDeleteCount))
return
}
seen := map[int]struct{}{}
instances := make([]*models.Instance, 0, len(req.InstanceIDs))
for _, id := range req.InstanceIDs {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, fmt.Sprintf("instance %d not found", id))
return
}
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
if !isLiteInstance(instance) {
utils.Error(c, http.StatusBadRequest, fmt.Sprintf("instance %d is not a lite instance", id))
return
}
instances = append(instances, instance)
}
response := BatchDeleteLiteInstancesResponse{
Requested: len(instances),
Results: make([]BatchDeleteLiteInstanceResult, 0, len(instances)),
}
for _, instance := range instances {
result := BatchDeleteLiteInstanceResult{
InstanceID: instance.ID,
Name: instance.Name,
Status: "deleting",
}
if err := h.instanceService.Delete(instance.ID); err != nil {
result.Status = "failed"
result.Error = err.Error()
response.Failed++
} else {
response.Deleted++
}
response.Results = append(response.Results, result)
}
status := http.StatusAccepted
if response.Failed > 0 {
status = http.StatusMultiStatus
}
utils.Success(c, status, "Lite instances batch delete started", response)
}
func isLiteInstance(instance *models.Instance) bool {
if instance == nil {
return false
}
if mode, ok := services.NormalizeInstanceMode(instance.InstanceMode); ok && mode == services.InstanceModeLite {
return true
}
return strings.EqualFold(strings.TrimSpace(instance.RuntimeType), services.RuntimeBackendGateway)
}
// GetInstance gets an instance by ID
func (h *InstanceHandler) GetInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can view)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
runtime, _ := h.runtimeStatusService.GetByInstanceID(instance.ID)
agent, _ := h.instanceAgentService.GetPayloadByInstanceID(instance.ID)
utils.Success(c, http.StatusOK, "Instance retrieved successfully", gin.H{
"instance": instance,
"runtime": runtime,
"agent": agent,
})
}
// UpdateInstance updates an instance
func (h *InstanceHandler) UpdateInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can update)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
var req UpdateInstanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
updateReq := services.UpdateInstanceRequest{
Name: req.Name,
Description: req.Description,
DesktopStreamProfile: req.DesktopStreamProfile,
}
if err := h.instanceService.Update(id, updateReq); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance updated successfully", nil)
}
// DeleteInstance deletes an instance
func (h *InstanceHandler) DeleteInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can delete)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
if err := h.instanceService.Delete(id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusAccepted, "Instance deletion started", nil)
}
// StartInstance starts an instance
func (h *InstanceHandler) StartInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can start)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
if err := h.instanceService.Start(id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance started successfully", nil)
}
// StopInstance stops an instance
func (h *InstanceHandler) StopInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can stop)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
if err := h.instanceService.Stop(id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance stopped successfully", nil)
}
// RestartInstance restarts an instance
func (h *InstanceHandler) RestartInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can restart)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
if err := h.instanceService.Restart(id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance restarted successfully", nil)
}
// GetInstanceStatus gets the detailed status of an instance
func (h *InstanceHandler) GetInstanceStatus(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can view status)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
status, err := h.instanceService.GetInstanceStatus(id)
if err != nil {
utils.HandleError(c, err)
return
}
if userRole != "admin" {
utils.Success(c, http.StatusOK, "Instance status retrieved successfully", gin.H{
"instance_status": buildUserSafeInstanceStatus(instance, status),
})
return
}
runtime, _ := h.runtimeStatusService.GetByInstanceID(id)
agent, _ := h.instanceAgentService.GetPayloadByInstanceID(id)
utils.Success(c, http.StatusOK, "Instance status retrieved successfully", gin.H{
"instance_status": status,
"runtime": runtime,
"agent": agent,
})
}
func buildUserSafeInstanceStatus(instance *models.Instance, status *services.InstanceStatus) map[string]interface{} {
if status == nil {
return map[string]interface{}{}
}
payload := map[string]interface{}{
"instance_id": status.InstanceID,
"status": status.Status,
"availability": status.Availability,
"created_at": status.CreatedAt,
}
if payload["availability"] == "" {
payload["availability"] = availabilityForInstanceStatus(status.Status)
}
if status.StartedAt != nil {
payload["started_at"] = status.StartedAt
}
if instance == nil {
return payload
}
if agentType, ok := services.NormalizeV2RuntimeType(instance.Type); ok && (strings.EqualFold(strings.TrimSpace(instance.RuntimeType), "gateway") || strings.EqualFold(strings.TrimSpace(instance.InstanceMode), "lite")) {
payload["agent_type"] = agentType
payload["workspace_usage_bytes"] = instance.WorkspaceUsageBytes
}
return payload
}
func availabilityForInstanceStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "running":
return "available"
case "creating":
return "starting"
default:
return "unavailable"
}
}
func (h *InstanceHandler) GetRuntimeDetails(c *gin.Context) {
id, _, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
runtime, err := h.runtimeStatusService.GetByInstanceID(id)
if err != nil {
utils.HandleError(c, err)
return
}
agent, err := h.instanceAgentService.GetPayloadByInstanceID(id)
if err != nil {
utils.HandleError(c, err)
return
}
commands, err := h.instanceCommandService.ListByInstanceID(id, 20)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance runtime details retrieved successfully", InstanceRuntimeDetailsResponse{
Runtime: runtime,
Agent: agent,
Commands: commands,
})
}
func (h *InstanceHandler) CreateRuntimeCommand(c *gin.Context) {
id, _, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
commandKey := strings.TrimSpace(c.Param("command"))
commandType := ""
switch commandKey {
case "start":
commandType = services.InstanceCommandTypeStartOpenClaw
case "stop":
commandType = services.InstanceCommandTypeStopOpenClaw
case "restart":
commandType = services.InstanceCommandTypeRestartOpenClaw
case "collect-system-info":
commandType = services.InstanceCommandTypeCollectSystemInfo
case "health-check":
commandType = services.InstanceCommandTypeHealthCheck
default:
utils.Error(c, http.StatusBadRequest, "Invalid runtime command")
return
}
var req CreateRuntimeCommandRequest
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
issuedBy := userID.(int)
command, err := h.instanceCommandService.Create(id, &issuedBy, services.CreateInstanceCommandRequest{
CommandType: commandType,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Instance runtime command created successfully", command)
}
func (h *InstanceHandler) ListConfigRevisions(c *gin.Context) {
id, _, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
items, err := h.instanceConfigRevisionService.ListByInstanceID(id, limit)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance config revisions retrieved successfully", items)
}
func (h *InstanceHandler) PublishConfigRevision(c *gin.Context) {
id, instance, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
if !strings.EqualFold(instance.Type, "openclaw") && !strings.EqualFold(instance.Type, "hermes") {
utils.Error(c, http.StatusBadRequest, "Only managed runtime instances support config revisions")
return
}
var req PublishConfigRevisionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
snapshot, err := h.openClawConfigService.GetSnapshot(userID.(int), req.SnapshotID)
if err != nil {
utils.HandleError(c, err)
return
}
if snapshot.InstanceID != nil && *snapshot.InstanceID != id {
utils.Error(c, http.StatusBadRequest, "Snapshot does not belong to this instance")
return
}
modelSnapshot := &models.OpenClawInjectionSnapshot{
ID: snapshot.ID,
InstanceID: snapshot.InstanceID,
UserID: snapshot.UserID,
BundleID: snapshot.BundleID,
Mode: snapshot.Mode,
RenderedManifestJSON: string(snapshot.Manifest),
}
issuedBy := userID.(int)
revision, err := h.instanceConfigRevisionService.CreateFromSnapshot(id, modelSnapshot, &issuedBy)
if err != nil {
utils.HandleError(c, err)
return
}
command, err := h.instanceCommandService.Create(id, &issuedBy, services.CreateInstanceCommandRequest{
CommandType: services.InstanceCommandTypeApplyConfigRevision,
IdempotencyKey: fmt.Sprintf("apply-config-revision-%d", revision.ID),
Payload: map[string]interface{}{
"revision_id": revision.ID,
"snapshot_id": snapshot.ID,
},
TimeoutSeconds: 300,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Instance config revision published successfully", gin.H{
"revision": revision,
"command": command,
})
}
func (h *InstanceHandler) resolveOwnedInstance(c *gin.Context) (int, *models.Instance, bool) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return 0, nil, false
}
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return 0, nil, false
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return 0, nil, false
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return 0, nil, false
}
return id, instance, true
}
// GenerateAccessToken generates an access token for an instance
func (h *InstanceHandler) GenerateAccessToken(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership (only admin or owner can generate access token)
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
// Check if instance is running
if instance.Status != "running" {
utils.Error(c, http.StatusBadRequest, "Instance is not running")
return
}
if strings.EqualFold(strings.TrimSpace(instance.RuntimeType), "shell") {
utils.Error(c, http.StatusBadRequest, "Desktop access is not available for shell runtime instances")
return
}
// Generate proxy entry URL. The actual Service remains internal-only.
accessURL := h.proxyService.GetProxyURLForInstance(instance, "")
if accessURL == "" {
utils.Error(c, http.StatusServiceUnavailable, "Unable to generate access URL")
return
}
targetPort := h.proxyService.GetTargetPortForInstance(instance)
// When direct desktop proxying is enabled, embed the instance Service
// "host:port" into the token so the edge gateway can dial the instance
// directly. On any failure we fall back to an empty upstream, which keeps
// the request flowing through the in-process control-plane proxy.
upstream, directProxyEnabled := h.desktopAccessUpstream(c, instance, targetPort)
// Generate access token (valid for 1 hour)
maxAgeSeconds := int(time.Hour.Seconds())
token, err := h.accessService.GenerateToken(
userID.(int),
instance.ID,
instance.Type,
accessURL,
upstream,
targetPort,
1*time.Hour,
)
if err != nil {
utils.HandleError(c, err)
return
}
// Store the short-lived access token in an HttpOnly cookie so iframe subresources
// and websocket requests can reuse it without leaking the token in URLs.
c.SetCookie(
fmt.Sprintf("instance_access_%d", instance.ID),
token.Token,
maxAgeSeconds,
fmt.Sprintf("/api/v1/instances/%d/proxy", instance.ID),
"",
false,
true,
)
// Return token and URLs
response := map[string]interface{}{
"token": token.Token,
"access_url": accessURL,
"proxy_url": h.proxyService.GetProxyURLForInstance(instance, token.Token),
"expires_at": token.ExpiresAt,
"desktop_proxy_mode": desktopProxyMode(directProxyEnabled, upstream),
"desktop_upstream_present": upstream != "",
}
utils.Success(c, http.StatusOK, "Access token generated successfully", response)
}
// AccessInstance handles instance access via token
func (h *InstanceHandler) AccessInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Validate access token
token := c.Query("token")
if token == "" {
utils.Error(c, http.StatusBadRequest, "Access token required")
return
}
accessToken, err := h.accessService.ValidateToken(token)
if err != nil {
utils.Error(c, http.StatusUnauthorized, err.Error())
return
}
// Verify instance ID matches
if accessToken.InstanceID != id {
utils.Error(c, http.StatusForbidden, "Invalid access token for this instance")
return
}
// Redirect to actual access URL
c.Redirect(http.StatusTemporaryRedirect, accessToken.AccessURL)
}
func (h *InstanceHandler) StreamShell(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if !strings.EqualFold(strings.TrimSpace(instance.RuntimeType), "shell") {
utils.Error(c, http.StatusBadRequest, "Shell access is only available for shell runtime instances")
return
}
if instance.Status != "running" {
utils.Error(c, http.StatusBadRequest, "Instance is not running")
return
}
if err := h.shellService.Stream(c.Request.Context(), instance.UserID, instance.ID, c.Writer, c.Request); err != nil {
if !c.Writer.Written() {
utils.HandleError(c, err)
return
}
fmt.Printf("Shell stream for instance %d closed with error: %v\n", instance.ID, err)
}
}
// ForceSync manually triggers a status sync
func (h *InstanceHandler) ForceSync(c *gin.Context) {
// Get instance ID from URL
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
// Get instance first to check ownership
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return
}
// Check ownership
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
// Force sync
if err := h.instanceService.ForceSyncInstance(id); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance status synced", nil)
}
// ProxyInstance proxies requests to an instance
func (h *InstanceHandler) ProxyInstance(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return
}
token, ok := h.proxyAccessToken(c, id)
if !ok {
return
}
h.proxyInstanceWithToken(c, id, token)
}
func (h *InstanceHandler) proxyAccessToken(c *gin.Context, id int) (string, bool) {
cookieName := fmt.Sprintf("instance_access_%d", id)
if cookieToken, err := c.Cookie(cookieName); err == nil && strings.TrimSpace(cookieToken) != "" {
if accessToken, validateErr := h.accessService.ValidateToken(cookieToken); validateErr == nil && accessToken.InstanceID == id {
return cookieToken, true
}
}
queryToken := strings.TrimSpace(c.Query("token"))
if queryToken == "" {
utils.Error(c, http.StatusBadRequest, "Access token required")
return "", false
}
accessToken, err := h.accessService.ValidateToken(queryToken)
if err != nil || accessToken.InstanceID != id {
utils.Error(c, http.StatusUnauthorized, "Access token expired or invalid")
return "", false
}
// Promote only a validated ClawManager access token. Runtime applications may
// also use a token query parameter for their own websocket/session protocol.
c.SetCookie(
cookieName,
queryToken,
int(time.Hour.Seconds()),
fmt.Sprintf("/api/v1/instances/%d/proxy", id),
"",
false,
true,
)
return queryToken, true
}
func (h *InstanceHandler) proxyInstanceWithToken(c *gin.Context, id int, token string) {
// Check if it's a WebSocket upgrade request
if strings.EqualFold(c.GetHeader("Upgrade"), "websocket") {
if err := h.proxyService.ProxyWebSocket(c.Request.Context(), id, token, c.Writer, c.Request); err != nil {
if errors.Is(err, services.ErrInstanceGatewayUnavailable) {
http.Error(c.Writer, "Instance gateway is not available", http.StatusServiceUnavailable)
} else {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
}
}
return
}
// Proxy regular HTTP request
if err := h.proxyService.ProxyRequest(c.Request.Context(), id, token, c.Writer, c.Request); err != nil {
// Log the error
fmt.Printf("Proxy error for instance %d: %v\n", id, err)
// Return appropriate error response
if err.Error() == "invalid token: token expired" ||
err.Error() == "invalid token: invalid token" {
http.Error(c.Writer, "Access token expired or invalid", http.StatusUnauthorized)
} else if err.Error() == "token does not match instance" {
http.Error(c.Writer, "Token does not match instance", http.StatusForbidden)
} else if errors.Is(err, services.ErrInstanceGatewayUnavailable) {
http.Error(c.Writer, "Instance gateway is not available", http.StatusServiceUnavailable)
} else {
http.Error(c.Writer, fmt.Sprintf("Failed to proxy request: %v", err), http.StatusBadGateway)
}
}
}
func (h *InstanceHandler) ExportOpenClaw(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if instance.Type != "openclaw" {
utils.Error(c, http.StatusBadRequest, "openclaw import/export is only available for openclaw instances")
return
}
if instance.Status != "running" {
utils.Error(c, http.StatusBadRequest, "instance must be running to export .openclaw")
return
}
archive, err := h.openClawTransferService.Export(c.Request.Context(), instance.UserID, instance.ID)
if err != nil {
if errors.Is(err, services.ErrOpenClawWorkspaceMissing) {
utils.Error(c, http.StatusNotFound, "openclaw workspace is empty or missing")
return
}
utils.HandleError(c, err)
return
}
if len(archive) < openclawMinArchiveBytes {
utils.Error(c, http.StatusInternalServerError, "export produced an empty archive")
return
}
maxArchiveBytes := workspaceArchiveMaxBytes()
if int64(len(archive)) > maxArchiveBytes {
utils.Error(c, http.StatusRequestEntityTooLarge,
fmt.Sprintf("archive too large; maximum archive size is %d MiB", maxArchiveBytes>>20))
return
}
filename := fmt.Sprintf("%s.openclaw.tar.gz", sanitizeDownloadName(instance.Name))
c.Header("Content-Type", "application/gzip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Header("Content-Length", strconv.Itoa(len(archive)))
c.Data(http.StatusOK, "application/gzip", archive)
}
func (h *InstanceHandler) ExportHermes(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if instance.Type != "hermes" {
utils.Error(c, http.StatusBadRequest, "hermes import/export is only available for hermes instances")
return
}
if instance.Status != "running" {
utils.Error(c, http.StatusBadRequest, "instance must be running to export .hermes")
return
}
archive, err := h.openClawTransferService.ExportHermes(c.Request.Context(), instance.UserID, instance.ID)
if err != nil {
if errors.Is(err, services.ErrHermesWorkspaceMissing) {
utils.Error(c, http.StatusNotFound, "hermes workspace is empty or missing")
return
}
utils.HandleError(c, err)
return
}
if len(archive) < openclawMinArchiveBytes {
utils.Error(c, http.StatusInternalServerError, "export produced an empty archive")
return
}
maxArchiveBytes := workspaceArchiveMaxBytes()
if int64(len(archive)) > maxArchiveBytes {
utils.Error(c, http.StatusRequestEntityTooLarge,
fmt.Sprintf("archive too large; maximum archive size is %d MiB", maxArchiveBytes>>20))
return
}
filename := fmt.Sprintf("%s.hermes.tar.gz", sanitizeDownloadName(instance.Name, "hermes-workspace"))
c.Header("Content-Type", "application/gzip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Header("Content-Length", strconv.Itoa(len(archive)))
c.Data(http.StatusOK, "application/gzip", archive)
}
func (h *InstanceHandler) ImportOpenClaw(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if instance.Type != "openclaw" {
utils.Error(c, http.StatusBadRequest, "openclaw import/export is only available for openclaw instances")
return
}
if instance.Status != "running" {
utils.Error(c, http.StatusBadRequest, "instance must be running to import .openclaw")
return
}
// Cap the request body at the same deployment limit used by nginx. When
// the request reaches the backend, MaxBytesReader trips ParseMultipartForm
// (invoked by c.FormFile) with a typed *http.MaxBytesError.
maxArchiveBytes := workspaceArchiveMaxBytes()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxArchiveBytes)
fileHeader, err := c.FormFile("file")
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
utils.Error(c, http.StatusRequestEntityTooLarge,
fmt.Sprintf("archive too large; maximum upload size is %d MiB", maxArchiveBytes>>20))
return
}
utils.Error(c, http.StatusBadRequest, "file is required")
return
}
if fileHeader.Size > maxArchiveBytes {
utils.Error(c, http.StatusRequestEntityTooLarge,
fmt.Sprintf("archive too large; maximum upload size is %d MiB", maxArchiveBytes>>20))
return
}
file, err := fileHeader.Open()
if err != nil {
utils.HandleError(c, err)
return
}
defer file.Close()
if err := h.openClawTransferService.Import(c.Request.Context(), instance.UserID, instance.ID, io.LimitReader(file, maxArchiveBytes)); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "OpenClaw workspace imported successfully", nil)
}
func (h *InstanceHandler) ImportHermes(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if instance.Type != "hermes" {
utils.Error(c, http.StatusBadRequest, "hermes import/export is only available for hermes instances")
return
}
if instance.Status != "running" {
utils.Error(c, http.StatusBadRequest, "instance must be running to import .hermes")
return
}
maxArchiveBytes := workspaceArchiveMaxBytes()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxArchiveBytes)
fileHeader, err := c.FormFile("file")
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
utils.Error(c, http.StatusRequestEntityTooLarge,
fmt.Sprintf("archive too large; maximum upload size is %d MiB", maxArchiveBytes>>20))
return
}
utils.Error(c, http.StatusBadRequest, "file is required")
return
}
if fileHeader.Size > maxArchiveBytes {
utils.Error(c, http.StatusRequestEntityTooLarge,
fmt.Sprintf("archive too large; maximum upload size is %d MiB", maxArchiveBytes>>20))
return
}
file, err := fileHeader.Open()
if err != nil {
utils.HandleError(c, err)
return
}
defer file.Close()
if err := h.openClawTransferService.ImportHermes(c.Request.Context(), instance.UserID, instance.ID, io.LimitReader(file, maxArchiveBytes)); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Hermes workspace imported successfully", nil)
}
func (h *InstanceHandler) requireOwnedInstance(c *gin.Context) (*models.Instance, bool) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return nil, false
}
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return nil, false
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return nil, false
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return nil, false
}
return instance, true
}
func (h *InstanceHandler) GetExternalAccess(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if h.externalAccessService == nil {
utils.Error(c, http.StatusServiceUnavailable, "External access is not configured")
return
}
access, err := h.externalAccessService.Get(c.Request.Context(), instance.ID)
if err != nil {
utils.HandleError(c, err)
return
}
payload := gin.H{"external_access": access}
if shareURL := services.ExternalAccessShareURL(access); shareURL != "" {
payload["share_url"] = shareURL
}
if password := services.ExternalAccessPassword(access); password != "" {
payload["password"] = password
}
utils.Success(c, http.StatusOK, "External access retrieved successfully", payload)
}
func (h *InstanceHandler) EnableShareLink(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if h.externalAccessService == nil {
utils.Error(c, http.StatusServiceUnavailable, "External access is not configured")
return
}
var req ExternalAccessRequest
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
result, err := h.externalAccessService.EnableShareLink(c.Request.Context(), instance.ID, userID.(int), externalAccessExpirationRequest(req))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Share link enabled successfully", result)
}
func (h *InstanceHandler) CreateExternalAccessPassword(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if h.externalAccessService == nil {
utils.Error(c, http.StatusServiceUnavailable, "External access is not configured")
return
}
var req ExternalAccessRequest
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
result, err := h.externalAccessService.CreatePassword(c.Request.Context(), instance.ID, userID.(int), externalAccessExpirationRequest(req))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Share link password created successfully", result)
}
func (h *InstanceHandler) DisableExternalAccess(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
if h.externalAccessService == nil {
utils.Error(c, http.StatusServiceUnavailable, "External access is not configured")
return
}
if err := h.externalAccessService.Disable(c.Request.Context(), instance.ID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "External access disabled successfully", nil)
}
func (h *InstanceHandler) OpenShortExternalAccess(c *gin.Context) {
if h.externalAccessService == nil {
utils.Error(c, http.StatusServiceUnavailable, "External access is not configured")
return
}
code := strings.TrimSpace(c.Param("code"))
access, err := h.externalAccessService.ResolveShortLink(c.Request.Context(), code)
if err != nil {
utils.Error(c, http.StatusUnauthorized, err.Error())
return
}
var instance *models.Instance
var token string
canonicalAccessURL := ""
switch access.AuthMode {
case services.ExternalAccessModeShareLink:
if _, err := h.externalAccessService.ValidateShortLink(c.Request.Context(), code, ""); err != nil {
utils.Error(c, http.StatusUnauthorized, err.Error())
return
}
case services.ExternalAccessModePassword:
token = h.validShortLinkAccessToken(c, code, access.InstanceID)
if token == "" {
password := externalPassword(c)
isPasswordFormPost := c.Request.Method == http.MethodPost && password == ""
if isPasswordFormPost {
password = c.PostForm("password")
}
if strings.TrimSpace(password) == "" {
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead {
renderShortLinkPasswordForm(c, code, "")
return
}
utils.Error(c, http.StatusUnauthorized, "share link password is required")
return
}
if _, err := h.externalAccessService.ValidateShortLink(c.Request.Context(), code, password); err != nil {
if c.Request.Method == http.MethodPost || shortExternalAccessWantsHTML(c) {
renderShortLinkPasswordForm(c, code, "Invalid password")
return
}
utils.Error(c, http.StatusUnauthorized, err.Error())
return
}
var ok bool
instance, ok = h.requireExternalAccessInstance(c, access)
if !ok {
return
}
instanceToken, ok := h.issueShortExternalAccessToken(c, instance, code)
if !ok {
return
}
token = instanceToken.Token
canonicalAccessURL = instanceToken.AccessURL
if c.Request.Method == http.MethodPost {
c.Redirect(http.StatusSeeOther, shortExternalAccessEntryPath(code))
return
}
}
default:
utils.Error(c, http.StatusBadRequest, "Unsupported share link mode")
return
}
if instance == nil {
var ok bool
instance, ok = h.requireExternalAccessInstance(c, access)
if !ok {
return
}
}
if token == "" {
instanceToken, ok := h.issueShortExternalAccessToken(c, instance, code)
if !ok {
return
}
token = instanceToken.Token
canonicalAccessURL = instanceToken.AccessURL
} else {
setShortExternalAccessCookies(c, instance.ID, code, token, int(time.Hour.Seconds()))
canonicalAccessURL = h.proxyService.GetProxyURLForInstance(instance, "")
}
originalPath := c.Request.URL.Path
if redirectTarget := shortExternalAccessEntryRedirectTarget(c.Request.Method, originalPath, code, canonicalAccessURL); redirectTarget != "" {
c.Redirect(http.StatusSeeOther, redirectTarget)
return
}
originalRawPath := c.Request.URL.RawPath
originalAuthorization := c.Request.Header.Get("Authorization")
originalPassword := c.Request.Header.Get("X-Password")
c.Request.URL.Path = shortExternalAccessProxyPath(originalPath, code, instance.ID)
c.Request.URL.RawPath = ""
c.Request.Header.Del("Authorization")
c.Request.Header.Del("X-Password")
defer func() {
c.Request.URL.Path = originalPath
c.Request.URL.RawPath = originalRawPath
if originalAuthorization != "" {
c.Request.Header.Set("Authorization", originalAuthorization)
}
if originalPassword != "" {
c.Request.Header.Set("X-Password", originalPassword)
}
}()
h.proxyInstanceWithToken(c, instance.ID, token)
}
func bearerToken(header string) string {
value := strings.TrimSpace(header)
if value == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
return strings.TrimSpace(value[len("bearer "):])
}
return value
}
func externalPassword(c *gin.Context) string {
if password := strings.TrimSpace(c.GetHeader("X-Password")); password != "" {
return password
}
return bearerToken(c.GetHeader("Authorization"))
}
func externalAccessExpirationRequest(req ExternalAccessRequest) services.ExternalAccessExpirationRequest {
return services.ExternalAccessExpirationRequest{
Mode: strings.TrimSpace(req.ExpiresMode),
Preset: strings.TrimSpace(req.ExpiresPreset),
ExpiresAt: req.ExpiresAt,
}
}
func (h *InstanceHandler) requireExternalAccessInstance(c *gin.Context, access *models.InstanceExternalAccess) (*models.Instance, bool) {
if access == nil {
utils.Error(c, http.StatusUnauthorized, "External access is not enabled")
return nil, false
}
instance, err := h.instanceService.GetByID(access.InstanceID)
if err != nil {
utils.HandleError(c, err)
return nil, false
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return nil, false
}
if instance.Status != "running" {
utils.Error(c, http.StatusServiceUnavailable, "Instance is not running")
return nil, false
}
if strings.EqualFold(strings.TrimSpace(instance.RuntimeType), "shell") {
utils.Error(c, http.StatusBadRequest, "External desktop access is not available for shell runtime instances")
return nil, false
}
return instance, true
}
func (h *InstanceHandler) issueShortExternalAccessToken(c *gin.Context, instance *models.Instance, code string) (*services.AccessToken, bool) {
accessURL := h.proxyService.GetProxyURLForInstance(instance, "")
if accessURL == "" {
utils.Error(c, http.StatusServiceUnavailable, "Unable to generate access URL")
return nil, false
}
targetPort := h.proxyService.GetTargetPortForInstance(instance)
upstream, _ := h.desktopAccessUpstream(c, instance, targetPort)
instanceToken, err := h.accessService.GenerateToken(
instance.UserID,
instance.ID,
instance.Type,
accessURL,
upstream,
targetPort,
1*time.Hour,
)
if err != nil {
utils.HandleError(c, err)
return nil, false
}
setShortExternalAccessCookies(c, instance.ID, code, instanceToken.Token, int(time.Hour.Seconds()))
return instanceToken, true
}
func (h *InstanceHandler) validShortLinkAccessToken(c *gin.Context, code string, instanceID int) string {
if h == nil || h.accessService == nil {
return ""
}
token, err := c.Cookie(shortExternalAccessCookieName(code))
if err != nil || strings.TrimSpace(token) == "" {
return ""
}
accessToken, err := h.accessService.ValidateToken(token)
if err != nil || accessToken.InstanceID != instanceID {
return ""
}
return token
}
func setShortExternalAccessCookies(c *gin.Context, instanceID int, code, token string, maxAge int) {
c.SetCookie(
fmt.Sprintf("instance_access_%d", instanceID),
token,
maxAge,
fmt.Sprintf("/api/v1/instances/%d/proxy", instanceID),
"",
false,
true,
)
c.SetCookie(
shortExternalAccessCookieName(code),
token,
maxAge,
shortExternalAccessCookiePath(code),
"",
false,
true,
)
}
func shortExternalAccessCookieName(code string) string {
code = strings.Trim(strings.TrimSpace(code), "/")
var builder strings.Builder
for _, r := range code {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-':
builder.WriteRune(r)
default:
builder.WriteByte('_')
}
}
if builder.Len() == 0 {
return "share_link_access"
}
return "share_link_access_" + builder.String()
}
func shortExternalAccessCookiePath(code string) string {
code = strings.Trim(strings.TrimSpace(code), "/")
if code == "" {
return "/s"
}
return "/s/" + code
}
func shortExternalAccessEntryPath(code string) string {
return shortExternalAccessCookiePath(code) + "/"
}
func shortExternalAccessEntryRedirectTarget(method, requestPath, code, canonicalPath string) string {
if method != http.MethodGet && method != http.MethodHead {
return ""
}
path := strings.TrimSpace(requestPath)
entryPath := shortExternalAccessEntryPath(code)
if path != entryPath && path != strings.TrimSuffix(entryPath, "/") {
return ""
}
target := strings.TrimSpace(canonicalPath)
if target == "" {
return ""
}
parsed, err := url.Parse(target)
if err != nil || parsed.Path == "" {
return ""
}
if !strings.HasPrefix(parsed.Path, "/api/v1/instances/") {
return ""
}
return parsed.Path
}
func shortExternalAccessWantsHTML(c *gin.Context) bool {
accept := strings.ToLower(c.GetHeader("Accept"))
return strings.Contains(accept, "text/html")
}
func renderShortLinkPasswordForm(c *gin.Context, code, errorMessage string) {
action := html.EscapeString(shortExternalAccessEntryPath(code))
errorHTML := ""
if strings.TrimSpace(errorMessage) != "" {
errorHTML = fmt.Sprintf(`<div class="error">%s</div>`, html.EscapeString(errorMessage))
}
body := fmt.Sprintf(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Share link password</title>
<style>
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f6f8fb; color: #0f172a; }
main { width: min(92vw, 380px); border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; box-shadow: 0 18px 45px rgba(15, 23, 42, .08); padding: 28px; }
h1 { margin: 0 0 18px; font-size: 20px; line-height: 1.25; }
label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 600; color: #334155; }
input { box-sizing: border-box; width: 100%%; height: 42px; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0 12px; font-size: 14px; outline: none; }
input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, .14); }
button { width: 100%%; height: 42px; margin-top: 14px; border: 0; border-radius: 6px; background: #4f46e5; color: white; font-size: 14px; font-weight: 700; cursor: pointer; }
.error { margin-bottom: 12px; border: 1px solid #fecaca; border-radius: 6px; background: #fef2f2; color: #b91c1c; padding: 10px 12px; font-size: 13px; }
</style>
</head>
<body>
<main>
<h1>Share link password</h1>
%s
<form method="post" action="%s" autocomplete="off">
<label for="share-link-password">Password</label>
<input id="share-link-password" name="password" type="password" autofocus required>
<button type="submit">Open</button>
</form>
</main>
</body>
</html>`, errorHTML, action)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(body))
}
func shortExternalAccessProxyPath(requestPath, code string, instanceID int) string {
internalPrefix := fmt.Sprintf("/api/v1/instances/%d/proxy", instanceID)
shortPrefix := fmt.Sprintf("/s/%s", strings.Trim(strings.TrimSpace(code), "/"))
path := strings.TrimSpace(requestPath)
if path == "" || path == shortPrefix || path == shortPrefix+"/" {
return internalPrefix + "/"
}
if strings.HasPrefix(path, shortPrefix+"/") {
return internalPrefix + strings.TrimPrefix(path, shortPrefix)
}
return internalPrefix + "/"
}
func sanitizeDownloadName(name string, fallback ...string) string {
name = strings.TrimSpace(name)
if name == "" {
if len(fallback) > 0 && strings.TrimSpace(fallback[0]) != "" {
return strings.TrimSpace(fallback[0])
}
return "openclaw-workspace"
}
replacer := strings.NewReplacer("\\", "-", "/", "-", ":", "-", "*", "-", "?", "-", "\"", "-", "<", "-", ">", "-", "|", "-")
name = replacer.Replace(name)
name = strings.ReplaceAll(name, " ", "-")
return name
}