feat(sandbox): enhance sandbox configuration with display name and identifier updates

- Introduced a human-readable display name for the sandbox, constructed from the agent and workspace names.
- Updated the BuildIdentifier function to include workspace ID in identifiers for long-running and persistent lifecycles.
- Modified sandbox creation options to incorporate the display name, improving clarity in sandbox management.
- Refactored related tests to ensure compatibility with the new identifier structure and display name integration.

Made-with: Cursor
This commit is contained in:
Max 2026-03-14 18:32:09 +08:00
parent c4dbcdba2b
commit d26bbd1e1f
15 changed files with 231 additions and 55 deletions

View file

@ -48,7 +48,10 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
return nil, nil, nil, "", fmt.Errorf("get connector: %w", err)
}
// 2. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
// 2. Build human-readable DisplayName from real Agent name + Workspace name.
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name)
// 3. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
@ -56,7 +59,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
}
_ = identifier
// 3. Get Runner.
// 4. Get Runner.
runner, err := sandboxv2.Get(cfg.Runner.Name)
if err != nil {
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
@ -64,7 +67,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
return nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
}
// 4. Resolve skills directory.
// 5. Resolve skills directory.
skillsDir := ""
if ast.Path != "" {
dir := filepath.Join(config.Conf.AppSource, ast.Path, "skills")
@ -73,7 +76,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
}
}
// 5. Convert MCP servers.
// 6. Convert MCP servers.
var mcpServers []sandboxTypes.MCPServer
if ast.MCP != nil {
for _, s := range ast.MCP.Servers {
@ -85,7 +88,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
}
}
// 6. Runner.Prepare (standard context).
// 7. Runner.Prepare (standard context).
err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{
Computer: computer,
Config: cfg,
@ -199,6 +202,34 @@ func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {
ctx.SetWorkspace(wsFS)
}
// buildBoxDisplayName constructs a human-readable display name for a Box
// using the locale-resolved Agent name and Workspace name (matching the UI list pages).
func buildBoxDisplayName(ctx *context.Context, assistantID, rawName string) string {
agentName := i18n.Tr(assistantID, ctx.Locale, rawName)
wsName := ""
if ctx.Metadata != nil {
if wsID, ok := ctx.Metadata["workspace_id"].(string); ok && wsID != "" {
if wsm := workspace.M(); wsm != nil {
if ws, err := wsm.Get(ctx.Context, wsID); err == nil && ws != nil {
wsName = ws.Name
}
}
}
}
if agentName != "" && wsName != "" {
return agentName + " / " + wsName
}
if agentName != "" {
return agentName
}
if wsName != "" {
return wsName
}
return ""
}
func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
if loadingMsgID == "" || ctx == nil {
return

View file

@ -17,7 +17,7 @@ import (
// BuildIdentifier determines the Computer identifier based on lifecycle policy
// and optional metadata override. Returns "" for oneshot (always new).
func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID string, metadata map[string]any) string {
func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, workspaceID string, metadata map[string]any) string {
if cfg.Lifecycle == "oneshot" {
return ""
}
@ -25,7 +25,7 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
// Custom identifier from metadata takes precedence.
if metadata != nil {
if cid, ok := metadata["computer_id"].(string); ok && cid != "" {
return fmt.Sprintf("%s-%s", ownerID, cid)
return fmt.Sprintf("%s-%s.%s", ownerID, cid, workspaceID)
}
}
@ -33,7 +33,7 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
case "session":
return fmt.Sprintf("%s-%s", ownerID, chatID)
case "longrunning", "persistent":
return fmt.Sprintf("%s-%s", ownerID, assistantID)
return fmt.Sprintf("%s-%s.%s", ownerID, assistantID, workspaceID)
default:
return ""
}
@ -44,11 +44,6 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
// Returns the Computer, the resolved identifier, and any error.
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager, conn ...connector.Connector) (infra.Computer, string, error) {
ownerID := resolveOwnerID(ctx)
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, ctx.Metadata)
// Fill runtime fields.
cfg.Owner = ownerID
cfg.ID = identifier
workspaceID := ""
if ctx.Metadata != nil {
@ -59,6 +54,12 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
if workspaceID == "" {
workspaceID = ownerID
}
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, workspaceID, ctx.Metadata)
// Fill runtime fields.
cfg.Owner = ownerID
cfg.ID = identifier
cfg.WorkspaceID = workspaceID
// Resolve computer_id from metadata to determine kind and nodeID.

View file

@ -20,7 +20,7 @@ import (
func TestBuildIdentifier_Oneshot(t *testing.T) {
cfg := &types.SandboxConfig{Lifecycle: "oneshot"}
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil)
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", nil)
if id != "" {
t.Errorf("oneshot should return empty, got %q", id)
}
@ -28,7 +28,7 @@ func TestBuildIdentifier_Oneshot(t *testing.T) {
func TestBuildIdentifier_Session(t *testing.T) {
cfg := &types.SandboxConfig{Lifecycle: "session"}
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", nil)
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", nil)
if id != "owner1-chat42" {
t.Errorf("session: got %q, want %q", id, "owner1-chat42")
}
@ -36,33 +36,33 @@ func TestBuildIdentifier_Session(t *testing.T) {
func TestBuildIdentifier_Longrunning(t *testing.T) {
cfg := &types.SandboxConfig{Lifecycle: "longrunning"}
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil)
if id != "owner1-ast99" {
t.Errorf("longrunning: got %q, want %q", id, "owner1-ast99")
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", "ws1", nil)
if id != "owner1-ast99.ws1" {
t.Errorf("longrunning: got %q, want %q", id, "owner1-ast99.ws1")
}
}
func TestBuildIdentifier_Persistent(t *testing.T) {
cfg := &types.SandboxConfig{Lifecycle: "persistent"}
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil)
if id != "owner1-ast99" {
t.Errorf("persistent: got %q, want %q", id, "owner1-ast99")
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", "ws1", nil)
if id != "owner1-ast99.ws1" {
t.Errorf("persistent: got %q, want %q", id, "owner1-ast99.ws1")
}
}
func TestBuildIdentifier_MetadataOverride(t *testing.T) {
cfg := &types.SandboxConfig{Lifecycle: "session"}
meta := map[string]any{"computer_id": "custom-box"}
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", meta)
if id != "owner1-custom-box" {
t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box")
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", meta)
if id != "owner1-custom-box.ws1" {
t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box.ws1")
}
}
func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) {
cfg := &types.SandboxConfig{Lifecycle: "session"}
meta := map[string]any{"computer_id": ""}
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", meta)
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", meta)
if id != "owner1-chat42" {
t.Errorf("empty metadata should fall through to session, got %q", id)
}
@ -70,7 +70,7 @@ func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) {
func TestBuildIdentifier_UnknownLifecycle(t *testing.T) {
cfg := &types.SandboxConfig{Lifecycle: "unknown"}
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil)
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", nil)
if id != "" {
t.Errorf("unknown lifecycle should return empty, got %q", id)
}

View file

@ -34,6 +34,7 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
MountMode: cfg.Computer.MountMode,
WorkspaceID: workspaceID,
Labels: cfg.Labels,
DisplayName: cfg.DisplayName,
}
if opts.Labels == nil {

View file

@ -32,6 +32,7 @@ type SandboxConfig struct {
NodeID string `json:"-" yaml:"-"`
Kind string `json:"-" yaml:"-"`
WorkspaceID string `json:"-" yaml:"-"`
DisplayName string `json:"-" yaml:"-"`
}
// ComputerFilter defines the query parameters for GET /computer/options.

View file

@ -45,7 +45,6 @@ type computerOption struct {
Image string `json:"image,omitempty"`
Policy string `json:"policy,omitempty"`
VNC bool `json:"vnc"`
Labels map[string]string `json:"labels,omitempty"`
System computerSystemInfo `json:"system"`
}
@ -255,7 +254,10 @@ func boxToOption(b *sandboxv2.Box) computerOption {
snap := b.Snapshot()
info := b.ComputerInfo()
displayName := info.System.Hostname
displayName := info.DisplayName
if displayName == "" {
displayName = info.System.Hostname
}
if displayName == "" {
displayName = snap.ID
}
@ -285,7 +287,6 @@ func boxToOption(b *sandboxv2.Box) computerOption {
Image: snap.Image,
Policy: string(snap.Policy),
VNC: snap.VNC,
Labels: snap.Labels,
System: computerSystemInfo{
OS: info.System.OS,
Arch: info.System.Arch,

View file

@ -94,7 +94,6 @@ type sandboxResponse struct {
Owner string `json:"owner"`
Status string `json:"status"`
Policy string `json:"policy,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Image string `json:"image,omitempty"`
Mode string `json:"mode,omitempty"`
Addr string `json:"addr,omitempty"`
@ -110,7 +109,10 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
snap := b.Snapshot()
info := b.ComputerInfo()
displayName := info.System.Hostname
displayName := info.DisplayName
if displayName == "" {
displayName = info.System.Hostname
}
if displayName == "" {
displayName = snap.ID
}
@ -137,7 +139,6 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
Owner: snap.Owner,
Status: snap.Status,
Policy: string(snap.Policy),
Labels: snap.Labels,
Image: snap.Image,
Mode: mode,
Addr: addr,

View file

@ -30,6 +30,7 @@ type Box struct {
image string
workspaceID string
system SystemInfo
displayName string
workDir string
ws taiworkspace.FS
manager *Manager
@ -56,6 +57,7 @@ func (b *Box) ComputerInfo() ComputerInfo {
Image: b.image,
Policy: b.policy,
Labels: b.labels,
DisplayName: b.displayName,
}
}

View file

@ -3,7 +3,10 @@ package sandbox
import (
"context"
"fmt"
"log"
"path/filepath"
goruntime "runtime"
"strconv"
"sync"
"time"
@ -172,7 +175,9 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
return nil, fmt.Errorf("sandbox: node %q has no container runtime", nodeID)
}
taiOpts := m.buildTaiCreateOptions(opts, nodeID, id)
sys := inferSystemInfo(ctx, res, opts.Image)
taiOpts := m.buildTaiCreateOptions(opts, nodeID, id, sys)
containerID, err := res.Runtime.Create(ctx, taiOpts)
if err != nil {
@ -189,16 +194,6 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
policy = Session
}
sys := SystemInfo{
OS: res.System.OS,
Arch: res.System.Arch,
Hostname: res.System.Hostname,
NumCPU: res.System.NumCPU,
TotalMem: res.System.TotalMem,
Shell: res.System.Shell,
TempDir: res.System.TempDir,
}
boxWorkDir := opts.WorkDir
if boxWorkDir == "" {
boxWorkDir = "/workspace"
@ -220,6 +215,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
image: opts.Image,
workspaceID: opts.WorkspaceID,
workDir: boxWorkDir,
displayName: opts.DisplayName,
system: sys,
}
box.lastCall.Store(time.Now().UnixMilli())
@ -348,7 +344,7 @@ func (m *Manager) getNode(name string) (*tai.ConnResources, error) {
return res, nil
}
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID string) tairuntime.CreateOptions {
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID string, sys SystemInfo) tairuntime.CreateOptions {
env := make(map[string]string)
reg := registry.Global()
@ -378,6 +374,27 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
if opts.WorkspaceID != "" {
labels["workspace-id"] = opts.WorkspaceID
}
if opts.DisplayName != "" {
labels["sandbox-display-name"] = opts.DisplayName
}
if sys.OS != "" {
labels["sandbox-sys-os"] = sys.OS
}
if sys.Arch != "" {
labels["sandbox-sys-arch"] = sys.Arch
}
if sys.Hostname != "" {
labels["sandbox-sys-hostname"] = sys.Hostname
}
if sys.NumCPU > 0 {
labels["sandbox-sys-numcpu"] = strconv.Itoa(sys.NumCPU)
}
if sys.TotalMem > 0 {
labels["sandbox-sys-totalmem"] = strconv.FormatInt(sys.TotalMem, 10)
}
if sys.Shell != "" {
labels["sandbox-sys-shell"] = sys.Shell
}
for k, v := range opts.Labels {
labels[k] = v
}
@ -467,6 +484,10 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
}
}
}
sys := systemInfoFromLabels(c.Labels)
if sys.OS == "" {
sys = inferSystemInfo(ctx, res, c.Image)
}
box := &Box{
id: sandboxID,
containerID: cid,
@ -479,6 +500,8 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
workspaceID: c.Labels["workspace-id"],
vnc: hasVNC,
workDir: "/workspace",
displayName: c.Labels["sandbox-display-name"],
system: sys,
manager: m,
}
box.lastCall.Store(time.Now().UnixMilli())
@ -486,6 +509,51 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
}
}
// inferSystemInfo derives static SystemInfo for a container from image metadata
// and Tai host resources. OS/Arch/Shell come from the image; Hostname/NumCPU/TotalMem
// come from the Tai host.
func inferSystemInfo(ctx context.Context, res *tai.ConnResources, imageRef string) SystemInfo {
sys := SystemInfo{
Hostname: res.System.Hostname,
NumCPU: res.System.NumCPU,
TotalMem: res.System.TotalMem,
}
if res.Image != nil {
meta, err := res.Image.Inspect(ctx, imageRef)
if err != nil {
log.Printf("[sandbox/v2] image inspect %q: %v (using fallback)", imageRef, err)
}
if meta != nil {
sys.OS = meta.OS
sys.Arch = meta.Arch
sys.Shell = meta.Shell
return sys
}
}
sys.OS = "linux"
sys.Arch = goruntime.GOARCH
sys.Shell = "bash"
return sys
}
// systemInfoFromLabels restores SystemInfo from Docker container labels that
// were persisted at creation time, so recovery doesn't depend on the Tai node
// being connected.
func systemInfoFromLabels(labels map[string]string) SystemInfo {
numCPU, _ := strconv.Atoi(labels["sandbox-sys-numcpu"])
totalMem, _ := strconv.ParseInt(labels["sandbox-sys-totalmem"], 10, 64)
return SystemInfo{
OS: labels["sandbox-sys-os"],
Arch: labels["sandbox-sys-arch"],
Hostname: labels["sandbox-sys-hostname"],
NumCPU: numCPU,
TotalMem: totalMem,
Shell: labels["sandbox-sys-shell"],
}
}
// ImageExists reports whether the given image ref exists on the target node.
func (m *Manager) ImageExists(ctx context.Context, nodeID, ref string) (bool, error) {
res, err := m.getNode(nodeID)

View file

@ -44,6 +44,7 @@ type ComputerInfo struct {
Image string
Policy LifecyclePolicy
Labels map[string]string
DisplayName string
}
// SystemInfo describes the hardware and environment of a Tai node.
@ -104,6 +105,7 @@ type CreateOptions struct {
WorkspaceID string
MountMode string
MountPath string
DisplayName string
}
type ListOptions struct {

View file

@ -8,11 +8,20 @@ import (
// Image manages container images on a runtime node.
type Image interface {
Exists(ctx context.Context, ref string) (bool, error)
Inspect(ctx context.Context, ref string) (*ImageMeta, error)
Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error)
Remove(ctx context.Context, ref string, force bool) error
List(ctx context.Context) ([]ImageInfo, error)
}
// ImageMeta holds static metadata extracted from a container image.
type ImageMeta struct {
OS string // "linux", "windows"
Arch string // "amd64", "arm64"
Shell string // preferred shell: "bash", "sh", "cmd.exe", "pwsh"
WorkDir string // default working directory from Dockerfile WORKDIR
}
// PullOptions configures an image pull operation.
type PullOptions struct {
Auth *RegistryAuth // nil = anonymous / public

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/docker/docker/api/types/image"
@ -35,6 +36,44 @@ func (d *dockerImage) Exists(ctx context.Context, ref string) (bool, error) {
return true, nil
}
func (d *dockerImage) Inspect(ctx context.Context, ref string) (*ImageMeta, error) {
inspect, _, err := d.cli.ImageInspectWithRaw(ctx, ref)
if err != nil {
return nil, fmt.Errorf("image inspect %q: %w", ref, err)
}
meta := &ImageMeta{
OS: inspect.Os,
Arch: inspect.Architecture,
}
if inspect.Config != nil {
meta.WorkDir = inspect.Config.WorkingDir
if len(inspect.Config.Shell) > 0 {
meta.Shell = inspect.Config.Shell[0]
}
if meta.Shell == "" {
for _, e := range inspect.Config.Env {
if strings.HasPrefix(e, "SHELL=") {
meta.Shell = e[6:]
break
}
}
}
}
if meta.Shell == "" {
if strings.EqualFold(meta.OS, "windows") {
meta.Shell = "cmd.exe"
} else {
meta.Shell = "bash"
}
}
return meta, nil
}
func (d *dockerImage) Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) {
pullOpts := image.PullOptions{}
if opts.Auth != nil {

View file

@ -12,6 +12,10 @@ func (k *k8sImage) Exists(_ context.Context, _ string) (bool, error) {
return true, nil
}
func (k *k8sImage) Inspect(_ context.Context, _ string) (*ImageMeta, error) {
return nil, nil
}
func (k *k8sImage) Pull(_ context.Context, _ string, _ PullOptions) (<-chan PullProgress, error) {
return nil, nil
}

View file

@ -113,17 +113,26 @@ func resolveTargetPort(c *gin.Context, node *types.NodeMeta) int {
return 0
}
// rewriteRequest clones the request and strips everything up to and including
// /tai/:taiID from the path, handling any baseURL prefix (e.g. /v1/tai/abc/proxy/x → /proxy/x).
// rewriteRequest clones the request and strips the Yao-side route prefix,
// leaving only what the Tai-side handler expects.
//
// The Tai httpproxy expects /{containerID}:{port}/..., so the /proxy prefix
// is stripped. The Tai VNC router expects /vnc/{containerID}/ws, so the /vnc
// prefix is kept.
//
// /v1/tai/abc/proxy/cid:8080/foo → /cid:8080/foo
// /v1/tai/abc/vnc/cid/ws → /vnc/cid/ws
func rewriteRequest(orig *http.Request, taiID string) *http.Request {
r := orig.Clone(orig.Context())
marker := "/tai/" + taiID
if idx := strings.Index(r.URL.Path, marker); idx >= 0 {
r.URL.Path = r.URL.Path[idx+len(marker):]
if r.URL.Path == "" {
r.URL.Path = "/"
rest := r.URL.Path[idx+len(marker):]
rest = strings.TrimPrefix(rest, "/proxy")
if rest == "" {
rest = "/"
}
r.URL.Path = rest
}
r.RequestURI = r.URL.RequestURI()

View file

@ -84,8 +84,8 @@ func TestRewriteRequest(t *testing.T) {
"proxy_path",
"/tai/abc123/proxy/api/v1/data",
"abc123",
"/proxy/api/v1/data",
"/proxy/api/v1/data",
"/api/v1/data",
"/api/v1/data",
},
{
"vnc_path",
@ -98,8 +98,8 @@ func TestRewriteRequest(t *testing.T) {
"with_query",
"/tai/node-1/proxy/api?foo=bar",
"node-1",
"/proxy/api",
"/proxy/api?foo=bar",
"/api",
"/api?foo=bar",
},
{
"exact_prefix",
@ -112,8 +112,8 @@ func TestRewriteRequest(t *testing.T) {
"with_base_url",
"/v1/tai/node-1/proxy/api/v1/data",
"node-1",
"/proxy/api/v1/data",
"/proxy/api/v1/data",
"/api/v1/data",
"/api/v1/data",
},
{
"with_base_url_vnc",
@ -122,6 +122,13 @@ func TestRewriteRequest(t *testing.T) {
"/vnc/__host__/ws",
"/vnc/__host__/ws",
},
{
"proxy_container_port",
"/v1/tai/abc/proxy/cid123:8080/foo",
"abc",
"/cid123:8080/foo",
"/cid123:8080/foo",
},
{
"no_match",
"/other/path",