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:
parent
18bcf22089
commit
4d4b03be96
8 changed files with 581 additions and 292 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue