ci: update Tai to 1.2.0 with new default ports and fix documentation

- Update CI (unit-test.yml, pr-test.yml) to use yaoapp/tai:1.2.0
  with new default ports (gRPC:19100, HTTP:8099, VNC:16080, Docker:12375)
- Add explicit 0.0.0.0 bind for containerized Tai instances
- Fix sandbox/v2 grpc.go default port fallback (9100 → 19100)
- Fix tai/tunnel/proxy.go fallback ports (8080→8099, 6080→16080)
- Sync tai SDK and sandbox/v2 documentation with implementation
- Add new docs: api.md, registry.md, tunnel.md

Made-with: Cursor
This commit is contained in:
Max 2026-03-08 11:33:56 +08:00
parent 1e454a38ad
commit d70694b7ac
25 changed files with 798 additions and 141 deletions

View file

@ -1071,7 +1071,7 @@ jobs:
- name: Pull Test Images
run: |
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull yaoapp/tai:1.2.0
docker pull alpine:latest
- name: Install k3d
@ -1087,26 +1087,27 @@ jobs:
run: |
docker run -d --name tai-docker \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
yaoapp/tai:latest
-p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \
yaoapp/tai:1.2.0 server \
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
if curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1; then
echo "Tai Docker HTTP ready"; break
fi
echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || {
curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1 || {
echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
if nc -z 127.0.0.1 19100 2>/dev/null; then
echo "Tai Docker gRPC ready"; break
fi
echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9100 2>/dev/null || {
nc -z 127.0.0.1 19100 2>/dev/null || {
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
}
@ -1136,44 +1137,48 @@ jobs:
docker run -d --name tai-k8s \
--network k3d-tai-test \
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-p 19101:19100 -p 8100:8099 -p 6443:16443 -p 16081:16080 \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest
yaoapp/tai:1.2.0 server \
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then
if curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1; then
echo "Tai K8s HTTP ready"; break
fi
echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || {
curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1 || {
echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9101 2>/dev/null; then
if nc -z 127.0.0.1 19101 2>/dev/null; then
echo "Tai K8s gRPC ready"; break
fi
echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9101 2>/dev/null || {
nc -z 127.0.0.1 19101 2>/dev/null || {
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Run Sandbox V2 CI Tests (tai + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_DOCKER: "tcp://127.0.0.1:12375"
TAI_TEST_GRPC_PORT: "19100"
TAI_TEST_HTTP_PORT: "8099"
TAI_TEST_VNC_PORT: "16080"
TAI_TEST_DOCKER_PORT: "12375"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_K8S_GRPC_PORT: "9101"
TAI_TEST_K8S_GRPC_PORT: "19101"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_HOST_IP: "172.17.0.1"
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:19100"
SANDBOX_TEST_IMAGE: "yaoapp/tai-sandbox-test:latest"
run: make unit-test-sandbox-v2

View file

@ -782,7 +782,7 @@ jobs:
- name: Pull Test Images
run: |
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull yaoapp/tai:1.2.0
docker pull alpine:latest
- name: Install k3d
@ -798,26 +798,27 @@ jobs:
run: |
docker run -d --name tai-docker \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
yaoapp/tai:latest
-p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \
yaoapp/tai:1.2.0 server \
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
if curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1; then
echo "Tai Docker HTTP ready"; break
fi
echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || {
curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1 || {
echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
if nc -z 127.0.0.1 19100 2>/dev/null; then
echo "Tai Docker gRPC ready"; break
fi
echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9100 2>/dev/null || {
nc -z 127.0.0.1 19100 2>/dev/null || {
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
}
@ -847,45 +848,49 @@ jobs:
docker run -d --name tai-k8s \
--network k3d-tai-test \
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-p 19101:19100 -p 8100:8099 -p 6443:16443 -p 16081:16080 \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest
yaoapp/tai:1.2.0 server \
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then
if curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1; then
echo "Tai K8s HTTP ready"; break
fi
echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || {
curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1 || {
echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9101 2>/dev/null; then
if nc -z 127.0.0.1 19101 2>/dev/null; then
echo "Tai K8s gRPC ready"; break
fi
echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9101 2>/dev/null || {
nc -z 127.0.0.1 19101 2>/dev/null || {
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Run Sandbox V2 CI Tests (tai + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_DOCKER: "tcp://127.0.0.1:12375"
TAI_TEST_GRPC_PORT: "19100"
TAI_TEST_HTTP_PORT: "8099"
TAI_TEST_VNC_PORT: "16080"
TAI_TEST_DOCKER_PORT: "12375"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_K8S_GRPC_PORT: "9101"
TAI_TEST_K8S_GRPC_PORT: "19101"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_HOST_IP: "172.17.0.1"
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:19100"
SANDBOX_TEST_IMAGE: "yaoapp/tai-sandbox-test:latest"
run: make unit-test-sandbox-v2
- name: Codecov Report

View file

@ -26,7 +26,7 @@ err := sandbox.Init(sandbox.Config{
Pool: []sandbox.Pool{
{
Name: "docker",
Addr: "tai://192.168.1.10:9100",
Addr: "tai://192.168.1.10:19100",
MaxPerUser: 5,
MaxTotal: 20,
IdleTimeout: 30 * time.Minute,
@ -247,7 +247,7 @@ Registers a new pool at runtime.
```go
err := sandbox.M().AddPool(ctx, sandbox.Pool{
Name: "k8s-gpu",
Addr: "tai://10.0.0.5:9100",
Addr: "tai://10.0.0.5:19100",
MaxTotal: 10,
})
```
@ -423,7 +423,7 @@ Returns the VNC WebSocket URL for the sandbox (requires `VNC: true` at creation)
```go
url, err := box.VNC(ctx)
// url = "ws://tai-host:6080/websockify?container=xxx"
// url = "ws://tai-host:16080/vnc/xxx/ws"
```
### Proxy
@ -436,7 +436,7 @@ Returns the HTTP proxy URL for a service on the given port.
```go
url, err := box.Proxy(ctx, 3000, "/api/health")
// url = "http://tai-host:8080/proxy/container-id/3000/api/health"
// url = "http://tai-host:8099/container-id:3000/api/health"
```
### Workspace
@ -581,7 +581,7 @@ func WithTimeout(timeout time.Duration) ExecOption
## AttachOption Functions
```go
func WithProtocol(protocol string) AttachOption // "ws" (default), "tcp"
func WithProtocol(protocol string) AttachOption // "ws" (default) or "sse"
func WithPath(path string) AttachOption // URL path on the target service
func WithHeaders(headers map[string]string) AttachOption
```
@ -802,6 +802,6 @@ Builds environment variables injected into sandbox containers:
| `YAO_GRPC_ADDR` | gRPC server address (auto-derived) |
Address derivation logic:
- `tai://host:port``host:port`
- `tai://host:port``host:port` (default port 19100 when omitted)
- `tunnel://...``127.0.0.1:<grpcPort>`
- Local/default → `127.0.0.1:<grpcPort>`

View file

@ -63,7 +63,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m
taiHost := u.Hostname()
taiPort := u.Port()
if taiPort == "" {
taiPort = "9100"
taiPort = "19100"
}
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort)

View file

@ -25,8 +25,8 @@ func TestBuildGRPCEnvRemote(t *testing.T) {
pool := &sandbox.Pool{Name: "gpu", Addr: "tai://gpu-server"}
env := sandbox.BuildGRPCEnv(pool, "sb-002", "access", "refresh", 9099)
if env["YAO_GRPC_ADDR"] != "gpu-server:9100" {
t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:9100", env["YAO_GRPC_ADDR"])
if env["YAO_GRPC_ADDR"] != "gpu-server:19100" {
t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:19100", env["YAO_GRPC_ADDR"])
}
}

View file

@ -55,7 +55,7 @@ func purgeStaleContainers() {
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if kubeconfig != "" {
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100))
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
opts := []tai.Option{
tai.K8s,
tai.WithKubeConfig(kubeconfig),
@ -128,7 +128,7 @@ func testPools() []poolConfig {
if kubeconfig == "" {
return pools
}
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100))
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
addr := fmt.Sprintf("tai://%s:%d", host, grpcPort)
opts := []tai.Option{
tai.K8s,
@ -176,7 +176,7 @@ func hostExecTargets() []hostExecTarget {
targets = append(targets, hostExecTarget{Name: "remote", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100))
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
targets = append(targets, hostExecTarget{Name: "k8s", Addr: fmt.Sprintf("%s:%d", host, grpcPort)})
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" {

View file

@ -50,7 +50,7 @@ func TestHandleRegister_Success(t *testing.T) {
MachineID: "m-001",
Version: "0.2.0",
Addr: "192.168.1.100",
Ports: map[string]int{"grpc": 9100, "http": 8080},
Ports: map[string]int{"grpc": 19100, "http": 8099},
Capabilities: map[string]bool{"docker": true, "host_exec": false},
System: registry.SystemInfo{
OS: "linux", Arch: "amd64", Hostname: "docker-host-01", NumCPU: 16,

View file

@ -1,6 +1,6 @@
# Tai SDK
Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. Provides unified access to container sandboxes, volume IO, HTTP proxy, and VNC routing — transparently working in both **Local** (direct Docker) and **Remote** (via Tai server) modes.
Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. Provides unified access to container sandboxes, volume IO, HTTP proxy, and VNC routing — transparently working in **Local** (direct Docker), **Remote** (via Tai server), and **Tunnel** (via Yao WebSocket tunnel) modes.
## Package Layout
@ -12,6 +12,11 @@ Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. P
| `workspace` | `github.com/yaoapp/yao/tai/workspace` | `fs.FS`-compatible filesystem over Volume |
| `proxy` | `github.com/yaoapp/yao/tai/proxy` | HTTP reverse proxy URL resolution |
| `vnc` | `github.com/yaoapp/yao/tai/vnc` | VNC WebSocket URL resolution |
| `registry` | `github.com/yaoapp/yao/tai/registry` | In-memory Tai node registry (direct + tunnel) |
| `api` | `github.com/yaoapp/yao/tai/api` | HTTP handlers for node registration/heartbeat |
| `tunnel` | `github.com/yaoapp/yao/tai/tunnel` | WebSocket tunnel server (control + data + proxy) |
| `hostexec/pb` | `github.com/yaoapp/yao/tai/hostexec/pb` | HostExec gRPC client (host command execution) |
| `serverinfo/pb` | `github.com/yaoapp/yao/tai/serverinfo/pb` | ServerInfo gRPC client (port/capability discovery) |
## Quick Start
@ -47,52 +52,71 @@ fmt.Println(result.Stdout) // "hello\n"
c, err := tai.New("tai://192.168.1.100", tai.K8s,
tai.WithKubeConfig("/path/to/kubeconfig.yml"),
tai.WithNamespace("default"),
tai.WithPorts(tai.Ports{K8s: 6443}),
tai.WithPorts(tai.Ports{K8s: 16443}),
)
defer c.Close()
```
### Tunnel Mode (via Yao WebSocket tunnel)
```go
// Requires a running Yao server with the Tai node registered via tunnel.
// The taiID is the node's identifier in the registry.
c, err := tai.New("tunnel://tai-abc123")
defer c.Close()
```
## Address Protocols
| Address | Mode | Description |
|---------|------|-------------|
| `""` | Local | Platform default Docker socket |
| `"local"` | Local | Platform default Docker socket |
| `"127.0.0.1"` / `"localhost"` / `"::1"` | Local | Auto-detected as local Docker |
| `unix:///var/run/docker.sock` | Local | Explicit Unix socket |
| `tcp://host:port` | Local | Explicit TCP Docker daemon |
| `npipe:////./pipe/docker_engine` | Local | Windows named pipe |
| `docker://host:port` | Local | Docker scheme |
| `tai://host` | Remote | Connect via Tai server |
| `tai://host` | Remote | Connect via Tai server (gRPC default 19100) |
| `tai://host:port` | Remote | Connect via Tai server on custom gRPC port |
| `tunnel://tai-id` | Tunnel | Connect via Yao WebSocket tunnel |
| `192.168.x.x` (non-local IP) | Remote | Auto-prepends `tai://` |
## Options
| Option | Description | Default |
|--------|-------------|---------|
| `WithPorts(Ports{...})` | Override Tai service ports | gRPC=9100, HTTP=8080, VNC=6080 |
| `WithPorts(Ports{...})` | Override Tai service ports (takes precedence over ServerInfo) | gRPC=19100, HTTP=8099, VNC=16080 |
| `WithHTTPClient(*http.Client)` | Custom HTTP client for proxy/VNC | `http.DefaultClient` |
| `WithDataDir(path)` | Volume storage root (Local mode) | `/tmp/tai-volumes` |
| `WithKubeConfig(path)` | Kubeconfig file path (K8s mode, **required**) | - |
| `WithNamespace(ns)` | K8s namespace | `"default"` |
| `WithVolume(vol)` | Inject custom Volume implementation (testing) | - |
## Default Ports
| Service | Port | Description |
|---------|------|-------------|
| gRPC | 9100 | Volume IO + Gateway |
| HTTP | 8080 | HTTP reverse proxy |
| VNC | 6080 | VNC WebSocket router |
| Docker | 2375 | Docker API proxy |
| K8s | 6443 | Kubernetes API proxy |
| gRPC | 19100 | Volume IO + Gateway + ServerInfo + HostExec |
| HTTP | 8099 | HTTP reverse proxy |
| VNC | 16080 | VNC WebSocket router |
| Docker | 12375 | Docker API proxy |
| K8s | 16443 | Kubernetes API proxy |
Ports are auto-discovered via Tai's `ServerInfo.GetInfo` gRPC call. Values set via `WithPorts` take precedence over server-reported values.
## Client API
```go
c.Volume() // volume.Volume
c.Workspace(sessionID) // workspace.FS
c.Sandbox() // sandbox.Sandbox
c.Proxy() // proxy.Proxy
c.VNC() // vnc.VNC
c.IsLocal() // bool
c.Close() // error
c.Volume() // volume.Volume — file IO (never nil)
c.Workspace(sessionID) // workspace.FS — fs.FS over Volume
c.DataDir() // string — host-side data directory (Local mode only)
c.Sandbox() // sandbox.Sandbox — container lifecycle (nil if host-exec-only)
c.Image() // sandbox.Image — image management (nil if host-exec-only)
c.Proxy() // proxy.Proxy — HTTP reverse proxy (nil if host-exec-only)
c.VNC() // vnc.VNC — VNC WebSocket (nil if host-exec-only)
c.HostExec() // hepb.HostExecClient — host command execution (nil in local mode)
c.IsLocal() // bool — true for local mode (docker/unix/tcp/npipe/local)
c.Close() // error — releases all resources
```
## Runtime Constants
@ -102,10 +126,37 @@ tai.Docker // default — use Docker runtime via Tai
tai.K8s // use Kubernetes runtime via Tai
```
## Yao gRPC Compatibility
The `tai` package re-exports Yao gRPC helpers for backward compatibility:
```go
tai.NewTokenManagerFromEnv() // *TokenManager from env vars
tai.NewTokenManager(access, refresh, sandboxID)
tai.NewYaoClientFromEnv() // *YaoClient from env vars
tai.DialYao(addr, tm) // connect to Yao gRPC
tai.Run(ctx, client, process, args, timeout) // execute Yao process
tai.Shell(ctx, client, cmd, args, env, timeout) // execute shell command
tai.HeartbeatLoop(ctx, client, sandboxID) // periodic heartbeat (blocks)
```
New code should use `grpc/client` directly. These wrappers exist for sandbox/container code that imports `tai`.
## Capabilities
When connecting to a remote Tai server, the client calls `ServerInfo.GetInfo` to discover:
- **Ports**: actual listening ports (http, docker, vnc, k8s)
- **Capabilities**: `docker`, `k8s`, `host_exec`
If no usable capabilities are found, `New()` returns an error. Remote mode checks `docker`/`k8s`/`host_exec`; Tunnel mode checks `docker`/`host_exec` (K8s is not supported over tunnel).
## Sub-Package Documentation
- [sandbox.md](sandbox.md) — Container lifecycle management
- [sandbox.md](sandbox.md) — Container lifecycle & Image management
- [volume.md](volume.md) — File IO and sync
- [workspace.md](workspace.md) — fs.FS-compatible filesystem
- [proxy.md](proxy.md) — HTTP reverse proxy
- [vnc.md](vnc.md) — VNC WebSocket routing
- [registry.md](registry.md) — Tai node registry (direct + tunnel)
- [api.md](api.md) — HTTP registration API
- [tunnel.md](tunnel.md) — WebSocket tunnel handlers

135
tai/docs/api.md Normal file
View file

@ -0,0 +1,135 @@
# Package `api`
HTTP handlers for Tai node registration, heartbeat, and unregistration. Built on [Gin](https://github.com/gin-gonic/gin), these handlers are mounted on the Yao server to allow remote Tai instances to register themselves.
## Routes
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `POST` | `/tai-nodes/register` | `HandleRegister` | Register a Tai node |
| `POST` | `/tai-nodes/heartbeat` | `HandleHeartbeat` | Update heartbeat timestamp |
| `DELETE` | `/tai-nodes/register/:tai_id` | `HandleUnregister` | Remove a Tai node |
All endpoints require a `Bearer` token in the `Authorization` header. Tokens are validated via the Yao OAuth service.
## Authentication
```
Authorization: Bearer <access_token>
```
The token is validated against `oauth.OAuth.AuthenticateToken()`. On success, an `AuthInfo` is extracted containing `Subject`, `UserID`, `ClientID`, `Scope`, `TeamID`, and `TenantID`. The `ClientID` is used for ownership checks on heartbeat and unregister.
## Endpoints
### POST /tai-nodes/register
Registers a new Tai node in the global registry.
**Request Body:**
```json
{
"tai_id": "tai-abc123",
"machine_id": "m-001",
"version": "1.2.0",
"addr": "192.168.1.100",
"ports": {"grpc": 19100, "http": 8099, "vnc": 16080, "docker": 12375},
"capabilities": {"docker": true, "host_exec": true},
"system": {
"os": "linux",
"arch": "amd64",
"hostname": "docker-host-01",
"num_cpu": 16,
"total_mem": 34359738368
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tai_id` | string | yes | Unique identifier for this Tai instance |
| `machine_id` | string | no | Host machine identifier |
| `version` | string | no | Tai version string |
| `addr` | string | no | Reachable address of the Tai server |
| `ports` | map[string]int | no | Service ports (grpc, http, vnc, docker, k8s) |
| `capabilities` | map[string]bool | no | Supported features (docker, k8s, host_exec) |
| `system` | object | no | Host system information |
**Response (200):**
```json
{
"status": "registered",
"tai_id": "tai-abc123",
"remote_ip": "203.0.113.50"
}
```
**Errors:**
| Code | Condition |
|------|-----------|
| 400 | Missing `tai_id` or invalid JSON body |
| 401 | Missing or invalid Bearer token |
| 500 | Registry not initialized |
### POST /tai-nodes/heartbeat
Updates the `LastPing` timestamp for a registered node. The node's `ClientID` must match the token's `ClientID`.
**Request Body:**
```json
{
"tai_id": "tai-abc123"
}
```
**Response (200):**
```json
{
"status": "ok"
}
```
**Errors:**
| Code | Condition |
|------|-----------|
| 400 | Missing `tai_id` or invalid JSON body |
| 401 | Missing or invalid Bearer token |
| 403 | `tai_id` belongs to a different client |
| 404 | `tai_id` not found in registry |
| 500 | Registry not initialized |
### DELETE /tai-nodes/register/:tai_id
Removes a registered node. The node's `ClientID` must match the token's `ClientID`.
**Response (200):**
```json
{
"status": "unregistered"
}
```
**Errors:**
| Code | Condition |
|------|-----------|
| 400 | Missing `tai_id` path parameter |
| 401 | Missing or invalid Bearer token |
| 403 | `tai_id` belongs to a different client |
| 404 | `tai_id` not found in registry |
| 500 | Registry not initialized |
## Node Mode
Nodes registered via this HTTP API are marked with `Mode: "direct"`. This means the Yao server can reach the Tai instance directly over the network. For tunnel-mode nodes (registered via WebSocket), see [registry.md](registry.md).
## Health Check
The registry runs a background health checker (started via `Registry.StartHealthCheck`). Direct-mode nodes that miss heartbeats beyond the configured timeout are marked `"offline"`. Nodes that remain offline longer than the cleanup threshold are automatically unregistered.

View file

@ -7,6 +7,7 @@ HTTP reverse proxy URL resolution. Resolves service URLs for containers so that
```go
type Proxy interface {
URL(ctx context.Context, containerID string, port int, path string) (string, error)
Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error)
Healthz(ctx context.Context) error
}
```
@ -15,8 +16,9 @@ type Proxy interface {
| Implementation | Constructor | Mode | URL Pattern |
|----------------|-------------|------|-------------|
| **Remote** | `NewRemote(host, port, hc)` | Via Tai HTTP proxy | `http://tai-host:8080/{containerID}:{port}/{path}` |
| **Remote** | `NewRemote(host, port, hc)` | Via Tai HTTP proxy | `http://tai-host:8099/{containerID}:{port}/{path}` |
| **Local** | `NewLocal(sb)` | Direct host port lookup | `http://127.0.0.1:{hostPort}/{path}` |
| **Tunnel** | `NewTunnel(taiID, yaoBase)` | Via Yao reverse proxy | `{yaoBase}/tai/{taiID}/proxy/{containerID}:{port}/{path}` |
## Constructors
@ -29,7 +31,7 @@ func NewRemote(host string, port int, hc *http.Client) Proxy
Creates a Proxy that routes through Tai's HTTP reverse proxy. URLs are constructed by combining the Tai server address with the container ID and port.
- `host` — Tai server hostname/IP
- `port` — Tai HTTP proxy port (default 8080)
- `port` — Tai HTTP proxy port (default 8099)
- `hc` — custom HTTP client, `nil` uses `http.DefaultClient`
### NewLocal
@ -42,6 +44,17 @@ Creates a Proxy that resolves URLs by inspecting the container's port mappings v
Returns an error if the requested port is not mapped.
### NewTunnel
```go
func NewTunnel(taiID, yaoBase string) Proxy
```
Creates a Proxy that routes through Yao's HTTP reverse proxy for tunnel-mode connections.
- `taiID` — the Tai node identifier in the registry
- `yaoBase` — the Yao server base URL (e.g. `"http://yao-server:5099"`)
## Methods
### URL
@ -53,11 +66,44 @@ URL(ctx context.Context, containerID string, port int, path string) (string, err
Resolves an HTTP URL to reach a service running on `port` inside the given container.
**Remote example:** container `abc123` port `3000` path `/api/health`
`http://tai-host:8080/abc123:3000/api/health`
`http://tai-host:8099/abc123:3000/api/health`
**Local example:** container `abc123` port `3000` mapped to host port `32768`
`http://127.0.0.1:32768/api/health`
### Connect
```go
Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error)
```
Establishes a persistent connection to a container service. Supports WebSocket and SSE protocols.
### ConnectOptions
```go
type ConnectOptions struct {
Port int // container port
Path string // URL path (e.g. "/ws" or "/events")
Protocol string // "ws" or "sse"
}
```
### Connection
```go
type Connection struct {
Messages <-chan []byte // incoming data; closed when connection ends
Send func(data []byte) error // write data (only valid for "ws" protocol)
Close func() error // terminate the connection
}
```
| Protocol | Messages | Send | Description |
|----------|----------|------|-------------|
| `"ws"` | WebSocket messages | write to WS | Full-duplex WebSocket |
| `"sse"` | SSE `data:` lines | returns error | Read-only Server-Sent Events |
### Healthz
```go
@ -68,6 +114,7 @@ Checks the health of the proxy backend.
- **Remote**: sends `GET /healthz` to the Tai HTTP proxy server
- **Local**: always returns `nil` (no external dependency)
- **Tunnel**: always returns `nil`
## Example
@ -83,4 +130,23 @@ resp, _ := http.Get(url)
if err := c.Proxy().Healthz(ctx); err != nil {
log.Fatal("Tai HTTP proxy is down:", err)
}
// WebSocket connection to a service
conn, _ := c.Proxy().Connect(ctx, containerID, proxy.ConnectOptions{
Port: 8080, Path: "/ws", Protocol: "ws",
})
defer conn.Close()
conn.Send([]byte(`{"action":"ping"}`))
for msg := range conn.Messages {
fmt.Println(string(msg))
}
// SSE event stream
conn, _ = c.Proxy().Connect(ctx, containerID, proxy.ConnectOptions{
Port: 8080, Path: "/events", Protocol: "sse",
})
defer conn.Close()
for msg := range conn.Messages {
fmt.Println("event:", string(msg))
}
```

178
tai/docs/registry.md Normal file
View file

@ -0,0 +1,178 @@
# Package `registry`
In-memory registry for Tai nodes. Manages both **direct** (network-reachable) and **tunnel** (WebSocket-bridged) connections. Used server-side by Yao to track all connected Tai instances.
## Architecture
```
Direct Mode: Yao ── TCP ──> Tai (gRPC/HTTP/Docker/VNC)
Tunnel Mode: Yao <── WS ── Tai (control channel)
Yao <── WS ── Tai (data channels, on-demand)
```
## Types
### TaiNode
```go
type TaiNode struct {
TaiID string
MachineID string
Version string
Auth AuthInfo
System SystemInfo
Mode string // "direct" | "tunnel"
Addr string // direct: "tai-host"; tunnel: empty
YaoBase string // tunnel: Yao server base URL
Ports map[string]int // {"grpc":19100, "http":8099, ...}
Capabilities map[string]bool // {"docker":true, "host_exec":true}
ControlConn *websocket.Conn // tunnel: WS control channel
Status string // "online" | "offline" | "connecting"
ConnectedAt time.Time
LastPing time.Time
PoolName string
}
```
### NodeSnapshot
Read-only copy of `TaiNode` safe to use outside locks. Returned by `Get()` and `List()`.
```go
type NodeSnapshot struct {
TaiID, MachineID, Version string
Auth AuthInfo
System SystemInfo
Mode, Addr, YaoBase string
Ports map[string]int
Capabilities map[string]bool
Status string
ConnectedAt, LastPing time.Time
PoolName string
}
```
### AuthInfo
```go
type AuthInfo struct {
Subject string
UserID string
ClientID string
Scope string
TeamID string
TenantID string
}
```
### SystemInfo
```go
type SystemInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
Hostname string `json:"hostname"`
NumCPU int `json:"num_cpu"`
TotalMem int64 `json:"total_mem,omitempty"`
}
```
## Registry API
### Init / Global
```go
func Init(logger *slog.Logger)
func Global() *Registry
```
`Init` creates the global singleton (once). `Global` returns it (nil before Init).
### Register / Unregister
```go
func (r *Registry) Register(node *TaiNode)
func (r *Registry) Unregister(taiID string)
```
`Register` adds or replaces a node, setting `Status="online"` and recording timestamps. `Unregister` closes all tunnel listeners and the control connection, then removes the node.
### Query
```go
func (r *Registry) Get(taiID string) (*NodeSnapshot, bool)
func (r *Registry) List() []NodeSnapshot
func (r *Registry) ListByTeam(teamID string) []NodeSnapshot
```
### Heartbeat
```go
func (r *Registry) UpdatePing(taiID string)
```
### Health Check
```go
func (r *Registry) StartHealthCheck(done <-chan struct{}, interval, timeout, cleanupAfter time.Duration)
```
Runs a background goroutine that:
1. Marks direct-mode nodes as `"offline"` if `LastPing` exceeds `timeout`
2. Auto-unregisters nodes that stay offline longer than `timeout + cleanupAfter`
## Tunnel API
For tunnel-mode nodes, the registry manages on-demand TCP-over-WebSocket channels.
### RequestChannel
```go
func (r *Registry) RequestChannel(taiID string, targetPort int) (channelID string, result chan net.Conn, err error)
```
Sends an `{"type":"open", "channel_id":"...", "target_port":...}` command to the node's control WebSocket. Returns a channel that receives the `net.Conn` when Tai connects back with the data channel. Times out after 30 seconds.
### AcceptDataChannel
```go
func (r *Registry) AcceptDataChannel(channelID, taiID string, conn net.Conn) error
```
Called when Tai establishes a data WebSocket for a pending channel. Validates `taiID` ownership and delivers the connection to the waiting `RequestChannel` caller.
### WriteControlJSON
```go
func (r *Registry) WriteControlJSON(taiID string, v interface{}) error
```
Thread-safe JSON write to a node's control WebSocket.
### OpenLocalListener
```go
func (r *Registry) OpenLocalListener(taiID string, targetPort int) (net.Listener, error)
```
Creates a `127.0.0.1:0` TCP listener. Every accepted connection is automatically bridged through the tunnel to `targetPort` on the Tai node. Returns the listener so the caller can read `ln.Addr()` to get the ephemeral port.
## Connection Flow (Tunnel)
```
1. Tai → Yao: WebSocket upgrade to GET /ws/tai (Bearer auth)
2. Tai → Yao: sends {"type":"register", "tai_id":"xxx", ...} on WS
3. Yao: Register(node) with Mode="tunnel", ControlConn=ws
4. Yao → Tai: sends {"type":"registered", "tai_id":"xxx"}
5. Client → Yao: tai.New("tunnel://tai-abc123")
6. Yao: OpenLocalListener("tai-abc123", 19100) → 127.0.0.1:54321
7. Yao: grpc.Dial("passthrough:///127.0.0.1:54321") → triggers accept
8. Yao: RequestChannel("tai-abc123", 19100) → sends {"type":"open"} on control WS
9. Tai: receives "open", dials localhost:19100, connects data WS to GET /ws/tai/data/:channel_id
10. Yao: AcceptDataChannel(channelID, taiID, conn) → bridges local TCP ↔ data WS
11. gRPC traffic flows transparently through the tunnel
```
### Keep-alive
Tai sends `{"type":"ping"}` periodically on the control channel. Yao replies `{"type":"pong"}` and updates `LastPing`.

View file

@ -12,17 +12,32 @@ Container lifecycle management. Provides a unified `Sandbox` interface with thre
```go
type Sandbox interface {
Create(ctx context.Context, opts CreateOptions) (id string, err error)
Create(ctx context.Context, opts CreateOptions) (string, error)
Start(ctx context.Context, id string) error
Stop(ctx context.Context, id string, timeout time.Duration) error
Remove(ctx context.Context, id string, force bool) error
Exec(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecResult, error)
ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error)
Inspect(ctx context.Context, id string) (*ContainerInfo, error)
List(ctx context.Context, opts ListOptions) ([]ContainerInfo, error)
Close() error
}
```
### StreamHandle
```go
type StreamHandle struct {
Stdin io.WriteCloser
Stdout io.Reader
Stderr io.Reader
Wait func() (int, error) // blocks until exec finishes, returns exit code
Cancel func() // aborts the exec process
}
```
`ExecStream` provides real-time I/O access to a running exec process. Unlike `Exec` which collects all output, `ExecStream` returns immediately with readers/writers for interactive use.
## Constructors
### NewLocal
@ -44,7 +59,7 @@ Pings the daemon on creation; returns an error if unreachable.
func NewDocker(addr string) (Sandbox, error)
```
Connects to Docker Engine API through Tai's Docker proxy. `addr` should be `"tcp://tai-host:2375"`.
Connects to Docker Engine API through Tai's Docker proxy. `addr` should be `"tcp://tai-host:12375"`.
### NewK8s
@ -77,8 +92,10 @@ type CreateOptions struct {
WorkingDir string // working directory
Memory int64 // memory limit in bytes, 0 = no limit
CPUs float64 // CPU limit, 0 = no limit
VNC bool // enable VNC port mapping (Local only)
VNC bool // enable VNC port mapping (Local and Docker modes)
Ports []PortMapping // port mappings (Docker only)
Labels map[string]string // container/pod labels for discovery and management
User string // container user, e.g. "1000:1000" or "sandbox"
}
```
@ -97,13 +114,14 @@ type PortMapping struct {
```go
type ContainerInfo struct {
ID string // container/pod ID
Name string // container/pod name
Image string // image name
Status string // "created", "running", "exited", "removing" (Docker)
// "Pending", "Running", "Succeeded", "Failed" (K8s)
IP string // container/pod IP address
Ports []PortMapping // mapped ports (Docker only)
ID string // container/pod ID
Name string // container/pod name
Image string // image name
Status string // "created", "running", "exited", "removing" (Docker)
// "Pending", "Running", "Succeeded", "Failed" (K8s)
IP string // container/pod IP address
Ports []PortMapping // mapped ports (Docker only)
Labels map[string]string // container/pod labels
}
```
@ -149,16 +167,88 @@ type K8sOption struct {
| Behavior | Docker (Local/Remote) | K8s |
|----------|----------------------|-----|
| `Create` returns | container ID (hash) | pod name |
| `Start` | starts a stopped container | polls until pod leaves Pending (up to 30s) |
| `Start` | starts a stopped container | polls until pod leaves Pending (up to 60s) |
| `Stop` | stops with timeout, container persists | deletes the pod with grace period |
| `Remove(force=true)` | force-removes | deletes with grace period 0 |
| `Exec` | Docker exec API | `kubectl exec` via SPDY |
| `Inspect.Ports` | populated from Docker | always empty |
| `List` | filters by `tai-sdk=true` label | filters by `managed-by=yao-tai-sdk` label |
| `List` | filters only by `opts.Labels` (no auto label) | auto-merges `managed-by=yao-tai-sdk` + `opts.Labels` |
| `Binds` | supported | not supported |
| `VNC` flag | auto port-maps 6080 on macOS/Windows | not applicable |
| `VNC` flag | auto port-maps 6080 and 5900 (all platforms) | not applicable |
## Example
## Image Interface
```go
type Image interface {
Exists(ctx context.Context, ref string) (bool, error)
Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error)
Remove(ctx context.Context, ref string, force bool) error
List(ctx context.Context) ([]ImageInfo, error)
}
```
Accessed via `c.Image()` on the top-level client. Nil when the Tai server has no container runtime.
| Implementation | Constructor | Backend | Notes |
|----------------|-------------|---------|-------|
| **Docker** | `NewDockerImage(cli)` | Docker SDK | Shared by Local and Docker-via-Tai modes |
| **K8s** | `NewK8sImage()` | No-op | Image pulling is handled by kubelet |
### DockerCli Helper
```go
func DockerCli(sb Sandbox) *client.Client
```
Extracts the underlying Docker SDK client from a `Sandbox` (Local or Docker). Returns `nil` for K8s sandboxes. Used internally to construct `NewDockerImage(DockerCli(sb))`.
### Types
```go
type PullOptions struct {
Auth *RegistryAuth // nil = anonymous / public
}
type RegistryAuth struct {
Username string
Password string
Server string // e.g. "ghcr.io", "registry.example.com"
}
type PullProgress struct {
Status string // "Pulling fs layer", "Downloading", "Extracting", "Pull complete", etc.
Layer string // layer digest / short ID
Current int64 // bytes completed
Total int64 // bytes total (0 if unknown)
Error string // non-empty on failure
}
type ImageInfo struct {
ID string
Tags []string
Size int64
Created time.Time
}
```
### Image Example
```go
c, _ := tai.New("tai://192.168.1.100")
defer c.Close()
progress, _ := c.Image().Pull(ctx, "alpine:latest", sandbox.PullOptions{})
for p := range progress {
fmt.Printf("%s %s %d/%d\n", p.Status, p.Layer, p.Current, p.Total)
}
images, _ := c.Image().List(ctx)
for _, img := range images {
fmt.Printf("%s %v\n", img.ID[:12], img.Tags)
}
```
## Sandbox Example
```go
sb, _ := sandbox.NewLocal("")

91
tai/docs/tunnel.md Normal file
View file

@ -0,0 +1,91 @@
# Package `tunnel`
WebSocket tunnel handlers for Tai nodes that cannot be reached directly (e.g. behind NAT/firewall). Provides Gin HTTP handlers mounted on the Yao server.
## Handlers
| Method | Route | Handler | Description |
|--------|-------|---------|-------------|
| `GET` | `/ws/tai` | `HandleControl` | Control channel WebSocket |
| `GET` | `/ws/tai/data/:channel_id` | `HandleData` | Data channel WebSocket |
| `ANY` | `/tai/:taiID/proxy/*path` | `HandleProxy` | HTTP reverse proxy via tunnel |
| `GET` | `/tai/:taiID/vnc/*path` | `HandleVNC` | VNC WebSocket proxy via tunnel |
All WebSocket endpoints require `Authorization: Bearer <token>` header.
## HandleControl
Manages the long-lived control WebSocket for a Tai node.
**Flow:**
1. Authenticate Bearer token via OAuth
2. Upgrade to WebSocket
3. Read `register` message (JSON):
```json
{
"type": "register",
"tai_id": "tai-abc123",
"machine_id": "m-001",
"version": "1.2.0",
"server": "http://yao-server:5099",
"ports": {"grpc": 19100, "http": 8099, "vnc": 16080, "docker": 12375},
"capabilities": {"docker": true, "host_exec": true},
"system": {"os": "linux", "arch": "amd64", "hostname": "host-01", "num_cpu": 8}
}
```
4. Register node in global registry with `Mode="tunnel"`
5. Reply `{"type": "registered", "tai_id": "tai-abc123"}`
6. Loop reading messages:
- `{"type": "ping"}` → update heartbeat, reply `{"type": "pong"}`
7. On disconnect → unregister node
## HandleData
Handles on-demand data channel connections from Tai.
**Flow:**
1. Authenticate Bearer token
2. Extract `:channel_id` from URL
3. Upgrade to WebSocket
4. Wrap WebSocket as `net.Conn` (bidirectional byte bridge)
5. Call `registry.AcceptDataChannel(channelID, clientID, conn)`
The `channel_id` must match a pending `RequestChannel` call. The `clientID` (from token) must match the Tai node that owns the channel.
## HandleProxy
HTTP reverse proxy for tunnel-connected Tai nodes.
**Flow:**
1. Look up Tai node from `:taiID` in registry
2. Get the node's HTTP port (from `node.Ports["http"]`, default 8099)
3. Open a tunnel data channel to that port via `RequestChannel`
4. Forward the incoming HTTP request through the tunnel
5. Read the response and stream it back to the client
## HandleVNC
VNC WebSocket proxy for tunnel-connected Tai nodes.
**Flow:**
1. Look up Tai node from `:taiID` in registry
2. Get the node's VNC port (from `node.Ports["vnc"]`, default 16080)
3. Open a tunnel data channel to that port via `RequestChannel`
4. Upgrade the client connection to WebSocket
5. Bridge client WebSocket ↔ tunnel data channel (binary messages)
## Internal Types
### wsConn
`wsConn` wraps `gorilla/websocket.Conn` to implement `net.Conn` for bidirectional byte bridging. This allows tunnel data channels to be treated as standard TCP connections by the registry's bridge logic.
```go
type wsConn struct { ... }
func (c *wsConn) Read(p []byte) (int, error) // reads WS binary messages
func (c *wsConn) Write(p []byte) (int, error) // writes WS binary messages
func (c *wsConn) Close() error
// Also implements: LocalAddr, RemoteAddr, SetDeadline, SetReadDeadline, SetWriteDeadline
```

View file

@ -15,8 +15,9 @@ type VNC interface {
| Implementation | Constructor | Mode | URL Pattern |
|----------------|-------------|------|-------------|
| **Remote** | `NewRemote(host, port, hc)` | Via Tai VNC router | `ws://tai-host:6080/vnc/{containerID}/ws` |
| **Remote** | `NewRemote(host, port, hc)` | Via Tai VNC router | `ws://tai-host:16080/vnc/{containerID}/ws` |
| **Local** | `NewLocal(sb)` | Direct host port lookup | `ws://127.0.0.1:{hostPort}/ws` |
| **Tunnel** | `NewTunnel(taiID, yaoBase)` | Via Yao reverse proxy | `ws(s)://{yaoHost}/tai/{taiID}/vnc/{containerID}/ws` |
## Constructors
@ -29,7 +30,7 @@ func NewRemote(host string, port int, hc *http.Client) VNC
Creates a VNC that routes through Tai's VNC WebSocket router.
- `host` — Tai server hostname/IP
- `port` — Tai VNC router port (default 6080)
- `port` — Tai VNC router port (default 16080)
- `hc` — custom HTTP client for Ping, `nil` uses `http.DefaultClient`
### NewLocal
@ -42,6 +43,17 @@ Creates a VNC that resolves URLs by inspecting the container's port mappings. Lo
Returns an error if port 6080 is not mapped. On macOS and Windows (Docker Desktop), the Local sandbox automatically maps port 6080 when `CreateOptions.VNC` is `true`.
### NewTunnel
```go
func NewTunnel(taiID, yaoBase string) VNC
```
Creates a VNC that routes through Yao's HTTP reverse proxy for tunnel-mode connections.
- `taiID` — the Tai node identifier in the registry
- `yaoBase` — the Yao server base URL (e.g. `"http://yao-server:5099"`)
## Methods
### URL
@ -52,7 +64,7 @@ URL(ctx context.Context, containerID string) (string, error)
Returns a WebSocket URL for connecting to the container's VNC session.
**Remote:** `ws://tai-host:6080/vnc/abc123/ws`
**Remote:** `ws://tai-host:16080/vnc/abc123/ws`
**Local:** `ws://127.0.0.1:32769/ws`
### Ping
@ -63,8 +75,9 @@ Ping(ctx context.Context, containerID string) error
Checks if the VNC endpoint is reachable by making an HTTP GET request to the WebSocket URL. Useful for verifying that the VNC server inside the container is ready before connecting a client.
- **Remote**: sends GET to `http://tai-host:6080/vnc/{containerID}/ws`
- **Remote**: sends GET to `http://tai-host:16080/vnc/{containerID}/ws`
- **Local**: resolves the host port via Inspect, then sends GET
- **Tunnel**: always returns `nil` (no direct network path to probe)
## Example
@ -90,5 +103,5 @@ for i := 0; i < 10; i++ {
// Get the WebSocket URL for a noVNC client
url, _ := c.VNC().URL(ctx, id)
fmt.Println(url) // ws://192.168.1.100:6080/vnc/desktop/ws
fmt.Println(url) // ws://192.168.1.100:16080/vnc/desktop/ws
```

View file

@ -5,7 +5,7 @@ File IO and directory synchronization. Provides a `Volume` interface with two im
| Implementation | Constructor | Backend | Mode |
|----------------|-------------|---------|------|
| **Local** | `NewLocal(root)` | Direct filesystem | Local |
| **Remote** | `NewRemote(conn)` | gRPC to Tai :9100 | Remote |
| **Remote** | `NewRemote(conn)` | gRPC to Tai :19100 | Remote |
## Interface
@ -33,10 +33,10 @@ All paths are **relative** to the session's workspace root. The `sessionID` iden
### NewLocal
```go
func NewLocal(root string) Volume
func NewLocal(dataDir string) Volume
```
Creates a Volume backed by the local filesystem. Files are stored under `<root>/<sessionID>/`.
Creates a Volume backed by the local filesystem. Files are stored under `<dataDir>/<sessionID>/`.
### NewRemote
@ -44,7 +44,7 @@ Creates a Volume backed by the local filesystem. Files are stored under `<root>/
func NewRemote(conn *grpc.ClientConn) Volume
```
Creates a Volume backed by Tai's gRPC Volume service. The connection should target Tai's gRPC port (default 9100). Uses lz4 compression for `SyncPush`/`SyncPull` bulk transfers.
Creates a Volume backed by Tai's gRPC Volume service. The connection should target Tai's gRPC port (default 19100). Uses lz4 compression for `SyncPush`/`SyncPull` bulk transfers.
## Types

View file

@ -21,7 +21,7 @@ type Proxy interface {
type ConnectOptions struct {
Port int // container port
Path string // URL path (e.g. "/ws" or "/events")
Protocol string // "ws", "sse", or "tcp"
Protocol string // "ws" or "sse"
}
// Connection represents a persistent connection to a container service.

View file

@ -33,7 +33,7 @@ type TaiNode struct {
Mode string // "direct" | "tunnel"
Addr string // direct mode: "tai-host"; tunnel mode: empty
YaoBase string // Yao server base URL reported by Tai (tunnel mode)
Ports map[string]int // {"grpc":9100, "http":8080, "vnc":6080, "docker":2375}
Ports map[string]int // {"grpc":19100, "http":8099, "vnc":16080, "docker":12375}
Capabilities map[string]bool
ControlConn *websocket.Conn

View file

@ -29,7 +29,7 @@ func TestRegister_SetsFieldsAndOnline(t *testing.T) {
MachineID: "m-abc",
Version: "1.0.0",
Mode: "tunnel",
Ports: map[string]int{"grpc": 9100},
Ports: map[string]int{"grpc": 19100},
}
r.Register(node)
@ -117,14 +117,14 @@ func TestSnapshot_DeepCopy(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{
TaiID: "tai-001",
Ports: map[string]int{"grpc": 9100, "http": 8080},
Ports: map[string]int{"grpc": 19100, "http": 8099},
})
snap, _ := r.Get("tai-001")
snap.Ports["grpc"] = 0
snap2, _ := r.Get("tai-001")
if snap2.Ports["grpc"] != 9100 {
if snap2.Ports["grpc"] != 19100 {
t.Error("snapshot modification leaked into registry node")
}
}
@ -165,7 +165,7 @@ func TestWriteControlJSON_NilConn(t *testing.T) {
func TestRequestChannel_NotFound(t *testing.T) {
r := newTestRegistry()
_, _, err := r.RequestChannel("ghost", 9100)
_, _, err := r.RequestChannel("ghost", 19100)
if err == nil {
t.Fatal("expected error for missing node")
}
@ -174,7 +174,7 @@ func TestRequestChannel_NotFound(t *testing.T) {
func TestRequestChannel_DirectMode(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-001", Mode: "direct"})
_, _, err := r.RequestChannel("tai-001", 9100)
_, _, err := r.RequestChannel("tai-001", 19100)
if err == nil {
t.Fatal("expected error for direct-mode node")
}
@ -357,7 +357,7 @@ func TestRequestChannel_Success(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-001", Mode: "tunnel", ControlConn: wsConn})
channelID, resultCh, err := r.RequestChannel("tai-001", 9100)
channelID, resultCh, err := r.RequestChannel("tai-001", 19100)
if err != nil {
t.Fatalf("RequestChannel: %v", err)
}
@ -379,8 +379,8 @@ func TestRequestChannel_Success(t *testing.T) {
if cmd["channel_id"] != channelID {
t.Errorf("cmd channel_id = %v, want %s", cmd["channel_id"], channelID)
}
if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 9100 {
t.Errorf("cmd target_port = %v, want 9100", cmd["target_port"])
if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 19100 {
t.Errorf("cmd target_port = %v, want 19100", cmd["target_port"])
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for open command")
@ -391,7 +391,7 @@ func TestRequestChannel_NoControlConn(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-001", Mode: "tunnel"})
_, _, err := r.RequestChannel("tai-001", 9100)
_, _, err := r.RequestChannel("tai-001", 19100)
if err == nil {
t.Fatal("expected error for nil ControlConn")
}
@ -420,7 +420,7 @@ func TestOpenLocalListener_Success(t *testing.T) {
r.Register(&TaiNode{TaiID: "tai-001", Mode: "tunnel", ControlConn: wsConn})
ln, err := r.OpenLocalListener("tai-001", 9100)
ln, err := r.OpenLocalListener("tai-001", 19100)
if err != nil {
t.Fatalf("OpenLocalListener: %v", err)
}
@ -448,8 +448,8 @@ func TestOpenLocalListener_Success(t *testing.T) {
if _, ok := cmd["channel_id"].(string); !ok {
t.Error("open cmd missing channel_id")
}
if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 9100 {
t.Errorf("target_port = %v, want 9100", cmd["target_port"])
if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 19100 {
t.Errorf("target_port = %v, want 19100", cmd["target_port"])
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for open command from local listener")
@ -458,7 +458,7 @@ func TestOpenLocalListener_Success(t *testing.T) {
func TestOpenLocalListener_NodeNotFound(t *testing.T) {
r := newTestRegistry()
_, err := r.OpenLocalListener("ghost", 9100)
_, err := r.OpenLocalListener("ghost", 19100)
if err == nil {
t.Fatal("expected error for missing node")
}

View file

@ -13,7 +13,7 @@ type dockerSandbox struct {
}
// NewDocker creates a Sandbox backed by Docker SDK through Tai's Docker API proxy.
// addr should be "tcp://tai-host:2375".
// addr should be "tcp://tai-host:12375".
func NewDocker(addr string) (Sandbox, error) {
cli, err := client.NewClientWithOpts(
client.WithHost(addr),

View file

@ -43,11 +43,11 @@ func (f optionFunc) apply(c *config) { f(c) }
// Ports configures service ports for Tai server.
type Ports struct {
GRPC int // default 9100
HTTP int // default 8080
VNC int // default 6080
Docker int // default 2375
K8s int // default 6443
GRPC int // default 19100
HTTP int // default 8099
VNC int // default 16080
Docker int // default 12375
K8s int // default 16443
}
// WithPorts overrides default Tai service ports.
@ -99,9 +99,9 @@ type config struct {
func defaultPorts() Ports {
return Ports{
GRPC: 9100,
HTTP: 8080,
VNC: 6080,
GRPC: 19100,
HTTP: 8099,
VNC: 16080,
}
}
@ -245,7 +245,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
}
k8sPort := c.ports.K8s
if k8sPort == 0 {
k8sPort = 6443
k8sPort = 16443
}
sbAddr := fmt.Sprintf("%s:%d", c.host, k8sPort)
sb, err := sandbox.NewK8s(sbAddr, sandbox.K8sOption{
@ -259,7 +259,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
} else if hasDocker {
dockerPort := c.ports.Docker
if dockerPort == 0 {
dockerPort = 2375
dockerPort = 12375
}
sbAddr := fmt.Sprintf("tcp://%s:%d", c.host, dockerPort)
sb, err := sandbox.NewDocker(sbAddr)
@ -306,10 +306,10 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) {
}
c.ports = Ports{
GRPC: nodePort(node.Ports, "grpc", 9100),
HTTP: nodePort(node.Ports, "http", 8080),
VNC: nodePort(node.Ports, "vnc", 6080),
Docker: nodePort(node.Ports, "docker", 2375),
GRPC: nodePort(node.Ports, "grpc", 19100),
HTTP: nodePort(node.Ports, "http", 8099),
VNC: nodePort(node.Ports, "vnc", 16080),
Docker: nodePort(node.Ports, "docker", 12375),
}
grpcLn, err := reg.OpenLocalListener(taiID, c.ports.GRPC)
@ -458,7 +458,7 @@ func parseAddr(addr string) (scheme, host, dockerAddr string, grpcPort int, err
if isLocalHost(addr) {
return "docker", "", "", 0, nil
}
// host:port — split carefully (IPv6 like [::1]:9100 is already handled above)
// host:port — split carefully (IPv6 like [::1]:19100 is already handled above)
h := addr
if idx := strings.LastIndex(addr, ":"); idx > 0 {
h = addr[:idx]

View file

@ -14,6 +14,26 @@ func taiTestHost() string {
return "127.0.0.1"
}
// taiRemoteAddr returns the tai:// address for remote tests (e.g. TestNewRemoteDocker).
// Uses TAI_TEST_HOST and, when set, TAI_TEST_GRPC_PORT so Tai on non-default port works.
func taiRemoteAddr() string {
host := taiTestHost()
if p := os.Getenv("TAI_TEST_GRPC_PORT"); p != "" {
return "tai://" + host + ":" + p
}
return "tai://" + host
}
// taiTestPorts builds a Ports struct from TAI_TEST_*_PORT env vars.
// Only non-zero fields are set so they override ServerInfo-discovered values.
func taiTestPorts() Ports {
return Ports{
Docker: envPort("TAI_TEST_DOCKER_PORT", 0),
HTTP: envPort("TAI_TEST_HTTP_PORT", 0),
VNC: envPort("TAI_TEST_VNC_PORT", 0),
}
}
func envPort(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if p, err := strconv.Atoi(v); err == nil {
@ -84,11 +104,11 @@ func TestMergedPorts(t *testing.T) {
if p.HTTP != 8888 {
t.Errorf("HTTP = %d, want 8888", p.HTTP)
}
if p.GRPC != 9100 {
t.Errorf("GRPC = %d, want 9100 (default)", p.GRPC)
if p.GRPC != 19100 {
t.Errorf("GRPC = %d, want 19100 (default)", p.GRPC)
}
if p.VNC != 6080 {
t.Errorf("VNC = %d, want 6080 (default)", p.VNC)
if p.VNC != 16080 {
t.Errorf("VNC = %d, want 16080 (default)", p.VNC)
}
if p.Docker != 0 {
t.Errorf("Docker = %d, want 0 (unset)", p.Docker)
@ -202,12 +222,12 @@ func TestNewRemoteK8s(t *testing.T) {
t.Skip("TAI_TEST_K8S_HOST or TAI_TEST_KUBECONFIG not set")
}
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100))
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
ports := Ports{
K8s: envPort("TAI_TEST_K8S_PORT", 6443),
K8s: envPort("TAI_TEST_K8S_PORT", 16443),
GRPC: grpcPort,
HTTP: envPort("TAI_TEST_K8S_HTTP_PORT", 8080),
VNC: envPort("TAI_TEST_K8S_VNC_PORT", 6080),
HTTP: envPort("TAI_TEST_K8S_HTTP_PORT", 8099),
VNC: envPort("TAI_TEST_K8S_VNC_PORT", 16080),
}
c, err := New(fmt.Sprintf("tai://%s:%d", host, grpcPort), K8s,
@ -255,13 +275,16 @@ func TestNewInvalidScheme(t *testing.T) {
}
func TestNewRemoteDocker(t *testing.T) {
addr := "tai://" + taiTestHost()
c, err := New(addr)
addr := taiRemoteAddr()
ports := taiTestPorts()
c, err := New(addr, WithPorts(ports))
if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err)
}
defer c.Close()
t.Logf("remote docker: addr=%s ports=%+v", addr, c.ports)
if c.IsLocal() {
t.Error("expected IsLocal = false for tai://")
}
@ -284,7 +307,7 @@ func TestNewRemoteDocker(t *testing.T) {
}
func TestDiscoverPorts(t *testing.T) {
addr := "tai://" + taiTestHost()
addr := taiRemoteAddr()
c, err := New(addr)
if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err)
@ -303,7 +326,7 @@ func TestDiscoverPorts(t *testing.T) {
}
func TestDiscoverPortsWithUserOverride(t *testing.T) {
addr := "tai://" + taiTestHost()
addr := taiRemoteAddr()
c, err := New(addr, WithPorts(Ports{HTTP: 9999}))
if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err)

View file

@ -33,7 +33,7 @@ func HandleProxy(c *gin.Context) {
httpPort := node.Ports["http"]
if httpPort == 0 {
httpPort = 8080
httpPort = 8099
}
channelID, resultCh, err := reg.RequestChannel(taiID, httpPort)
@ -104,7 +104,7 @@ func HandleVNC(c *gin.Context) {
vncPort := node.Ports["vnc"]
if vncPort == 0 {
vncPort = 6080
vncPort = 16080
}
channelID, resultCh, err := reg.RequestChannel(taiID, vncPort)

View file

@ -3,7 +3,7 @@ package volume;
option go_package = "github.com/yaoapp/tai/volume/pb";
// Volume provides bulk file synchronization and real-time filesystem I/O.
// Shares gRPC port :9100 with Yao Gateway.
// Shares gRPC port :19100 with Yao Gateway.
service Volume {
// --- Bulk Sync ---

View file

@ -35,7 +35,7 @@ const (
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// Volume provides bulk file synchronization and real-time filesystem I/O.
// Shares gRPC port :9100 with Yao Gateway.
// Shares gRPC port :19100 with Yao Gateway.
type VolumeClient interface {
// SyncPush: Yao sends code to Tai (before container start).
// Bidirectional stream:
@ -183,7 +183,7 @@ func (c *volumeClient) MkdirAll(ctx context.Context, in *FSRequest, opts ...grpc
// for forward compatibility.
//
// Volume provides bulk file synchronization and real-time filesystem I/O.
// Shares gRPC port :9100 with Yao Gateway.
// Shares gRPC port :19100 with Yao Gateway.
type VolumeServer interface {
// SyncPush: Yao sends code to Tai (before container start).
// Bidirectional stream:

View file

@ -8,7 +8,7 @@ import (
)
// Volume provides filesystem IO and directory synchronization.
// Remote connects to Tai gRPC :9100; Local operates directly on disk.
// Remote connects to Tai gRPC :19100; Local operates directly on disk.
type Volume interface {
ReadFile(ctx context.Context, sessionID, path string) ([]byte, os.FileMode, error)
WriteFile(ctx context.Context, sessionID, path string, data []byte, perm os.FileMode) error