refactor: remove x-grpc-upstream, benchmark CI job, and harden sandbox v2

- Remove x-grpc-upstream metadata from gateway forwarding; use SetUpstream
- Remove YAO_GRPC_TAI / YAO_GRPC_UPSTREAM env vars from sandbox containers
- Delete benchmark-sandbox-v2 CI job (run benchmarks locally)
- Simplify sandbox-v2 CI to tai SDK + workspace tests only
- Add HostExec support to sandbox v2 box interface
- Add K8s semaphore and cleanup mutex for test stability
- Update design docs to reflect new architecture

Made-with: Cursor
This commit is contained in:
Max 2026-03-07 21:41:48 +08:00
parent 83ebe49036
commit d16086ff51
17 changed files with 1206 additions and 502 deletions

View file

@ -924,12 +924,11 @@ jobs:
});
# =============================================================================
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
# TEMPORARILY DISABLED: docker images need rebuild after tai repo migration
# Sandbox V2 Tests (tai SDK + workspace, Docker + K8s via k3d)
# Full sandbox/v2 integration tests are run locally.
# =============================================================================
SandboxV2Test:
runs-on: ubuntu-latest
if: false # temporarily disabled restore after sandbox docker images are rebuilt
services:
mongodb:
image: mongo:6.0
@ -984,7 +983,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '🤖 Sandbox V2 Tests running (tai + sandbox-v2 + workspace)...'
body: '🤖 Sandbox V2 CI Tests running (tai + workspace)...'
});
- name: Checkout Kun
@ -1164,7 +1163,7 @@ jobs:
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
- name: Run Sandbox V2 CI Tests (tai + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
@ -1196,7 +1195,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '✅ Sandbox V2 Tests passed (tai + sandbox-v2 + workspace)!'
body: '✅ Sandbox V2 CI Tests passed (tai + workspace)!'
});
# =============================================================================
@ -1808,227 +1807,6 @@ jobs:
body: '✅ Registry Client SDK Tests passed!'
});
# =============================================================================
# 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"]
steps:
- name: "Download artifact"
uses: actions/github-script@v7
with:
script: |
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{github.event.workflow_run.id }},
});
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "pr"
})[0];
var download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
var fs = require('fs');
fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data));
- name: "Read NR & SHA"
run: |
unzip pr.zip
cat NR
cat SHA
echo HEAD=$(cat SHA) >> $GITHUB_ENV
echo NR=$(cat NR) >> $GITHUB_ENV
- name: Checkout Kun
uses: actions/checkout@v4
with:
repository: yaoapp/kun
path: kun
- name: Checkout Xun
uses: actions/checkout@v4
with:
repository: yaoapp/xun
path: xun
- name: Checkout Gou
uses: actions/checkout@v4
with:
repository: yaoapp/gou
path: gou
- name: Checkout V8Go
uses: actions/checkout@v4
with:
repository: yaoapp/v8go
path: v8go
- name: Unzip libv8
run: |
files=$(find ./v8go -name "libv8*.zip")
for file in $files; do
dir=$(dirname "$file")
echo "Extracting $file to directory $dir"
unzip -o -d $dir $file
rm -rf $dir/__MACOSX
done
- name: Checkout Demo App
uses: actions/checkout@v4
with:
repository: yaoapp/yao-dev-app
path: app
- name: Checkout Extension
uses: actions/checkout@v4
with:
repository: yaoapp/yao-extensions-dev
path: extension
- name: Move Dependencies
run: |
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
mv app ../
mv extension ../
- name: Checkout pull request HEAD commit
uses: actions/checkout@v4
with:
ref: ${{ env.HEAD }}
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
run: docker run --name redis --publish 6379:6379 --detach redis:6
- name: Setup Go Tools
run: make tools
- name: Setup ENV (SQLite)
run: |
mkdir -p ${{ github.WORKSPACE }}/../app/db
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Test Images
run: |
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Install k3d
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
- name: Create k3d cluster
run: |
k3d cluster create tai-test --no-lb --wait --api-port 16443
kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test
- name: Start Tai Docker instance (benchmarks)
run: |
docker run -d --name tai-docker \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
yaoapp/tai:latest
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai Docker HTTP ready"; break
fi
echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || {
echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai Docker gRPC ready"; break
fi
echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9100 2>/dev/null || {
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
}
- name: Generate kubeconfig for benchmarks
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d-bench.yml
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d-bench.yml \
> /tmp/kubeconfig-tai-k8s-bench.yml
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d-bench.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
- name: Start Tai K8s instance (benchmarks)
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
echo "k3d server IP: ${K3D_IP}"
docker run -d --name tai-k8s \
--network k3d-tai-test \
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /tmp/kubeconfig-tai-k8s-bench.yml:/etc/tai/kubeconfig.yml:ro \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then
echo "Tai K8s HTTP ready"; break
fi
echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || {
echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9101 2>/dev/null; then
echo "Tai K8s gRPC ready"; break
fi
echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9101 2>/dev/null || {
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Run Benchmarks
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_K8S_GRPC_PORT: "9101"
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/tai-sandbox-test:latest"
run: make benchmark-sandbox-v2
# =============================================================================
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)
# =============================================================================

View file

@ -680,7 +680,8 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
# =============================================================================
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
# Sandbox V2 Tests (tai SDK + workspace, Docker + K8s via k3d)
# Full sandbox/v2 integration tests are run locally.
# =============================================================================
sandbox-v2-test:
runs-on: ubuntu-latest
@ -873,7 +874,7 @@ jobs:
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
- name: Run Sandbox V2 CI Tests (tai + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
@ -1349,194 +1350,6 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
# =============================================================================
# Benchmark: Sandbox V2 + Workspace (parallel with sandbox-v2-test)
# =============================================================================
benchmark-sandbox-v2:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.25"]
steps:
- name: Checkout Kun
uses: actions/checkout@v4
with:
repository: ${{ env.REPO_KUN }}
path: kun
- name: Checkout Xun
uses: actions/checkout@v4
with:
repository: ${{ env.REPO_XUN }}
path: xun
- name: Checkout Gou
uses: actions/checkout@v4
with:
repository: ${{ env.REPO_GOU }}
path: gou
- name: Checkout V8Go
uses: actions/checkout@v4
with:
repository: yaoapp/v8go
path: v8go
- name: Unzip libv8
run: |
files=$(find ./v8go -name "libv8*.zip")
for file in $files; do
dir=$(dirname "$file")
echo "Extracting $file to directory $dir"
unzip -o -d $dir $file
rm -rf $dir/__MACOSX
done
- name: Checkout Demo App
uses: actions/checkout@v4
with:
repository: yaoapp/yao-dev-app
path: app
- name: Checkout Extension
uses: actions/checkout@v4
with:
repository: yaoapp/yao-extensions-dev
path: extension
- name: Move Dependencies
run: |
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
mv app ../
mv extension ../
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
run: docker run --name redis --publish 6379:6379 --detach redis:6
- name: Setup Go Tools
run: make tools
- name: Setup ENV (SQLite)
run: |
mkdir -p ${{ github.WORKSPACE }}/../app/db
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Test Images
run: |
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Install k3d
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
- name: Create k3d cluster
run: |
k3d cluster create tai-test --no-lb --wait --api-port 16443
kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test
- name: Start Tai Docker instance (benchmarks)
run: |
docker run -d --name tai-docker \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
yaoapp/tai:latest
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai Docker HTTP ready"; break
fi
echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || {
echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai Docker gRPC ready"; break
fi
echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9100 2>/dev/null || {
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
}
- name: Generate kubeconfig for benchmarks
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d-bench.yml
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d-bench.yml \
> /tmp/kubeconfig-tai-k8s-bench.yml
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d-bench.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
- name: Start Tai K8s instance (benchmarks)
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
echo "k3d server IP: ${K3D_IP}"
docker run -d --name tai-k8s \
--network k3d-tai-test \
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /tmp/kubeconfig-tai-k8s-bench.yml:/etc/tai/kubeconfig.yml:ro \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then
echo "Tai K8s HTTP ready"; break
fi
echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1
done
curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || {
echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1
}
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9101 2>/dev/null; then
echo "Tai K8s gRPC ready"; break
fi
echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1
done
nc -z 127.0.0.1 9101 2>/dev/null || {
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Run Benchmarks
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_K8S_GRPC_PORT: "9101"
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"
run: make benchmark-sandbox-v2
# =============================================================================
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)
# =============================================================================

View file

@ -202,35 +202,17 @@ unit-test-registry:
fi
# ---------------------------------------------------------------------------
# Sandbox V2 Integration Test (tai + sandbox/v2 + workspace)
# Requires: Docker, Tai container, optionally k3d for K8s mode
# Sandbox V2 CI Test (tai SDK + workspace only)
# Full sandbox/v2 integration tests (multi-pool, K8s, etc.) are run locally.
# ---------------------------------------------------------------------------
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
unit-test-sandbox-v2: unit-test-tai unit-test-workspace
@echo ""
@echo "============================================="
@echo "All Sandbox V2 integration tests passed"
@echo "All Sandbox V2 CI tests passed (tai + workspace)"
@echo "============================================="
.PHONY: unit-test-sandbox-v2-pull
unit-test-sandbox-v2-pull:
@echo ""
@echo "============================================="
@echo "Pulling test images..."
@echo "============================================="
docker pull $(SANDBOX_V2_IMAGE) || true
docker pull alpine:latest || true
.PHONY: unit-test-sandbox-v2-core
unit-test-sandbox-v2-core:
@echo ""
@echo "============================================="
@echo "Running Sandbox V2 Tests..."
@echo "============================================="
$(MAKE) -C sandbox/v2 test-ci TEST_IMAGE=$(SANDBOX_V2_IMAGE)
# Workspace Unit Test (requires Tai for remote mode)
.PHONY: unit-test-workspace
unit-test-workspace:
@ -271,26 +253,6 @@ unit-test-workspace:
@echo "All workspace tests passed"
@echo "============================================="
# Benchmark: Sandbox V2 + Workspace
.PHONY: benchmark-sandbox-v2
benchmark-sandbox-v2:
@echo ""
@echo "============================================="
@echo "Running Sandbox V2 + Workspace Benchmarks..."
@echo "============================================="
@for d in $$($(GO) list ./sandbox/v2/... ./workspace/...); do \
if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \
echo ""; \
echo "Benchmarking: $$d"; \
echo "---------------------------------------------"; \
$(GO) test -bench=. -benchmem -benchtime=1x -run='^$$' -timeout=600s $$d || true; \
fi; \
done
@echo ""
@echo "============================================="
@echo "All benchmarks completed"
@echo "============================================="
# Sandbox Unit Test (requires Docker)
.PHONY: unit-test-sandbox
unit-test-sandbox:

View file

@ -164,18 +164,18 @@ Deliverable: LLM (unary + stream) and Agent streaming via gRPC.
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.
Tai gateway receives the upstream address during registration (`SetUpstream`). All gRPC requests are forwarded to the configured upstream — no per-request metadata required.
| Task | Detail | Status |
|------|--------|--------|
| Tai `gateway/gateway.go` | Remove fixed `upstream *grpc.ClientConn`. On each request, read `x-grpc-upstream` from metadata → lookup/create conn from `sync.Map` cache (key = address string) → forward. Typical deployment has 1 upstream, cache stays tiny. | ✅ Done |
| Tai `server/server.go` | Remove `YaoUpstream` from `Config`. Gateway init no longer needs an address. | ✅ Done |
| Tai `gateway/gateway.go` | Removed fixed `upstream *grpc.ClientConn`. `SetUpstream` configures the forwarding target. `sync.Map` cache for connections (key = address string). | ✅ Done |
| Tai `server/server.go` | Remove `YaoUpstream` from `Config`. Gateway uses `SetUpstream` after registration. | ✅ Done |
| Tai `main.go` | Remove `--yao` flag, `TAI_YAO_UPSTREAM` env var, YAML `yao` field, and required check. | ✅ Done |
| Tai `gateway/gateway_test.go` | Updated tests: dynamic routing, missing metadata → InvalidArgument, metadata forwarding (x-grpc-upstream stripped), upstream error propagation, multiple upstreams, connection cache. Coverage: 88.8%. | ✅ Done |
| Tai `gateway/gateway_test.go` | Updated tests: SetUpstream routing, no-upstream → Unavailable, metadata forwarding, upstream error propagation, upstream switching, connection cache. | ✅ Done |
Connection cache: `sync.Map[string, *grpc.ClientConn]` — lazy dial on first request per upstream, reuse thereafter. No eviction needed (upstream count ≈ 1 in practice). `Close` closes all cached connections.
Deliverable: Tai starts without Yao address. Forwards based on request metadata.
Deliverable: Tai receives upstream via registration. Forwards all requests to configured upstream.
### Phase 5: yao-grpc container client ✅
@ -183,14 +183,11 @@ Depends on: Phase 1 (server + auth), Phase 4 (Tai gateway accepts `x-grpc-upstre
| Task | Detail | Status |
|------|--------|--------|
| `tai/grpc/auth.go` | `TokenManager`: read `YAO_TOKEN` / `YAO_REFRESH_TOKEN` / `YAO_SANDBOX_ID` / `YAO_GRPC_UPSTREAM` from env. `YAO_GRPC_TAI=enable` triggers Tai relay mode (requires `YAO_GRPC_UPSTREAM`). Attach as gRPC metadata on every call via unary + stream interceptors. Auto-refresh from response headers. | ✅ Done |
| `tai/grpc/grpc.go` | `Client`: `Dial(addr, TokenManager)`, `NewFromEnv()`. Method wrappers for all RPCs: Run, Shell, API, MCP (list/call/resources/read), ChatCompletions, ChatCompletionsStream, AgentStream, Healthz. | ✅ Done |
| `tai/grpc/cmd/main.go` | Stdio MCP server: JSON-RPC → gRPC. `yao-grpc version` prints version/commit/build time (via `-ldflags`). `yao-grpc serve` reads stdin JSON-RPC, dispatches to gRPC client. | ✅ Done |
| `tai/grpc/grpc_test.go` + `integration_test.go` | Black-box tests (package `grpc_test`). Unit: TokenManager metadata attachment, env parsing, refresh handling. Integration: real Yao gRPC server, all method wrappers, token refresh, auth rejection. Coverage: 83.9%. | ✅ Done |
| `tai call` (tai repo) | In-container gRPC bridge. Reads `YAO_TOKEN` / `YAO_REFRESH_TOKEN` / `YAO_SANDBOX_ID` / `YAO_GRPC_ADDR` from env. Attaches auth metadata on every call via unary + stream interceptors. Auto-refresh from response headers. | ✅ Done |
Container token issuance uses existing `oauth.MakeAccessToken` / `oauth.MakeRefreshToken` — called by sandbox Manager at container creation, injected as env vars. Revoke on Remove. No new auth code needed on the issuance side.
Deliverable: `go build -o yao-grpc ./tai/grpc/cmd`.
Deliverable: `tai call` subcommand (part of Tai binary).
### Phase 6: Device Flow + CLI auth ✅

View file

@ -233,8 +233,8 @@ agent/context/jsapi_sandbox.go
| Container exec | `dockerClient.ContainerExecCreate/Start/Attach` | `tai.Client.Sandbox().Exec()` |
| File read | Host path via bind mount (`containerPathToHost`) | Local: bind mount (same). Remote: `tai.Client.Volume().Read()` |
| File write | `dockerClient.CopyToContainer` | Local: bind mount. Remote: `tai.Client.Volume().Write()` |
| IPC | Unix socket bind mount + yao-bridge | All modes: `yao-grpc` → gRPC (direct or via Tai relay). No Unix socket. |
| MCP config | `{args: ["/tmp/yao.sock"]}` hardcoded | `YAO_GRPC_ADDR` + `YAO_TOKEN` env vars. Local: direct. Remote: + `YAO_GRPC_TAI`/`YAO_GRPC_UPSTREAM`. |
| IPC | Unix socket bind mount + yao-bridge | All modes: `tai call` → gRPC (direct or via Tai gateway). No Unix socket. |
| MCP config | `{args: ["/tmp/yao.sock"]}` hardcoded | `YAO_GRPC_ADDR` + `YAO_TOKEN` env vars. Local: direct. Remote: via Tai gateway. |
| VNC | `vncproxy.NewProxy(nil)` local assumption | `tai.Client.VNC().URL()` |
| Cleanup | `dockerClient.ContainerRemove` | `tai.Client.Sandbox().Remove()` |
@ -243,11 +243,11 @@ agent/context/jsapi_sandbox.go
All modes use gRPC — no Unix socket fallback, one code path for local and remote.
```
Local: Container → yao-grpc → Yao gRPC 127.0.0.1:9099
Remote: Container → yao-grpc → Tai :9100 relay → Yao gRPC :9099
Local: Container → tai call → Yao gRPC 127.0.0.1:9099
Remote: Container → tai call → Tai :9100 gateway → Yao gRPC :9099
```
`yao-grpc` source lives in `yao/tai/grpc/` — shares gRPC deps with `tai/`, version-locked with the Tai protocol. Built via `go build -o yao-grpc ./tai/grpc/cmd`.
`tai call` is the in-container gRPC bridge (part of the Tai binary). It reads env vars and bridges JSON-RPC/stdio to the Yao gRPC server.
Mode determined by env vars injected by Manager at container creation:
@ -255,13 +255,11 @@ Mode determined by env vars injected by Manager at container creation:
# Local: direct to Yao
YAO_GRPC_ADDR=127.0.0.1:9099
# Remote: via Tai relay
# Remote: via Tai gateway
YAO_GRPC_ADDR=tai-host:9100
YAO_GRPC_TAI=enable
YAO_GRPC_UPSTREAM=yao-host:9099
```
`yao-grpc` reads `YAO_TOKEN` / `YAO_REFRESH_TOKEN` / `YAO_SANDBOX_ID` from env, attaches as gRPC metadata on every call, and handles automatic token refresh from response metadata. In Tai relay mode, attaches `x-grpc-upstream` metadata so Tai knows where to forward.
`tai call` reads `YAO_TOKEN` / `YAO_REFRESH_TOKEN` / `YAO_SANDBOX_ID` from env, attaches as gRPC metadata on every call, and handles automatic token refresh from response metadata. The Tai gateway uses the upstream address configured during Tai registration to forward requests to Yao.
Tai relay upstream is NOT configured at Tai startup. `yao-grpc` carries the target address per request — Tai reads `x-grpc-upstream` metadata and proxies dynamically. One Tai can serve containers from different Yao instances.
@ -308,9 +306,9 @@ sandbox:
2. **Tai SDK** (`yao/tai`) — unified sandbox runtime SDK with Local/Remote modes. Sandbox (container lifecycle), Volume (file IO + sync with lz4), Workspace (`fs.FS` compatible), Proxy (HTTP reverse proxy), VNC (WebSocket). Remote mode connects via Tai gateway (gRPC :9100, Docker :2375, K8s :6443, HTTP :8080, VNC :6080). Details: [tai/docs/README.md](../tai/docs/README.md).
3. **Tai gateway dynamic routing** (Tai repo) — removed fixed `YaoUpstream` startup config. `yao-grpc` carries `x-grpc-upstream` metadata per request; Tai reads target and proxies dynamically. One Tai serves containers from multiple Yao instances.
3. **Tai gateway dynamic routing** (Tai repo) — removed fixed `YaoUpstream` startup config. Tai receives upstream address during registration (`SetUpstream`) and forwards all gRPC requests to it. One Tai serves containers from multiple Yao instances.
4. **yao-grpc container client** (`yao/tai/grpc`) — in-container gRPC client binary replacing `yao-bridge`. Reads `YAO_TOKEN`/`YAO_REFRESH_TOKEN`/`YAO_SANDBOX_ID` from env, auto-refreshes tokens via response metadata. Supports direct mode (`YAO_GRPC_ADDR=127.0.0.1:9099`) and Tai relay mode (`YAO_GRPC_TAI=enable`). Built as `go build -o yao-grpc ./tai/grpc/cmd`.
4. **`tai call` container client** (Tai repo) — in-container gRPC bridge replacing `yao-bridge`. Reads `YAO_TOKEN`/`YAO_REFRESH_TOKEN`/`YAO_SANDBOX_ID` from env, auto-refreshes tokens via response metadata. `YAO_GRPC_ADDR` determines the target (local Yao or remote Tai gateway).
5. **OAuth Device Flow + CLI auth**`yao login --server <url>` (RFC 8628 Device Authorization Grant), `yao logout`, credentials stored as base64 JSON in `~/.yao/credentials`. CUI `/auth/device` page for user authorization. Dynamic client registration via machine ID.

View file

@ -158,7 +158,7 @@ type Config struct {
}
```
Container gRPC env vars (`YAO_GRPC_ADDR`, `YAO_GRPC_UPSTREAM`, etc.) are derived automatically at creation time. Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions`.
Container gRPC env vars (`YAO_GRPC_ADDR`, etc.) are derived automatically at creation time. Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions`.
### Core API
@ -548,10 +548,8 @@ YAO_TOKEN=<access_token>
YAO_REFRESH_TOKEN=<refresh_token>
YAO_GRPC_ADDR=127.0.0.1:9099
# Remote mode (tai://) adds:
YAO_GRPC_TAI=enable
# Remote mode (tai://)
YAO_GRPC_ADDR=<tai-host>:9100
YAO_GRPC_UPSTREAM=127.0.0.1:9099
```
## Errors

View file

@ -468,13 +468,11 @@ func TestBuildGRPCEnv_Local(t *testing.T) {
assert.Equal(t, "tok", env["YAO_TOKEN"])
assert.Equal(t, "ref", env["YAO_REFRESH_TOKEN"])
assert.NotEmpty(t, env["YAO_GRPC_ADDR"])
assert.Empty(t, env["YAO_GRPC_TAI"])
}
func TestBuildGRPCEnv_Remote(t *testing.T) {
env := sandbox.BuildGRPCEnv(&sandbox.Pool{Addr: "tai://gpu.internal"}, "sb-002", "tok", "ref")
assert.Equal(t, "enable", env["YAO_GRPC_TAI"])
assert.NotEmpty(t, env["YAO_GRPC_UPSTREAM"])
assert.NotEmpty(t, env["YAO_GRPC_ADDR"])
}
func TestCreateContainerTokens(t *testing.T) {

View file

@ -2,10 +2,12 @@ package sandbox
import (
"context"
"fmt"
"io"
"sync/atomic"
"time"
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
"github.com/yaoapp/yao/tai/proxy"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/workspace"
@ -281,3 +283,49 @@ func (b *Box) stopTimeout() time.Duration {
}
return DefaultStopTimeout
}
// ExecOnHost runs a command on the Tai host machine (not inside the container).
// Returns an error if the pool uses a local Docker connection (no Tai server).
func (b *Box) ExecOnHost(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecResult, error) {
b.touch()
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
he := client.HostExec()
if he == nil {
return nil, fmt.Errorf("hostexec not available on pool %q (local mode)", b.pool)
}
cfg := &hostExecConfig{}
for _, o := range opts {
o(cfg)
}
req := &hepb.ExecRequest{
Command: cmd,
Args: args,
WorkingDir: cfg.WorkDir,
Stdin: cfg.Stdin,
TimeoutMs: cfg.TimeoutMs,
MaxOutputBytes: cfg.MaxOutputBytes,
}
if cfg.Env != nil {
req.Env = cfg.Env
}
resp, err := he.Exec(ctx, req)
if err != nil {
return nil, fmt.Errorf("hostexec rpc: %w", err)
}
return &HostExecResult{
ExitCode: int(resp.ExitCode),
Stdout: resp.Stdout,
Stderr: resp.Stderr,
DurationMs: resp.DurationMs,
Error: resp.Error,
Truncated: resp.Truncated,
}, nil
}

View file

@ -0,0 +1,359 @@
package sandbox_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
)
func hostExecClient(t *testing.T, tgt hostExecTarget) hepb.HostExecClient {
t.Helper()
addr := fmt.Sprintf("tai://%s", tgt.Addr)
client, err := tai.New(addr)
if err != nil {
t.Skipf("tai.New(%s): %v", addr, err)
return nil
}
t.Cleanup(func() { client.Close() })
he := client.HostExec()
if he == nil {
t.Skipf("hostexec not available on %s", tgt.Name)
return nil
}
probeCmd, probeArgs := linuxCmd(tgt, "echo", "probe")
probe, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = he.Exec(probe, &hepb.ExecRequest{Command: probeCmd, Args: probeArgs})
if err != nil {
client.Close()
t.Skipf("hostexec on %s unreachable: %v", tgt.Name, err)
return nil
}
return he
}
func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string) {
if tgt.IsWinNative {
switch cmd {
case "echo":
return "cmd.exe", append([]string{"/c", "echo"}, args...)
case "pwd":
return "cmd.exe", []string{"/c", "cd"}
case "env":
return "cmd.exe", []string{"/c", "set"}
case "sleep":
return "cmd.exe", []string{"/c", "ping", "-n", "10", "127.0.0.1"}
case "cat":
return "cmd.exe", []string{"/c", "more"}
case "sh":
if len(args) >= 2 && args[0] == "-c" {
return "cmd.exe", []string{"/c", args[1]}
}
return "cmd.exe", append([]string{"/c"}, args...)
default:
return cmd, args
}
}
return cmd, args
}
func TestHostExec_Echo(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
t.Run(tgt.Name, func(t *testing.T) {
he := hostExecClient(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd, args := linuxCmd(tgt, "echo", "hello", "from", "host")
resp, err := he.Exec(ctx, &hepb.ExecRequest{Command: cmd, Args: args})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if resp.Error != "" {
if strings.Contains(resp.Error, "not in the allowed list") {
t.Skipf("command not allowed on %s", tgt.Name)
}
t.Fatalf("error: %s", resp.Error)
}
if resp.ExitCode != 0 {
t.Errorf("exit_code = %d, want 0", resp.ExitCode)
}
got := strings.TrimSpace(string(resp.Stdout))
if !strings.Contains(got, "hello") {
t.Errorf("stdout = %q, want contains 'hello'", got)
}
})
}
}
func TestHostExec_Env(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
t.Run(tgt.Name, func(t *testing.T) {
he := hostExecClient(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd, args := linuxCmd(tgt, "env")
resp, err := he.Exec(ctx, &hepb.ExecRequest{Command: cmd, Args: args})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if resp.Error != "" {
if strings.Contains(resp.Error, "not in the allowed list") {
t.Skipf("command not allowed on %s", tgt.Name)
}
t.Fatalf("error: %s", resp.Error)
}
out := string(resp.Stdout)
if out == "" {
t.Error("stdout is empty, expected environment variables")
}
})
}
}
func TestHostExec_Timeout(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
t.Run(tgt.Name, func(t *testing.T) {
he := hostExecClient(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd, args := linuxCmd(tgt, "sleep", "10")
resp, err := he.Exec(ctx, &hepb.ExecRequest{
Command: cmd,
Args: args,
TimeoutMs: 200,
})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if resp.Error != "" && strings.Contains(resp.Error, "not in the allowed list") {
t.Skipf("command not allowed on %s", tgt.Name)
}
if !strings.Contains(resp.Error, "timed out") {
t.Errorf("error = %q, want contains 'timed out'", resp.Error)
}
})
}
}
func TestHostExec_WorkingDir(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
t.Run(tgt.Name, func(t *testing.T) {
he := hostExecClient(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd, args := linuxCmd(tgt, "pwd")
workDir := "/tmp"
if tgt.IsWinNative {
workDir = "C:\\Windows\\Temp"
}
resp, err := he.Exec(ctx, &hepb.ExecRequest{
Command: cmd,
Args: args,
WorkingDir: workDir,
})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if resp.Error != "" {
if strings.Contains(resp.Error, "not in") && strings.Contains(resp.Error, "allowed") {
t.Skipf("working_dir not allowed on %s: %s", tgt.Name, resp.Error)
}
t.Fatalf("error: %s", resp.Error)
}
got := strings.TrimSpace(string(resp.Stdout))
if got == "" {
t.Error("stdout is empty")
}
})
}
}
func TestHostExec_Stdin(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
t.Run(tgt.Name, func(t *testing.T) {
he := hostExecClient(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd, args := linuxCmd(tgt, "cat")
resp, err := he.Exec(ctx, &hepb.ExecRequest{
Command: cmd,
Args: args,
Stdin: []byte("piped input"),
})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if resp.Error != "" {
if strings.Contains(resp.Error, "not in the allowed list") {
t.Skipf("command not allowed on %s", tgt.Name)
}
t.Fatalf("error: %s", resp.Error)
}
got := string(resp.Stdout)
if !strings.Contains(got, "piped input") {
t.Errorf("stdout = %q, want contains 'piped input'", got)
}
})
}
}
func TestHostExec_NonZeroExit(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
t.Run(tgt.Name, func(t *testing.T) {
he := hostExecClient(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var cmd string
var args []string
if tgt.IsWinNative {
cmd = "cmd.exe"
args = []string{"/c", "exit", "42"}
} else {
cmd = "sh"
args = []string{"-c", "exit 42"}
}
resp, err := he.Exec(ctx, &hepb.ExecRequest{Command: cmd, Args: args})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if resp.Error != "" {
if strings.Contains(resp.Error, "not in the allowed list") {
t.Skipf("command not allowed on %s", tgt.Name)
}
}
if resp.ExitCode != 42 {
t.Errorf("exit_code = %d, want 42", resp.ExitCode)
}
})
}
}
func TestHostExec_UserEnv(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
t.Run(tgt.Name, func(t *testing.T) {
he := hostExecClient(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var cmd string
var args []string
if tgt.IsWinNative {
cmd = "cmd.exe"
args = []string{"/c", "echo", "%MY_VAR%"}
} else {
cmd = "sh"
args = []string{"-c", "echo $MY_VAR"}
}
resp, err := he.Exec(ctx, &hepb.ExecRequest{
Command: cmd,
Args: args,
Env: map[string]string{"MY_VAR": "test_value"},
})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if resp.Error != "" {
if strings.Contains(resp.Error, "not in the allowed list") {
t.Skipf("command not allowed on %s", tgt.Name)
}
t.Fatalf("error: %s", resp.Error)
}
got := strings.TrimSpace(string(resp.Stdout))
if !strings.Contains(got, "test_value") {
t.Errorf("stdout = %q, want contains 'test_value'", got)
}
})
}
}
// TestHostExec_LocalUnavailable verifies ExecOnHost returns an error for local pools.
func TestHostExec_LocalUnavailable(t *testing.T) {
skipIfNoDocker(t)
m := setupManagerForPool(t, poolConfig{Name: "local", Addr: testLocalAddr()})
box := createTestBox(t, m)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := box.ExecOnHost(ctx, "echo", []string{"should fail"})
if err == nil {
t.Fatal("expected error for local pool, got nil")
}
if !strings.Contains(err.Error(), "not available") {
t.Errorf("error = %q, expected 'not available'", err.Error())
}
}
// TestHostExec_BoxIntegration verifies ExecOnHost works through a sandbox Box
// (requires container creation — only tests pools with Docker/K8s support).
func TestHostExec_BoxIntegration(t *testing.T) {
skipIfNoTai(t)
for _, pc := range testPools() {
if pc.Name == "local" {
continue
}
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool := pc.Name
if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil {
t.Skipf("pool %s unavailable (image check): %v", pool, err)
}
box, err := m.Create(ctx, sandbox.CreateOptions{Image: testImage(), Owner: "test-user"})
if err != nil {
t.Skipf("pool %s unavailable (create): %v", pool, err)
}
t.Cleanup(func() { m.Remove(context.Background(), box.ID()) })
result, err := box.ExecOnHost(ctx, "echo", []string{"box", "integration"})
if err != nil {
t.Skipf("ExecOnHost unavailable on pool %s: %v", pc.Name, err)
}
if result.Error != "" {
if strings.Contains(result.Error, "not in the allowed list") {
t.Skipf("echo not in allowed commands on pool %s", pc.Name)
}
t.Fatalf("hostexec error: %s", result.Error)
}
got := strings.TrimSpace(string(result.Stdout))
if !strings.Contains(got, "box") || !strings.Contains(got, "integration") {
t.Errorf("stdout = %q, want contains 'box integration'", got)
}
})
}
}

View file

@ -52,9 +52,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m
switch {
case strings.HasPrefix(pool.Addr, "tunnel://"):
env["YAO_GRPC_TAI"] = "enable"
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%d", grpcPort)
env["YAO_GRPC_UPSTREAM"] = fmt.Sprintf("127.0.0.1:%s", portStr)
case strings.HasPrefix(pool.Addr, "tai://"):
u, err := url.Parse(pool.Addr)
@ -67,9 +65,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m
if taiPort == "" {
taiPort = "9100"
}
env["YAO_GRPC_TAI"] = "enable"
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort)
env["YAO_GRPC_UPSTREAM"] = fmt.Sprintf("127.0.0.1:%s", portStr)
default:
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr)

View file

@ -19,23 +19,23 @@ func TestBuildGRPCEnvLocal(t *testing.T) {
if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" {
t.Errorf("YAO_GRPC_ADDR = %q", env["YAO_GRPC_ADDR"])
}
if _, ok := env["YAO_GRPC_TAI"]; ok {
t.Error("local mode should not set YAO_GRPC_TAI")
}
}
func TestBuildGRPCEnvRemote(t *testing.T) {
pool := &sandbox.Pool{Name: "gpu", Addr: "tai://gpu-server"}
env := sandbox.BuildGRPCEnv(pool, "sb-002", "access", "refresh", 9099)
if env["YAO_GRPC_TAI"] != "enable" {
t.Errorf("YAO_GRPC_TAI = %q, want enable", env["YAO_GRPC_TAI"])
}
if env["YAO_GRPC_ADDR"] != "gpu-server:9100" {
t.Errorf("YAO_GRPC_ADDR = %q", env["YAO_GRPC_ADDR"])
t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:9100", env["YAO_GRPC_ADDR"])
}
if env["YAO_GRPC_UPSTREAM"] != "127.0.0.1:9099" {
t.Errorf("YAO_GRPC_UPSTREAM = %q", env["YAO_GRPC_UPSTREAM"])
}
func TestBuildGRPCEnvTunnel(t *testing.T) {
pool := &sandbox.Pool{Name: "tunnel", Addr: "tunnel://relay.example.com"}
env := sandbox.BuildGRPCEnv(pool, "sb-003", "access", "refresh", 9099)
if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" {
t.Errorf("YAO_GRPC_ADDR = %q, want 127.0.0.1:9099", env["YAO_GRPC_ADDR"])
}
}

View file

@ -5,6 +5,8 @@ import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
@ -14,6 +16,13 @@ import (
"github.com/yaoapp/yao/workspace"
)
// k8sSem limits concurrent K8s pod creation to avoid overwhelming the cluster.
var k8sSem = make(chan struct{}, 2)
// k8sCleanupMu serialises K8s pod cleanup to prevent overlapping API calls
// when many tests finish at once.
var k8sCleanupMu sync.Mutex
type poolConfig struct {
Name string
Addr string
@ -77,6 +86,40 @@ func skipIfNoTai(t *testing.T) {
}
}
type hostExecTarget struct {
Name string
Addr string // host:port (without tai:// prefix)
IsWinNative bool
}
// hostExecTargets returns all Tai instances that support HostExec gRPC.
// No container creation needed — these are direct gRPC connections.
func hostExecTargets() []hostExecTarget {
var targets []hostExecTarget
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
addr = strings.TrimPrefix(addr, "tai://")
targets = append(targets, hostExecTarget{Name: "remote", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100))
targets = append(targets, hostExecTarget{Name: "k8s", Addr: fmt.Sprintf("%s:%d", host, grpcPort)})
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" {
targets = append(targets, hostExecTarget{Name: "win-linux", Addr: addr})
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_NATIVE"); addr != "" {
targets = append(targets, hostExecTarget{Name: "win-native", Addr: addr, IsWinNative: true})
}
return targets
}
func skipIfNoHostExec(t *testing.T) {
t.Helper()
if len(hostExecTargets()) == 0 {
t.Skip("no HostExec targets configured")
}
}
func testLocalAddr() string {
if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" {
return addr
@ -176,21 +219,43 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat
}
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
isK8s := pool == "k8s"
if isK8s {
k8sSem <- struct{}{}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if pool != "" {
if err := m.EnsureImage(ctx, pool, co.Image, sandbox.ImagePullOptions{}); err != nil {
if isK8s {
<-k8sSem
}
t.Fatalf("EnsureImage(%s, %s): %v", pool, co.Image, err)
}
}
box, err := m.Create(ctx, co)
if err != nil {
if isK8s {
<-k8sSem
}
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
m.Remove(context.Background(), box.ID())
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cleanCancel()
if isK8s {
k8sCleanupMu.Lock()
defer k8sCleanupMu.Unlock()
}
if err := m.Remove(cleanCtx, box.ID()); err != nil {
t.Logf("cleanup Remove(%s): %v", box.ID(), err)
}
if isK8s {
<-k8sSem
}
})
return box
}

View file

@ -176,3 +176,44 @@ type BoxInfo struct {
ProcessCount int
VNC bool
}
// HostExecResult holds the outcome of a command executed on the Tai host.
type HostExecResult struct {
ExitCode int
Stdout []byte
Stderr []byte
DurationMs int64
Error string
Truncated bool
}
type hostExecConfig struct {
WorkDir string
Env map[string]string
Stdin []byte
TimeoutMs int64
MaxOutputBytes int64
}
// HostExecOption configures an ExecOnHost call.
type HostExecOption func(*hostExecConfig)
func WithHostWorkDir(dir string) HostExecOption {
return func(c *hostExecConfig) { c.WorkDir = dir }
}
func WithHostEnv(env map[string]string) HostExecOption {
return func(c *hostExecConfig) { c.Env = env }
}
func WithHostStdin(data []byte) HostExecOption {
return func(c *hostExecConfig) { c.Stdin = data }
}
func WithHostTimeout(ms int64) HostExecOption {
return func(c *hostExecConfig) { c.TimeoutMs = ms }
}
func WithHostMaxOutput(bytes int64) HostExecOption {
return func(c *hostExecConfig) { c.MaxOutputBytes = bytes }
}

View file

@ -0,0 +1,424 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v4.25.0
// source: hostexec/pb/hostexec.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ExecOutput_Stream int32
const (
ExecOutput_STDOUT ExecOutput_Stream = 0
ExecOutput_STDERR ExecOutput_Stream = 1
)
// Enum value maps for ExecOutput_Stream.
var (
ExecOutput_Stream_name = map[int32]string{
0: "STDOUT",
1: "STDERR",
}
ExecOutput_Stream_value = map[string]int32{
"STDOUT": 0,
"STDERR": 1,
}
)
func (x ExecOutput_Stream) Enum() *ExecOutput_Stream {
p := new(ExecOutput_Stream)
*p = x
return p
}
func (x ExecOutput_Stream) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (ExecOutput_Stream) Descriptor() protoreflect.EnumDescriptor {
return file_hostexec_pb_hostexec_proto_enumTypes[0].Descriptor()
}
func (ExecOutput_Stream) Type() protoreflect.EnumType {
return &file_hostexec_pb_hostexec_proto_enumTypes[0]
}
func (x ExecOutput_Stream) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use ExecOutput_Stream.Descriptor instead.
func (ExecOutput_Stream) EnumDescriptor() ([]byte, []int) {
return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{2, 0}
}
type ExecRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"`
Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
WorkingDir string `protobuf:"bytes,3,opt,name=working_dir,json=workingDir,proto3" json:"working_dir,omitempty"`
Env map[string]string `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Stdin []byte `protobuf:"bytes,5,opt,name=stdin,proto3" json:"stdin,omitempty"`
TimeoutMs int64 `protobuf:"varint,6,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"`
MaxOutputBytes int64 `protobuf:"varint,7,opt,name=max_output_bytes,json=maxOutputBytes,proto3" json:"max_output_bytes,omitempty"` // max stdout+stderr size (0 = default 10MB), truncate if exceeded
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecRequest) Reset() {
*x = ExecRequest{}
mi := &file_hostexec_pb_hostexec_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecRequest) ProtoMessage() {}
func (x *ExecRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostexec_pb_hostexec_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead.
func (*ExecRequest) Descriptor() ([]byte, []int) {
return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{0}
}
func (x *ExecRequest) GetCommand() string {
if x != nil {
return x.Command
}
return ""
}
func (x *ExecRequest) GetArgs() []string {
if x != nil {
return x.Args
}
return nil
}
func (x *ExecRequest) GetWorkingDir() string {
if x != nil {
return x.WorkingDir
}
return ""
}
func (x *ExecRequest) GetEnv() map[string]string {
if x != nil {
return x.Env
}
return nil
}
func (x *ExecRequest) GetStdin() []byte {
if x != nil {
return x.Stdin
}
return nil
}
func (x *ExecRequest) GetTimeoutMs() int64 {
if x != nil {
return x.TimeoutMs
}
return 0
}
func (x *ExecRequest) GetMaxOutputBytes() int64 {
if x != nil {
return x.MaxOutputBytes
}
return 0
}
type ExecResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
Stdout []byte `protobuf:"bytes,2,opt,name=stdout,proto3" json:"stdout,omitempty"`
Stderr []byte `protobuf:"bytes,3,opt,name=stderr,proto3" json:"stderr,omitempty"`
DurationMs int64 `protobuf:"varint,4,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"`
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` // non-empty if Tai failed to execute (not the command's error)
Truncated bool `protobuf:"varint,6,opt,name=truncated,proto3" json:"truncated,omitempty"` // true if stdout+stderr exceeded max_output_bytes and was truncated
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecResponse) Reset() {
*x = ExecResponse{}
mi := &file_hostexec_pb_hostexec_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecResponse) ProtoMessage() {}
func (x *ExecResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostexec_pb_hostexec_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead.
func (*ExecResponse) Descriptor() ([]byte, []int) {
return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{1}
}
func (x *ExecResponse) GetExitCode() int32 {
if x != nil {
return x.ExitCode
}
return 0
}
func (x *ExecResponse) GetStdout() []byte {
if x != nil {
return x.Stdout
}
return nil
}
func (x *ExecResponse) GetStderr() []byte {
if x != nil {
return x.Stderr
}
return nil
}
func (x *ExecResponse) GetDurationMs() int64 {
if x != nil {
return x.DurationMs
}
return 0
}
func (x *ExecResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
func (x *ExecResponse) GetTruncated() bool {
if x != nil {
return x.Truncated
}
return false
}
type ExecOutput struct {
state protoimpl.MessageState `protogen:"open.v1"`
Stream ExecOutput_Stream `protobuf:"varint,1,opt,name=stream,proto3,enum=hostexec.ExecOutput_Stream" json:"stream,omitempty"`
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
// Only set in the final message (exit_code is meaningful).
Done bool `protobuf:"varint,3,opt,name=done,proto3" json:"done,omitempty"`
ExitCode int32 `protobuf:"varint,4,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecOutput) Reset() {
*x = ExecOutput{}
mi := &file_hostexec_pb_hostexec_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecOutput) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecOutput) ProtoMessage() {}
func (x *ExecOutput) ProtoReflect() protoreflect.Message {
mi := &file_hostexec_pb_hostexec_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecOutput.ProtoReflect.Descriptor instead.
func (*ExecOutput) Descriptor() ([]byte, []int) {
return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{2}
}
func (x *ExecOutput) GetStream() ExecOutput_Stream {
if x != nil {
return x.Stream
}
return ExecOutput_STDOUT
}
func (x *ExecOutput) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
func (x *ExecOutput) GetDone() bool {
if x != nil {
return x.Done
}
return false
}
func (x *ExecOutput) GetExitCode() int32 {
if x != nil {
return x.ExitCode
}
return 0
}
func (x *ExecOutput) GetError() string {
if x != nil {
return x.Error
}
return ""
}
var File_hostexec_pb_hostexec_proto protoreflect.FileDescriptor
const file_hostexec_pb_hostexec_proto_rawDesc = "" +
"\n" +
"\x1ahostexec/pb/hostexec.proto\x12\bhostexec\"\xa5\x02\n" +
"\vExecRequest\x12\x18\n" +
"\acommand\x18\x01 \x01(\tR\acommand\x12\x12\n" +
"\x04args\x18\x02 \x03(\tR\x04args\x12\x1f\n" +
"\vworking_dir\x18\x03 \x01(\tR\n" +
"workingDir\x120\n" +
"\x03env\x18\x04 \x03(\v2\x1e.hostexec.ExecRequest.EnvEntryR\x03env\x12\x14\n" +
"\x05stdin\x18\x05 \x01(\fR\x05stdin\x12\x1d\n" +
"\n" +
"timeout_ms\x18\x06 \x01(\x03R\ttimeoutMs\x12(\n" +
"\x10max_output_bytes\x18\a \x01(\x03R\x0emaxOutputBytes\x1a6\n" +
"\bEnvEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb0\x01\n" +
"\fExecResponse\x12\x1b\n" +
"\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x16\n" +
"\x06stdout\x18\x02 \x01(\fR\x06stdout\x12\x16\n" +
"\x06stderr\x18\x03 \x01(\fR\x06stderr\x12\x1f\n" +
"\vduration_ms\x18\x04 \x01(\x03R\n" +
"durationMs\x12\x14\n" +
"\x05error\x18\x05 \x01(\tR\x05error\x12\x1c\n" +
"\ttruncated\x18\x06 \x01(\bR\ttruncated\"\xbe\x01\n" +
"\n" +
"ExecOutput\x123\n" +
"\x06stream\x18\x01 \x01(\x0e2\x1b.hostexec.ExecOutput.StreamR\x06stream\x12\x12\n" +
"\x04data\x18\x02 \x01(\fR\x04data\x12\x12\n" +
"\x04done\x18\x03 \x01(\bR\x04done\x12\x1b\n" +
"\texit_code\x18\x04 \x01(\x05R\bexitCode\x12\x14\n" +
"\x05error\x18\x05 \x01(\tR\x05error\" \n" +
"\x06Stream\x12\n" +
"\n" +
"\x06STDOUT\x10\x00\x12\n" +
"\n" +
"\x06STDERR\x10\x012~\n" +
"\bHostExec\x125\n" +
"\x04Exec\x12\x15.hostexec.ExecRequest\x1a\x16.hostexec.ExecResponse\x12;\n" +
"\n" +
"ExecStream\x12\x15.hostexec.ExecRequest\x1a\x14.hostexec.ExecOutput0\x01B#Z!github.com/yaoapp/tai/hostexec/pbb\x06proto3"
var (
file_hostexec_pb_hostexec_proto_rawDescOnce sync.Once
file_hostexec_pb_hostexec_proto_rawDescData []byte
)
func file_hostexec_pb_hostexec_proto_rawDescGZIP() []byte {
file_hostexec_pb_hostexec_proto_rawDescOnce.Do(func() {
file_hostexec_pb_hostexec_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_hostexec_pb_hostexec_proto_rawDesc), len(file_hostexec_pb_hostexec_proto_rawDesc)))
})
return file_hostexec_pb_hostexec_proto_rawDescData
}
var file_hostexec_pb_hostexec_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_hostexec_pb_hostexec_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_hostexec_pb_hostexec_proto_goTypes = []any{
(ExecOutput_Stream)(0), // 0: hostexec.ExecOutput.Stream
(*ExecRequest)(nil), // 1: hostexec.ExecRequest
(*ExecResponse)(nil), // 2: hostexec.ExecResponse
(*ExecOutput)(nil), // 3: hostexec.ExecOutput
nil, // 4: hostexec.ExecRequest.EnvEntry
}
var file_hostexec_pb_hostexec_proto_depIdxs = []int32{
4, // 0: hostexec.ExecRequest.env:type_name -> hostexec.ExecRequest.EnvEntry
0, // 1: hostexec.ExecOutput.stream:type_name -> hostexec.ExecOutput.Stream
1, // 2: hostexec.HostExec.Exec:input_type -> hostexec.ExecRequest
1, // 3: hostexec.HostExec.ExecStream:input_type -> hostexec.ExecRequest
2, // 4: hostexec.HostExec.Exec:output_type -> hostexec.ExecResponse
3, // 5: hostexec.HostExec.ExecStream:output_type -> hostexec.ExecOutput
4, // [4:6] is the sub-list for method output_type
2, // [2:4] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_hostexec_pb_hostexec_proto_init() }
func file_hostexec_pb_hostexec_proto_init() {
if File_hostexec_pb_hostexec_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostexec_pb_hostexec_proto_rawDesc), len(file_hostexec_pb_hostexec_proto_rawDesc)),
NumEnums: 1,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_hostexec_pb_hostexec_proto_goTypes,
DependencyIndexes: file_hostexec_pb_hostexec_proto_depIdxs,
EnumInfos: file_hostexec_pb_hostexec_proto_enumTypes,
MessageInfos: file_hostexec_pb_hostexec_proto_msgTypes,
}.Build()
File_hostexec_pb_hostexec_proto = out.File
file_hostexec_pb_hostexec_proto_goTypes = nil
file_hostexec_pb_hostexec_proto_depIdxs = nil
}

View file

@ -0,0 +1,46 @@
syntax = "proto3";
package hostexec;
option go_package = "github.com/yaoapp/yao/tai/hostexec/pb";
// HostExec provides remote command execution on the Tai host machine.
// High privilege enabled only when --host-exec flag is set.
service HostExec {
// Exec runs a command and returns the result when it completes.
rpc Exec(ExecRequest) returns (ExecResponse);
// ExecStream runs a command and streams stdout/stderr in real time.
rpc ExecStream(ExecRequest) returns (stream ExecOutput);
}
message ExecRequest {
string command = 1;
repeated string args = 2;
string working_dir = 3;
map<string, string> env = 4;
bytes stdin = 5;
int64 timeout_ms = 6;
int64 max_output_bytes = 7; // max stdout+stderr size (0 = default 10MB), truncate if exceeded
}
message ExecResponse {
int32 exit_code = 1;
bytes stdout = 2;
bytes stderr = 3;
int64 duration_ms = 4;
string error = 5; // non-empty if Tai failed to execute (not the command's error)
bool truncated = 6; // true if stdout+stderr exceeded max_output_bytes and was truncated
}
message ExecOutput {
enum Stream {
STDOUT = 0;
STDERR = 1;
}
Stream stream = 1;
bytes data = 2;
// Only set in the final message (exit_code is meaningful).
bool done = 3;
int32 exit_code = 4;
string error = 5;
}

View file

@ -0,0 +1,173 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v4.25.0
// source: hostexec/pb/hostexec.proto
package pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
HostExec_Exec_FullMethodName = "/hostexec.HostExec/Exec"
HostExec_ExecStream_FullMethodName = "/hostexec.HostExec/ExecStream"
)
// HostExecClient is the client API for HostExec service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// HostExec provides remote command execution on the Tai host machine.
// High privilege — enabled only when --host-exec flag is set.
type HostExecClient interface {
// Exec runs a command and returns the result when it completes.
Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (*ExecResponse, error)
// ExecStream runs a command and streams stdout/stderr in real time.
ExecStream(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecOutput], error)
}
type hostExecClient struct {
cc grpc.ClientConnInterface
}
func NewHostExecClient(cc grpc.ClientConnInterface) HostExecClient {
return &hostExecClient{cc}
}
func (c *hostExecClient) Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (*ExecResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExecResponse)
err := c.cc.Invoke(ctx, HostExec_Exec_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostExecClient) ExecStream(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecOutput], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &HostExec_ServiceDesc.Streams[0], HostExec_ExecStream_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ExecRequest, ExecOutput]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type HostExec_ExecStreamClient = grpc.ServerStreamingClient[ExecOutput]
// HostExecServer is the server API for HostExec service.
// All implementations must embed UnimplementedHostExecServer
// for forward compatibility.
//
// HostExec provides remote command execution on the Tai host machine.
// High privilege — enabled only when --host-exec flag is set.
type HostExecServer interface {
// Exec runs a command and returns the result when it completes.
Exec(context.Context, *ExecRequest) (*ExecResponse, error)
// ExecStream runs a command and streams stdout/stderr in real time.
ExecStream(*ExecRequest, grpc.ServerStreamingServer[ExecOutput]) error
mustEmbedUnimplementedHostExecServer()
}
// UnimplementedHostExecServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedHostExecServer struct{}
func (UnimplementedHostExecServer) Exec(context.Context, *ExecRequest) (*ExecResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Exec not implemented")
}
func (UnimplementedHostExecServer) ExecStream(*ExecRequest, grpc.ServerStreamingServer[ExecOutput]) error {
return status.Error(codes.Unimplemented, "method ExecStream not implemented")
}
func (UnimplementedHostExecServer) mustEmbedUnimplementedHostExecServer() {}
func (UnimplementedHostExecServer) testEmbeddedByValue() {}
// UnsafeHostExecServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to HostExecServer will
// result in compilation errors.
type UnsafeHostExecServer interface {
mustEmbedUnimplementedHostExecServer()
}
func RegisterHostExecServer(s grpc.ServiceRegistrar, srv HostExecServer) {
// If the following call panics, it indicates UnimplementedHostExecServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&HostExec_ServiceDesc, srv)
}
func _HostExec_Exec_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExecRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostExecServer).Exec(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HostExec_Exec_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostExecServer).Exec(ctx, req.(*ExecRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HostExec_ExecStream_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(ExecRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(HostExecServer).ExecStream(m, &grpc.GenericServerStream[ExecRequest, ExecOutput]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type HostExec_ExecStreamServer = grpc.ServerStreamingServer[ExecOutput]
// HostExec_ServiceDesc is the grpc.ServiceDesc for HostExec service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var HostExec_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hostexec.HostExec",
HandlerType: (*HostExecServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Exec",
Handler: _HostExec_Exec_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "ExecStream",
Handler: _HostExec_ExecStream_Handler,
ServerStreams: true,
},
},
Metadata: "hostexec/pb/hostexec.proto",
}

View file

@ -10,6 +10,7 @@ import (
"strings"
"time"
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
"github.com/yaoapp/yao/tai/proxy"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/sandbox"
@ -136,6 +137,7 @@ type Client struct {
img sandbox.Image
prx proxy.Proxy
vc vnc.VNC
he hepb.HostExecClient
grpcConn *grpc.ClientConn
// tunnel mode: local listeners that bridge to Tai via WS
@ -217,6 +219,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
return nil, fmt.Errorf("grpc dial %s: %w", grpcAddr, err)
}
c.grpcConn = conn
c.he = hepb.NewHostExecClient(conn)
// Auto-discover server ports via ServerInfo RPC.
// Only overwrite ports that were NOT explicitly set by WithPorts.
@ -315,6 +318,7 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) {
return nil, fmt.Errorf("grpc dial tunnel %s: %w", grpcAddr, err)
}
c.grpcConn = conn
c.he = hepb.NewHostExecClient(conn)
c.vol = volume.NewRemote(conn)
dockerLn, err := reg.OpenLocalListener(taiID, c.ports.Docker)
@ -408,6 +412,10 @@ func (c *Client) Proxy() proxy.Proxy { return c.prx }
// VNC returns the VNC WebSocket helper. Never nil.
func (c *Client) VNC() vnc.VNC { return c.vc }
// HostExec returns the HostExec gRPC client for executing commands on the Tai
// host machine. Returns nil in local mode (no Tai server).
func (c *Client) HostExec() hepb.HostExecClient { return c.he }
// IsLocal returns true if the client connects directly to a Docker daemon.
func (c *Client) IsLocal() bool { return c.scheme == "docker" }