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
This commit is contained in:
Max 2026-03-08 22:33:12 +08:00
parent 18bcf22089
commit 4d4b03be96
8 changed files with 581 additions and 292 deletions

View file

@ -41,14 +41,17 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers.
│ ├── EnsureImage / ImageExists / PullImage │ │ ├── EnsureImage / ImageExists / PullImage │
│ └── guard rails (limits, TTL) + Box factory │ │ └── guard rails (limits, TTL) + Box factory │
│ │ │ │
Box (per-instance) Computer (unified interface)
│ ├── Exec(cmd) → ExecResult │ │ ├── Exec(cmd) → ExecResult │
│ ├── Stream(cmd) → ExecStream (real-time I/O) │ │ ├── Stream(cmd) → ExecStream (real-time I/O) │
│ ├── Attach(port) → ServiceConn (WS/SSE) │
│ ├── Workspace() → workspace.FS │
│ ├── VNC() → url │ │ ├── VNC() → url │
│ ├── Proxy(port) → url │ │ ├── Proxy(port, path) → url │
│ └── Start / Stop / Remove / Info │ │ ├── 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 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 ## 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 ```go
type Box struct { type Box struct {
@ -303,7 +367,9 @@ func (b *Box) Remove(ctx context.Context) error
func (b *Box) Info(ctx context.Context) (*BoxInfo, 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 ```go
type ExecOption func(*execConfig) type ExecOption func(*execConfig)
@ -311,11 +377,16 @@ type ExecOption func(*execConfig)
func WithWorkDir(dir string) ExecOption func WithWorkDir(dir string) ExecOption
func WithEnv(env map[string]string) ExecOption func WithEnv(env map[string]string) ExecOption
func WithTimeout(d time.Duration) ExecOption func WithTimeout(d time.Duration) ExecOption
func WithStdin(data []byte) ExecOption
func WithMaxOutput(bytes int64) ExecOption
type ExecResult struct { type ExecResult struct {
ExitCode int ExitCode int
Stdout string Stdout string
Stderr string Stderr string
DurationMs int64 // Host fills; Box = 0
Error string // Host fills; Box = ""
Truncated bool // Host fills; Box = false
} }
type ExecStream struct { type ExecStream struct {
@ -570,14 +641,16 @@ var (
sandbox/v2/ sandbox/v2/
├── sandbox.go // Init, M(), global singleton ├── sandbox.go // Init, M(), global singleton
├── manager.go // Manager: CRUD, pool management, image ops, cleanup ├── manager.go // Manager: CRUD, pool management, image ops, cleanup
├── box.go // Box: Exec, Stream, Attach, Workspace, VNC, Proxy, lifecycle ├── types.go // Computer interface, ComputerInfo, ExecResult, ExecStream, etc.
├── types.go // CreateOptions, ExecResult, ExecStream, ServiceConn, BoxInfo, 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 ├── config.go // Config struct
├── errors.go // sentinel errors ├── errors.go // sentinel errors
├── grpc.go // token creation/revocation, gRPC env var injection ├── grpc.go // token creation/revocation, gRPC env var injection
├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace ├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace
│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete │ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete/Host
│ └── box.go // Box JS object: Exec/Attach/VNC/Proxy/Workspace/Info/Start/Stop/Remove │ ├── 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 ├── export_test.go // ResetForTest() for test isolation
├── testutils_test.go // shared test helpers (multi-pool setup) ├── testutils_test.go // shared test helpers (multi-pool setup)
├── sandbox_test.go // Init/M singleton tests ├── sandbox_test.go // Init/M singleton tests
@ -586,6 +659,7 @@ sandbox/v2/
├── box_test.go // Box Exec/Workspace/Info tests ├── box_test.go // Box Exec/Workspace/Info tests
├── box_attach_test.go // Attach WS/SSE/VNC tests ├── box_attach_test.go // Attach WS/SSE/VNC tests
├── box_workspace_test.go // Workspace integration 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 ├── box_image_test.go // Image Pull API tests
├── bench_test.go // Performance benchmarks ├── bench_test.go // Performance benchmarks
├── grpc_test.go // Token/env building tests ├── grpc_test.go // Token/env building tests
@ -851,7 +925,7 @@ Static methods:
| `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` | | `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` |
| `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)``Box.Info()` | `BoxInfo[]` | | `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)``Box.Info()` | `BoxInfo[]` |
| `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` | | `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.GetNode(taiID)` | `registry.Global().Get(taiID)` | `NodeInfo \| null` |
| `sandbox.Nodes()` | `registry.Global().List()` | `NodeInfo[]` | | `sandbox.Nodes()` | `registry.Global().List()` | `NodeInfo[]` |
| `sandbox.NodesByTeam(teamID)` | `registry.Global().ListByTeam(teamID)` | `NodeInfo[]` | | `sandbox.NodesByTeam(teamID)` | `registry.Global().ListByTeam(teamID)` | `NodeInfo[]` |
@ -922,13 +996,23 @@ Read-only properties:
Methods: 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 | | 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.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.Workspace()` | `Box.WorkspaceID()``NewFSObject` | `WorkspaceFS` |
| `box.Info()` | `Box.Info(ctx)` | `BoxInfo` | | `box.Info()` | `Box.Info(ctx)` | `BoxInfo` |
| `box.Start()` | `Box.Start(ctx)` | `void` | | `box.Start()` | `Box.Start(ctx)` | `void` |
@ -942,12 +1026,17 @@ cmd: string[] → cmd []string
options: { options: {
workdir: string, → WithWorkDir(dir) workdir: string, → WithWorkDir(dir)
env: object, → WithEnv(map[string]string) env: object, → WithEnv(map[string]string)
timeout: number → WithTimeout(ms → time.Duration) stdin: string, → WithStdin([]byte)
timeout: number, → WithTimeout(ms → time.Duration)
max_output: number → WithMaxOutput(bytes int64)
} }
returns: { returns: {
exit_code: number, ← ExecResult.ExitCode exit_code: number, ← ExecResult.ExitCode
stdout: string, ← ExecResult.Stdout stdout: string, ← ExecResult.Stdout
stderr: string ← ExecResult.Stderr 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. `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: Read-only properties:
@ -988,49 +1077,50 @@ Read-only properties:
|----|----| |----|----|
| `host.pool` | `Host.Pool()` | | `host.pool` | `Host.Pool()` |
Methods: Methods (same Computer interface as Box):
| JS | Go | Returns | | JS | Go | Returns |
|----|-----|---------| |----|-----|---------|
| `host.Exec(cmd, args, opts?)` | `Host.Exec(ctx, cmd, args, ...HostExecOption)` | `HostExecResult` | | `host.Exec(cmd, opts?)` | `Computer.Exec(ctx, cmd []string, ...ExecOption)` | `ExecResult` |
| `host.Stream(cmd, args, [opts,] cb)` | `Host.Stream(ctx, cmd, args, ...HostExecOption)` | callback(type, data) | | `host.Stream(cmd, [opts,] cb)` | `Computer.Stream(ctx, cmd []string, ...ExecOption)` | callback(type, data) |
| `host.Workspace(sessionID)` | `Host.Workspace(sessionID)` | `WorkspaceFS` | | `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 cmd: string[] → cmd []string (unified with Box)
args: string[] → args []string
options: { options: {
workdir: string, → WithHostWorkDir(dir) workdir: string, → WithWorkDir(dir)
env: object, → WithHostEnv(map[string]string) env: object, → WithEnv(map[string]string)
stdin: string, → WithHostStdin([]byte) stdin: string, → WithStdin([]byte)
timeout: number, → WithHostTimeout(ms int64) timeout: number, → WithTimeout(ms → time.Duration)
max_output: number → WithHostMaxOutput(bytes int64) max_output: number → WithMaxOutput(bytes int64)
} }
returns: { returns: {
exit_code: number, ← HostExecResult.ExitCode exit_code: number, ← ExecResult.ExitCode
stdout: string, HostExecResult.Stdout (UTF-8) stdout: string, ← ExecResult.Stdout
stderr: string, HostExecResult.Stderr (UTF-8) stderr: string, ← ExecResult.Stderr
duration_ms: number, ← HostExecResult.DurationMs duration_ms: number, ← ExecResult.DurationMs
error: string, ← HostExecResult.Error error: string, ← ExecResult.Error
truncated: boolean HostExecResult.Truncated 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. Blocks until exit. Last arg must be a JS function.
options: same as host.Exec (optional) options: same as host.Exec (optional)
callback: function(type, data) callback: function(type, data)
type = "stdout" → data is string (chunk) ← HostExecStream.Stdout type = "stdout" → data is string (chunk) ← ExecStream.Stdout (io.ReadCloser)
type = "stderr" → data is string (chunk) ← HostExecStream.Stderr type = "stderr" → data is string (chunk) ← ExecStream.Stderr (io.ReadCloser)
type = "exit" → data is number (exit code) ← HostExecStream.Wait() 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 #### NodeInfo object
`sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()` return NodeInfo objects mapped from `registry.NodeSnapshot`. Auth and YaoBase fields are excluded for security. `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()` return NodeInfo objects mapped from `registry.NodeSnapshot`. Auth and YaoBase fields are excluded for security.

View file

@ -33,11 +33,44 @@ type Box struct {
manager *Manager manager *Manager
} }
// Compile-time check: *Box implements Computer.
var _ Computer = (*Box)(nil)
func (b *Box) ID() string { return b.id } func (b *Box) ID() string { return b.id }
func (b *Box) Owner() string { return b.owner } func (b *Box) Owner() string { return b.owner }
func (b *Box) ContainerID() string { return b.containerID } func (b *Box) ContainerID() string { return b.containerID }
func (b *Box) Pool() string { return b.pool } 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. // Exec runs a command and waits for it to finish.
func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) { func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) {
b.touch() b.touch()

View file

@ -1,8 +1,10 @@
package sandbox package sandbox
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
hepb "github.com/yaoapp/yao/tai/hostexec/pb" hepb "github.com/yaoapp/yao/tai/hostexec/pb"
"github.com/yaoapp/yao/tai/workspace" "github.com/yaoapp/yao/tai/workspace"
@ -12,18 +14,34 @@ import (
// Unlike Box (which wraps a container), Host executes commands directly on // 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. // 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 // Host implements the Computer interface.
// long as the pool's Tai server reports host_exec capability.
type Host struct { type Host struct {
pool string pool string
workplaceID string
manager *Manager manager *Manager
} }
// Pool returns the pool name this Host belongs to. // Compile-time check: *Host implements Computer.
func (h *Host) Pool() string { return h.pool } 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. // 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) client, err := h.manager.getPool(h.pool)
if err != nil { if err != nil {
return nil, err 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) return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool)
} }
cfg := &hostExecConfig{} cfg := &execConfig{}
for _, o := range opts { for _, o := range opts {
o(cfg) o(cfg)
} }
req := &hepb.ExecRequest{ req := &hepb.ExecRequest{
Command: cmd, Command: cmd[0],
Args: args, Args: cmd[1:],
WorkingDir: cfg.WorkDir,
Stdin: cfg.Stdin, Stdin: cfg.Stdin,
TimeoutMs: cfg.TimeoutMs, }
MaxOutputBytes: cfg.MaxOutputBytes, if cfg.WorkDir != "" {
req.WorkingDir = cfg.WorkDir
} }
if cfg.Env != nil { if cfg.Env != nil {
req.Env = cfg.Env 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) resp, err := he.Exec(ctx, req)
if err != nil { if err != nil {
return nil, fmt.Errorf("hostexec rpc: %w", err) return nil, fmt.Errorf("hostexec rpc: %w", err)
} }
return &HostExecResult{ return &ExecResult{
ExitCode: int(resp.ExitCode), ExitCode: int(resp.ExitCode),
Stdout: resp.Stdout, Stdout: string(resp.Stdout),
Stderr: resp.Stderr, Stderr: string(resp.Stderr),
DurationMs: resp.DurationMs, DurationMs: resp.DurationMs,
Error: resp.Error, Error: resp.Error,
Truncated: resp.Truncated, 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 // 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 // via HostExec gRPC ExecStream. Returns a unified ExecStream with io.ReadCloser
// for stdout and stderr, plus Wait (blocks until exit) and Cancel. // for stdout/stderr.
func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecStream, error) { 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) client, err := h.manager.getPool(h.pool)
if err != nil { if err != nil {
return nil, err 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) return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool)
} }
cfg := &hostExecConfig{} cfg := &execConfig{}
for _, o := range opts { for _, o := range opts {
o(cfg) o(cfg)
} }
req := &hepb.ExecRequest{ req := &hepb.ExecRequest{
Command: cmd, Command: cmd[0],
Args: args, Args: cmd[1:],
WorkingDir: cfg.WorkDir,
Stdin: cfg.Stdin, Stdin: cfg.Stdin,
TimeoutMs: cfg.TimeoutMs, }
MaxOutputBytes: cfg.MaxOutputBytes, if cfg.WorkDir != "" {
req.WorkingDir = cfg.WorkDir
} }
if cfg.Env != nil { if cfg.Env != nil {
req.Env = cfg.Env 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) streamCtx, cancel := context.WithCancel(ctx)
rpcStream, err := he.ExecStream(streamCtx, req) 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) return nil, fmt.Errorf("hostexec stream rpc: %w", err)
} }
stdoutCh := make(chan []byte, 64) stdoutR, stdoutW := io.Pipe()
stderrCh := make(chan []byte, 64) stderrR, stderrW := io.Pipe()
doneCh := make(chan struct{}) doneCh := make(chan struct{})
var exitCode int var exitCode int
var exitErr error var exitErr error
go func() { go func() {
defer close(stdoutCh) defer stdoutW.Close()
defer close(stderrCh) defer stderrW.Close()
defer close(doneCh) defer close(doneCh)
for { for {
msg, err := rpcStream.Recv() 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 { if len(msg.Data) > 0 {
switch msg.Stream { switch msg.Stream {
case hepb.ExecOutput_STDOUT: case hepb.ExecOutput_STDOUT:
stdoutCh <- msg.Data stdoutW.Write(msg.Data)
case hepb.ExecOutput_STDERR: case hepb.ExecOutput_STDERR:
stderrCh <- msg.Data stderrW.Write(msg.Data)
} }
} }
if msg.Done { if msg.Done {
@ -138,9 +172,10 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho
} }
}() }()
return &HostExecStream{ return &ExecStream{
Stdout: stdoutCh, Stdout: stdoutR,
Stderr: stderrCh, Stderr: stderrR,
Stdin: nopWriteCloser{&bytes.Buffer{}},
Wait: func() (int, error) { Wait: func() (int, error) {
<-doneCh <-doneCh
return exitCode, exitErr return exitCode, exitErr
@ -149,13 +184,48 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho
}, nil }, nil
} }
// Workspace returns a filesystem interface for the given session on the host. // VNC returns the VNC WebSocket URL for the Tai host machine.
// The sessionID typically corresponds to a workspace ID; files are stored // Uses the special __host__ identifier to route to localhost:5900 on the Tai server.
// under dataDir/{sessionID}/ on the Tai host, accessed via Volume gRPC. func (h *Host) VNC(ctx context.Context) (string, error) {
func (h *Host) Workspace(sessionID string) workspace.FS { 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) client, err := h.manager.getPool(h.pool)
if err != nil { if err != nil {
return 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 }

View file

@ -3,6 +3,7 @@ package sandbox_test
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -39,8 +40,8 @@ func TestHost_Exec_Echo(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
cmd, args := linuxCmd(tgt, "echo", "hello", "from", "host") cmd := hostCmd(tgt, "echo", "hello", "from", "host")
result, err := host.Exec(ctx, cmd, args) result, err := host.Exec(ctx, cmd)
if err != nil { if err != nil {
t.Fatalf("Exec: %v", err) t.Fatalf("Exec: %v", err)
} }
@ -53,7 +54,7 @@ func TestHost_Exec_Echo(t *testing.T) {
if result.ExitCode != 0 { if result.ExitCode != 0 {
t.Errorf("exit_code = %d, want 0", result.ExitCode) 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") { if !strings.Contains(got, "hello") {
t.Errorf("stdout = %q, want contains 'hello'", got) 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) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
var cmd string var cmd []string
var args []string
if tgt.IsWinNative { if tgt.IsWinNative {
cmd = "cmd.exe" cmd = []string{"cmd.exe", "/c", "echo", "%MY_VAR%"}
args = []string{"/c", "echo", "%MY_VAR%"}
} else { } else {
cmd = "sh" cmd = []string{"sh", "-c", "echo $MY_VAR"}
args = []string{"-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 { if err != nil {
t.Fatalf("Exec: %v", err) t.Fatalf("Exec: %v", err)
} }
@ -96,7 +94,7 @@ func TestHost_Exec_Env(t *testing.T) {
} }
t.Fatalf("error: %s", result.Error) t.Fatalf("error: %s", result.Error)
} }
got := strings.TrimSpace(string(result.Stdout)) got := strings.TrimSpace(result.Stdout)
if !strings.Contains(got, "host_test_value") { if !strings.Contains(got, "host_test_value") {
t.Errorf("stdout = %q, want contains 'host_test_value'", got) 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) skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
@ -117,12 +115,13 @@ func TestHost_Workspace(t *testing.T) {
} }
sessionID := fmt.Sprintf("host-test-%d", time.Now().UnixNano()) sessionID := fmt.Sprintf("host-test-%d", time.Now().UnixNano())
ws := host.Workspace(sessionID) host.BindWorkplace(sessionID)
ws := host.Workplace()
if ws == nil { 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 { if err := ws.WriteFile("test.txt", content, 0644); err != nil {
t.Fatalf("WriteFile: %v", err) 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) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() 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"}) "for i in 1 2 3 4 5; do echo chunk$i; sleep 0.2; done"})
if err != nil { if err != nil {
t.Fatalf("Stream: %v", err) t.Fatalf("Stream: %v", err)
} }
var chunks []string var chunks []string
for chunk := range stream.Stdout { buf := make([]byte, 4096)
chunks = append(chunks, string(chunk)) for {
n, err := stream.Stdout.Read(buf)
if n > 0 {
chunks = append(chunks, string(buf[:n]))
}
if err != nil {
break
}
} }
exitCode, err := stream.Wait() exitCode, err := stream.Wait()
@ -230,15 +236,12 @@ func TestHost_Stream_MultiLine(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() 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 { if err != nil {
t.Fatalf("Stream: %v", err) t.Fatalf("Stream: %v", err)
} }
var stdout []byte stdout, _ := io.ReadAll(stream.Stdout)
for chunk := range stream.Stdout {
stdout = append(stdout, chunk...)
}
exitCode, err := stream.Wait() exitCode, err := stream.Wait()
if err != nil && !strings.Contains(err.Error(), "EOF") { 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) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() 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 { if err != nil {
t.Fatalf("Stream: %v", err) t.Fatalf("Stream: %v", err)
} }
var stderr []byte
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
for chunk := range stream.Stdout { io.ReadAll(stream.Stdout)
_ = chunk
}
close(done) close(done)
}() }()
for chunk := range stream.Stderr { stderr, _ := io.ReadAll(stream.Stderr)
stderr = append(stderr, chunk...)
}
<-done <-done
exitCode, err := stream.Wait() exitCode, err := stream.Wait()
@ -332,7 +330,7 @@ func TestHost_Stream_Cancel(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() 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 err != nil {
if strings.Contains(err.Error(), "not in the allowed list") { if strings.Contains(err.Error(), "not in the allowed list") {
t.Skipf("command not allowed on %s", tgt.Name) t.Skipf("command not allowed on %s", tgt.Name)
@ -341,13 +339,19 @@ func TestHost_Stream_Cancel(t *testing.T) {
} }
received := 0 received := 0
for chunk := range stream.Stdout { buf := make([]byte, 4096)
_ = chunk for {
n, err := stream.Stdout.Read(buf)
if n > 0 {
received++ received++
}
if received >= 3 { if received >= 3 {
stream.Cancel() stream.Cancel()
break break
} }
if err != nil {
break
}
} }
_, waitErr := stream.Wait() _, 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) { func TestHost_CreateRejectsNoContainerPool(t *testing.T) {
// Use the Windows native HostExec target which has no Docker.
tgt := findHostExecOnly(t) tgt := findHostExecOnly(t)
if tgt == nil { if tgt == nil {
t.Skip("no host-exec-only target available") 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 { func findHostExecOnly(t *testing.T) *hostExecTarget {
t.Helper() t.Helper()
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
if tgt.IsWinNative { if tgt.IsWinNative {
// Windows native Tai typically has no Docker
addr := fmt.Sprintf("tai://%s", tgt.Addr) addr := fmt.Sprintf("tai://%s", tgt.Addr)
client, err := tai.New(addr) client, err := tai.New(addr)
if err != nil { if err != nil {
@ -418,3 +463,12 @@ func findHostExecOnly(t *testing.T) *hostExecTarget {
} }
return nil 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...)
}

View file

@ -8,34 +8,43 @@ import (
// All methods delegate to the Go sandbox.M() singleton — no Go object is // All methods delegate to the Go sandbox.M() singleton — no Go object is
// passed to V8, no bridge registration, no Release() needed. // 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) // # Properties (read-only)
// //
// box.id → string // sandbox ID ← Box.ID() // box.id → string // sandbox ID ← Box.ID()
// box.owner → string // owner user ID ← Box.Owner() // box.owner → string // owner user ID ← Box.Owner()
// box.pool → string // pool name ← Box.Pool() // box.pool → string // pool name ← Box.Pool()
// //
// # Methods — Go mapping // # Methods — Computer interface (unified with Host)
// //
// box.Exec(cmd, options?) → ExecResult // 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: // JS args:
// cmd: string[] → cmd []string // cmd: string[] → cmd []string
// options: { → ExecOption functional options // options: { → ExecOption
// workdir: string, → WithWorkDir(dir) // workdir: string, → WithWorkDir(dir)
// env: object, → WithEnv(map[string]string) // env: object, → WithEnv(map[string]string)
// timeout: number → WithTimeout(ms → time.Duration) // stdin: string, → WithStdin([]byte)
// timeout: number, → WithTimeout(ms → time.Duration)
// max_output: number → WithMaxOutput(bytes int64)
// } // }
// JS returns: { // JS returns: {
// exit_code: number, ← ExecResult.ExitCode // exit_code: number, ← ExecResult.ExitCode
// stdout: string, ← ExecResult.Stdout // stdout: string, ← ExecResult.Stdout
// stderr: string ← ExecResult.Stderr // 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) // 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. // Blocks until the process exits. The last argument must be a JS function.
// Callback signature: function(type, data) // Callback signature: function(type, data)
@ -43,16 +52,39 @@ import (
// type = "stderr" → data is string (chunk) // type = "stderr" → data is string (chunk)
// type = "exit" → data is number (exit code) // type = "exit" → data is number (exit code)
// //
// JS args: // box.VNC() → string
// cmd: string[] //
// options: { workdir, env, timeout } (optional, same as Exec) // Go: Computer.VNC(ctx) (string, error)
// callback: function(type, data) // 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 // box.Attach(port, options?) → string
// //
// Go: Proxy.URL(ctx, containerID, port, path) (string, error) // 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: // JS args:
// port: number → port int // port: number → port int
// options: { → AttachOption // options: { → AttachOption
@ -61,38 +93,17 @@ import (
// } // }
// JS returns: string (URL) // 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 // box.Workspace() → WorkspaceFS
// //
// Implemented in workspace/jsapi package. This method calls: // Implemented in workspace/jsapi package. Calls:
// workspace.NewFSObject(v8ctx, box.WorkspaceID()) // workspace.NewFSObject(v8ctx, box.WorkspaceID())
// and returns the resulting WorkspaceFS object directly.
// //
// box.Info() → BoxInfo // box.Info() → BoxInfo
// //
// Go: Box.Info(ctx) (*BoxInfo, error) // Go: Box.Info(ctx) (*BoxInfo, error)
// JS returns: { // JS returns: {
// id: string, ← BoxInfo.ID // id, container_id, pool, owner, status, image, vnc, policy,
// container_id: string, ← BoxInfo.ContainerID // labels, created_at, last_active, process_count
// 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
// } // }
// //
// box.Start() → void // box.Start() → void
@ -110,12 +121,10 @@ func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) {
// TODO: Phase 2 implementation // TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate // 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID)) // 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID))
// 3. Bind each method as FunctionTemplate: // 3. Bind Computer interface methods:
// - Exec → sandbox.M().Get(id).Exec(ctx, cmd, opts...) // - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace
// - Stream → sandbox.M().Get(id).Stream(ctx, cmd, opts...) // 4. Bind Box-specific methods:
// - Attach → client.Proxy().URL(ctx, containerID, port, path) → string // - 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()) // - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID())
// - Info → sandbox.M().Get(id).Info(ctx) → JS object // - Info → sandbox.M().Get(id).Info(ctx) → JS object
// - Start → sandbox.M().Get(id).Start(ctx) // - Start → sandbox.M().Get(id).Start(ctx)

View file

@ -4,7 +4,7 @@ import (
"rogchap.com/v8go" "rogchap.com/v8go"
) )
// sbHost: `sandbox.Host(pool?)` → Host // sbHost: `sandbox.Host(pool?)` → Computer (Host)
// //
// Go: Manager.Host(ctx, pool) (*Host, error) // Go: Manager.Host(ctx, pool) (*Host, error)
// //
@ -12,7 +12,7 @@ import (
// //
// pool: string (optional) — pool name; empty = default pool // 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 // Host executes commands on the Tai host machine (no container). Available only
// when the pool's Tai server exposes HostExec gRPC. // 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() } // 1. pool := ""; if len(info.Args()) > 0 && info.Args()[0].IsString() { pool = info.Args()[0].String() }
// 2. host, err := sandbox.M().Host(ctx, pool) // 2. host, err := sandbox.M().Host(ctx, pool)
// 3. if err != nil { throw in V8 } // 3. if err != nil { throw in V8 }
// 4. Return NewHostObject(v8ctx, host.Pool()) // 4. Return NewComputerObject(v8ctx, host)
return v8go.Undefined(info.Context().Isolate()) 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. // 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) // # 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: // JS args:
// cmd: string → cmd string // cmd: string[] → cmd []string
// args: string[] → args []string // options: { → ExecOption
// options: { → HostExecOption // workdir: string, → WithWorkDir(dir)
// workdir: string, → WithHostWorkDir(dir) // env: object, → WithEnv(map[string]string)
// env: object, → WithHostEnv(map[string]string) // stdin: string, → WithStdin([]byte)
// stdin: string, → WithHostStdin([]byte) // timeout: number, → WithTimeout(ms → time.Duration)
// timeout: number, → WithHostTimeout(ms int64) // max_output: number → WithMaxOutput(bytes int64)
// max_output: number → WithHostMaxOutput(bytes int64)
// } // }
// JS returns: { // JS returns: {
// exit_code: number, ← HostExecResult.ExitCode // exit_code: number, ← ExecResult.ExitCode
// stdout: string (UTF-8), ← HostExecResult.Stdout // stdout: string, ← ExecResult.Stdout
// stderr: string (UTF-8), ← HostExecResult.Stderr // stderr: string, ← ExecResult.Stderr
// duration_ms: number, HostExecResult.DurationMs // duration_ms: number, ← ExecResult.DurationMs
// error: string, HostExecResult.Error // error: string, ← ExecResult.Error
// truncated: boolean HostExecResult.Truncated // 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. // Blocks until the process exits. The last argument must be a JS function.
// Callback signature: function(type, data) // Callback signature: function(type, data)
@ -67,21 +69,34 @@ func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value {
// type = "stderr" → data is string (chunk) // type = "stderr" → data is string (chunk)
// type = "exit" → data is number (exit code) // type = "exit" → data is number (exit code)
// //
// JS args: // host.VNC() → string
// cmd: string
// args: string[]
// options: { workdir, env, stdin, timeout, max_output } (optional, same as host.Exec)
// callback: function(type, data)
// //
// 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: // host.Proxy(port, path?) → string
// workspace.NewFSObject(v8ctx, sessionID) //
// and returns the resulting WorkspaceFS object directly. // 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) { func NewHostObject(v8ctx *v8go.Context, pool string) (*v8go.Value, error) {
// TODO: Phase 2 implementation // TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate // 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only property: pool // 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 return nil, nil
} }

View file

@ -11,7 +11,7 @@
// const box = sandbox.Get(id) // → Box // const box = sandbox.Get(id) // → Box
// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[] // const list = sandbox.List({ owner: "u1" }) // → BoxInfo[]
// sandbox.Delete(id) // → void // 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 node = sandbox.GetNode("tai-abc123") // → NodeInfo | null
// const all = sandbox.Nodes() // → NodeInfo[] // const all = sandbox.Nodes() // → NodeInfo[]
// const team = sandbox.NodesByTeam("t-001") // → NodeInfo[] // const team = sandbox.NodesByTeam("t-001") // → NodeInfo[]
@ -23,7 +23,7 @@
// sandbox.Get(id) → Manager.Get(ctx, id) → Box // sandbox.Get(id) → Manager.Get(ctx, id) → Box
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[] // sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[]
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void // 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.GetNode(id) → registry.Global().Get(id) → NodeInfo | null
// sandbox.Nodes() → registry.Global().List() → NodeInfo[] // sandbox.Nodes() → registry.Global().List() → NodeInfo[]
// sandbox.NodesByTeam(t)→ registry.Global().ListByTeam(t) → NodeInfo[] // sandbox.NodesByTeam(t)→ registry.Global().ListByTeam(t) → NodeInfo[]

View file

@ -1,12 +1,64 @@
package sandbox package sandbox
import ( import (
"context"
"io" "io"
"time" "time"
"github.com/yaoapp/yao/tai" "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 type LifecyclePolicy string
const ( const (
@ -26,7 +78,7 @@ type Pool struct {
MaxTotal int MaxTotal int
IdleTimeout time.Duration IdleTimeout time.Duration
MaxLifetime time.Duration MaxLifetime time.Duration
StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout StopTimeout time.Duration
} }
type PoolInfo struct { type PoolInfo struct {
@ -40,6 +92,10 @@ type PoolInfo struct {
MaxLifetime time.Duration MaxLifetime time.Duration
} }
// ---------------------------------------------------------------------------
// Create / List options
// ---------------------------------------------------------------------------
type PortMapping struct { type PortMapping struct {
ContainerPort int ContainerPort int
HostPort int HostPort int
@ -62,12 +118,11 @@ type CreateOptions struct {
Ports []PortMapping Ports []PortMapping
Policy LifecyclePolicy Policy LifecyclePolicy
IdleTimeout time.Duration IdleTimeout time.Duration
StopTimeout time.Duration
StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout WorkspaceID string
MountMode string
WorkspaceID string // workspace to mount; empty = no workspace MountPath string
MountMode string // "rw" (default) or "ro"
MountPath string // container path; default "/workspace"
} }
type ListOptions struct { type ListOptions struct {
@ -76,38 +131,52 @@ type ListOptions struct {
Labels map[string]string Labels map[string]string
} }
// ---------------------------------------------------------------------------
// Unified ExecOption / ExecResult / ExecStream
// ---------------------------------------------------------------------------
type execConfig struct { type execConfig struct {
WorkDir string WorkDir string
Env map[string]string Env map[string]string
Timeout time.Duration Timeout time.Duration
Stdin []byte
MaxOutputBytes int64
} }
// ExecOption configures an Exec or Stream call on any Computer.
type ExecOption func(*execConfig) type ExecOption func(*execConfig)
func WithWorkDir(dir string) ExecOption { func WithWorkDir(dir string) ExecOption {
return func(c *execConfig) { return func(c *execConfig) { c.WorkDir = dir }
c.WorkDir = dir
}
} }
func WithEnv(env map[string]string) ExecOption { func WithEnv(env map[string]string) ExecOption {
return func(c *execConfig) { return func(c *execConfig) { c.Env = env }
c.Env = env
}
} }
func WithTimeout(timeout time.Duration) ExecOption { func WithTimeout(timeout time.Duration) ExecOption {
return func(c *execConfig) { return func(c *execConfig) { c.Timeout = timeout }
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 { type ExecResult struct {
ExitCode int ExitCode int
Stdout string Stdout string
Stderr string Stderr string
DurationMs int64
Error string
Truncated bool
} }
// ExecStream provides real-time streaming I/O for a running command.
type ExecStream struct { type ExecStream struct {
Stdout io.ReadCloser Stdout io.ReadCloser
Stderr io.ReadCloser Stderr io.ReadCloser
@ -116,6 +185,10 @@ type ExecStream struct {
Cancel func() Cancel func()
} }
// ---------------------------------------------------------------------------
// Attach (Box-specific, not part of Computer interface)
// ---------------------------------------------------------------------------
type attachConfig struct { type attachConfig struct {
Protocol string Protocol string
Path string Path string
@ -125,26 +198,20 @@ type attachConfig struct {
type AttachOption func(*attachConfig) type AttachOption func(*attachConfig)
func WithProtocol(protocol string) AttachOption { func WithProtocol(protocol string) AttachOption {
return func(c *attachConfig) { return func(c *attachConfig) { c.Protocol = protocol }
c.Protocol = protocol
}
} }
func WithPath(path string) AttachOption { func WithPath(path string) AttachOption {
return func(c *attachConfig) { return func(c *attachConfig) { c.Path = path }
c.Path = path
}
} }
func WithHeaders(headers map[string]string) AttachOption { func WithHeaders(headers map[string]string) AttachOption {
return func(c *attachConfig) { return func(c *attachConfig) { c.Headers = headers }
c.Headers = headers
}
} }
// ImagePullOptions configures an image pull operation. // ImagePullOptions configures an image pull operation.
type ImagePullOptions struct { type ImagePullOptions struct {
Auth *RegistryAuth // nil = anonymous / public Auth *RegistryAuth
} }
// RegistryAuth holds credentials for a private container registry. // RegistryAuth holds credentials for a private container registry.
@ -162,6 +229,7 @@ type ServiceConn struct {
Close func() error Close func() error
} }
// BoxInfo is a snapshot of a Box's runtime state (used by Manager.List).
type BoxInfo struct { type BoxInfo struct {
ID string ID string
ContainerID string ContainerID string
@ -176,53 +244,3 @@ type BoxInfo struct {
ProcessCount int ProcessCount int
VNC bool 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 }
}