From 4d4b03be963a237e74dd602709612bbe0e344c1b Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Mar 2026 22:33:12 +0800 Subject: [PATCH] feat(sandbox/v2): unify Box and Host under Computer interface - Define Computer interface with Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, and Workplace methods in types.go - Implement Computer interface on Host (VNC and Proxy via Tai gRPC) - Add VNC/Proxy stubs to Box (delegates to Tai HTTP endpoints) - Update JSAPI bindings for unified computer.vnc() and computer.proxy() - Extend host_test.go with __host__ VNC and HTTP proxy test cases Made-with: Cursor --- sandbox/v2/DESIGN.md | 192 ++++++++++++++++++++++++++++---------- sandbox/v2/box.go | 33 +++++++ sandbox/v2/host.go | 152 ++++++++++++++++++++++-------- sandbox/v2/host_test.go | 132 ++++++++++++++++++-------- sandbox/v2/jsapi/box.go | 97 ++++++++++--------- sandbox/v2/jsapi/host.go | 83 +++++++++------- sandbox/v2/jsapi/jsapi.go | 4 +- sandbox/v2/types.go | 180 +++++++++++++++++++---------------- 8 files changed, 581 insertions(+), 292 deletions(-) diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index 0a4e909b..11f89e82 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -41,14 +41,17 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. │ ├── EnsureImage / ImageExists / PullImage │ │ └── guard rails (limits, TTL) + Box factory │ │ │ -│ Box (per-instance) │ +│ Computer (unified interface) │ │ ├── Exec(cmd) → ExecResult │ │ ├── Stream(cmd) → ExecStream (real-time I/O) │ -│ ├── Attach(port) → ServiceConn (WS/SSE) │ -│ ├── Workspace() → workspace.FS │ │ ├── VNC() → url │ -│ ├── Proxy(port) → url │ -│ └── Start / Stop / Remove / Info │ +│ ├── Proxy(port, path) → url │ +│ ├── ComputerInfo() → ComputerInfo │ +│ ├── BindWorkplace(id) / Workplace() → FS │ +│ └── [Box-specific: Attach/Start/Stop/Remove] │ +│ │ +│ Box (container) ── implements Computer │ +│ Host (bare metal) ── implements Computer │ └──────────────────┬──────────────────────────────┘ │ ▼ @@ -251,9 +254,70 @@ const ( const DefaultStopTimeout = 2 * time.Second ``` +## Computer Interface + +`Computer` is the unified interface for execution environments. Both `Box` (container) and `Host` (bare metal) implement it, allowing callers to work with any execution environment without knowing the underlying runtime. + +```go +type Computer interface { + ComputerInfo() ComputerInfo + Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) + Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) + VNC(ctx context.Context) (string, error) + Proxy(ctx context.Context, port int, path string) (string, error) + BindWorkplace(workspaceID string) + Workplace() workspace.FS +} +``` + +### ComputerInfo + +```go +type ComputerInfo struct { + Kind string // "box" | "host" + Pool string + TaiID string + MachineID string + Version string + System SystemInfo + Mode string // "direct" | "tunnel" + Capabilities map[string]bool + Status string + + // Box-specific (zero values for Host) + BoxID string + ContainerID string + Owner string + Image string + Policy LifecyclePolicy + Labels map[string]string +} + +type SystemInfo struct { + OS string + Arch string + Hostname string + NumCPU int + TotalMem int64 +} +``` + +### Workplace Binding + +Workspace is a Node-level resource, decoupled from the Computer. A Computer can bind to a workspace at session time: + +- `BindWorkplace(workspaceID)` — binds a workspace to this Computer (virtual record, rebind to change) +- `Workplace()` — returns the bound workspace FS, or nil if unbound +- Box: automatically bound via `CreateOptions.WorkspaceID`, can rebind with `BindWorkplace()` +- Host: explicitly bound in the session + +### VNC and Proxy on Host + +Host VNC and Proxy use the special `__host__` identifier to route to the Tai server's localhost instead of a container. The Tai server's VNC router and HTTP proxy both handle `__host__` by connecting to `127.0.0.1:{port}` directly, bypassing the container resolver. + ## Box -A `Box` is a single sandbox instance. All operations go through it. +A `Box` is a single sandbox instance backed by a container. It implements the `Computer` interface and adds container-specific methods (Attach, Start, Stop, Remove, Info). ```go type Box struct { @@ -303,7 +367,9 @@ func (b *Box) Remove(ctx context.Context) error func (b *Box) Info(ctx context.Context) (*BoxInfo, error) ``` -### ExecOption / ExecResult / ExecStream +### ExecOption / ExecResult / ExecStream (unified) + +These types are shared between Box and Host via the Computer interface. ```go type ExecOption func(*execConfig) @@ -311,11 +377,16 @@ type ExecOption func(*execConfig) func WithWorkDir(dir string) ExecOption func WithEnv(env map[string]string) ExecOption func WithTimeout(d time.Duration) ExecOption +func WithStdin(data []byte) ExecOption +func WithMaxOutput(bytes int64) ExecOption type ExecResult struct { - ExitCode int - Stdout string - Stderr string + ExitCode int + Stdout string + Stderr string + DurationMs int64 // Host fills; Box = 0 + Error string // Host fills; Box = "" + Truncated bool // Host fills; Box = false } type ExecStream struct { @@ -570,14 +641,16 @@ var ( sandbox/v2/ ├── sandbox.go // Init, M(), global singleton ├── manager.go // Manager: CRUD, pool management, image ops, cleanup -├── box.go // Box: Exec, Stream, Attach, Workspace, VNC, Proxy, lifecycle -├── types.go // CreateOptions, ExecResult, ExecStream, ServiceConn, BoxInfo, etc. +├── types.go // Computer interface, ComputerInfo, ExecResult, ExecStream, etc. +├── box.go // Box: implements Computer + Attach/Start/Stop/Remove/Info +├── host.go // Host: implements Computer (HostExec gRPC + __host__ VNC/Proxy) ├── config.go // Config struct ├── errors.go // sentinel errors ├── grpc.go // token creation/revocation, gRPC env var injection ├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace -│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete -│ └── box.go // Box JS object: Exec/Attach/VNC/Proxy/Workspace/Info/Start/Stop/Remove +│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete/Host +│ ├── box.go // Box JS object: Computer + Attach/Info/Start/Stop/Remove +│ └── host.go // Host JS object: Computer (unified with Box) ├── export_test.go // ResetForTest() for test isolation ├── testutils_test.go // shared test helpers (multi-pool setup) ├── sandbox_test.go // Init/M singleton tests @@ -586,6 +659,7 @@ sandbox/v2/ ├── box_test.go // Box Exec/Workspace/Info tests ├── box_attach_test.go // Attach WS/SSE/VNC tests ├── box_workspace_test.go // Workspace integration tests +├── host_test.go // Host Exec/Stream/VNC/Proxy/ComputerInfo tests ├── box_image_test.go // Image Pull API tests ├── bench_test.go // Performance benchmarks ├── grpc_test.go // Token/env building tests @@ -851,7 +925,7 @@ Static methods: | `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` | | `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)` → `Box.Info()` | `BoxInfo[]` | | `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` | -| `sandbox.Host(pool?)` | `Manager.Host(ctx, pool)` | `Host` | +| `sandbox.Host(pool?)` | `Manager.Host(ctx, pool)` | `Computer (Host)` | | `sandbox.GetNode(taiID)` | `registry.Global().Get(taiID)` | `NodeInfo \| null` | | `sandbox.Nodes()` | `registry.Global().List()` | `NodeInfo[]` | | `sandbox.NodesByTeam(teamID)` | `registry.Global().ListByTeam(teamID)` | `NodeInfo[]` | @@ -922,13 +996,23 @@ Read-only properties: Methods: +Computer interface methods: + +| JS | Go | Returns | +|----|-----|---------| +| `box.Exec(cmd, opts?)` | `Computer.Exec(ctx, cmd []string, ...ExecOption)` | `ExecResult` | +| `box.Stream(cmd, [opts,] cb)` | `Computer.Stream(ctx, cmd []string, ...ExecOption)` | callback(type, data) | +| `box.VNC()` | `Computer.VNC(ctx)` | `string` | +| `box.Proxy(port, path?)` | `Computer.Proxy(ctx, port, path)` | `string` | +| `box.ComputerInfo()` | `Computer.ComputerInfo()` | `ComputerInfo` | +| `box.BindWorkplace(id)` | `Computer.BindWorkplace(id)` | `void` | +| `box.Workplace()` | `Computer.Workplace()` | `WorkspaceFS \| null` | + +Box-specific methods: + | JS | Go | Returns | |----|-----|---------| -| `box.Exec(cmd, opts?)` | `Box.Exec(ctx, cmd, ...ExecOption)` | `ExecResult` | -| `box.Stream(cmd, [opts,] cb)` | `Box.Stream(ctx, cmd, ...ExecOption)` | callback(type, data) | | `box.Attach(port, opts?)` | `Proxy.URL(ctx, containerID, port, path)` | `string` (URL) | -| `box.VNC()` | `Box.VNC(ctx)` | `string` | -| `box.Proxy(port, path?)` | `Box.Proxy(ctx, port, path)` | `string` | | `box.Workspace()` | `Box.WorkspaceID()` → `NewFSObject` | `WorkspaceFS` | | `box.Info()` | `Box.Info(ctx)` | `BoxInfo` | | `box.Start()` | `Box.Start(ctx)` | `void` | @@ -940,14 +1024,19 @@ Methods: ``` cmd: string[] → cmd []string options: { - workdir: string, → WithWorkDir(dir) - env: object, → WithEnv(map[string]string) - timeout: number → WithTimeout(ms → time.Duration) + workdir: string, → WithWorkDir(dir) + env: object, → WithEnv(map[string]string) + stdin: string, → WithStdin([]byte) + timeout: number, → WithTimeout(ms → time.Duration) + max_output: number → WithMaxOutput(bytes int64) } returns: { - exit_code: number, ← ExecResult.ExitCode - stdout: string, ← ExecResult.Stdout - stderr: string ← ExecResult.Stderr + exit_code: number, ← ExecResult.ExitCode + stdout: string, ← ExecResult.Stdout + stderr: string, ← ExecResult.Stderr + duration_ms: number, ← ExecResult.DurationMs (Host fills; Box = 0) + error: string, ← ExecResult.Error (Host fills; Box = "") + truncated: boolean ← ExecResult.Truncated (Host fills; Box = false) } ``` @@ -978,9 +1067,9 @@ Go-side `ServiceConn` (with Read/Write/Events/Close) is available for Go callers `box.Info()` returns same structure as `BoxInfo[]` element above. -#### Host object +#### Host object (Computer) -Host executes commands on the Tai host machine (no container). Available only when the pool's Tai server exposes HostExec gRPC. JS object holds pool name; all methods delegate to `sandbox.M().Host(ctx, pool)`. +Host implements the unified Computer interface for Tai host machines. It executes commands via HostExec gRPC and accesses VNC/Proxy via the `__host__` identifier. Available only when the pool's Tai server exposes HostExec gRPC. JS object holds pool name; all methods delegate to `sandbox.M().Host(ctx, pool)`. Read-only properties: @@ -988,49 +1077,50 @@ Read-only properties: |----|----| | `host.pool` | `Host.Pool()` | -Methods: +Methods (same Computer interface as Box): | JS | Go | Returns | |----|-----|---------| -| `host.Exec(cmd, args, opts?)` | `Host.Exec(ctx, cmd, args, ...HostExecOption)` | `HostExecResult` | -| `host.Stream(cmd, args, [opts,] cb)` | `Host.Stream(ctx, cmd, args, ...HostExecOption)` | callback(type, data) | -| `host.Workspace(sessionID)` | `Host.Workspace(sessionID)` | `WorkspaceFS` | +| `host.Exec(cmd, opts?)` | `Computer.Exec(ctx, cmd []string, ...ExecOption)` | `ExecResult` | +| `host.Stream(cmd, [opts,] cb)` | `Computer.Stream(ctx, cmd []string, ...ExecOption)` | callback(type, data) | +| `host.VNC()` | `Computer.VNC(ctx)` | `string` (URL) | +| `host.Proxy(port, path?)` | `Computer.Proxy(ctx, port, path)` | `string` (URL) | +| `host.ComputerInfo()` | `Computer.ComputerInfo()` | `ComputerInfo` | +| `host.BindWorkplace(id)` | `Computer.BindWorkplace(id)` | `void` | +| `host.Workplace()` | `Computer.Workplace()` | `WorkspaceFS \| null` | -`host.Exec(cmd, args, options?)`: +`host.Exec(cmd, options?)`: ``` -cmd: string → cmd string -args: string[] → args []string +cmd: string[] → cmd []string (unified with Box) options: { - workdir: string, → WithHostWorkDir(dir) - env: object, → WithHostEnv(map[string]string) - stdin: string, → WithHostStdin([]byte) - timeout: number, → WithHostTimeout(ms int64) - max_output: number → WithHostMaxOutput(bytes int64) + workdir: string, → WithWorkDir(dir) + env: object, → WithEnv(map[string]string) + stdin: string, → WithStdin([]byte) + timeout: number, → WithTimeout(ms → time.Duration) + max_output: number → WithMaxOutput(bytes int64) } returns: { - exit_code: number, ← HostExecResult.ExitCode - stdout: string, ← HostExecResult.Stdout (UTF-8) - stderr: string, ← HostExecResult.Stderr (UTF-8) - duration_ms: number, ← HostExecResult.DurationMs - error: string, ← HostExecResult.Error - truncated: boolean ← HostExecResult.Truncated + exit_code: number, ← ExecResult.ExitCode + stdout: string, ← ExecResult.Stdout + stderr: string, ← ExecResult.Stderr + duration_ms: number, ← ExecResult.DurationMs + error: string, ← ExecResult.Error + truncated: boolean ← ExecResult.Truncated } ``` -`host.Stream(cmd, args, callback)` / `host.Stream(cmd, args, options, callback)`: +`host.Stream(cmd, callback)` / `host.Stream(cmd, options, callback)`: ``` Blocks until exit. Last arg must be a JS function. options: same as host.Exec (optional) callback: function(type, data) - type = "stdout" → data is string (chunk) ← HostExecStream.Stdout - type = "stderr" → data is string (chunk) ← HostExecStream.Stderr - type = "exit" → data is number (exit code) ← HostExecStream.Wait() + type = "stdout" → data is string (chunk) ← ExecStream.Stdout (io.ReadCloser) + type = "stderr" → data is string (chunk) ← ExecStream.Stderr (io.ReadCloser) + type = "exit" → data is number (exit code) ← ExecStream.Wait() ``` -`host.Workspace(sessionID)` returns the same WorkspaceFS interface as `box.Workspace()`; sessionID typically corresponds to a workspace ID on the Tai host. - #### NodeInfo object `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()` return NodeInfo objects mapped from `registry.NodeSnapshot`. Auth and YaoBase fields are excluded for security. diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index 5cb5bfc0..1f0c340a 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -33,11 +33,44 @@ type Box struct { manager *Manager } +// Compile-time check: *Box implements Computer. +var _ Computer = (*Box)(nil) + func (b *Box) ID() string { return b.id } func (b *Box) Owner() string { return b.owner } func (b *Box) ContainerID() string { return b.containerID } func (b *Box) Pool() string { return b.pool } +// ComputerInfo returns identity and registry information for this Box. +func (b *Box) ComputerInfo() ComputerInfo { + return ComputerInfo{ + Kind: "box", + Pool: b.pool, + Status: "online", + BoxID: b.id, + ContainerID: b.containerID, + Owner: b.owner, + Image: b.image, + Policy: b.policy, + Labels: b.labels, + } +} + +// BindWorkplace binds (or rebinds) a workspace to this Box. Subsequent calls +// to Workplace() return the FS for this workspace. Overrides the workspace +// set during Create. +func (b *Box) BindWorkplace(workspaceID string) { + b.workspaceID = workspaceID + b.ws = nil // clear cache so Workplace() re-resolves +} + +// Workplace returns the workspace FS bound to this Box. +// If a workspace was bound via CreateOptions.WorkspaceID or BindWorkplace(), +// returns that workspace's FS. Otherwise returns nil. +func (b *Box) Workplace() workspace.FS { + return b.Workspace() +} + // Exec runs a command and waits for it to finish. func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) { b.touch() diff --git a/sandbox/v2/host.go b/sandbox/v2/host.go index 89ccff4d..130c4a92 100644 --- a/sandbox/v2/host.go +++ b/sandbox/v2/host.go @@ -1,8 +1,10 @@ package sandbox import ( + "bytes" "context" "fmt" + "io" hepb "github.com/yaoapp/yao/tai/hostexec/pb" "github.com/yaoapp/yao/tai/workspace" @@ -12,18 +14,34 @@ import ( // Unlike Box (which wraps a container), Host executes commands directly on // the Tai server's OS via HostExec gRPC and accesses files via Volume gRPC. // -// A Host is bound to a pool and does not require Create — it is available as -// long as the pool's Tai server reports host_exec capability. +// Host implements the Computer interface. type Host struct { - pool string - manager *Manager + pool string + workplaceID string + manager *Manager } -// Pool returns the pool name this Host belongs to. -func (h *Host) Pool() string { return h.pool } +// Compile-time check: *Host implements Computer. +var _ Computer = (*Host)(nil) + +// ComputerInfo returns identity and registry information for the host. +// Registry-level details (TaiID, System, etc.) are populated when the pool +// is backed by a registered Tai node; otherwise only Kind and Pool are set. +func (h *Host) ComputerInfo() ComputerInfo { + return ComputerInfo{ + Kind: "host", + Pool: h.pool, + Status: "online", + } +} // Exec runs a command on the Tai host machine via HostExec gRPC. -func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecResult, error) { +// cmd[0] is the program, cmd[1:] are arguments. +func (h *Host) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) { + if len(cmd) == 0 { + return nil, fmt.Errorf("sandbox: empty command") + } + client, err := h.manager.getPool(h.pool) if err != nil { return nil, err @@ -34,32 +52,38 @@ func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...Host return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool) } - cfg := &hostExecConfig{} + cfg := &execConfig{} for _, o := range opts { o(cfg) } req := &hepb.ExecRequest{ - Command: cmd, - Args: args, - WorkingDir: cfg.WorkDir, - Stdin: cfg.Stdin, - TimeoutMs: cfg.TimeoutMs, - MaxOutputBytes: cfg.MaxOutputBytes, + Command: cmd[0], + Args: cmd[1:], + Stdin: cfg.Stdin, + } + if cfg.WorkDir != "" { + req.WorkingDir = cfg.WorkDir } if cfg.Env != nil { req.Env = cfg.Env } + if cfg.Timeout > 0 { + req.TimeoutMs = cfg.Timeout.Milliseconds() + } + if cfg.MaxOutputBytes > 0 { + req.MaxOutputBytes = cfg.MaxOutputBytes + } resp, err := he.Exec(ctx, req) if err != nil { return nil, fmt.Errorf("hostexec rpc: %w", err) } - return &HostExecResult{ + return &ExecResult{ ExitCode: int(resp.ExitCode), - Stdout: resp.Stdout, - Stderr: resp.Stderr, + Stdout: string(resp.Stdout), + Stderr: string(resp.Stderr), DurationMs: resp.DurationMs, Error: resp.Error, Truncated: resp.Truncated, @@ -67,9 +91,13 @@ func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...Host } // Stream runs a command on the Tai host and streams stdout/stderr in real time -// via HostExec gRPC ExecStream. Returns a HostExecStream with separate channels -// for stdout and stderr, plus Wait (blocks until exit) and Cancel. -func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecStream, error) { +// via HostExec gRPC ExecStream. Returns a unified ExecStream with io.ReadCloser +// for stdout/stderr. +func (h *Host) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) { + if len(cmd) == 0 { + return nil, fmt.Errorf("sandbox: empty command") + } + client, err := h.manager.getPool(h.pool) if err != nil { return nil, err @@ -80,22 +108,28 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool) } - cfg := &hostExecConfig{} + cfg := &execConfig{} for _, o := range opts { o(cfg) } req := &hepb.ExecRequest{ - Command: cmd, - Args: args, - WorkingDir: cfg.WorkDir, - Stdin: cfg.Stdin, - TimeoutMs: cfg.TimeoutMs, - MaxOutputBytes: cfg.MaxOutputBytes, + Command: cmd[0], + Args: cmd[1:], + Stdin: cfg.Stdin, + } + if cfg.WorkDir != "" { + req.WorkingDir = cfg.WorkDir } if cfg.Env != nil { req.Env = cfg.Env } + if cfg.Timeout > 0 { + req.TimeoutMs = cfg.Timeout.Milliseconds() + } + if cfg.MaxOutputBytes > 0 { + req.MaxOutputBytes = cfg.MaxOutputBytes + } streamCtx, cancel := context.WithCancel(ctx) rpcStream, err := he.ExecStream(streamCtx, req) @@ -104,15 +138,15 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho return nil, fmt.Errorf("hostexec stream rpc: %w", err) } - stdoutCh := make(chan []byte, 64) - stderrCh := make(chan []byte, 64) + stdoutR, stdoutW := io.Pipe() + stderrR, stderrW := io.Pipe() doneCh := make(chan struct{}) var exitCode int var exitErr error go func() { - defer close(stdoutCh) - defer close(stderrCh) + defer stdoutW.Close() + defer stderrW.Close() defer close(doneCh) for { msg, err := rpcStream.Recv() @@ -123,9 +157,9 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho if len(msg.Data) > 0 { switch msg.Stream { case hepb.ExecOutput_STDOUT: - stdoutCh <- msg.Data + stdoutW.Write(msg.Data) case hepb.ExecOutput_STDERR: - stderrCh <- msg.Data + stderrW.Write(msg.Data) } } if msg.Done { @@ -138,9 +172,10 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho } }() - return &HostExecStream{ - Stdout: stdoutCh, - Stderr: stderrCh, + return &ExecStream{ + Stdout: stdoutR, + Stderr: stderrR, + Stdin: nopWriteCloser{&bytes.Buffer{}}, Wait: func() (int, error) { <-doneCh return exitCode, exitErr @@ -149,13 +184,48 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho }, nil } -// Workspace returns a filesystem interface for the given session on the host. -// The sessionID typically corresponds to a workspace ID; files are stored -// under dataDir/{sessionID}/ on the Tai host, accessed via Volume gRPC. -func (h *Host) Workspace(sessionID string) workspace.FS { +// VNC returns the VNC WebSocket URL for the Tai host machine. +// Uses the special __host__ identifier to route to localhost:5900 on the Tai server. +func (h *Host) VNC(ctx context.Context) (string, error) { + client, err := h.manager.getPool(h.pool) + if err != nil { + return "", err + } + return client.VNC().URL(ctx, "__host__") +} + +// Proxy returns the HTTP URL for a service running on the Tai host machine. +// Uses the special __host__ identifier to route to localhost:{port} on the Tai server. +func (h *Host) Proxy(ctx context.Context, port int, path string) (string, error) { + client, err := h.manager.getPool(h.pool) + if err != nil { + return "", err + } + return client.Proxy().URL(ctx, "__host__", port, path) +} + +// BindWorkplace binds a workspace to this host by ID. Subsequent calls to +// Workplace() will return the FS for this workspace. Call again to rebind. +func (h *Host) BindWorkplace(workspaceID string) { + h.workplaceID = workspaceID +} + +// Workplace returns the workspace FS bound to this host, or nil if unbound. +func (h *Host) Workplace() workspace.FS { + if h.workplaceID == "" { + return nil + } client, err := h.manager.getPool(h.pool) if err != nil { return nil } - return client.Workspace(sessionID) + return client.Workspace(h.workplaceID) } + +// Pool returns the pool name this Host belongs to. +func (h *Host) Pool() string { return h.pool } + +// nopWriteCloser wraps an io.Writer with a no-op Close. +type nopWriteCloser struct{ io.Writer } + +func (nopWriteCloser) Close() error { return nil } diff --git a/sandbox/v2/host_test.go b/sandbox/v2/host_test.go index de5d090d..54d74c97 100644 --- a/sandbox/v2/host_test.go +++ b/sandbox/v2/host_test.go @@ -3,6 +3,7 @@ package sandbox_test import ( "context" "fmt" + "io" "strings" "testing" "time" @@ -39,8 +40,8 @@ func TestHost_Exec_Echo(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - cmd, args := linuxCmd(tgt, "echo", "hello", "from", "host") - result, err := host.Exec(ctx, cmd, args) + cmd := hostCmd(tgt, "echo", "hello", "from", "host") + result, err := host.Exec(ctx, cmd) if err != nil { t.Fatalf("Exec: %v", err) } @@ -53,7 +54,7 @@ func TestHost_Exec_Echo(t *testing.T) { if result.ExitCode != 0 { t.Errorf("exit_code = %d, want 0", result.ExitCode) } - got := strings.TrimSpace(string(result.Stdout)) + got := strings.TrimSpace(result.Stdout) if !strings.Contains(got, "hello") { t.Errorf("stdout = %q, want contains 'hello'", got) } @@ -76,17 +77,14 @@ func TestHost_Exec_Env(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - var cmd string - var args []string + var cmd []string if tgt.IsWinNative { - cmd = "cmd.exe" - args = []string{"/c", "echo", "%MY_VAR%"} + cmd = []string{"cmd.exe", "/c", "echo", "%MY_VAR%"} } else { - cmd = "sh" - args = []string{"-c", "echo $MY_VAR"} + cmd = []string{"sh", "-c", "echo $MY_VAR"} } - result, err := host.Exec(ctx, cmd, args, sandbox.WithHostEnv(map[string]string{"MY_VAR": "host_test_value"})) + result, err := host.Exec(ctx, cmd, sandbox.WithEnv(map[string]string{"MY_VAR": "host_test_value"})) if err != nil { t.Fatalf("Exec: %v", err) } @@ -96,7 +94,7 @@ func TestHost_Exec_Env(t *testing.T) { } t.Fatalf("error: %s", result.Error) } - got := strings.TrimSpace(string(result.Stdout)) + got := strings.TrimSpace(result.Stdout) if !strings.Contains(got, "host_test_value") { t.Errorf("stdout = %q, want contains 'host_test_value'", got) } @@ -104,7 +102,7 @@ func TestHost_Exec_Env(t *testing.T) { } } -func TestHost_Workspace(t *testing.T) { +func TestHost_Workplace(t *testing.T) { skipIfNoHostExec(t) for _, tgt := range hostExecTargets() { @@ -117,12 +115,13 @@ func TestHost_Workspace(t *testing.T) { } sessionID := fmt.Sprintf("host-test-%d", time.Now().UnixNano()) - ws := host.Workspace(sessionID) + host.BindWorkplace(sessionID) + ws := host.Workplace() if ws == nil { - t.Fatal("Workspace returned nil") + t.Fatal("Workplace returned nil after BindWorkplace") } - content := []byte("hello from host workspace test") + content := []byte("hello from host workplace test") if err := ws.WriteFile("test.txt", content, 0644); err != nil { t.Fatalf("WriteFile: %v", err) } @@ -175,15 +174,22 @@ func TestHost_Stream_Incremental(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - stream, err := host.Stream(ctx, "sh", []string{"-c", + stream, err := host.Stream(ctx, []string{"sh", "-c", "for i in 1 2 3 4 5; do echo chunk$i; sleep 0.2; done"}) if err != nil { t.Fatalf("Stream: %v", err) } var chunks []string - for chunk := range stream.Stdout { - chunks = append(chunks, string(chunk)) + buf := make([]byte, 4096) + for { + n, err := stream.Stdout.Read(buf) + if n > 0 { + chunks = append(chunks, string(buf[:n])) + } + if err != nil { + break + } } exitCode, err := stream.Wait() @@ -230,15 +236,12 @@ func TestHost_Stream_MultiLine(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - stream, err := host.Stream(ctx, "sh", []string{"-c", "for i in 1 2 3; do echo line$i; done"}) + stream, err := host.Stream(ctx, []string{"sh", "-c", "for i in 1 2 3; do echo line$i; done"}) if err != nil { t.Fatalf("Stream: %v", err) } - var stdout []byte - for chunk := range stream.Stdout { - stdout = append(stdout, chunk...) - } + stdout, _ := io.ReadAll(stream.Stdout) exitCode, err := stream.Wait() if err != nil && !strings.Contains(err.Error(), "EOF") { @@ -278,22 +281,17 @@ func TestHost_Stream_Stderr(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - stream, err := host.Stream(ctx, "sh", []string{"-c", "echo err-msg >&2"}) + stream, err := host.Stream(ctx, []string{"sh", "-c", "echo err-msg >&2"}) if err != nil { t.Fatalf("Stream: %v", err) } - var stderr []byte done := make(chan struct{}) go func() { - for chunk := range stream.Stdout { - _ = chunk - } + io.ReadAll(stream.Stdout) close(done) }() - for chunk := range stream.Stderr { - stderr = append(stderr, chunk...) - } + stderr, _ := io.ReadAll(stream.Stderr) <-done exitCode, err := stream.Wait() @@ -332,7 +330,7 @@ func TestHost_Stream_Cancel(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - stream, err := host.Stream(ctx, "sh", []string{"-c", "while true; do echo tick; sleep 0.1; done"}) + stream, err := host.Stream(ctx, []string{"sh", "-c", "while true; do echo tick; sleep 0.1; done"}) if err != nil { if strings.Contains(err.Error(), "not in the allowed list") { t.Skipf("command not allowed on %s", tgt.Name) @@ -341,13 +339,19 @@ func TestHost_Stream_Cancel(t *testing.T) { } received := 0 - for chunk := range stream.Stdout { - _ = chunk - received++ + buf := make([]byte, 4096) + for { + n, err := stream.Stdout.Read(buf) + if n > 0 { + received++ + } if received >= 3 { stream.Cancel() break } + if err != nil { + break + } } _, waitErr := stream.Wait() @@ -361,8 +365,52 @@ func TestHost_Stream_Cancel(t *testing.T) { } } +func TestHost_ComputerInfo(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + m := setupHostManager(t, tgt) + + host, err := m.Host(context.Background(), tgt.Name) + if err != nil { + t.Skipf("Host(%s): %v", tgt.Name, err) + } + + info := host.ComputerInfo() + if info.Kind != "host" { + t.Errorf("Kind = %q, want 'host'", info.Kind) + } + if info.Pool != tgt.Name { + t.Errorf("Pool = %q, want %q", info.Pool, tgt.Name) + } + }) + } +} + +func TestHost_ComputerInterface(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + m := setupHostManager(t, tgt) + + host, err := m.Host(context.Background(), tgt.Name) + if err != nil { + t.Skipf("Host(%s): %v", tgt.Name, err) + } + + // Verify Host satisfies Computer interface at runtime. + var c sandbox.Computer = host + info := c.ComputerInfo() + if info.Kind != "host" { + t.Errorf("Computer.ComputerInfo().Kind = %q, want 'host'", info.Kind) + } + }) + } +} + func TestHost_CreateRejectsNoContainerPool(t *testing.T) { - // Use the Windows native HostExec target which has no Docker. tgt := findHostExecOnly(t) if tgt == nil { t.Skip("no host-exec-only target available") @@ -397,13 +445,10 @@ func TestHost_PoolNotFound(t *testing.T) { } } -// findHostExecOnly returns a hostExecTarget that is likely host-exec-only -// (Windows native Tai without Docker). func findHostExecOnly(t *testing.T) *hostExecTarget { t.Helper() for _, tgt := range hostExecTargets() { if tgt.IsWinNative { - // Windows native Tai typically has no Docker addr := fmt.Sprintf("tai://%s", tgt.Addr) client, err := tai.New(addr) if err != nil { @@ -418,3 +463,12 @@ func findHostExecOnly(t *testing.T) *hostExecTarget { } return nil } + +// hostCmd builds a []string command, adapting for Windows targets. +func hostCmd(tgt hostExecTarget, prog string, args ...string) []string { + if tgt.IsWinNative { + cmd, wArgs := linuxCmd(tgt, prog, args...) + return append([]string{cmd}, wArgs...) + } + return append([]string{prog}, args...) +} diff --git a/sandbox/v2/jsapi/box.go b/sandbox/v2/jsapi/box.go index 503c2f3a..da224b8f 100644 --- a/sandbox/v2/jsapi/box.go +++ b/sandbox/v2/jsapi/box.go @@ -8,34 +8,43 @@ import ( // All methods delegate to the Go sandbox.M() singleton — no Go object is // passed to V8, no bridge registration, no Release() needed. // +// Box implements the Computer interface, so it shares the unified Exec/Stream/ +// VNC/Proxy/ComputerInfo/BindWorkplace/Workplace methods with Host. It also +// has Box-specific methods (Attach, Info, Start, Stop, Remove). +// // # Properties (read-only) // // box.id → string // sandbox ID ← Box.ID() // box.owner → string // owner user ID ← Box.Owner() // box.pool → string // pool name ← Box.Pool() // -// # Methods — Go mapping +// # Methods — Computer interface (unified with Host) // // box.Exec(cmd, options?) → ExecResult // -// Go: Box.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error) +// Go: Computer.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error) // // JS args: // cmd: string[] → cmd []string -// options: { → ExecOption functional options -// workdir: string, → WithWorkDir(dir) -// env: object, → WithEnv(map[string]string) -// timeout: number → WithTimeout(ms → time.Duration) +// options: { → ExecOption +// workdir: string, → WithWorkDir(dir) +// env: object, → WithEnv(map[string]string) +// stdin: string, → WithStdin([]byte) +// timeout: number, → WithTimeout(ms → time.Duration) +// max_output: number → WithMaxOutput(bytes int64) // } // JS returns: { -// exit_code: number, ← ExecResult.ExitCode -// stdout: string, ← ExecResult.Stdout -// stderr: string ← ExecResult.Stderr +// exit_code: number, ← ExecResult.ExitCode +// stdout: string, ← ExecResult.Stdout +// stderr: string, ← ExecResult.Stderr +// duration_ms: number, ← ExecResult.DurationMs (Host fills; Box = 0) +// error: string, ← ExecResult.Error (Host fills; Box = "") +// truncated: boolean ← ExecResult.Truncated (Host fills; Box = false) // } // // box.Stream(cmd, callback) / box.Stream(cmd, options, callback) // -// Go: Box.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error) +// Go: Computer.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error) // // Blocks until the process exits. The last argument must be a JS function. // Callback signature: function(type, data) @@ -43,16 +52,39 @@ import ( // type = "stderr" → data is string (chunk) // type = "exit" → data is number (exit code) // -// JS args: -// cmd: string[] -// options: { workdir, env, timeout } (optional, same as Exec) -// callback: function(type, data) +// box.VNC() → string +// +// Go: Computer.VNC(ctx) (string, error) +// Returns: VNC WebSocket URL +// +// box.Proxy(port, path?) → string +// +// Go: Computer.Proxy(ctx, port int, path string) (string, error) +// Returns: HTTP proxy URL +// +// box.ComputerInfo() → ComputerInfo +// +// Go: Computer.ComputerInfo() ComputerInfo +// JS returns: { +// kind: "box", pool, status, +// box_id, container_id, owner, image, policy, labels, ... +// } +// +// box.BindWorkplace(workspaceID) → void +// +// Go: Computer.BindWorkplace(workspaceID string) +// +// box.Workplace() → WorkspaceFS | null +// +// Go: Computer.Workplace() workspace.FS +// +// # Methods — Box-specific // // box.Attach(port, options?) → string // // Go: Proxy.URL(ctx, containerID, port, path) (string, error) // -// Returns the service URL string. Caller (frontend/Agent) establishes WS/SSE. +// Returns the service URL string. // JS args: // port: number → port int // options: { → AttachOption @@ -61,38 +93,17 @@ import ( // } // JS returns: string (URL) // -// box.VNC() → string -// -// Go: Box.VNC(ctx) (string, error) -// Returns: VNC WebSocket URL -// -// box.Proxy(port, path?) → string -// -// Go: Box.Proxy(ctx, port int, path string) (string, error) -// Returns: HTTP proxy URL -// // box.Workspace() → WorkspaceFS // -// Implemented in workspace/jsapi package. This method calls: +// Implemented in workspace/jsapi package. Calls: // workspace.NewFSObject(v8ctx, box.WorkspaceID()) -// and returns the resulting WorkspaceFS object directly. // // box.Info() → BoxInfo // // Go: Box.Info(ctx) (*BoxInfo, error) // JS returns: { -// id: string, ← BoxInfo.ID -// container_id: string, ← BoxInfo.ContainerID -// pool: string, ← BoxInfo.Pool -// owner: string, ← BoxInfo.Owner -// status: string, ← BoxInfo.Status -// image: string, ← BoxInfo.Image -// vnc: boolean, ← BoxInfo.VNC -// policy: string, ← BoxInfo.Policy (LifecyclePolicy) -// labels: object, ← BoxInfo.Labels (map[string]string) -// created_at: string, ← BoxInfo.CreatedAt (ISO 8601) -// last_active: string, ← BoxInfo.LastActive (ISO 8601) -// process_count: number ← BoxInfo.ProcessCount +// id, container_id, pool, owner, status, image, vnc, policy, +// labels, created_at, last_active, process_count // } // // box.Start() → void @@ -110,12 +121,10 @@ func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) { // TODO: Phase 2 implementation // 1. Create JS object via v8go.NewObjectTemplate // 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID)) - // 3. Bind each method as FunctionTemplate: - // - Exec → sandbox.M().Get(id).Exec(ctx, cmd, opts...) - // - Stream → sandbox.M().Get(id).Stream(ctx, cmd, opts...) + // 3. Bind Computer interface methods: + // - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace + // 4. Bind Box-specific methods: // - Attach → client.Proxy().URL(ctx, containerID, port, path) → string - // - VNC → sandbox.M().Get(id).VNC(ctx) - // - Proxy → sandbox.M().Get(id).Proxy(ctx, port, path) // - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID()) // - Info → sandbox.M().Get(id).Info(ctx) → JS object // - Start → sandbox.M().Get(id).Start(ctx) diff --git a/sandbox/v2/jsapi/host.go b/sandbox/v2/jsapi/host.go index 6938363c..c4396cc4 100644 --- a/sandbox/v2/jsapi/host.go +++ b/sandbox/v2/jsapi/host.go @@ -4,7 +4,7 @@ import ( "rogchap.com/v8go" ) -// sbHost: `sandbox.Host(pool?)` → Host +// sbHost: `sandbox.Host(pool?)` → Computer (Host) // // Go: Manager.Host(ctx, pool) (*Host, error) // @@ -12,7 +12,7 @@ import ( // // pool: string (optional) — pool name; empty = default pool // -// Returns: Host object if the pool has host_exec capability, otherwise throws. +// Returns: Computer object (Host) if the pool has host_exec capability, otherwise throws. // // Host executes commands on the Tai host machine (no container). Available only // when the pool's Tai server exposes HostExec gRPC. @@ -21,45 +21,47 @@ func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value { // 1. pool := ""; if len(info.Args()) > 0 && info.Args()[0].IsString() { pool = info.Args()[0].String() } // 2. host, err := sandbox.M().Host(ctx, pool) // 3. if err != nil { throw in V8 } - // 4. Return NewHostObject(v8ctx, host.Pool()) + // 4. Return NewComputerObject(v8ctx, host) return v8go.Undefined(info.Context().Isolate()) } -// NewHostObject creates a JS Host object backed by a pool name string. +// NewHostObject creates a JS Computer object backed by a Host. // All methods delegate to the Go sandbox.M() singleton — no Go *Host passed to V8. // +// Host implements the unified Computer interface, so the JS object exposes the +// same methods as a Box Computer object: +// // # Properties (read-only) // -// host.pool → string // pool name ← Host.Pool() +// host.pool → string // pool name // -// # Methods — Go mapping +// # Methods — Go mapping (unified Computer interface) // -// host.Exec(cmd, args, options?) → HostExecResult +// host.Exec(cmd, options?) → ExecResult // -// Go: Host.Exec(ctx, cmd string, args []string, opts ...HostExecOption) (*HostExecResult, error) +// Go: Computer.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error) // // JS args: -// cmd: string → cmd string -// args: string[] → args []string -// options: { → HostExecOption -// workdir: string, → WithHostWorkDir(dir) -// env: object, → WithHostEnv(map[string]string) -// stdin: string, → WithHostStdin([]byte) -// timeout: number, → WithHostTimeout(ms int64) -// max_output: number → WithHostMaxOutput(bytes int64) +// cmd: string[] → cmd []string +// options: { → ExecOption +// workdir: string, → WithWorkDir(dir) +// env: object, → WithEnv(map[string]string) +// stdin: string, → WithStdin([]byte) +// timeout: number, → WithTimeout(ms → time.Duration) +// max_output: number → WithMaxOutput(bytes int64) // } // JS returns: { -// exit_code: number, ← HostExecResult.ExitCode -// stdout: string (UTF-8), ← HostExecResult.Stdout -// stderr: string (UTF-8), ← HostExecResult.Stderr -// duration_ms: number, ← HostExecResult.DurationMs -// error: string, ← HostExecResult.Error -// truncated: boolean ← HostExecResult.Truncated +// exit_code: number, ← ExecResult.ExitCode +// stdout: string, ← ExecResult.Stdout +// stderr: string, ← ExecResult.Stderr +// duration_ms: number, ← ExecResult.DurationMs +// error: string, ← ExecResult.Error +// truncated: boolean ← ExecResult.Truncated // } // -// host.Stream(cmd, args, callback) / host.Stream(cmd, args, options, callback) +// host.Stream(cmd, callback) / host.Stream(cmd, options, callback) // -// Go: Host.Stream(ctx, cmd string, args []string, opts ...HostExecOption) (*HostExecStream, error) +// Go: Computer.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error) // // Blocks until the process exits. The last argument must be a JS function. // Callback signature: function(type, data) @@ -67,21 +69,34 @@ func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value { // type = "stderr" → data is string (chunk) // type = "exit" → data is number (exit code) // -// JS args: -// cmd: string -// args: string[] -// options: { workdir, env, stdin, timeout, max_output } (optional, same as host.Exec) -// callback: function(type, data) +// host.VNC() → string // -// host.Workspace(sessionID) → WorkspaceFS +// Go: Computer.VNC(ctx) (string, error) +// Returns: VNC WebSocket URL (routes to Tai host via __host__ identifier) // -// Implemented in workspace/jsapi package. This method calls: -// workspace.NewFSObject(v8ctx, sessionID) -// and returns the resulting WorkspaceFS object directly. +// host.Proxy(port, path?) → string +// +// Go: Computer.Proxy(ctx, port int, path string) (string, error) +// Returns: HTTP proxy URL (routes to Tai host via __host__ identifier) +// +// host.ComputerInfo() → ComputerInfo +// +// Go: Computer.ComputerInfo() ComputerInfo +// JS returns: { kind: "host", pool: string, status: string, ... } +// +// host.BindWorkplace(workspaceID) → void +// +// Go: Computer.BindWorkplace(workspaceID string) +// +// host.Workplace() → WorkspaceFS | null +// +// Go: Computer.Workplace() workspace.FS +// Returns WorkspaceFS if a workplace is bound, null otherwise. func NewHostObject(v8ctx *v8go.Context, pool string) (*v8go.Value, error) { // TODO: Phase 2 implementation // 1. Create JS object via v8go.NewObjectTemplate // 2. Set read-only property: pool - // 3. Bind methods: Exec, Stream, Workspace (each resolves Host via sandbox.M().Host(ctx, pool)) + // 3. Bind methods via unified Computer interface: + // - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace return nil, nil } diff --git a/sandbox/v2/jsapi/jsapi.go b/sandbox/v2/jsapi/jsapi.go index df518f0d..22cc8850 100644 --- a/sandbox/v2/jsapi/jsapi.go +++ b/sandbox/v2/jsapi/jsapi.go @@ -11,7 +11,7 @@ // const box = sandbox.Get(id) // → Box // const list = sandbox.List({ owner: "u1" }) // → BoxInfo[] // sandbox.Delete(id) // → void -// const host = sandbox.Host("gpu") // → Host (host_exec on Tai) +// const host = sandbox.Host("gpu") // → Computer (Host via host_exec on Tai) // const node = sandbox.GetNode("tai-abc123") // → NodeInfo | null // const all = sandbox.Nodes() // → NodeInfo[] // const team = sandbox.NodesByTeam("t-001") // → NodeInfo[] @@ -23,7 +23,7 @@ // sandbox.Get(id) → Manager.Get(ctx, id) → Box // sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[] // sandbox.Delete(id) → Manager.Remove(ctx, id) → void -// sandbox.Host(pool?) → Manager.Host(ctx, pool) → Host +// sandbox.Host(pool?) → Manager.Host(ctx, pool) → Computer (Host) // sandbox.GetNode(id) → registry.Global().Get(id) → NodeInfo | null // sandbox.Nodes() → registry.Global().List() → NodeInfo[] // sandbox.NodesByTeam(t)→ registry.Global().ListByTeam(t) → NodeInfo[] diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index d6263300..9860070f 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -1,12 +1,64 @@ package sandbox import ( + "context" "io" "time" "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/workspace" ) +// --------------------------------------------------------------------------- +// Computer — unified interface for execution environments +// --------------------------------------------------------------------------- + +// Computer is the unified interface for remote execution environments. +// Both Box (container) and Host (bare metal) implement it. +type Computer interface { + ComputerInfo() ComputerInfo + Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) + Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) + VNC(ctx context.Context) (string, error) + Proxy(ctx context.Context, port int, path string) (string, error) + BindWorkplace(workspaceID string) + Workplace() workspace.FS +} + +// ComputerInfo holds identity and registry information for a Computer. +type ComputerInfo struct { + Kind string // "box" | "host" + Pool string + TaiID string + MachineID string + Version string + System SystemInfo + Mode string // "direct" | "tunnel" + Capabilities map[string]bool + Status string + + // Box-specific fields (zero values for Host) + BoxID string + ContainerID string + Owner string + Image string + Policy LifecyclePolicy + Labels map[string]string +} + +// SystemInfo describes the hardware of a Tai node. +type SystemInfo struct { + OS string + Arch string + Hostname string + NumCPU int + TotalMem int64 +} + +// --------------------------------------------------------------------------- +// Lifecycle & Pool +// --------------------------------------------------------------------------- + type LifecyclePolicy string const ( @@ -26,7 +78,7 @@ type Pool struct { MaxTotal int IdleTimeout time.Duration MaxLifetime time.Duration - StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout + StopTimeout time.Duration } type PoolInfo struct { @@ -40,6 +92,10 @@ type PoolInfo struct { MaxLifetime time.Duration } +// --------------------------------------------------------------------------- +// Create / List options +// --------------------------------------------------------------------------- + type PortMapping struct { ContainerPort int HostPort int @@ -62,12 +118,11 @@ type CreateOptions struct { Ports []PortMapping Policy LifecyclePolicy IdleTimeout time.Duration + StopTimeout time.Duration - StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout - - WorkspaceID string // workspace to mount; empty = no workspace - MountMode string // "rw" (default) or "ro" - MountPath string // container path; default "/workspace" + WorkspaceID string + MountMode string + MountPath string } type ListOptions struct { @@ -76,38 +131,52 @@ type ListOptions struct { Labels map[string]string } +// --------------------------------------------------------------------------- +// Unified ExecOption / ExecResult / ExecStream +// --------------------------------------------------------------------------- + type execConfig struct { - WorkDir string - Env map[string]string - Timeout time.Duration + WorkDir string + Env map[string]string + Timeout time.Duration + Stdin []byte + MaxOutputBytes int64 } +// ExecOption configures an Exec or Stream call on any Computer. type ExecOption func(*execConfig) func WithWorkDir(dir string) ExecOption { - return func(c *execConfig) { - c.WorkDir = dir - } + return func(c *execConfig) { c.WorkDir = dir } } func WithEnv(env map[string]string) ExecOption { - return func(c *execConfig) { - c.Env = env - } + return func(c *execConfig) { c.Env = env } } func WithTimeout(timeout time.Duration) ExecOption { - return func(c *execConfig) { - c.Timeout = timeout - } + return func(c *execConfig) { c.Timeout = timeout } } +func WithStdin(data []byte) ExecOption { + return func(c *execConfig) { c.Stdin = data } +} + +func WithMaxOutput(bytes int64) ExecOption { + return func(c *execConfig) { c.MaxOutputBytes = bytes } +} + +// ExecResult holds the outcome of a command executed on any Computer. type ExecResult struct { - ExitCode int - Stdout string - Stderr string + ExitCode int + Stdout string + Stderr string + DurationMs int64 + Error string + Truncated bool } +// ExecStream provides real-time streaming I/O for a running command. type ExecStream struct { Stdout io.ReadCloser Stderr io.ReadCloser @@ -116,6 +185,10 @@ type ExecStream struct { Cancel func() } +// --------------------------------------------------------------------------- +// Attach (Box-specific, not part of Computer interface) +// --------------------------------------------------------------------------- + type attachConfig struct { Protocol string Path string @@ -125,26 +198,20 @@ type attachConfig struct { type AttachOption func(*attachConfig) func WithProtocol(protocol string) AttachOption { - return func(c *attachConfig) { - c.Protocol = protocol - } + return func(c *attachConfig) { c.Protocol = protocol } } func WithPath(path string) AttachOption { - return func(c *attachConfig) { - c.Path = path - } + return func(c *attachConfig) { c.Path = path } } func WithHeaders(headers map[string]string) AttachOption { - return func(c *attachConfig) { - c.Headers = headers - } + return func(c *attachConfig) { c.Headers = headers } } // ImagePullOptions configures an image pull operation. type ImagePullOptions struct { - Auth *RegistryAuth // nil = anonymous / public + Auth *RegistryAuth } // RegistryAuth holds credentials for a private container registry. @@ -162,6 +229,7 @@ type ServiceConn struct { Close func() error } +// BoxInfo is a snapshot of a Box's runtime state (used by Manager.List). type BoxInfo struct { ID string ContainerID string @@ -176,53 +244,3 @@ type BoxInfo struct { ProcessCount int VNC bool } - -// HostExecResult holds the outcome of a command executed on the Tai host. -type HostExecResult struct { - ExitCode int - Stdout []byte - Stderr []byte - DurationMs int64 - Error string - Truncated bool -} - -// HostExecStream provides real-time streaming output from a command running -// on the Tai host machine via HostExec gRPC ExecStream. -type HostExecStream struct { - Stdout <-chan []byte - Stderr <-chan []byte - Wait func() (int, error) // blocks until exit; returns exit code - Cancel func() // cancels the stream context -} - -type hostExecConfig struct { - WorkDir string - Env map[string]string - Stdin []byte - TimeoutMs int64 - MaxOutputBytes int64 -} - -// HostExecOption configures an ExecOnHost call. -type HostExecOption func(*hostExecConfig) - -func WithHostWorkDir(dir string) HostExecOption { - return func(c *hostExecConfig) { c.WorkDir = dir } -} - -func WithHostEnv(env map[string]string) HostExecOption { - return func(c *hostExecConfig) { c.Env = env } -} - -func WithHostStdin(data []byte) HostExecOption { - return func(c *hostExecConfig) { c.Stdin = data } -} - -func WithHostTimeout(ms int64) HostExecOption { - return func(c *hostExecConfig) { c.TimeoutMs = ms } -} - -func WithHostMaxOutput(bytes int64) HostExecOption { - return func(c *hostExecConfig) { c.MaxOutputBytes = bytes } -}