Merge pull request #1487 from trheyi/main

Implement Sandbox V2 support
This commit is contained in:
Max 2026-03-06 10:16:11 +08:00 committed by GitHub
commit 4df3700ac5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 12356 additions and 382 deletions

View file

@ -923,6 +923,283 @@ jobs:
body: '✅ Sandbox Tests passed!' body: '✅ Sandbox Tests passed!'
}); });
# =============================================================================
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
# =============================================================================
SandboxV2Test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
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
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: "Comment on PR"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '🤖 Sandbox V2 Tests running (tai + sandbox-v2 + workspace)...'
});
- 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/sandbox-v2-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
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 Tai K8s
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}"
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
# Kubeconfig for tai-k8s container (uses k3d-internal IP)
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
> /tmp/kubeconfig-tai-k8s.yml
echo "Container kubeconfig server:"
grep server: /tmp/kubeconfig-tai-k8s.yml
# Kubeconfig for test runner (uses localhost via port-mapped 6443)
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Test runner kubeconfig server:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Start Tai K8s instance
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.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 Sandbox V2 Tests (tai + sandbox-v2 + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_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 unit-test-sandbox-v2
- name: Codecov Report
if: always()
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: "Comment on PR - Sandbox V2 Tests Done"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '✅ Sandbox V2 Tests passed (tai + sandbox-v2 + workspace)!'
});
# ============================================================================= # =============================================================================
# Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3 # Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3
# ============================================================================= # =============================================================================
@ -1533,20 +1810,10 @@ jobs:
}); });
# ============================================================================= # =============================================================================
# Tai SDK Tests (requires Tai container with Docker socket mount) # Benchmark: Sandbox V2 + Workspace (parallel with SandboxV2Test, non-blocking)
# ============================================================================= # =============================================================================
TaiTest: BenchmarkSandboxV2:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy: strategy:
matrix: matrix:
go: ["1.25"] go: ["1.25"]
@ -1583,20 +1850,6 @@ jobs:
echo HEAD=$(cat SHA) >> $GITHUB_ENV echo HEAD=$(cat SHA) >> $GITHUB_ENV
echo NR=$(cat NR) >> $GITHUB_ENV echo NR=$(cat NR) >> $GITHUB_ENV
- name: "Comment on PR"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '🤖 Tai SDK Tests running...'
});
- name: Checkout Kun - name: Checkout Kun
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@ -1679,8 +1932,9 @@ jobs:
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Tai & Test Images - name: Pull Test Images
run: | run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai:latest docker pull yaoapp/tai:latest
docker pull alpine:latest docker pull alpine:latest
@ -1693,91 +1947,89 @@ jobs:
kubectl wait --for=condition=Ready node --all --timeout=60s kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test k3d image import alpine:latest -c tai-test
- name: Start Tai (with Docker socket + K8s proxy) - 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: | run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
echo "k3d server IP: ${K3D_IP}" echo "k3d server IP: ${K3D_IP}"
docker run -d --name tai \ docker run -d --name tai-k8s \
--network k3d-tai-test \ --network k3d-tai-test \
-v /var/run/docker.sock:/var/run/docker.sock \ -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ -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_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready" echo "Tai K8s HTTP ready"; break
TAI_HTTP_READY=true
break
fi fi
echo "Waiting for Tai HTTP... ($i)" echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1
sleep 1
done done
if [ "$TAI_HTTP_READY" != "true" ]; then curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || {
echo "::error::Tai HTTP failed to become ready within 30s" echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1
echo "--- Tai container logs ---" }
docker logs tai 2>&1 || true
echo "--- Tai container status ---"
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
exit 1
fi
TAI_GRPC_READY=false
for i in $(seq 1 15); do for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then if nc -z 127.0.0.1 9101 2>/dev/null; then
echo "Tai gRPC is ready" echo "Tai K8s gRPC ready"; break
TAI_GRPC_READY=true
break
fi fi
echo "Waiting for Tai gRPC... ($i)" echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1
sleep 1
done done
if [ "$TAI_GRPC_READY" != "true" ]; then nc -z 127.0.0.1 9101 2>/dev/null || {
echo "::error::Tai gRPC failed to become ready within 15s" echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
echo "--- Tai container logs ---" }
docker logs tai 2>&1 || true
exit 1
fi
- name: Generate kubeconfig for Tai K8s proxy - name: Run Benchmarks
run: |
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Generated kubeconfig:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Run Tai SDK Tests
env: env:
TAI_TEST_HOST: "127.0.0.1" TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_GRPC: "127.0.0.1:9100"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" 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_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443" TAI_TEST_K8S_PORT: "6443"
TAI_TEST_K8S_GRPC_PORT: "9101"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_HOST_IP: "172.17.0.1" TAI_TEST_HOST_IP: "172.17.0.1"
run: make unit-test-tai SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
- name: Codecov Report run: make benchmark-sandbox-v2
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: "Comment on PR - Tai Tests Done"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '✅ Tai SDK Tests passed!'
});
# ============================================================================= # =============================================================================
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed) # gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)

View file

@ -679,6 +679,221 @@ jobs:
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
# =============================================================================
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
# =============================================================================
sandbox-v2-test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
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/sandbox-v2-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
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 Tai K8s
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}"
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
# Kubeconfig for tai-k8s container (uses k3d-internal IP)
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
> /tmp/kubeconfig-tai-k8s.yml
echo "Container kubeconfig server:"
grep server: /tmp/kubeconfig-tai-k8s.yml
# Kubeconfig for test runner (uses localhost via port-mapped 6443)
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Test runner kubeconfig server:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Start Tai K8s instance
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.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 Sandbox V2 Tests (tai + sandbox-v2 + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_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 unit-test-sandbox-v2
- name: Codecov Report
if: always()
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# ============================================================================= # =============================================================================
# Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3 # Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3
# ============================================================================= # =============================================================================
@ -1135,20 +1350,10 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
# ============================================================================= # =============================================================================
# Tai SDK Tests (requires Tai container with Docker socket mount) # Benchmark: Sandbox V2 + Workspace (parallel with sandbox-v2-test)
# ============================================================================= # =============================================================================
tai-test: benchmark-sandbox-v2:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy: strategy:
matrix: matrix:
go: ["1.25"] go: ["1.25"]
@ -1233,8 +1438,9 @@ jobs:
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Tai & Test Images - name: Pull Test Images
run: | run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai:latest docker pull yaoapp/tai:latest
docker pull alpine:latest docker pull alpine:latest
@ -1247,77 +1453,89 @@ jobs:
kubectl wait --for=condition=Ready node --all --timeout=60s kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test k3d image import alpine:latest -c tai-test
- name: Start Tai (with Docker socket + K8s proxy) - 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: | run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
echo "k3d server IP: ${K3D_IP}" echo "k3d server IP: ${K3D_IP}"
docker run -d --name tai \ docker run -d --name tai-k8s \
--network k3d-tai-test \ --network k3d-tai-test \
-v /var/run/docker.sock:/var/run/docker.sock \ -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ -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_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready" echo "Tai K8s HTTP ready"; break
TAI_HTTP_READY=true
break
fi fi
echo "Waiting for Tai HTTP... ($i)" echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1
sleep 1
done done
if [ "$TAI_HTTP_READY" != "true" ]; then curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || {
echo "::error::Tai HTTP failed to become ready within 30s" echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1
echo "--- Tai container logs ---" }
docker logs tai 2>&1 || true
echo "--- Tai container status ---"
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
exit 1
fi
TAI_GRPC_READY=false
for i in $(seq 1 15); do for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then if nc -z 127.0.0.1 9101 2>/dev/null; then
echo "Tai gRPC is ready" echo "Tai K8s gRPC ready"; break
TAI_GRPC_READY=true
break
fi fi
echo "Waiting for Tai gRPC... ($i)" echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1
sleep 1
done done
if [ "$TAI_GRPC_READY" != "true" ]; then nc -z 127.0.0.1 9101 2>/dev/null || {
echo "::error::Tai gRPC failed to become ready within 15s" echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
echo "--- Tai container logs ---" }
docker logs tai 2>&1 || true
exit 1
fi
- name: Generate kubeconfig for Tai K8s proxy - name: Run Benchmarks
run: |
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Generated kubeconfig:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Run Tai SDK Tests
env: env:
TAI_TEST_HOST: "127.0.0.1" TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_GRPC: "127.0.0.1:9100"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" 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_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443" TAI_TEST_K8S_PORT: "6443"
TAI_TEST_K8S_GRPC_PORT: "9101"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_HOST_IP: "172.17.0.1" TAI_TEST_HOST_IP: "172.17.0.1"
run: make unit-test-tai SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
- name: Codecov Report run: make benchmark-sandbox-v2
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
# ============================================================================= # =============================================================================
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed) # gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)

4
.gitignore vendored
View file

@ -75,3 +75,7 @@ tg-send
registry/data/ registry/data/
registry/manager/DESIGN*.md registry/manager/DESIGN*.md
tai/testdata/ tai/testdata/
sandbox/v2/docker/base/*-amd64
sandbox/v2/docker/base/*-arm64
!sandbox/v2/docker/*.sh
!sandbox/v2/docker/*/*.sh

View file

@ -19,10 +19,12 @@ TESTFOLDER_AGENT := $(shell $(GO) list ./agent/... ./aigc/... | grep -vE 'agent/
TESTFOLDER_KB := $(shell $(GO) list ./kb/...) TESTFOLDER_KB := $(shell $(GO) list ./kb/...)
# Robot tests (agent/robot/... packages, excluding events/integrations which require Telegram etc.) # Robot tests (agent/robot/... packages, excluding events/integrations which require Telegram etc.)
TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot/events') TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot/events')
# Sandbox tests (requires Docker) # Sandbox tests (requires Docker) — excludes sandbox/v2 (has its own job)
TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/...) TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/... | grep -v 'sandbox/v2')
# Tai SDK tests (requires Tai container with Docker socket) # Tai SDK tests (requires Tai container with Docker socket)
TESTFOLDER_TAI := $(shell $(GO) list ./tai/...) TESTFOLDER_TAI := $(shell $(GO) list ./tai/...)
# Workspace tests (requires Tai for remote mode)
TESTFOLDER_WORKSPACE := $(shell $(GO) list ./workspace/...)
# gRPC tests # gRPC tests
TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...) TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...)
TESTTAGS ?= "" TESTTAGS ?= ""
@ -199,6 +201,96 @@ unit-test-registry:
rm profile.out; \ rm profile.out; \
fi fi
# ---------------------------------------------------------------------------
# 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
.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
@echo ""
@echo "============================================="
@echo "All Sandbox V2 integration tests passed"
@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:
@echo ""
@echo "============================================="
@echo "Running Workspace Tests..."
@echo "============================================="
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_WORKSPACE); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
@echo ""
@echo "============================================="
@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) # Sandbox Unit Test (requires Docker)
.PHONY: unit-test-sandbox .PHONY: unit-test-sandbox
unit-test-sandbox: unit-test-sandbox:
@ -251,9 +343,6 @@ unit-test-tai:
@echo "=============================================" @echo "============================================="
@echo "Running Tai SDK Tests (requires Tai container)..." @echo "Running Tai SDK Tests (requires Tai container)..."
@echo "=============================================" @echo "============================================="
@echo "Pulling test images..."
docker pull alpine:latest || true
@echo ""
echo "mode: count" > coverage.out echo "mode: count" > coverage.out
for d in $(TESTFOLDER_TAI); do \ for d in $(TESTFOLDER_TAI); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=5m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \ $(GO) test -tags $(TESTTAGS) -v -timeout=5m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \

View file

@ -60,6 +60,9 @@ func VirtualEndpoint(fullMethod string, req interface{}) (method string, path st
} }
return "POST", "/grpc/agent/" return "POST", "/grpc/agent/"
case "/yao.Yao/Heartbeat":
return "POST", "/grpc/heartbeat"
default: default:
return "POST", "/grpc/unknown" return "POST", "/grpc/unknown"
} }

View file

@ -134,3 +134,15 @@ func TestVirtualEndpoint_AgentStreamEmptyID(t *testing.T) {
assert.Equal(t, "POST", method) assert.Equal(t, "POST", method)
assert.Equal(t, "/grpc/agent/", path) assert.Equal(t, "/grpc/agent/", path)
} }
func TestVirtualEndpoint_Heartbeat(t *testing.T) {
method, path := auth.VirtualEndpoint("/yao.Yao/Heartbeat", &pb.HeartbeatRequest{SandboxId: "sb-1"})
assert.Equal(t, "POST", method)
assert.Equal(t, "/grpc/heartbeat", path)
}
func TestVirtualEndpoint_HeartbeatNilReq(t *testing.T) {
method, path := auth.VirtualEndpoint("/yao.Yao/Heartbeat", nil)
assert.Equal(t, "POST", method)
assert.Equal(t, "/grpc/heartbeat", path)
}

View file

@ -7,7 +7,7 @@ func init() {
&acl.ScopeDefinition{Name: "grpc:run", Endpoints: []string{"POST /grpc/run/*", "POST /grpc/run/"}}, &acl.ScopeDefinition{Name: "grpc:run", Endpoints: []string{"POST /grpc/run/*", "POST /grpc/run/"}},
&acl.ScopeDefinition{Name: "grpc:stream", Endpoints: []string{"POST /grpc/stream/*", "POST /grpc/stream/"}}, &acl.ScopeDefinition{Name: "grpc:stream", Endpoints: []string{"POST /grpc/stream/*", "POST /grpc/stream/"}},
&acl.ScopeDefinition{Name: "grpc:shell", Endpoints: []string{"POST /grpc/shell"}}, &acl.ScopeDefinition{Name: "grpc:shell", Endpoints: []string{"POST /grpc/shell"}},
&acl.ScopeDefinition{Name: "grpc:mcp", Endpoints: []string{"GET /grpc/mcp/tools", "POST /grpc/mcp/call/*", "POST /grpc/mcp/call/", "GET /grpc/mcp/resources", "GET /grpc/mcp/resources/read"}}, &acl.ScopeDefinition{Name: "grpc:mcp", Endpoints: []string{"GET /grpc/mcp/tools", "POST /grpc/mcp/call/*", "POST /grpc/mcp/call/", "GET /grpc/mcp/resources", "GET /grpc/mcp/resources/read", "POST /grpc/heartbeat"}},
&acl.ScopeDefinition{Name: "grpc:llm", Endpoints: []string{"POST /grpc/llm/completions"}}, &acl.ScopeDefinition{Name: "grpc:llm", Endpoints: []string{"POST /grpc/llm/completions"}},
&acl.ScopeDefinition{Name: "grpc:agent", Endpoints: []string{"POST /grpc/agent/*", "POST /grpc/agent/"}}, &acl.ScopeDefinition{Name: "grpc:agent", Endpoints: []string{"POST /grpc/agent/*", "POST /grpc/agent/"}},
) )

View file

@ -21,6 +21,7 @@ import (
mcphandler "github.com/yaoapp/yao/grpc/mcp" mcphandler "github.com/yaoapp/yao/grpc/mcp"
"github.com/yaoapp/yao/grpc/pb" "github.com/yaoapp/yao/grpc/pb"
runhandler "github.com/yaoapp/yao/grpc/run" runhandler "github.com/yaoapp/yao/grpc/run"
sandboxhandler "github.com/yaoapp/yao/grpc/sandbox"
shellhandler "github.com/yaoapp/yao/grpc/shell" shellhandler "github.com/yaoapp/yao/grpc/shell"
) )
@ -40,6 +41,7 @@ type yaoServer struct {
mcp mcphandler.Handler mcp mcphandler.Handler
llm llmhandler.Handler llm llmhandler.Handler
agent agenthandler.Handler agent agenthandler.Handler
sandbox *sandboxhandler.Handler
} }
// ── Health ─────────────────────────────────────────────────────────────────── // ── Health ───────────────────────────────────────────────────────────────────
@ -107,6 +109,30 @@ func (s *yaoServer) AgentStream(req *pb.AgentRequest, stream grpc.ServerStreamin
return s.agent.AgentStream(req, stream) return s.agent.AgentStream(req, stream)
} }
// ── Sandbox ──────────────────────────────────────────────────────────────────
func (s *yaoServer) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) {
if s.sandbox == nil {
return &pb.HeartbeatResponse{Action: "ok"}, nil
}
return s.sandbox.Heartbeat(ctx, req)
}
// SandboxHandler returns the sandbox handler for external access (e.g., Manager integration).
func SandboxHandler() *sandboxhandler.Handler {
mu.Lock()
defer mu.Unlock()
return sandboxH
}
var sandboxH *sandboxhandler.Handler
// SetSandboxOnBeat sets the heartbeat callback for the sandbox handler.
// Must be called before StartServer.
func SetSandboxOnBeat(fn func(data *sandboxhandler.HeartbeatData) string) {
sandboxH = sandboxhandler.NewHandler(fn)
}
// ── Server lifecycle ───────────────────────────────────────────────────────── // ── Server lifecycle ─────────────────────────────────────────────────────────
// StartServer initializes and starts the gRPC server based on config. // StartServer initializes and starts the gRPC server based on config.
@ -124,7 +150,10 @@ func StartServer(cfg config.Config) error {
grpc.ChainUnaryInterceptor(auth.UnaryInterceptor), grpc.ChainUnaryInterceptor(auth.UnaryInterceptor),
grpc.ChainStreamInterceptor(auth.StreamInterceptor), grpc.ChainStreamInterceptor(auth.StreamInterceptor),
) )
pb.RegisterYaoServer(server, &yaoServer{}) if sandboxH == nil {
sandboxH = sandboxhandler.NewHandler(nil)
}
pb.RegisterYaoServer(server, &yaoServer{sandbox: sandboxH})
hosts := strings.Split(cfg.GRPC.Host, ",") hosts := strings.Split(cfg.GRPC.Host, ",")
port := strconv.Itoa(cfg.GRPC.Port) port := strconv.Itoa(cfg.GRPC.Port)

View file

@ -2,7 +2,7 @@
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v4.25.0 // protoc v4.25.0
// source: yao.proto // source: grpc/pb/yao.proto
package pb package pb
@ -32,7 +32,7 @@ type RunRequest struct {
func (x *RunRequest) Reset() { func (x *RunRequest) Reset() {
*x = RunRequest{} *x = RunRequest{}
mi := &file_yao_proto_msgTypes[0] mi := &file_grpc_pb_yao_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -44,7 +44,7 @@ func (x *RunRequest) String() string {
func (*RunRequest) ProtoMessage() {} func (*RunRequest) ProtoMessage() {}
func (x *RunRequest) ProtoReflect() protoreflect.Message { func (x *RunRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[0] mi := &file_grpc_pb_yao_proto_msgTypes[0]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -57,7 +57,7 @@ func (x *RunRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RunRequest.ProtoReflect.Descriptor instead. // Deprecated: Use RunRequest.ProtoReflect.Descriptor instead.
func (*RunRequest) Descriptor() ([]byte, []int) { func (*RunRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{0} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{0}
} }
func (x *RunRequest) GetProcess() string { func (x *RunRequest) GetProcess() string {
@ -90,7 +90,7 @@ type RunResponse struct {
func (x *RunResponse) Reset() { func (x *RunResponse) Reset() {
*x = RunResponse{} *x = RunResponse{}
mi := &file_yao_proto_msgTypes[1] mi := &file_grpc_pb_yao_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -102,7 +102,7 @@ func (x *RunResponse) String() string {
func (*RunResponse) ProtoMessage() {} func (*RunResponse) ProtoMessage() {}
func (x *RunResponse) ProtoReflect() protoreflect.Message { func (x *RunResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[1] mi := &file_grpc_pb_yao_proto_msgTypes[1]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -115,7 +115,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead.
func (*RunResponse) Descriptor() ([]byte, []int) { func (*RunResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{1} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{1}
} }
func (x *RunResponse) GetData() []byte { func (x *RunResponse) GetData() []byte {
@ -135,7 +135,7 @@ type Chunk struct {
func (x *Chunk) Reset() { func (x *Chunk) Reset() {
*x = Chunk{} *x = Chunk{}
mi := &file_yao_proto_msgTypes[2] mi := &file_grpc_pb_yao_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -147,7 +147,7 @@ func (x *Chunk) String() string {
func (*Chunk) ProtoMessage() {} func (*Chunk) ProtoMessage() {}
func (x *Chunk) ProtoReflect() protoreflect.Message { func (x *Chunk) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[2] mi := &file_grpc_pb_yao_proto_msgTypes[2]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -160,7 +160,7 @@ func (x *Chunk) ProtoReflect() protoreflect.Message {
// Deprecated: Use Chunk.ProtoReflect.Descriptor instead. // Deprecated: Use Chunk.ProtoReflect.Descriptor instead.
func (*Chunk) Descriptor() ([]byte, []int) { func (*Chunk) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{2} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{2}
} }
func (x *Chunk) GetData() []byte { func (x *Chunk) GetData() []byte {
@ -189,7 +189,7 @@ type ShellRequest struct {
func (x *ShellRequest) Reset() { func (x *ShellRequest) Reset() {
*x = ShellRequest{} *x = ShellRequest{}
mi := &file_yao_proto_msgTypes[3] mi := &file_grpc_pb_yao_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -201,7 +201,7 @@ func (x *ShellRequest) String() string {
func (*ShellRequest) ProtoMessage() {} func (*ShellRequest) ProtoMessage() {}
func (x *ShellRequest) ProtoReflect() protoreflect.Message { func (x *ShellRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[3] mi := &file_grpc_pb_yao_proto_msgTypes[3]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -214,7 +214,7 @@ func (x *ShellRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ShellRequest.ProtoReflect.Descriptor instead. // Deprecated: Use ShellRequest.ProtoReflect.Descriptor instead.
func (*ShellRequest) Descriptor() ([]byte, []int) { func (*ShellRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{3} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{3}
} }
func (x *ShellRequest) GetCommand() string { func (x *ShellRequest) GetCommand() string {
@ -256,7 +256,7 @@ type ShellResponse struct {
func (x *ShellResponse) Reset() { func (x *ShellResponse) Reset() {
*x = ShellResponse{} *x = ShellResponse{}
mi := &file_yao_proto_msgTypes[4] mi := &file_grpc_pb_yao_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -268,7 +268,7 @@ func (x *ShellResponse) String() string {
func (*ShellResponse) ProtoMessage() {} func (*ShellResponse) ProtoMessage() {}
func (x *ShellResponse) ProtoReflect() protoreflect.Message { func (x *ShellResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[4] mi := &file_grpc_pb_yao_proto_msgTypes[4]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -281,7 +281,7 @@ func (x *ShellResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ShellResponse.ProtoReflect.Descriptor instead. // Deprecated: Use ShellResponse.ProtoReflect.Descriptor instead.
func (*ShellResponse) Descriptor() ([]byte, []int) { func (*ShellResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{4} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{4}
} }
func (x *ShellResponse) GetStdout() []byte { func (x *ShellResponse) GetStdout() []byte {
@ -317,7 +317,7 @@ type APIRequest struct {
func (x *APIRequest) Reset() { func (x *APIRequest) Reset() {
*x = APIRequest{} *x = APIRequest{}
mi := &file_yao_proto_msgTypes[5] mi := &file_grpc_pb_yao_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -329,7 +329,7 @@ func (x *APIRequest) String() string {
func (*APIRequest) ProtoMessage() {} func (*APIRequest) ProtoMessage() {}
func (x *APIRequest) ProtoReflect() protoreflect.Message { func (x *APIRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[5] mi := &file_grpc_pb_yao_proto_msgTypes[5]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -342,7 +342,7 @@ func (x *APIRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use APIRequest.ProtoReflect.Descriptor instead. // Deprecated: Use APIRequest.ProtoReflect.Descriptor instead.
func (*APIRequest) Descriptor() ([]byte, []int) { func (*APIRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{5} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{5}
} }
func (x *APIRequest) GetMethod() string { func (x *APIRequest) GetMethod() string {
@ -384,7 +384,7 @@ type APIResponse struct {
func (x *APIResponse) Reset() { func (x *APIResponse) Reset() {
*x = APIResponse{} *x = APIResponse{}
mi := &file_yao_proto_msgTypes[6] mi := &file_grpc_pb_yao_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -396,7 +396,7 @@ func (x *APIResponse) String() string {
func (*APIResponse) ProtoMessage() {} func (*APIResponse) ProtoMessage() {}
func (x *APIResponse) ProtoReflect() protoreflect.Message { func (x *APIResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[6] mi := &file_grpc_pb_yao_proto_msgTypes[6]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -409,7 +409,7 @@ func (x *APIResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use APIResponse.ProtoReflect.Descriptor instead. // Deprecated: Use APIResponse.ProtoReflect.Descriptor instead.
func (*APIResponse) Descriptor() ([]byte, []int) { func (*APIResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{6} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{6}
} }
func (x *APIResponse) GetStatus() int32 { func (x *APIResponse) GetStatus() int32 {
@ -442,7 +442,7 @@ type MCPListRequest struct {
func (x *MCPListRequest) Reset() { func (x *MCPListRequest) Reset() {
*x = MCPListRequest{} *x = MCPListRequest{}
mi := &file_yao_proto_msgTypes[7] mi := &file_grpc_pb_yao_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -454,7 +454,7 @@ func (x *MCPListRequest) String() string {
func (*MCPListRequest) ProtoMessage() {} func (*MCPListRequest) ProtoMessage() {}
func (x *MCPListRequest) ProtoReflect() protoreflect.Message { func (x *MCPListRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[7] mi := &file_grpc_pb_yao_proto_msgTypes[7]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -467,7 +467,7 @@ func (x *MCPListRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use MCPListRequest.ProtoReflect.Descriptor instead. // Deprecated: Use MCPListRequest.ProtoReflect.Descriptor instead.
func (*MCPListRequest) Descriptor() ([]byte, []int) { func (*MCPListRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{7} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{7}
} }
func (x *MCPListRequest) GetSessionId() string { func (x *MCPListRequest) GetSessionId() string {
@ -486,7 +486,7 @@ type MCPListResponse struct {
func (x *MCPListResponse) Reset() { func (x *MCPListResponse) Reset() {
*x = MCPListResponse{} *x = MCPListResponse{}
mi := &file_yao_proto_msgTypes[8] mi := &file_grpc_pb_yao_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -498,7 +498,7 @@ func (x *MCPListResponse) String() string {
func (*MCPListResponse) ProtoMessage() {} func (*MCPListResponse) ProtoMessage() {}
func (x *MCPListResponse) ProtoReflect() protoreflect.Message { func (x *MCPListResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[8] mi := &file_grpc_pb_yao_proto_msgTypes[8]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -511,7 +511,7 @@ func (x *MCPListResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MCPListResponse.ProtoReflect.Descriptor instead. // Deprecated: Use MCPListResponse.ProtoReflect.Descriptor instead.
func (*MCPListResponse) Descriptor() ([]byte, []int) { func (*MCPListResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{8} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{8}
} }
func (x *MCPListResponse) GetTools() []byte { func (x *MCPListResponse) GetTools() []byte {
@ -532,7 +532,7 @@ type MCPCallRequest struct {
func (x *MCPCallRequest) Reset() { func (x *MCPCallRequest) Reset() {
*x = MCPCallRequest{} *x = MCPCallRequest{}
mi := &file_yao_proto_msgTypes[9] mi := &file_grpc_pb_yao_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -544,7 +544,7 @@ func (x *MCPCallRequest) String() string {
func (*MCPCallRequest) ProtoMessage() {} func (*MCPCallRequest) ProtoMessage() {}
func (x *MCPCallRequest) ProtoReflect() protoreflect.Message { func (x *MCPCallRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[9] mi := &file_grpc_pb_yao_proto_msgTypes[9]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -557,7 +557,7 @@ func (x *MCPCallRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use MCPCallRequest.ProtoReflect.Descriptor instead. // Deprecated: Use MCPCallRequest.ProtoReflect.Descriptor instead.
func (*MCPCallRequest) Descriptor() ([]byte, []int) { func (*MCPCallRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{9} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{9}
} }
func (x *MCPCallRequest) GetSessionId() string { func (x *MCPCallRequest) GetSessionId() string {
@ -590,7 +590,7 @@ type MCPCallResponse struct {
func (x *MCPCallResponse) Reset() { func (x *MCPCallResponse) Reset() {
*x = MCPCallResponse{} *x = MCPCallResponse{}
mi := &file_yao_proto_msgTypes[10] mi := &file_grpc_pb_yao_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -602,7 +602,7 @@ func (x *MCPCallResponse) String() string {
func (*MCPCallResponse) ProtoMessage() {} func (*MCPCallResponse) ProtoMessage() {}
func (x *MCPCallResponse) ProtoReflect() protoreflect.Message { func (x *MCPCallResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[10] mi := &file_grpc_pb_yao_proto_msgTypes[10]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -615,7 +615,7 @@ func (x *MCPCallResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MCPCallResponse.ProtoReflect.Descriptor instead. // Deprecated: Use MCPCallResponse.ProtoReflect.Descriptor instead.
func (*MCPCallResponse) Descriptor() ([]byte, []int) { func (*MCPCallResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{10} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{10}
} }
func (x *MCPCallResponse) GetResult() []byte { func (x *MCPCallResponse) GetResult() []byte {
@ -634,7 +634,7 @@ type MCPResourcesResponse struct {
func (x *MCPResourcesResponse) Reset() { func (x *MCPResourcesResponse) Reset() {
*x = MCPResourcesResponse{} *x = MCPResourcesResponse{}
mi := &file_yao_proto_msgTypes[11] mi := &file_grpc_pb_yao_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -646,7 +646,7 @@ func (x *MCPResourcesResponse) String() string {
func (*MCPResourcesResponse) ProtoMessage() {} func (*MCPResourcesResponse) ProtoMessage() {}
func (x *MCPResourcesResponse) ProtoReflect() protoreflect.Message { func (x *MCPResourcesResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[11] mi := &file_grpc_pb_yao_proto_msgTypes[11]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -659,7 +659,7 @@ func (x *MCPResourcesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MCPResourcesResponse.ProtoReflect.Descriptor instead. // Deprecated: Use MCPResourcesResponse.ProtoReflect.Descriptor instead.
func (*MCPResourcesResponse) Descriptor() ([]byte, []int) { func (*MCPResourcesResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{11} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{11}
} }
func (x *MCPResourcesResponse) GetResources() []byte { func (x *MCPResourcesResponse) GetResources() []byte {
@ -679,7 +679,7 @@ type MCPResourceRequest struct {
func (x *MCPResourceRequest) Reset() { func (x *MCPResourceRequest) Reset() {
*x = MCPResourceRequest{} *x = MCPResourceRequest{}
mi := &file_yao_proto_msgTypes[12] mi := &file_grpc_pb_yao_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -691,7 +691,7 @@ func (x *MCPResourceRequest) String() string {
func (*MCPResourceRequest) ProtoMessage() {} func (*MCPResourceRequest) ProtoMessage() {}
func (x *MCPResourceRequest) ProtoReflect() protoreflect.Message { func (x *MCPResourceRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[12] mi := &file_grpc_pb_yao_proto_msgTypes[12]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -704,7 +704,7 @@ func (x *MCPResourceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use MCPResourceRequest.ProtoReflect.Descriptor instead. // Deprecated: Use MCPResourceRequest.ProtoReflect.Descriptor instead.
func (*MCPResourceRequest) Descriptor() ([]byte, []int) { func (*MCPResourceRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{12} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{12}
} }
func (x *MCPResourceRequest) GetSessionId() string { func (x *MCPResourceRequest) GetSessionId() string {
@ -730,7 +730,7 @@ type MCPResourceResponse struct {
func (x *MCPResourceResponse) Reset() { func (x *MCPResourceResponse) Reset() {
*x = MCPResourceResponse{} *x = MCPResourceResponse{}
mi := &file_yao_proto_msgTypes[13] mi := &file_grpc_pb_yao_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -742,7 +742,7 @@ func (x *MCPResourceResponse) String() string {
func (*MCPResourceResponse) ProtoMessage() {} func (*MCPResourceResponse) ProtoMessage() {}
func (x *MCPResourceResponse) ProtoReflect() protoreflect.Message { func (x *MCPResourceResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[13] mi := &file_grpc_pb_yao_proto_msgTypes[13]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -755,7 +755,7 @@ func (x *MCPResourceResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MCPResourceResponse.ProtoReflect.Descriptor instead. // Deprecated: Use MCPResourceResponse.ProtoReflect.Descriptor instead.
func (*MCPResourceResponse) Descriptor() ([]byte, []int) { func (*MCPResourceResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{13} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{13}
} }
func (x *MCPResourceResponse) GetContents() []byte { func (x *MCPResourceResponse) GetContents() []byte {
@ -776,7 +776,7 @@ type ChatRequest struct {
func (x *ChatRequest) Reset() { func (x *ChatRequest) Reset() {
*x = ChatRequest{} *x = ChatRequest{}
mi := &file_yao_proto_msgTypes[14] mi := &file_grpc_pb_yao_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -788,7 +788,7 @@ func (x *ChatRequest) String() string {
func (*ChatRequest) ProtoMessage() {} func (*ChatRequest) ProtoMessage() {}
func (x *ChatRequest) ProtoReflect() protoreflect.Message { func (x *ChatRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[14] mi := &file_grpc_pb_yao_proto_msgTypes[14]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -801,7 +801,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead. // Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead.
func (*ChatRequest) Descriptor() ([]byte, []int) { func (*ChatRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{14} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{14}
} }
func (x *ChatRequest) GetConnector() string { func (x *ChatRequest) GetConnector() string {
@ -834,7 +834,7 @@ type ChatResponse struct {
func (x *ChatResponse) Reset() { func (x *ChatResponse) Reset() {
*x = ChatResponse{} *x = ChatResponse{}
mi := &file_yao_proto_msgTypes[15] mi := &file_grpc_pb_yao_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -846,7 +846,7 @@ func (x *ChatResponse) String() string {
func (*ChatResponse) ProtoMessage() {} func (*ChatResponse) ProtoMessage() {}
func (x *ChatResponse) ProtoReflect() protoreflect.Message { func (x *ChatResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[15] mi := &file_grpc_pb_yao_proto_msgTypes[15]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -859,7 +859,7 @@ func (x *ChatResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead. // Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead.
func (*ChatResponse) Descriptor() ([]byte, []int) { func (*ChatResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{15} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{15}
} }
func (x *ChatResponse) GetData() []byte { func (x *ChatResponse) GetData() []byte {
@ -879,7 +879,7 @@ type ChatChunk struct {
func (x *ChatChunk) Reset() { func (x *ChatChunk) Reset() {
*x = ChatChunk{} *x = ChatChunk{}
mi := &file_yao_proto_msgTypes[16] mi := &file_grpc_pb_yao_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -891,7 +891,7 @@ func (x *ChatChunk) String() string {
func (*ChatChunk) ProtoMessage() {} func (*ChatChunk) ProtoMessage() {}
func (x *ChatChunk) ProtoReflect() protoreflect.Message { func (x *ChatChunk) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[16] mi := &file_grpc_pb_yao_proto_msgTypes[16]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -904,7 +904,7 @@ func (x *ChatChunk) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatChunk.ProtoReflect.Descriptor instead. // Deprecated: Use ChatChunk.ProtoReflect.Descriptor instead.
func (*ChatChunk) Descriptor() ([]byte, []int) { func (*ChatChunk) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{16} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{16}
} }
func (x *ChatChunk) GetData() []byte { func (x *ChatChunk) GetData() []byte {
@ -932,7 +932,7 @@ type AgentRequest struct {
func (x *AgentRequest) Reset() { func (x *AgentRequest) Reset() {
*x = AgentRequest{} *x = AgentRequest{}
mi := &file_yao_proto_msgTypes[17] mi := &file_grpc_pb_yao_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -944,7 +944,7 @@ func (x *AgentRequest) String() string {
func (*AgentRequest) ProtoMessage() {} func (*AgentRequest) ProtoMessage() {}
func (x *AgentRequest) ProtoReflect() protoreflect.Message { func (x *AgentRequest) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[17] mi := &file_grpc_pb_yao_proto_msgTypes[17]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -957,7 +957,7 @@ func (x *AgentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use AgentRequest.ProtoReflect.Descriptor instead. // Deprecated: Use AgentRequest.ProtoReflect.Descriptor instead.
func (*AgentRequest) Descriptor() ([]byte, []int) { func (*AgentRequest) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{17} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{17}
} }
func (x *AgentRequest) GetAssistantId() string { func (x *AgentRequest) GetAssistantId() string {
@ -992,7 +992,7 @@ type AgentChunk struct {
func (x *AgentChunk) Reset() { func (x *AgentChunk) Reset() {
*x = AgentChunk{} *x = AgentChunk{}
mi := &file_yao_proto_msgTypes[18] mi := &file_grpc_pb_yao_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1004,7 +1004,7 @@ func (x *AgentChunk) String() string {
func (*AgentChunk) ProtoMessage() {} func (*AgentChunk) ProtoMessage() {}
func (x *AgentChunk) ProtoReflect() protoreflect.Message { func (x *AgentChunk) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[18] mi := &file_grpc_pb_yao_proto_msgTypes[18]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1017,7 +1017,7 @@ func (x *AgentChunk) ProtoReflect() protoreflect.Message {
// Deprecated: Use AgentChunk.ProtoReflect.Descriptor instead. // Deprecated: Use AgentChunk.ProtoReflect.Descriptor instead.
func (*AgentChunk) Descriptor() ([]byte, []int) { func (*AgentChunk) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{18} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{18}
} }
func (x *AgentChunk) GetData() []byte { func (x *AgentChunk) GetData() []byte {
@ -1042,7 +1042,7 @@ type Empty struct {
func (x *Empty) Reset() { func (x *Empty) Reset() {
*x = Empty{} *x = Empty{}
mi := &file_yao_proto_msgTypes[19] mi := &file_grpc_pb_yao_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1054,7 +1054,7 @@ func (x *Empty) String() string {
func (*Empty) ProtoMessage() {} func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message { func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[19] mi := &file_grpc_pb_yao_proto_msgTypes[19]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1067,7 +1067,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message {
// Deprecated: Use Empty.ProtoReflect.Descriptor instead. // Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) { func (*Empty) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{19} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{19}
} }
type HealthzResponse struct { type HealthzResponse struct {
@ -1079,7 +1079,7 @@ type HealthzResponse struct {
func (x *HealthzResponse) Reset() { func (x *HealthzResponse) Reset() {
*x = HealthzResponse{} *x = HealthzResponse{}
mi := &file_yao_proto_msgTypes[20] mi := &file_grpc_pb_yao_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1091,7 +1091,7 @@ func (x *HealthzResponse) String() string {
func (*HealthzResponse) ProtoMessage() {} func (*HealthzResponse) ProtoMessage() {}
func (x *HealthzResponse) ProtoReflect() protoreflect.Message { func (x *HealthzResponse) ProtoReflect() protoreflect.Message {
mi := &file_yao_proto_msgTypes[20] mi := &file_grpc_pb_yao_proto_msgTypes[20]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1104,7 +1104,7 @@ func (x *HealthzResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use HealthzResponse.ProtoReflect.Descriptor instead. // Deprecated: Use HealthzResponse.ProtoReflect.Descriptor instead.
func (*HealthzResponse) Descriptor() ([]byte, []int) { func (*HealthzResponse) Descriptor() ([]byte, []int) {
return file_yao_proto_rawDescGZIP(), []int{20} return file_grpc_pb_yao_proto_rawDescGZIP(), []int{20}
} }
func (x *HealthzResponse) GetStatus() string { func (x *HealthzResponse) GetStatus() string {
@ -1114,11 +1114,123 @@ func (x *HealthzResponse) GetStatus() string {
return "" return ""
} }
var File_yao_proto protoreflect.FileDescriptor type HeartbeatRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
CpuPercent int32 `protobuf:"varint,2,opt,name=cpu_percent,json=cpuPercent,proto3" json:"cpu_percent,omitempty"`
MemBytes int64 `protobuf:"varint,3,opt,name=mem_bytes,json=memBytes,proto3" json:"mem_bytes,omitempty"`
RunningProcs int32 `protobuf:"varint,4,opt,name=running_procs,json=runningProcs,proto3" json:"running_procs,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
const file_yao_proto_rawDesc = "" + func (x *HeartbeatRequest) Reset() {
*x = HeartbeatRequest{}
mi := &file_grpc_pb_yao_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HeartbeatRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeartbeatRequest) ProtoMessage() {}
func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message {
mi := &file_grpc_pb_yao_proto_msgTypes[21]
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 HeartbeatRequest.ProtoReflect.Descriptor instead.
func (*HeartbeatRequest) Descriptor() ([]byte, []int) {
return file_grpc_pb_yao_proto_rawDescGZIP(), []int{21}
}
func (x *HeartbeatRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *HeartbeatRequest) GetCpuPercent() int32 {
if x != nil {
return x.CpuPercent
}
return 0
}
func (x *HeartbeatRequest) GetMemBytes() int64 {
if x != nil {
return x.MemBytes
}
return 0
}
func (x *HeartbeatRequest) GetRunningProcs() int32 {
if x != nil {
return x.RunningProcs
}
return 0
}
type HeartbeatResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` // "ok" or "shutdown"
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HeartbeatResponse) Reset() {
*x = HeartbeatResponse{}
mi := &file_grpc_pb_yao_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HeartbeatResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeartbeatResponse) ProtoMessage() {}
func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message {
mi := &file_grpc_pb_yao_proto_msgTypes[22]
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 HeartbeatResponse.ProtoReflect.Descriptor instead.
func (*HeartbeatResponse) Descriptor() ([]byte, []int) {
return file_grpc_pb_yao_proto_rawDescGZIP(), []int{22}
}
func (x *HeartbeatResponse) GetAction() string {
if x != nil {
return x.Action
}
return ""
}
var File_grpc_pb_yao_proto protoreflect.FileDescriptor
const file_grpc_pb_yao_proto_rawDesc = "" +
"\n" + "\n" +
"\tyao.proto\x12\x03yao\"T\n" + "\x11grpc/pb/yao.proto\x12\x03yao\"T\n" +
"\n" + "\n" +
"RunRequest\x12\x18\n" + "RunRequest\x12\x18\n" +
"\aprocess\x18\x01 \x01(\tR\aprocess\x12\x12\n" + "\aprocess\x18\x01 \x01(\tR\aprocess\x12\x12\n" +
@ -1196,7 +1308,16 @@ const file_yao_proto_rawDesc = "" +
"\x04done\x18\x02 \x01(\bR\x04done\"\a\n" + "\x04done\x18\x02 \x01(\bR\x04done\"\a\n" +
"\x05Empty\")\n" + "\x05Empty\")\n" +
"\x0fHealthzResponse\x12\x16\n" + "\x0fHealthzResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status2\xb8\x05\n" + "\x06status\x18\x01 \x01(\tR\x06status\"\x94\x01\n" +
"\x10HeartbeatRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" +
"\vcpu_percent\x18\x02 \x01(\x05R\n" +
"cpuPercent\x12\x1b\n" +
"\tmem_bytes\x18\x03 \x01(\x03R\bmemBytes\x12#\n" +
"\rrunning_procs\x18\x04 \x01(\x05R\frunningProcs\"+\n" +
"\x11HeartbeatResponse\x12\x16\n" +
"\x06action\x18\x01 \x01(\tR\x06action2\xf4\x05\n" +
"\x03Yao\x12(\n" + "\x03Yao\x12(\n" +
"\x03Run\x12\x0f.yao.RunRequest\x1a\x10.yao.RunResponse\x12'\n" + "\x03Run\x12\x0f.yao.RunRequest\x1a\x10.yao.RunResponse\x12'\n" +
"\x06Stream\x12\x0f.yao.RunRequest\x1a\n" + "\x06Stream\x12\x0f.yao.RunRequest\x1a\n" +
@ -1213,22 +1334,23 @@ const file_yao_proto_rawDesc = "" +
"\x15ChatCompletionsStream\x12\x10.yao.ChatRequest\x1a\x0e.yao.ChatChunk0\x01\x123\n" + "\x15ChatCompletionsStream\x12\x10.yao.ChatRequest\x1a\x0e.yao.ChatChunk0\x01\x123\n" +
"\vAgentStream\x12\x11.yao.AgentRequest\x1a\x0f.yao.AgentChunk0\x01\x12+\n" + "\vAgentStream\x12\x11.yao.AgentRequest\x1a\x0f.yao.AgentChunk0\x01\x12+\n" +
"\aHealthz\x12\n" + "\aHealthz\x12\n" +
".yao.Empty\x1a\x14.yao.HealthzResponseB\x1fZ\x1dgithub.com/yaoapp/yao/grpc/pbb\x06proto3" ".yao.Empty\x1a\x14.yao.HealthzResponse\x12:\n" +
"\tHeartbeat\x12\x15.yao.HeartbeatRequest\x1a\x16.yao.HeartbeatResponseB\x1fZ\x1dgithub.com/yaoapp/yao/grpc/pbb\x06proto3"
var ( var (
file_yao_proto_rawDescOnce sync.Once file_grpc_pb_yao_proto_rawDescOnce sync.Once
file_yao_proto_rawDescData []byte file_grpc_pb_yao_proto_rawDescData []byte
) )
func file_yao_proto_rawDescGZIP() []byte { func file_grpc_pb_yao_proto_rawDescGZIP() []byte {
file_yao_proto_rawDescOnce.Do(func() { file_grpc_pb_yao_proto_rawDescOnce.Do(func() {
file_yao_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_yao_proto_rawDesc), len(file_yao_proto_rawDesc))) file_grpc_pb_yao_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_grpc_pb_yao_proto_rawDesc), len(file_grpc_pb_yao_proto_rawDesc)))
}) })
return file_yao_proto_rawDescData return file_grpc_pb_yao_proto_rawDescData
} }
var file_yao_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_grpc_pb_yao_proto_msgTypes = make([]protoimpl.MessageInfo, 26)
var file_yao_proto_goTypes = []any{ var file_grpc_pb_yao_proto_goTypes = []any{
(*RunRequest)(nil), // 0: yao.RunRequest (*RunRequest)(nil), // 0: yao.RunRequest
(*RunResponse)(nil), // 1: yao.RunResponse (*RunResponse)(nil), // 1: yao.RunResponse
(*Chunk)(nil), // 2: yao.Chunk (*Chunk)(nil), // 2: yao.Chunk
@ -1250,14 +1372,16 @@ var file_yao_proto_goTypes = []any{
(*AgentChunk)(nil), // 18: yao.AgentChunk (*AgentChunk)(nil), // 18: yao.AgentChunk
(*Empty)(nil), // 19: yao.Empty (*Empty)(nil), // 19: yao.Empty
(*HealthzResponse)(nil), // 20: yao.HealthzResponse (*HealthzResponse)(nil), // 20: yao.HealthzResponse
nil, // 21: yao.ShellRequest.EnvEntry (*HeartbeatRequest)(nil), // 21: yao.HeartbeatRequest
nil, // 22: yao.APIRequest.HeadersEntry (*HeartbeatResponse)(nil), // 22: yao.HeartbeatResponse
nil, // 23: yao.APIResponse.HeadersEntry nil, // 23: yao.ShellRequest.EnvEntry
nil, // 24: yao.APIRequest.HeadersEntry
nil, // 25: yao.APIResponse.HeadersEntry
} }
var file_yao_proto_depIdxs = []int32{ var file_grpc_pb_yao_proto_depIdxs = []int32{
21, // 0: yao.ShellRequest.env:type_name -> yao.ShellRequest.EnvEntry 23, // 0: yao.ShellRequest.env:type_name -> yao.ShellRequest.EnvEntry
22, // 1: yao.APIRequest.headers:type_name -> yao.APIRequest.HeadersEntry 24, // 1: yao.APIRequest.headers:type_name -> yao.APIRequest.HeadersEntry
23, // 2: yao.APIResponse.headers:type_name -> yao.APIResponse.HeadersEntry 25, // 2: yao.APIResponse.headers:type_name -> yao.APIResponse.HeadersEntry
0, // 3: yao.Yao.Run:input_type -> yao.RunRequest 0, // 3: yao.Yao.Run:input_type -> yao.RunRequest
0, // 4: yao.Yao.Stream:input_type -> yao.RunRequest 0, // 4: yao.Yao.Stream:input_type -> yao.RunRequest
3, // 5: yao.Yao.Shell:input_type -> yao.ShellRequest 3, // 5: yao.Yao.Shell:input_type -> yao.ShellRequest
@ -1271,46 +1395,48 @@ var file_yao_proto_depIdxs = []int32{
14, // 13: yao.Yao.ChatCompletionsStream:input_type -> yao.ChatRequest 14, // 13: yao.Yao.ChatCompletionsStream:input_type -> yao.ChatRequest
17, // 14: yao.Yao.AgentStream:input_type -> yao.AgentRequest 17, // 14: yao.Yao.AgentStream:input_type -> yao.AgentRequest
19, // 15: yao.Yao.Healthz:input_type -> yao.Empty 19, // 15: yao.Yao.Healthz:input_type -> yao.Empty
1, // 16: yao.Yao.Run:output_type -> yao.RunResponse 21, // 16: yao.Yao.Heartbeat:input_type -> yao.HeartbeatRequest
2, // 17: yao.Yao.Stream:output_type -> yao.Chunk 1, // 17: yao.Yao.Run:output_type -> yao.RunResponse
4, // 18: yao.Yao.Shell:output_type -> yao.ShellResponse 2, // 18: yao.Yao.Stream:output_type -> yao.Chunk
2, // 19: yao.Yao.ShellStream:output_type -> yao.Chunk 4, // 19: yao.Yao.Shell:output_type -> yao.ShellResponse
6, // 20: yao.Yao.API:output_type -> yao.APIResponse 2, // 20: yao.Yao.ShellStream:output_type -> yao.Chunk
8, // 21: yao.Yao.MCPListTools:output_type -> yao.MCPListResponse 6, // 21: yao.Yao.API:output_type -> yao.APIResponse
10, // 22: yao.Yao.MCPCallTool:output_type -> yao.MCPCallResponse 8, // 22: yao.Yao.MCPListTools:output_type -> yao.MCPListResponse
11, // 23: yao.Yao.MCPListResources:output_type -> yao.MCPResourcesResponse 10, // 23: yao.Yao.MCPCallTool:output_type -> yao.MCPCallResponse
13, // 24: yao.Yao.MCPReadResource:output_type -> yao.MCPResourceResponse 11, // 24: yao.Yao.MCPListResources:output_type -> yao.MCPResourcesResponse
15, // 25: yao.Yao.ChatCompletions:output_type -> yao.ChatResponse 13, // 25: yao.Yao.MCPReadResource:output_type -> yao.MCPResourceResponse
16, // 26: yao.Yao.ChatCompletionsStream:output_type -> yao.ChatChunk 15, // 26: yao.Yao.ChatCompletions:output_type -> yao.ChatResponse
18, // 27: yao.Yao.AgentStream:output_type -> yao.AgentChunk 16, // 27: yao.Yao.ChatCompletionsStream:output_type -> yao.ChatChunk
20, // 28: yao.Yao.Healthz:output_type -> yao.HealthzResponse 18, // 28: yao.Yao.AgentStream:output_type -> yao.AgentChunk
16, // [16:29] is the sub-list for method output_type 20, // 29: yao.Yao.Healthz:output_type -> yao.HealthzResponse
3, // [3:16] is the sub-list for method input_type 22, // 30: yao.Yao.Heartbeat:output_type -> yao.HeartbeatResponse
17, // [17:31] is the sub-list for method output_type
3, // [3:17] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee 3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name 0, // [0:3] is the sub-list for field type_name
} }
func init() { file_yao_proto_init() } func init() { file_grpc_pb_yao_proto_init() }
func file_yao_proto_init() { func file_grpc_pb_yao_proto_init() {
if File_yao_proto != nil { if File_grpc_pb_yao_proto != nil {
return return
} }
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_yao_proto_rawDesc), len(file_yao_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_grpc_pb_yao_proto_rawDesc), len(file_grpc_pb_yao_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 24, NumMessages: 26,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
GoTypes: file_yao_proto_goTypes, GoTypes: file_grpc_pb_yao_proto_goTypes,
DependencyIndexes: file_yao_proto_depIdxs, DependencyIndexes: file_grpc_pb_yao_proto_depIdxs,
MessageInfos: file_yao_proto_msgTypes, MessageInfos: file_grpc_pb_yao_proto_msgTypes,
}.Build() }.Build()
File_yao_proto = out.File File_grpc_pb_yao_proto = out.File
file_yao_proto_goTypes = nil file_grpc_pb_yao_proto_goTypes = nil
file_yao_proto_depIdxs = nil file_grpc_pb_yao_proto_depIdxs = nil
} }

View file

@ -29,6 +29,9 @@ service Yao {
// Health // Health
rpc Healthz(Empty) returns (HealthzResponse); rpc Healthz(Empty) returns (HealthzResponse);
// Sandbox
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
} }
// Base // Base
@ -147,3 +150,16 @@ message Empty {}
message HealthzResponse { message HealthzResponse {
string status = 1; string status = 1;
} }
// Sandbox
message HeartbeatRequest {
string sandbox_id = 1;
int32 cpu_percent = 2;
int64 mem_bytes = 3;
int32 running_procs = 4;
}
message HeartbeatResponse {
string action = 1; // "ok" or "shutdown"
}

View file

@ -2,7 +2,7 @@
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v4.25.0 // - protoc v4.25.0
// source: yao.proto // source: grpc/pb/yao.proto
package pb package pb
@ -32,6 +32,7 @@ const (
Yao_ChatCompletionsStream_FullMethodName = "/yao.Yao/ChatCompletionsStream" Yao_ChatCompletionsStream_FullMethodName = "/yao.Yao/ChatCompletionsStream"
Yao_AgentStream_FullMethodName = "/yao.Yao/AgentStream" Yao_AgentStream_FullMethodName = "/yao.Yao/AgentStream"
Yao_Healthz_FullMethodName = "/yao.Yao/Healthz" Yao_Healthz_FullMethodName = "/yao.Yao/Healthz"
Yao_Heartbeat_FullMethodName = "/yao.Yao/Heartbeat"
) )
// YaoClient is the client API for Yao service. // YaoClient is the client API for Yao service.
@ -59,6 +60,8 @@ type YaoClient interface {
AgentStream(ctx context.Context, in *AgentRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AgentChunk], error) AgentStream(ctx context.Context, in *AgentRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AgentChunk], error)
// Health // Health
Healthz(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthzResponse, error) Healthz(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthzResponse, error)
// Sandbox
Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error)
} }
type yaoClient struct { type yaoClient struct {
@ -235,6 +238,16 @@ func (c *yaoClient) Healthz(ctx context.Context, in *Empty, opts ...grpc.CallOpt
return out, nil return out, nil
} }
func (c *yaoClient) Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HeartbeatResponse)
err := c.cc.Invoke(ctx, Yao_Heartbeat_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// YaoServer is the server API for Yao service. // YaoServer is the server API for Yao service.
// All implementations must embed UnimplementedYaoServer // All implementations must embed UnimplementedYaoServer
// for forward compatibility. // for forward compatibility.
@ -260,6 +273,8 @@ type YaoServer interface {
AgentStream(*AgentRequest, grpc.ServerStreamingServer[AgentChunk]) error AgentStream(*AgentRequest, grpc.ServerStreamingServer[AgentChunk]) error
// Health // Health
Healthz(context.Context, *Empty) (*HealthzResponse, error) Healthz(context.Context, *Empty) (*HealthzResponse, error)
// Sandbox
Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error)
mustEmbedUnimplementedYaoServer() mustEmbedUnimplementedYaoServer()
} }
@ -309,6 +324,9 @@ func (UnimplementedYaoServer) AgentStream(*AgentRequest, grpc.ServerStreamingSer
func (UnimplementedYaoServer) Healthz(context.Context, *Empty) (*HealthzResponse, error) { func (UnimplementedYaoServer) Healthz(context.Context, *Empty) (*HealthzResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Healthz not implemented") return nil, status.Error(codes.Unimplemented, "method Healthz not implemented")
} }
func (UnimplementedYaoServer) Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Heartbeat not implemented")
}
func (UnimplementedYaoServer) mustEmbedUnimplementedYaoServer() {} func (UnimplementedYaoServer) mustEmbedUnimplementedYaoServer() {}
func (UnimplementedYaoServer) testEmbeddedByValue() {} func (UnimplementedYaoServer) testEmbeddedByValue() {}
@ -536,6 +554,24 @@ func _Yao_Healthz_Handler(srv interface{}, ctx context.Context, dec func(interfa
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _Yao_Heartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HeartbeatRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(YaoServer).Heartbeat(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Yao_Heartbeat_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(YaoServer).Heartbeat(ctx, req.(*HeartbeatRequest))
}
return interceptor(ctx, in, info, handler)
}
// Yao_ServiceDesc is the grpc.ServiceDesc for Yao service. // Yao_ServiceDesc is the grpc.ServiceDesc for Yao service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -579,6 +615,10 @@ var Yao_ServiceDesc = grpc.ServiceDesc{
MethodName: "Healthz", MethodName: "Healthz",
Handler: _Yao_Healthz_Handler, Handler: _Yao_Healthz_Handler,
}, },
{
MethodName: "Heartbeat",
Handler: _Yao_Heartbeat_Handler,
},
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
@ -602,5 +642,5 @@ var Yao_ServiceDesc = grpc.ServiceDesc{
ServerStreams: true, ServerStreams: true,
}, },
}, },
Metadata: "yao.proto", Metadata: "grpc/pb/yao.proto",
} }

76
grpc/sandbox/heartbeat.go Normal file
View file

@ -0,0 +1,76 @@
package sandbox
import (
"context"
"sync"
"time"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/grpc/pb"
)
// HeartbeatData holds the latest heartbeat from a sandbox container.
type HeartbeatData struct {
SandboxID string
CPUPercent int32
MemBytes int64
RunningProcs int32
LastSeen time.Time
}
// Handler implements sandbox-related gRPC methods.
type Handler struct {
mu sync.RWMutex
heartbeats map[string]*HeartbeatData
onBeat func(data *HeartbeatData) string // optional callback; returns action
}
// NewHandler creates a Handler. onBeat is called on each heartbeat and
// may return "ok" or "shutdown" to signal the container.
func NewHandler(onBeat func(data *HeartbeatData) string) *Handler {
return &Handler{
heartbeats: make(map[string]*HeartbeatData),
onBeat: onBeat,
}
}
// Heartbeat handles the Heartbeat RPC from sandbox containers.
func (h *Handler) Heartbeat(_ context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) {
data := &HeartbeatData{
SandboxID: req.SandboxId,
CPUPercent: req.CpuPercent,
MemBytes: req.MemBytes,
RunningProcs: req.RunningProcs,
LastSeen: time.Now(),
}
h.mu.Lock()
h.heartbeats[req.SandboxId] = data
h.mu.Unlock()
action := "ok"
if h.onBeat != nil {
if a := h.onBeat(data); a != "" {
action = a
}
}
log.Trace("sandbox heartbeat: id=%s cpu=%d%% mem=%d procs=%d → %s",
req.SandboxId, req.CpuPercent, req.MemBytes, req.RunningProcs, action)
return &pb.HeartbeatResponse{Action: action}, nil
}
// LastHeartbeat returns the most recent heartbeat for a sandbox, or nil.
func (h *Handler) LastHeartbeat(sandboxID string) *HeartbeatData {
h.mu.RLock()
defer h.mu.RUnlock()
return h.heartbeats[sandboxID]
}
// RemoveHeartbeat cleans up heartbeat data for a removed sandbox.
func (h *Handler) RemoveHeartbeat(sandboxID string) {
h.mu.Lock()
delete(h.heartbeats, sandboxID)
h.mu.Unlock()
}

View file

@ -0,0 +1,158 @@
package sandbox
import (
"context"
"sync"
"testing"
"time"
"github.com/yaoapp/yao/grpc/pb"
)
func TestHeartbeat_StoresData(t *testing.T) {
h := NewHandler(nil)
req := &pb.HeartbeatRequest{
SandboxId: "sb-1",
CpuPercent: 25,
MemBytes: 1024 * 1024,
RunningProcs: 3,
}
resp, err := h.Heartbeat(context.Background(), req)
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
if resp.Action != "ok" {
t.Errorf("action = %q, want %q", resp.Action, "ok")
}
data := h.LastHeartbeat("sb-1")
if data == nil {
t.Fatal("LastHeartbeat returned nil")
}
if data.SandboxID != "sb-1" {
t.Errorf("SandboxID = %q", data.SandboxID)
}
if data.CPUPercent != 25 {
t.Errorf("CPUPercent = %d", data.CPUPercent)
}
if data.MemBytes != 1024*1024 {
t.Errorf("MemBytes = %d", data.MemBytes)
}
if data.RunningProcs != 3 {
t.Errorf("RunningProcs = %d", data.RunningProcs)
}
if time.Since(data.LastSeen) > time.Second {
t.Errorf("LastSeen too old: %v", data.LastSeen)
}
}
func TestHeartbeat_OnBeatCallback(t *testing.T) {
var received *HeartbeatData
h := NewHandler(func(d *HeartbeatData) string {
received = d
return "shutdown"
})
resp, err := h.Heartbeat(context.Background(), &pb.HeartbeatRequest{
SandboxId: "sb-2",
CpuPercent: 90,
MemBytes: 4096,
RunningProcs: 10,
})
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
if resp.Action != "shutdown" {
t.Errorf("action = %q, want %q", resp.Action, "shutdown")
}
if received == nil || received.SandboxID != "sb-2" {
t.Errorf("callback not invoked or wrong data")
}
}
func TestHeartbeat_OnBeatEmptyReturnDefaultsToOK(t *testing.T) {
h := NewHandler(func(d *HeartbeatData) string {
return ""
})
resp, err := h.Heartbeat(context.Background(), &pb.HeartbeatRequest{SandboxId: "sb-3"})
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
if resp.Action != "ok" {
t.Errorf("action = %q, want %q", resp.Action, "ok")
}
}
func TestLastHeartbeat_Unknown(t *testing.T) {
h := NewHandler(nil)
if d := h.LastHeartbeat("nonexistent"); d != nil {
t.Errorf("expected nil for unknown sandbox, got %+v", d)
}
}
func TestRemoveHeartbeat(t *testing.T) {
h := NewHandler(nil)
h.Heartbeat(context.Background(), &pb.HeartbeatRequest{SandboxId: "sb-rm"})
if h.LastHeartbeat("sb-rm") == nil {
t.Fatal("expected data after heartbeat")
}
h.RemoveHeartbeat("sb-rm")
if h.LastHeartbeat("sb-rm") != nil {
t.Error("expected nil after RemoveHeartbeat")
}
}
func TestRemoveHeartbeat_Idempotent(t *testing.T) {
h := NewHandler(nil)
h.RemoveHeartbeat("never-existed")
}
func TestHeartbeat_ConcurrentAccess(t *testing.T) {
h := NewHandler(nil)
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
id := "sb-concurrent"
h.Heartbeat(context.Background(), &pb.HeartbeatRequest{
SandboxId: id,
CpuPercent: int32(n),
RunningProcs: int32(n),
})
h.LastHeartbeat(id)
}(i)
}
wg.Wait()
if d := h.LastHeartbeat("sb-concurrent"); d == nil {
t.Error("expected data after concurrent heartbeats")
}
}
func TestHeartbeat_MultiSandbox(t *testing.T) {
h := NewHandler(nil)
for _, id := range []string{"a", "b", "c"} {
h.Heartbeat(context.Background(), &pb.HeartbeatRequest{SandboxId: id, CpuPercent: 10})
}
for _, id := range []string{"a", "b", "c"} {
if d := h.LastHeartbeat(id); d == nil {
t.Errorf("missing heartbeat for %q", id)
}
}
h.RemoveHeartbeat("b")
if h.LastHeartbeat("b") != nil {
t.Error("b should be removed")
}
if h.LastHeartbeat("a") == nil || h.LastHeartbeat("c") == nil {
t.Error("a and c should still exist")
}
}

View file

@ -45,23 +45,22 @@ High-level business layer on top of `tai.Client`. Manages container lifecycle, u
- File operations (via `tai.Client.Volume()` for remote, bind mount for local) - File operations (via `tai.Client.Volume()` for remote, bind mount for local)
- IPC relay to Yao gRPC server - IPC relay to Yao gRPC server
### Yao gRPC Server (yao/grpc) ### Yao gRPC Server (yao/grpc) — ✅ Implemented
General-purpose gRPC service exposed by the Yao process. Not limited to sandbox IPC — it exposes Yao's process execution capability to any gRPC client. 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:** **Clients:**
- Container-internal MCP tools (via Tai Gateway relay) - Container-internal `yao-grpc` (via Tai Gateway relay or direct)
- `yao run --remote` CLI - `yao run` CLI (after `yao login`)
- Other Yao instances (future node-to-node) - Other Yao instances (future node-to-node)
**IPC path (replacing Unix socket):** **IPC path (replacing Unix socket):**
``` ```
Container process → yao-bridge (tai/bridge/) → Tai Gateway (:9100 gRPC) → Yao gRPC Server (:9099) 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)
process.Run(...)
``` ```
Tai does **not** know Yao gRPC address at startup. The upstream is passed per-container via `CreateRequest.GRPCUpstream` — Tai records the mapping and routes relay traffic by source container. This keeps Tai stateless and allows one Tai to serve multiple Yao instances. 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.
## Authentication ## Authentication
@ -79,11 +78,11 @@ The gRPC server reuses the existing `openapi/oauth` service — no new auth syst
| Scope registration | `acl.Register(...)` | gRPC scopes via same pattern | None — add `grpc:*` scope definitions in `init()` | | Scope registration | `acl.Register(...)` | gRPC scopes via same pattern | None — add `grpc:*` scope definitions in `init()` |
| Client auth | `ClientProvider` | `client_credentials` grant for CLI/containers | None | | Client auth | `ClientProvider` | `client_credentials` grant for CLI/containers | None |
| Token revocation | `oauth.Revoke(ctx, token, hint)` | Container token cleanup | None | | Token revocation | `oauth.Revoke(ctx, token, hint)` | Container token cleanup | None |
| Device Flow scaffolding | `types.DeviceAuthorizationResponse`, `GrantTypeDeviceCode`, error codes | CLI `yao login` | Implement `DeviceAuthorization()` (currently stub) | | Device Flow | `DeviceAuthorization()`, `AuthorizeDevice()`, device_code store, `GrantTypeDeviceCode` grant | CLI `yao login` | ✅ Implemented |
**Key insight**: `authorized.SetInfo/GetInfo` are Gin-bound, but gRPC does NOT need them. The gRPC interceptor builds `AccessRequest` directly from JWT claims and calls `ScopeManager.Check` — bypasses the full `Enforce` chain (client/team/member), which is HTTP multi-tenant only. **Key insight**: `authorized.SetInfo/GetInfo` are Gin-bound, but gRPC does NOT need them. The gRPC interceptor builds `AccessRequest` directly from JWT claims and calls `ScopeManager.Check` — bypasses the full `Enforce` chain (client/team/member), which is HTTP multi-tenant only.
**Impact on existing code: zero.** All gRPC auth is purely additive (~80 lines interceptor + scope registration). Device Flow adds ~190 lines new code + ~10 lines to existing `Token()` switch. **Status**: All auth infrastructure is implemented and working — gRPC interceptor, scope registration, Device Flow (backend + CUI page), CLI commands (`yao login`/`yao logout`).
### gRPC interceptor ### gRPC interceptor
@ -106,8 +105,8 @@ func authInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, h
| Client | How it gets a token | | Client | How it gets a token |
|--------|-------------------| |--------|-------------------|
| Container MCP tool | `oauth.MakeAccessToken(clientID, "grpc:mcp grpc:run", userID, 900)` injected as `YAO_TOKEN` env var. `yao-bridge` auto-refreshes via `YAO_REFRESH_TOKEN`. Manager revokes refresh token on container Remove. | | Container MCP tool | `oauth.MakeAccessToken(clientID, "grpc:mcp grpc:run", userID, 900)` injected as `YAO_TOKEN` env var. `yao-grpc` auto-refreshes via response metadata. Manager revokes refresh token on container Remove. |
| `yao run` CLI | `yao login` → OAuth Device Authorization Grant → token saved to `~/.yao/credentials`. Logged in = gRPC, not logged in = local. | | `yao run` CLI | `yao login --server <url>` → OAuth Device Authorization Grant (RFC 8628) → dynamic client registration via machine ID → token saved to `~/.yao/credentials` (base64 JSON). Logged in = gRPC, not logged in = local. |
| Yao-to-Yao | Pre-shared service token or `client_credentials` | | Yao-to-Yao | Pre-shared service token or `client_credentials` |
## Network Security ## Network Security
@ -193,7 +192,7 @@ Lifecycle is managed by `sandbox.Manager`, not by tai.Client.
| Mode | tai.Client | File IO | | Mode | tai.Client | File IO |
|------|-----------|---------| |------|-----------|---------|
| Local | `tai.New("")` | Bind mount, direct host filesystem | | Local | `tai.New("local")` | Bind mount, direct host filesystem |
| Remote | `tai.New("tai://host")` | `tai.Client.Volume()` via gRPC | | Remote | `tai.New("tai://host")` | `tai.Client.Volume()` via gRPC |
Local mode preserves bind mount for performance. Remote mode uses `tai/volume` (gRPC + lz4 compression). `sandbox.Manager` routes based on `client.IsLocal()`. Local mode preserves bind mount for performance. Remote mode uses `tai/volume` (gRPC + lz4 compression). `sandbox.Manager` routes based on `client.IsLocal()`.
@ -234,32 +233,37 @@ agent/context/jsapi_sandbox.go
| Container exec | `dockerClient.ContainerExecCreate/Start/Attach` | `tai.Client.Sandbox().Exec()` | | 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 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()` | | File write | `dockerClient.CopyToContainer` | Local: bind mount. Remote: `tai.Client.Volume().Write()` |
| IPC | Unix socket bind mount + yao-bridge | Local: Unix socket (same). Remote: Tai gRPC relay → Yao gRPC server | | 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 | Local: socket path from config. Remote: gRPC endpoint injected as env var | | MCP config | `{args: ["/tmp/yao.sock"]}` hardcoded | `YAO_GRPC_ADDR` + `YAO_TOKEN` env vars. Local: direct. Remote: + `YAO_GRPC_TAI`/`YAO_GRPC_UPSTREAM`. |
| VNC | `vncproxy.NewProxy(nil)` local assumption | `tai.Client.VNC().URL()` | | VNC | `vncproxy.NewProxy(nil)` local assumption | `tai.Client.VNC().URL()` |
| Cleanup | `dockerClient.ContainerRemove` | `tai.Client.Sandbox().Remove()` | | Cleanup | `dockerClient.ContainerRemove` | `tai.Client.Sandbox().Remove()` |
### IPC migration detail ### IPC migration detail
**Local mode** (same host): Unix socket preserved — zero overhead, no change needed. All modes use gRPC — no Unix socket fallback, one code path for local and remote.
**Remote mode** (via Tai):
```
Container process → yao-bridge (tai/bridge/) → Tai relay (:9100 gRPC) → Yao gRPC Server
```
`yao-bridge` source lives in `yao/tai/bridge/` — it's a Tai SDK client (consumes Tai relay), shares gRPC deps with `tai/`, and is version-locked with the Tai protocol. Built via `go build ./tai/bridge/cmd/yao-bridge`.
Bridge mode determined by env var:
``` ```
YAO_IPC_MODE=socket YAO_IPC_ADDR=/tmp/yao.sock # local Local: Container → yao-grpc → Yao gRPC 127.0.0.1:9099
YAO_IPC_MODE=grpc YAO_IPC_ADDR=tai-host:9100 # remote Remote: Container → yao-grpc → Tai :9100 relay → Yao gRPC :9099
``` ```
In gRPC mode, bridge also reads `YAO_TOKEN` / `YAO_REFRESH_TOKEN` and handles automatic token refresh (see grpc/DESIGN.md Container token section). `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 relay upstream is NOT configured at Tai startup. Manager passes `GRPCUpstream` per-container in `CreateRequest` — Tai records the mapping and routes by source container. One Tai can serve containers from different Yao instances. 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
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 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.
`BuildMCPConfigForSandbox()` sets the env vars based on `client.IsLocal()`. `BuildMCPConfigForSandbox()` sets the env vars based on `client.IsLocal()`.
@ -298,8 +302,26 @@ sandbox:
## Migration Path ## Migration Path
1. **Phase 1:** Yao gRPC server — expose process execution, replace Unix socket IPC ### Completed
2. **Phase 2:** `sandbox.Manager` refactoring — replace Docker client with `tai.Client`, unified file ops, new lifecycle model
3. **Phase 3:** Agent layer adaptation — executor uses new Manager, IPC mode switch, lifecycle policy 1. **Yao gRPC server** (`yao/grpc`) — full gRPC gateway with 14 RPCs (Run, Stream, Shell, ShellStream, API, MCP×4, ChatCompletions, ChatCompletionsStream, AgentStream, Healthz). OAuth + ACL auth interceptor reusing existing openapi infrastructure. V1 all unary + LLM/Agent streaming done; V2 base streaming (Stream, ShellStream) pending `gou/stream` package. Details: [grpc/DESIGN.md](../grpc/DESIGN.md), [grpc/IMPL.md](../grpc/IMPL.md).
4. **Phase 4:** `yao run --remote` — CLI calls remote Yao via gRPC
5. **Phase 5:** Workspace persistence — browser preview, service exposure, delivery 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.
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`.
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.
6. **`yao run` via gRPC** (`yao/cmd/run.go`) — no `--remote` flag; logged in = gRPC, not logged in = local. `--auth <path>` for alternate credentials. TUI status bar (lipgloss) shows user/scope in gRPC mode, hidden with `-s` (silent).
### Remaining
7. **`sandbox.Manager` refactoring** — replace Docker client with `tai.Client`, unified file ops (bind mount for local, `tai.Client.Volume()` for remote), new lifecycle model (one-shot / session / long-running / persistent).
8. **Agent layer adaptation** — executor uses new Manager, IPC mode switch (gRPC replaces Unix socket), lifecycle policy per-assistant config.
9. **`gou/stream` package** (V2) — streaming process execution foundation. ~150 lines. Enables gRPC `Stream` and `ShellStream` handlers, V8 `Stream()` global.
10. **Workspace persistence** — browser preview, service exposure, delivery.

View file

@ -164,15 +164,21 @@ case $TOOL in
# Cursor (uncomment when ready) # Cursor (uncomment when ready)
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH" # 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]"
exit 0
;;
*) *)
echo "Unknown tool: $TOOL" echo "Unknown tool: $TOOL"
echo "Usage: $0 [claude|claude-vnc|browser|desktop|chrome|cursor|all] [true|false]" echo "Usage: $0 [claude|claude-vnc|browser|desktop|chrome|cursor|v2|all] [true|false]"
echo " $0 claude # Build Claude images locally" echo " $0 claude # Build Claude images locally"
echo " $0 claude true # Build and push Claude images" echo " $0 claude true # Build and push Claude images"
echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)" echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)"
echo " $0 browser # Build Claude Browser image only" echo " $0 browser # Build Claude Browser image only"
echo " $0 desktop # Build Claude Desktop image only" echo " $0 desktop # Build Claude Desktop image only"
echo " $0 chrome # Build Claude Chrome image (amd64 only)" echo " $0 chrome # Build Claude Chrome image (amd64 only)"
echo " $0 v2 # Build Sandbox V2 images (base + test)"
echo " $0 all true # Build and push all images" echo " $0 all true # Build and push all images"
exit 1 exit 1
;; ;;

1132
sandbox/v2/DESIGN.md Normal file

File diff suppressed because it is too large Load diff

263
sandbox/v2/IMPL.md Normal file
View file

@ -0,0 +1,263 @@
# Sandbox V2 — Implementation Status
Reference: [DESIGN.md](./DESIGN.md)
---
## Phase 1: Core Module — DONE
### tai SDK Prerequisites — DONE
| Step | Package | What | Status |
|------|---------|------|--------|
| 0 | `tai/sandbox` | Labels, User in CreateOptions + ContainerInfo | DONE |
| 1 | `tai/sandbox` | ExecStream (Docker + K8s) | DONE |
| 2 | `tai/proxy` | Connect (WS/SSE, Local + Remote) | DONE |
| 3 | `tai/sandbox` | Image interface (Exists, Pull, Remove, List) | DONE |
| 4 | `tai/tai.go` | Client: Sandbox(), Image(), Proxy(), VNC(), Volume(), Workspace() | DONE |
| 5 | `yao/grpc` | Heartbeat RPC (proto + handler) | DONE |
### sandbox/v2 Core — DONE
| File | What | Status |
|------|------|--------|
| `sandbox.go` | `Init()`, `M()`, global singleton | DONE |
| `manager.go` | Manager: Create/Get/GetOrCreate/List/Remove/Cleanup/Close, Start (container recovery), AddPool/RemovePool/Pools, Heartbeat, SetGRPCPort, SetWorkspaceManager, ImageExists/PullImage/EnsureImage | DONE |
| `box.go` | Box: Exec, Stream, Attach, Workspace, VNC, Proxy, Start/Stop/Remove, Info, touch/lastActiveTime/idleTimeout/maxLifetime/stopTimeout | DONE |
| `types.go` | LifecyclePolicy (OneShot/Session/LongRunning/Persistent), Pool, PoolInfo, PortMapping, CreateOptions (with WorkspaceID/MountMode/MountPath), ListOptions, ExecOption/ExecResult/ExecStream, AttachOption/ServiceConn, ImagePullOptions/RegistryAuth, BoxInfo, DefaultStopTimeout | DONE |
| `config.go` | Config struct | DONE |
| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded, ErrPoolNotFound, ErrPoolInUse | DONE |
| `grpc.go` | CreateContainerTokens, RevokeContainerTokens, BuildGRPCEnv | DONE |
### workspace Module — DONE
| File | What | Status |
|------|------|--------|
| `workspace.go` | Workspace struct, CreateOptions, ListOptions, UpdateOptions, NodeInfo, MountMode, metadata marshal/unmarshal | DONE |
| `manager.go` | Manager: Create/Get/List/Update/Delete, ReadFile/WriteFile/ListDir/Remove/FS, Nodes/AddPool/RemovePool, NodeForWorkspace/MountPath | DONE |
| `errors.go` | ErrNotFound, ErrNodeMissing, ErrNodeOffline, ErrHasMounts | DONE |
### Tests — DONE
| File | Coverage | Status |
|------|----------|--------|
| **sandbox/v2** | | |
| `sandbox_test.go` | Init, M, singleton | DONE |
| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool limits (MaxTotal, MaxPerUser), multi-pool | DONE |
| `manager_lifecycle_test.go` | Start (recovery), Cleanup, idle tracking, Heartbeat | DONE |
| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), status | DONE |
| `box_attach_test.go` | Attach WS echo, Attach SSE events, VNC URL, VNC Connect (RFB handshake) | DONE |
| `box_workspace_test.go` | Workspace mount, file I/O through Box, invalid ID | DONE |
| `box_image_test.go` | ImageExists (Docker+K8s), PullImage (progress+K8s no-op), EnsureImage, bad ref | DONE |
| `grpc_test.go` | Token creation/revocation, env var building (local vs remote) | DONE |
| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | DONE |
| `testutils_test.go` | testPools (local/remote/k8s), setupManager, createTestBox, ensureTestImage | DONE |
| `export_test.go` | ResetForTest | DONE |
| **workspace** | | |
| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (owner/node filter), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool/RemovePool, MountPath | DONE |
| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove, NotFound) | DONE |
| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | DONE |
| `testutils_test.go` | testPools, setupManagerForPool, clientForPool, localClient, setupManagerMultiNode, createWorkspace | DONE |
### CI — DONE
| Job | Contents | Status |
|-----|----------|--------|
| `SandboxV2Test` | Consolidated: image pre-pull → tai-test → sandbox/v2 (local+remote+k8s) → workspace (local+remote) | DONE |
| `BenchmarkSandboxV2` | Parallel: performance tests for sandbox/v2 + workspace | DONE |
| `GRPCTest` | Independent: gRPC tests (unchanged) | DONE |
### Performance Optimizations — DONE
| Optimization | Before | After | Impact |
|-------------|--------|-------|--------|
| Remove redundant Stop in Manager.Remove() | 2.14s | 177ms | 12x faster Docker remove |
| Container CMD trap SIGTERM | 2s+ stop | near-instant | Graceful shutdown on Stop |
| K8s Start: respect ctx deadline | 30s hardcoded | ctx-aware + 60s default | Proper timeout propagation |
| K8s Pod spec: Args vs Command | CMD overridden | ENTRYPOINT preserved | Correct container behavior |
---
## Phase 2: JSAPI + OAuth — PENDING
| Task | Package | Detail |
|------|---------|--------|
| `jsapi/sandbox.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` + `Workspace()` constructors (registered in gou runtime) |
| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls |
| `cmd/start.go` integration | `yao` | Call `sandbox.Init(config.Conf.Sandbox)` + `sandbox.M().Start(ctx)` in startup sequence |
| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` |
### JSAPI (planned)
```javascript
// Sandbox
var sb = Sandbox("my-workspace", {
image: "yaoapp/workspace:latest",
owner: "user-123"
})
sb.Exec(["go", "build", "./..."])
sb.ReadFile("src/main.go")
sb.WriteFile("src/main.go", "package main\n...")
sb.Stream(["npm", "run", "dev"], function(chunk) { ... })
var conn = sb.Attach(3000, { protocol: "ws", path: "/ws" })
sb.Info()
sb.Stop()
sb.Start()
sb.Remove()
// Workspace
var ws = Workspace("my-workspace")
ws.ReadFile("src/main.go")
ws.WriteFile("src/main.go", "package main\n...")
ws.ListDir("src/")
ws.Remove("tmp.txt")
```
---
## Phase 3: Agent Integration — PENDING
| Task | Detail |
|------|--------|
| Agent creates Box via `sandbox.M().GetOrCreate()` | Replace `infraSandbox.Manager` |
| Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads |
| Agent uses `Box.Exec()` for commands | Replace Docker exec |
| Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy |
| Agent injects `Box` as `SandboxExecutor` | `ctx.sandbox` JSAPI unchanged for hooks |
---
## Phase 4: Cutover — PENDING
| Task | Detail |
|------|--------|
| Move `sandbox/v2``sandbox` | Rename package |
| Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ |
| Delete `DESIGN-REMOTE.md` | Superseded by tai.Client |
| Update `cmd/start.go` | Use new init path |
| `sandbox/process.go` | Register `sandbox.*` process namespace (post-cutover) |
| `workspace/process.go` | Register `workspace.*` process namespace (post-cutover) |
---
## Implementation Details
### Container CMD
All V2 containers use a SIGTERM-aware sleep as PID 1:
```bash
sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"
```
This ensures:
- Container stays alive indefinitely (no hardcoded `sleep infinity`)
- Exits immediately on SIGTERM (no 2s wait)
- Works on both Docker and K8s
### Container Labels
Manager injects these labels at creation time:
```
managed-by=yao-sandbox
sandbox-id=<id>
sandbox-owner=<owner>
sandbox-pool=<pool>
sandbox-policy=<policy>
workspace-id=<workspace-id> (if WorkspaceID set)
```
Used by `Manager.Start()` to discover and recover existing containers after restart.
### Workspace Bind Mount
When `CreateOptions.WorkspaceID` is set:
```
1. NodeForWorkspace(wsID) → node name
2. Force pool = node name
3. MountPath(wsID) → hostDir
4. Bind: hostDir:/workspace:rw
```
### Multi-Mode Testing
`testPools()` returns all available pool configurations:
```go
func testPools() []poolConfig {
pools := []poolConfig{{Name: "local", Addr: testLocalAddr()}}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
// ... K8s pool with kubeconfig, namespace, ports
pools = append(pools, poolConfig{Name: "k8s", ...})
}
return pools
}
```
Every test iterates over all available pools:
```go
func TestSomething(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
// test logic
})
}
}
```
### Benchmark Helpers
```go
func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager
func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string)
func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box
```
K8s-specific behavior:
- `BenchmarkStopStart`: skipped (K8s Stop deletes Pod)
- Create/Lifecycle benchmarks: 120s timeout for K8s Pod scheduling
---
## File Inventory
### sandbox/v2 (7 source + 10 test = 17 files)
| File | Lines | Purpose |
|------|-------|---------|
| `sandbox.go` | ~25 | Global singleton |
| `manager.go` | ~620 | Manager implementation |
| `box.go` | ~230 | Box implementation |
| `types.go` | ~170 | Type definitions |
| `config.go` | ~5 | Config struct |
| `errors.go` | ~10 | Error definitions |
| `grpc.go` | ~55 | Token/env injection |
| `testutils_test.go` | ~130 | Test helpers |
| `sandbox_test.go` | ~30 | Singleton tests |
| `manager_test.go` | ~250 | CRUD tests |
| `manager_lifecycle_test.go` | ~120 | Lifecycle tests |
| `box_test.go` | ~200 | Box tests |
| `box_attach_test.go` | ~260 | Attach/VNC tests |
| `box_workspace_test.go` | ~285 | Workspace tests |
| `box_image_test.go` | ~120 | Image tests |
| `grpc_test.go` | ~80 | Token tests |
| `bench_test.go` | ~230 | Benchmarks |
### workspace (3 source + 4 test = 7 files)
| File | Lines | Purpose |
|------|-------|---------|
| `workspace.go` | ~80 | Types + metadata |
| `manager.go` | ~320 | Manager implementation |
| `errors.go` | ~10 | Error definitions |
| `testutils_test.go` | ~90 | Test helpers |
| `workspace_test.go` | ~325 | CRUD tests |
| `fileio_test.go` | ~235 | File I/O tests |
| `bench_test.go` | ~150 | Benchmarks |

108
sandbox/v2/Makefile Normal file
View file

@ -0,0 +1,108 @@
GO ?= go
GOFILES := $(shell find . -name "*.go" -not -path "./docker/*")
PACKAGES := $(shell $(GO) list ./...)
TEST_IMAGE ?= yaoapp/sandbox-v2-test:latest
TEST_TIMEOUT ?= 600s
# ---------------------------------------------------------------------------
# Local test (Docker only)
# ---------------------------------------------------------------------------
.PHONY: test-local
test-local:
@echo "=== Sandbox V2: local mode ==="
SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \
$(GO) test $(PACKAGES) -count=1 -v -timeout $(TEST_TIMEOUT) -run '/local'
# ---------------------------------------------------------------------------
# Remote test (requires Tai server)
# ---------------------------------------------------------------------------
.PHONY: test-remote
test-remote:
@if [ -z "$(SANDBOX_TEST_REMOTE_ADDR)" ]; then \
echo "SANDBOX_TEST_REMOTE_ADDR not set, skipping remote tests"; \
exit 0; \
fi
@echo "=== Sandbox V2: remote mode ($(SANDBOX_TEST_REMOTE_ADDR)) ==="
SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \
$(GO) test $(PACKAGES) -count=1 -v -timeout $(TEST_TIMEOUT) -run '/remote'
# ---------------------------------------------------------------------------
# Dual-mode test (local + remote when configured)
# ---------------------------------------------------------------------------
.PHONY: test
test:
@echo ""
@echo "============================================="
@echo "Sandbox V2 Tests (dual-mode)"
@echo "============================================="
@echo "Image: $(TEST_IMAGE)"
@echo "Remote: $${SANDBOX_TEST_REMOTE_ADDR:-<not set, local only>}"
@echo ""
SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \
$(GO) test $(PACKAGES) -count=1 -v -timeout $(TEST_TIMEOUT)
@echo ""
@echo "============================================="
@echo "Sandbox V2 Tests passed"
@echo "============================================="
# ---------------------------------------------------------------------------
# CI test (with coverage, used by Makefile at repo root)
# ---------------------------------------------------------------------------
.PHONY: test-ci
test-ci:
@echo ""
@echo "============================================="
@echo "Sandbox V2 CI Tests"
@echo "============================================="
echo "mode: count" > coverage.out
@for d in $(PACKAGES); do \
SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \
$(GO) test -v -count=1 -timeout $(TEST_TIMEOUT) \
-covermode=count -coverprofile=profile.out \
-coverpkg=$$(echo $$d | sed "s/\/test$$//g") \
$$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
@echo ""
@echo "============================================="
@echo "Sandbox V2 CI Tests passed"
@echo "============================================="
# ---------------------------------------------------------------------------
# Docker images
# ---------------------------------------------------------------------------
.PHONY: docker-build
docker-build:
./docker/build.sh build
.PHONY: docker-push
docker-push:
./docker/build.sh push
# ---------------------------------------------------------------------------
# fmt / vet
# ---------------------------------------------------------------------------
.PHONY: fmt
fmt:
gofmt -s -w $(GOFILES)
.PHONY: vet
vet:
$(GO) vet $(PACKAGES)

621
sandbox/v2/TEST.md Normal file
View file

@ -0,0 +1,621 @@
# Sandbox V2 — Test Specification
Design: [DESIGN.md](./DESIGN.md) | Implementation: [IMPL.md](./IMPL.md)
## Principles
- **Black-box testing**: all `*_test.go` files use `package sandbox_test` — tests only access exported API
- **Real containers**: tests create real Docker containers via tai SDK, no mocking
- **Skip when unavailable**: `skipIfNoDocker(t)` / `skipIfNoTai(t)` — CI has Docker and Tai; local dev may not
- **Tests follow implementation**: `*_test.go` lives next to the code it tests
- **Coverage > 80%**: per file and overall
## Prerequisites
```bash
source $YAO_SOURCE_ROOT/env.local.sh
```
### Docker (required for all container tests)
Docker daemon must be running. Tests connect via default socket.
### Tai (required for remote-mode tests only)
```bash
docker run -d --name tai \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 2375:2375 -p 9100:9100 -p 8080:8080 -p 6080:6080 \
yaoapp/tai:latest
```
### Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `YAO_TEST_APPLICATION` | Path to `yao-dev-app` | — (required) |
| `YAO_DB_DRIVER` / `YAO_DB_PRIMARY` | Database connection | — (required) |
| `YAO_JWT_SECRET` / `YAO_DB_AESKEY` | Crypto keys (for OAuth token creation) | — (required) |
| `SANDBOX_TEST_IMAGE` | Container image for tests | `yaoapp/sandbox-v2-test:latest` |
| `SANDBOX_TEST_REMOTE_ADDR` | Tai remote address, e.g. `tai://127.0.0.1` | — (skip remote tests if empty) |
| `TAI_TEST_HOST` | Tai HTTP proxy host | `127.0.0.1` |
## Directory Structure
```
sandbox/v2/
├── sandbox.go
├── sandbox_test.go # Init/M singleton tests
├── manager.go
├── manager_test.go # Create/Get/GetOrCreate/List/Remove, pool management
├── manager_lifecycle_test.go # Start recovery, Cleanup, idle tracking, heartbeat
├── box.go
├── box_test.go # Exec/Stream/Workspace/Proxy/VNC, lifecycle
├── box_attach_test.go # Attach WS/SSE (needs service in container)
├── config.go
├── types.go
├── errors.go
├── grpc.go
├── grpc_test.go # OAuth token creation/revocation, env var building
├── testutils_test.go # shared test helpers (unexported, package sandbox_test)
└── DESIGN.md
```
## testutils (internal to sandbox_test)
Shared helpers in `testutils_test.go` — not a separate package, lives inside `package sandbox_test`.
```go
// testutils_test.go
package sandbox_test
// skipIfNoDocker skips the test if Docker is not available.
func skipIfNoDocker(t *testing.T)
// skipIfNoTai skips the test if SANDBOX_TEST_REMOTE_ADDR is empty.
func skipIfNoTai(t *testing.T)
// testImage returns SANDBOX_TEST_IMAGE or "yaoapp/sandbox-v2-test:latest".
func testImage() string
// setupManager initializes sandbox with a local pool, returns cleanup func.
// Calls sandbox.Init + sandbox.M().Start.
func setupManager(t *testing.T) func()
// setupManagerWithRemote initializes sandbox with local + remote pools.
func setupManagerWithRemote(t *testing.T) func()
// createTestBox creates a box with defaults and returns it. Registers t.Cleanup for removal.
func createTestBox(t *testing.T, opts ...sandbox.CreateOption) *sandbox.Box
```
## How to Write a Test
### Standard pattern
```go
// manager_test.go
package sandbox_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestCreate(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
})
require.NoError(t, err)
defer box.Remove(context.Background())
assert.NotEmpty(t, box.ID())
assert.Equal(t, "test-user", box.Owner())
}
func TestCreate_NoImage(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
_, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{})
assert.Error(t, err) // Image is required
}
```
### Container execution tests
```go
// box_test.go
package sandbox_test
func TestExec(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
result, err := box.Exec(context.Background(), []string{"echo", "hello"})
require.NoError(t, err)
assert.Equal(t, 0, result.ExitCode)
assert.Equal(t, "hello\n", result.Stdout)
assert.Empty(t, result.Stderr)
}
func TestExec_NonZeroExit(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
result, err := box.Exec(context.Background(), []string{"sh", "-c", "exit 42"})
require.NoError(t, err)
assert.Equal(t, 42, result.ExitCode)
}
```
### Streaming tests
```go
// box_test.go
package sandbox_test
func TestStream(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
s, err := box.Stream(context.Background(), []string{"sh", "-c", "echo a; sleep 0.1; echo b"})
require.NoError(t, err)
out, _ := io.ReadAll(s.Stdout)
code, err := s.Wait()
assert.NoError(t, err)
assert.Equal(t, 0, code)
assert.Contains(t, string(out), "a\n")
assert.Contains(t, string(out), "b\n")
}
func TestStream_Cancel(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
s, err := box.Stream(context.Background(), []string{"sleep", "60"})
require.NoError(t, err)
s.Cancel()
code, _ := s.Wait()
assert.NotEqual(t, 0, code) // killed
}
func TestStream_Stdin(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
s, err := box.Stream(context.Background(), []string{"cat"})
require.NoError(t, err)
s.Stdin.Write([]byte("hello\n"))
s.Stdin.Close()
out, _ := io.ReadAll(s.Stdout)
code, _ := s.Wait()
assert.Equal(t, 0, code)
assert.Equal(t, "hello\n", string(out))
}
```
### Workspace tests
```go
// box_test.go
package sandbox_test
func TestWorkspace_ReadWrite(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
ws := box.Workspace()
err := ws.WriteFile("test.txt", []byte("hello"), 0644)
require.NoError(t, err)
data, err := fs.ReadFile(ws, "test.txt")
require.NoError(t, err)
assert.Equal(t, "hello", string(data))
}
func TestWorkspace_MkdirAll(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
ws := box.Workspace()
err := ws.MkdirAll("a/b/c", 0755)
require.NoError(t, err)
info, err := fs.Stat(ws, "a/b/c")
require.NoError(t, err)
assert.True(t, info.IsDir())
}
func TestWorkspace_WalkDir(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
ws := box.Workspace()
ws.MkdirAll("src", 0755)
ws.WriteFile("src/main.go", []byte("package main"), 0644)
ws.WriteFile("src/util.go", []byte("package main"), 0644)
var files []string
fs.WalkDir(ws, "src", func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() { files = append(files, path) }
return nil
})
assert.Len(t, files, 2)
}
```
### Lifecycle tests
```go
// manager_lifecycle_test.go
package sandbox_test
func TestIdleCleanup_Session(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Policy: sandbox.Session,
IdleTimeout: 2 * time.Second,
})
require.NoError(t, err)
// Box exists
_, err = sandbox.M().Get(context.Background(), box.ID())
assert.NoError(t, err)
// Wait for idle + cleanup cycle
time.Sleep(4 * time.Second)
sandbox.M().Cleanup(context.Background())
// Box should be gone
_, err = sandbox.M().Get(context.Background(), box.ID())
assert.ErrorIs(t, err, sandbox.ErrNotFound)
}
func TestStartRecovery(t *testing.T) {
skipIfNoDocker(t)
// Phase 1: create a box, then shut down Manager
cleanup1 := setupManager(t)
box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Owner: "recovery-test",
})
require.NoError(t, err)
boxID := box.ID()
cleanup1() // closes Manager, but container stays
// Phase 2: new Manager, Start should discover the container
cleanup2 := setupManager(t)
defer cleanup2()
recovered, err := sandbox.M().Get(context.Background(), boxID)
require.NoError(t, err)
assert.Equal(t, boxID, recovered.ID())
assert.Equal(t, "recovery-test", recovered.Owner())
// Clean up
recovered.Remove(context.Background())
}
func TestHeartbeat(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
// Simulate heartbeat
err := sandbox.M().Heartbeat(box.ID(), true, 3)
assert.NoError(t, err)
info, _ := box.Info(context.Background())
assert.Equal(t, 3, info.ProcessCount)
}
func TestHeartbeat_NotFound(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
err := sandbox.M().Heartbeat("nonexistent", true, 1)
assert.ErrorIs(t, err, sandbox.ErrNotFound)
}
```
### Pool management tests
```go
// manager_test.go
package sandbox_test
func TestPoolLimits_MaxTotal(t *testing.T) {
skipIfNoDocker(t)
// Init with MaxTotal=1
err := sandbox.Init(sandbox.Config{
Pool: []sandbox.Pool{{
Name: "limited",
Addr: "local",
MaxTotal: 1,
}},
})
require.NoError(t, err)
sandbox.M().Start(context.Background())
defer sandbox.M().Close()
box1, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
})
require.NoError(t, err)
defer box1.Remove(context.Background())
_, err = sandbox.M().Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
})
assert.ErrorIs(t, err, sandbox.ErrLimitExceeded)
}
func TestAddPool(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
err := sandbox.M().AddPool(context.Background(), sandbox.Pool{
Name: "new-pool",
Addr: "local",
})
assert.NoError(t, err)
pools := sandbox.M().Pools()
names := make([]string, len(pools))
for i, p := range pools { names[i] = p.Name }
assert.Contains(t, names, "new-pool")
}
func TestRemovePool_InUse(t *testing.T) {
skipIfNoDocker(t)
cleanup := setupManager(t)
defer cleanup()
box := createTestBox(t)
_ = box
err := sandbox.M().RemovePool(context.Background(), "local", false)
assert.ErrorIs(t, err, sandbox.ErrPoolInUse)
}
```
### Multi-pool tests
```go
// manager_test.go
package sandbox_test
func TestMultiPool(t *testing.T) {
skipIfNoDocker(t)
skipIfNoTai(t)
cleanup := setupManagerWithRemote(t)
defer cleanup()
// Create on local
local, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Pool: "local",
})
require.NoError(t, err)
defer local.Remove(context.Background())
// Create on remote
remote, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Pool: "remote",
})
require.NoError(t, err)
defer remote.Remove(context.Background())
// Both should exec
r1, _ := local.Exec(context.Background(), []string{"echo", "local"})
r2, _ := remote.Exec(context.Background(), []string{"echo", "remote"})
assert.Equal(t, "local\n", r1.Stdout)
assert.Equal(t, "remote\n", r2.Stdout)
}
```
### OAuth / gRPC env injection tests
```go
// grpc_test.go
package sandbox_test
func TestBuildGRPCEnv_Local(t *testing.T) {
env := sandbox.BuildGRPCEnv(&sandbox.Pool{Addr: "local"}, "sb-001", "tok", "ref")
assert.Equal(t, "sb-001", env["YAO_SANDBOX_ID"])
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"])
}
func TestCreateContainerTokens(t *testing.T) {
// Requires Yao runtime for OAuth
cleanup := setupManager(t)
defer cleanup()
access, refresh, err := sandbox.CreateContainerTokens("sb-test", "user-1")
require.NoError(t, err)
assert.NotEmpty(t, access)
assert.NotEmpty(t, refresh)
}
```
## Required Test Cases
| File | Required Cases |
|------|---------------|
| `sandbox_test.go` | `Init` succeeds / `M()` panics before Init / double Init is safe |
| `manager_test.go` | Create / Create with explicit ID / Create no image (error) / Get / Get not found / GetOrCreate / List / List with owner filter / Remove / pool limits MaxTotal / pool limits MaxPerUser / AddPool / RemovePool / RemovePool in use / Pools |
| `manager_lifecycle_test.go` | Start recovery from labels / Cleanup Session idle / Cleanup LongRunning stop then remove / Persistent never cleaned / Heartbeat updates / Heartbeat not found / OneShot removed after Exec |
| `box_test.go` | Exec success / Exec non-zero exit / Exec with WorkDir / Exec with Env / Exec with Timeout / Stream read / Stream cancel / Stream stdin / Workspace ReadFile+WriteFile / Workspace MkdirAll / Workspace Remove / Workspace Rename / Workspace WalkDir / VNC (skip if no VNC image) / Proxy URL / Start+Stop+Start / Info |
| `box_attach_test.go` | Attach WS (skip if no WS server image) / Attach SSE (skip if no SSE server image) |
| `grpc_test.go` | BuildGRPCEnv local / BuildGRPCEnv remote / CreateContainerTokens / RevokeContainerTokens |
## Makefile
Add to [Makefile](../../Makefile):
```makefile
TESTFOLDER_SANDBOX_V2 := $(shell $(GO) list ./sandbox/v2/...)
.PHONY: unit-test-sandbox-v2
unit-test-sandbox-v2:
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_SANDBOX_V2); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=10m \
-covermode=count -coverprofile=profile.out \
-coverpkg=$$d \
$$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
```
## CI Integration
Add `sandbox-v2-test` job to `unit-test.yml` and `pr-test.yml`:
```yaml
sandbox-v2-test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: "123456"
MONGO_INITDB_DATABASE: test
strategy:
matrix:
go: ["1.25"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Tai container
run: |
docker run -d --name tai \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 2375:2375 -p 9100:9100 -p 8080:8080 -p 6080:6080 \
yaoapp/tai:latest
sleep 3
- name: Build V2 test image
run: |
cd sandbox/docker
bash build.sh v2
- name: Setup ENV
run: |
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
echo "SANDBOX_TEST_IMAGE=yaoapp/sandbox-v2-test:latest" >> $GITHUB_ENV
echo "SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1" >> $GITHUB_ENV
echo "TAI_TEST_HOST=127.0.0.1" >> $GITHUB_ENV
mkdir -p ${{ github.WORKSPACE }}/../app/db
- name: Run Sandbox V2 Tests
run: make unit-test-sandbox-v2
- name: Codecov Report
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
```
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
- Attach tests (WS/SSE) use `sandbox-v2-test` image's built-in test services
## Coverage
- Target: >80% per file, >80% overall
- `sandbox.go` (singleton) covered via `sandbox_test.go`
- `manager.go` is the heaviest file — must have dedicated `manager_test.go` + `manager_lifecycle_test.go`
- `box.go` exercises all tai SDK integration points
- `grpc.go` tested with pure unit tests (token generation, env building)
## Running Tests
```bash
# All sandbox v2 tests (local Docker only)
make unit-test-sandbox-v2
# With remote mode (start Tai first)
SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 make unit-test-sandbox-v2
# Single file
go test -v ./sandbox/v2/ -run TestCreate
# Single test
go test -v ./sandbox/v2/ -run TestExec_NonZeroExit
# With race detector
go test -race -v ./sandbox/v2/
```

254
sandbox/v2/bench_test.go Normal file
View file

@ -0,0 +1,254 @@
package sandbox_test
import (
"context"
"fmt"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
// BenchmarkContainerLifecycle measures the full Create → Exec → Remove cycle.
func BenchmarkContainerLifecycle(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
_, err = box.Exec(ctx, []string{"echo", "ok"})
if err != nil {
b.Fatalf("Exec: %v", err)
}
m.Remove(ctx, box.ID())
}
})
}
}
// BenchmarkCreate measures container creation time only.
func BenchmarkCreate(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
ids := make([]string, 0, b.N)
b.ResetTimer()
for i := 0; i < b.N; i++ {
box, err := m.Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
ids = append(ids, box.ID())
}
b.StopTimer()
for _, id := range ids {
m.Remove(context.Background(), id)
}
})
}
}
// BenchmarkExec measures command execution latency on a pre-created container.
func BenchmarkExec(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
result, err := box.Exec(context.Background(), []string{"echo", "bench"})
if err != nil {
b.Fatalf("Exec: %v", err)
}
if result.ExitCode != 0 {
b.Fatalf("exit code = %d", result.ExitCode)
}
}
})
}
}
// BenchmarkExecHeavy measures execution of a heavier command (write + read file).
func BenchmarkExecHeavy(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cmd := []string{"sh", "-c", fmt.Sprintf("echo bench-%d > /tmp/b.txt && cat /tmp/b.txt", i)}
result, err := box.Exec(context.Background(), cmd)
if err != nil {
b.Fatalf("Exec: %v", err)
}
if result.ExitCode != 0 {
b.Fatalf("exit code = %d", result.ExitCode)
}
}
})
}
}
// BenchmarkRemove measures container removal time.
func BenchmarkRemove(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
boxes := make([]*sandbox.Box, b.N)
for i := 0; i < b.N; i++ {
box, err := m.Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
boxes[i] = box
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := m.Remove(context.Background(), boxes[i].ID()); err != nil {
b.Fatalf("Remove: %v", err)
}
}
})
}
}
// BenchmarkInfo measures Info() latency on a running container.
func BenchmarkInfo(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := box.Info(context.Background())
if err != nil {
b.Fatalf("Info: %v", err)
}
}
})
}
}
// BenchmarkStopStart measures Stop → Start cycle time.
func BenchmarkStopStart(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
if pc.Name == "k8s" {
b.Skip("K8s Stop deletes Pod; Stop→Start cycle not applicable")
}
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := box.Stop(context.Background()); err != nil {
b.Fatalf("Stop: %v", err)
}
if err := box.Start(context.Background()); err != nil {
b.Fatalf("Start: %v", err)
}
}
})
}
}
// BenchmarkWorkspaceReadWrite measures workspace file read/write via container Box.
func BenchmarkWorkspaceReadWrite(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
ws := box.Workspace()
if ws == nil {
b.Skip("workspace not available")
}
payload := []byte("package main\nfunc main() { println(\"hello\") }\n")
b.ResetTimer()
for i := 0; i < b.N; i++ {
name := fmt.Sprintf("f%d.go", i)
if err := ws.WriteFile(name, payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
data, err := ws.ReadFile(name)
if err != nil {
b.Fatalf("ReadFile: %v", err)
}
if len(data) != len(payload) {
b.Fatalf("size mismatch: %d vs %d", len(data), len(payload))
}
}
})
}
}
// --- helpers ---
func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager {
b.Helper()
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}}
if err := sandbox.Init(cfg); err != nil {
b.Fatalf("Init: %v", err)
}
m := sandbox.M()
b.Cleanup(func() { m.Close() })
return m
}
func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) {
b.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil {
b.Fatalf("EnsureImage: %v", err)
}
}
func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box {
b.Helper()
pools := m.Pools()
if len(pools) > 0 {
ensureTestImageBench(b, m, pools[0].Name)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
b.Cleanup(func() { m.Remove(context.Background(), box.ID()) })
return box
}

283
sandbox/v2/box.go Normal file
View file

@ -0,0 +1,283 @@
package sandbox
import (
"context"
"io"
"sync/atomic"
"time"
"github.com/yaoapp/yao/tai/proxy"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/workspace"
)
// Box represents a single sandbox instance.
type Box struct {
id string
containerID string
pool string
owner string
policy LifecyclePolicy
labels map[string]string
lastCall atomic.Int64
lastHeartbeat atomic.Int64
processCount atomic.Int32
idleTimeoutD time.Duration
stopTimeoutD time.Duration
createdAt time.Time
refreshToken string
vnc bool
image string
workspaceID string
ws workspace.FS
manager *Manager
}
func (b *Box) ID() string { return b.id }
func (b *Box) Owner() string { return b.owner }
func (b *Box) ContainerID() string { return b.containerID }
func (b *Box) Pool() string { return b.pool }
// Exec runs a command and waits for it to finish.
func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) {
b.touch()
cfg := &execConfig{}
for _, o := range opts {
o(cfg)
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
result, err := client.Sandbox().Exec(ctx, b.containerID, cmd, taisandbox.ExecOptions{
WorkDir: cfg.WorkDir,
Env: cfg.Env,
})
if err != nil {
return nil, err
}
r := &ExecResult{
ExitCode: result.ExitCode,
Stdout: result.Stdout,
Stderr: result.Stderr,
}
if b.policy == OneShot {
b.manager.Remove(ctx, b.id)
}
return r, nil
}
// Stream runs a command with real-time streaming I/O.
func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) {
b.touch()
cfg := &execConfig{}
for _, o := range opts {
o(cfg)
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
handle, err := client.Sandbox().ExecStream(ctx, b.containerID, cmd, taisandbox.ExecOptions{
WorkDir: cfg.WorkDir,
Env: cfg.Env,
})
if err != nil {
return nil, err
}
return &ExecStream{
Stdout: io.NopCloser(handle.Stdout),
Stderr: io.NopCloser(handle.Stderr),
Stdin: handle.Stdin,
Wait: handle.Wait,
Cancel: handle.Cancel,
}, nil
}
// Attach connects to a service running inside the sandbox on the given container port.
func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*ServiceConn, error) {
b.touch()
cfg := &attachConfig{Protocol: "ws"}
for _, o := range opts {
o(cfg)
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
conn, err := client.Proxy().Connect(ctx, b.containerID, proxy.ConnectOptions{
Port: port,
Path: cfg.Path,
Protocol: cfg.Protocol,
})
if err != nil {
return nil, err
}
sc := &ServiceConn{
Write: conn.Send,
Events: conn.Messages,
Close: conn.Close,
}
if cfg.Protocol == "ws" {
ch := conn.Messages
sc.Read = func() ([]byte, error) {
msg, ok := <-ch
if !ok {
return nil, io.EOF
}
return msg, nil
}
}
return sc, nil
}
// Workspace returns an fs.FS-compatible filesystem for this sandbox.
// If a workspace is mounted (WorkspaceID set), uses the workspace ID as session;
// otherwise falls back to the sandbox ID (backward compatible).
func (b *Box) Workspace() workspace.FS {
b.touch()
if b.ws != nil {
return b.ws
}
sessionID := b.workspaceID
if sessionID == "" {
sessionID = b.id
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil
}
b.ws = client.Workspace(sessionID)
return b.ws
}
// WorkspaceID returns the workspace ID mounted to this sandbox, or empty string.
func (b *Box) WorkspaceID() string { return b.workspaceID }
// VNC returns the VNC WebSocket URL.
func (b *Box) VNC(ctx context.Context) (string, error) {
b.touch()
client, err := b.manager.getPool(b.pool)
if err != nil {
return "", err
}
return client.VNC().URL(ctx, b.containerID)
}
// Proxy returns the HTTP URL for a service on the given port inside the sandbox.
func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) {
b.touch()
client, err := b.manager.getPool(b.pool)
if err != nil {
return "", err
}
return client.Proxy().URL(ctx, b.containerID, port, path)
}
// Start starts a stopped sandbox.
func (b *Box) Start(ctx context.Context) error {
client, err := b.manager.getPool(b.pool)
if err != nil {
return err
}
return client.Sandbox().Start(ctx, b.containerID)
}
// Stop stops the sandbox without removing it.
func (b *Box) Stop(ctx context.Context) error {
client, err := b.manager.getPool(b.pool)
if err != nil {
return err
}
return client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout())
}
// Remove stops and removes the sandbox.
func (b *Box) Remove(ctx context.Context) error {
return b.manager.Remove(ctx, b.id)
}
// Info returns current sandbox status.
func (b *Box) Info(ctx context.Context) (*BoxInfo, error) {
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
info, err := client.Sandbox().Inspect(ctx, b.containerID)
if err != nil {
return nil, err
}
return &BoxInfo{
ID: b.id,
ContainerID: b.containerID,
Pool: b.pool,
Owner: b.owner,
Status: info.Status,
Policy: b.policy,
Labels: b.labels,
Image: info.Image,
CreatedAt: b.createdAt,
LastActive: b.lastActiveTime(),
ProcessCount: int(b.processCount.Load()),
VNC: b.vnc,
}, nil
}
func (b *Box) touch() {
b.lastCall.Store(time.Now().UnixMilli())
}
func (b *Box) lastActiveTime() time.Time {
call := b.lastCall.Load()
hb := b.lastHeartbeat.Load()
ts := call
if hb > ts {
ts = hb
}
return time.UnixMilli(ts)
}
func (b *Box) idleTimeout() time.Duration {
if b.idleTimeoutD > 0 {
return b.idleTimeoutD
}
pd := b.manager.findPoolDef(b.pool)
if pd != nil {
return pd.IdleTimeout
}
return 0
}
func (b *Box) maxLifetime() time.Duration {
pd := b.manager.findPoolDef(b.pool)
if pd != nil {
return pd.MaxLifetime
}
return 0
}
func (b *Box) stopTimeout() time.Duration {
if b.stopTimeoutD > 0 {
return b.stopTimeoutD
}
pd := b.manager.findPoolDef(b.pool)
if pd != nil && pd.StopTimeout > 0 {
return pd.StopTimeout
}
return DefaultStopTimeout
}

View file

@ -0,0 +1,280 @@
package sandbox_test
import (
"context"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
proxyURL, err := box.Proxy(ctx, port, "/")
if err != nil {
t.Fatalf("Proxy URL: %v", err)
}
host := proxyURL[len("http://"):]
if i := len(host) - 1; host[i] == '/' {
host = host[:i]
}
for i := 0; i < len(host); i++ {
if host[i] == '/' {
host = host[:i]
break
}
}
deadline := time.After(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatalf("port %d not ready within %v", port, timeout)
case <-ticker.C:
conn, err := net.DialTimeout("tcp", host, 2*time.Second)
if err != nil {
continue
}
conn.Close()
// TCP reachable — give the service process time to accept
// application-layer connections (Python ws/sse servers in CI
// may take 1-3s after the port opens before they're ready).
time.Sleep(2 * time.Second)
return
}
}
}
func TestAttachWS(t *testing.T) {
skipIfNoDocker(t)
img := testImage()
if img == "alpine:latest" {
t.Skip("WebSocket test requires sandbox-v2-test image with ws-echo service")
}
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
co.Ports = []sandbox.PortMapping{
{ContainerPort: 9800, HostPort: 0, Protocol: "tcp"},
}
})
waitForPort(t, box, 9800, 30*time.Second)
var conn *sandbox.ServiceConn
var err error
for attempt := 0; attempt < 5; attempt++ {
conn, err = box.Attach(t.Context(), 9800, sandbox.WithProtocol("ws"), sandbox.WithPath("/"))
if err == nil {
break
}
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
}
if err != nil {
t.Fatalf("Attach WS after retries: %v", err)
}
defer conn.Close()
if err := conn.Write([]byte("ping")); err != nil {
t.Fatalf("Write: %v", err)
}
msg, err := conn.Read()
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(msg) != "ping" {
t.Errorf("echo = %q, want %q", string(msg), "ping")
}
})
}
}
func TestAttachSSE(t *testing.T) {
skipIfNoDocker(t)
img := testImage()
if img == "alpine:latest" {
t.Skip("SSE test requires sandbox-v2-test image with sse-server service")
}
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
co.Ports = []sandbox.PortMapping{
{ContainerPort: 9801, HostPort: 0, Protocol: "tcp"},
}
})
waitForPort(t, box, 9801, 30*time.Second)
var conn *sandbox.ServiceConn
var err error
for attempt := 0; attempt < 5; attempt++ {
conn, err = box.Attach(t.Context(), 9801, sandbox.WithProtocol("sse"), sandbox.WithPath("/events"))
if err == nil {
break
}
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
}
if err != nil {
t.Fatalf("Attach SSE after retries: %v", err)
}
defer conn.Close()
count := 0
for event := range conn.Events {
if len(event) > 0 {
count++
}
if count >= 2 {
break
}
}
if count < 2 {
t.Errorf("received %d events, want >= 2", count)
}
})
}
}
func TestVNCURL(t *testing.T) {
skipIfNoDocker(t)
img := testImage()
if img == "alpine:latest" {
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
}
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
co.VNC = true
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
url, err := box.VNC(ctx)
if err != nil {
t.Fatalf("VNC URL: %v", err)
}
if !strings.HasPrefix(url, "ws://") {
t.Fatalf("VNC URL = %q, want ws:// prefix", url)
}
t.Logf("VNC URL: %s", url)
})
}
}
func TestVNCConnect(t *testing.T) {
skipIfNoDocker(t)
img := testImage()
if img == "alpine:latest" {
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
}
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
co.VNC = true
})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
vncURL, err := box.VNC(ctx)
if err != nil {
t.Fatalf("VNC URL: %v", err)
}
t.Logf("VNC URL: %s", vncURL)
waitForWSEndpoint(t, vncURL, 30*time.Second)
dialer := websocket.Dialer{
Subprotocols: []string{"binary"},
HandshakeTimeout: 10 * time.Second,
}
var ws *websocket.Conn
for attempt := 0; attempt < 5; attempt++ {
var resp *http.Response
ws, resp, err = dialer.DialContext(ctx, vncURL, http.Header{})
if err == nil {
break
}
if resp != nil {
resp.Body.Close()
}
time.Sleep(time.Duration(attempt+1) * time.Second)
}
if err != nil {
t.Fatalf("VNC dial after retries: %v", err)
}
defer ws.Close()
ws.SetReadDeadline(time.Now().Add(10 * time.Second))
_, msg, err := ws.ReadMessage()
if err != nil {
t.Fatalf("VNC read: %v", err)
}
if !strings.HasPrefix(string(msg), "RFB ") {
t.Fatalf("VNC banner = %q, want RFB prefix", string(msg))
}
t.Logf("VNC banner: %s", strings.TrimSpace(string(msg)))
})
}
}
func waitForWSEndpoint(t *testing.T, wsURL string, timeout time.Duration) {
t.Helper()
httpURL := "http" + strings.TrimPrefix(wsURL, "ws")
if idx := strings.LastIndex(httpURL, "/ws"); idx > 0 {
httpURL = httpURL[:idx]
}
host := strings.TrimPrefix(httpURL, "http://")
if i := strings.Index(host, "/"); i > 0 {
host = host[:i]
}
deadline := time.After(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatalf("VNC endpoint %s not ready within %v", host, timeout)
case <-ticker.C:
conn, err := net.DialTimeout("tcp", host, 2*time.Second)
if err != nil {
continue
}
conn.Close()
// VNC services (Xvfb → fluxbox → x11vnc → websockify) need time
// after the TCP port is reachable. Give the process chain time to
// stabilize before attempting the WebSocket handshake.
time.Sleep(2 * time.Second)
return
}
}
}

View file

@ -0,0 +1,121 @@
package sandbox_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestImageExists(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" {
t.Run("always_true", func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
exists, err := m.ImageExists(ctx, pc.Name, "anything:nonexistent")
require.NoError(t, err)
assert.True(t, exists, "k8s mode should always return true")
})
return
}
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
t.Run("existing", func(t *testing.T) {
exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest")
require.NoError(t, err)
assert.True(t, exists)
})
t.Run("missing", func(t *testing.T) {
exists, err := m.ImageExists(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345")
require.NoError(t, err)
assert.False(t, exists)
})
})
}
}
func TestImagePull(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" {
t.Run("noop", func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
assert.Nil(t, ch, "k8s mode should return nil channel")
})
return
}
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
t.Run("pull_with_progress", func(t *testing.T) {
ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
require.NotNil(t, ch)
var count int
for p := range ch {
if p.Error != "" {
t.Fatalf("pull error: %s", p.Error)
}
count++
}
assert.Greater(t, count, 0, "should receive at least one progress event")
})
})
}
}
func TestEnsureImage(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
err := m.EnsureImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
if pc.Name != "k8s" {
exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest")
require.NoError(t, err)
assert.True(t, exists)
}
})
}
}
func TestEnsureImage_BadRef(t *testing.T) {
for _, pc := range testPools() {
pc := pc
if pc.Name == "k8s" {
continue
}
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := m.EnsureImage(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345", sandbox.ImagePullOptions{})
assert.Error(t, err)
})
}
}

215
sandbox/v2/box_test.go Normal file
View file

@ -0,0 +1,215 @@
package sandbox_test
import (
"context"
"io"
"strings"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestBoxExec(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := box.Exec(ctx, []string{"echo", "box-exec"})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if result.Stdout != "box-exec\n" {
t.Errorf("stdout = %q, want %q", result.Stdout, "box-exec\n")
}
})
}
}
func TestBoxExecWithOptions(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
ctx := context.Background()
result, err := box.Exec(ctx, []string{"pwd"},
sandbox.WithWorkDir("/tmp"),
)
if err != nil {
t.Fatalf("Exec: %v", err)
}
if result.Stdout != "/tmp\n" {
t.Errorf("stdout = %q, want %q", result.Stdout, "/tmp\n")
}
})
}
}
func TestBoxStream(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
ctx := context.Background()
stream, err := box.Stream(ctx, []string{"sh", "-c", "echo line1; echo line2"})
if err != nil {
t.Fatalf("Stream: %v", err)
}
out, err := io.ReadAll(stream.Stdout)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if string(out) != "line1\nline2\n" {
t.Errorf("stdout = %q, want %q", string(out), "line1\nline2\n")
}
code, err := stream.Wait()
if err != nil {
t.Fatalf("Wait: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
})
}
}
func TestBoxWorkspace(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
ws := box.Workspace()
if ws == nil {
t.Skip("Workspace returned nil (volume not available)")
}
content := []byte("package main\n")
if err := ws.WriteFile("main.go", content, 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
data, err := ws.ReadFile("main.go")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != string(content) {
t.Errorf("content = %q, want %q", string(data), string(content))
}
if err := ws.MkdirAll("src/pkg", 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
entries, err := ws.ReadDir("src")
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
if len(entries) == 0 {
t.Error("expected non-empty directory listing")
}
})
}
}
func TestBoxInfo(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
ctx := context.Background()
info, err := box.Info(ctx)
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.ID != box.ID() {
t.Errorf("ID = %q, want %q", info.ID, box.ID())
}
if s := strings.ToLower(info.Status); s != "running" {
t.Errorf("status = %q, want running", info.Status)
}
if info.Owner != "test-user" {
t.Errorf("owner = %q, want test-user", info.Owner)
}
})
}
}
func TestBoxStopStart(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
ctx := context.Background()
if err := box.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
if err := box.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
result, err := box.Exec(ctx, []string{"echo", "after-restart"})
if err != nil {
t.Fatalf("Exec after restart: %v", err)
}
if result.Stdout != "after-restart\n" {
t.Errorf("stdout = %q", result.Stdout)
}
})
}
}
func TestBoxGetOrCreate(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx := context.Background()
box1, err := m.GetOrCreate(ctx, sandbox.CreateOptions{
ID: "goc-" + pc.Name,
Image: testImage(),
Owner: "test-user",
})
if err != nil {
t.Fatalf("GetOrCreate first: %v", err)
}
defer m.Remove(ctx, box1.ID())
box2, err := m.GetOrCreate(ctx, sandbox.CreateOptions{
ID: "goc-" + pc.Name,
Image: testImage(),
Owner: "test-user",
})
if err != nil {
t.Fatalf("GetOrCreate second: %v", err)
}
if box2.ContainerID() != box1.ContainerID() {
t.Error("expected same container for GetOrCreate with same ID")
}
})
}
}

View file

@ -0,0 +1,284 @@
package sandbox_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/workspace"
)
func TestWorkspaceID_Set(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "test-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
assert.Equal(t, ws.ID, box.WorkspaceID())
})
}
}
func TestWorkspaceID_Empty(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
assert.Empty(t, box.WorkspaceID())
})
}
}
func TestWorkspace_NodeRouting(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "routed-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
assert.Equal(t, pc.Name, box.Pool())
})
}
}
func TestWorkspace_InvalidID(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, _ := setupManagerWithWorkspace(t, pc)
ensureTestImage(t, sbm, pc.Name)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := sbm.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "user",
WorkspaceID: "nonexistent-workspace",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "resolve workspace")
})
}
}
func TestWorkspace_BindMountLocal(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "mount-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "seed.txt", []byte("hello from workspace"), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
result, err := box.Exec(ctx, []string{"cat", "/workspace/seed.txt"})
require.NoError(t, err)
assert.Equal(t, "hello from workspace", result.Stdout)
}
func TestWorkspace_ContainerWriteBack(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "writeback-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
_, err = box.Exec(ctx, []string{"sh", "-c", "echo 'from container' > /workspace/output.txt"})
require.NoError(t, err)
data, err := wsm.ReadFile(ctx, ws.ID, "output.txt")
require.NoError(t, err)
assert.Equal(t, "from container\n", string(data))
}
func TestWorkspace_ReadOnlyMount(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "ro-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "readonly.txt", []byte("immutable"), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
co.MountMode = "ro"
})
result, err := box.Exec(ctx, []string{"cat", "/workspace/readonly.txt"})
require.NoError(t, err)
assert.Equal(t, "immutable", result.Stdout)
result, err = box.Exec(ctx, []string{"sh", "-c", "echo fail > /workspace/nope.txt 2>&1; echo $?"})
require.NoError(t, err)
// Write to read-only mount should fail (non-zero exit or error message)
assert.True(t, result.ExitCode != 0 || result.Stdout != "0\n" || len(result.Stderr) > 0,
"expected write to read-only mount to fail")
}
func TestWorkspace_CustomMountPath(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "custom-path-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "data.json", []byte(`{"ok":true}`), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
co.MountPath = "/data"
})
result, err := box.Exec(ctx, []string{"cat", "/data/data.json"})
require.NoError(t, err)
assert.Equal(t, `{"ok":true}`, result.Stdout)
}
func TestWorkspace_BoxWorkspaceFS(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
if pc.Name == "local" {
// Local mode: sandbox and workspace use separate tai.Clients with
// different dataDirs, so Box.Workspace() writes to the sandbox volume
// while wsm reads from the workspace volume. Bind mount tests cover
// local workspace I/O end-to-end instead.
continue
}
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "fs-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
wfs := box.Workspace()
if wfs == nil {
t.Skip("Workspace FS not available")
}
require.NoError(t, wfs.WriteFile("via-box.txt", []byte("box wrote this"), 0644))
data, err := wsm.ReadFile(ctx, ws.ID, "via-box.txt")
require.NoError(t, err)
assert.Equal(t, "box wrote this", string(data))
})
}
}
func TestWorkspace_LabelPersistence(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "label-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
// WorkspaceID getter should reflect what was set
assert.Equal(t, ws.ID, box.WorkspaceID())
// Container should also carry the label (verify via exec reading env or
// just trust that buildTaiCreateOptions sets it — the label is tested
// indirectly by TestWorkspace_NodeRouting which relies on correct routing)
info, err := box.Info(ctx)
require.NoError(t, err)
assert.Contains(t, []string{"running", "Running"}, info.Status)
})
}
}

5
sandbox/v2/config.go Normal file
View file

@ -0,0 +1,5 @@
package sandbox
type Config struct {
Pool []Pool
}

View file

@ -0,0 +1,39 @@
# 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

@ -0,0 +1,12 @@
#!/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

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

View file

@ -0,0 +1,419 @@
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

@ -0,0 +1,510 @@
// 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

@ -0,0 +1,244 @@
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
}

79
sandbox/v2/docker/build.sh Executable file
View file

@ -0,0 +1,79 @@
#!/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

@ -0,0 +1,28 @@
# 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

@ -0,0 +1,22 @@
#!/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

@ -0,0 +1,28 @@
"""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

@ -0,0 +1,14 @@
"""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())

11
sandbox/v2/errors.go Normal file
View file

@ -0,0 +1,11 @@
package sandbox
import "errors"
var (
ErrNotAvailable = errors.New("sandbox: not available (no pools configured)")
ErrNotFound = errors.New("sandbox: not found")
ErrLimitExceeded = errors.New("sandbox: limit exceeded")
ErrPoolNotFound = errors.New("sandbox: pool not found")
ErrPoolInUse = errors.New("sandbox: pool has running boxes")
)

View file

@ -0,0 +1,6 @@
package sandbox
// ResetForTest resets the global manager for testing purposes.
func ResetForTest() {
mgr = nil
}

54
sandbox/v2/grpc.go Normal file
View file

@ -0,0 +1,54 @@
package sandbox
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strconv"
"strings"
)
func createToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// CreateContainerTokens creates an OAuth token pair for a sandbox container.
func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) {
access, err = createToken()
if err != nil {
return "", "", err
}
refresh, err = createToken()
if err != nil {
return "", "", err
}
return access, refresh, nil
}
// RevokeContainerTokens revokes a refresh token for a sandbox container.
func RevokeContainerTokens(refresh string) error {
return nil
}
// BuildGRPCEnv builds the gRPC environment variables for a sandbox container.
func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string {
portStr := strconv.Itoa(grpcPort)
env := map[string]string{
"YAO_SANDBOX_ID": sandboxID,
"YAO_TOKEN": access,
"YAO_REFRESH_TOKEN": refresh,
}
if pool != nil && strings.Contains(pool.Addr, "tai://") {
taiHost := strings.TrimPrefix(pool.Addr, "tai://")
env["YAO_GRPC_TAI"] = "enable"
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:9100", taiHost)
env["YAO_GRPC_UPSTREAM"] = fmt.Sprintf("127.0.0.1:%s", portStr)
} else {
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr)
}
return env
}

56
sandbox/v2/grpc_test.go Normal file
View file

@ -0,0 +1,56 @@
package sandbox_test
import (
"testing"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestBuildGRPCEnvLocal(t *testing.T) {
pool := &sandbox.Pool{Name: "local", Addr: "local"}
env := sandbox.BuildGRPCEnv(pool, "sb-001", "access-tok", "refresh-tok", 9099)
if env["YAO_SANDBOX_ID"] != "sb-001" {
t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"])
}
if env["YAO_TOKEN"] != "access-tok" {
t.Errorf("YAO_TOKEN = %q", env["YAO_TOKEN"])
}
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"])
}
if env["YAO_GRPC_UPSTREAM"] != "127.0.0.1:9099" {
t.Errorf("YAO_GRPC_UPSTREAM = %q", env["YAO_GRPC_UPSTREAM"])
}
}
func TestCreateContainerTokens(t *testing.T) {
access, refresh, err := sandbox.CreateContainerTokens("sb-001", "user1", nil)
if err != nil {
t.Fatalf("CreateContainerTokens: %v", err)
}
if len(access) != 64 {
t.Errorf("access token len = %d, want 64 hex chars", len(access))
}
if len(refresh) != 64 {
t.Errorf("refresh token len = %d, want 64 hex chars", len(refresh))
}
if access == refresh {
t.Error("access and refresh tokens should be different")
}
}

129
sandbox/v2/jsapi/box.go Normal file
View file

@ -0,0 +1,129 @@
package jsapi
import (
"rogchap.com/v8go"
)
// NewBoxObject creates a JS Box object backed by a sandbox ID string.
// All methods delegate to the Go sandbox.M() singleton — no Go object is
// passed to V8, no bridge registration, no Release() needed.
//
// # Properties (read-only)
//
// box.id → string // sandbox ID ← Box.ID()
// box.owner → string // owner user ID ← Box.Owner()
// box.pool → string // pool name ← Box.Pool()
//
// # Methods — Go mapping
//
// box.Exec(cmd, options?) → ExecResult
//
// Go: Box.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error)
//
// JS args:
// cmd: string[] → cmd []string
// options: { → ExecOption functional options
// workdir: string, → WithWorkDir(dir)
// env: object, → WithEnv(map[string]string)
// timeout: number → WithTimeout(ms → time.Duration)
// }
// JS returns: {
// exit_code: number, ← ExecResult.ExitCode
// stdout: string, ← ExecResult.Stdout
// stderr: string ← ExecResult.Stderr
// }
//
// box.Stream(cmd, options?) → ExecStream
//
// Go: Box.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error)
//
// JS returns: {
// stdout: ReadableStream, ← ExecStream.Stdout
// stderr: ReadableStream, ← ExecStream.Stderr
// stdin: WritableStream, ← ExecStream.Stdin
// wait: function() → number, ← ExecStream.Wait() (int, error)
// cancel: function() → void ← ExecStream.Cancel()
// }
//
// box.Attach(port, options?) → ServiceConn
//
// Go: Box.Attach(ctx, port int, opts ...AttachOption) (*ServiceConn, error)
//
// JS args:
// port: number → port int
// options: { → AttachOption functional options
// protocol: "ws"|"sse", → WithProtocol(protocol)
// path: string, → WithPath(path)
// headers: object → WithHeaders(map[string]string)
// }
// JS returns: {
// url: string, ← ServiceConn.URL
// read: function() → Uint8Array, ← ServiceConn.Read() ([]byte, error)
// write: function(data) → void, ← ServiceConn.Write(data) error
// events: AsyncIterable<Uint8Array>, ← ServiceConn.Events <-chan []byte
// close: function() → void ← ServiceConn.Close() error
// }
//
// box.VNC() → string
//
// Go: Box.VNC(ctx) (string, error)
// Returns: VNC WebSocket URL
//
// box.Proxy(port, path?) → string
//
// Go: Box.Proxy(ctx, port int, path string) (string, error)
// Returns: HTTP proxy URL
//
// box.Workspace() → WorkspaceFS
//
// Go: Box.Workspace() workspace.FS
// Box.WorkspaceID() string
// Returns: WorkspaceFS object (see workspace/jsapi/fs.go)
// Uses box.WorkspaceID() to create NewFSObject
//
// box.Info() → BoxInfo
//
// Go: Box.Info(ctx) (*BoxInfo, error)
// JS returns: {
// id: string, ← BoxInfo.ID
// container_id: string, ← BoxInfo.ContainerID
// pool: string, ← BoxInfo.Pool
// owner: string, ← BoxInfo.Owner
// status: string, ← BoxInfo.Status
// image: string, ← BoxInfo.Image
// vnc: boolean, ← BoxInfo.VNC
// policy: string, ← BoxInfo.Policy (LifecyclePolicy)
// labels: object, ← BoxInfo.Labels (map[string]string)
// created_at: string, ← BoxInfo.CreatedAt (ISO 8601)
// last_active: string, ← BoxInfo.LastActive (ISO 8601)
// process_count: number ← BoxInfo.ProcessCount
// }
//
// box.Start() → void
//
// Go: Box.Start(ctx) error
//
// box.Stop() → void
//
// Go: Box.Stop(ctx) error
//
// box.Remove() → void
//
// Go: Box.Remove(ctx) error
func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID))
// 3. Bind each method as FunctionTemplate:
// - Exec → sandbox.M().Get(id).Exec(ctx, cmd, opts...)
// - Stream → sandbox.M().Get(id).Stream(ctx, cmd, opts...)
// - Attach → sandbox.M().Get(id).Attach(ctx, port, opts...)
// - VNC → sandbox.M().Get(id).VNC(ctx)
// - Proxy → sandbox.M().Get(id).Proxy(ctx, port, path)
// - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID())
// - Info → sandbox.M().Get(id).Info(ctx) → JS object
// - Start → sandbox.M().Get(id).Start(ctx)
// - Stop → sandbox.M().Get(id).Stop(ctx)
// - Remove → sandbox.M().Get(id).Remove(ctx)
return nil, nil
}

155
sandbox/v2/jsapi/jsapi.go Normal file
View file

@ -0,0 +1,155 @@
// Package jsapi registers the sandbox namespace into the Yao V8 runtime.
//
// All methods are static on the sandbox object — no constructor.
//
// # JavaScript API
//
// const box = sandbox.Create({ image: "node:20", owner: "user1" })
// const result = box.Exec(["node", "-e", "console.log('hi')"])
// console.log(result.stdout)
//
// const box = sandbox.Get(id) // → Box
// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[]
// sandbox.Delete(id) // → void
//
// # Go mapping
//
// sandbox.Create(opts) → Manager.Create(ctx, CreateOptions) → Box
// sandbox.Create(opts) → Manager.GetOrCreate(ctx, opts) → Box (when opts.id is set)
// sandbox.Get(id) → Manager.Get(ctx, id) → Box
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[]
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void
//
// Registration happens via init() — import with:
//
// _ "github.com/yaoapp/yao/sandbox/v2/jsapi"
package jsapi
import (
v8 "github.com/yaoapp/gou/runtime/v8"
"rogchap.com/v8go"
)
func init() {
v8.RegisterObject("sandbox", ExportObject)
}
// ExportObject exports the sandbox namespace object to V8.
func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
obj := v8go.NewObjectTemplate(iso)
obj.Set("Create", v8go.NewFunctionTemplate(iso, sbCreate))
obj.Set("Get", v8go.NewFunctionTemplate(iso, sbGet))
obj.Set("List", v8go.NewFunctionTemplate(iso, sbList))
obj.Set("Delete", v8go.NewFunctionTemplate(iso, sbDelete))
return obj
}
// sbCreate: `sandbox.Create(options)` → Box
//
// Go: Manager.Create(ctx, CreateOptions) (*Box, error)
//
// Manager.GetOrCreate(ctx, CreateOptions) (*Box, error) — when opts.id is set
//
// JS options → Go CreateOptions mapping:
//
// {
// id: string → CreateOptions.ID // optional; triggers GetOrCreate
// owner: string → CreateOptions.Owner // required
// pool: string → CreateOptions.Pool // default: first pool
// image: string → CreateOptions.Image // required
// workdir: string → CreateOptions.WorkDir
// user: string → CreateOptions.User // e.g. "1000:1000"
// env: object → CreateOptions.Env // map[string]string
// memory: number → CreateOptions.Memory // bytes (int64)
// cpus: number → CreateOptions.CPUs // float64 e.g. 1.5
// vnc: boolean → CreateOptions.VNC
// ports: array → CreateOptions.Ports // [{container, host, host_ip, protocol}] → []PortMapping
// policy: string → CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent"
// idle_timeout: number → CreateOptions.IdleTimeout // ms → time.Duration
// stop_timeout: number → CreateOptions.StopTimeout // ms → time.Duration
// workspace_id: string → CreateOptions.WorkspaceID
// mount_mode: string → CreateOptions.MountMode // "rw"|"ro"
// mount_path: string → CreateOptions.MountPath
// labels: object → CreateOptions.Labels // map[string]string
// }
//
// Returns: Box object (see box.go)
func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse options from info.Args()[0]
// 2. Validate required fields (image, owner)
// 3. If opts.id != "" → sandbox.M().GetOrCreate(ctx, opts)
// else → sandbox.M().Create(ctx, opts)
// 4. Return NewBoxObject(v8ctx, box.ID())
return v8go.Undefined(info.Context().Isolate())
}
// sbGet: `sandbox.Get(id)` → Box | null
//
// Go: Manager.Get(ctx, id) (*Box, error)
//
// Args:
//
// id: string — sandbox ID
//
// Returns: Box object if found, null if not found
func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. box, err := sandbox.M().Get(ctx, id)
// 3. Return NewBoxObject(v8ctx, id) or null
return v8go.Undefined(info.Context().Isolate())
}
// sbList: `sandbox.List(filter?)` → BoxInfo[]
//
// Go: Manager.List(ctx, ListOptions) ([]*Box, error)
//
// then Box.Info(ctx) for each → BoxInfo
//
// JS filter → Go ListOptions mapping:
//
// {
// owner: string → ListOptions.Owner // filter by owner; empty = all
// pool: string → ListOptions.Pool // filter by pool; empty = all
// labels: object → ListOptions.Labels // filter by labels
// }
//
// Returns: BoxInfo[] — each element:
//
// {
// id: string ← BoxInfo.ID
// container_id: string ← BoxInfo.ContainerID
// pool: string ← BoxInfo.Pool
// owner: string ← BoxInfo.Owner
// status: string ← BoxInfo.Status
// image: string ← BoxInfo.Image
// vnc: boolean ← BoxInfo.VNC
// policy: string ← BoxInfo.Policy
// labels: object ← BoxInfo.Labels
// created_at: string ← BoxInfo.CreatedAt (ISO 8601)
// last_active: string ← BoxInfo.LastActive (ISO 8601)
// process_count: number ← BoxInfo.ProcessCount
// }
func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse optional filter from info.Args()[0]
// 2. boxes := sandbox.M().List(ctx, opts)
// 3. For each box: box.Info(ctx) → BoxInfo → JS object
// 4. Return JS array of BoxInfo objects
return v8go.Undefined(info.Context().Isolate())
}
// sbDelete: `sandbox.Delete(id)` → void
//
// Go: Manager.Remove(ctx, id) error
//
// Args:
//
// id: string — sandbox ID to remove
func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. sandbox.M().Remove(ctx, id)
return v8go.Undefined(info.Context().Isolate())
}

616
sandbox/v2/manager.go Normal file
View file

@ -0,0 +1,616 @@
package sandbox
import (
"context"
"fmt"
"sync"
"time"
"github.com/yaoapp/yao/tai"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/workspace"
)
// Manager manages a pool of tai.Client connections and sandbox lifecycle.
type Manager struct {
pool map[string]*tai.Client
poolDefs []Pool
defaultPool string
config Config
boxes sync.Map
mu sync.Mutex
cancel context.CancelFunc
grpcPort int
wsManager *workspace.Manager
}
func newManager(cfg Config) (*Manager, error) {
m := &Manager{
pool: make(map[string]*tai.Client),
poolDefs: cfg.Pool,
config: cfg,
grpcPort: 9099,
}
if len(cfg.Pool) > 0 {
m.defaultPool = cfg.Pool[0].Name
}
return m, nil
}
// Start discovers existing containers from all pools, rebuilds the boxes map,
// and starts the cleanup loop.
func (m *Manager) Start(ctx context.Context) error {
if len(m.poolDefs) == 0 {
return nil
}
for _, pd := range m.poolDefs {
client, err := m.getPool(pd.Name)
if err != nil {
continue
}
m.recoverBoxes(ctx, &pd, client)
}
loopCtx, cancel := context.WithCancel(ctx)
m.cancel = cancel
go m.cleanupLoop(loopCtx)
return nil
}
// AddPool registers a new pool at runtime.
func (m *Manager) AddPool(_ context.Context, p Pool) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, pd := range m.poolDefs {
if pd.Name == p.Name {
return fmt.Errorf("sandbox: pool %q already exists", p.Name)
}
}
m.poolDefs = append(m.poolDefs, p)
if m.defaultPool == "" {
m.defaultPool = p.Name
}
return nil
}
// RemovePool removes a pool by name.
func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error {
m.mu.Lock()
defer m.mu.Unlock()
idx := -1
for i, pd := range m.poolDefs {
if pd.Name == name {
idx = i
break
}
}
if idx < 0 {
return ErrPoolNotFound
}
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == name {
count++
}
return true
})
if count > 0 && !force {
return ErrPoolInUse
}
if count > 0 {
m.boxes.Range(func(key, value any) bool {
b := value.(*Box)
if b.pool == name {
b.Remove(ctx)
}
return true
})
}
m.poolDefs = append(m.poolDefs[:idx], m.poolDefs[idx+1:]...)
if client, ok := m.pool[name]; ok {
client.Close()
delete(m.pool, name)
}
return nil
}
// Pools returns all registered pool names and their status.
func (m *Manager) Pools() []PoolInfo {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]PoolInfo, 0, len(m.poolDefs))
for _, pd := range m.poolDefs {
_, connected := m.pool[pd.Name]
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == pd.Name {
count++
}
return true
})
result = append(result, PoolInfo{
Name: pd.Name,
Addr: pd.Addr,
Connected: connected,
Boxes: count,
MaxPerUser: pd.MaxPerUser,
MaxTotal: pd.MaxTotal,
IdleTimeout: pd.IdleTimeout,
MaxLifetime: pd.MaxLifetime,
})
}
return result
}
// Heartbeat updates the box's last heartbeat timestamp.
func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) error {
v, ok := m.boxes.Load(sandboxID)
if !ok {
return ErrNotFound
}
b := v.(*Box)
if active {
b.lastHeartbeat.Store(time.Now().UnixMilli())
}
b.processCount.Store(int32(processCount))
return nil
}
// Create creates and starts a new sandbox.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) {
if len(m.poolDefs) == 0 {
return nil, ErrNotAvailable
}
if opts.Image == "" {
return nil, fmt.Errorf("sandbox: image is required")
}
poolName := opts.Pool
if poolName == "" {
poolName = m.defaultPool
}
// Workspace node binding: when WorkspaceID is set, resolve the workspace's
// bound node and force the container onto that pool.
if opts.WorkspaceID != "" && m.wsManager != nil {
node, err := m.wsManager.NodeForWorkspace(ctx, opts.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err)
}
poolName = node
}
pd := m.findPoolDef(poolName)
if pd == nil {
return nil, ErrPoolNotFound
}
if err := m.checkLimits(pd, opts.Owner); err != nil {
return nil, err
}
id := opts.ID
if id == "" {
id = fmt.Sprintf("sb-%d", time.Now().UnixNano())
}
client, err := m.getPool(poolName)
if err != nil {
return nil, fmt.Errorf("sandbox: connect pool %q: %w", poolName, err)
}
access, refresh, err := CreateContainerTokens(id, opts.Owner, nil)
if err != nil {
return nil, fmt.Errorf("sandbox: create tokens: %w", err)
}
taiOpts := m.buildTaiCreateOptions(opts, pd, id, access, refresh)
containerID, err := client.Sandbox().Create(ctx, taiOpts)
if err != nil {
return nil, fmt.Errorf("sandbox: create container: %w", err)
}
if err := client.Sandbox().Start(ctx, containerID); err != nil {
client.Sandbox().Remove(ctx, containerID, true)
return nil, fmt.Errorf("sandbox: start container: %w", err)
}
policy := opts.Policy
if policy == "" {
policy = Session
}
box := &Box{
id: id,
containerID: containerID,
pool: poolName,
owner: opts.Owner,
policy: policy,
labels: opts.Labels,
idleTimeoutD: opts.IdleTimeout,
stopTimeoutD: opts.StopTimeout,
createdAt: time.Now(),
refreshToken: refresh,
manager: m,
vnc: opts.VNC,
image: opts.Image,
workspaceID: opts.WorkspaceID,
}
box.lastCall.Store(time.Now().UnixMilli())
m.boxes.Store(id, box)
return box, nil
}
// Get returns an existing sandbox by ID.
func (m *Manager) Get(_ context.Context, id string) (*Box, error) {
v, ok := m.boxes.Load(id)
if !ok {
return nil, ErrNotFound
}
return v.(*Box), nil
}
// GetOrCreate returns existing sandbox by ID or creates a new one.
func (m *Manager) GetOrCreate(ctx context.Context, opts CreateOptions) (*Box, error) {
if opts.ID != "" {
if v, ok := m.boxes.Load(opts.ID); ok {
return v.(*Box), nil
}
}
return m.Create(ctx, opts)
}
// List returns all sandboxes, optionally filtered.
func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) {
var result []*Box
m.boxes.Range(func(_, value any) bool {
b := value.(*Box)
if opts.Owner != "" && b.owner != opts.Owner {
return true
}
if opts.Pool != "" && b.pool != opts.Pool {
return true
}
if len(opts.Labels) > 0 {
for k, v := range opts.Labels {
if b.labels[k] != v {
return true
}
}
}
result = append(result, b)
return true
})
return result, nil
}
// Remove force-removes a sandbox (SIGKILL + delete).
func (m *Manager) Remove(ctx context.Context, id string) error {
v, ok := m.boxes.Load(id)
if !ok {
return ErrNotFound
}
b := v.(*Box)
client, err := m.getPool(b.pool)
if err == nil {
client.Sandbox().Remove(ctx, b.containerID, true)
}
if b.refreshToken != "" {
RevokeContainerTokens(b.refreshToken)
}
m.boxes.Delete(id)
return nil
}
// Cleanup removes idle/expired sandboxes.
func (m *Manager) Cleanup(ctx context.Context) error {
now := time.Now()
m.boxes.Range(func(key, value any) bool {
b := value.(*Box)
idle := now.Sub(b.lastActiveTime())
switch b.policy {
case OneShot:
// handled after Exec
case Session:
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
m.Remove(ctx, b.id)
}
case LongRunning:
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
if client, err := m.getPool(b.pool); err == nil {
client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout())
}
}
if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime {
m.Remove(ctx, b.id)
}
case Persistent:
// never auto-cleaned
}
return true
})
return nil
}
// Close stops the cleanup loop and releases all pool connections.
func (m *Manager) Close() error {
if m.cancel != nil {
m.cancel()
}
m.mu.Lock()
defer m.mu.Unlock()
for name, client := range m.pool {
client.Close()
delete(m.pool, name)
}
return nil
}
// SetGRPCPort sets the local gRPC port for container env injection.
func (m *Manager) SetGRPCPort(port int) {
m.grpcPort = port
}
// SetWorkspaceManager links the workspace manager for workspace-aware container creation.
// When CreateOptions.WorkspaceID is set, the sandbox Manager uses the workspace Manager
// to resolve the workspace's bound node and force container routing.
func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) {
m.wsManager = wm
}
func (m *Manager) cleanupLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.Cleanup(ctx)
case <-ctx.Done():
return
}
}
}
func (m *Manager) getPool(name string) (*tai.Client, error) {
m.mu.Lock()
defer m.mu.Unlock()
if client, ok := m.pool[name]; ok {
return client, nil
}
pd := m.findPoolDefLocked(name)
if pd == nil {
return nil, ErrPoolNotFound
}
client, err := tai.New(pd.Addr, pd.Options...)
if err != nil {
return nil, err
}
m.pool[name] = client
return client, nil
}
func (m *Manager) findPoolDef(name string) *Pool {
m.mu.Lock()
defer m.mu.Unlock()
return m.findPoolDefLocked(name)
}
func (m *Manager) findPoolDefLocked(name string) *Pool {
for i := range m.poolDefs {
if m.poolDefs[i].Name == name {
return &m.poolDefs[i]
}
}
return nil
}
func (m *Manager) checkLimits(pd *Pool, owner string) error {
if pd.MaxTotal > 0 {
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == pd.Name {
count++
}
return true
})
if count >= pd.MaxTotal {
return ErrLimitExceeded
}
}
if pd.MaxPerUser > 0 && owner != "" {
count := 0
m.boxes.Range(func(_, value any) bool {
b := value.(*Box)
if b.pool == pd.Name && b.owner == owner {
count++
}
return true
})
if count >= pd.MaxPerUser {
return ErrLimitExceeded
}
}
return nil
}
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, access, refresh string) taisandbox.CreateOptions {
env := make(map[string]string)
for k, v := range opts.Env {
env[k] = v
}
grpcEnv := BuildGRPCEnv(pd, sandboxID, access, refresh, m.grpcPort)
for k, v := range grpcEnv {
env[k] = v
}
labels := map[string]string{
"managed-by": "yao-sandbox",
"sandbox-id": sandboxID,
"sandbox-owner": opts.Owner,
"sandbox-pool": pd.Name,
"sandbox-policy": string(opts.Policy),
}
if opts.WorkspaceID != "" {
labels["workspace-id"] = opts.WorkspaceID
}
for k, v := range opts.Labels {
labels[k] = v
}
workDir := opts.WorkDir
if workDir == "" {
workDir = "/workspace"
}
cmd := []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"}
var ports []taisandbox.PortMapping
for _, p := range opts.Ports {
ports = append(ports, taisandbox.PortMapping{
ContainerPort: p.ContainerPort,
HostPort: p.HostPort,
HostIP: p.HostIP,
Protocol: p.Protocol,
})
}
// Workspace bind mount
var binds []string
if opts.WorkspaceID != "" && m.wsManager != nil {
mountPath := opts.MountPath
if mountPath == "" {
mountPath = "/workspace"
}
mode := opts.MountMode
if mode == "" {
mode = "rw"
}
hostPath, _ := m.wsManager.MountPath(context.Background(), opts.WorkspaceID)
if hostPath != "" {
binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode))
}
}
return taisandbox.CreateOptions{
Name: sandboxID,
Image: opts.Image,
Cmd: cmd,
Env: env,
Binds: binds,
WorkingDir: workDir,
User: opts.User,
Memory: opts.Memory,
CPUs: opts.CPUs,
VNC: opts.VNC,
Ports: ports,
Labels: labels,
}
}
func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client) {
containers, err := client.Sandbox().List(ctx, taisandbox.ListOptions{
All: true,
Labels: map[string]string{"managed-by": "yao-sandbox"},
})
if err != nil {
return
}
for _, c := range containers {
sandboxID := c.Labels["sandbox-id"]
if sandboxID == "" {
continue
}
if _, loaded := m.boxes.Load(sandboxID); loaded {
continue
}
box := &Box{
id: sandboxID,
containerID: c.ID,
pool: c.Labels["sandbox-pool"],
owner: c.Labels["sandbox-owner"],
policy: LifecyclePolicy(c.Labels["sandbox-policy"]),
labels: c.Labels,
createdAt: time.Now(),
image: c.Image,
workspaceID: c.Labels["workspace-id"],
manager: m,
}
box.lastCall.Store(time.Now().UnixMilli())
m.boxes.Store(sandboxID, box)
}
}
// ImageExists reports whether the given image ref exists on the target pool node.
func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) {
client, err := m.getPool(pool)
if err != nil {
return false, err
}
return client.Image().Exists(ctx, ref)
}
// PullImage pulls an image to the target pool node, returning a channel of
// real-time progress events. The channel is nil when no pull is needed (e.g. K8s mode).
func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) {
client, err := m.getPool(pool)
if err != nil {
return nil, err
}
pullOpts := taisandbox.PullOptions{}
if opts.Auth != nil {
pullOpts.Auth = &taisandbox.RegistryAuth{
Username: opts.Auth.Username,
Password: opts.Auth.Password,
Server: opts.Auth.Server,
}
}
return client.Image().Pull(ctx, ref, pullOpts)
}
// EnsureImage checks whether the image exists on the pool node; if not, it
// pulls the image and blocks until the pull completes. Returns the first
// error encountered during pull. For K8s pools this is a no-op.
func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error {
exists, err := m.ImageExists(ctx, pool, ref)
if err != nil {
return fmt.Errorf("image exists check: %w", err)
}
if exists {
return nil
}
ch, err := m.PullImage(ctx, pool, ref, opts)
if err != nil {
return fmt.Errorf("image pull: %w", err)
}
if ch == nil {
return nil
}
for p := range ch {
if p.Error != "" {
return fmt.Errorf("image pull %q: %s", ref, p.Error)
}
}
return nil
}

View file

@ -0,0 +1,142 @@
package sandbox_test
import (
"context"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestHeartbeatUpdates(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
err := m.Heartbeat(box.ID(), true, 5)
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
info, err := box.Info(context.Background())
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.ProcessCount != 5 {
t.Errorf("ProcessCount = %d, want 5", info.ProcessCount)
}
})
}
}
func TestHeartbeatUnknownBox(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
err := m.Heartbeat("nonexistent", true, 1)
if err != sandbox.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
}
}
func TestIdleCleanup(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.IdleTimeout = 1 * time.Second
})
ensureTestImage(t, m, pc.Name)
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Policy: sandbox.Session,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
boxID := box.ID()
time.Sleep(2 * time.Second)
if err := m.Cleanup(ctx); err != nil {
t.Fatalf("Cleanup: %v", err)
}
_, err = m.Get(ctx, boxID)
if err != sandbox.ErrNotFound {
t.Errorf("after idle cleanup, Get err = %v, want ErrNotFound", err)
}
})
}
}
func TestStartRecovery(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr}
m1 := setupManager(t, pool)
box := createTestBox(t, m1)
boxID := box.ID()
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init2: %v", err)
}
m2 := sandbox.M()
defer m2.Close()
ctx := context.Background()
if err := m2.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
recovered, err := m2.Get(ctx, boxID)
if err != nil {
t.Fatalf("Get recovered box: %v", err)
}
if recovered.Owner() != "test-user" {
t.Errorf("owner = %q, want %q", recovered.Owner(), "test-user")
}
m2.Remove(ctx, boxID)
})
}
}
func TestPersistentNotCleaned(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.IdleTimeout = 1 * time.Second
})
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
co.Policy = sandbox.Persistent
})
time.Sleep(2 * time.Second)
ctx := context.Background()
m.Cleanup(ctx)
_, err := m.Get(ctx, box.ID())
if err != nil {
t.Errorf("persistent box should not be cleaned: %v", err)
}
})
}
}

299
sandbox/v2/manager_test.go Normal file
View file

@ -0,0 +1,299 @@
package sandbox_test
import (
"context"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestCreateAndExec(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := box.Exec(ctx, []string{"echo", "hello"})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("exit code = %d, want 0", result.ExitCode)
}
if result.Stdout != "hello\n" {
t.Errorf("stdout = %q, want %q", result.Stdout, "hello\n")
}
})
}
}
func TestCreateWithLabels(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
co.Labels = map[string]string{"app": "test-app"}
})
ctx := context.Background()
info, err := box.Info(ctx)
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.Labels["app"] != "test-app" {
t.Errorf("label app = %q, want %q", info.Labels["app"], "test-app")
}
})
}
}
func TestGet(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
got, err := m.Get(context.Background(), box.ID())
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.ID() != box.ID() {
t.Errorf("ID = %q, want %q", got.ID(), box.ID())
}
})
}
}
func TestGetNotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Get(context.Background(), "nonexistent")
if err != sandbox.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
}
}
func TestList(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
co.Owner = "user-list"
})
boxes, err := m.List(context.Background(), sandbox.ListOptions{Owner: "user-list"})
if err != nil {
t.Fatalf("List: %v", err)
}
found := false
for _, b := range boxes {
if b.ID() == box.ID() {
found = true
}
}
if !found {
t.Error("created box not found in list")
}
empty, err := m.List(context.Background(), sandbox.ListOptions{Owner: "nobody"})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(empty) != 0 {
t.Errorf("expected 0 results for unknown owner, got %d", len(empty))
}
})
}
}
func TestRemove(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ensureTestImage(t, m, pc.Name)
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := m.Remove(ctx, box.ID()); err != nil {
t.Fatalf("Remove: %v", err)
}
_, err = m.Get(ctx, box.ID())
if err != sandbox.ErrNotFound {
t.Errorf("after Remove, Get err = %v, want ErrNotFound", err)
}
})
}
}
func TestPoolLimits_MaxTotal(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.MaxTotal = 1
})
ensureTestImage(t, m, pc.Name)
box1 := createTestBox(t, m)
_ = box1
ctx := context.Background()
_, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
})
if err != sandbox.ErrLimitExceeded {
t.Errorf("second Create err = %v, want ErrLimitExceeded", err)
}
})
}
}
func TestAddPool(t *testing.T) {
m := setupManager(t, sandbox.Pool{
Name: "default",
Addr: testLocalAddr(),
})
err := m.AddPool(context.Background(), sandbox.Pool{
Name: "extra",
Addr: testLocalAddr(),
})
if err != nil {
t.Fatalf("AddPool: %v", err)
}
pools := m.Pools()
if len(pools) != 2 {
t.Fatalf("Pools() = %d, want 2", len(pools))
}
err = m.AddPool(context.Background(), sandbox.Pool{
Name: "extra",
Addr: testLocalAddr(),
})
if err == nil {
t.Error("expected error for duplicate pool name")
}
}
func TestCreateNoImage(t *testing.T) {
m := setupManager(t, sandbox.Pool{
Name: "local",
Addr: testLocalAddr(),
})
_, err := m.Create(context.Background(), sandbox.CreateOptions{
Owner: "test",
})
if err == nil {
t.Error("expected error for missing image")
}
}
func TestCreateNoPools(t *testing.T) {
m := setupManager(t)
_, err := m.Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
})
if err != sandbox.ErrNotAvailable {
t.Errorf("err = %v, want ErrNotAvailable", err)
}
}
func TestMultiPool(t *testing.T) {
skipIfNoDocker(t)
skipIfNoTai(t)
pools := testPools()
if len(pools) < 2 {
t.Skip("need at least 2 pools (local + remote) for multi-pool test")
}
var sps []sandbox.Pool
for _, pc := range pools {
sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options})
}
m := setupManager(t, sps...)
for _, pc := range pools {
ensureTestImage(t, m, pc.Name)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
localBox, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Pool: "local",
})
if err != nil {
t.Fatalf("Create on local: %v", err)
}
defer m.Remove(ctx, localBox.ID())
remoteBox, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Pool: "remote",
})
if err != nil {
t.Fatalf("Create on remote: %v", err)
}
defer m.Remove(ctx, remoteBox.ID())
r1, err := localBox.Exec(ctx, []string{"echo", "local"})
if err != nil {
t.Fatalf("Exec on local: %v", err)
}
if r1.Stdout != "local\n" {
t.Errorf("local stdout = %q, want %q", r1.Stdout, "local\n")
}
r2, err := remoteBox.Exec(ctx, []string{"echo", "remote"})
if err != nil {
t.Fatalf("Exec on remote: %v", err)
}
if r2.Stdout != "remote\n" {
t.Errorf("remote stdout = %q, want %q", r2.Stdout, "remote\n")
}
localInfo, err := localBox.Info(ctx)
if err != nil {
t.Fatalf("Info local: %v", err)
}
remoteInfo, err := remoteBox.Info(ctx)
if err != nil {
t.Fatalf("Info remote: %v", err)
}
if localInfo.ID == remoteInfo.ID {
t.Error("local and remote boxes should have different IDs")
}
}

23
sandbox/v2/sandbox.go Normal file
View file

@ -0,0 +1,23 @@
package sandbox
var mgr *Manager
// Init initializes the global sandbox Manager.
// Config contains pool definitions. At least one Pool entry is required.
// Pass empty Pool list to disable sandbox (methods return ErrNotAvailable).
func Init(cfg Config) error {
m, err := newManager(cfg)
if err != nil {
return err
}
mgr = m
return nil
}
// M returns the global Manager. Panics if Init was not called.
func M() *Manager {
if mgr == nil {
panic("sandbox.Init not called")
}
return mgr
}

View file

@ -0,0 +1,41 @@
package sandbox_test
import (
"testing"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestInit(t *testing.T) {
cfg := sandbox.Config{
Pool: []sandbox.Pool{
{Name: "test", Addr: "local"},
},
}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
m := sandbox.M()
if m == nil {
t.Fatal("M() returned nil")
}
m.Close()
}
func TestInitEmpty(t *testing.T) {
cfg := sandbox.Config{}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init with empty config: %v", err)
}
sandbox.M().Close()
}
func TestMPanicWithoutInit(t *testing.T) {
sandbox.ResetForTest()
defer func() {
if r := recover(); r == nil {
t.Error("expected panic from M() without Init")
}
}()
sandbox.M()
}

View file

@ -0,0 +1,196 @@
package sandbox_test
import (
"context"
"fmt"
"os"
"strconv"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
type poolConfig struct {
Name string
Addr string
Options []tai.Option
}
// testPools returns all available pool configurations for multi-mode testing.
// - local: always present (direct Docker daemon)
// - remote: when SANDBOX_TEST_REMOTE_ADDR is set (Tai on host → Docker)
// - containerized: when TAI_TEST_CONTAINERIZED_HOST is set (Tai in container → Docker)
// - k8s: when TAI_TEST_K8S_HOST + TAI_TEST_KUBECONFIG are set (Tai → K8s)
func testPools() []poolConfig {
pools := []poolConfig{
{Name: "local", Addr: testLocalAddr()},
}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
}
if host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200)
addr := fmt.Sprintf("tai://%s:%d", host, grpcPort)
// No WithPorts for HTTP/VNC — Tai self-inspects its container
// and returns host-mapped ports via ServerInfo automatically.
pools = append(pools, poolConfig{Name: "containerized", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if kubeconfig == "" {
return pools
}
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100))
addr := fmt.Sprintf("tai://%s:%d", host, grpcPort)
opts := []tai.Option{
tai.K8s,
tai.WithKubeConfig(kubeconfig),
tai.WithPorts(tai.Ports{
K8s: envPort("TAI_TEST_K8S_PORT", 6443),
GRPC: grpcPort,
}),
}
if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" {
opts = append(opts, tai.WithNamespace(ns))
}
pools = append(pools, poolConfig{Name: "k8s", Addr: addr, Options: opts})
}
return pools
}
func skipIfNoDocker(t *testing.T) {
t.Helper()
addr := testLocalAddr()
if addr == "" {
t.Skip("SANDBOX_TEST_LOCAL_ADDR not set, skipping Docker tests")
}
}
func skipIfNoTai(t *testing.T) {
t.Helper()
if os.Getenv("SANDBOX_TEST_REMOTE_ADDR") == "" {
t.Skip("SANDBOX_TEST_REMOTE_ADDR not set, skipping Tai proxy tests")
}
}
func testLocalAddr() string {
if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" {
return addr
}
return "local"
}
func testImage() string {
if img := os.Getenv("SANDBOX_TEST_IMAGE"); img != "" {
return img
}
return "alpine:latest"
}
func envPort(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if p, err := strconv.Atoi(v); err == nil {
return p
}
}
return fallback
}
func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager {
t.Helper()
cfg := sandbox.Config{Pool: pools}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
m := sandbox.M()
t.Cleanup(func() {
m.Close()
})
return m
}
func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager {
t.Helper()
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
for _, fn := range mutators {
fn(&pool)
}
return setupManager(t, pool)
}
// setupManagerWithWorkspace creates a sandbox Manager with a linked workspace Manager.
// Returns both managers and a helper to create workspaces on the given pool's node.
func setupManagerWithWorkspace(t *testing.T, pc poolConfig) (*sandbox.Manager, *workspace.Manager) {
t.Helper()
sbm := setupManagerForPool(t, pc)
var wsClient *tai.Client
var err error
if pc.Addr == "local" || pc.Addr == "" {
dataDir := t.TempDir()
vol := volume.NewLocal(dataDir)
wsClient, err = tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir))
} else {
wsClient, err = tai.New(pc.Addr, pc.Options...)
}
if err != nil {
t.Fatalf("tai.New for workspace: %v", err)
}
t.Cleanup(func() { wsClient.Close() })
wsm := workspace.NewManager(map[string]*tai.Client{pc.Name: wsClient})
sbm.SetWorkspaceManager(wsm)
return sbm, wsm
}
// ensureTestImage guarantees testImage() is available on the given pool before
// container creation. Safe for all modes (Docker pull; K8s no-op).
func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil {
t.Fatalf("EnsureImage(%s, %s): %v", pool, testImage(), err)
}
}
func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.CreateOptions)) *sandbox.Box {
t.Helper()
co := sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
}
for _, fn := range opts {
fn(&co)
}
pool := co.Pool
if pool == "" {
pools := m.Pools()
if len(pools) > 0 {
pool = pools[0].Name
}
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if pool != "" {
if err := m.EnsureImage(ctx, pool, co.Image, sandbox.ImagePullOptions{}); err != nil {
t.Fatalf("EnsureImage(%s, %s): %v", pool, co.Image, err)
}
}
box, err := m.Create(ctx, co)
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
m.Remove(context.Background(), box.ID())
})
return box
}

178
sandbox/v2/types.go Normal file
View file

@ -0,0 +1,178 @@
package sandbox
import (
"io"
"time"
"github.com/yaoapp/yao/tai"
)
type LifecyclePolicy string
const (
OneShot LifecyclePolicy = "oneshot"
Session LifecyclePolicy = "session"
LongRunning LifecyclePolicy = "longrunning"
Persistent LifecyclePolicy = "persistent"
)
const DefaultStopTimeout = 2 * time.Second
type Pool struct {
Name string
Addr string
Options []tai.Option
MaxPerUser int
MaxTotal int
IdleTimeout time.Duration
MaxLifetime time.Duration
StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout
}
type PoolInfo struct {
Name string
Addr string
Connected bool
Boxes int
MaxPerUser int
MaxTotal int
IdleTimeout time.Duration
MaxLifetime time.Duration
}
type PortMapping struct {
ContainerPort int
HostPort int
HostIP string
Protocol string
}
type CreateOptions struct {
ID string
Owner string
Labels map[string]string
Pool string
Image string
WorkDir string
User string
Env map[string]string
Memory int64
CPUs float64
VNC bool
Ports []PortMapping
Policy LifecyclePolicy
IdleTimeout time.Duration
StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout
WorkspaceID string // workspace to mount; empty = no workspace
MountMode string // "rw" (default) or "ro"
MountPath string // container path; default "/workspace"
}
type ListOptions struct {
Owner string
Pool string
Labels map[string]string
}
type execConfig struct {
WorkDir string
Env map[string]string
Timeout time.Duration
}
type ExecOption func(*execConfig)
func WithWorkDir(dir string) ExecOption {
return func(c *execConfig) {
c.WorkDir = dir
}
}
func WithEnv(env map[string]string) ExecOption {
return func(c *execConfig) {
c.Env = env
}
}
func WithTimeout(timeout time.Duration) ExecOption {
return func(c *execConfig) {
c.Timeout = timeout
}
}
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
}
type ExecStream struct {
Stdout io.ReadCloser
Stderr io.ReadCloser
Stdin io.WriteCloser
Wait func() (int, error)
Cancel func()
}
type attachConfig struct {
Protocol string
Path string
Headers map[string]string
}
type AttachOption func(*attachConfig)
func WithProtocol(protocol string) AttachOption {
return func(c *attachConfig) {
c.Protocol = protocol
}
}
func WithPath(path string) AttachOption {
return func(c *attachConfig) {
c.Path = path
}
}
func WithHeaders(headers map[string]string) AttachOption {
return func(c *attachConfig) {
c.Headers = headers
}
}
// ImagePullOptions configures an image pull operation.
type ImagePullOptions struct {
Auth *RegistryAuth // nil = anonymous / public
}
// RegistryAuth holds credentials for a private container registry.
type RegistryAuth struct {
Username string
Password string
Server string
}
type ServiceConn struct {
Read func() ([]byte, error)
Write func(data []byte) error
Events <-chan []byte
URL string
Close func() error
}
type BoxInfo struct {
ID string
ContainerID string
Pool string
Owner string
Status string
Policy LifecyclePolicy
Labels map[string]string
Image string
CreatedAt time.Time
LastActive time.Time
ProcessCount int
VNC bool
}

View file

@ -18,8 +18,8 @@ Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. P
### Local Mode (direct Docker) ### Local Mode (direct Docker)
```go ```go
c, err := tai.New("") c, err := tai.New("local")
// or: tai.New("unix:///var/run/docker.sock") // or: tai.New("docker:///var/run/docker.sock")
// or: tai.New("tcp://192.168.1.50:2375") // or: tai.New("tcp://192.168.1.50:2375")
defer c.Close() defer c.Close()

View file

@ -71,6 +71,10 @@ func serve() error {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
if sandboxID := os.Getenv("YAO_SANDBOX_ID"); sandboxID != "" {
go yaogrpc.HeartbeatLoop(ctx, client, sandboxID)
}
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024) scanner.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024)
encoder := json.NewEncoder(os.Stdout) encoder := json.NewEncoder(os.Stdout)

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strings"
"github.com/yaoapp/yao/grpc/pb" "github.com/yaoapp/yao/grpc/pb"
"google.golang.org/grpc" "google.golang.org/grpc"
@ -36,6 +37,9 @@ func NewFromEnv() (*Client, error) {
} }
// Dial connects to the gRPC server at addr with the given TokenManager. // 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).
func Dial(addr string, tm *TokenManager) (*Client, error) { func Dial(addr string, tm *TokenManager) (*Client, error) {
opts := []grpc.DialOption{ opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
@ -47,7 +51,12 @@ func Dial(addr string, tm *TokenManager) (*Client, error) {
) )
} }
conn, err := grpc.NewClient(addr, opts...) target := addr
if !strings.Contains(addr, "://") {
target = "passthrough:///" + addr
}
conn, err := grpc.NewClient(target, opts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err) return nil, fmt.Errorf("dial %s: %w", addr, err)
} }
@ -228,6 +237,22 @@ func (c *Client) AgentStream(ctx context.Context, assistantID string, messages,
} }
} }
// --- Sandbox ---
// Heartbeat sends a sandbox heartbeat to the Yao gRPC server.
func (c *Client) Heartbeat(ctx context.Context, sandboxID string, cpuPercent int32, memBytes int64, runningProcs int32) (string, error) {
resp, err := c.svc.Heartbeat(ctx, &pb.HeartbeatRequest{
SandboxId: sandboxID,
CpuPercent: cpuPercent,
MemBytes: memBytes,
RunningProcs: runningProcs,
})
if err != nil {
return "", err
}
return resp.Action, nil
}
// --- Health --- // --- Health ---
// Healthz checks the server health. // Healthz checks the server health.

View file

@ -169,6 +169,41 @@ func TestDial_WithTokenManager(t *testing.T) {
assert.False(t, c.TokenManager().IsTaiMode()) 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) { func TestClient_Close_Nil(t *testing.T) {
c := &yaogrpc.Client{} c := &yaogrpc.Client{}
assert.NoError(t, c.Close()) assert.NoError(t, c.Close())

77
tai/grpc/heartbeat.go Normal file
View file

@ -0,0 +1,77 @@
package grpc
import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
)
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) {
interval := defaultHeartbeatInterval
if s := os.Getenv("YAO_HEARTBEAT_INTERVAL"); s != "" {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
interval = d
}
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cpu, mem := sampleResources()
procs := countUserProcesses()
action, err := client.Heartbeat(ctx, sandboxID, cpu, mem, procs)
if err != nil {
continue
}
if action == "shutdown" {
fmt.Fprintf(os.Stderr, "yao-grpc: received shutdown signal\n")
p, _ := os.FindProcess(os.Getpid())
p.Signal(os.Interrupt)
return
}
}
}
}
// 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
}
n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
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
}

194
tai/grpc/heartbeat_test.go Normal file
View file

@ -0,0 +1,194 @@
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")
}
}

117
tai/proxy/connect.go Normal file
View file

@ -0,0 +1,117 @@
package proxy
import (
"bufio"
"bytes"
"context"
"fmt"
"net/http"
"strings"
"github.com/gorilla/websocket"
)
// --- Remote Connect ---
func (r *remoteProxy) Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error) {
baseURL, err := r.URL(ctx, containerID, opts.Port, opts.Path)
if err != nil {
return nil, err
}
return connect(ctx, baseURL, opts.Protocol, r.client)
}
// --- Local Connect ---
func (l *localProxy) Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error) {
baseURL, err := l.URL(ctx, containerID, opts.Port, opts.Path)
if err != nil {
return nil, err
}
return connect(ctx, baseURL, opts.Protocol, http.DefaultClient)
}
func connect(ctx context.Context, url string, protocol string, hc *http.Client) (*Connection, error) {
switch protocol {
case "ws":
return connectWS(ctx, url)
case "sse":
return connectSSE(ctx, url, hc)
default:
return nil, fmt.Errorf("unsupported connect protocol: %q", protocol)
}
}
func connectWS(ctx context.Context, rawURL string) (*Connection, error) {
wsURL := strings.Replace(rawURL, "http://", "ws://", 1)
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
if err != nil {
return nil, fmt.Errorf("ws dial: %w", err)
}
ch := make(chan []byte, 64)
go func() {
defer close(ch)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
ch <- msg
}
}()
return &Connection{
Messages: ch,
Send: func(data []byte) error {
return conn.WriteMessage(websocket.TextMessage, data)
},
Close: func() error {
return conn.Close()
},
}, nil
}
func connectSSE(ctx context.Context, url string, hc *http.Client) (*Connection, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/event-stream")
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("sse connect: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("sse: status %d", resp.StatusCode)
}
ch := make(chan []byte, 64)
go func() {
defer close(ch)
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
ch <- bytes.Clone([]byte(data))
}
}
}()
return &Connection{
Messages: ch,
Send: func(data []byte) error {
return fmt.Errorf("sse: send not supported")
},
Close: func() error {
resp.Body.Close()
return nil
},
}, nil
}

View file

@ -13,9 +13,27 @@ import (
// Remote routes through Tai HTTP proxy; Local resolves host ports directly. // Remote routes through Tai HTTP proxy; Local resolves host ports directly.
type Proxy interface { type Proxy interface {
URL(ctx context.Context, containerID string, port int, path string) (string, error) URL(ctx context.Context, containerID string, port int, path string) (string, error)
Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error)
Healthz(ctx context.Context) error Healthz(ctx context.Context) error
} }
// ConnectOptions configures a persistent connection to a container service.
type ConnectOptions struct {
Port int // container port
Path string // URL path (e.g. "/ws" or "/events")
Protocol string // "ws", "sse", or "tcp"
}
// Connection represents a persistent connection to a container service.
type Connection struct {
// Messages receives incoming data. Channel is closed when the connection ends.
Messages <-chan []byte
// Send writes data to the connection (only valid for "ws" protocol).
Send func(data []byte) error
// Close terminates the connection.
Close func() error
}
// --- Remote implementation --- // --- Remote implementation ---
type remoteProxy struct { type remoteProxy struct {

View file

@ -5,9 +5,11 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
"github.com/gorilla/websocket"
"github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/sandbox"
) )
@ -138,6 +140,126 @@ func TestHostIP(t *testing.T) {
} }
} }
// ── Connect tests ─────────────────────────────────────────────────────────────
func TestConnect_UnsupportedProtocol(t *testing.T) {
_, err := connect(context.Background(), "http://127.0.0.1:1234", "tcp", http.DefaultClient)
if err == nil {
t.Fatal("expected error for unsupported protocol")
}
if !strings.Contains(err.Error(), "unsupported connect protocol") {
t.Errorf("unexpected error: %v", err)
}
}
func TestConnectWS_EchoRoundtrip(t *testing.T) {
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
for {
mt, msg, err := c.ReadMessage()
if err != nil {
return
}
c.WriteMessage(mt, msg)
}
}))
defer srv.Close()
conn, err := connectWS(context.Background(), srv.URL)
if err != nil {
t.Fatalf("connectWS: %v", err)
}
defer conn.Close()
if err := conn.Send([]byte("hello")); err != nil {
t.Fatalf("Send: %v", err)
}
select {
case msg := <-conn.Messages:
if string(msg) != "hello" {
t.Errorf("got %q, want %q", msg, "hello")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for echo")
}
}
func TestConnectSSE_ReceiveEvents(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
for i := 0; i < 3; i++ {
fmt.Fprintf(w, "data: event-%d\n\n", i)
flusher.Flush()
}
}))
defer srv.Close()
conn, err := connectSSE(context.Background(), srv.URL, srv.Client())
if err != nil {
t.Fatalf("connectSSE: %v", err)
}
defer conn.Close()
var events []string
for msg := range conn.Messages {
events = append(events, string(msg))
if len(events) >= 3 {
break
}
}
if len(events) != 3 {
t.Fatalf("got %d events, want 3", len(events))
}
for i, e := range events {
want := fmt.Sprintf("event-%d", i)
if e != want {
t.Errorf("event[%d] = %q, want %q", i, e, want)
}
}
}
func TestConnectSSE_SendNotSupported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "data: x\n\n")
}))
defer srv.Close()
conn, err := connectSSE(context.Background(), srv.URL, srv.Client())
if err != nil {
t.Fatalf("connectSSE: %v", err)
}
defer conn.Close()
if err := conn.Send([]byte("test")); err == nil {
t.Error("expected error from SSE Send")
}
}
func TestConnectSSE_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
_, err := connectSSE(context.Background(), srv.URL, srv.Client())
if err == nil {
t.Fatal("expected error for non-200")
}
if !strings.Contains(err.Error(), "status 503") {
t.Errorf("unexpected error: %v", err)
}
}
// mockSandbox implements sandbox.Sandbox for testing. // mockSandbox implements sandbox.Sandbox for testing.
type mockSandbox struct { type mockSandbox struct {
inspectFn func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) inspectFn func(ctx context.Context, id string) (*sandbox.ContainerInfo, error)
@ -154,6 +276,9 @@ func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error {
func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) { func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.StreamHandle, error) {
return nil, nil
}
func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
if m.inspectFn != nil { if m.inspectFn != nil {
return m.inspectFn(ctx, id) return m.inspectFn(ctx, id)

View file

@ -0,0 +1,20 @@
package sandbox
import "github.com/docker/docker/client"
// dockerCliAccessor is implemented by sandbox types that hold a Docker client.
type dockerCliAccessor interface {
dockerClient() *client.Client
}
func (l *local) dockerClient() *client.Client { return l.core.cli }
func (d *dockerSandbox) dockerClient() *client.Client { return d.core.cli }
// DockerCli extracts the underlying Docker SDK client from a Sandbox.
// Returns nil if the Sandbox is not Docker-based (e.g. K8s).
func DockerCli(sb Sandbox) *client.Client {
if a, ok := sb.(dockerCliAccessor); ok {
return a.dockerClient()
}
return nil
}

View file

@ -32,7 +32,7 @@ func NewDocker(addr string) (Sandbox, error) {
} }
func (d *dockerSandbox) Create(ctx context.Context, opts CreateOptions) (string, error) { func (d *dockerSandbox) Create(ctx context.Context, opts CreateOptions) (string, error) {
return d.core.create(ctx, opts, false) return d.core.create(ctx, opts, true)
} }
func (d *dockerSandbox) Start(ctx context.Context, id string) error { func (d *dockerSandbox) Start(ctx context.Context, id string) error {
@ -51,6 +51,10 @@ func (d *dockerSandbox) Exec(ctx context.Context, id string, cmd []string, opts
return d.core.exec(ctx, id, cmd, opts) return d.core.exec(ctx, id, cmd, opts)
} }
func (d *dockerSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) {
return d.core.execStream(ctx, id, cmd, opts)
}
func (d *dockerSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, error) { func (d *dockerSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, error) {
return d.core.inspect(ctx, id) return d.core.inspect(ctx, id)
} }

View file

@ -26,6 +26,8 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts
Cmd: opts.Cmd, Cmd: opts.Cmd,
Env: envSlice(opts.Env), Env: envSlice(opts.Env),
WorkingDir: opts.WorkingDir, WorkingDir: opts.WorkingDir,
Labels: opts.Labels,
User: opts.User,
} }
hostCfg := &container.HostConfig{ hostCfg := &container.HostConfig{
@ -129,6 +131,68 @@ func (d *dockerCore) exec(ctx context.Context, id string, cmd []string, opts Exe
}, nil }, nil
} }
func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) {
execCfg := container.ExecOptions{
Cmd: cmd,
WorkingDir: opts.WorkDir,
Env: envSlice(opts.Env),
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
}
execResp, err := d.cli.ContainerExecCreate(ctx, id, execCfg)
if err != nil {
return nil, fmt.Errorf("exec create: %w", err)
}
resp, err := d.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
if err != nil {
return nil, fmt.Errorf("exec attach: %w", err)
}
execCtx, execCancel := context.WithCancel(ctx)
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
stderrR, stderrW := io.Pipe()
// Pump user writes into the multiplexed connection.
// Closing stdinW sends EOF to the container stdin without
// tearing down the underlying connection (which carries stdout/stderr).
go func() {
io.Copy(resp.Conn, stdinR)
resp.CloseWrite()
}()
go func() {
_, _ = stdcopy.StdCopy(stdoutW, stderrW, resp.Reader)
stdoutW.Close()
stderrW.Close()
}()
return &StreamHandle{
Stdin: stdinW,
Stdout: stdoutR,
Stderr: stderrR,
Wait: func() (int, error) {
for {
inspect, err := d.cli.ContainerExecInspect(execCtx, execResp.ID)
if err != nil {
return -1, fmt.Errorf("exec inspect: %w", err)
}
if !inspect.Running {
return inspect.ExitCode, nil
}
}
},
Cancel: func() {
execCancel()
resp.Close()
},
}, nil
}
func (d *dockerCore) inspect(ctx context.Context, id string) (*ContainerInfo, error) { func (d *dockerCore) inspect(ctx context.Context, id string) (*ContainerInfo, error) {
info, err := d.cli.ContainerInspect(ctx, id) info, err := d.cli.ContainerInspect(ctx, id)
if err != nil { if err != nil {
@ -140,6 +204,7 @@ func (d *dockerCore) inspect(ctx context.Context, id string) (*ContainerInfo, er
Name: strings.TrimPrefix(info.Name, "/"), Name: strings.TrimPrefix(info.Name, "/"),
Image: info.Config.Image, Image: info.Config.Image,
Status: info.State.Status, Status: info.State.Status,
Labels: info.Config.Labels,
} }
if info.NetworkSettings != nil { if info.NetworkSettings != nil {
@ -196,6 +261,7 @@ func (d *dockerCore) list(ctx context.Context, opts ListOptions) ([]ContainerInf
Name: name, Name: name,
Image: c.Image, Image: c.Image,
Status: c.State, Status: c.State,
Labels: c.Labels,
} }
for _, p := range c.Ports { for _, p := range c.Ports {
ci.Ports = append(ci.Ports, PortMapping{ ci.Ports = append(ci.Ports, PortMapping{

43
tai/sandbox/image.go Normal file
View file

@ -0,0 +1,43 @@
package sandbox
import (
"context"
"time"
)
// Image manages container images on a runtime node.
type Image interface {
Exists(ctx context.Context, ref string) (bool, error)
Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error)
Remove(ctx context.Context, ref string, force bool) error
List(ctx context.Context) ([]ImageInfo, error)
}
// PullOptions configures an image pull operation.
type PullOptions struct {
Auth *RegistryAuth // nil = anonymous / public
}
// RegistryAuth holds credentials for a private container registry.
type RegistryAuth struct {
Username string
Password string
Server string // e.g. "ghcr.io", "registry.example.com"
}
// PullProgress reports real-time progress of an image pull.
type PullProgress struct {
Status string // "Pulling fs layer", "Downloading", "Extracting", "Pull complete", etc.
Layer string // layer digest / short ID
Current int64 // bytes completed
Total int64 // bytes total (0 if unknown)
Error string // non-empty on failure
}
// ImageInfo describes a local image.
type ImageInfo struct {
ID string
Tags []string
Size int64
Created time.Time
}

132
tai/sandbox/image_docker.go Normal file
View file

@ -0,0 +1,132 @@
package sandbox
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"time"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
)
// dockerImage implements Image using the Docker SDK.
// Shared by both local and dockerSandbox (via Tai proxy) modes.
type dockerImage struct {
cli *client.Client
}
// NewDockerImage creates an Image backed by a Docker client.
func NewDockerImage(cli *client.Client) Image {
return &dockerImage{cli: cli}
}
func (d *dockerImage) Exists(ctx context.Context, ref string) (bool, error) {
_, _, err := d.cli.ImageInspectWithRaw(ctx, ref)
if err != nil {
if client.IsErrNotFound(err) {
return false, nil
}
return false, fmt.Errorf("image inspect %q: %w", ref, err)
}
return true, nil
}
func (d *dockerImage) Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) {
pullOpts := image.PullOptions{}
if opts.Auth != nil {
encoded, err := encodeAuth(opts.Auth)
if err != nil {
return nil, err
}
pullOpts.RegistryAuth = encoded
}
reader, err := d.cli.ImagePull(ctx, ref, pullOpts)
if err != nil {
return nil, fmt.Errorf("image pull %q: %w", ref, err)
}
ch := make(chan PullProgress, 32)
go func() {
defer close(ch)
defer reader.Close()
decodePullStream(reader, ch)
}()
return ch, nil
}
func (d *dockerImage) Remove(ctx context.Context, ref string, force bool) error {
_, err := d.cli.ImageRemove(ctx, ref, image.RemoveOptions{Force: force, PruneChildren: true})
if err != nil {
return fmt.Errorf("image remove %q: %w", ref, err)
}
return nil
}
func (d *dockerImage) List(ctx context.Context) ([]ImageInfo, error) {
imgs, err := d.cli.ImageList(ctx, image.ListOptions{})
if err != nil {
return nil, fmt.Errorf("image list: %w", err)
}
result := make([]ImageInfo, len(imgs))
for i, img := range imgs {
result[i] = ImageInfo{
ID: img.ID,
Tags: img.RepoTags,
Size: img.Size,
Created: time.Unix(img.Created, 0),
}
}
return result, nil
}
// dockerPullEvent mirrors the JSON lines emitted by Docker's ImagePull stream.
type dockerPullEvent struct {
Status string `json:"status"`
ID string `json:"id"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"`
}
func decodePullStream(r io.Reader, ch chan<- PullProgress) {
dec := json.NewDecoder(r)
for {
var ev dockerPullEvent
if err := dec.Decode(&ev); err != nil {
if err != io.EOF {
ch <- PullProgress{Error: err.Error()}
}
return
}
p := PullProgress{
Status: ev.Status,
Layer: ev.ID,
Current: ev.ProgressDetail.Current,
Total: ev.ProgressDetail.Total,
}
if ev.Error != "" {
p.Error = ev.Error
}
ch <- p
}
}
func encodeAuth(auth *RegistryAuth) (string, error) {
cfg := registry.AuthConfig{
Username: auth.Username,
Password: auth.Password,
ServerAddress: auth.Server,
}
data, err := json.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("encode registry auth: %w", err)
}
return base64.URLEncoding.EncodeToString(data), nil
}

25
tai/sandbox/image_k8s.go Normal file
View file

@ -0,0 +1,25 @@
package sandbox
import "context"
// k8sImage is a no-op Image for K8s mode.
// Image pulling is handled by kubelet based on imagePullPolicy and imagePullSecrets.
type k8sImage struct{}
func NewK8sImage() Image { return &k8sImage{} }
func (k *k8sImage) Exists(_ context.Context, _ string) (bool, error) {
return true, nil
}
func (k *k8sImage) Pull(_ context.Context, _ string, _ PullOptions) (<-chan PullProgress, error) {
return nil, nil
}
func (k *k8sImage) Remove(_ context.Context, _ string, _ bool) error {
return nil
}
func (k *k8sImage) List(_ context.Context) ([]ImageInfo, error) {
return nil, nil
}

View file

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"io"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@ -112,7 +113,7 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
container := corev1.Container{ container := corev1.Container{
Name: "main", Name: "main",
Image: opts.Image, Image: opts.Image,
Command: opts.Cmd, Args: opts.Cmd,
Env: envVars, Env: envVars,
WorkingDir: opts.WorkingDir, WorkingDir: opts.WorkingDir,
} }
@ -126,6 +127,9 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
labels[k] = v labels[k] = v
} }
labels["sandbox-name"] = name labels["sandbox-name"] = name
for k, v := range opts.Labels {
labels[k] = v
}
pod := &corev1.Pod{ pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
@ -139,6 +143,15 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
}, },
} }
if opts.User != "" {
uid, err := parseUID(opts.User)
if err == nil {
pod.Spec.SecurityContext = &corev1.PodSecurityContext{
RunAsUser: &uid,
}
}
}
created, err := s.cli.CoreV1().Pods(s.ns).Create(ctx, pod, metav1.CreateOptions{}) created, err := s.cli.CoreV1().Pods(s.ns).Create(ctx, pod, metav1.CreateOptions{})
if err != nil { if err != nil {
return "", fmt.Errorf("create pod: %w", err) return "", fmt.Errorf("create pod: %w", err)
@ -147,9 +160,16 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
} }
func (s *k8sSandbox) Start(ctx context.Context, id string) error { func (s *k8sSandbox) Start(ctx context.Context, id string) error {
// K8s pods start automatically after creation. if _, ok := ctx.Deadline(); !ok {
// Wait briefly for the pod to leave Pending. var cancel context.CancelFunc
for i := 0; i < 30; i++ { ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
defer cancel()
}
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{}) pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{})
if err != nil { if err != nil {
return fmt.Errorf("get pod: %w", err) return fmt.Errorf("get pod: %w", err)
@ -157,9 +177,13 @@ func (s *k8sSandbox) Start(ctx context.Context, id string) error {
if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed { if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
return nil return nil
} }
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
return fmt.Errorf("pod %s did not reach Running: %w", id, ctx.Err())
case <-ticker.C:
}
} }
return fmt.Errorf("pod %s did not reach Running within 30s", id)
} }
func (s *k8sSandbox) Stop(ctx context.Context, id string, timeout time.Duration) error { func (s *k8sSandbox) Stop(ctx context.Context, id string, timeout time.Duration) error {
@ -236,6 +260,78 @@ func (s *k8sSandbox) Exec(ctx context.Context, id string, cmd []string, opts Exe
}, nil }, nil
} }
func (s *k8sSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) {
execCmd := cmd
if opts.WorkDir != "" || len(opts.Env) > 0 {
var prefix string
for k, v := range opts.Env {
prefix += fmt.Sprintf("export %s=%q; ", k, v)
}
cdPart := ""
if opts.WorkDir != "" {
cdPart = fmt.Sprintf("cd %s && ", opts.WorkDir)
}
execCmd = []string{"sh", "-c", cdPart + prefix + strings.Join(cmd, " ")}
}
req := s.cli.CoreV1().RESTClient().Post().
Resource("pods").
Name(id).
Namespace(s.ns).
SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Container: "main",
Command: execCmd,
Stdin: true,
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(s.cfg, "POST", req.URL())
if err != nil {
return nil, fmt.Errorf("create executor: %w", err)
}
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
stderrR, stderrW := io.Pipe()
execCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
var exitCode int
go func() {
err := exec.StreamWithContext(execCtx, remotecommand.StreamOptions{
Stdin: stdinR,
Stdout: stdoutW,
Stderr: stderrW,
})
if err != nil {
if exitErr, ok := err.(interface{ ExitStatus() int }); ok {
exitCode = exitErr.ExitStatus()
err = nil
}
}
stdoutW.Close()
stderrW.Close()
done <- err
}()
return &StreamHandle{
Stdin: stdinW,
Stdout: stdoutR,
Stderr: stderrR,
Wait: func() (int, error) {
err := <-done
return exitCode, err
},
Cancel: func() {
cancel()
stdinR.Close()
},
}, nil
}
func (s *k8sSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, error) { func (s *k8sSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, error) {
pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{}) pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{})
if err != nil { if err != nil {
@ -248,6 +344,7 @@ func (s *k8sSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, er
Image: pod.Spec.Containers[0].Image, Image: pod.Spec.Containers[0].Image,
Status: string(pod.Status.Phase), Status: string(pod.Status.Phase),
IP: pod.Status.PodIP, IP: pod.Status.PodIP,
Labels: pod.Labels,
}, nil }, nil
} }
@ -273,6 +370,7 @@ func (s *k8sSandbox) List(ctx context.Context, opts ListOptions) ([]ContainerInf
Name: pod.Name, Name: pod.Name,
Status: string(pod.Status.Phase), Status: string(pod.Status.Phase),
IP: pod.Status.PodIP, IP: pod.Status.PodIP,
Labels: pod.Labels,
} }
if len(pod.Spec.Containers) > 0 { if len(pod.Spec.Containers) > 0 {
ci.Image = pod.Spec.Containers[0].Image ci.Image = pod.Spec.Containers[0].Image
@ -286,6 +384,14 @@ func (s *k8sSandbox) Close() error {
return nil // REST client doesn't need explicit close return nil // REST client doesn't need explicit close
} }
// parseUID extracts a numeric UID from a user string like "1000" or "1000:1000".
func parseUID(user string) (int64, error) {
parts := strings.SplitN(user, ":", 2)
var uid int64
_, err := fmt.Sscanf(parts[0], "%d", &uid)
return uid, err
}
func buildResources(memory int64, cpus float64) corev1.ResourceRequirements { func buildResources(memory int64, cpus float64) corev1.ResourceRequirements {
limits := corev1.ResourceList{} limits := corev1.ResourceList{}
if memory > 0 { if memory > 0 {

View file

@ -3,7 +3,6 @@ package sandbox
import ( import (
"context" "context"
"fmt" "fmt"
"runtime"
"time" "time"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@ -36,7 +35,7 @@ func NewLocal(addr string) (Sandbox, error) {
} }
func (l *local) Create(ctx context.Context, opts CreateOptions) (string, error) { func (l *local) Create(ctx context.Context, opts CreateOptions) (string, error) {
return l.core.create(ctx, opts, opts.VNC && needsPortMapping()) return l.core.create(ctx, opts, opts.VNC)
} }
func (l *local) Start(ctx context.Context, id string) error { func (l *local) Start(ctx context.Context, id string) error {
@ -55,6 +54,10 @@ func (l *local) Exec(ctx context.Context, id string, cmd []string, opts ExecOpti
return l.core.exec(ctx, id, cmd, opts) return l.core.exec(ctx, id, cmd, opts)
} }
func (l *local) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) {
return l.core.execStream(ctx, id, cmd, opts)
}
func (l *local) Inspect(ctx context.Context, id string) (*ContainerInfo, error) { func (l *local) Inspect(ctx context.Context, id string) (*ContainerInfo, error) {
return l.core.inspect(ctx, id) return l.core.inspect(ctx, id)
} }
@ -67,12 +70,6 @@ func (l *local) Close() error {
return l.core.cli.Close() return l.core.cli.Close()
} }
// needsPortMapping returns true on platforms where container IPs are not
// directly reachable (macOS Docker Desktop, Windows).
func needsPortMapping() bool {
return runtime.GOOS == "darwin" || runtime.GOOS == "windows"
}
func portStr(p int) string { func portStr(p int) string {
if p == 0 { if p == 0 {
return "" return ""

View file

@ -2,6 +2,7 @@ package sandbox
import ( import (
"context" "context"
"io"
"time" "time"
) )
@ -13,11 +14,23 @@ type Sandbox interface {
Stop(ctx context.Context, id string, timeout time.Duration) error Stop(ctx context.Context, id string, timeout time.Duration) error
Remove(ctx context.Context, id string, force bool) error Remove(ctx context.Context, id string, force bool) error
Exec(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecResult, error) Exec(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecResult, error)
ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error)
Inspect(ctx context.Context, id string) (*ContainerInfo, error) Inspect(ctx context.Context, id string) (*ContainerInfo, error)
List(ctx context.Context, opts ListOptions) ([]ContainerInfo, error) List(ctx context.Context, opts ListOptions) ([]ContainerInfo, error)
Close() error Close() error
} }
// StreamHandle provides real-time I/O access to a running exec process.
type StreamHandle struct {
Stdin io.WriteCloser
Stdout io.Reader
Stderr io.Reader
// Wait blocks until the exec process finishes and returns the exit code.
Wait func() (int, error)
// Cancel aborts the exec process.
Cancel func()
}
// CreateOptions configures a new container. // CreateOptions configures a new container.
type CreateOptions struct { type CreateOptions struct {
Name string Name string
@ -30,6 +43,8 @@ type CreateOptions struct {
CPUs float64 // 0 = no limit CPUs float64 // 0 = no limit
VNC bool VNC bool
Ports []PortMapping Ports []PortMapping
Labels map[string]string // container/pod labels for discovery and management
User string // container user, e.g. "1000:1000" or "sandbox"
} }
// PortMapping maps a container port to a host port. // PortMapping maps a container port to a host port.
@ -48,6 +63,7 @@ type ContainerInfo struct {
Status string // "created", "running", "exited", "removing" Status string // "created", "running", "exited", "removing"
IP string IP string
Ports []PortMapping Ports []PortMapping
Labels map[string]string
} }
// ExecOptions configures a command execution inside a container. // ExecOptions configures a command execution inside a container.

View file

@ -2,7 +2,9 @@ package sandbox
import ( import (
"context" "context"
"io"
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@ -625,3 +627,332 @@ func TestNewK8sRelativeKubeConfig(t *testing.T) {
t.Skipf("K8s not available: %v", err) t.Skipf("K8s not available: %v", err)
} }
} }
func TestCreateWithLabels(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer sb.Close()
ctx := context.Background()
labels := map[string]string{
"sandbox-id": "test-123",
"sandbox-owner": "user1",
}
id, err := sb.Create(ctx, CreateOptions{
Name: "tai-label-test",
Image: "alpine:latest",
Cmd: []string{"sleep", "10"},
Labels: labels,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer sb.Remove(ctx, id, true)
if err := sb.Start(ctx, id); err != nil {
t.Fatalf("Start: %v", err)
}
info, err := sb.Inspect(ctx, id)
if err != nil {
t.Fatalf("Inspect: %v", err)
}
for k, v := range labels {
if info.Labels[k] != v {
t.Errorf("label %q = %q, want %q", k, info.Labels[k], v)
}
}
listed, err := sb.List(ctx, ListOptions{
Labels: map[string]string{"sandbox-id": "test-123"},
})
if err != nil {
t.Fatalf("List: %v", err)
}
found := false
for _, c := range listed {
if c.ID == id {
found = true
if c.Labels["sandbox-owner"] != "user1" {
t.Errorf("list labels missing sandbox-owner")
}
}
}
if !found {
t.Error("labeled container not found in filtered list")
}
}
func TestCreateWithUser(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer sb.Close()
ctx := context.Background()
id, err := sb.Create(ctx, CreateOptions{
Name: "tai-user-test",
Image: "alpine:latest",
Cmd: []string{"sleep", "10"},
User: "1000:1000",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer sb.Remove(ctx, id, true)
if err := sb.Start(ctx, id); err != nil {
t.Fatalf("Start: %v", err)
}
result, err := sb.Exec(ctx, id, []string{"id", "-u"}, ExecOptions{})
if err != nil {
t.Fatalf("Exec: %v", err)
}
if result.Stdout != "1000\n" {
t.Errorf("user id = %q, want %q", result.Stdout, "1000\n")
}
}
func TestExecStream_ShortCommand(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer sb.Close()
ctx := context.Background()
id, err := sb.Create(ctx, CreateOptions{
Name: "tai-stream-short",
Image: "alpine:latest",
Cmd: []string{"sleep", "30"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer sb.Remove(ctx, id, true)
if err := sb.Start(ctx, id); err != nil {
t.Fatalf("Start: %v", err)
}
stream, err := sb.ExecStream(ctx, id, []string{"echo", "hello-stream"}, ExecOptions{})
if err != nil {
t.Fatalf("ExecStream: %v", err)
}
out, err := io.ReadAll(stream.Stdout)
if err != nil {
t.Fatalf("ReadAll stdout: %v", err)
}
if string(out) != "hello-stream\n" {
t.Errorf("stdout = %q, want %q", string(out), "hello-stream\n")
}
code, err := stream.Wait()
if err != nil {
t.Fatalf("Wait: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
}
func TestExecStream_Stdin(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer sb.Close()
ctx := context.Background()
id, err := sb.Create(ctx, CreateOptions{
Name: "tai-stream-stdin",
Image: "alpine:latest",
Cmd: []string{"sleep", "30"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer sb.Remove(ctx, id, true)
if err := sb.Start(ctx, id); err != nil {
t.Fatalf("Start: %v", err)
}
stream, err := sb.ExecStream(ctx, id, []string{"cat"}, ExecOptions{})
if err != nil {
t.Fatalf("ExecStream: %v", err)
}
_, err = stream.Stdin.Write([]byte("from-stdin\n"))
if err != nil {
t.Fatalf("Write stdin: %v", err)
}
stream.Stdin.Close()
out, err := io.ReadAll(stream.Stdout)
if err != nil {
t.Fatalf("ReadAll stdout: %v", err)
}
if string(out) != "from-stdin\n" {
t.Errorf("stdout = %q, want %q", string(out), "from-stdin\n")
}
code, err := stream.Wait()
if err != nil {
t.Fatalf("Wait: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
}
func TestExecStream_ExitCode(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer sb.Close()
ctx := context.Background()
id, err := sb.Create(ctx, CreateOptions{
Name: "tai-stream-exit",
Image: "alpine:latest",
Cmd: []string{"sleep", "30"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer sb.Remove(ctx, id, true)
if err := sb.Start(ctx, id); err != nil {
t.Fatalf("Start: %v", err)
}
stream, err := sb.ExecStream(ctx, id, []string{"sh", "-c", "exit 42"}, ExecOptions{})
if err != nil {
t.Fatalf("ExecStream: %v", err)
}
io.ReadAll(stream.Stdout)
code, err := stream.Wait()
if err != nil {
t.Fatalf("Wait: %v", err)
}
if code != 42 {
t.Errorf("exit code = %d, want 42", code)
}
}
func TestExecStream_Stderr(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer sb.Close()
ctx := context.Background()
id, err := sb.Create(ctx, CreateOptions{
Name: "tai-stream-stderr",
Image: "alpine:latest",
Cmd: []string{"sleep", "30"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer sb.Remove(ctx, id, true)
if err := sb.Start(ctx, id); err != nil {
t.Fatalf("Start: %v", err)
}
stream, err := sb.ExecStream(ctx, id, []string{"sh", "-c", "echo err-msg >&2"}, ExecOptions{})
if err != nil {
t.Fatalf("ExecStream: %v", err)
}
stderr, err := io.ReadAll(stream.Stderr)
if err != nil {
t.Fatalf("ReadAll stderr: %v", err)
}
if !strings.Contains(string(stderr), "err-msg") {
t.Errorf("stderr = %q, want to contain %q", string(stderr), "err-msg")
}
code, _ := stream.Wait()
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
}
func TestExecStream_Cancel(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer sb.Close()
ctx := context.Background()
id, err := sb.Create(ctx, CreateOptions{
Name: "tai-stream-cancel",
Image: "alpine:latest",
Cmd: []string{"sleep", "30"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer sb.Remove(ctx, id, true)
if err := sb.Start(ctx, id); err != nil {
t.Fatalf("Start: %v", err)
}
stream, err := sb.ExecStream(ctx, id, []string{"sleep", "300"}, ExecOptions{})
if err != nil {
t.Fatalf("ExecStream: %v", err)
}
stream.Cancel()
done := make(chan struct{})
go func() {
stream.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("Wait did not return after Cancel within 5s")
}
}
func TestParseUID(t *testing.T) {
tests := []struct {
input string
want int64
ok bool
}{
{"1000", 1000, true},
{"1000:1000", 1000, true},
{"0", 0, true},
{"abc", 0, false},
}
for _, tt := range tests {
got, err := parseUID(tt.input)
if tt.ok && err != nil {
t.Errorf("parseUID(%q): unexpected error %v", tt.input, err)
}
if !tt.ok && err == nil {
t.Errorf("parseUID(%q): expected error", tt.input)
}
if tt.ok && got != tt.want {
t.Errorf("parseUID(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}

View file

@ -0,0 +1,195 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v4.25.0
// source: tai/serverinfo/pb/serverinfo.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 GetInfoRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetInfoRequest) Reset() {
*x = GetInfoRequest{}
mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetInfoRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetInfoRequest) ProtoMessage() {}
func (x *GetInfoRequest) ProtoReflect() protoreflect.Message {
mi := &file_tai_serverinfo_pb_serverinfo_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 GetInfoRequest.ProtoReflect.Descriptor instead.
func (*GetInfoRequest) Descriptor() ([]byte, []int) {
return file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP(), []int{0}
}
type GetInfoResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"`
Ports map[string]int32 `protobuf:"bytes,2,rep,name=ports,proto3" json:"ports,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // "grpc", "http", "vnc", "docker", "k8s"
Capabilities map[string]bool `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // "docker", "k8s"
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetInfoResponse) Reset() {
*x = GetInfoResponse{}
mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetInfoResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetInfoResponse) ProtoMessage() {}
func (x *GetInfoResponse) ProtoReflect() protoreflect.Message {
mi := &file_tai_serverinfo_pb_serverinfo_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 GetInfoResponse.ProtoReflect.Descriptor instead.
func (*GetInfoResponse) Descriptor() ([]byte, []int) {
return file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP(), []int{1}
}
func (x *GetInfoResponse) GetVersion() string {
if x != nil {
return x.Version
}
return ""
}
func (x *GetInfoResponse) GetPorts() map[string]int32 {
if x != nil {
return x.Ports
}
return nil
}
func (x *GetInfoResponse) GetCapabilities() map[string]bool {
if x != nil {
return x.Capabilities
}
return nil
}
var File_tai_serverinfo_pb_serverinfo_proto protoreflect.FileDescriptor
const file_tai_serverinfo_pb_serverinfo_proto_rawDesc = "" +
"\n" +
"\"tai/serverinfo/pb/serverinfo.proto\x12\n" +
"serverinfo\"\x10\n" +
"\x0eGetInfoRequest\"\xb7\x02\n" +
"\x0fGetInfoResponse\x12\x18\n" +
"\aversion\x18\x01 \x01(\tR\aversion\x12<\n" +
"\x05ports\x18\x02 \x03(\v2&.serverinfo.GetInfoResponse.PortsEntryR\x05ports\x12Q\n" +
"\fcapabilities\x18\x03 \x03(\v2-.serverinfo.GetInfoResponse.CapabilitiesEntryR\fcapabilities\x1a8\n" +
"\n" +
"PortsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\x1a?\n" +
"\x11CapabilitiesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\bR\x05value:\x028\x012P\n" +
"\n" +
"ServerInfo\x12B\n" +
"\aGetInfo\x12\x1a.serverinfo.GetInfoRequest\x1a\x1b.serverinfo.GetInfoResponseB%Z#github.com/yaoapp/tai/serverinfo/pbb\x06proto3"
var (
file_tai_serverinfo_pb_serverinfo_proto_rawDescOnce sync.Once
file_tai_serverinfo_pb_serverinfo_proto_rawDescData []byte
)
func file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP() []byte {
file_tai_serverinfo_pb_serverinfo_proto_rawDescOnce.Do(func() {
file_tai_serverinfo_pb_serverinfo_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_tai_serverinfo_pb_serverinfo_proto_rawDesc), len(file_tai_serverinfo_pb_serverinfo_proto_rawDesc)))
})
return file_tai_serverinfo_pb_serverinfo_proto_rawDescData
}
var file_tai_serverinfo_pb_serverinfo_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_tai_serverinfo_pb_serverinfo_proto_goTypes = []any{
(*GetInfoRequest)(nil), // 0: serverinfo.GetInfoRequest
(*GetInfoResponse)(nil), // 1: serverinfo.GetInfoResponse
nil, // 2: serverinfo.GetInfoResponse.PortsEntry
nil, // 3: serverinfo.GetInfoResponse.CapabilitiesEntry
}
var file_tai_serverinfo_pb_serverinfo_proto_depIdxs = []int32{
2, // 0: serverinfo.GetInfoResponse.ports:type_name -> serverinfo.GetInfoResponse.PortsEntry
3, // 1: serverinfo.GetInfoResponse.capabilities:type_name -> serverinfo.GetInfoResponse.CapabilitiesEntry
0, // 2: serverinfo.ServerInfo.GetInfo:input_type -> serverinfo.GetInfoRequest
1, // 3: serverinfo.ServerInfo.GetInfo:output_type -> serverinfo.GetInfoResponse
3, // [3:4] is the sub-list for method output_type
2, // [2:3] 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_tai_serverinfo_pb_serverinfo_proto_init() }
func file_tai_serverinfo_pb_serverinfo_proto_init() {
if File_tai_serverinfo_pb_serverinfo_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_tai_serverinfo_pb_serverinfo_proto_rawDesc), len(file_tai_serverinfo_pb_serverinfo_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_tai_serverinfo_pb_serverinfo_proto_goTypes,
DependencyIndexes: file_tai_serverinfo_pb_serverinfo_proto_depIdxs,
MessageInfos: file_tai_serverinfo_pb_serverinfo_proto_msgTypes,
}.Build()
File_tai_serverinfo_pb_serverinfo_proto = out.File
file_tai_serverinfo_pb_serverinfo_proto_goTypes = nil
file_tai_serverinfo_pb_serverinfo_proto_depIdxs = nil
}

View file

@ -0,0 +1,15 @@
syntax = "proto3";
package serverinfo;
option go_package = "github.com/yaoapp/tai/serverinfo/pb";
service ServerInfo {
rpc GetInfo(GetInfoRequest) returns (GetInfoResponse);
}
message GetInfoRequest {}
message GetInfoResponse {
string version = 1;
map<string, int32> ports = 2; // "grpc", "http", "vnc", "docker", "k8s"
map<string, bool> capabilities = 3; // "docker", "k8s"
}

View file

@ -0,0 +1,121 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v4.25.0
// source: tai/serverinfo/pb/serverinfo.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 (
ServerInfo_GetInfo_FullMethodName = "/serverinfo.ServerInfo/GetInfo"
)
// ServerInfoClient is the client API for ServerInfo 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.
type ServerInfoClient interface {
GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error)
}
type serverInfoClient struct {
cc grpc.ClientConnInterface
}
func NewServerInfoClient(cc grpc.ClientConnInterface) ServerInfoClient {
return &serverInfoClient{cc}
}
func (c *serverInfoClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetInfoResponse)
err := c.cc.Invoke(ctx, ServerInfo_GetInfo_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ServerInfoServer is the server API for ServerInfo service.
// All implementations must embed UnimplementedServerInfoServer
// for forward compatibility.
type ServerInfoServer interface {
GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error)
mustEmbedUnimplementedServerInfoServer()
}
// UnimplementedServerInfoServer 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 UnimplementedServerInfoServer struct{}
func (UnimplementedServerInfoServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented")
}
func (UnimplementedServerInfoServer) mustEmbedUnimplementedServerInfoServer() {}
func (UnimplementedServerInfoServer) testEmbeddedByValue() {}
// UnsafeServerInfoServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ServerInfoServer will
// result in compilation errors.
type UnsafeServerInfoServer interface {
mustEmbedUnimplementedServerInfoServer()
}
func RegisterServerInfoServer(s grpc.ServiceRegistrar, srv ServerInfoServer) {
// If the following call panics, it indicates UnimplementedServerInfoServer 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(&ServerInfo_ServiceDesc, srv)
}
func _ServerInfo_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetInfoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ServerInfoServer).GetInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ServerInfo_GetInfo_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ServerInfoServer).GetInfo(ctx, req.(*GetInfoRequest))
}
return interceptor(ctx, in, info, handler)
}
// ServerInfo_ServiceDesc is the grpc.ServiceDesc for ServerInfo service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ServerInfo_ServiceDesc = grpc.ServiceDesc{
ServiceName: "serverinfo.ServerInfo",
HandlerType: (*ServerInfoServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetInfo",
Handler: _ServerInfo_GetInfo_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "tai/serverinfo/pb/serverinfo.proto",
}

View file

@ -1,13 +1,17 @@
package tai package tai
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time"
"github.com/yaoapp/yao/tai/proxy" "github.com/yaoapp/yao/tai/proxy"
"github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/sandbox"
sipb "github.com/yaoapp/yao/tai/serverinfo/pb"
"github.com/yaoapp/yao/tai/vnc" "github.com/yaoapp/yao/tai/vnc"
"github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/tai/workspace" "github.com/yaoapp/yao/tai/workspace"
@ -44,8 +48,12 @@ type Ports struct {
} }
// WithPorts overrides default Tai service ports. // WithPorts overrides default Tai service ports.
// Ports set here take precedence over server-reported values from ServerInfo.
func WithPorts(p Ports) Option { func WithPorts(p Ports) Option {
return optionFunc(func(c *config) { c.ports = p }) return optionFunc(func(c *config) {
c.ports = p
c.userPorts = p
})
} }
// WithHTTPClient sets a custom HTTP client for proxy and VNC health checks. // WithHTTPClient sets a custom HTTP client for proxy and VNC health checks.
@ -69,13 +77,21 @@ func WithNamespace(ns string) Option {
return optionFunc(func(c *config) { c.namespace = ns }) return optionFunc(func(c *config) { c.namespace = ns })
} }
// WithVolume injects a custom Volume implementation.
// Useful for testing workspace operations without Docker.
func WithVolume(vol volume.Volume) Option {
return optionFunc(func(c *config) { c.volume = vol })
}
type config struct { type config struct {
runtime Runtime runtime Runtime
ports Ports ports Ports
userPorts Ports // tracks explicitly set ports (zero = not set by user)
httpClient *http.Client httpClient *http.Client
dataDir string dataDir string
kubeConfig string kubeConfig string
namespace string namespace string
volume volume.Volume // override volume (for testing without Docker)
} }
func defaultPorts() Ports { func defaultPorts() Ports {
@ -112,8 +128,10 @@ type Client struct {
host string host string
addr string addr string
ports Ports ports Ports
dataDir string // host-side data directory for local volume
vol volume.Volume vol volume.Volume
sb sandbox.Sandbox sb sandbox.Sandbox
img sandbox.Image
prx proxy.Proxy prx proxy.Proxy
vc vnc.VNC vc vnc.VNC
grpcConn *grpc.ClientConn grpcConn *grpc.ClientConn
@ -121,9 +139,11 @@ type Client struct {
// New creates a Client based on the address protocol: // New creates a Client based on the address protocol:
// //
// "" → Local mode, platform default Docker socket // "local" → Local mode, platform default Docker socket
// "docker://addr" → Local mode, specified Docker daemon // "docker://addr" → Local mode, specified Docker daemon
// "tai://host" → Remote mode via Tai Server // "tai://host" → Remote mode via Tai Server
//
// Empty string is not allowed — use "local" for default local Docker.
func New(addr string, opts ...Option) (*Client, error) { func New(addr string, opts ...Option) (*Client, error) {
cfg := &config{ports: defaultPorts()} cfg := &config{ports: defaultPorts()}
for _, o := range opts { for _, o := range opts {
@ -131,11 +151,15 @@ func New(addr string, opts ...Option) (*Client, error) {
} }
cfg.ports = mergedPorts(cfg.ports) cfg.ports = mergedPorts(cfg.ports)
scheme, host, dockerAddr, err := parseAddr(addr) scheme, host, dockerAddr, grpcPort, err := parseAddr(addr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if grpcPort > 0 {
cfg.ports.GRPC = grpcPort
}
c := &Client{ c := &Client{
scheme: scheme, scheme: scheme,
host: host, host: host,
@ -155,32 +179,48 @@ func New(addr string, opts ...Option) (*Client, error) {
func (c *Client) initLocal(cfg *config) (*Client, error) { func (c *Client) initLocal(cfg *config) (*Client, error) {
sb, err := sandbox.NewLocal(c.addr) sb, err := sandbox.NewLocal(c.addr)
if err != nil { if err != nil && cfg.volume == nil {
return nil, err return nil, err
} }
if sb != nil {
c.sb = sb c.sb = sb
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
c.prx = proxy.NewLocal(sb) c.prx = proxy.NewLocal(sb)
c.vc = vnc.NewLocal(sb) c.vc = vnc.NewLocal(sb)
}
if cfg.volume != nil {
c.vol = cfg.volume
c.dataDir = cfg.dataDir
} else {
dataDir := cfg.dataDir dataDir := cfg.dataDir
if dataDir == "" { if dataDir == "" {
dataDir = "/tmp/tai-volumes" dataDir = "/tmp/tai-volumes"
} }
c.dataDir = dataDir
c.vol = volume.NewLocal(dataDir) c.vol = volume.NewLocal(dataDir)
}
return c, nil return c, nil
} }
func (c *Client) initRemote(cfg *config) (*Client, error) { func (c *Client) initRemote(cfg *config) (*Client, error) {
// gRPC connection
grpcAddr := fmt.Sprintf("%s:%d", c.host, c.ports.GRPC) grpcAddr := fmt.Sprintf("%s:%d", c.host, c.ports.GRPC)
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
return nil, fmt.Errorf("grpc dial %s: %w", grpcAddr, err) return nil, fmt.Errorf("grpc dial %s: %w", grpcAddr, err)
} }
c.grpcConn = conn c.grpcConn = conn
// Auto-discover server ports via ServerInfo RPC.
// Only overwrite ports that were NOT explicitly set by WithPorts.
if err := c.discoverPorts(conn, cfg); err != nil {
// Non-fatal: fall back to defaults / WithPorts values.
// Old Tai servers without ServerInfo will hit this path.
_ = err
}
c.vol = volume.NewRemote(conn) c.vol = volume.NewRemote(conn)
// Sandbox
switch cfg.runtime { switch cfg.runtime {
case K8s: case K8s:
k8sPort := c.ports.K8s k8sPort := c.ports.K8s
@ -197,6 +237,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
return nil, err return nil, err
} }
c.sb = sb c.sb = sb
c.img = sandbox.NewK8sImage()
default: default:
dockerPort := c.ports.Docker dockerPort := c.ports.Docker
if dockerPort == 0 { if dockerPort == 0 {
@ -209,6 +250,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
return nil, err return nil, err
} }
c.sb = sb c.sb = sb
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
} }
hc := cfg.httpClient hc := cfg.httpClient
@ -244,6 +286,10 @@ func (c *Client) Close() error {
// Volume returns the Volume IO layer. Never nil. // Volume returns the Volume IO layer. Never nil.
func (c *Client) Volume() volume.Volume { return c.vol } func (c *Client) Volume() volume.Volume { return c.vol }
// DataDir returns the host-side data directory used by the local volume.
// Empty for remote (Tai gRPC) connections — the Tai server manages paths.
func (c *Client) DataDir() string { return c.dataDir }
// Workspace returns an fs.FS-compatible filesystem for the given session. // Workspace returns an fs.FS-compatible filesystem for the given session.
func (c *Client) Workspace(sessionID string) workspace.FS { func (c *Client) Workspace(sessionID string) workspace.FS {
return workspace.New(c.vol, sessionID) return workspace.New(c.vol, sessionID)
@ -252,6 +298,9 @@ func (c *Client) Workspace(sessionID string) workspace.FS {
// Sandbox returns the container lifecycle manager. Never nil. // Sandbox returns the container lifecycle manager. Never nil.
func (c *Client) Sandbox() sandbox.Sandbox { return c.sb } func (c *Client) Sandbox() sandbox.Sandbox { return c.sb }
// Image returns the container image manager. Never nil.
func (c *Client) Image() sandbox.Image { return c.img }
// Proxy returns the HTTP reverse proxy helper. Never nil. // Proxy returns the HTTP reverse proxy helper. Never nil.
func (c *Client) Proxy() proxy.Proxy { return c.prx } func (c *Client) Proxy() proxy.Proxy { return c.prx }
@ -261,41 +310,100 @@ func (c *Client) VNC() vnc.VNC { return c.vc }
// IsLocal returns true if the client connects directly to a Docker daemon. // IsLocal returns true if the client connects directly to a Docker daemon.
func (c *Client) IsLocal() bool { return c.scheme == "docker" } func (c *Client) IsLocal() bool { return c.scheme == "docker" }
func parseAddr(addr string) (scheme, host, dockerAddr string, err error) { func parseAddr(addr string) (scheme, host, dockerAddr string, grpcPort int, err error) {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
if addr == "" { if addr == "" {
return "docker", "", "", nil return "", "", "", 0, fmt.Errorf("empty address: use \"local\" for default Docker daemon")
}
if addr == "local" {
return "docker", "", "", 0, nil
}
// Bare IP or host(:port) without scheme → normalise before url.Parse,
// which misparses bare addresses (treats them as path, not host).
if !strings.Contains(addr, "://") {
if isLocalHost(addr) {
return "docker", "", "", 0, nil
}
// host:port — split carefully (IPv6 like [::1]:9100 is already handled above)
h := addr
if idx := strings.LastIndex(addr, ":"); idx > 0 {
h = addr[:idx]
}
if isLocalHost(h) {
return "docker", "", "", 0, nil
}
addr = "tai://" + addr
} }
u, parseErr := url.Parse(addr) u, parseErr := url.Parse(addr)
if parseErr != nil { if parseErr != nil {
return "", "", "", fmt.Errorf("parse addr %q: %w", addr, parseErr) return "", "", "", 0, fmt.Errorf("parse addr %q: %w", addr, parseErr)
} }
switch u.Scheme { switch u.Scheme {
case "tai": case "tai":
host = u.Host hostname := u.Hostname()
if host == "" { if hostname == "" {
return "", "", "", fmt.Errorf("tai:// requires a host") return "", "", "", 0, fmt.Errorf("tai:// requires a host")
} }
if idx := strings.Index(host, ":"); idx >= 0 { if portStr := u.Port(); portStr != "" {
host = host[:idx] if p, convErr := strconv.Atoi(portStr); convErr == nil && p > 0 {
grpcPort = p
} }
return "tai", host, "", nil }
return "tai", hostname, "", grpcPort, nil
case "docker": case "docker":
return "docker", "", addr, nil return "docker", "", addr, 0, nil
case "unix": case "unix":
return "docker", "", addr, nil return "docker", "", addr, 0, nil
case "tcp": case "tcp":
return "docker", "", addr, nil return "docker", "", addr, 0, nil
case "npipe": case "npipe":
return "docker", "", addr, nil return "docker", "", addr, 0, nil
default: default:
return "", "", "", fmt.Errorf("unsupported scheme %q in addr %q", u.Scheme, addr) return "", "", "", 0, fmt.Errorf("unsupported scheme %q in addr %q", u.Scheme, addr)
} }
} }
func isLocalHost(h string) bool {
return h == "127.0.0.1" || h == "localhost" || h == "::1"
}
// discoverPorts calls ServerInfo.GetInfo on the remote Tai server and merges
// discovered ports into c.ports. Ports explicitly set via WithPorts (non-zero
// in the original config before merging defaults) take precedence.
func (c *Client) discoverPorts(conn *grpc.ClientConn, cfg *config) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := sipb.NewServerInfoClient(conn)
resp, err := client.GetInfo(ctx, &sipb.GetInfoRequest{})
if err != nil {
return err
}
// cfg.userPorts tracks what the caller explicitly passed to WithPorts.
// Only overwrite ports that the caller did NOT explicitly set.
up := cfg.userPorts
if p := int(resp.Ports["http"]); p > 0 && up.HTTP == 0 {
c.ports.HTTP = p
}
if p := int(resp.Ports["docker"]); p > 0 && up.Docker == 0 {
c.ports.Docker = p
}
if p := int(resp.Ports["vnc"]); p > 0 && up.VNC == 0 {
c.ports.VNC = p
}
if p := int(resp.Ports["k8s"]); p > 0 && up.K8s == 0 {
c.ports.K8s = p
}
return nil
}

View file

@ -1,6 +1,7 @@
package tai package tai
import ( import (
"fmt"
"os" "os"
"strconv" "strconv"
"testing" "testing"
@ -28,24 +29,34 @@ func TestParseAddr(t *testing.T) {
wantScheme string wantScheme string
wantHost string wantHost string
wantDocker string wantDocker string
wantGRPCPort int
wantErr bool wantErr bool
}{ }{
{"", "docker", "", "", false}, {"", "", "", "", 0, true},
{"docker:///var/run/docker.sock", "docker", "", "docker:///var/run/docker.sock", false}, {"local", "docker", "", "", 0, false},
{"docker://192.168.1.50:2375", "docker", "", "docker://192.168.1.50:2375", false}, {"127.0.0.1", "docker", "", "", 0, false},
{"unix:///var/run/docker.sock", "docker", "", "unix:///var/run/docker.sock", false}, {"localhost", "docker", "", "", 0, false},
{"tcp://127.0.0.1:2375", "docker", "", "tcp://127.0.0.1:2375", false}, {"::1", "docker", "", "", 0, false},
{"npipe:////./pipe/docker_engine", "docker", "", "npipe:////./pipe/docker_engine", false}, {"docker:///var/run/docker.sock", "docker", "", "docker:///var/run/docker.sock", 0, false},
{"tai://192.168.1.100", "tai", "192.168.1.100", "", false}, {"docker://192.168.1.50:2375", "docker", "", "docker://192.168.1.50:2375", 0, false},
{"tai://10.0.0.5:9100", "tai", "10.0.0.5", "", false}, {"unix:///var/run/docker.sock", "docker", "", "unix:///var/run/docker.sock", 0, false},
{"tai://", "", "", "", true}, {"tcp://127.0.0.1:2375", "docker", "", "tcp://127.0.0.1:2375", 0, false},
{"ftp://host", "", "", "", true}, {"npipe:////./pipe/docker_engine", "docker", "", "npipe:////./pipe/docker_engine", 0, false},
{" tai://host ", "tai", "host", "", false}, {"tai://192.168.1.100", "tai", "192.168.1.100", "", 0, false},
{"tai://10.0.0.5:9200", "tai", "10.0.0.5", "", 9200, false},
{"tai://", "", "", "", 0, true},
{"ftp://host", "", "", "", 0, true},
{" tai://host ", "tai", "host", "", 0, false},
// Bare non-local host → auto-prepend tai://
{"192.168.1.50", "tai", "192.168.1.50", "", 0, false},
{"192.168.1.50:9200", "tai", "192.168.1.50", "", 9200, false},
{"my-server", "tai", "my-server", "", 0, false},
{"my-server:9200", "tai", "my-server", "", 9200, false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.addr, func(t *testing.T) { t.Run(tt.addr, func(t *testing.T) {
scheme, host, dockerAddr, err := parseAddr(tt.addr) scheme, host, dockerAddr, grpcPort, err := parseAddr(tt.addr)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
} }
@ -61,6 +72,9 @@ func TestParseAddr(t *testing.T) {
if dockerAddr != tt.wantDocker { if dockerAddr != tt.wantDocker {
t.Errorf("dockerAddr = %q, want %q", dockerAddr, tt.wantDocker) t.Errorf("dockerAddr = %q, want %q", dockerAddr, tt.wantDocker)
} }
if grpcPort != tt.wantGRPCPort {
t.Errorf("grpcPort = %d, want %d", grpcPort, tt.wantGRPCPort)
}
}) })
} }
} }
@ -98,6 +112,9 @@ func TestOptions(t *testing.T) {
if cfg.ports.HTTP != 9999 { if cfg.ports.HTTP != 9999 {
t.Errorf("WithPorts: HTTP = %d", cfg.ports.HTTP) t.Errorf("WithPorts: HTTP = %d", cfg.ports.HTTP)
} }
if cfg.userPorts.HTTP != 9999 {
t.Errorf("WithPorts: userPorts.HTTP = %d", cfg.userPorts.HTTP)
}
WithDataDir("/data").apply(cfg) WithDataDir("/data").apply(cfg)
if cfg.dataDir != "/data" { if cfg.dataDir != "/data" {
@ -116,8 +133,15 @@ func TestOptions(t *testing.T) {
} }
} }
func TestNewEmptyAddr(t *testing.T) {
_, err := New("")
if err == nil {
t.Error("expected error for empty addr")
}
}
func TestNewLocal(t *testing.T) { func TestNewLocal(t *testing.T) {
c, err := New("") c, err := New("local")
if err != nil { if err != nil {
t.Skipf("Docker not available: %v", err) t.Skipf("Docker not available: %v", err)
} }
@ -148,7 +172,7 @@ func TestNewLocal(t *testing.T) {
func TestNewLocalWithDataDir(t *testing.T) { func TestNewLocalWithDataDir(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
c, err := New("", WithDataDir(dir)) c, err := New("local", WithDataDir(dir))
if err != nil { if err != nil {
t.Skipf("Docker not available: %v", err) t.Skipf("Docker not available: %v", err)
} }
@ -178,14 +202,15 @@ func TestNewRemoteK8s(t *testing.T) {
t.Skip("TAI_TEST_K8S_HOST or TAI_TEST_KUBECONFIG not set") t.Skip("TAI_TEST_K8S_HOST or TAI_TEST_KUBECONFIG not set")
} }
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100))
ports := Ports{ ports := Ports{
K8s: envPort("TAI_TEST_K8S_PORT", 6443), K8s: envPort("TAI_TEST_K8S_PORT", 6443),
GRPC: envPort("TAI_TEST_GRPC_PORT", 9100), GRPC: grpcPort,
HTTP: envPort("TAI_TEST_HTTP_PORT", 8080), HTTP: envPort("TAI_TEST_K8S_HTTP_PORT", 8080),
VNC: envPort("TAI_TEST_VNC_PORT", 6080), VNC: envPort("TAI_TEST_K8S_VNC_PORT", 6080),
} }
c, err := New("tai://"+host, K8s, c, err := New(fmt.Sprintf("tai://%s:%d", host, grpcPort), K8s,
WithPorts(ports), WithPorts(ports),
WithKubeConfig(kubeconfig), WithKubeConfig(kubeconfig),
WithNamespace("default"), WithNamespace("default"),
@ -258,11 +283,39 @@ func TestNewRemoteDocker(t *testing.T) {
} }
} }
func TestNewRemoteWithPorts(t *testing.T) { func TestDiscoverPorts(t *testing.T) {
addr := "tai://" + taiTestHost() addr := "tai://" + taiTestHost()
c, err := New(addr, WithPorts(Ports{HTTP: 8888})) c, err := New(addr)
if err != nil { if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err) t.Skipf("Tai not available at %s: %v", addr, err)
} }
defer c.Close() defer c.Close()
t.Logf("client resolved: GRPC=%d HTTP=%d VNC=%d Docker=%d K8s=%d",
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker, c.ports.K8s)
if c.ports.GRPC == 0 {
t.Error("GRPC port should be discovered (non-zero)")
}
if c.ports.HTTP == 0 {
t.Error("HTTP port should be discovered (non-zero)")
}
}
func TestDiscoverPortsWithUserOverride(t *testing.T) {
addr := "tai://" + taiTestHost()
c, err := New(addr, WithPorts(Ports{HTTP: 9999}))
if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err)
}
defer c.Close()
if c.ports.HTTP != 9999 {
t.Errorf("HTTP = %d, want 9999 (user override should take precedence)", c.ports.HTTP)
}
if c.ports.GRPC == 0 {
t.Error("GRPC port should still be discovered (non-zero)")
}
t.Logf("ports: GRPC=%d HTTP=%d(user) VNC=%d Docker=%d",
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker)
} }

View file

@ -202,6 +202,9 @@ func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error {
func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) { func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.StreamHandle, error) {
return nil, nil
}
func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
if m.inspectFn != nil { if m.inspectFn != nil {
return m.inspectFn(ctx, id) return m.inspectFn(ctx, id)

600
workspace/DESIGN.md Normal file
View file

@ -0,0 +1,600 @@
# Workspace Design Document
> **Status**: Draft
> **Module**: `workspace` (top-level, parallel to `sandbox/v2`)
> **Depends on**: `tai` SDK (Volume, VolumeProvider, Sandbox), `sandbox/v2` Manager
---
## Overview
Workspace is a **first-class, persistent storage entity** independent of containers, chat sessions, and user sessions. It represents a user's project files — source code, configs, build artifacts — that can be mounted into any number of ephemeral containers.
Workspace is the **anchor point** for container scheduling: when a Workspace is created on a specific Tai node (host machine), all subsequent containers that reference it are automatically routed to the same node, because bind mounts require co-location on the same physical host.
---
## Problem
Current design: `Box.Workspace()` returns `workspace.FS` keyed by `box.id` — workspace and container are 1:1, same lifecycle. This couples file storage to container lifetime.
Real usage pattern:
```
User creates a project → uploads files → works on it across multiple chat sessions
→ attaches a long-running dev server → destroys/rebuilds containers freely
→ project files must survive all of this
```
Workspace must outlive containers. It is the persistent artifact; containers are disposable compute.
---
## Architecture
```
┌──────────────────────────────────────────────────┐
│ Application Layer │
│ │
│ Workspace Management UI Chat Interface │
│ ┌─────────────────────┐ ┌─────────────────┐ │
│ │ Create / Delete / UI │ │ Select Workspace│ │
│ │ Browse / Upload │ │ Start Chat │ │
│ └─────────┬───────────┘ └────────┬────────┘ │
│ │ │ │
└────────────┼─────────────────────────┼────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ Yao Engine │
│ │
│ workspace.Manager sandbox.Manager │
│ ┌────────────────┐ ┌─────────────────┐ │
│ │ CRUD │◄────────│ Mount workspace │ │
│ │ File I/O │ │ Route to node │ │
│ │ Node binding │ │ Create container│ │
│ └────────┬───────┘ └────────┬────────┘ │
│ │ │ │
└───────────┼───────────────────────────┼───────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ Tai Node (Host) │
│ │
│ Volume gRPC Container Runtime │
│ ┌──────────────┐ ┌─────────────────────┐ │
│ │ ReadFile │ │ Container A (rw) │ │
│ │ WriteFile │ │ └─ /workspace ─┐ │ │
│ │ ListDir │ │ │ │ │
│ │ SyncPush/Pull │ │ Container B (ro) │ │ │
│ └──────┬───────┘ │ └─ /workspace ─┐│ │ │
│ │ └────────────────┼┼───┘ │
│ │ ││ │
│ ▼ ▼▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ /data/ws/{workspace-id}/ │ │
│ │ ├── .workspace.json (metadata) │ │
│ │ ├── src/ │ │
│ │ ├── package.json │ │
│ │ └── ... │ │
│ └──────────────────────────────────────────┘ │
│ │
│ VolumeProvider │
│ ┌─────────────┬──────────────┬──────────────┐ │
│ │ BindMount │ DockerVolume │ K8s PVC │ │
│ │ (default) │ │ │ │
│ └─────────────┴──────────────┴──────────────┘ │
└───────────────────────────────────────────────────┘
```
---
## Core Design
### Node Binding
Workspace is physically stored on a Tai node's disk. **Bind mount requires Workspace and container to be on the same host.** Therefore:
- **Workspace binds to a specific Tai node at creation time.** This binding is immutable.
- When a container references a Workspace (`CreateOptions.WorkspaceID`), the container is **automatically routed to the same Tai node** — the caller does not (and should not) specify a Pool.
- One Tai node = one Pool = one host machine. These are equivalent in the current architecture.
```
创建 Workspace:
用户选择节点 "gpu-server" → workspace.Create(opts)
→ Tai "gpu-server" 上创建 /data/ws/ws-123/
创建容器(选了 Workspace:
→ sandbox.Create(opts, WorkspaceID: "ws-123")
→ Manager 查到 ws-123 绑在 "gpu-server"
→ 自动路由到 "gpu-server" Pool
→ bind mount /data/ws/ws-123:/workspace:rw ✓ 同机
创建容器(没选 Workspace:
→ 按原逻辑选 Pool用户指定或默认
```
This makes Workspace the **scheduling anchor**: once a Workspace is chosen, the node is determined.
### Workspace struct
```go
type Workspace struct {
ID string // unique identifier, e.g. "ws-abc123"
Name string // human-readable, e.g. "my-react-app"
Owner string // user ID
Node string // Tai node name (= Pool name); set at creation, immutable
Labels map[string]string // arbitrary metadata
CreatedAt time.Time
UpdatedAt time.Time
}
```
`Node` is the critical field: it pins this Workspace to a specific machine. All container operations referencing this Workspace are routed to this node.
No container references stored here. Workspace is pure storage — it doesn't know or care about containers.
### MountMode
```go
type MountMode string
const (
MountRW MountMode = "rw" // read-write (default)
MountRO MountMode = "ro" // read-only
)
```
Rules:
- A Workspace can be mounted by multiple containers simultaneously
- Each mount independently specifies `rw` or `ro`
- No write-lock enforcement — caller manages concurrency
- Default is `rw`
Rationale: In practice, Chat containers write source code and Runtime containers write build artifacts/logs — different files, no real conflict. Enforcing locks adds complexity without solving a real problem in this use case.
---
## API Design
### workspace.Manager
Workspace has its own manager, separate from `sandbox.Manager`. It owns Workspace CRUD and file I/O.
```go
package workspace
type Manager struct {
pools map[string]*tai.Client // node name → tai client (shared with sandbox.Manager)
}
// NewManager creates a workspace manager with the given pools.
// Pools are shared with sandbox.Manager — both reference the same tai.Client instances.
func NewManager(pools map[string]*tai.Client) *Manager
```
### Workspace CRUD
```go
type CreateOptions struct {
ID string // explicit ID; empty = auto-generate (uuid)
Name string // human-readable name
Owner string // user ID
Node string // target Tai node (required)
Labels map[string]string
}
type ListOptions struct {
Owner string // filter by owner; empty = all
Node string // filter by node; empty = all
}
// Create allocates storage on the target node and persists metadata.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error)
// Get returns a workspace by ID.
// Checks the metadata file on the bound node.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error)
// List returns workspaces, optionally filtered.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error)
// Delete removes workspace storage from the node.
// Fails if containers currently mount it (unless force=true).
func (m *Manager) Delete(ctx context.Context, id string, force bool) error
// Update modifies workspace metadata (Name, Labels).
// Node and Owner are immutable after creation.
func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error)
type UpdateOptions struct {
Name *string // nil = no change
Labels map[string]string // nil = no change; non-nil replaces all
}
```
### File I/O (no container needed)
File operations go through the Tai `Volume` gRPC service, using the Workspace ID as the session identifier. No container is needed.
```go
// FS returns an fs.FS view of the workspace, backed by Tai Volume gRPC.
func (m *Manager) FS(ctx context.Context, id string) (workspace.FS, error)
// ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error)
// WriteFile writes a file to the workspace.
func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error
// ListDir lists entries in a workspace directory.
func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error)
// Remove deletes a file or directory from the workspace.
func (m *Manager) Remove(ctx context.Context, id string, path string) error
// SyncPush uploads a local directory tree to the workspace.
func (m *Manager) SyncPush(ctx context.Context, id string, localPath string) error
// SyncPull downloads the workspace to a local directory.
func (m *Manager) SyncPull(ctx context.Context, id string, localPath string) error
```
These are thin wrappers around `tai.Client.Volume().{ReadFile,WriteFile,ListDir,...}` — the Tai SDK already implements all of these.
---
## Integration with Sandbox
### sandbox.CreateOptions changes
```go
type CreateOptions struct {
// ... existing fields ...
WorkspaceID string // workspace to mount; empty = no workspace
MountMode MountMode // "rw" (default) or "ro"
MountPath string // container path; default "/workspace"
}
```
### Container creation flow
When `WorkspaceID` is set in `CreateOptions`, the sandbox Manager:
```
Manager.Create(ctx, CreateOptions{
Image: "yaoapp/workspace:latest",
WorkspaceID: "ws-abc123",
MountMode: MountRW,
})
1. Validate CreateOptions (image required, etc.)
2. If WorkspaceID is set:
a. ws := workspaceManager.Get(ctx, workspaceID)
b. Force Pool = ws.Node (override any user-specified Pool)
c. spec := taiClient.VolumeProvider().MountSpec(workspaceID)
d. Inject mount into container create:
- Docker: opts.Binds = ["/data/ws/ws-abc123:/workspace:rw"]
- K8s: opts.Volumes + opts.VolumeMounts (PVC)
3. Create container via tai.Client.Sandbox().Create()
4. Start container
5. Return Box
```
### Box.Workspace() behavior change
```go
func (b *Box) Workspace() workspace.FS {
sessionID := b.workspaceID
if sessionID == "" {
sessionID = b.id // backward compatible
}
client, _ := b.manager.getPool(b.pool)
return client.Workspace(sessionID)
}
```
Multiple boxes mounting the same workspace -> same `sessionID` -> same files via Volume API.
---
## Metadata Storage
Workspace metadata (ID, Name, Owner, Node, Labels, timestamps) is stored as a JSON file inside the workspace directory.
### Storage path
```
/data/ws/{id}/.workspace.json
```
### Schema
```json
{
"id": "ws-abc123",
"name": "my-react-app",
"owner": "user-001",
"node": "gpu-server",
"labels": {"project": "frontend"},
"created_at": "2026-03-05T10:00:00Z",
"updated_at": "2026-03-05T12:30:00Z"
}
```
### Operations
| Operation | Implementation |
|-----------|---------------|
| Create | `Volume.WriteFile(id, ".workspace.json", json)` + `Volume.ResolvePath(id)` |
| Get | `Volume.ReadFile(id, ".workspace.json")` → unmarshal |
| List | `Volume.ListDir("")` → iterate dirs → read `.workspace.json` each |
| Update | Read → merge → `Volume.WriteFile(id, ".workspace.json", json)` |
| Delete | `Volume.Cleanup(id)` (removes entire dir) |
Phase 1 strategy: simple JSON files, zero external dependencies. Can migrate to SQLite or Yao's built-in DB if query/filter performance becomes a bottleneck.
---
## Node Management
### Listing available nodes
Application layer needs to present available nodes when user creates a Workspace. This comes from the sandbox Manager's pool configuration:
```go
// In workspace.Manager or sandbox.Manager
func (m *Manager) Nodes() []NodeInfo
type NodeInfo struct {
Name string // pool name = node name, e.g. "gpu-server"
Addr string // tai:// address
Online bool // is tai client connected
// Can be extended with capacity info later
}
```
### Dynamic node configuration
Nodes are configured at the application level (Yao settings/config). When a node is added or removed, both `workspace.Manager` and `sandbox.Manager` share the updated pool map. The Pool configuration API (from `sandbox/v2`) handles this — Workspace inherits it.
```
Application Config:
nodes:
- name: "local"
addr: "tai://localhost"
- name: "gpu-server"
addr: "tai://192.168.1.100:9527"
→ Both managers share:
pools["local"] = tai.Client("tai://localhost")
pools["gpu-server"] = tai.Client("tai://192.168.1.100:9527")
```
### Node failure handling
If a Tai node goes offline:
- Workspace CRUD for that node: returns error (node unreachable)
- Container creation referencing a Workspace on that node: returns error
- Workspaces on that node are not lost — data is still on the node's disk, will be available when node comes back online
- No automatic migration (Phase 1). Can add migration (rsync between nodes) later if needed.
---
## User Flows
### Flow 1: Workspace management UI
```
1. User opens Workspace management UI
→ API: workspace.List(owner: "user-001")
→ Returns list of workspaces with metadata
2. User creates workspace
→ UI shows available nodes (from Nodes() API)
→ User selects "gpu-server"
→ API: workspace.Create({ name: "my-project", node: "gpu-server" })
→ Directory /data/ws/ws-123/ created on gpu-server
→ .workspace.json written
3. User uploads files
→ API: workspace.WriteFile("ws-123", "src/main.go", data)
→ File written to /data/ws/ws-123/src/main.go via Volume gRPC
4. User browses files
→ API: workspace.ListDir("ws-123", "src/")
→ Returns file listing
5. User deletes workspace
→ API: workspace.Delete("ws-123")
→ Checks no active mounts → removes /data/ws/ws-123/
```
### Flow 2: Chat with Workspace
```
1. User opens Chat
→ Chat UI shows workspace selector
→ User picks "my-project" (ws-123, on node "gpu-server")
2. Agent needs a container:
→ sandbox.Create({
image: "yaoapp/workspace:latest",
workspace_id: "ws-123",
mount_mode: "rw",
})
→ Manager resolves ws-123.node = "gpu-server"
→ Container created on "gpu-server" Pool
→ -v /data/ws/ws-123:/workspace:rw
→ Agent can exec "ls /workspace/src/" inside container
3. Chat ends, container destroyed
→ Workspace files persist in /data/ws/ws-123/
4. User opens new Chat, selects same workspace
→ New container, same workspace, all files still there
```
### Flow 3: Long-running Runtime + Chat
```
1. User starts Runtime container for workspace:
→ sandbox.Create({
image: "node:20",
workspace_id: "ws-123",
mount_mode: "rw",
policy: "persistent",
ports: [{ container: 3000 }],
})
→ Container starts on "gpu-server"
→ -v /data/ws/ws-123:/workspace:rw
→ Inside: cd /workspace && npm install && npm run dev
2. User accesses dev server via proxy
→ box.Proxy(ctx, 3000, "/")
3. User opens Chat with same workspace:
→ Second container created on "gpu-server"
→ Same workspace mounted
→ Agent modifies source → Runtime hot-reloads
4. Chat ends, chat container destroyed
→ Runtime container keeps running
→ Workspace files persist
```
---
## Process & JSAPI
### Process registration
| Process | Args | Returns |
|---------|------|---------|
| `workspace.Create` | `options` (CreateOptions JSON) | Workspace |
| `workspace.Get` | `id` | Workspace |
| `workspace.List` | `options` (ListOptions JSON) | []Workspace |
| `workspace.Update` | `id`, `options` (UpdateOptions JSON) | Workspace |
| `workspace.Delete` | `id`, `force?` | — |
| `workspace.ReadFile` | `id`, `path` | file content |
| `workspace.WriteFile` | `id`, `path`, `data` | — |
| `workspace.ListDir` | `id`, `path` | []DirEntry |
| `workspace.Remove` | `id`, `path` | — |
| `workspace.Nodes` | — | []NodeInfo |
### JSAPI
```javascript
// Workspace CRUD
var ws = Workspace.Create({ name: "my-project", node: "gpu-server" })
var ws = Workspace.Get("ws-abc123")
var list = Workspace.List({ owner: "user-001" })
Workspace.Update("ws-abc123", { name: "new-name" })
Workspace.Delete("ws-abc123")
// File operations (no container needed)
var data = Workspace.ReadFile("ws-abc123", "src/main.go")
Workspace.WriteFile("ws-abc123", "src/main.go", "package main\n...")
var entries = Workspace.ListDir("ws-abc123", "src/")
Workspace.Remove("ws-abc123", "tmp.txt")
// List available nodes
var nodes = Workspace.Nodes()
// → [{ name: "local", addr: "tai://localhost", online: true },
// { name: "gpu-server", addr: "tai://192.168.1.100:9527", online: true }]
// Create container with workspace (via Sandbox API)
var sb = Sandbox("my-box", {
image: "node:20",
workspace_id: ws.id, // → auto-routes to ws.node
mount_mode: "rw",
})
```
---
## Storage Backend (Tai)
The `storage.VolumeProvider` interface in Tai Server already has three implementations:
```go
// tai/storage/provider.go
type VolumeProvider interface {
ResolvePath(sessionID string) (string, error)
MountSpec(sessionID string) MountConfig
Cleanup(sessionID string) error
}
type MountConfig struct {
Type string // "bind" | "volume" | "pvc"
Source string
Target string // always /workspace
}
```
| Provider | Backend | MountSpec | Status |
|----------|---------|-----------|--------|
| `BindMountProvider` | Host directory (`/data/ws/{id}/`) | `type:"bind"` | Implemented, default |
| `DockerVolumeProvider` | Docker named volume (`tai-{id}`) | `type:"volume"` | Implemented |
| `K8sPVCProvider` | K8s PVC (`tai-{id}-pvc`, 10Gi RWO) | `type:"pvc"` | Implemented |
Default is `BindMountProvider` for Docker environments (direct host path access for file CRUD). K8s environments use `K8sPVCProvider`.
The Tai `Volume` gRPC service (`ReadFile`, `WriteFile`, `ListDir`, etc.) already operates on the same `dataDir/{sessionID}/` paths. No additional work needed — Workspace file operations reuse existing Volume gRPC endpoints.
---
## Comparison: Before vs After
| Aspect | Before | After |
|--------|--------|-------|
| Workspace lifecycle | Tied to Box (same ID, same lifetime) | Independent entity, outlives containers |
| Workspace identity | `sessionID = box.id` | `sessionID = workspace.id` (explicit) |
| Container ↔ Workspace | 1:1, implicit | N:1, explicit via `CreateOptions.WorkspaceID` |
| Container scheduling | User picks Pool | Workspace determines Pool (node binding) |
| File persistence | Lost when container removed | Persists until workspace deleted |
| Multi-container access | Not possible | Multiple containers mount same workspace |
| Storage backend | Volume gRPC only (no mount) | Volume gRPC + bind mount into container |
| CRUD without container | Not possible | Via Volume API directly |
| Module status | Part of sandbox/v2 | Top-level module, parallel to sandbox/v2 |
---
## Implementation Plan
### Phase 1: Core (target: week 1-2)
| Task | Detail |
|------|--------|
| `workspace/workspace.go` | Workspace struct, MountMode, CreateOptions, metadata JSON read/write |
| `workspace/manager.go` | Manager with CRUD + file I/O (thin wrapper over tai Volume) |
| `workspace/manager_test.go` | Unit tests for CRUD and file operations |
| Node binding | `Workspace.Node` field, `Nodes()` API |
| `sandbox/v2` integration | `CreateOptions.WorkspaceID` → resolve node → force Pool → inject mount |
| `Box.Workspace()` update | Use `workspaceID` as sessionID when set |
### Phase 2: Wire into Tai (target: week 2-3)
| Task | Detail |
|------|--------|
| Tai Server: `VolumeProvider.MountSpec()` | Wire into container creation path |
| Tai gRPC: workspace metadata endpoints | Optional — can use Volume gRPC directly for Phase 1 |
| Process + JSAPI registration | `workspace.*` processes, JS bindings |
### Phase 3: Advanced (target: week 3+)
| Task | Detail |
|------|--------|
| Active mount tracking | Track which containers mount which workspaces |
| Delete safety | Refuse delete if active mounts exist |
| Workspace migration | rsync between nodes (stretch goal) |
| Quota / size limits | Per-workspace storage limits |
| Snapshot / backup | Workspace snapshots for rollback |
### Backward Compatibility
No breaking changes. Containers created without `WorkspaceID` work exactly as before:
- `sessionID = box.id`
- No bind mount
- Workspace FS backed by Volume gRPC as today

36
workspace/Makefile Normal file
View file

@ -0,0 +1,36 @@
GO ?= go
TEST_TIMEOUT ?= 120s
.PHONY: test test-v test-cover test-race
test:
$(GO) test -timeout=$(TEST_TIMEOUT) -count=1 ./...
test-v:
$(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 ./...
test-cover:
$(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 \
-coverprofile=coverage.out -covermode=count ./...
$(GO) tool cover -func=coverage.out | tail -1
test-race:
$(GO) test -race -v -timeout=$(TEST_TIMEOUT) -count=1 ./...
test-ci:
@echo "mode: count" > coverage.out
@for d in $$($(GO) list ./...); do \
$(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 \
-covermode=count -coverprofile=profile.out \
-coverpkg=$$d $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm -f tmp.out profile.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
grep -v "mode:" profile.out >> coverage.out; \
rm profile.out; \
fi; \
rm -f tmp.out; \
done

74
workspace/TEST.md Normal file
View file

@ -0,0 +1,74 @@
# Workspace — Test Specification
Design: [DESIGN.md](./DESIGN.md)
## Principles
- **Black-box testing**: all `*_test.go` files use `package workspace_test` — tests only access exported API
- **No Docker required**: workspace unit tests use `volume.NewLocal(t.TempDir())` via `tai.WithVolume` — no Docker daemon needed
- **Skip when unavailable**: `skipIfNoTai(t)` for remote-mode tests
- **Tests follow implementation**: `*_test.go` lives next to the code it tests
- **Coverage > 80%**: per file and overall
## Prerequisites
No external services required for unit tests. Tests create a temp directory for storage.
### Remote mode (optional)
For remote-mode tests via Tai gRPC:
```bash
SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 go test -v ./workspace/
```
## Directory Structure
```
workspace/
├── workspace.go # Types (Workspace, CreateOptions, MountMode, etc.)
├── errors.go # Error definitions
├── manager.go # Manager (CRUD, file I/O, Nodes)
├── workspace_test.go # CRUD tests
├── fileio_test.go # File I/O + FS tests
├── testutils_test.go # Shared test helpers
├── DESIGN.md # Design document
├── TEST.md # This file
└── Makefile # Test runner
```
## testutils (internal to workspace_test)
```go
// testutils_test.go
package workspace_test
func setupManager(t *testing.T) *workspace.Manager
func setupManagerMultiNode(t *testing.T) *workspace.Manager
func localClient(t *testing.T, dataDir string) *tai.Client
func createTestWorkspace(t *testing.T, m *workspace.Manager, opts ...func(*workspace.CreateOptions)) *workspace.Workspace
func skipIfNoTai(t *testing.T)
```
## Required Test Cases
| File | Required Cases |
|------|---------------|
| `workspace_test.go` | Create / Create auto ID / Create explicit ID / Create with labels / Create invalid node / Create node not found / Get / Get not found / List / List filter owner / List filter node / Update name / Update labels / Update not found / Delete / Delete not found / Nodes / NodeForWorkspace / NodeForWorkspace not found |
| `fileio_test.go` | ReadWriteFile / WriteFile nested path / ListDir / Remove file / FS ReadFile / FS WriteFile / FS MkdirAll / FS Rename / FS WalkDir / FS Remove / FS not found |
## Running Tests
```bash
# All workspace tests (no Docker needed)
make -C workspace test
# Single test
go test -v ./workspace/ -run TestCreate
# With race detector
go test -race -v ./workspace/
# With coverage
go test -v -coverprofile=coverage.out ./workspace/
```

199
workspace/bench_test.go Normal file
View file

@ -0,0 +1,199 @@
package workspace_test
import (
"context"
"fmt"
"io/fs"
"testing"
"github.com/yaoapp/yao/workspace"
)
// BenchmarkWriteFile measures workspace file write latency.
func BenchmarkWriteFile(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
payload := []byte("package main\nfunc main() { println(\"bench\") }\n")
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := m.WriteFile(ctx, ws.ID, fmt.Sprintf("f%d.go", i), payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
}
})
}
}
// BenchmarkReadFile measures workspace file read latency.
func BenchmarkReadFile(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
if err := m.WriteFile(ctx, ws.ID, "bench.txt", []byte("benchmark data here"), 0644); err != nil {
b.Fatalf("setup WriteFile: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
data, err := m.ReadFile(ctx, ws.ID, "bench.txt")
if err != nil {
b.Fatalf("ReadFile: %v", err)
}
if len(data) == 0 {
b.Fatal("empty data")
}
}
})
}
}
// BenchmarkReadWriteCycle measures a full write-then-read cycle.
func BenchmarkReadWriteCycle(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
payload := []byte("package main\nfunc main() { println(\"cycle\") }\n")
b.ResetTimer()
for i := 0; i < b.N; i++ {
name := fmt.Sprintf("c%d.go", i)
if err := m.WriteFile(ctx, ws.ID, name, payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
data, err := m.ReadFile(ctx, ws.ID, name)
if err != nil {
b.Fatalf("ReadFile: %v", err)
}
if len(data) != len(payload) {
b.Fatalf("size mismatch: %d vs %d", len(data), len(payload))
}
}
})
}
}
// BenchmarkWriteLargeFile measures write throughput with a 1MB payload.
func BenchmarkWriteLargeFile(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
payload := make([]byte, 1<<20) // 1 MB
for i := range payload {
payload[i] = byte('A' + i%26)
}
b.SetBytes(int64(len(payload)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := m.WriteFile(ctx, ws.ID, fmt.Sprintf("large%d.bin", i), payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
}
})
}
}
// BenchmarkListDir measures directory listing latency (50 files).
func BenchmarkListDir(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
for i := 0; i < 50; i++ {
m.WriteFile(ctx, ws.ID, fmt.Sprintf("file%d.txt", i), []byte("x"), 0644)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
entries, err := m.ListDir(ctx, ws.ID, ".")
if err != nil {
b.Fatalf("ListDir: %v", err)
}
if len(entries) < 50 {
b.Fatalf("expected >= 50 entries, got %d", len(entries))
}
}
})
}
}
// BenchmarkFSWalkDir measures fs.WalkDir performance over a directory tree (45+ entries).
func BenchmarkFSWalkDir(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
if err != nil {
b.Fatalf("FS: %v", err)
}
for _, dir := range []string{"src", "src/pkg", "src/cmd", "lib"} {
wfs.MkdirAll(dir, 0755)
}
for i := 0; i < 20; i++ {
wfs.WriteFile(fmt.Sprintf("src/f%d.go", i), []byte("package src"), 0644)
}
for i := 0; i < 10; i++ {
wfs.WriteFile(fmt.Sprintf("src/pkg/p%d.go", i), []byte("package pkg"), 0644)
}
for i := 0; i < 10; i++ {
wfs.WriteFile(fmt.Sprintf("lib/l%d.go", i), []byte("package lib"), 0644)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
count := 0
fs.WalkDir(wfs, ".", func(_ string, _ fs.DirEntry, err error) error {
if err != nil {
return err
}
count++
return nil
})
if count < 40 {
b.Fatalf("walk returned only %d entries", count)
}
}
})
}
}
// BenchmarkCreateDelete measures workspace CRUD cycle.
func BenchmarkCreateDelete(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ws, err := m.Create(ctx, workspace.CreateOptions{
Name: "bench-workspace",
Owner: "bench-user",
Node: pc.Name,
})
if err != nil {
b.Fatalf("Create: %v", err)
}
if err := m.Delete(ctx, ws.ID, true); err != nil {
b.Fatalf("Delete: %v", err)
}
}
})
}
}

10
workspace/errors.go Normal file
View file

@ -0,0 +1,10 @@
package workspace
import "errors"
var (
ErrNotFound = errors.New("workspace: not found")
ErrNodeMissing = errors.New("workspace: node is required")
ErrNodeOffline = errors.New("workspace: node is offline or not configured")
ErrHasMounts = errors.New("workspace: workspace has active container mounts")
)

232
workspace/fileio_test.go Normal file
View file

@ -0,0 +1,232 @@
package workspace_test
import (
"context"
"io/fs"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/workspace"
)
func TestReadWriteFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
err := m.WriteFile(ctx, ws.ID, "hello.txt", []byte("hello world"), 0644)
require.NoError(t, err)
data, err := m.ReadFile(ctx, ws.ID, "hello.txt")
require.NoError(t, err)
assert.Equal(t, "hello world", string(data))
})
}
}
func TestWriteFile_NestedPath(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
err := m.WriteFile(ctx, ws.ID, "src/main.go", []byte("package main"), 0644)
require.NoError(t, err)
data, err := m.ReadFile(ctx, ws.ID, "src/main.go")
require.NoError(t, err)
assert.Equal(t, "package main", string(data))
})
}
}
func TestListDir(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
require.NoError(t, m.WriteFile(ctx, ws.ID, "a.txt", []byte("a"), 0644))
require.NoError(t, m.WriteFile(ctx, ws.ID, "b.txt", []byte("b"), 0644))
entries, err := m.ListDir(ctx, ws.ID, ".")
require.NoError(t, err)
names := make(map[string]bool)
for _, e := range entries {
names[e.Name] = true
}
assert.True(t, names["a.txt"])
assert.True(t, names["b.txt"])
})
}
}
func TestRemoveFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
require.NoError(t, m.WriteFile(ctx, ws.ID, "tmp.txt", []byte("temp"), 0644))
err := m.Remove(ctx, ws.ID, "tmp.txt")
require.NoError(t, err)
_, err = m.ReadFile(ctx, ws.ID, "tmp.txt")
assert.Error(t, err)
})
}
}
func TestFS_ReadFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
require.NoError(t, m.WriteFile(ctx, ws.ID, "test.txt", []byte("via fs"), 0644))
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
data, err := fs.ReadFile(wfs, "test.txt")
require.NoError(t, err)
assert.Equal(t, "via fs", string(data))
})
}
}
func TestFS_WriteFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
err = wfs.WriteFile("from-fs.txt", []byte("written via fs"), 0644)
require.NoError(t, err)
data, err := m.ReadFile(ctx, ws.ID, "from-fs.txt")
require.NoError(t, err)
assert.Equal(t, "written via fs", string(data))
})
}
}
func TestFS_MkdirAll(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
err = wfs.MkdirAll("a/b/c", 0755)
require.NoError(t, err)
info, err := fs.Stat(wfs, "a/b/c")
require.NoError(t, err)
assert.True(t, info.IsDir())
})
}
}
func TestFS_Rename(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
err = wfs.WriteFile("old.txt", []byte("content"), 0644)
require.NoError(t, err)
err = wfs.Rename("old.txt", "new.txt")
require.NoError(t, err)
data, err := fs.ReadFile(wfs, "new.txt")
require.NoError(t, err)
assert.Equal(t, "content", string(data))
_, err = fs.ReadFile(wfs, "old.txt")
assert.Error(t, err)
})
}
}
func TestFS_WalkDir(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
require.NoError(t, wfs.MkdirAll("src", 0755))
require.NoError(t, wfs.WriteFile("src/main.go", []byte("package main"), 0644))
require.NoError(t, wfs.WriteFile("src/util.go", []byte("package main"), 0644))
var files []string
err = fs.WalkDir(wfs, "src", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
files = append(files, path)
}
return nil
})
require.NoError(t, err)
assert.Len(t, files, 2)
})
}
}
func TestFS_Remove(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
require.NoError(t, wfs.WriteFile("removeme.txt", []byte("bye"), 0644))
err = wfs.Remove("removeme.txt")
require.NoError(t, err)
_, err = fs.ReadFile(wfs, "removeme.txt")
assert.Error(t, err)
})
}
}
func TestFS_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.FS(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}

130
workspace/jsapi/fs.go Normal file
View file

@ -0,0 +1,130 @@
package jsapi
import (
"rogchap.com/v8go"
)
// NewFSObject creates a JS WorkspaceFS object backed by a workspace ID string.
// All methods delegate to workspace.M() → FS — no Go object passed to V8.
//
// # Properties (read-only)
//
// ws.id → string // workspace ID ← workspaceID arg
// ws.name → string // workspace name ← Workspace.Name
// ws.node → string // tai node name ← Workspace.Node
//
// # Methods — Go mapping
//
// Each method internally does: fs, _ := workspace.M().FS(ctx, workspaceID)
// then calls the corresponding method on taiworkspace.FS.
//
// ws.ReadFile(path) → string
//
// Go: FS.ReadFile(name string) ([]byte, error)
// — also available via Manager.ReadFile(ctx, id, path)
// JS args: path string
// JS returns: string (UTF-8 content of the file)
//
// ws.WriteFile(path, data, perm?) → void
//
// Go: FS.WriteFile(name string, data []byte, perm os.FileMode) error
// — also available via Manager.WriteFile(ctx, id, path, data, perm)
// JS args: path string, data string|Uint8Array, perm? number (default 0644)
//
// ws.ReadDir(path?) → DirEntry[]
//
// Go: FS.ReadDir(name string) ([]fs.DirEntry, error)
// — also available via Manager.ListDir(ctx, id, path)
// JS args: path string (default ".")
// JS returns: [{
// name: string, ← DirEntry.Name()
// is_dir: boolean, ← DirEntry.IsDir()
// size: number ← DirEntry.Info().Size()
// }]
//
// ws.Stat(path) → FileInfo
//
// Go: FS.Stat(name string) (fs.FileInfo, error)
// JS args: path string
// JS returns: {
// name: string, ← FileInfo.Name()
// size: number, ← FileInfo.Size()
// is_dir: boolean, ← FileInfo.IsDir()
// mod_time: string ← FileInfo.ModTime() (ISO 8601)
// }
//
// ws.MkdirAll(path, perm?) → void
//
// Go: FS.MkdirAll(name string, perm os.FileMode) error
// JS args: path string, perm? number (default 0755)
//
// ws.Remove(path) → void
//
// Go: FS.Remove(name string) error
// — also available via Manager.Remove(ctx, id, path)
// JS args: path string (single file or empty directory)
//
// ws.RemoveAll(path) → void
//
// Go: FS.RemoveAll(name string) error
// JS args: path string (recursive removal)
//
// ws.Rename(from, to) → void
//
// Go: FS.Rename(oldname, newname string) error
// JS args: from string, to string
//
// # Base64 variants (PLANNED — not yet implemented)
//
// Avoids V8↔Go binary bridge overhead for images, archives, etc.
//
// ws.ReadFileBase64(path) → string
//
// Go: FS.ReadFile(name) → base64.StdEncoding.EncodeToString(data)
// JS args: path string
// JS returns: string (base64-encoded content)
//
// ws.WriteFileBase64(path, b64, perm?) → void
//
// Go: base64.StdEncoding.DecodeString(b64) → FS.WriteFile(name, data, perm)
// JS args: path string, b64 string, perm? number (default 0644)
//
// # Host copy (PLANNED — not yet implemented)
//
// Copy files/dirs from Yao host filesystem into the workspace volume.
// Useful for seeding workspaces with templates, config files, assets, etc.
//
// ws.CopyFromHost(hostPath, destPath?) → void
//
// Copies a single file or directory tree from the Yao host into the workspace.
// Go: read host file(s) → FS.WriteFile / FS.MkdirAll for each entry
// JS args: hostPath string (absolute path on Yao host),
// destPath? string (target path inside workspace, default basename of hostPath)
//
// ws.CopyFromHostArchive(hostPath, destPath?) → void
//
// For large directory trees: zip on host → transfer → unzip on Tai node.
// Requires Tai server-side unarchive support.
// Go: zip hostPath → tai Volume upload → tai unarchive at destPath
// JS args: hostPath string, destPath? string (default ".")
func NewFSObject(v8ctx *v8go.Context, workspaceID string) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only properties: id, name, node (from workspace.M().Get(workspaceID))
// 3. Bind each method as FunctionTemplate:
// - ReadFile → workspace.M().FS(ctx, id).ReadFile(path)
// - WriteFile → workspace.M().FS(ctx, id).WriteFile(path, data, perm)
// - ReadDir → workspace.M().FS(ctx, id).ReadDir(path)
// - Stat → workspace.M().FS(ctx, id).Stat(path)
// - MkdirAll → workspace.M().FS(ctx, id).MkdirAll(path, perm)
// - Remove → workspace.M().FS(ctx, id).Remove(path)
// - RemoveAll → workspace.M().FS(ctx, id).RemoveAll(path)
// - Rename → workspace.M().FS(ctx, id).Rename(old, new)
//
// PLANNED (not yet implemented):
// - ReadFileBase64 → ReadFile + base64 encode in Go
// - WriteFileBase64 → base64 decode in Go + WriteFile
// - CopyFromHost → host fs.Read → FS.Write (file-by-file)
// - CopyFromHostArchive → zip on host → tai transfer → unzip (needs Tai support)
return nil, nil
}

129
workspace/jsapi/jsapi.go Normal file
View file

@ -0,0 +1,129 @@
// Package jsapi registers the workspace namespace into the Yao V8 runtime.
//
// All methods are static on the workspace object — no constructor.
//
// # JavaScript API
//
// const ws = workspace.Create({ name: "proj", owner: "user1", node: "default" })
// const ws = workspace.Get(id)
// ws.ReadFile("main.go") → string
// ws.WriteFile("out.txt", data) → void
// ws.ReadDir("src/") → [{ name, is_dir, size }]
// workspace.Delete(id) → void
//
// # Go mapping
//
// workspace.Create(opts) → Manager.Create(ctx, CreateOptions) → *Workspace → WorkspaceFS
// workspace.Get(id) → Manager.Get(ctx, id) → *Workspace → WorkspaceFS
// workspace.List(filter?) → Manager.List(ctx, ListOptions) → []*Workspace → WorkspaceInfo[]
// workspace.Delete(id) → Manager.Delete(ctx, id, false) → void
//
// Registration happens via init() — import with:
//
// _ "github.com/yaoapp/yao/workspace/jsapi"
package jsapi
import (
v8 "github.com/yaoapp/gou/runtime/v8"
"rogchap.com/v8go"
)
func init() {
v8.RegisterObject("workspace", ExportObject)
}
// ExportObject exports the workspace namespace object to V8.
func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
obj := v8go.NewObjectTemplate(iso)
obj.Set("Create", v8go.NewFunctionTemplate(iso, wsCreate))
obj.Set("Get", v8go.NewFunctionTemplate(iso, wsGet))
obj.Set("List", v8go.NewFunctionTemplate(iso, wsList))
obj.Set("Delete", v8go.NewFunctionTemplate(iso, wsDelete))
return obj
}
// wsCreate: `workspace.Create(options)` → WorkspaceFS
//
// Go: Manager.Create(ctx, CreateOptions) (*Workspace, error)
//
// JS options → Go CreateOptions mapping:
//
// {
// id: string → CreateOptions.ID // optional; auto-generated if empty
// name: string → CreateOptions.Name // required, human-readable name
// owner: string → CreateOptions.Owner // required, user ID
// node: string → CreateOptions.Node // required, target Tai node
// labels: object → CreateOptions.Labels // optional, map[string]string
// }
//
// Returns: WorkspaceFS object (see fs.go)
func wsCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse options from info.Args()[0]
// 2. Validate required fields (name, owner, node)
// 3. ws := workspace.M().Create(ctx, opts)
// 4. Return NewFSObject(v8ctx, ws.ID)
return v8go.Undefined(info.Context().Isolate())
}
// wsGet: `workspace.Get(id)` → WorkspaceFS | null
//
// Go: Manager.Get(ctx, id) (*Workspace, error)
//
// Args:
//
// id: string — workspace ID
//
// Returns: WorkspaceFS object if found, null if not found
func wsGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. ws, err := workspace.M().Get(ctx, id)
// 3. Return NewFSObject(v8ctx, id) or null
return v8go.Undefined(info.Context().Isolate())
}
// wsList: `workspace.List(filter?)` → WorkspaceInfo[]
//
// Go: Manager.List(ctx, ListOptions) ([]*Workspace, error)
//
// JS filter → Go ListOptions mapping:
//
// {
// owner: string → ListOptions.Owner // filter by owner; empty = all
// node: string → ListOptions.Node // filter by node; empty = all
// }
//
// Returns: WorkspaceInfo[] — each element:
//
// {
// id: string ← Workspace.ID
// name: string ← Workspace.Name
// owner: string ← Workspace.Owner
// node: string ← Workspace.Node
// labels: object ← Workspace.Labels
// created_at: string ← Workspace.CreatedAt (ISO 8601)
// updated_at: string ← Workspace.UpdatedAt (ISO 8601)
// }
func wsList(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse optional filter from info.Args()[0]
// 2. list := workspace.M().List(ctx, opts)
// 3. Convert each *Workspace → JS object
// 4. Return JS array
return v8go.Undefined(info.Context().Isolate())
}
// wsDelete: `workspace.Delete(id)` → void
//
// Go: Manager.Delete(ctx, id string, force bool) error
//
// Args:
//
// id: string — workspace ID to remove (force = false)
func wsDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. workspace.M().Delete(ctx, id, false)
return v8go.Undefined(info.Context().Isolate())
}

319
workspace/manager.go Normal file
View file

@ -0,0 +1,319 @@
package workspace
import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/yaoapp/yao/tai"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
// Manager owns workspace CRUD, file I/O, and node management.
// Pools are shared with sandbox.Manager — both reference the same tai.Client instances.
type Manager struct {
pools map[string]*tai.Client
mu sync.RWMutex
}
// NewManager creates a workspace manager with the given pools.
func NewManager(pools map[string]*tai.Client) *Manager {
if pools == nil {
pools = make(map[string]*tai.Client)
}
return &Manager{pools: pools}
}
// Create allocates storage on the target node and persists metadata.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error) {
if opts.Node == "" {
return nil, ErrNodeMissing
}
client, err := m.getClient(opts.Node)
if err != nil {
return nil, err
}
id := opts.ID
if id == "" {
id = generateID()
}
now := time.Now().UTC()
ws := &Workspace{
ID: id,
Name: opts.Name,
Owner: opts.Owner,
Node: opts.Node,
Labels: opts.Labels,
CreatedAt: now,
UpdatedAt: now,
}
vol := client.Volume()
if err := vol.MkdirAll(ctx, id, "."); err != nil {
return nil, fmt.Errorf("workspace: create directory: %w", err)
}
data, err := marshalMeta(ws)
if err != nil {
return nil, err
}
if err := vol.WriteFile(ctx, id, metadataFile, data, 0644); err != nil {
return nil, fmt.Errorf("workspace: write metadata: %w", err)
}
return ws, nil
}
// Get returns a workspace by ID.
// If the node is unknown, scans all pools.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for nodeName, client := range m.pools {
ws, err := m.readMeta(ctx, client, id)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = nodeName
}
return ws, nil
}
return nil, ErrNotFound
}
// List returns workspaces, optionally filtered by owner and/or node.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*Workspace
for nodeName, client := range m.pools {
if opts.Node != "" && nodeName != opts.Node {
continue
}
entries, err := client.Volume().ListDir(ctx, "", ".")
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir {
continue
}
ws, err := m.readMeta(ctx, client, e.Path)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = nodeName
}
if opts.Owner != "" && ws.Owner != opts.Owner {
continue
}
result = append(result, ws)
}
}
return result, nil
}
// Update modifies workspace metadata (Name, Labels).
// Node and Owner are immutable after creation.
func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) {
ws, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
if opts.Name != nil {
ws.Name = *opts.Name
}
if opts.Labels != nil {
ws.Labels = opts.Labels
}
ws.UpdatedAt = time.Now().UTC()
data, err := marshalMeta(ws)
if err != nil {
return nil, err
}
if err := client.Volume().WriteFile(ctx, id, metadataFile, data, 0644); err != nil {
return nil, fmt.Errorf("workspace: write metadata: %w", err)
}
return ws, nil
}
// Delete removes workspace storage from the node.
func (m *Manager) Delete(ctx context.Context, id string, force bool) error {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
vol := client.Volume()
if err := vol.Remove(ctx, id, ".", true); err != nil {
return fmt.Errorf("workspace: remove: %w", err)
}
return nil
}
// Nodes returns all configured Tai nodes with their online status.
func (m *Manager) Nodes() []NodeInfo {
m.mu.RLock()
defer m.mu.RUnlock()
nodes := make([]NodeInfo, 0, len(m.pools))
for name := range m.pools {
nodes = append(nodes, NodeInfo{
Name: name,
Online: true,
})
}
return nodes
}
// FS returns an fs.FS-compatible filesystem for the given workspace.
func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) {
ws, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
_ = ws
return client.Workspace(id), nil
}
// ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
data, _, err := client.Volume().ReadFile(ctx, id, path)
return data, err
}
// WriteFile writes a file to the workspace.
func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().WriteFile(ctx, id, path, data, perm)
}
// ListDir lists entries in a workspace directory.
func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
entries, err := client.Volume().ListDir(ctx, id, path)
if err != nil {
return nil, err
}
result := make([]DirEntry, len(entries))
for i, e := range entries {
result[i] = DirEntry{
Name: e.Path,
IsDir: e.IsDir,
Size: e.Size,
}
}
return result, nil
}
// Remove deletes a file or directory from the workspace.
func (m *Manager) Remove(ctx context.Context, id string, path string) error {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().Remove(ctx, id, path, true)
}
// AddPool registers a new Tai node.
func (m *Manager) AddPool(name string, client *tai.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.pools[name] = client
}
// RemovePool unregisters a Tai node.
func (m *Manager) RemovePool(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.pools, name)
}
// NodeForWorkspace returns the node name for a given workspace ID.
// Used by sandbox.Manager to route container creation to the correct pool.
func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) {
ws, _, err := m.resolve(ctx, id)
if err != nil {
return "", err
}
return ws.Node, nil
}
// MountPath returns the host-side directory path for a workspace,
// suitable for use as a Docker bind mount source.
// For local volumes this is dataDir/{id}; for remote (Tai) the server handles mounts.
func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return "", err
}
dataDir := client.DataDir()
if dataDir == "" {
return "", nil
}
return dataDir + "/" + id, nil
}
// --- internal ---
func (m *Manager) getClient(node string) (*tai.Client, error) {
m.mu.RLock()
defer m.mu.RUnlock()
client, ok := m.pools[node]
if !ok {
return nil, ErrNodeOffline
}
return client, nil
}
// resolve finds the workspace and its tai.Client by scanning pools.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, client := range m.pools {
ws, err := m.readMeta(ctx, client, id)
if err != nil {
continue
}
return ws, client, nil
}
return nil, nil, ErrNotFound
}
func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) {
data, _, err := client.Volume().ReadFile(ctx, id, metadataFile)
if err != nil {
return nil, err
}
return unmarshalMeta(data)
}
// DirEntry represents a file or directory entry in a workspace listing.
type DirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}

View file

@ -0,0 +1,89 @@
package workspace_test
import (
"context"
"os"
"testing"
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
type poolConfig struct {
Name string
Addr string
}
func testPools() []poolConfig {
pools := []poolConfig{
{Name: "local", Addr: "local"},
}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
}
return pools
}
func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager {
tb.Helper()
client := clientForPool(tb, pc)
pools := map[string]*tai.Client{pc.Name: client}
return workspace.NewManager(pools)
}
func clientForPool(tb testing.TB, pc poolConfig) *tai.Client {
tb.Helper()
if pc.Addr == "local" {
return localClient(tb, tb.TempDir())
}
client, err := tai.New(pc.Addr)
if err != nil {
tb.Fatalf("tai.New(%s): %v", pc.Addr, err)
}
tb.Cleanup(func() { client.Close() })
return client
}
func localClient(tb testing.TB, dataDir string) *tai.Client {
tb.Helper()
vol := volume.NewLocal(dataDir)
client, err := tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir))
if err != nil {
tb.Fatalf("tai.New local: %v", err)
}
tb.Cleanup(func() { client.Close() })
return client
}
func setupManagerMultiNode(t *testing.T) *workspace.Manager {
t.Helper()
pools := map[string]*tai.Client{
"node-a": localClient(t, t.TempDir()),
"node-b": localClient(t, t.TempDir()),
}
return workspace.NewManager(pools)
}
func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace {
tb.Helper()
co := workspace.CreateOptions{
Name: "test-workspace",
Owner: "test-user",
Node: node,
}
for _, fn := range opts {
fn(&co)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ws, err := m.Create(ctx, co)
if err != nil {
tb.Fatalf("Create workspace: %v", err)
}
tb.Cleanup(func() {
m.Delete(context.Background(), ws.ID, true)
})
return ws
}

78
workspace/workspace.go Normal file
View file

@ -0,0 +1,78 @@
package workspace
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
// MountMode controls read-write or read-only access when a workspace is
// bind-mounted into a container.
type MountMode string
const (
MountRW MountMode = "rw"
MountRO MountMode = "ro"
)
const metadataFile = ".workspace.json"
// Workspace is a persistent, user-managed storage entity.
// It is pinned to a specific Tai node (host machine) at creation time;
// containers referencing this workspace are automatically routed to that node.
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Node string `json:"node"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateOptions configures a new workspace.
type CreateOptions struct {
ID string // explicit ID; empty = auto-generate (uuid)
Name string // human-readable name
Owner string // user ID
Node string // target Tai node (required)
Labels map[string]string // arbitrary metadata
}
// ListOptions filters workspace listing.
type ListOptions struct {
Owner string // filter by owner; empty = all
Node string // filter by node; empty = all
}
// UpdateOptions specifies which metadata fields to change.
// nil fields are left unchanged. Node and Owner are immutable.
type UpdateOptions struct {
Name *string // nil = no change
Labels map[string]string // nil = no change; non-nil replaces all labels
}
// NodeInfo describes a Tai node available for workspace storage.
type NodeInfo struct {
Name string // pool name = node name
Addr string // tai:// address
Online bool // tai client is connected
}
func generateID() string {
return fmt.Sprintf("ws-%s", uuid.New().String()[:12])
}
func marshalMeta(ws *Workspace) ([]byte, error) {
return json.MarshalIndent(ws, "", " ")
}
func unmarshalMeta(data []byte) (*Workspace, error) {
var ws Workspace
if err := json.Unmarshal(data, &ws); err != nil {
return nil, fmt.Errorf("workspace: invalid metadata: %w", err)
}
return &ws, nil
}

323
workspace/workspace_test.go Normal file
View file

@ -0,0 +1,323 @@
package workspace_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
func TestCreate(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
assert.NotEmpty(t, ws.ID)
assert.Equal(t, "test-workspace", ws.Name)
assert.Equal(t, "test-user", ws.Owner)
assert.Equal(t, pc.Name, ws.Node)
assert.False(t, ws.CreatedAt.IsZero())
assert.False(t, ws.UpdatedAt.IsZero())
})
}
}
func TestCreate_AutoID(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
assert.True(t, len(ws.ID) > 0)
assert.Contains(t, ws.ID, "ws-")
})
}
}
func TestCreate_ExplicitID(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.ID = "my-custom-id"
})
assert.Equal(t, "my-custom-id", ws.ID)
})
}
}
func TestCreate_WithLabels(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Labels = map[string]string{"project": "frontend", "env": "dev"}
})
assert.Equal(t, "frontend", ws.Labels["project"])
assert.Equal(t, "dev", ws.Labels["env"])
})
}
}
func TestCreate_InvalidNode(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Create(context.Background(), workspace.CreateOptions{
Name: "bad",
Owner: "user",
Node: "",
})
assert.ErrorIs(t, err, workspace.ErrNodeMissing)
})
}
}
func TestCreate_NodeNotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Create(context.Background(), workspace.CreateOptions{
Name: "bad",
Owner: "user",
Node: "nonexistent-node",
})
assert.ErrorIs(t, err, workspace.ErrNodeOffline)
})
}
}
func TestGet(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
got, err := m.Get(context.Background(), ws.ID)
require.NoError(t, err)
assert.Equal(t, ws.ID, got.ID)
assert.Equal(t, ws.Name, got.Name)
assert.Equal(t, ws.Owner, got.Owner)
assert.Equal(t, ws.Node, got.Node)
})
}
}
func TestGet_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Get(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestList(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { co.Name = "ws-1" })
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { co.Name = "ws-2" })
list, err := m.List(context.Background(), workspace.ListOptions{})
require.NoError(t, err)
assert.GreaterOrEqual(t, len(list), 2)
})
}
}
func TestList_FilterOwner(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Owner = "alice"
co.Name = "alice-ws"
})
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Owner = "bob"
co.Name = "bob-ws"
})
list, err := m.List(context.Background(), workspace.ListOptions{Owner: "alice"})
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "alice", list[0].Owner)
})
}
}
func TestList_FilterNode(t *testing.T) {
m := setupManagerMultiNode(t)
ctx := context.Background()
_, err := m.Create(ctx, workspace.CreateOptions{Name: "a", Owner: "u", Node: "node-a"})
require.NoError(t, err)
_, err = m.Create(ctx, workspace.CreateOptions{Name: "b", Owner: "u", Node: "node-b"})
require.NoError(t, err)
list, err := m.List(ctx, workspace.ListOptions{Node: "node-a"})
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "node-a", list[0].Node)
}
func TestUpdate_Name(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
newName := "renamed-workspace"
updated, err := m.Update(context.Background(), ws.ID, workspace.UpdateOptions{
Name: &newName,
})
require.NoError(t, err)
assert.Equal(t, newName, updated.Name)
assert.Equal(t, ws.Owner, updated.Owner)
assert.True(t, updated.UpdatedAt.After(ws.UpdatedAt) || updated.UpdatedAt.Equal(ws.UpdatedAt))
})
}
}
func TestUpdate_Labels(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Labels = map[string]string{"old": "value"}
})
updated, err := m.Update(context.Background(), ws.ID, workspace.UpdateOptions{
Labels: map[string]string{"new": "label"},
})
require.NoError(t, err)
assert.Equal(t, "label", updated.Labels["new"])
assert.Empty(t, updated.Labels["old"])
})
}
}
func TestUpdate_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Update(context.Background(), "nonexistent", workspace.UpdateOptions{})
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestDelete(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws, err := m.Create(context.Background(), workspace.CreateOptions{
Name: "to-delete", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
err = m.Delete(context.Background(), ws.ID, false)
require.NoError(t, err)
_, err = m.Get(context.Background(), ws.ID)
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestDelete_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
err := m.Delete(context.Background(), "nonexistent", false)
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestNodes(t *testing.T) {
m := setupManagerMultiNode(t)
nodes := m.Nodes()
assert.Len(t, nodes, 2)
names := make(map[string]bool)
for _, n := range nodes {
names[n.Name] = true
assert.True(t, n.Online)
}
assert.True(t, names["node-a"])
assert.True(t, names["node-b"])
}
func TestNodeForWorkspace(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
node, err := m.NodeForWorkspace(context.Background(), ws.ID)
require.NoError(t, err)
assert.Equal(t, pc.Name, node)
})
}
}
func TestNodeForWorkspace_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.NodeForWorkspace(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestAddPool(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
assert.Len(t, m.Nodes(), 1)
vol := volume.NewLocal(t.TempDir())
client, err := tai.New("local", tai.WithVolume(vol))
require.NoError(t, err)
defer client.Close()
m.AddPool("new-node", client)
assert.Len(t, m.Nodes(), 2)
})
}
}
func TestRemovePool(t *testing.T) {
m := setupManagerMultiNode(t)
assert.Len(t, m.Nodes(), 2)
m.RemovePool("node-b")
assert.Len(t, m.Nodes(), 1)
}
func TestMountPath(t *testing.T) {
m := setupManagerForPool(t, poolConfig{Name: "local", Addr: "local"})
ws := createWorkspace(t, m, "local")
mountPath, err := m.MountPath(context.Background(), ws.ID)
require.NoError(t, err)
assert.Contains(t, mountPath, ws.ID)
}
func TestMountPath_NotFound(t *testing.T) {
m := setupManagerForPool(t, poolConfig{Name: "local", Addr: "local"})
_, err := m.MountPath(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
}