refactor: unify server lifecycle, migrate gRPC client, and clean up sandbox v2

Server lifecycle:
- Introduce service.Service to manage HTTP + gRPC startup/shutdown
- Fix gRPC mutex deadlock in StartServer when port is occupied
- Add GracefulStop with 5s timeout before forced Stop in grpc.go
- Pre-check HTTP and gRPC port availability in cmd/start.go
- Print gRPC server address in startup access-points block

gRPC client refactor:
- Move token manager and client from tai/grpc/ to grpc/client/
- Add backward-compatible aliases in tai/yao.go and tai/token.go
- Update cmd/run.go to import grpc/client directly (no tai dependency)

Sandbox v2 docker migration:
- Delete sandbox/v2/docker/ (moved to tai repo)
- Update sandbox/docker/build.sh hint to point to tai repo
- Clean up .gitignore entries for removed docker directory
- Temporarily disable SandboxV2Test and BenchmarkSandboxV2 in CI
  (docker images need rebuild after tai repo migration)

Tai integration:
- Add direct-mode registration API handlers in tai/api/
- Add heartbeat handler and token management wrappers
- Update tai/registry and tai/tunnel for latest protocol
- Replace yao-grpc references with tai call in docs

Made-with: Cursor
This commit is contained in:
Max 2026-03-07 17:19:19 +08:00
parent f6b917afe1
commit 83ebe49036
44 changed files with 979 additions and 2762 deletions

View file

@ -925,9 +925,11 @@ jobs:
# =============================================================================
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
# TEMPORARILY DISABLED: docker images need rebuild after tai repo migration
# =============================================================================
SandboxV2Test:
runs-on: ubuntu-latest
if: false # temporarily disabled restore after sandbox docker images are rebuilt
services:
mongodb:
image: mongo:6.0
@ -941,9 +943,6 @@ jobs:
strategy:
matrix:
go: ["1.25"]
if: >
${{ github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' }}
steps:
- name: "Download artifact"
uses: actions/github-script@v7
@ -1072,7 +1071,7 @@ jobs:
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
@ -1176,7 +1175,7 @@ jobs:
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_IMAGE: "yaoapp/tai-sandbox-test:latest"
run: make unit-test-sandbox-v2
- name: Codecov Report
@ -1811,15 +1810,14 @@ jobs:
# =============================================================================
# Benchmark: Sandbox V2 + Workspace (parallel with SandboxV2Test, non-blocking)
# TEMPORARILY DISABLED: docker images need rebuild after tai repo migration
# =============================================================================
BenchmarkSandboxV2:
runs-on: ubuntu-latest
if: false # temporarily disabled restore after sandbox docker images are rebuilt
strategy:
matrix:
go: ["1.25"]
if: >
${{ github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' }}
steps:
- name: "Download artifact"
uses: actions/github-script@v7
@ -1934,7 +1932,7 @@ jobs:
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
@ -2028,7 +2026,7 @@ jobs:
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_IMAGE: "yaoapp/tai-sandbox-test:latest"
run: make benchmark-sandbox-v2
# =============================================================================

View file

@ -780,7 +780,7 @@ jobs:
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
@ -1440,7 +1440,7 @@ jobs:
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest

6
.gitignore vendored
View file

@ -74,8 +74,4 @@ tg-login
tg-send
registry/data/
registry/manager/DESIGN*.md
tai/testdata/
sandbox/v2/docker/base/*-amd64
sandbox/v2/docker/base/*-arm64
!sandbox/v2/docker/*.sh
!sandbox/v2/docker/*/*.sh
tai/testdata/

View file

@ -205,7 +205,7 @@ unit-test-registry:
# Sandbox V2 Integration Test (tai + sandbox/v2 + workspace)
# Requires: Docker, Tai container, optionally k3d for K8s mode
# ---------------------------------------------------------------------------
SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest
SANDBOX_V2_IMAGE ?= yaoapp/tai-sandbox-test:latest
.PHONY: unit-test-sandbox-v2
unit-test-sandbox-v2: unit-test-sandbox-v2-pull unit-test-tai unit-test-sandbox-v2-core unit-test-workspace

View file

@ -16,9 +16,9 @@ import (
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
grpcclient "github.com/yaoapp/yao/grpc/client"
ischedule "github.com/yaoapp/yao/schedule"
"github.com/yaoapp/yao/share"
taigrpc "github.com/yaoapp/yao/tai/grpc"
itask "github.com/yaoapp/yao/task"
)
@ -93,8 +93,8 @@ func runGRPC(cred *Credential, args []string) {
os.Exit(1)
}
tm := taigrpc.NewTokenManager(cred.AccessToken, cred.RefreshToken, "", "")
client, err := taigrpc.Dial(cred.GRPCAddr, tm)
tm := grpcclient.NewTokenManager(cred.AccessToken, cred.RefreshToken, "")
client, err := grpcclient.Dial(cred.GRPCAddr, tm)
if err != nil {
color.Red(" %s %s\n", L("gRPC connect failed:"), err.Error())
os.Exit(1)

View file

@ -2,9 +2,11 @@ package cmd
import (
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
@ -16,7 +18,6 @@ import (
"github.com/yaoapp/gou/mcp"
"github.com/yaoapp/gou/plugin"
"github.com/yaoapp/gou/schedule"
"github.com/yaoapp/gou/server/http"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/gou/task"
"github.com/yaoapp/gou/websocket"
@ -128,49 +129,24 @@ var startCmd = &cobra.Command{
// print the messages under the development mode
if mode == "development" {
// Start Studio Server
// Yao Studio will be deprecated in the future
// go func() {
// err = studio.Load(config.Conf)
// if err != nil {
// // fmt.Println(color.RedString(L("Studio Load: %s"), err.Error()))
// log.Error("Studio Load: %s", err.Error())
// return
// }
// err := studio.Start(config.Conf)
// if err != nil {
// log.Error("Studio Start: %s", err.Error())
// return
// }
// }()
// defer studio.Stop()
printApis(false)
printTasks(false)
printSchedules(false)
printConnectors(false)
printStores(false)
printMCPs(false)
}
root, _ := adminRoot()
endpoints := []setup.Endpoint{{URL: fmt.Sprintf("http://%s%s", "127.0.0.1", port), Interface: "localhost"}}
switch host {
case "0.0.0.0":
// All interfaces
if values, err := setup.Endpoints(config.Conf); err == nil {
endpoints = append(endpoints, values...)
}
break
case "127.0.0.1":
// Localhost only
break
default:
// Filter by the host IP
matched := false
endpoints = []setup.Endpoint{}
if values, err := setup.Endpoints(config.Conf); err == nil {
@ -187,32 +163,6 @@ var startCmd = &cobra.Command{
}
}
// Print gRPC listen addresses
grpcAddrs := yaogrpc.Addr()
for _, addr := range grpcAddrs {
fmt.Println(color.WhiteString(L("Listening")), color.GreenString(" %s (gRPC)", addr))
}
fmt.Println(color.WhiteString("\n---------------------------------"))
fmt.Println(color.WhiteString(L("Access Points")))
fmt.Println(color.WhiteString("---------------------------------"))
apiRoot := "/api"
if openapi.Server != nil {
apiRoot = openapi.Server.Config.BaseURL
}
for _, endpoint := range endpoints {
fmt.Println(color.CyanString("\n%s", endpoint.Interface))
fmt.Println(color.WhiteString("--------------------------"))
fmt.Println(color.WhiteString(L("Website")), color.GreenString(" %s", endpoint.URL))
fmt.Println(color.WhiteString(L("Dashboard")), color.GreenString(" %s/%s/auth/entry", endpoint.URL, strings.Trim(root, "/")))
if openapi.Server != nil {
fmt.Println(color.WhiteString(L("OpenAPI")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
} else {
fmt.Println(color.WhiteString(L("API")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
}
}
fmt.Println("")
// Print welcome message for the new application
if isnew {
printWelcome()
@ -230,32 +180,66 @@ var startCmd = &cobra.Command{
// (must happen before HTTP/gRPC start so handlers can access it)
tairegistry.Init(nil)
// Start HTTP Server
srv, err := service.Start(config.Conf)
defer func() {
service.Stop(srv)
fmt.Println(color.GreenString(L("✨Exited successfully!")))
}()
// Pre-flight: detect port conflicts before attempting to start servers.
if occupied, proc := portOccupied(config.Conf.Host, config.Conf.Port); occupied {
fmt.Println(color.RedString(L("Fatal: HTTP port %d is already in use%s"), config.Conf.Port, proc))
return
}
if strings.ToLower(config.Conf.GRPC.Enabled) != "off" {
for _, h := range strings.Split(config.Conf.GRPC.Host, ",") {
if occupied, proc := portOccupied(strings.TrimSpace(h), config.Conf.GRPC.Port); occupied {
fmt.Println(color.RedString(L("Fatal: gRPC port %d is already in use%s"), config.Conf.GRPC.Port, proc))
return
}
}
}
// Start all servers (gRPC + HTTP) as a single unit.
// Start() blocks until HTTP port is bound (READY) or returns error.
svc, err := service.Start(config.Conf, service.ServerHooks{
Start: yaogrpc.StartServer,
Stop: yaogrpc.Stop,
Addrs: yaogrpc.Addr,
})
if err != nil {
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
os.Exit(1)
return
}
// Start gRPC Server (after HTTP, LIFO shutdown: gRPC stops before HTTP)
if grpcErr := yaogrpc.StartServer(config.Conf); grpcErr != nil {
fmt.Println(color.RedString(L("gRPC: %s"), grpcErr.Error()))
os.Exit(1)
// Access Points (printed after servers are up so addresses are known)
fmt.Println(color.WhiteString("\n---------------------------------"))
fmt.Println(color.WhiteString(L("Access Points")))
fmt.Println(color.WhiteString("---------------------------------"))
if grpcAddrs := svc.HookAddrs(); len(grpcAddrs) > 0 {
fmt.Println(color.CyanString("\ngRPC"))
fmt.Println(color.WhiteString("--------------------------"))
for _, addr := range grpcAddrs {
fmt.Println(color.WhiteString(L("Server")), color.GreenString(" %s", addr))
}
}
defer yaogrpc.Stop()
apiRoot := "/api"
if openapi.Server != nil {
apiRoot = openapi.Server.Config.BaseURL
}
for _, endpoint := range endpoints {
fmt.Println(color.CyanString("\n%s", endpoint.Interface))
fmt.Println(color.WhiteString("--------------------------"))
fmt.Println(color.WhiteString(L("Website")), color.GreenString(" %s", endpoint.URL))
fmt.Println(color.WhiteString(L("Dashboard")), color.GreenString(" %s/%s/auth/entry", endpoint.URL, strings.Trim(root, "/")))
if openapi.Server != nil {
fmt.Println(color.WhiteString(L("OpenAPI")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
} else {
fmt.Println(color.WhiteString(L("API")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
}
}
fmt.Println("")
// Start watching
watchDone := make(chan uint8, 1)
if mode == "development" && !startDisableWatching {
// fmt.Println(color.WhiteString("\n---------------------------------"))
// fmt.Println(color.WhiteString(L("Watching")))
// fmt.Println(color.WhiteString("---------------------------------"))
go service.Watch(srv, watchDone)
go svc.Watch(watchDone)
}
// Print the messages under the production mode
@ -279,31 +263,15 @@ var startCmd = &cobra.Command{
fmt.Printf("\n")
}
fmt.Println(color.GreenString(L("Server is up and running...")))
fmt.Println(color.GreenString("Ctrl+C to stop"))
for {
select {
case v := <-srv.Event():
switch v {
case http.READY:
fmt.Println(color.GreenString(L("Server is up and running...")))
fmt.Println(color.GreenString("Ctrl+C to stop"))
break
case http.CLOSED:
fmt.Println(color.GreenString(L("✨Exited successfully!")))
watchDone <- 1
return
case http.ERROR:
color.Red("Fatal: check the error information in the log")
watchDone <- 1
return
default:
fmt.Println("Signal:", v)
}
case <-interrupt:
fmt.Println(color.WhiteString("\nShutting down..."))
svc.Stop()
fmt.Println(color.GreenString(L("✨Exited successfully!")))
watchDone <- 1
return
}
@ -399,7 +367,7 @@ func printStores(silent bool) {
}
fmt.Println(color.WhiteString("\n---------------------------------"))
fmt.Println(color.WhiteString(L("Stores List (%d)"), len(connector.Connectors)))
fmt.Println(color.WhiteString(L("Stores List (%d)"), len(store.Pools)))
fmt.Println(color.WhiteString("---------------------------------"))
for name := range store.Pools {
fmt.Print(color.CyanString("[Store]"))
@ -647,6 +615,18 @@ func colorMehtod(method string) string {
}
}
// portOccupied probes whether host:port is already bound.
// Returns (true, " (pid XXXX)") when occupied, (false, "") otherwise.
func portOccupied(host string, port int) (bool, string) {
addr := net.JoinHostPort(host, strconv.Itoa(port))
ln, err := net.Listen("tcp", addr)
if err != nil {
return true, fmt.Sprintf(" (%s)", err.Error())
}
ln.Close()
return false, ""
}
func init() {
startCmd.PersistentFlags().BoolVarP(&startDebug, "debug", "", false, L("Development mode"))
startCmd.PersistentFlags().BoolVarP(&startDisableWatching, "disable-watching", "", false, L("Disable watching"))

View file

@ -40,12 +40,11 @@ grpc/
Container client:
```
tai/grpc/
├── grpc.go // gRPC client, Dial, method wrappers
├── auth.go // read env tokens, attach metadata, handle refresh
├── grpc_test.go
└── cmd/
└── main.go // yao-grpc binary entry
grpc/client/ // gRPC client (moved from tai/grpc/ to grpc/client/)
├── client.go // gRPC client, Dial, method wrappers
└── token.go // read env tokens, attach metadata, handle refresh
tai repo: tai/call/ // container-side binary (replaces yao-grpc)
```
## V1 Phases
@ -163,7 +162,7 @@ Deliverable: LLM (unary + stream) and Agent streaming via gRPC.
### Phase 4: Tai gateway change (Tai repo) ✅
Depends on: Phase 1 (need proto definitions for testing). yao-grpc depends on this.
Depends on: Phase 1 (need proto definitions for testing). `tai call` (tai repo) depends on this.
Tai gateway currently dials a fixed `YaoUpstream` at startup. New behavior: yao-grpc tells Tai where to forward via request metadata (`x-grpc-upstream`). Tai reads the target address and proxies to it — removes `YaoUpstream` startup config.

View file

@ -1,4 +1,4 @@
package grpc
package client
import (
"context"
@ -12,15 +12,14 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
// Client wraps a gRPC connection to a Yao server (direct or via Tai relay).
// TokenManager handles auth metadata attachment and token refresh automatically.
// Client wraps a gRPC connection to a Yao server.
type Client struct {
conn *grpc.ClientConn
svc pb.YaoClient
token *TokenManager
}
// NewFromEnv reads YAO_GRPC_ADDR (required) and token env vars, dials the
// NewFromEnv reads YAO_GRPC_ADDR and token env vars, dials the
// gRPC server, and returns a connected Client.
func NewFromEnv() (*Client, error) {
addr := os.Getenv("YAO_GRPC_ADDR")
@ -36,10 +35,7 @@ func NewFromEnv() (*Client, error) {
return Dial(addr, tm)
}
// Dial connects to the gRPC server at addr with the given TokenManager.
// Bare host:port addresses are wrapped with passthrough:/// for grpc.NewClient
// compatibility (grpc.NewClient defaults to dns scheme which may fail for hostnames
// like host.docker.internal).
// Dial connects to a Yao gRPC server at addr with the given TokenManager.
func Dial(addr string, tm *TokenManager) (*Client, error) {
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
@ -180,7 +176,6 @@ func (c *Client) ChatCompletions(ctx context.Context, connector string, messages
}
// ChatCompletionsStream sends a streaming chat completion request.
// The callback receives each chunk's data; return a non-nil error to stop.
func (c *Client) ChatCompletionsStream(ctx context.Context, connector string, messages, options []byte, cb func(data []byte, done bool) error) error {
stream, err := c.svc.ChatCompletionsStream(ctx, &pb.ChatRequest{
Connector: connector,
@ -210,7 +205,6 @@ func (c *Client) ChatCompletionsStream(ctx context.Context, connector string, me
// --- Agent ---
// AgentStream calls an agent with streaming response.
// The callback receives each chunk's data; return a non-nil error to stop.
func (c *Client) AgentStream(ctx context.Context, assistantID string, messages, options []byte, cb func(data []byte, done bool) error) error {
stream, err := c.svc.AgentStream(ctx, &pb.AgentRequest{
AssistantId: assistantID,

View file

@ -1,8 +1,7 @@
package grpc
package client
import (
"context"
"fmt"
"os"
"sync"
@ -10,46 +9,30 @@ import (
"google.golang.org/grpc/metadata"
)
// TokenManager reads auth credentials from environment variables and attaches
// them as gRPC metadata on every call. It also handles automatic token refresh
// by reading new tokens from response headers.
// TokenManager attaches auth credentials as gRPC metadata on every call
// and handles automatic token refresh from response headers.
type TokenManager struct {
mu sync.RWMutex
accessToken string
refreshToken string
sandboxID string
upstream string // only set when YAO_GRPC_TAI=enable
taiMode bool
}
// NewTokenManagerFromEnv creates a TokenManager from environment variables.
// Returns an error if required variables are missing.
func NewTokenManagerFromEnv() (*TokenManager, error) {
tm := &TokenManager{
return &TokenManager{
accessToken: os.Getenv("YAO_TOKEN"),
refreshToken: os.Getenv("YAO_REFRESH_TOKEN"),
sandboxID: os.Getenv("YAO_SANDBOX_ID"),
}
if os.Getenv("YAO_GRPC_TAI") == "enable" {
tm.taiMode = true
tm.upstream = os.Getenv("YAO_GRPC_UPSTREAM")
if tm.upstream == "" {
return nil, fmt.Errorf("YAO_GRPC_TAI=enable but YAO_GRPC_UPSTREAM is not set")
}
}
return tm, nil
}, nil
}
// NewTokenManager creates a TokenManager with explicit values (for testing).
func NewTokenManager(accessToken, refreshToken, sandboxID, upstream string) *TokenManager {
// NewTokenManager creates a TokenManager with explicit values.
func NewTokenManager(accessToken, refreshToken, sandboxID string) *TokenManager {
return &TokenManager{
accessToken: accessToken,
refreshToken: refreshToken,
sandboxID: sandboxID,
upstream: upstream,
taiMode: upstream != "",
}
}
@ -58,7 +41,7 @@ func (tm *TokenManager) AttachMetadata(ctx context.Context) context.Context {
tm.mu.RLock()
defer tm.mu.RUnlock()
pairs := []string{}
var pairs []string
if tm.accessToken != "" {
pairs = append(pairs, "authorization", "Bearer "+tm.accessToken)
}
@ -68,9 +51,6 @@ func (tm *TokenManager) AttachMetadata(ctx context.Context) context.Context {
if tm.sandboxID != "" {
pairs = append(pairs, "x-sandbox-id", tm.sandboxID)
}
if tm.taiMode && tm.upstream != "" {
pairs = append(pairs, "x-grpc-upstream", tm.upstream)
}
if len(pairs) == 0 {
return ctx
@ -79,7 +59,7 @@ func (tm *TokenManager) AttachMetadata(ctx context.Context) context.Context {
}
// HandleResponseHeaders reads new tokens from response headers and updates
// the in-memory credentials. Call after each gRPC response.
// the in-memory credentials.
func (tm *TokenManager) HandleResponseHeaders(header metadata.MD) {
if header == nil {
return
@ -103,10 +83,8 @@ func (tm *TokenManager) UnaryInterceptor() grpc.UnaryClientInterceptor {
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = tm.AttachMetadata(ctx)
var header metadata.MD
opts = append(opts, grpc.Header(&header))
err := invoker(ctx, method, req, reply, cc, opts...)
tm.HandleResponseHeaders(header)
return err
@ -114,8 +92,7 @@ func (tm *TokenManager) UnaryInterceptor() grpc.UnaryClientInterceptor {
}
// StreamInterceptor returns a gRPC stream client interceptor that attaches
// auth metadata. Token refresh from stream headers is handled by the caller
// via stream.Header().
// auth metadata.
func (tm *TokenManager) StreamInterceptor() grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn,
method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
@ -125,23 +102,16 @@ func (tm *TokenManager) StreamInterceptor() grpc.StreamClientInterceptor {
if err != nil {
return nil, err
}
if header, hErr := stream.Header(); hErr == nil {
tm.HandleResponseHeaders(header)
}
return stream, nil
}
}
// AccessToken returns the current access token (for testing/debugging).
// AccessToken returns the current access token.
func (tm *TokenManager) AccessToken() string {
tm.mu.RLock()
defer tm.mu.RUnlock()
return tm.accessToken
}
// IsTaiMode returns whether the client is configured for Tai relay mode.
func (tm *TokenManager) IsTaiMode() bool {
return tm.taiMode
}

View file

@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@ -162,7 +163,7 @@ func StartServer(cfg config.Config) error {
addr := net.JoinHostPort(strings.TrimSpace(h), port)
lis, err := net.Listen("tcp", addr)
if err != nil {
Stop()
stopLocked()
return err
}
listeners = append(listeners, lis)
@ -179,17 +180,39 @@ func StartServer(cfg config.Config) error {
return nil
}
// Stop gracefully stops the gRPC server. Safe to call if server was never started.
// stopLocked performs cleanup while the caller already holds mu.
func stopLocked() {
s := server
server = nil
listeners = nil
addrs = nil
if s == nil {
return
}
done := make(chan struct{})
go func() {
s.GracefulStop()
close(done)
}()
select {
case <-done:
log.Info("gRPC server stopped gracefully")
case <-time.After(5 * time.Second):
log.Warn("gRPC server graceful stop timed out, forcing stop")
s.Stop()
}
}
// Stop gracefully stops the gRPC server with a 5-second timeout.
// If GracefulStop doesn't complete in time (e.g. active streams), it forces Stop.
// Safe to call if server was never started.
func Stop() {
mu.Lock()
defer mu.Unlock()
if server != nil {
server.GracefulStop()
server = nil
}
listeners = nil
addrs = nil
stopLocked()
}
// GRPCServer returns the active gRPC server instance.

View file

@ -28,6 +28,7 @@ import (
"github.com/yaoapp/yao/openapi/team"
openapiTrace "github.com/yaoapp/yao/openapi/trace"
"github.com/yaoapp/yao/openapi/user"
taiapi "github.com/yaoapp/yao/tai/api"
taitunnel "github.com/yaoapp/yao/tai/tunnel"
)
@ -182,6 +183,11 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
group.Any("/tai/:taiID/proxy/*path", taitunnel.HandleProxy)
group.GET("/tai/:taiID/vnc/*path", taitunnel.HandleVNC)
// Tai direct registration API (uses /tai-nodes/ prefix to avoid routing conflict with /tai/:taiID/)
group.POST("/tai-nodes/register", taiapi.HandleRegister)
group.POST("/tai-nodes/heartbeat", taiapi.HandleHeartbeat)
group.DELETE("/tai-nodes/register/:tai_id", taiapi.HandleUnregister)
// Custom handlers (Defined by developer)
}

View file

@ -50,14 +50,14 @@ High-level business layer on top of `tai.Client`. Manages container lifecycle, u
General-purpose gRPC gateway exposed by the Yao process. Not limited to sandbox IPC — it exposes process execution, shell, API proxy, MCP, LLM, and Agent capabilities to any gRPC client. 14 RPCs defined; V1 (unary + LLM/Agent streaming) complete, V2 (base streaming via `gou/stream`) pending.
**Clients:**
- Container-internal `yao-grpc` (via Tai Gateway relay or direct)
- Container-internal `tai call` (via Tai Gateway relay or direct)
- `yao run` CLI (after `yao login`)
- Other Yao instances (future node-to-node)
**IPC path (replacing Unix socket):**
```
Local: Container → yao-grpc (tai/grpc/) → Yao gRPC 127.0.0.1:9099
Remote: Container → yao-grpc (tai/grpc/) → Tai Gateway (:9100 gRPC) → Yao gRPC Server (:9099)
Local: Container → tai call (tai repo) → Yao gRPC 127.0.0.1:9099
Remote: Container → tai call (tai repo) → Tai Gateway (:9100 gRPC) → Yao gRPC Server (:9099)
```
All modes use gRPC — no Unix socket fallback. `yao-grpc` reads `YAO_GRPC_ADDR` from env and connects. Local containers point directly at the Yao gRPC server on loopback; remote containers point at the Tai relay. Tai does **not** know Yao gRPC address at startup — `yao-grpc` carries target in `x-grpc-upstream` request metadata. This keeps Tai stateless and allows one Tai to serve multiple Yao instances.

View file

@ -165,8 +165,8 @@ case $TOOL in
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
;;
v2)
echo "V2 images have their own build script: sandbox/v2/docker/build.sh"
echo "Usage: sandbox/v2/docker/build.sh [true|false]"
echo "V2 images have moved to the tai repo: tai/docker/sandbox/build.sh"
echo "See: https://github.com/yaoapp/tai/tree/main/docker/sandbox"
exit 0
;;
*)

View file

@ -1121,7 +1121,7 @@ Permission control is the responsibility of the caller (JS scripts, Agent hooks,
| **Runtime** | Direct Docker SDK | tai.Client pool (Docker/K8s/Remote) |
| **Execution** | Exec + Stream | Exec + Stream + Attach (WS/SSE) |
| **File I/O** | bind mount + Docker Copy | `workspace.FS` (fs.FS compatible) |
| **IPC** | Unix socket + yao-bridge | gRPC (yao-grpc) |
| **IPC** | Unix socket + yao-bridge | gRPC (tai call) |
| **Idle detection** | External calls only | Dual: external calls + container heartbeat |
| **Lifecycle** | Chat session only | Policy-based (oneshot/session/longrunning/persistent) |
| **Pool** | Single Docker daemon | Multi-pool with per-pool policies |

View file

@ -1,7 +1,7 @@
GO ?= go
GOFILES := $(shell find . -name "*.go" -not -path "./docker/*")
PACKAGES := $(shell $(GO) list ./...)
TEST_IMAGE ?= yaoapp/sandbox-v2-test:latest
TEST_IMAGE ?= yaoapp/tai-sandbox-test:latest
TEST_TIMEOUT ?= 600s
# ---------------------------------------------------------------------------

View file

@ -589,8 +589,8 @@ sandbox-v2-test:
Key decisions:
- SQLite only — sandbox is infrastructure, not data-model dependent
- Tai container provides remote mode — exercises the full proxy path
- `sandbox-v2-test` as default test image — includes `yao-grpc` (heartbeat), `openai-proxy`, Nginx, WS echo + SSE test services
- CI builds test image from source (Step 4.5) — ensures binary compatibility with latest tai SDK + yao-grpc changes
- `sandbox-v2-test` as default test image — includes `tai` (heartbeat), `openai-proxy`, Nginx, WS echo + SSE test services
- CI builds test image from source (Step 4.5) — ensures binary compatibility with latest tai SDK changes
- Attach tests (WS/SSE) use `sandbox-v2-test` image's built-in test services
## Coverage

View file

@ -62,7 +62,7 @@ func TestAttachWS(t *testing.T) {
img := testImage()
if img == "alpine:latest" {
t.Skip("WebSocket test requires sandbox-v2-test image with ws-echo service")
t.Skip("WebSocket test requires tai-sandbox-test image with ws-echo service")
}
for _, pc := range testPools() {
@ -110,7 +110,7 @@ func TestAttachSSE(t *testing.T) {
img := testImage()
if img == "alpine:latest" {
t.Skip("SSE test requires sandbox-v2-test image with sse-server service")
t.Skip("SSE test requires tai-sandbox-test image with sse-server service")
}
for _, pc := range testPools() {
@ -159,7 +159,7 @@ func TestVNCURL(t *testing.T) {
img := testImage()
if img == "alpine:latest" {
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
t.Skip("VNC test requires tai-sandbox-test image with VNC desktop")
}
for _, pc := range testPools() {
@ -189,7 +189,7 @@ func TestVNCConnect(t *testing.T) {
img := testImage()
if img == "alpine:latest" {
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
t.Skip("VNC test requires tai-sandbox-test image with VNC desktop")
}
for _, pc := range testPools() {

View file

@ -1,39 +0,0 @@
# Sandbox V2 base image — self-contained, no dependency on V1 sandbox-base
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Faster mirror for ARM64
RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || \
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
curl wget git ca-certificates gnupg lsb-release jq \
vim less tree \
iputils-ping net-tools dnsutils telnet netcat-openbsd \
zip unzip tar gzip \
htop procps \
sed gawk grep \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
RUN useradd -m -s /bin/bash sandbox && \
chown -R sandbox:sandbox /workspace
# yao-grpc binary (replaces yao-bridge from V1)
ARG TARGETARCH
COPY yao-grpc-${TARGETARCH} /usr/local/bin/yao-grpc
RUN chmod +x /usr/local/bin/yao-grpc
# openai-proxy: Anthropic Messages API → OpenAI Chat Completions API
COPY openai-proxy-${TARGETARCH} /usr/local/bin/openai-proxy
RUN chmod +x /usr/local/bin/openai-proxy
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER sandbox
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
CMD ["sleep", "infinity"]

View file

@ -1,12 +0,0 @@
#!/bin/bash
# V2 base entrypoint — conditionally starts yao-grpc and openai-proxy
if [ -n "$YAO_GRPC_ADDR" ] && [ -n "$YAO_SANDBOX_ID" ]; then
tail -f /dev/null | yao-grpc serve &
fi
if [ -n "$OPENAI_PROXY_BACKEND" ]; then
openai-proxy &
fi
exec "$@"

View file

@ -1,7 +0,0 @@
package main
import proxy "github.com/yaoapp/yao/sandbox/v2/docker/bin/openai-proxy"
func main() {
proxy.Main()
}

View file

@ -1,419 +0,0 @@
package proxy
import (
"encoding/json"
"fmt"
"strings"
)
func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest {
maxTokens := req.MaxTokens
if s.config.Options != nil {
if mt, ok := s.config.Options["max_tokens"]; ok {
switch v := mt.(type) {
case float64:
maxTokens = int(v)
case int:
maxTokens = v
}
}
}
temperature := req.Temperature
if s.config.Options != nil {
if temp, ok := s.config.Options["temperature"]; ok {
if v, ok := temp.(float64); ok {
temperature = &v
}
}
}
openaiReq := &OpenAIRequest{
Model: s.config.Model,
MaxTokens: maxTokens,
Stream: req.Stream,
Temperature: temperature,
TopP: req.TopP,
Stop: req.StopSequences,
}
if s.config.Options != nil {
openaiReq.ExtraOptions = make(map[string]interface{})
for k, v := range s.config.Options {
switch k {
case "max_tokens", "temperature", "model", "key", "proxy":
continue
default:
openaiReq.ExtraOptions[k] = v
}
}
}
openaiReq.Messages = s.convertMessages(req.Messages, req.System)
if len(req.Tools) > 0 {
openaiReq.Tools = s.convertTools(req.Tools)
}
if req.ToolChoice != nil {
openaiReq.ToolChoice = s.convertToolChoice(req.ToolChoice)
}
return openaiReq
}
func (s *Server) convertMessages(msgs []AnthropicMsg, system interface{}) []OpenAIMsg {
var result []OpenAIMsg
if system != nil {
systemText := extractSystemText(system)
if systemText != "" {
result = append(result, OpenAIMsg{
Role: "system",
Content: systemText,
})
}
}
for _, msg := range msgs {
converted := s.convertMessage(msg)
result = append(result, converted...)
}
return result
}
func (s *Server) convertMessage(msg AnthropicMsg) []OpenAIMsg {
var result []OpenAIMsg
switch content := msg.Content.(type) {
case string:
result = append(result, OpenAIMsg{
Role: mapRole(msg.Role),
Content: content,
})
case []interface{}:
var toolResults []ContentBlock
var otherContent []interface{}
for _, item := range content {
block := parseContentBlock(item)
if block.Type == "tool_result" {
toolResults = append(toolResults, block)
} else {
otherContent = append(otherContent, item)
}
}
for _, tr := range toolResults {
toolMsg := OpenAIMsg{
Role: "tool",
ToolCallID: tr.ToolUseID,
Content: extractToolResultContent(tr.Content),
}
result = append(result, toolMsg)
}
if len(otherContent) > 0 {
openaiContent := s.convertContentBlocks(otherContent)
if len(openaiContent) == 1 && openaiContent[0].Type == "text" {
result = append(result, OpenAIMsg{
Role: mapRole(msg.Role),
Content: openaiContent[0].Text,
})
} else if len(openaiContent) > 0 {
result = append(result, OpenAIMsg{
Role: mapRole(msg.Role),
Content: openaiContent,
})
}
}
if msg.Role == "assistant" {
toolCalls := extractToolUseBlocks(content)
if len(toolCalls) > 0 {
found := false
for i := range result {
if result[i].Role == "assistant" {
result[i].ToolCalls = toolCalls
found = true
break
}
}
if !found {
result = append(result, OpenAIMsg{
Role: "assistant",
Content: "",
ToolCalls: toolCalls,
})
}
}
}
}
return result
}
func (s *Server) convertContentBlocks(blocks []interface{}) []OpenAIContent {
var result []OpenAIContent
for _, item := range blocks {
block := parseContentBlock(item)
switch block.Type {
case "text":
result = append(result, OpenAIContent{
Type: "text",
Text: block.Text,
})
case "image":
if block.Source != nil {
imageURL := convertImageSource(block.Source)
result = append(result, OpenAIContent{
Type: "image_url",
ImageURL: imageURL,
})
}
case "tool_use", "tool_result":
continue
}
}
return result
}
func convertImageSource(source *ImageSource) *OpenAIImageURL {
if source == nil {
return nil
}
switch source.Type {
case "base64":
mediaType := source.MediaType
if mediaType == "" {
mediaType = "image/jpeg"
}
return &OpenAIImageURL{
URL: fmt.Sprintf("data:%s;base64,%s", mediaType, source.Data),
}
case "url":
return &OpenAIImageURL{
URL: source.URL,
}
}
return nil
}
func (s *Server) convertTools(tools []AnthropicTool) []OpenAITool {
var result []OpenAITool
for _, tool := range tools {
result = append(result, OpenAITool{
Type: "function",
Function: OpenAIFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.InputSchema,
},
})
}
return result
}
func (s *Server) convertToolChoice(choice *AnthropicToolChoice) interface{} {
if choice == nil {
return nil
}
switch choice.Type {
case "auto":
return "auto"
case "any":
return "required"
case "tool":
return map[string]interface{}{
"type": "function",
"function": map[string]string{
"name": choice.Name,
},
}
case "none":
return "none"
}
return "auto"
}
func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse {
result := &AnthropicResponse{
ID: generateID("msg_"),
Type: "message",
Role: "assistant",
Content: []ContentBlock{},
Model: s.config.Model,
}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
if content, ok := choice.Message.Content.(string); ok && content != "" {
result.Content = append(result.Content, ContentBlock{
Type: "text",
Text: content,
})
}
for _, tc := range choice.Message.ToolCalls {
var input interface{}
json.Unmarshal([]byte(tc.Function.Arguments), &input)
result.Content = append(result.Content, ContentBlock{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
stopReason := mapFinishReason(choice.FinishReason)
result.StopReason = &stopReason
}
if resp.Usage != nil {
result.Usage = &Usage{
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
}
} else {
result.Usage = &Usage{InputTokens: 0, OutputTokens: 0}
}
return result
}
func extractSystemText(system interface{}) string {
switch s := system.(type) {
case string:
return s
case []interface{}:
var texts []string
for _, item := range s {
if block, ok := item.(map[string]interface{}); ok {
if text, ok := block["text"].(string); ok {
if strings.HasPrefix(text, "x-anthropic-") {
continue
}
texts = append(texts, text)
}
}
}
if len(texts) > 0 {
return strings.Join(texts, "\n\n")
}
}
return ""
}
func parseContentBlock(item interface{}) ContentBlock {
var block ContentBlock
switch v := item.(type) {
case map[string]interface{}:
if t, ok := v["type"].(string); ok {
block.Type = t
}
if text, ok := v["text"].(string); ok {
block.Text = text
}
if id, ok := v["id"].(string); ok {
block.ID = id
}
if name, ok := v["name"].(string); ok {
block.Name = name
}
if input, ok := v["input"]; ok {
block.Input = input
}
if toolUseID, ok := v["tool_use_id"].(string); ok {
block.ToolUseID = toolUseID
}
if content, ok := v["content"]; ok {
block.Content = content
}
if isError, ok := v["is_error"].(bool); ok {
block.IsError = isError
}
if source, ok := v["source"].(map[string]interface{}); ok {
block.Source = parseImageSource(source)
}
}
return block
}
func parseImageSource(source map[string]interface{}) *ImageSource {
if source == nil {
return nil
}
result := &ImageSource{}
if t, ok := source["type"].(string); ok {
result.Type = t
}
if mediaType, ok := source["media_type"].(string); ok {
result.MediaType = mediaType
}
if data, ok := source["data"].(string); ok {
result.Data = data
}
if url, ok := source["url"].(string); ok {
result.URL = url
}
return result
}
func extractToolUseBlocks(content []interface{}) []OpenAIToolCall {
var result []OpenAIToolCall
for _, item := range content {
block := parseContentBlock(item)
if block.Type == "tool_use" {
args, _ := json.Marshal(block.Input)
result = append(result, OpenAIToolCall{
ID: block.ID,
Type: "function",
Function: OpenAIFunctionCall{
Name: block.Name,
Arguments: string(args),
},
})
}
}
return result
}
func extractToolResultContent(content interface{}) string {
switch c := content.(type) {
case string:
return c
case []interface{}:
for _, item := range c {
if block, ok := item.(map[string]interface{}); ok {
if block["type"] == "text" {
if text, ok := block["text"].(string); ok {
return text
}
}
}
}
}
return ""
}
func mapRole(role string) string {
switch role {
case "user":
return "user"
case "assistant":
return "assistant"
default:
return role
}
}

View file

@ -1,510 +0,0 @@
// Package proxy provides a lightweight API proxy that translates
// Anthropic Messages API to OpenAI Chat Completions API.
package proxy
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// Config holds the proxy server configuration
type Config struct {
Port int
Backend string
Model string
APIKey string
Timeout int
Verbose bool
LogFile string
Options map[string]interface{}
}
// Server is the API proxy server
type Server struct {
config *Config
client *http.Client
}
// Main is the entry point for the proxy server
func Main() {
config := parseFlags()
if err := config.Validate(); err != nil {
log.Fatalf("Configuration error: %v", err)
}
if config.LogFile != "" {
f, err := os.OpenFile(config.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatalf("Failed to open log file: %v", err)
}
mw := io.MultiWriter(os.Stdout, f)
log.SetOutput(mw)
}
server := NewServer(config)
addr := fmt.Sprintf(":%d", config.Port)
log.Printf("OpenAI Proxy starting on %s", addr)
log.Printf("Backend: %s", config.Backend)
log.Printf("Model: %s", config.Model)
if len(config.Options) > 0 {
optBytes, _ := json.Marshal(config.Options)
log.Printf("Options: %s", string(optBytes))
}
http.HandleFunc("/v1/messages", server.handleMessages)
http.HandleFunc("/health", server.handleHealth)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
func parseFlags() *Config {
config := &Config{}
flag.IntVar(&config.Port, "p", 0, "Listen port")
flag.IntVar(&config.Port, "port", 0, "Listen port")
flag.StringVar(&config.Backend, "b", "", "Backend API URL")
flag.StringVar(&config.Backend, "backend", "", "Backend API URL")
flag.StringVar(&config.Model, "m", "", "Backend model name")
flag.StringVar(&config.Model, "model", "", "Backend model name")
flag.StringVar(&config.APIKey, "k", "", "Backend API key")
flag.StringVar(&config.APIKey, "api-key", "", "Backend API key")
flag.IntVar(&config.Timeout, "t", 0, "Request timeout in seconds")
flag.IntVar(&config.Timeout, "timeout", 0, "Request timeout in seconds")
flag.BoolVar(&config.Verbose, "v", false, "Verbose logging")
flag.BoolVar(&config.Verbose, "verbose", false, "Verbose logging")
flag.StringVar(&config.LogFile, "l", "", "Log file path")
flag.StringVar(&config.LogFile, "log", "", "Log file path")
flag.Parse()
if config.Port == 0 {
if v := os.Getenv("OPENAI_PROXY_PORT"); v != "" {
config.Port, _ = strconv.Atoi(v)
}
}
if config.Port == 0 {
config.Port = 3456
}
if config.Backend == "" {
config.Backend = os.Getenv("OPENAI_PROXY_BACKEND")
}
if config.Model == "" {
config.Model = os.Getenv("OPENAI_PROXY_MODEL")
}
if config.APIKey == "" {
config.APIKey = os.Getenv("OPENAI_PROXY_API_KEY")
}
if config.Timeout == 0 {
if v := os.Getenv("OPENAI_PROXY_TIMEOUT"); v != "" {
config.Timeout, _ = strconv.Atoi(v)
}
}
if config.Timeout == 0 {
config.Timeout = 300
}
if optionsStr := os.Getenv("OPENAI_PROXY_OPTIONS"); optionsStr != "" {
var options map[string]interface{}
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
log.Printf("Warning: failed to parse OPENAI_PROXY_OPTIONS: %v", err)
} else {
config.Options = options
}
}
return config
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.Backend == "" {
return fmt.Errorf("backend URL is required (-b or OPENAI_PROXY_BACKEND)")
}
if c.Model == "" {
return fmt.Errorf("model name is required (-m or OPENAI_PROXY_MODEL)")
}
if c.APIKey == "" {
return fmt.Errorf("API key is required (-k or OPENAI_PROXY_API_KEY)")
}
return nil
}
// NewServer creates a new proxy server
func NewServer(config *Config) *Server {
return &Server{
config: config,
client: &http.Client{
Timeout: time.Duration(config.Timeout) * time.Second,
},
}
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body")
return
}
defer r.Body.Close()
if s.config.Verbose {
log.Printf("Received request: %s", string(body))
}
var anthropicReq AnthropicRequest
if err := json.Unmarshal(body, &anthropicReq); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid JSON")
return
}
openaiReq := s.convertRequest(&anthropicReq)
if anthropicReq.Stream {
s.handleStreamingRequest(w, openaiReq)
} else {
s.handleNonStreamingRequest(w, openaiReq)
}
}
func (s *Server) handleNonStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) {
openaiReq.Stream = false
resp, err := s.forwardRequest(openaiReq)
if err != nil {
s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error())
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
s.errorResponse(w, http.StatusBadGateway, "backend_error", "Failed to read backend response")
return
}
if s.config.Verbose {
log.Printf("Backend response: %s", string(body))
}
if resp.StatusCode != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(body)
return
}
var openaiResp OpenAIResponse
if err := json.Unmarshal(body, &openaiResp); err != nil {
s.errorResponse(w, http.StatusBadGateway, "backend_error", "Invalid backend response")
return
}
anthropicResp := s.convertResponse(&openaiResp)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(anthropicResp)
}
func (s *Server) handleStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) {
openaiReq.Stream = true
openaiReq.StreamOptions = &StreamOptions{IncludeUsage: true}
resp, err := s.forwardRequest(openaiReq)
if err != nil {
s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(body)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
s.errorResponse(w, http.StatusInternalServerError, "server_error", "Streaming not supported")
return
}
msgID := generateID("msg_")
startEvent := AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: msgID,
Type: "message",
Role: "assistant",
Content: []ContentBlock{},
Model: s.config.Model,
StopReason: nil,
StopSequence: nil,
Usage: &Usage{InputTokens: 0, OutputTokens: 0},
},
}
s.writeSSE(w, flusher, startEvent)
s.processStream(w, flusher, resp.Body, msgID)
}
func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body io.Reader, msgID string) {
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
var contentBlockStarted bool
var currentToolCall *ToolCallAccumulator
var toolCalls []*ToolCallAccumulator
var contentIndex int
var finishReason string
var lastUsage *Usage
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chunk OpenAIStreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
if s.config.Verbose {
log.Printf("Failed to parse chunk: %s", data)
}
continue
}
if len(chunk.Choices) == 0 {
if chunk.Usage != nil {
lastUsage = &Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
}
}
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != "" {
finishReason = mapFinishReason(choice.FinishReason)
}
if len(choice.Delta.ToolCalls) > 0 {
for _, tc := range choice.Delta.ToolCalls {
if tc.Index != nil {
idx := *tc.Index
if idx >= len(toolCalls) {
if contentBlockStarted && currentToolCall == nil {
stopEvent := AnthropicStreamEvent{
Type: "content_block_stop",
Index: contentIndex - 1,
}
s.writeSSE(w, flusher, stopEvent)
}
currentToolCall = &ToolCallAccumulator{
Index: idx,
ID: tc.ID,
Name: tc.Function.Name,
Args: "",
}
toolCalls = append(toolCalls, currentToolCall)
startEvent := AnthropicStreamEvent{
Type: "content_block_start",
Index: contentIndex,
ContentBlock: &ContentBlock{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: map[string]interface{}{},
},
}
s.writeSSE(w, flusher, startEvent)
contentIndex++
}
if tc.Function.Arguments != "" {
currentToolCall.Args += tc.Function.Arguments
deltaEvent := AnthropicStreamEvent{
Type: "content_block_delta",
Index: contentIndex - 1,
Delta: &DeltaContent{
Type: "input_json_delta",
PartialJSON: tc.Function.Arguments,
},
}
s.writeSSE(w, flusher, deltaEvent)
}
}
}
continue
}
if choice.Delta.Content != "" {
if !contentBlockStarted {
startEvent := AnthropicStreamEvent{
Type: "content_block_start",
Index: contentIndex,
ContentBlock: &ContentBlock{
Type: "text",
Text: "",
},
}
s.writeSSE(w, flusher, startEvent)
contentBlockStarted = true
contentIndex++
}
deltaEvent := AnthropicStreamEvent{
Type: "content_block_delta",
Index: contentIndex - 1,
Delta: &DeltaContent{
Type: "text_delta",
Text: choice.Delta.Content,
},
}
s.writeSSE(w, flusher, deltaEvent)
}
}
if contentBlockStarted || len(toolCalls) > 0 {
stopEvent := AnthropicStreamEvent{
Type: "content_block_stop",
Index: contentIndex - 1,
}
s.writeSSE(w, flusher, stopEvent)
}
if finishReason == "" {
finishReason = "end_turn"
}
if lastUsage == nil {
lastUsage = &Usage{InputTokens: 0, OutputTokens: 0}
}
deltaEvent := AnthropicStreamEvent{
Type: "message_delta",
Delta: &DeltaContent{
StopReason: &finishReason,
},
Usage: lastUsage,
}
s.writeSSE(w, flusher, deltaEvent)
stopEvent := AnthropicStreamEvent{
Type: "message_stop",
}
s.writeSSE(w, flusher, stopEvent)
}
func (s *Server) writeSSE(w http.ResponseWriter, flusher http.Flusher, event interface{}) {
data, err := json.Marshal(event)
if err != nil {
return
}
eventType := ""
if e, ok := event.(AnthropicStreamEvent); ok {
eventType = e.Type
}
if eventType != "" {
fmt.Fprintf(w, "event: %s\n", eventType)
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
if s.config.Verbose {
log.Printf("SSE event: %s", string(data))
}
}
func (s *Server) forwardRequest(openaiReq *OpenAIRequest) (*http.Response, error) {
body, err := json.Marshal(openaiReq)
if err != nil {
return nil, err
}
if s.config.Verbose {
log.Printf("Forwarding to backend: %s", string(body))
}
req, err := http.NewRequest(http.MethodPost, s.config.Backend, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.config.APIKey)
return s.client.Do(req)
}
func (s *Server) errorResponse(w http.ResponseWriter, status int, errType, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"type": "error",
"error": map[string]string{
"type": errType,
"message": message,
},
})
}
func generateID(prefix string) string {
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
}
func mapFinishReason(reason string) string {
switch reason {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls", "function_call":
return "tool_use"
case "content_filter":
return "end_turn"
default:
return "end_turn"
}
}

View file

@ -1,244 +0,0 @@
package proxy
import "encoding/json"
// ============================================
// Anthropic API Types
// ============================================
type AnthropicRequest struct {
Model string `json:"model"`
Messages []AnthropicMsg `json:"messages"`
System interface{} `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Tools []AnthropicTool `json:"tools,omitempty"`
ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type AnthropicMsg struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Source *ImageSource `json:"source,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input interface{} `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content interface{} `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
type ImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
Data string `json:"data,omitempty"`
URL string `json:"url,omitempty"`
}
type SystemBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
type AnthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema interface{} `json:"input_schema"`
}
type AnthropicToolChoice struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
}
type AnthropicResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ContentBlock `json:"content"`
Model string `json:"model"`
StopReason *string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence,omitempty"`
Usage *Usage `json:"usage"`
}
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
type AnthropicStreamEvent struct {
Type string `json:"type"`
Index int `json:"index,omitempty"`
Message *AnthropicResponse `json:"message,omitempty"`
ContentBlock *ContentBlock `json:"content_block,omitempty"`
Delta *DeltaContent `json:"delta,omitempty"`
Usage *Usage `json:"usage,omitempty"`
}
type DeltaContent struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
}
// ============================================
// OpenAI API Types
// ============================================
type OpenAIRequest struct {
Model string `json:"model"`
Messages []OpenAIMsg `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stop []string `json:"stop,omitempty"`
Tools []OpenAITool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
ExtraOptions map[string]interface{} `json:"-"`
}
func (r OpenAIRequest) MarshalJSON() ([]byte, error) {
m := map[string]interface{}{
"model": r.Model,
"messages": r.Messages,
}
if r.MaxTokens > 0 {
m["max_tokens"] = r.MaxTokens
}
if r.Stream {
m["stream"] = r.Stream
}
if r.StreamOptions != nil {
m["stream_options"] = r.StreamOptions
}
if r.Temperature != nil {
m["temperature"] = *r.Temperature
}
if r.TopP != nil {
m["top_p"] = *r.TopP
}
if len(r.Stop) > 0 {
m["stop"] = r.Stop
}
if len(r.Tools) > 0 {
m["tools"] = r.Tools
}
if r.ToolChoice != nil {
m["tool_choice"] = r.ToolChoice
}
for k, v := range r.ExtraOptions {
if _, exists := m[k]; !exists {
m[k] = v
}
}
return json.Marshal(m)
}
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
type OpenAIMsg struct {
Role string `json:"role"`
Content interface{} `json:"content,omitempty"`
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type OpenAIContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *OpenAIImageURL `json:"image_url,omitempty"`
}
type OpenAIImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type OpenAITool struct {
Type string `json:"type"`
Function OpenAIFunction `json:"function"`
}
type OpenAIFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters interface{} `json:"parameters"`
}
type OpenAIToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function OpenAIFunctionCall `json:"function"`
Index *int `json:"index,omitempty"`
}
type OpenAIFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type OpenAIResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAIChoice `json:"choices"`
Usage *OpenAIUsage `json:"usage,omitempty"`
}
type OpenAIChoice struct {
Index int `json:"index"`
Message OpenAIMsg `json:"message"`
FinishReason string `json:"finish_reason"`
}
type OpenAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type OpenAIStreamChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAIStreamChoice `json:"choices"`
Usage *OpenAIUsage `json:"usage,omitempty"`
}
type OpenAIStreamChoice struct {
Index int `json:"index"`
Delta OpenAIStreamDelta `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
type OpenAIStreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
}
type ToolCallAccumulator struct {
Index int
ID string
Name string
Args string
}

View file

@ -1,79 +0,0 @@
#!/bin/bash
# Build script for Sandbox V2 Docker images (base + test)
# Usage: ./build.sh [true|false] — push to registry or build locally
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PUSH=${1:-false}
REGISTRY=${REGISTRY:-"yaoapp"}
YAO_ROOT="$SCRIPT_DIR/../../.."
echo "=== Building Sandbox V2 Images ==="
echo "Push: $PUSH"
echo "Registry: $REGISTRY"
# --- Cross-compile Go binaries ---
echo ""
echo "=== Building yao-grpc (multi-arch) ==="
cd "$YAO_ROOT/tai/grpc/cmd"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/yao-grpc-amd64" .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/yao-grpc-arm64" .
echo "Built: yao-grpc-amd64, yao-grpc-arm64"
echo ""
echo "=== Building openai-proxy (multi-arch) ==="
cd "$SCRIPT_DIR/bin/openai-proxy/cmd/openai-proxy"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-amd64" .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-arm64" .
echo "Built: openai-proxy-amd64, openai-proxy-arm64"
cd "$SCRIPT_DIR"
# --- Setup buildx ---
BUILDER_NAME="yao-multiarch"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
echo "Creating buildx builder: $BUILDER_NAME"
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
else
docker buildx use "$BUILDER_NAME"
fi
build_image() {
local IMAGE_NAME=$1
local CONTEXT_DIR=$2
local PUSH_FLAG=$3
echo ""
echo "=== Building $IMAGE_NAME (linux/amd64,linux/arm64) ==="
local BUILD_ARGS="--platform linux/amd64,linux/arm64 -t ${REGISTRY}/${IMAGE_NAME}:latest"
if [ "$PUSH_FLAG" = "true" ]; then
BUILD_ARGS="$BUILD_ARGS --push"
else
echo "Note: Multi-arch build without push. Building for current platform only."
BUILD_ARGS="--load -t ${REGISTRY}/${IMAGE_NAME}:latest"
fi
docker buildx build $BUILD_ARGS -f "$CONTEXT_DIR/Dockerfile" "$CONTEXT_DIR"
}
# --- Build images ---
build_image "sandbox-v2-base" "$SCRIPT_DIR/base" "$PUSH"
build_image "sandbox-v2-test" "$SCRIPT_DIR/test" "$PUSH"
# --- Cleanup binaries ---
echo ""
echo "=== Cleanup ==="
rm -f "$SCRIPT_DIR/base/yao-grpc-amd64" "$SCRIPT_DIR/base/yao-grpc-arm64"
rm -f "$SCRIPT_DIR/base/openai-proxy-amd64" "$SCRIPT_DIR/base/openai-proxy-arm64"
echo "Removed temporary binary files"
echo ""
echo "=== Build complete ==="
docker images | grep -E "sandbox-v2" | head -10 || true

View file

@ -1,28 +0,0 @@
# Sandbox V2 test image — adds test services + VNC desktop on top of v2-base
FROM yaoapp/sandbox-v2-base:latest
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx \
python3 \
python3-pip \
xvfb \
x11vnc \
fluxbox \
xterm \
&& pip3 install --break-system-packages websockets websockify \
&& rm -rf /var/lib/apt/lists/*
# Test service scripts
COPY ws-echo.py /opt/test/ws-echo.py
COPY sse-server.py /opt/test/sse-server.py
COPY entrypoint.sh /test-entrypoint.sh
RUN chmod +x /test-entrypoint.sh
ENV DISPLAY=:99
USER sandbox
EXPOSE 5900 6080
ENTRYPOINT ["/usr/bin/tini", "--", "/test-entrypoint.sh"]
CMD ["sleep", "infinity"]

View file

@ -1,22 +0,0 @@
#!/bin/bash
# V2 test entrypoint — starts test services + VNC desktop then delegates to base entrypoint
# Start Xvfb (virtual framebuffer)
Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset &
sleep 0.5
# Start fluxbox window manager
fluxbox &
# Start x11vnc (raw RFB on 5900)
x11vnc -display :99 -rfbport 5900 -nopw -shared -forever -xkb -ncache 10 &
sleep 0.3
# Start websockify (WebSocket on 6080 → RFB 5900)
websockify 0.0.0.0:6080 localhost:5900 &
# Test services
python3 /opt/test/ws-echo.py &
python3 /opt/test/sse-server.py &
exec /entrypoint.sh "$@"

View file

@ -1,28 +0,0 @@
"""Minimal SSE server on port 9801 using only stdlib.
Sends a 'hello' event every second, up to 5 events then closes."""
import http.server
import time
class SSEHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
for i in range(5):
msg = f"data: hello-{i}\n\n"
try:
self.wfile.write(msg.encode())
self.wfile.flush()
except BrokenPipeError:
return
time.sleep(0.2)
def log_message(self, format, *args):
pass
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", 9801), SSEHandler)
server.serve_forever()

View file

@ -1,14 +0,0 @@
"""WebSocket echo server on port 9800 using the websockets library."""
import asyncio
import websockets
async def echo(ws):
async for msg in ws:
await ws.send(msg)
async def main():
async with websockets.serve(echo, "0.0.0.0", 9800):
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,6 +1,7 @@
package service
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
@ -15,8 +16,23 @@ import (
// requests internally without an HTTP round-trip.
var Router *gin.Engine
// Start the yao service
func Start(cfg config.Config) (*http.Server, error) {
// ServerHooks allows the caller to inject gRPC (or other) server lifecycle
// without creating import cycles.
type ServerHooks struct {
Start func(cfg config.Config) error // called before HTTP starts; nil = skip
Stop func() // called on shutdown; nil = skip
Addrs func() []string // returns listen addresses; nil = skip
}
// Service manages HTTP and optional gRPC servers as a single unit.
type Service struct {
http *http.Server
hooks ServerHooks
}
// Start launches optional hook servers (e.g. gRPC) and the HTTP server.
// Returns a Service handle for shutdown coordination.
func Start(cfg config.Config, hooks ...ServerHooks) (*Service, error) {
if cfg.AllowFrom == nil {
cfg.AllowFrom = []string{}
@ -27,29 +43,31 @@ func Start(cfg config.Config) (*http.Server, error) {
return nil, err
}
var h ServerHooks
if len(hooks) > 0 {
h = hooks[0]
}
// Start hook server (gRPC, etc.)
if h.Start != nil {
if err := h.Start(cfg); err != nil {
return nil, err
}
}
router := gin.New()
Router = router
router.Use(Middlewares...)
var apiRoot string
if openapi.Server != nil {
// OpenAPI mode: use OAuth guards and dynamic routing
apiRoot = openapi.Server.Config.BaseURL
api.SetGuards(OpenAPIGuards())
// Developer APIs: use dynamic proxy (supports hot-reload)
router.Any(apiRoot+"/api/*path", DynamicAPIHandler)
// Widgets and system APIs: static registration
api.SetRoutes(router, apiRoot, cfg.AllowFrom...)
// Build route table for dynamic lookup
api.BuildRouteTable()
// Attach OpenAPI built-in features
openapi.Server.Attach(router)
} else {
// Traditional mode: unchanged
apiRoot = "/api"
api.SetGuards(Guards)
api.SetRoutes(router, "/api", cfg.AllowFrom...)
@ -63,21 +81,57 @@ func Start(cfg config.Config) (*http.Server, error) {
Timeout: 5 * time.Second,
})
// Start HTTP in background; wait for the first event to confirm
// the port is bound before returning.
go func() {
err = srv.Start()
srv.Start()
}()
return srv, nil
// Block until HTTP reports READY or ERROR
ev := <-srv.Event()
if ev != http.READY {
if h.Stop != nil {
h.Stop()
}
return nil, fmt.Errorf("HTTP server failed to start on %s:%d", cfg.Host, cfg.Port)
}
return &Service{http: srv, hooks: h}, nil
}
// Restart the yao service
func Restart(srv *http.Server, cfg config.Config) error {
// Event returns the HTTP server event channel (READY, CLOSED, ERROR).
func (s *Service) Event() chan uint8 {
return s.http.Event()
}
// Stop shuts down hook servers (gRPC, etc.) then signals the HTTP server to close.
func (s *Service) Stop() {
if s.hooks.Stop != nil {
s.hooks.Stop()
}
s.http.Stop()
}
// HookAddrs returns the hook server listen addresses (e.g. gRPC addresses).
func (s *Service) HookAddrs() []string {
if s.hooks.Addrs != nil {
return s.hooks.Addrs()
}
return nil
}
// Watch starts file watching in development mode. Blocking; run in a goroutine.
func (s *Service) Watch(done chan uint8) {
watch(s, done)
}
// Restart the HTTP server with a fresh router (hook servers stay running).
func Restart(svc *Service, cfg config.Config) error {
router := gin.New()
Router = router
router.Use(Middlewares...)
if openapi.Server != nil {
// OpenAPI mode
baseURL := openapi.Server.Config.BaseURL
api.SetGuards(OpenAPIGuards())
router.Any(baseURL+"/api/*path", DynamicAPIHandler)
@ -85,28 +139,15 @@ func Restart(srv *http.Server, cfg config.Config) error {
api.BuildRouteTable()
openapi.Server.Attach(router)
} else {
// Traditional mode: unchanged
api.SetGuards(Guards)
api.SetRoutes(router, "/api", cfg.AllowFrom...)
}
srv.Reset(router)
return srv.Restart()
}
// Stop the yao service
func Stop(srv *http.Server) error {
err := srv.Stop()
if err != nil {
return err
}
<-srv.Event()
return nil
svc.http.Reset(router)
return svc.http.Restart()
}
func prepare() error {
// Session server
err := share.SessionStart()
if err != nil {
return err

View file

@ -31,24 +31,12 @@ func TestStartStop(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer Stop(srv)
defer srv.Stop()
<-srv.Event()
if !srv.Ready() {
t.Fatal("server not ready")
}
port, err := srv.Port()
if err != nil {
t.Fatal(err)
}
if port <= 0 {
t.Fatal("invalid port")
}
// API Server
req := test.NewRequest(port).Route("/api/__yao/app/setting")
req := test.NewRequest(cfg.Port).Route("/api/__yao/app/setting")
res, err := req.Get()
if err != nil {
t.Fatal(err)
@ -58,11 +46,10 @@ func TestStartStop(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// assert.Equal(t, "Demo Application", data["name"])
assert.True(t, len(data["name"].(string)) > 0)
// Public
req = test.NewRequest(port).Route("/")
req = test.NewRequest(cfg.Port).Route("/")
res, err = req.Get()
if err != nil {
t.Fatal(err)
@ -71,7 +58,7 @@ func TestStartStop(t *testing.T) {
assert.Equal(t, "Hello World\n", res.Body())
// XGEN
req = test.NewRequest(port).Route("/admin/")
req = test.NewRequest(cfg.Port).Route("/admin/")
res, err = req.Get()
if err != nil {
t.Fatal(err)

View file

@ -6,14 +6,13 @@ import (
"github.com/fatih/color"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/gou/server/http"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/openapi"
)
// Watch the application code change for hot update
func Watch(srv *http.Server, interrupt chan uint8) (err error) {
func watch(svc *Service, interrupt chan uint8) error {
if application.App == nil {
return fmt.Errorf("Application is not initialized")
@ -24,23 +23,19 @@ func Watch(srv *http.Server, interrupt chan uint8) (err error) {
return
}
// Reload
err = engine.Reload(config.Conf, engine.LoadOption{Action: "watch"})
err := engine.Reload(config.Conf, engine.LoadOption{Action: "watch"})
if err != nil {
fmt.Println(color.RedString("[Watch] Reload: %s", err.Error()))
return
}
fmt.Println(color.GreenString("[Watch] Reload Completed"))
// Model
if strings.HasPrefix(name, "/models") {
fmt.Println(color.GreenString("[Watch] Model: %s changed (Please run yao migrate manually)", name))
}
// API changes: hot reload or restart
if strings.HasPrefix(name, "/apis") {
if openapi.Server != nil {
// OpenAPI mode: hot reload (no server restart needed)
err = ReloadAPIs()
if err != nil {
fmt.Println(color.RedString("[Watch] Reload APIs: %s", err.Error()))
@ -48,8 +43,7 @@ func Watch(srv *http.Server, interrupt chan uint8) (err error) {
}
fmt.Println(color.GreenString("[Watch] APIs Reloaded"))
} else {
// Traditional mode: restart server
err = Restart(srv, config.Conf)
err = Restart(svc, config.Conf)
if err != nil {
fmt.Println(color.RedString("[Watch] Restart: %s", err.Error()))
return

View file

@ -18,10 +18,10 @@ func TestWatch(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer Stop(srv)
defer srv.Stop()
done := make(chan uint8, 1)
go Watch(srv, done)
go srv.Watch(done)
select {
case <-time.After(200 * time.Millisecond):

205
tai/api/register.go Normal file
View file

@ -0,0 +1,205 @@
package api
import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/tai/registry"
)
// authenticateBearer validates a Bearer token and returns the caller's identity.
// Package-level var so tests can inject a mock without an OAuth service.
var authenticateBearer = authenticateBearerDefault
func authenticateBearerDefault(token string) (registry.AuthInfo, error) {
svc := oauth.OAuth
if svc == nil {
return registry.AuthInfo{}, fmt.Errorf("oauth service not initialized")
}
result, err := svc.AuthenticateToken(oauth.AuthInput{AccessToken: token})
if err != nil {
return registry.AuthInfo{}, err
}
info := registry.AuthInfo{}
if result.Info != nil {
info.Subject = result.Info.Subject
info.UserID = result.Info.UserID
info.ClientID = result.Info.ClientID
info.Scope = result.Info.Scope
info.TeamID = result.Info.TeamID
info.TenantID = result.Info.TenantID
}
return info, nil
}
func extractBearer(r *http.Request) string {
auth := r.Header.Get("Authorization")
if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") {
return auth[7:]
}
return ""
}
// registerRequest is the JSON body for POST /tai-nodes/register.
type registerRequest struct {
TaiID string `json:"tai_id"`
MachineID string `json:"machine_id"`
Version string `json:"version"`
Addr string `json:"addr"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
System registry.SystemInfo `json:"system"`
}
// heartbeatRequest is the JSON body for POST /tai-nodes/heartbeat.
type heartbeatRequest struct {
TaiID string `json:"tai_id"`
}
// HandleRegister handles POST /tai-nodes/register.
// Validates Bearer token, extracts AuthInfo, and writes the node to the Registry.
func HandleRegister(c *gin.Context) {
reg := registry.Global()
if reg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
return
}
bearer := extractBearer(c.Request)
if bearer == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
authInfo, err := authenticateBearer(bearer)
if err != nil {
slog.Warn("tai register auth failed", "err", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if req.TaiID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
return
}
node := &registry.TaiNode{
TaiID: req.TaiID,
MachineID: req.MachineID,
Version: req.Version,
Auth: authInfo,
System: req.System,
Mode: "direct",
Addr: req.Addr,
Ports: req.Ports,
Capabilities: req.Capabilities,
}
reg.Register(node)
remoteIP := c.ClientIP()
slog.Info("tai node registered via API",
"tai_id", req.TaiID, "remote_ip", remoteIP, "user_id", authInfo.UserID)
c.JSON(http.StatusOK, gin.H{
"status": "registered",
"tai_id": req.TaiID,
"remote_ip": remoteIP,
})
}
// HandleHeartbeat handles POST /tai-nodes/heartbeat.
// Validates Bearer token and updates the node's last ping timestamp.
func HandleHeartbeat(c *gin.Context) {
reg := registry.Global()
if reg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
return
}
bearer := extractBearer(c.Request)
if bearer == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
authInfo, err := authenticateBearer(bearer)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
var req heartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if req.TaiID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
return
}
snap, ok := reg.Get(req.TaiID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "tai node not found"})
return
}
if snap.Auth.ClientID != authInfo.ClientID {
c.JSON(http.StatusForbidden, gin.H{"error": "tai_id does not belong to this client"})
return
}
reg.UpdatePing(req.TaiID)
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// HandleUnregister handles DELETE /tai-nodes/register/:tai_id.
// Validates Bearer token, checks ownership, and removes the node.
func HandleUnregister(c *gin.Context) {
reg := registry.Global()
if reg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
return
}
bearer := extractBearer(c.Request)
if bearer == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
authInfo, err := authenticateBearer(bearer)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
taiID := c.Param("tai_id")
if taiID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
return
}
snap, ok := reg.Get(taiID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "tai node not found"})
return
}
if snap.Auth.ClientID != authInfo.ClientID {
c.JSON(http.StatusForbidden, gin.H{"error": "tai_id does not belong to this client"})
return
}
reg.Unregister(taiID)
slog.Info("tai node unregistered via API", "tai_id", taiID, "user_id", authInfo.UserID)
c.JSON(http.StatusOK, gin.H{"status": "unregistered"})
}

267
tai/api/register_test.go Normal file
View file

@ -0,0 +1,267 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/tai/registry"
)
func init() {
gin.SetMode(gin.TestMode)
}
func setupTest() func() {
r := registry.NewForTest()
registry.SetGlobalForTest(r)
origAuth := authenticateBearer
authenticateBearer = func(token string) (registry.AuthInfo, error) {
return registry.AuthInfo{
Subject: "sub-001",
UserID: "user-alice",
ClientID: "tai-abc123",
Scope: "tai:connect",
TeamID: "team-dev",
}, nil
}
return func() {
authenticateBearer = origAuth
registry.SetGlobalForTest(nil)
}
}
func jsonBody(v interface{}) *bytes.Buffer {
b, _ := json.Marshal(v)
return bytes.NewBuffer(b)
}
func TestHandleRegister_Success(t *testing.T) {
teardown := setupTest()
defer teardown()
body := registerRequest{
TaiID: "tai-abc123",
MachineID: "m-001",
Version: "0.2.0",
Addr: "192.168.1.100",
Ports: map[string]int{"grpc": 9100, "http": 8080},
Capabilities: map[string]bool{"docker": true, "host_exec": false},
System: registry.SystemInfo{
OS: "linux", Arch: "amd64", Hostname: "docker-host-01", NumCPU: 16,
},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/tai-nodes/register", jsonBody(body))
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Request.Header.Set("Content-Type", "application/json")
HandleRegister(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "registered" {
t.Errorf("status = %v, want registered", resp["status"])
}
if resp["tai_id"] != "tai-abc123" {
t.Errorf("tai_id = %v, want tai-abc123", resp["tai_id"])
}
if _, ok := resp["remote_ip"]; !ok {
t.Error("response missing remote_ip")
}
snap, ok := registry.Global().Get("tai-abc123")
if !ok {
t.Fatal("node not found in registry after register")
}
if snap.Mode != "direct" {
t.Errorf("Mode = %q, want direct", snap.Mode)
}
if snap.System.OS != "linux" {
t.Errorf("System.OS = %q, want linux", snap.System.OS)
}
if snap.Auth.UserID != "user-alice" {
t.Errorf("Auth.UserID = %q, want user-alice", snap.Auth.UserID)
}
}
func TestHandleRegister_MissingAuth(t *testing.T) {
teardown := setupTest()
defer teardown()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/tai-nodes/register", jsonBody(registerRequest{TaiID: "x"}))
c.Request.Header.Set("Content-Type", "application/json")
HandleRegister(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestHandleRegister_MissingTaiID(t *testing.T) {
teardown := setupTest()
defer teardown()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/tai-nodes/register", jsonBody(registerRequest{}))
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Request.Header.Set("Content-Type", "application/json")
HandleRegister(c)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestHandleHeartbeat_Success(t *testing.T) {
teardown := setupTest()
defer teardown()
reg := registry.Global()
reg.Register(&registry.TaiNode{
TaiID: "tai-abc123",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "tai-abc123"},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/tai-nodes/heartbeat",
jsonBody(heartbeatRequest{TaiID: "tai-abc123"}))
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Request.Header.Set("Content-Type", "application/json")
HandleHeartbeat(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestHandleHeartbeat_WrongOwner(t *testing.T) {
teardown := setupTest()
defer teardown()
reg := registry.Global()
reg.Register(&registry.TaiNode{
TaiID: "tai-other",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "different-client"},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/tai-nodes/heartbeat",
jsonBody(heartbeatRequest{TaiID: "tai-other"}))
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Request.Header.Set("Content-Type", "application/json")
HandleHeartbeat(c)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestHandleHeartbeat_NotFound(t *testing.T) {
teardown := setupTest()
defer teardown()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/tai-nodes/heartbeat",
jsonBody(heartbeatRequest{TaiID: "ghost"}))
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Request.Header.Set("Content-Type", "application/json")
HandleHeartbeat(c)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
}
func TestHandleUnregister_Success(t *testing.T) {
teardown := setupTest()
defer teardown()
reg := registry.Global()
reg.Register(&registry.TaiNode{
TaiID: "tai-abc123",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "tai-abc123"},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("DELETE", "/tai-nodes/register/tai-abc123", nil)
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Params = gin.Params{{Key: "tai_id", Value: "tai-abc123"}}
HandleUnregister(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
}
if _, ok := reg.Get("tai-abc123"); ok {
t.Error("node should be removed after unregister")
}
}
func TestHandleUnregister_WrongOwner(t *testing.T) {
teardown := setupTest()
defer teardown()
reg := registry.Global()
reg.Register(&registry.TaiNode{
TaiID: "tai-other",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "different-client"},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("DELETE", "/tai-nodes/register/tai-other", nil)
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Params = gin.Params{{Key: "tai_id", Value: "tai-other"}}
HandleUnregister(c)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestHandleUnregister_NotFound(t *testing.T) {
teardown := setupTest()
defer teardown()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("DELETE", "/tai-nodes/register/ghost", nil)
c.Request.Header.Set("Authorization", "Bearer test-token")
c.Params = gin.Params{{Key: "tai_id", Value: "ghost"}}
HandleUnregister(c)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
}

View file

@ -1,267 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"syscall"
yaogrpc "github.com/yaoapp/yao/tai/grpc"
)
// Build-time variables set via -ldflags.
var (
Version = "dev"
Commit = "none"
BuildTime = "unknown"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: yao-grpc <version|serve>")
os.Exit(1)
}
switch os.Args[1] {
case "version":
fmt.Printf("yao-grpc %s (commit: %s, built: %s)\n", Version, Commit, BuildTime)
case "serve":
if err := serve(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\nUsage: yao-grpc <version|serve>\n", os.Args[1])
os.Exit(1)
}
}
// jsonrpcRequest is a minimal JSON-RPC 2.0 request.
type jsonrpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// jsonrpcResponse is a minimal JSON-RPC 2.0 response.
type jsonrpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonrpcError `json:"error,omitempty"`
}
type jsonrpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func serve() error {
client, err := yaogrpc.NewFromEnv()
if err != nil {
return err
}
defer client.Close()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if sandboxID := os.Getenv("YAO_SANDBOX_ID"); sandboxID != "" {
go yaogrpc.HeartbeatLoop(ctx, client, sandboxID)
}
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024)
encoder := json.NewEncoder(os.Stdout)
for scanner.Scan() {
select {
case <-ctx.Done():
return nil
default:
}
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req jsonrpcRequest
if err := json.Unmarshal(line, &req); err != nil {
encoder.Encode(jsonrpcResponse{
JSONRPC: "2.0",
Error: &jsonrpcError{Code: -32700, Message: "parse error"},
})
continue
}
resp := dispatch(ctx, client, &req)
encoder.Encode(resp)
}
if err := scanner.Err(); err != nil && err != io.EOF {
return fmt.Errorf("stdin read: %w", err)
}
return nil
}
func dispatch(ctx context.Context, client *yaogrpc.Client, req *jsonrpcRequest) jsonrpcResponse {
base := jsonrpcResponse{JSONRPC: "2.0", ID: req.ID}
switch req.Method {
case "run":
return handleRun(ctx, client, req, base)
case "shell":
return handleShell(ctx, client, req, base)
case "mcp/list_tools":
return handleMCPListTools(ctx, client, req, base)
case "mcp/call_tool":
return handleMCPCallTool(ctx, client, req, base)
case "mcp/list_resources":
return handleMCPListResources(ctx, client, req, base)
case "mcp/read_resource":
return handleMCPReadResource(ctx, client, req, base)
case "healthz":
return handleHealthz(ctx, client, base)
default:
base.Error = &jsonrpcError{Code: -32601, Message: "method not found: " + req.Method}
return base
}
}
// --- handlers ---
type runParams struct {
Process string `json:"process"`
Args json.RawMessage `json:"args,omitempty"`
Timeout int32 `json:"timeout,omitempty"`
}
func handleRun(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
var p runParams
if err := json.Unmarshal(req.Params, &p); err != nil {
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
return base
}
data, err := c.Run(ctx, p.Process, p.Args, p.Timeout)
if err != nil {
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
return base
}
base.Result = data
return base
}
type shellParams struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
Timeout int32 `json:"timeout,omitempty"`
}
func handleShell(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
var p shellParams
if err := json.Unmarshal(req.Params, &p); err != nil {
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
return base
}
resp, err := c.Shell(ctx, p.Command, p.Args, p.Env, p.Timeout)
if err != nil {
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
return base
}
data, _ := json.Marshal(resp)
base.Result = data
return base
}
type mcpSessionParams struct {
SessionID string `json:"session_id"`
}
func handleMCPListTools(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
var p mcpSessionParams
if err := json.Unmarshal(req.Params, &p); err != nil {
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
return base
}
data, err := c.MCPListTools(ctx, p.SessionID)
if err != nil {
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
return base
}
base.Result = data
return base
}
type mcpCallParams struct {
SessionID string `json:"session_id"`
Tool string `json:"tool"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
func handleMCPCallTool(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
var p mcpCallParams
if err := json.Unmarshal(req.Params, &p); err != nil {
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
return base
}
data, err := c.MCPCallTool(ctx, p.SessionID, p.Tool, p.Arguments)
if err != nil {
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
return base
}
base.Result = data
return base
}
func handleMCPListResources(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
var p mcpSessionParams
if err := json.Unmarshal(req.Params, &p); err != nil {
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
return base
}
data, err := c.MCPListResources(ctx, p.SessionID)
if err != nil {
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
return base
}
base.Result = data
return base
}
type mcpReadParams struct {
SessionID string `json:"session_id"`
URI string `json:"uri"`
}
func handleMCPReadResource(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
var p mcpReadParams
if err := json.Unmarshal(req.Params, &p); err != nil {
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
return base
}
data, err := c.MCPReadResource(ctx, p.SessionID, p.URI)
if err != nil {
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
return base
}
base.Result = data
return base
}
func handleHealthz(ctx context.Context, c *yaogrpc.Client, base jsonrpcResponse) jsonrpcResponse {
status, err := c.Healthz(ctx)
if err != nil {
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
return base
}
data, _ := json.Marshal(map[string]string{"status": status})
base.Result = data
return base
}

View file

@ -1,210 +0,0 @@
package grpc_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
yaogrpc "github.com/yaoapp/yao/tai/grpc"
)
// ── TokenManager unit tests ──────────────────────────────────────────────────
func TestTokenManager_AttachMetadata_WithAllFields(t *testing.T) {
tm := yaogrpc.NewTokenManager("tok", "ref", "sb-1", "yao:9099")
ctx := tm.AttachMetadata(context.Background())
md, ok := metadata.FromOutgoingContext(ctx)
require.True(t, ok)
assert.Equal(t, []string{"Bearer tok"}, md.Get("authorization"))
assert.Equal(t, []string{"ref"}, md.Get("x-refresh-token"))
assert.Equal(t, []string{"sb-1"}, md.Get("x-sandbox-id"))
assert.Equal(t, []string{"yao:9099"}, md.Get("x-grpc-upstream"))
}
func TestTokenManager_AttachMetadata_DirectMode(t *testing.T) {
tm := yaogrpc.NewTokenManager("tok", "ref", "sb-1", "")
ctx := tm.AttachMetadata(context.Background())
md, ok := metadata.FromOutgoingContext(ctx)
require.True(t, ok)
assert.Equal(t, []string{"Bearer tok"}, md.Get("authorization"))
assert.Empty(t, md.Get("x-grpc-upstream"), "direct mode should not set x-grpc-upstream")
}
func TestTokenManager_AttachMetadata_EmptyTokens(t *testing.T) {
tm := yaogrpc.NewTokenManager("", "", "", "")
ctx := tm.AttachMetadata(context.Background())
_, ok := metadata.FromOutgoingContext(ctx)
assert.False(t, ok, "empty tokens should not produce metadata")
}
func TestTokenManager_HandleResponseHeaders(t *testing.T) {
tm := yaogrpc.NewTokenManager("old-tok", "old-ref", "", "")
tm.HandleResponseHeaders(metadata.New(map[string]string{
"x-access-token": "new-tok",
"x-refresh-token": "new-ref",
}))
assert.Equal(t, "new-tok", tm.AccessToken())
ctx := tm.AttachMetadata(context.Background())
md, _ := metadata.FromOutgoingContext(ctx)
assert.Equal(t, []string{"Bearer new-tok"}, md.Get("authorization"))
assert.Equal(t, []string{"new-ref"}, md.Get("x-refresh-token"))
}
func TestTokenManager_HandleResponseHeaders_Nil(t *testing.T) {
tm := yaogrpc.NewTokenManager("tok", "", "", "")
tm.HandleResponseHeaders(nil)
assert.Equal(t, "tok", tm.AccessToken())
}
func TestTokenManager_HandleResponseHeaders_EmptyValues(t *testing.T) {
tm := yaogrpc.NewTokenManager("tok", "ref", "", "")
tm.HandleResponseHeaders(metadata.New(map[string]string{
"x-access-token": "",
}))
assert.Equal(t, "tok", tm.AccessToken(), "empty header should not overwrite")
}
func TestTokenManager_IsTaiMode(t *testing.T) {
tmDirect := yaogrpc.NewTokenManager("tok", "", "", "")
assert.False(t, tmDirect.IsTaiMode())
tmTai := yaogrpc.NewTokenManager("tok", "", "", "tai:9100")
assert.True(t, tmTai.IsTaiMode())
}
func TestTokenManager_NewFromEnv_MissingUpstream(t *testing.T) {
t.Setenv("YAO_GRPC_TAI", "enable")
t.Setenv("YAO_GRPC_UPSTREAM", "")
t.Setenv("YAO_TOKEN", "tok")
_, err := yaogrpc.NewTokenManagerFromEnv()
assert.Error(t, err)
assert.Contains(t, err.Error(), "YAO_GRPC_UPSTREAM")
}
func TestTokenManager_NewFromEnv_TaiEnabled(t *testing.T) {
t.Setenv("YAO_GRPC_TAI", "enable")
t.Setenv("YAO_GRPC_UPSTREAM", "yao:9099")
t.Setenv("YAO_TOKEN", "my-token")
t.Setenv("YAO_REFRESH_TOKEN", "my-refresh")
t.Setenv("YAO_SANDBOX_ID", "sb-42")
tm, err := yaogrpc.NewTokenManagerFromEnv()
require.NoError(t, err)
assert.True(t, tm.IsTaiMode())
assert.Equal(t, "my-token", tm.AccessToken())
}
func TestTokenManager_NewFromEnv_DirectMode(t *testing.T) {
t.Setenv("YAO_GRPC_TAI", "")
t.Setenv("YAO_GRPC_UPSTREAM", "")
t.Setenv("YAO_TOKEN", "tok")
tm, err := yaogrpc.NewTokenManagerFromEnv()
require.NoError(t, err)
assert.False(t, tm.IsTaiMode())
}
// ── Dial tests ───────────────────────────────────────────────────────────────
func TestNewFromEnv_MissingAddr(t *testing.T) {
t.Setenv("YAO_GRPC_ADDR", "")
_, err := yaogrpc.NewFromEnv()
assert.Error(t, err)
assert.Contains(t, err.Error(), "YAO_GRPC_ADDR")
}
func TestNewFromEnv_Success(t *testing.T) {
t.Setenv("YAO_GRPC_ADDR", "127.0.0.1:9099")
t.Setenv("YAO_TOKEN", "test-token")
t.Setenv("YAO_REFRESH_TOKEN", "test-refresh")
t.Setenv("YAO_SANDBOX_ID", "sb-1")
t.Setenv("YAO_GRPC_TAI", "")
c, err := yaogrpc.NewFromEnv()
require.NoError(t, err)
defer c.Close()
assert.NotNil(t, c.Conn())
assert.Equal(t, "test-token", c.TokenManager().AccessToken())
}
func TestNewFromEnv_TaiMode_MissingUpstream(t *testing.T) {
t.Setenv("YAO_GRPC_ADDR", "tai:9100")
t.Setenv("YAO_GRPC_TAI", "enable")
t.Setenv("YAO_GRPC_UPSTREAM", "")
_, err := yaogrpc.NewFromEnv()
assert.Error(t, err)
assert.Contains(t, err.Error(), "YAO_GRPC_UPSTREAM")
}
func TestDial_WithNilTokenManager(t *testing.T) {
c, err := yaogrpc.Dial("127.0.0.1:0", nil)
require.NoError(t, err)
defer c.Close()
assert.NotNil(t, c.Conn())
assert.Nil(t, c.TokenManager())
}
func TestDial_WithTokenManager(t *testing.T) {
tm := yaogrpc.NewTokenManager("tok", "", "", "")
c, err := yaogrpc.Dial("127.0.0.1:0", tm)
require.NoError(t, err)
defer c.Close()
assert.NotNil(t, c.TokenManager())
assert.False(t, c.TokenManager().IsTaiMode())
}
func TestDial_PassthroughPrefix_BareAddress(t *testing.T) {
c, err := yaogrpc.Dial("host.docker.internal:9099", nil)
require.NoError(t, err)
defer c.Close()
assert.Equal(t, "passthrough:///host.docker.internal:9099", c.Conn().Target())
}
func TestDial_PassthroughPrefix_IPAddress(t *testing.T) {
c, err := yaogrpc.Dial("192.168.1.100:9100", nil)
require.NoError(t, err)
defer c.Close()
assert.Equal(t, "passthrough:///192.168.1.100:9100", c.Conn().Target())
}
func TestDial_PassthroughPrefix_PreservesExistingScheme(t *testing.T) {
tests := []struct {
addr string
target string
}{
{"dns:///myhost:9099", "dns:///myhost:9099"},
{"passthrough:///127.0.0.1:9099", "passthrough:///127.0.0.1:9099"},
{"unix:///var/run/grpc.sock", "unix:///var/run/grpc.sock"},
}
for _, tt := range tests {
t.Run(tt.addr, func(t *testing.T) {
c, err := yaogrpc.Dial(tt.addr, nil)
require.NoError(t, err)
defer c.Close()
assert.Equal(t, tt.target, c.Conn().Target())
})
}
}
func TestClient_Close_Nil(t *testing.T) {
c := &yaogrpc.Client{}
assert.NoError(t, c.Close())
}

View file

@ -1,194 +0,0 @@
package grpc
import (
"context"
"net"
"sync/atomic"
"testing"
"time"
"github.com/yaoapp/yao/grpc/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestCountUserProcesses(t *testing.T) {
n := countUserProcesses()
if n < 0 {
t.Errorf("countUserProcesses() = %d, want >= 0", n)
}
}
func TestSampleResources(t *testing.T) {
cpu, mem := sampleResources()
if cpu < 0 || mem < 0 {
t.Errorf("sampleResources() = (%d, %d), want non-negative", cpu, mem)
}
}
// ── HeartbeatLoop tests with mock gRPC server ───────────────────────────────
type mockYaoServer struct {
pb.UnimplementedYaoServer
calls atomic.Int32
action string
}
func (m *mockYaoServer) Heartbeat(_ context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) {
m.calls.Add(1)
return &pb.HeartbeatResponse{Action: m.action}, nil
}
func startMockServer(t *testing.T, srv *mockYaoServer) (addr string, stop func()) {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
s := grpc.NewServer()
pb.RegisterYaoServer(s, srv)
go s.Serve(lis)
return lis.Addr().String(), s.Stop
}
func dialClient(t *testing.T, addr string) *Client {
t.Helper()
c, err := Dial(addr, nil)
if err != nil {
t.Fatal(err)
}
return c
}
func TestHeartbeatLoop_SendsHeartbeats(t *testing.T) {
mock := &mockYaoServer{action: "ok"}
addr, stop := startMockServer(t, mock)
defer stop()
client := dialClient(t, addr)
defer client.Close()
t.Setenv("YAO_HEARTBEAT_INTERVAL", "50ms")
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
HeartbeatLoop(ctx, client, "sb-test")
calls := mock.calls.Load()
if calls < 2 {
t.Errorf("expected at least 2 heartbeat calls, got %d", calls)
}
}
func TestHeartbeatLoop_ShutdownAction(t *testing.T) {
mock := &mockYaoServer{action: "shutdown"}
addr, stop := startMockServer(t, mock)
defer stop()
client := dialClient(t, addr)
defer client.Close()
action, err := client.Heartbeat(context.Background(), "sb-shutdown", 0, 0, 0)
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
if action != "shutdown" {
t.Errorf("action = %q, want %q", action, "shutdown")
}
if mock.calls.Load() != 1 {
t.Errorf("expected 1 call, got %d", mock.calls.Load())
}
}
func TestHeartbeatLoop_ContextCancelStops(t *testing.T) {
mock := &mockYaoServer{action: "ok"}
addr, stop := startMockServer(t, mock)
defer stop()
client := dialClient(t, addr)
defer client.Close()
t.Setenv("YAO_HEARTBEAT_INTERVAL", "5s")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
HeartbeatLoop(ctx, client, "sb-cancel")
close(done)
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("HeartbeatLoop did not stop after context cancel")
}
}
func TestHeartbeatLoop_IntervalParsing(t *testing.T) {
mock := &mockYaoServer{action: "ok"}
addr, stop := startMockServer(t, mock)
defer stop()
client := dialClient(t, addr)
defer client.Close()
t.Setenv("YAO_HEARTBEAT_INTERVAL", "30ms")
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
HeartbeatLoop(ctx, client, "sb-interval")
calls := mock.calls.Load()
if calls < 3 {
t.Errorf("with 30ms interval over 150ms, expected >= 3 calls, got %d", calls)
}
}
func TestHeartbeatLoop_InvalidIntervalUsesDefault(t *testing.T) {
mock := &mockYaoServer{action: "ok"}
addr, stop := startMockServer(t, mock)
defer stop()
client := dialClient(t, addr)
defer client.Close()
t.Setenv("YAO_HEARTBEAT_INTERVAL", "not-a-duration")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
HeartbeatLoop(ctx, client, "sb-invalid")
if mock.calls.Load() > 0 {
t.Error("with default 10s interval and 100ms timeout, expected 0 calls")
}
}
func TestClientHeartbeat_ReturnsAction(t *testing.T) {
mock := &mockYaoServer{action: "ok"}
addr, stop := startMockServer(t, mock)
defer stop()
conn, err := grpc.NewClient("passthrough:///"+addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
c := &Client{conn: conn, svc: pb.NewYaoClient(conn)}
action, err := c.Heartbeat(context.Background(), "sb-1", 50, 2048, 5)
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
if action != "ok" {
t.Errorf("action = %q, want %q", action, "ok")
}
}

View file

@ -1,420 +0,0 @@
package grpc_test
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/grpc/tests/testutils"
yaogrpc "github.com/yaoapp/yao/tai/grpc"
)
// Integration tests that start a real Yao gRPC server and test the tai/grpc
// client through the full interceptor -> handler chain.
func setupClient(t *testing.T, scopes ...string) *yaogrpc.Client {
t.Helper()
conn := testutils.Prepare(t)
t.Cleanup(func() {
conn.Close()
testutils.Clean()
})
addr := testutils.Addr()
token := testutils.ObtainAccessToken(t, scopes...)
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
tm := yaogrpc.NewTokenManager(token, refreshToken, "test-sandbox", "")
client, err := yaogrpc.Dial(addr, tm)
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
return client
}
// ── Healthz ──────────────────────────────────────────────────────────────────
func TestIntegration_Healthz(t *testing.T) {
conn := testutils.Prepare(t)
defer func() {
conn.Close()
testutils.Clean()
}()
addr := testutils.Addr()
tm := yaogrpc.NewTokenManager("", "", "", "")
client, err := yaogrpc.Dial(addr, tm)
require.NoError(t, err)
defer client.Close()
status, err := client.Healthz(context.Background())
assert.NoError(t, err)
assert.Equal(t, "ok", status)
}
// ── Run ──────────────────────────────────────────────────────────────────────
func TestIntegration_Run_Ping(t *testing.T) {
client := setupClient(t, "grpc:run")
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
assert.NoError(t, err)
assert.NotNil(t, data)
}
func TestIntegration_Run_InvalidProcess(t *testing.T) {
client := setupClient(t, "grpc:run")
_, err := client.Run(context.Background(), "nonexistent.process", nil, 0)
assert.Error(t, err)
}
func TestIntegration_Run_WithArgs(t *testing.T) {
client := setupClient(t, "grpc:run")
args, _ := json.Marshal([]any{"hello", "world"})
data, err := client.Run(context.Background(), "utils.app.Ping", args, 5)
assert.NoError(t, err)
assert.NotNil(t, data)
}
// ── Shell ────────────────────────────────────────────────────────────────────
func TestIntegration_Shell_Echo(t *testing.T) {
client := setupClient(t, "grpc:shell")
resp, err := client.Shell(context.Background(), "echo", []string{"hello"}, nil, 5)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, int32(0), resp.ExitCode)
assert.Contains(t, string(resp.Stdout), "hello")
}
func TestIntegration_Shell_NotFound(t *testing.T) {
client := setupClient(t, "grpc:shell")
_, err := client.Shell(context.Background(), "nonexistent-command-xyz", nil, nil, 5)
assert.Error(t, err)
}
// ── MCP ──────────────────────────────────────────────────────────────────────
func TestIntegration_MCPListTools(t *testing.T) {
client := setupClient(t, "grpc:mcp")
data, err := client.MCPListTools(context.Background(), "echo")
assert.NoError(t, err)
assert.NotNil(t, data)
var tools []any
assert.NoError(t, json.Unmarshal(data, &tools))
assert.Greater(t, len(tools), 0)
}
func TestIntegration_MCPCallTool(t *testing.T) {
client := setupClient(t, "grpc:mcp")
args, _ := json.Marshal(map[string]string{"message": "hi"})
data, err := client.MCPCallTool(context.Background(), "echo", "ping", args)
assert.NoError(t, err)
assert.NotNil(t, data)
}
func TestIntegration_MCPListResources(t *testing.T) {
client := setupClient(t, "grpc:mcp")
data, err := client.MCPListResources(context.Background(), "echo")
assert.NoError(t, err)
assert.NotNil(t, data)
}
func TestIntegration_MCPReadResource(t *testing.T) {
client := setupClient(t, "grpc:mcp")
data, err := client.MCPReadResource(context.Background(), "echo", "echo://info")
assert.NoError(t, err)
assert.NotNil(t, data)
}
// ── API ──────────────────────────────────────────────────────────────────────
func TestIntegration_API_Proxy(t *testing.T) {
client := setupClient(t, "grpc:run", "grpc:mcp")
resp, err := client.API(context.Background(), "GET", "/api/__yao/app/setting", nil, nil)
require.NoError(t, err)
require.NotNil(t, resp)
t.Logf("API proxy status: %d", resp.Status)
}
// ── LLM ──────────────────────────────────────────────────────────────────────
func TestIntegration_ChatCompletions_InvalidConnector(t *testing.T) {
client := setupClient(t, "grpc:llm")
messages, _ := json.Marshal([]map[string]string{
{"role": "user", "content": "test"},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := client.ChatCompletions(ctx, "nonexistent-connector", messages, nil)
assert.Error(t, err)
}
func TestIntegration_ChatCompletionsStream_InvalidConnector(t *testing.T) {
client := setupClient(t, "grpc:llm")
messages, _ := json.Marshal([]map[string]string{
{"role": "user", "content": "test"},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := client.ChatCompletionsStream(ctx, "nonexistent-connector", messages, nil,
func(data []byte, done bool) error { return nil })
assert.Error(t, err)
}
func TestIntegration_ChatCompletions_EmptyMessages(t *testing.T) {
client := setupClient(t, "grpc:llm")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := client.ChatCompletions(ctx, "default", nil, nil)
assert.Error(t, err)
}
// ── Agent ────────────────────────────────────────────────────────────────────
func TestIntegration_AgentStream_InvalidRobot(t *testing.T) {
client := setupClient(t, "grpc:agent")
messages, _ := json.Marshal([]map[string]string{
{"role": "user", "content": "hello"},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := client.AgentStream(ctx, "nonexistent-robot-xyz", messages, nil,
func(data []byte, done bool) error { return nil })
assert.Error(t, err)
}
// ── Unauthenticated ──────────────────────────────────────────────────────────
func TestIntegration_Run_NoToken(t *testing.T) {
conn := testutils.Prepare(t)
defer func() {
conn.Close()
testutils.Clean()
}()
addr := testutils.Addr()
tm := yaogrpc.NewTokenManager("", "", "", "")
client, err := yaogrpc.Dial(addr, tm)
require.NoError(t, err)
defer client.Close()
_, err = client.Run(context.Background(), "utils.app.Ping", nil, 0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Unauthenticated")
}
// ── Token Refresh via interceptor ────────────────────────────────────────────
func TestIntegration_TokenRefresh(t *testing.T) {
conn := testutils.Prepare(t)
defer func() {
conn.Close()
testutils.Clean()
}()
addr := testutils.Addr()
scopes := []string{"grpc:run"}
expiredToken := testutils.ObtainExpiredAccessToken(t, scopes...)
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
tm := yaogrpc.NewTokenManager(expiredToken, refreshToken, "sb-test", "")
client, err := yaogrpc.Dial(addr, tm)
require.NoError(t, err)
defer client.Close()
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
assert.NoError(t, err)
assert.NotNil(t, data)
newToken := tm.AccessToken()
if newToken != expiredToken {
t.Logf("token was refreshed: old=%s... new=%s...", expiredToken[:20], newToken[:20])
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Relay mode tests — client → Tai (:9100) → x-grpc-upstream → Yao gRPC
// Requires TAI_TEST_GRPC env var (e.g. 127.0.0.1:9100) and a running Tai server.
// ══════════════════════════════════════════════════════════════════════════════
func setupRelayClient(t *testing.T, scopes ...string) *yaogrpc.Client {
t.Helper()
taiAddr := os.Getenv("TAI_TEST_GRPC")
if taiAddr == "" {
t.Skip("TAI_TEST_GRPC not set, skipping relay mode test")
}
conn := testutils.Prepare(t)
t.Cleanup(func() {
conn.Close()
testutils.Clean()
})
yaoAddr := testutils.RelayAddr()
token := testutils.ObtainAccessToken(t, scopes...)
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
// upstream = Yao gRPC address reachable from the Tai container
tm := yaogrpc.NewTokenManager(token, refreshToken, "relay-sandbox", yaoAddr)
client, err := yaogrpc.Dial(taiAddr, tm)
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
return client
}
func TestRelay_Healthz(t *testing.T) {
taiAddr := os.Getenv("TAI_TEST_GRPC")
if taiAddr == "" {
t.Skip("TAI_TEST_GRPC not set")
}
conn := testutils.Prepare(t)
defer func() {
conn.Close()
testutils.Clean()
}()
yaoAddr := testutils.RelayAddr()
tm := yaogrpc.NewTokenManager("", "", "", yaoAddr)
client, err := yaogrpc.Dial(taiAddr, tm)
require.NoError(t, err)
defer client.Close()
status, err := client.Healthz(context.Background())
require.NoError(t, err)
assert.Equal(t, "ok", status)
}
func TestRelay_Run_Ping(t *testing.T) {
client := setupRelayClient(t, "grpc:run")
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
require.NoError(t, err)
require.NotNil(t, data)
t.Logf("relay Run result: %s", string(data))
}
func TestRelay_Run_InvalidProcess(t *testing.T) {
client := setupRelayClient(t, "grpc:run")
_, err := client.Run(context.Background(), "nonexistent.process", nil, 0)
assert.Error(t, err)
}
func TestRelay_Shell_Echo(t *testing.T) {
client := setupRelayClient(t, "grpc:shell")
resp, err := client.Shell(context.Background(), "echo", []string{"relay-test"}, nil, 5)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, int32(0), resp.ExitCode)
assert.Contains(t, string(resp.Stdout), "relay-test")
}
func TestRelay_MCPListTools(t *testing.T) {
client := setupRelayClient(t, "grpc:mcp")
data, err := client.MCPListTools(context.Background(), "echo")
assert.NoError(t, err)
assert.NotNil(t, data)
var tools []any
assert.NoError(t, json.Unmarshal(data, &tools))
assert.Greater(t, len(tools), 0)
}
func TestRelay_MCPCallTool(t *testing.T) {
client := setupRelayClient(t, "grpc:mcp")
args, _ := json.Marshal(map[string]string{"message": "relay"})
data, err := client.MCPCallTool(context.Background(), "echo", "ping", args)
assert.NoError(t, err)
assert.NotNil(t, data)
}
func TestRelay_Run_NoToken(t *testing.T) {
taiAddr := os.Getenv("TAI_TEST_GRPC")
if taiAddr == "" {
t.Skip("TAI_TEST_GRPC not set")
}
conn := testutils.Prepare(t)
defer func() {
conn.Close()
testutils.Clean()
}()
yaoAddr := testutils.RelayAddr()
tm := yaogrpc.NewTokenManager("", "", "", yaoAddr)
client, err := yaogrpc.Dial(taiAddr, tm)
require.NoError(t, err)
defer client.Close()
_, err = client.Run(context.Background(), "utils.app.Ping", nil, 0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Unauthenticated")
}
func TestRelay_TokenRefresh(t *testing.T) {
taiAddr := os.Getenv("TAI_TEST_GRPC")
if taiAddr == "" {
t.Skip("TAI_TEST_GRPC not set")
}
conn := testutils.Prepare(t)
defer func() {
conn.Close()
testutils.Clean()
}()
yaoAddr := testutils.RelayAddr()
scopes := []string{"grpc:run"}
expiredToken := testutils.ObtainExpiredAccessToken(t, scopes...)
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
tm := yaogrpc.NewTokenManager(expiredToken, refreshToken, "relay-sb", yaoAddr)
client, err := yaogrpc.Dial(taiAddr, tm)
require.NoError(t, err)
defer client.Close()
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
assert.NoError(t, err)
assert.NotNil(t, data)
newToken := tm.AccessToken()
if newToken != expiredToken {
t.Logf("relay token refreshed: old=%s... new=%s...", expiredToken[:20], newToken[:20])
}
}

View file

@ -1,4 +1,4 @@
package grpc
package tai
import (
"context"
@ -14,8 +14,8 @@ import (
const defaultHeartbeatInterval = 10 * time.Second
// HeartbeatLoop sends periodic heartbeats to the Yao gRPC server.
// It runs until ctx is cancelled. The sandboxID comes from YAO_SANDBOX_ID.
func HeartbeatLoop(ctx context.Context, client *Client, sandboxID string) {
// It runs until ctx is cancelled.
func HeartbeatLoop(ctx context.Context, client *YaoClient, sandboxID string) {
interval := defaultHeartbeatInterval
if s := os.Getenv("YAO_HEARTBEAT_INTERVAL"); s != "" {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
@ -38,7 +38,7 @@ func HeartbeatLoop(ctx context.Context, client *Client, sandboxID string) {
continue
}
if action == "shutdown" {
fmt.Fprintf(os.Stderr, "yao-grpc: received shutdown signal\n")
fmt.Fprintf(os.Stderr, "tai: received shutdown signal\n")
p, _ := os.FindProcess(os.Getpid())
p.Signal(os.Interrupt)
return
@ -47,12 +47,10 @@ func HeartbeatLoop(ctx context.Context, client *Client, sandboxID string) {
}
}
// countUserProcesses counts running processes owned by the current user.
func countUserProcesses() int32 {
if runtime.GOOS != "linux" {
return 0
}
out, err := exec.Command("sh", "-c", "ps -e --no-headers | wc -l").Output()
if err != nil {
return 0
@ -61,17 +59,14 @@ func countUserProcesses() int32 {
return int32(n)
}
// sampleResources reads basic CPU/memory stats from /proc (Linux only).
func sampleResources() (cpuPercent int32, memBytes int64) {
if runtime.GOOS != "linux" {
return 0, 0
}
data, err := os.ReadFile("/sys/fs/cgroup/memory.current")
if err == nil {
mem, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
memBytes = mem
}
return 0, memBytes
}

View file

@ -13,6 +13,15 @@ import (
"github.com/gorilla/websocket"
)
// SystemInfo describes the host machine running Tai.
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"`
}
// TaiNode represents a registered Tai instance (direct or tunnel).
// Internal use only; external callers receive NodeSnapshot via Get()/List().
type TaiNode struct {
@ -20,6 +29,7 @@ type TaiNode struct {
MachineID string
Version string
Auth AuthInfo
System SystemInfo
Mode string // "direct" | "tunnel"
Addr string // direct mode: "tai-host"; tunnel mode: empty
YaoBase string // Yao server base URL reported by Tai (tunnel mode)
@ -43,6 +53,7 @@ type NodeSnapshot struct {
MachineID string
Version string
Auth AuthInfo
System SystemInfo
Mode string
Addr string
YaoBase string
@ -65,7 +76,8 @@ func (n *TaiNode) snapshot() NodeSnapshot {
}
return NodeSnapshot{
TaiID: n.TaiID, MachineID: n.MachineID, Version: n.Version,
Auth: n.Auth, Mode: n.Mode, Addr: n.Addr, YaoBase: n.YaoBase,
Auth: n.Auth, System: n.System,
Mode: n.Mode, Addr: n.Addr, YaoBase: n.YaoBase,
Ports: ports, Capabilities: caps,
Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing,
PoolName: n.PoolName,
@ -222,6 +234,66 @@ func (r *Registry) UpdatePing(taiID string) {
}
}
// ListByTeam returns snapshots of all nodes belonging to the given team.
func (r *Registry) ListByTeam(teamID string) []NodeSnapshot {
r.mu.RLock()
defer r.mu.RUnlock()
var result []NodeSnapshot
for _, n := range r.nodes {
if n.Auth.TeamID == teamID {
result = append(result, n.snapshot())
}
}
return result
}
// StartHealthCheck runs a background goroutine that periodically checks
// direct-mode nodes for heartbeat timeout. Nodes whose LastPing exceeds
// timeout are marked offline. Nodes that remain offline longer than
// cleanupAfter are automatically unregistered.
// The goroutine stops when ctx.Done() is closed.
func (r *Registry) StartHealthCheck(done <-chan struct{}, interval, timeout, cleanupAfter time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
r.checkHealth(timeout, cleanupAfter)
}
}
}()
}
func (r *Registry) checkHealth(timeout, cleanupAfter time.Duration) {
now := time.Now()
var toRemove []string
r.mu.Lock()
for id, n := range r.nodes {
if n.Mode != "direct" {
continue
}
elapsed := now.Sub(n.LastPing)
if n.Status == "online" && elapsed > timeout {
n.Status = "offline"
r.logger.Warn("tai node offline (heartbeat timeout)",
"tai_id", id, "last_ping", n.LastPing)
}
if n.Status == "offline" && elapsed > timeout+cleanupAfter {
toRemove = append(toRemove, id)
}
}
r.mu.Unlock()
for _, id := range toRemove {
r.logger.Info("tai node auto-unregistered (offline too long)", "tai_id", id)
r.Unregister(id)
}
}
// RequestChannel sends an "open" command to a tunnel-connected Tai via its
// control channel. Returns a channel_id that Tai will use to connect back.
// Blocks until the data channel is established or timeout.

View file

@ -475,6 +475,135 @@ func newWSServer(handler func(*websocket.Conn)) *httptest.Server {
}))
}
func TestRegister_SystemInfo(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{
TaiID: "tai-001",
System: SystemInfo{
OS: "linux",
Arch: "amd64",
Hostname: "docker-host-01",
NumCPU: 16,
},
})
snap, ok := r.Get("tai-001")
if !ok {
t.Fatal("node not found")
}
if snap.System.OS != "linux" {
t.Errorf("System.OS = %q, want linux", snap.System.OS)
}
if snap.System.Arch != "amd64" {
t.Errorf("System.Arch = %q, want amd64", snap.System.Arch)
}
if snap.System.Hostname != "docker-host-01" {
t.Errorf("System.Hostname = %q, want docker-host-01", snap.System.Hostname)
}
if snap.System.NumCPU != 16 {
t.Errorf("System.NumCPU = %d, want 16", snap.System.NumCPU)
}
}
func TestListByTeam(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-a", Auth: AuthInfo{TeamID: "team-dev"}})
r.Register(&TaiNode{TaiID: "tai-b", Auth: AuthInfo{TeamID: "team-dev"}})
r.Register(&TaiNode{TaiID: "tai-c", Auth: AuthInfo{TeamID: "team-ops"}})
devNodes := r.ListByTeam("team-dev")
if len(devNodes) != 2 {
t.Errorf("ListByTeam(team-dev) = %d nodes, want 2", len(devNodes))
}
opsNodes := r.ListByTeam("team-ops")
if len(opsNodes) != 1 {
t.Errorf("ListByTeam(team-ops) = %d nodes, want 1", len(opsNodes))
}
empty := r.ListByTeam("team-ghost")
if len(empty) != 0 {
t.Errorf("ListByTeam(team-ghost) = %d nodes, want 0", len(empty))
}
}
func TestStartHealthCheck_MarkOffline(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-direct", Mode: "direct"})
r.Register(&TaiNode{TaiID: "tai-tunnel", Mode: "tunnel"})
// Manually set LastPing to the past for the direct node.
r.mu.Lock()
r.nodes["tai-direct"].LastPing = time.Now().Add(-5 * time.Second)
r.mu.Unlock()
done := make(chan struct{})
r.StartHealthCheck(done, 50*time.Millisecond, 2*time.Second, 10*time.Minute)
defer close(done)
time.Sleep(200 * time.Millisecond)
snap, ok := r.Get("tai-direct")
if !ok {
t.Fatal("direct node should still exist")
}
if snap.Status != "offline" {
t.Errorf("direct node Status = %q, want offline", snap.Status)
}
// Tunnel nodes should not be affected.
snap2, ok := r.Get("tai-tunnel")
if !ok {
t.Fatal("tunnel node should still exist")
}
if snap2.Status != "online" {
t.Errorf("tunnel node Status = %q, want online", snap2.Status)
}
}
func TestStartHealthCheck_AutoCleanup(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-stale", Mode: "direct"})
// Set LastPing far in the past so it exceeds both timeout and cleanupAfter.
r.mu.Lock()
r.nodes["tai-stale"].LastPing = time.Now().Add(-1 * time.Hour)
r.mu.Unlock()
done := make(chan struct{})
r.StartHealthCheck(done, 50*time.Millisecond, 1*time.Second, 1*time.Second)
defer close(done)
time.Sleep(200 * time.Millisecond)
if _, ok := r.Get("tai-stale"); ok {
t.Error("stale node should have been auto-unregistered")
}
}
func TestStartHealthCheck_PingKeepsAlive(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-alive", Mode: "direct"})
done := make(chan struct{})
r.StartHealthCheck(done, 50*time.Millisecond, 2*time.Second, 10*time.Minute)
defer close(done)
// Continuously ping to keep the node alive.
for i := 0; i < 4; i++ {
time.Sleep(30 * time.Millisecond)
r.UpdatePing("tai-alive")
}
snap, ok := r.Get("tai-alive")
if !ok {
t.Fatal("node should still exist")
}
if snap.Status != "online" {
t.Errorf("Status = %q, want online", snap.Status)
}
}
func TestNodeSnapshot_AuthInfo(t *testing.T) {
r := newTestRegistry()
r.Register(&TaiNode{

17
tai/token.go Normal file
View file

@ -0,0 +1,17 @@
package tai
import grpcclient "github.com/yaoapp/yao/grpc/client"
// TokenManager is an alias for grpc/client.TokenManager.
// New code should use grpc/client.TokenManager directly.
type TokenManager = grpcclient.TokenManager
// NewTokenManagerFromEnv creates a TokenManager from environment variables.
func NewTokenManagerFromEnv() (*TokenManager, error) {
return grpcclient.NewTokenManagerFromEnv()
}
// NewTokenManager creates a TokenManager with explicit values.
func NewTokenManager(accessToken, refreshToken, sandboxID string) *TokenManager {
return grpcclient.NewTokenManager(accessToken, refreshToken, sandboxID)
}

View file

@ -73,6 +73,7 @@ func HandleControl(c *gin.Context) {
MachineID: regMsg.MachineID,
Version: regMsg.Version,
Auth: authInfo,
System: regMsg.System,
Mode: "tunnel",
YaoBase: regMsg.Server,
Ports: regMsg.Ports,
@ -159,13 +160,14 @@ func HandleData(c *gin.Context) {
// registerMessage is the JSON structure for Tai's register message.
type registerMessage struct {
Type string `json:"type"`
TaiID string `json:"tai_id"`
MachineID string `json:"machine_id"`
Version string `json:"version"`
Server string `json:"server"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
Type string `json:"type"`
TaiID string `json:"tai_id"`
MachineID string `json:"machine_id"`
Version string `json:"version"`
Server string `json:"server"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
System registry.SystemInfo `json:"system"`
}
// controlMsg is a generic control channel message.

35
tai/yao.go Normal file
View file

@ -0,0 +1,35 @@
package tai
import (
"context"
grpcclient "github.com/yaoapp/yao/grpc/client"
"github.com/yaoapp/yao/grpc/pb"
)
// YaoClient wraps grpc/client.Client for backward compatibility.
// New code should use grpc/client.Client directly.
type YaoClient = grpcclient.Client
// NewYaoClientFromEnv reads YAO_GRPC_ADDR and token env vars, dials the
// gRPC server, and returns a connected YaoClient.
func NewYaoClientFromEnv() (*YaoClient, error) {
return grpcclient.NewFromEnv()
}
// DialYao connects to a Yao gRPC server at addr with the given TokenManager.
func DialYao(addr string, tm *TokenManager) (*YaoClient, error) {
return grpcclient.Dial(addr, tm)
}
// --- Convenience wrappers kept for sandbox/container code ---
// Run executes a Yao process via the given client.
func Run(ctx context.Context, c *YaoClient, process string, args []byte, timeout int32) ([]byte, error) {
return c.Run(ctx, process, args, timeout)
}
// Shell executes a system command via the given client.
func Shell(ctx context.Context, c *YaoClient, command string, args []string, env map[string]string, timeout int32) (*pb.ShellResponse, error) {
return c.Shell(ctx, command, args, env, timeout)
}