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 │
│ └── 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.

View file

@ -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()

View file

@ -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 }

View file

@ -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...)
}

View file

@ -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)

View file

@ -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
}

View file

@ -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[]

View file

@ -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 }
}