feat(sandbox): add persistent Docker sandbox for external CLI agents

This commit is contained in:
Max 2026-01-29 19:23:50 +08:00
parent d21dee07c4
commit d4bed4f277
28 changed files with 7014 additions and 5 deletions

View file

@ -735,6 +735,178 @@ jobs:
body: '✅ Robot E2E Tests passed!'
});
# =============================================================================
# Sandbox Tests (requires Docker) - Run with Docker-in-Docker
# =============================================================================
SandboxTest:
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 Tests running with Docker...'
});
- 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 Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
uses: supercharge/redis-github-action@1.4.0
with:
redis-version: 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 Sandbox Test Images
run: |
docker pull alpine:latest
docker pull yaoapp/sandbox-base:latest || true
docker pull yaoapp/sandbox-claude:latest || true
- name: Run Sandbox Tests
run: make unit-test-sandbox
- name: Codecov Report
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: "Comment on PR - Sandbox 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 Tests passed!'
});
# =============================================================================
# Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3
# =============================================================================

View file

@ -104,8 +104,6 @@ env:
S3_BUCKET: ${{ secrets.S3_BUCKET }}
S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }}
# === Openapi Signin Configs ===
SIGNIN_CLIENT_ID: "kiCeR88kDwHBDuNHvN51cZgmpp3tmF6Z"
@ -158,7 +156,6 @@ env:
RELIABLE_IMAP_PASSWORD: ${{ secrets.RELIABLE_SMTP_PASSWORD }}
RELIABLE_IMAP_MAILBOX: "INBOX"
## Twilio
TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}
@ -556,6 +553,116 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
# =============================================================================
# Sandbox Tests (requires Docker) - Run with Docker-in-Docker
# =============================================================================
sandbox-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 Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
uses: supercharge/redis-github-action@1.4.0
with:
redis-version: 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 Sandbox Test Images
run: |
docker pull alpine:latest
docker pull yaoapp/sandbox-base:latest || true
docker pull yaoapp/sandbox-claude:latest || true
- name: Run Sandbox Tests
run: make unit-test-sandbox
- name: Codecov Report
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
# =============================================================================
# Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3
# =============================================================================

2
.gitignore vendored
View file

@ -55,3 +55,5 @@ agent/search/job-logs.txt
agent/test/MULTI_TURN_DESIGN.md
agent/test/UPGRADE_PLAN.md
introduction/*
!sandbox/docker/build.sh
sandbox/docker/yao-bridge-*

View file

@ -11,14 +11,16 @@ OS := $(shell uname)
# ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*' | awk '!/\/tests\// || /openapi\/tests/')
# Core tests (exclude AI-related: agent, aigc, openai, and KB)
TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb' | awk '!/\/tests\// || /openapi\/tests/')
# Core tests (exclude AI-related: agent, aigc, openai, KB, and sandbox which requires Docker)
TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox' | awk '!/\/tests\// || /openapi\/tests/')
# AI tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys) and robot/api E2E tests
TESTFOLDER_AI := $(shell $(GO) list ./agent/... ./aigc/... | grep -v 'agent/search/handlers/web')
# KB tests (kb)
TESTFOLDER_KB := $(shell $(GO) list ./kb/...)
# Robot E2E tests (agent/robot/api) - runs TestE2E* tests with real LLM calls
TESTFOLDER_ROBOT_E2E := $(shell $(GO) list ./agent/robot/api/...)
# Sandbox tests (requires Docker)
TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/...)
TESTTAGS ?= ""
# TESTWIDGETS := $(shell $(GO) list ./widgets/...)
@ -172,6 +174,51 @@ unit-test-robot-e2e:
fi; \
done
# Sandbox Unit Test (requires Docker)
.PHONY: unit-test-sandbox
unit-test-sandbox:
@echo ""
@echo "============================================="
@echo "Running Sandbox Tests (requires Docker)..."
@echo "============================================="
@echo "Pulling sandbox test images..."
docker pull alpine:latest || true
docker pull yaoapp/sandbox-base:latest || true
docker pull yaoapp/sandbox-claude:latest || true
@echo ""
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_SANDBOX); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal' $$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 sandbox tests passed"
@echo "============================================="
# Benchmark Test
.PHONY: benchmark
benchmark:

17
go.mod
View file

@ -49,6 +49,7 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
@ -66,14 +67,23 @@ require (
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
@ -111,11 +121,14 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/miekg/dns v1.1.66 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pdfcpu/pdfcpu v0.11.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
@ -150,7 +163,11 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/image v0.29.0 // indirect
golang.org/x/mod v0.29.0 // indirect

25
go.sum
View file

@ -4,6 +4,8 @@ github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjux
github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0/go.mod h1:D56Cl9r8M5i3UwAchE+LlLc5hPN3kJtdZNVJn06lSHU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
@ -58,6 +60,10 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -66,8 +72,16 @@ github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
@ -84,6 +98,8 @@ github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@ -99,6 +115,7 @@ github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8b
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@ -224,6 +241,8 @@ github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEu
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -245,6 +264,10 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pdfcpu/pdfcpu v0.11.0 h1:mL18Y3hSHzSezmnrzA21TqlayBOXuAx7BUzzZyroLGM=
github.com/pdfcpu/pdfcpu v0.11.0/go.mod h1:F1ca4GIVFdPtmgvIdvXAycAm88noyNxZwzr9CpTy+Mw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@ -356,6 +379,8 @@ go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeH
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=

1379
sandbox/DESIGN.md Normal file

File diff suppressed because it is too large Load diff

486
sandbox/PLAN.md Normal file
View file

@ -0,0 +1,486 @@
# Sandbox Implementation Plan
## Overview
This document outlines the implementation plan for the Sandbox module, which provides persistent Docker containers for external CLI agents like Claude Code.
**Estimated Code**: ~1200 lines
---
## Phase 1: Core Interfaces & Types ✅ COMPLETED
### Goals
- Define all core types and interfaces
- Set up package structure
### Implemented Files
```
sandbox/
├── manager.go # Manager implementation
├── types.go # Container, ExecOptions, ExecResult, FileInfo
├── config.go # Configuration types
├── errors.go # Custom errors
├── helpers.go # Helper functions
└── ipc/
├── manager.go # IPC Manager
├── session.go # IPC Session
└── types.go # JSON-RPC types
```
### Completed
- [x] Create package structure
- [x] Define types in `types.go`
- `Config` struct
- `Container` struct
- `ExecOptions` struct
- `ExecResult` struct
- `FileInfo` struct
- [x] Define `Manager` in `manager.go`
- Container lifecycle: `GetOrCreate`, `Stop`, `Start`, `Remove`, `List`, `Cleanup`
- Command execution: `Stream`, `Exec`
- Filesystem: `WriteFile`, `ReadFile`, `ListDir`, `Stat`, `MkDir`, `RemoveFile`, `CopyToContainer`, `CopyFromContainer`
- [x] Define IPC types in `ipc/`
- `Session` struct
- `Manager` struct
- `AgentContext` struct
- `MCPTool` struct
- JSON-RPC request/response types
- [x] Unit tests for type helpers
---
## Phase 2: Docker Container Management ✅ COMPLETED
### Goals
- Implement container lifecycle management
- Handle container creation, start, stop, remove
### Completed
- [x] Initialize Docker client (`NewManager`)
- [x] Implement `createContainer()`
- Generate container name: `yao-sandbox-{userID}-{chatID}`
- Create workspace directory on host
- Configure mounts (workspace, IPC socket)
- Set resource limits (memory, CPU)
- Apply security options (`--cap-drop ALL`, `no-new-privileges`)
- [x] Implement `GetOrCreate()` with double-check locking
- [x] Implement `ensureImage()` - auto-pull missing Docker images
- [x] Implement `ensureRunning()`
- [x] Implement `Stop()` and `Start()`
- [x] Implement `Remove()`
- [x] Implement `List()`
- [x] Concurrency limit (`ErrTooManyContainers`)
---
## Phase 3: Command Execution & Filesystem ✅ COMPLETED
### Goals
- Execute commands inside containers
- Support both streaming and blocking execution
- Full filesystem operations
### Completed
#### Command Execution
- [x] Implement `Stream()` - returns io.ReadCloser
- [x] Implement `Exec()` - blocking execution with result
- [x] Handle timeout via context
- [x] Handle environment variables
#### Filesystem Operations
- [x] `WriteFile()` - tar archive + CopyToContainer
- [x] `ReadFile()` - CopyFromContainer + extract tar
- [x] `ListDir()` - execute `ls -la` and parse
- [x] `Stat()` - execute `stat` and parse
- [x] `MkDir()` - execute `mkdir -p`
- [x] `RemoveFile()` - execute `rm -rf`
- [x] `CopyToContainer()` - tar + Docker API
- [x] `CopyFromContainer()` - Docker API + extract
---
## Phase 4: IPC System ✅ COMPLETED
### Goals
- Implement Unix socket IPC
- Handle MCP JSON-RPC protocol
### Completed
- [x] Implement `ipc.Manager`
- `NewManager(sockDir string) *Manager`
- `Create(ctx, sessionID, agentCtx, mcpTools) (*Session, error)`
- `Close(sessionID) error`
- `Get(sessionID) (*Session, bool)`
- `CloseAll()`
- [x] Implement `ipc.Session`
- Create Unix socket listener
- Set socket permissions (0660)
- Handle connection lifecycle
- [x] Implement message loop
- Accept connection
- Read NDJSON lines
- Parse JSON-RPC requests
- Dispatch to handlers
- Write JSON-RPC responses
- [x] Implement MCP handlers
- `initialize` → handshake response
- `tools/list` → return authorized tools
- `tools/call` → execute Yao process
- `resources/list` → list resources
- `resources/read` → read resource
- [x] JSON-RPC error handling with proper error codes
---
## Phase 5: yao-bridge & Docker Image ✅ COMPLETED
### Goals
- Build yao-bridge binary
- Create Docker images
### Implemented Files
```
sandbox/
├── bridge/
│ └── main.go # yao-bridge source
├── docker/
│ ├── base/
│ │ └── Dockerfile.base # Common base image
│ ├── claude/
│ │ ├── Dockerfile # Default: Claude + Node + Python
│ │ └── Dockerfile.full # + Go
│ └── build.sh # Build script
```
### Completed
- [x] Implement yao-bridge (`sandbox/bridge/main.go`)
- stdin/stdout ↔ Unix socket bridge
- Signal handling for graceful shutdown
- [x] Create Dockerfiles
- `base/Dockerfile.base` - Ubuntu 22.04, git, curl, yao-bridge
- `claude/Dockerfile` - + Node.js 20, Python 3.11
- `claude/Dockerfile.full` - + Go 1.23
- [x] Create build script (`sandbox/docker/build.sh`)
- Builds yao-bridge as static binary
- Builds all image variants
---
## Phase 6: ClaudeExecutor Integration 🔲 PENDING
### Goals
- Integrate Sandbox with ClaudeExecutor
- End-to-end execution flow
### Tasks
- [ ] Add Sandbox Manager to ClaudeExecutor
```go
type ClaudeExecutor struct {
Assistant *Assistant
SandboxManager *sandbox.Manager
IPCManager *ipc.Manager
}
```
- [ ] Implement `Stream()` method
1. Get or create container
2. Create IPC session
3. Generate .mcp.json
4. Setup skills
5. Build Claude CLI args
6. Execute in container
7. Parse output
- [ ] Implement `writeMCPConfig()`
- Generate MCP config with yao-bridge
- Include external MCP servers
- Write to workspace
- [ ] Implement `setupSkills()`
- Symlink skills directory to .claude/skills/
- [ ] Implement output parsing
- Parse NDJSON stream
- Extract text content
- Extract file changes from tool_use
- Handle result message
- [ ] Handle session mapping
- Map Yao ChatID to Claude SessionID
- Support `--resume` for continuation
### Deliverables
- [ ] ClaudeExecutor with Sandbox
- [ ] MCP config generation
- [ ] Skills setup
- [ ] Output parsing
- [ ] Integration tests
---
## Phase 7: Cleanup & Testing ✅ COMPLETED
### Goals
- Implement cleanup strategies
- Comprehensive testing
- Documentation
### Completed
- [x] Implement cleanup loop (every 5 minutes)
- [x] Implement `Cleanup(ctx) error`
- [x] Unit tests (no Docker required)
- `config_test.go` - Config parsing, validation, env vars, edge cases
- `helpers_test.go` - parseMemory, mapToSlice, parseLS, parseStat, tar operations
- `ipc/jsonrpc_test.go` - JSON-RPC parsing, serialization
- [x] Integration tests (Docker required)
- `manager_test.go` - Container lifecycle, exec, filesystem operations
- `ipc/manager_test.go` - IPC session management
- `ipc/session_test.go` - Session message handling, MCP protocol
- [x] README.md with usage examples
### Test Files
| File | Tests | Description |
| --------------------- | ----- | ------------------------------------ |
| `config_test.go` | 10 | Config parsing, env vars, edge cases |
| `helpers_test.go` | 6 | Utility functions |
| `ipc/jsonrpc_test.go` | 8 | JSON-RPC types |
| `manager_test.go` | 18 | Container lifecycle (Docker) |
| `ipc/manager_test.go` | 10 | IPC sessions |
| `ipc/session_test.go` | 11 | Session handlers (Docker optional) |
### Unit Tests (No Docker)
```
✅ TestDefaultConfig
✅ TestConfigInit
✅ TestConfigInitWithEnv
✅ TestConfigInitWithWorkspaceEnv
✅ TestConfigInitWithPresetValues
✅ TestConfigInitInvalidEnvValues
✅ TestConfigInitNegativeValues
✅ TestConfigInitZeroMax
✅ TestContainerName
✅ TestConfigEnvPriority
✅ TestParseMemory
✅ TestMapToSlice
✅ TestParseLS
✅ TestParseStat
✅ TestParseLSMode
✅ TestCreateAndExtractTar
✅ TestJSONRPCRequestParsing
✅ TestJSONRPCResponseSerialization
✅ TestJSONRPCErrorResponse
✅ TestToolCallParams
✅ TestToolResult
✅ TestToolsListResult
✅ TestInitializeResult
```
### Integration Tests (Docker Required)
```
✅ TestNewManager
✅ TestNewManagerWithNilConfig
✅ TestGetOrCreate
✅ TestContainerStartStopRemove
✅ TestExec
✅ TestExecWithEnv
✅ TestExecWithTimeout
✅ TestFileOperations
✅ TestCopyOperations
✅ TestListContainers
✅ TestConcurrencyLimit
✅ TestConcurrentAccess
✅ TestContainerNotFound
✅ TestCleanup
✅ TestGetAccessors
✅ TestEnsureImageAutoPull
✅ TestManagerWithYaoApp (requires YAO_TEST_APPLICATION)
```
### IPC Tests
```
✅ TestNewManager
✅ TestCreateSession
✅ TestGetSession
✅ TestCloseSession
✅ TestCloseNonExistentSession
✅ TestCloseAllSessions
✅ TestSessionReplace
✅ TestConcurrentSessionAccess
✅ TestSessionConnection
✅ TestToolsList
✅ TestMethodNotFound
✅ TestParseError
✅ TestInitializedNotification
✅ TestSessionHandleInitialize
✅ TestSessionHandleResourcesList
✅ TestSessionHandleResourcesRead
✅ TestSessionHandleToolsCallInvalidParams
✅ TestSessionHandleToolsCallUnauthorized
✅ TestSessionToolsCallWithYaoApp (requires YAO_TEST_APPLICATION)
✅ TestSessionMultipleRequests
✅ TestSessionClose
✅ TestSessionEmptyLines
```
### Running Tests
```bash
# Unit tests only (no Docker needed)
go test -v ./sandbox/... -run "Test(Default|Config|Parse|Map|LS|Stat|Tar|JSONRPC|Tool)"
# Integration tests (Docker required)
source env.local.sh
go test -v ./sandbox/...
# With Yao application (full integration)
export YAO_TEST_APPLICATION=/path/to/yao-dev-app
source env.local.sh
go test -v ./sandbox/...
# Using Makefile (pulls test images automatically)
make unit-test-sandbox
```
---
## Phase Summary
| Phase | Description | Status |
| ----- | ------------------------------ | ------------ |
| 1 | Core Interfaces & Types | ✅ COMPLETED |
| 2 | Docker Container Management | ✅ COMPLETED |
| 3 | Command Execution & Filesystem | ✅ COMPLETED |
| 4 | IPC System | ✅ COMPLETED |
| 5 | yao-bridge & Docker Image | ✅ COMPLETED |
| 6 | ClaudeExecutor Integration | 🔲 PENDING |
| 7 | Cleanup & Testing | ✅ COMPLETED |
---
## Implementation Summary
### Files Created
| File | Lines | Description |
| --------------------------------------- | ----- | ------------------------------ |
| `sandbox/errors.go` | 22 | Error types |
| `sandbox/types.go` | 52 | Core type definitions |
| `sandbox/config.go` | 90 | Configuration with env vars |
| `sandbox/helpers.go` | 305 | Helper functions |
| `sandbox/manager.go` | 541 | Main manager implementation |
| `sandbox/ipc/types.go` | 139 | IPC type definitions |
| `sandbox/ipc/manager.go` | 101 | IPC session manager |
| `sandbox/ipc/session.go` | 252 | Session handling |
| `sandbox/bridge/main.go` | 59 | yao-bridge binary |
| `sandbox/docker/base/Dockerfile.base` | 34 | Base Docker image (multi-arch) |
| `sandbox/docker/claude/Dockerfile` | 43 | Claude image |
| `sandbox/docker/claude/Dockerfile.full` | 34 | Full Claude image (multi-arch) |
| `sandbox/docker/build.sh` | 145 | Build script (multi-arch) |
| `sandbox/config_test.go` | 175 | Config tests |
| `sandbox/helpers_test.go` | 190 | Helper tests |
| `sandbox/manager_test.go` | 520 | Manager integration tests |
| `sandbox/ipc/jsonrpc_test.go` | 236 | JSON-RPC tests |
| `sandbox/ipc/manager_test.go` | 330 | IPC manager tests |
| `sandbox/ipc/session_test.go` | 420 | IPC session tests |
| `sandbox/README.md` | 152 | Documentation |
**Total**: ~3800 lines
---
## Dependencies
### External
- Docker Engine (or Docker Desktop)
- Claude CLI (placeholder in Dockerfile)
### Go Packages
- `github.com/docker/docker/client` - Docker SDK
- `github.com/docker/docker/api/types/container` - Docker types
### Internal
- `github.com/yaoapp/gou/process` - Yao process execution
---
## Next Steps
1. **Phase 6: ClaudeExecutor Integration**
- Implement in `yao/agent/assistant/executor/claude/`
- Wire up sandbox with assistant execution flow
2. **CI/CD for Docker Images** ✅ COMPLETED
- Images already built and pushed to Docker Hub:
- `yaoapp/sandbox-base:latest` (amd64, arm64)
- `yaoapp/sandbox-claude:latest` (amd64, arm64)
- `yaoapp/sandbox-claude-full:latest` (amd64, arm64)
- Set up automated builds on version tags
3. **CI/CD for Tests** ✅ COMPLETED
- Sandbox tests run separately from core tests
- Makefile: `make unit-test-sandbox`
- GitHub Actions workflows updated:
- `unit-test.yml`: Added `sandbox-test` job
- `pr-test.yml`: Added `SandboxTest` job
- Test images pre-pulled before tests:
- `alpine:latest`
- `yaoapp/sandbox-base:latest`
- `yaoapp/sandbox-claude:latest`
---
## Success Criteria
### Functional ✅ (Sandbox Core)
- [x] Can create/start/stop/remove containers
- [x] Can execute commands in containers
- [x] IPC communication works bidirectionally
- [ ] Claude CLI can call Yao MCP tools (requires Phase 6)
- [x] Data persists across container restarts
### Performance (To be validated)
- [ ] Container creation < 5 seconds
- [ ] Command execution latency < 100ms overhead
- [ ] IPC round-trip < 10ms
### Reliability
- [x] Handles connection drops gracefully
- [x] Cleans up resources on errors
- [x] No resource leaks (cleanup loop)
### Security
- [x] User isolation enforced (one container per user+chat)
- [x] Resource limits enforced (memory, CPU)
- [x] No privilege escalation (`--cap-drop ALL`, `no-new-privileges`)

151
sandbox/README.md Normal file
View file

@ -0,0 +1,151 @@
# Yao Sandbox
Sandbox provides persistent Docker containers as isolated execution environments for external CLI agents like Claude Code.
## Overview
The sandbox module enables Yao to safely run external AI coding agents (like Claude CLI) in isolated Docker containers. Each user+chat session gets its own container with:
- Persistent workspace for code and dependencies
- IPC communication via Unix sockets
- Resource limits (CPU, memory)
- Security isolation
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Yao Server │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Sandbox Manager │ │
│ │ │ │
│ │ - GetOrCreate(userID, chatID) → container │ │
│ │ - Exec/Stream commands in container │ │
│ │ - Filesystem operations (read, write, copy) │ │
│ │ │ │
│ └────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Container │ │ Container │ │ Container │ │
│ │ (user1) │ │ (user2) │ │ (user3) │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ ──────┴───────────────┴───────────────┴──── │
│ Unix Socket IPC │
│ (one socket per container) │
└───────────────────────────────────────────────────────────────┘
```
## Quick Start
### Build Docker Images
```bash
cd sandbox/docker
./build.sh claude
```
### Usage
```go
import "github.com/yaoapp/yao/sandbox"
// Create manager
config := sandbox.DefaultConfig()
config.Init("/path/to/yao/data")
manager, err := sandbox.NewManager(config)
if err != nil {
log.Fatal(err)
}
defer manager.Close()
// Get or create container
container, err := manager.GetOrCreate(ctx, "user123", "chat456")
if err != nil {
log.Fatal(err)
}
// Execute command
result, err := manager.Exec(ctx, container.Name, []string{"echo", "hello"}, nil)
fmt.Println(result.Stdout) // "hello\n"
// Write file
err = manager.WriteFile(ctx, container.Name, "/workspace/test.txt", []byte("content"))
// Read file
data, err := manager.ReadFile(ctx, container.Name, "/workspace/test.txt")
```
## Configuration
### Environment Variables
| Variable | Default | Description |
| -------------------------- | ----------------------------------- | ------------------------- |
| `YAO_SANDBOX_IMAGE` | `yao/sandbox-claude:latest` | Docker image |
| `YAO_SANDBOX_WORKSPACE` | `{YAO_DATA_ROOT}/sandbox/workspace` | Workspace directory |
| `YAO_SANDBOX_IPC` | `{YAO_DATA_ROOT}/sandbox/ipc` | IPC socket directory |
| `YAO_SANDBOX_MAX` | `100` | Max concurrent containers |
| `YAO_SANDBOX_IDLE_TIMEOUT` | `30m` | Idle timeout |
| `YAO_SANDBOX_MEMORY` | `2g` | Memory limit |
| `YAO_SANDBOX_CPU` | `1.0` | CPU limit |
## Docker Images
| Image | Description |
| --------------------------- | ------------------------------------- |
| `yao/sandbox-base:latest` | Base image with git, curl, yao-bridge |
| `yao/sandbox-claude:latest` | + Claude CLI, Node.js 20, Python 3.11 |
| `yao/sandbox-claude:full` | + Go 1.23 |
## IPC Communication
Sandbox containers communicate with Yao via Unix sockets using the MCP (Model Context Protocol) JSON-RPC format. The `yao-bridge` binary inside containers bridges stdio ↔ socket.
Supported methods:
- `initialize` - Handshake
- `tools/list` - List available tools
- `tools/call` - Execute a tool
## Directory Structure
```
sandbox/
├── bridge/ # yao-bridge source
├── docker/ # Dockerfiles and build script
│ ├── base/
│ ├── claude/
│ └── build.sh
├── ipc/ # IPC system
│ ├── manager.go
│ ├── session.go
│ └── types.go
├── config.go # Configuration
├── errors.go # Error types
├── helpers.go # Helper functions
├── manager.go # Main manager
└── types.go # Type definitions
```
## Testing
```bash
# Unit tests (no Docker required)
go test -v ./sandbox/... -run "^Test.*Validation|^Test.*Generation|^Test.*Parsing"
# All tests (requires Docker)
go test -v ./sandbox/...
```
## Security
- Containers run as non-root user
- `--cap-drop ALL` removes all capabilities
- `no-new-privileges` prevents privilege escalation
- Only workspace directory is mounted
- Per-session IPC sockets with authorized tools only

58
sandbox/bridge/main.go Normal file
View file

@ -0,0 +1,58 @@
// yao-bridge is a lightweight binary that bridges stdio to a Unix socket.
// It is used inside Docker containers to connect CLI tools (like Claude)
// to the Yao IPC server running on the host.
//
// Usage: yao-bridge /tmp/yao.sock
package main
import (
"io"
"log"
"net"
"os"
"os/signal"
"syscall"
)
func main() {
if len(os.Args) < 2 {
log.Fatal("Usage: yao-bridge <socket-path>")
}
sockPath := os.Args[1]
// Connect to Unix socket
conn, err := net.Dial("unix", sockPath)
if err != nil {
log.Fatalf("Failed to connect to socket %s: %v", sockPath, err)
}
defer conn.Close()
// Handle signals for graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Create done channel
done := make(chan struct{})
// stdin → socket
go func() {
io.Copy(conn, os.Stdin)
// Close write side when stdin is done
if unixConn, ok := conn.(*net.UnixConn); ok {
unixConn.CloseWrite()
}
}()
// socket → stdout
go func() {
io.Copy(os.Stdout, conn)
close(done)
}()
// Wait for completion or signal
select {
case <-done:
case <-sigCh:
}
}

94
sandbox/config.go Normal file
View file

@ -0,0 +1,94 @@
package sandbox
import (
"os"
"path/filepath"
"strconv"
"time"
)
// Config holds sandbox configuration
type Config struct {
Image string `json:"image,omitempty"` // Docker image, default: yao/sandbox-claude:latest
WorkspaceRoot string `json:"workspace_root,omitempty"` // Host workspace root directory
IPCDir string `json:"ipc_dir,omitempty"` // IPC socket directory
MaxContainers int `json:"max_containers,omitempty"` // Maximum concurrent containers
IdleTimeout time.Duration `json:"idle_timeout,omitempty"` // Idle timeout before stopping container
MaxMemory string `json:"max_memory,omitempty"` // Memory limit, e.g., "2g"
MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit, e.g., 1.0
}
// DefaultConfig returns a Config with default values
func DefaultConfig() *Config {
return &Config{
Image: "yaoapp/sandbox-claude:latest",
MaxContainers: 100,
IdleTimeout: 30 * time.Minute,
MaxMemory: "2g",
MaxCPU: 1.0,
}
}
// Init initializes the config with defaults based on environment variables and data root
func (c *Config) Init(dataRoot string) {
// Image
if env := os.Getenv("YAO_SANDBOX_IMAGE"); env != "" {
c.Image = env
} else if c.Image == "" {
c.Image = "yaoapp/sandbox-claude:latest"
}
// Workspace root
if env := os.Getenv("YAO_SANDBOX_WORKSPACE"); env != "" {
c.WorkspaceRoot = env
} else if c.WorkspaceRoot == "" {
c.WorkspaceRoot = filepath.Join(dataRoot, "sandbox", "workspace")
}
// IPC directory
if env := os.Getenv("YAO_SANDBOX_IPC"); env != "" {
c.IPCDir = env
} else if c.IPCDir == "" {
c.IPCDir = filepath.Join(dataRoot, "sandbox", "ipc")
}
// Max containers - set default first if zero, then try env override
if c.MaxContainers == 0 {
c.MaxContainers = 100
}
if env := os.Getenv("YAO_SANDBOX_MAX"); env != "" {
if v, err := strconv.Atoi(env); err == nil && v > 0 {
c.MaxContainers = v
}
// Invalid env value: keep existing/default value
}
// Idle timeout - set default first if zero, then try env override
if c.IdleTimeout == 0 {
c.IdleTimeout = 30 * time.Minute
}
if env := os.Getenv("YAO_SANDBOX_IDLE_TIMEOUT"); env != "" {
if v, err := time.ParseDuration(env); err == nil && v > 0 {
c.IdleTimeout = v
}
// Invalid env value: keep existing/default value
}
// Max memory
if env := os.Getenv("YAO_SANDBOX_MEMORY"); env != "" {
c.MaxMemory = env
} else if c.MaxMemory == "" {
c.MaxMemory = "2g"
}
// Max CPU - set default first if zero, then try env override
if c.MaxCPU == 0 {
c.MaxCPU = 1.0
}
if env := os.Getenv("YAO_SANDBOX_CPU"); env != "" {
if v, err := strconv.ParseFloat(env, 64); err == nil && v > 0 {
c.MaxCPU = v
}
// Invalid env value: keep existing/default value
}
}

230
sandbox/config_test.go Normal file
View file

@ -0,0 +1,230 @@
package sandbox
import (
"os"
"testing"
"time"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.Image != "yaoapp/sandbox-claude:latest" {
t.Errorf("expected default image 'yaoapp/sandbox-claude:latest', got '%s'", cfg.Image)
}
if cfg.MaxContainers != 100 {
t.Errorf("expected MaxContainers 100, got %d", cfg.MaxContainers)
}
if cfg.IdleTimeout != 30*time.Minute {
t.Errorf("expected IdleTimeout 30m, got %v", cfg.IdleTimeout)
}
if cfg.MaxMemory != "2g" {
t.Errorf("expected MaxMemory '2g', got '%s'", cfg.MaxMemory)
}
if cfg.MaxCPU != 1.0 {
t.Errorf("expected MaxCPU 1.0, got %f", cfg.MaxCPU)
}
}
func TestConfigInit(t *testing.T) {
// Test with dataRoot
cfg := &Config{}
cfg.Init("/tmp/yao-test")
if cfg.WorkspaceRoot != "/tmp/yao-test/sandbox/workspace" {
t.Errorf("expected WorkspaceRoot '/tmp/yao-test/sandbox/workspace', got '%s'", cfg.WorkspaceRoot)
}
if cfg.IPCDir != "/tmp/yao-test/sandbox/ipc" {
t.Errorf("expected IPCDir '/tmp/yao-test/sandbox/ipc', got '%s'", cfg.IPCDir)
}
}
func TestConfigInitWithEnv(t *testing.T) {
// Set environment variables
os.Setenv("YAO_SANDBOX_IMAGE", "test/image:v1")
os.Setenv("YAO_SANDBOX_MAX", "50")
os.Setenv("YAO_SANDBOX_IDLE_TIMEOUT", "15m")
os.Setenv("YAO_SANDBOX_MEMORY", "4g")
os.Setenv("YAO_SANDBOX_CPU", "2.5")
defer func() {
os.Unsetenv("YAO_SANDBOX_IMAGE")
os.Unsetenv("YAO_SANDBOX_MAX")
os.Unsetenv("YAO_SANDBOX_IDLE_TIMEOUT")
os.Unsetenv("YAO_SANDBOX_MEMORY")
os.Unsetenv("YAO_SANDBOX_CPU")
}()
cfg := &Config{}
cfg.Init("/tmp/yao-test")
if cfg.Image != "test/image:v1" {
t.Errorf("expected Image 'test/image:v1', got '%s'", cfg.Image)
}
if cfg.MaxContainers != 50 {
t.Errorf("expected MaxContainers 50, got %d", cfg.MaxContainers)
}
if cfg.IdleTimeout != 15*time.Minute {
t.Errorf("expected IdleTimeout 15m, got %v", cfg.IdleTimeout)
}
if cfg.MaxMemory != "4g" {
t.Errorf("expected MaxMemory '4g', got '%s'", cfg.MaxMemory)
}
if cfg.MaxCPU != 2.5 {
t.Errorf("expected MaxCPU 2.5, got %f", cfg.MaxCPU)
}
}
func TestConfigInitWithWorkspaceEnv(t *testing.T) {
// Test YAO_SANDBOX_WORKSPACE env var
os.Setenv("YAO_SANDBOX_WORKSPACE", "/custom/workspace")
os.Setenv("YAO_SANDBOX_IPC", "/custom/ipc")
defer func() {
os.Unsetenv("YAO_SANDBOX_WORKSPACE")
os.Unsetenv("YAO_SANDBOX_IPC")
}()
cfg := &Config{}
cfg.Init("/tmp/yao-test")
if cfg.WorkspaceRoot != "/custom/workspace" {
t.Errorf("expected WorkspaceRoot '/custom/workspace', got '%s'", cfg.WorkspaceRoot)
}
if cfg.IPCDir != "/custom/ipc" {
t.Errorf("expected IPCDir '/custom/ipc', got '%s'", cfg.IPCDir)
}
}
func TestConfigInitWithPresetValues(t *testing.T) {
// Test that preset values are not overwritten by defaults
cfg := &Config{
Image: "preset/image:v2",
MaxContainers: 200,
IdleTimeout: 1 * time.Hour,
MaxMemory: "8g",
MaxCPU: 4.0,
WorkspaceRoot: "/preset/workspace",
IPCDir: "/preset/ipc",
}
cfg.Init("/tmp/yao-test")
if cfg.Image != "preset/image:v2" {
t.Errorf("expected Image 'preset/image:v2', got '%s'", cfg.Image)
}
if cfg.MaxContainers != 200 {
t.Errorf("expected MaxContainers 200, got %d", cfg.MaxContainers)
}
if cfg.IdleTimeout != 1*time.Hour {
t.Errorf("expected IdleTimeout 1h, got %v", cfg.IdleTimeout)
}
if cfg.MaxMemory != "8g" {
t.Errorf("expected MaxMemory '8g', got '%s'", cfg.MaxMemory)
}
if cfg.MaxCPU != 4.0 {
t.Errorf("expected MaxCPU 4.0, got %f", cfg.MaxCPU)
}
if cfg.WorkspaceRoot != "/preset/workspace" {
t.Errorf("expected WorkspaceRoot '/preset/workspace', got '%s'", cfg.WorkspaceRoot)
}
if cfg.IPCDir != "/preset/ipc" {
t.Errorf("expected IPCDir '/preset/ipc', got '%s'", cfg.IPCDir)
}
}
func TestConfigInitInvalidEnvValues(t *testing.T) {
// Test with invalid environment values
os.Setenv("YAO_SANDBOX_MAX", "invalid")
os.Setenv("YAO_SANDBOX_IDLE_TIMEOUT", "not-a-duration")
os.Setenv("YAO_SANDBOX_CPU", "not-a-float")
defer func() {
os.Unsetenv("YAO_SANDBOX_MAX")
os.Unsetenv("YAO_SANDBOX_IDLE_TIMEOUT")
os.Unsetenv("YAO_SANDBOX_CPU")
}()
cfg := &Config{}
cfg.Init("/tmp/yao-test")
// Invalid env values should fall back to defaults
if cfg.MaxContainers != 100 {
t.Errorf("expected MaxContainers 100 (default), got %d", cfg.MaxContainers)
}
if cfg.IdleTimeout != 30*time.Minute {
t.Errorf("expected IdleTimeout 30m (default), got %v", cfg.IdleTimeout)
}
if cfg.MaxCPU != 1.0 {
t.Errorf("expected MaxCPU 1.0 (default), got %f", cfg.MaxCPU)
}
}
func TestConfigInitNegativeValues(t *testing.T) {
// Test with negative/zero values in env
os.Setenv("YAO_SANDBOX_MAX", "-5")
os.Setenv("YAO_SANDBOX_CPU", "-1.0")
defer func() {
os.Unsetenv("YAO_SANDBOX_MAX")
os.Unsetenv("YAO_SANDBOX_CPU")
}()
cfg := &Config{}
cfg.Init("/tmp/yao-test")
// Negative values should fall back to defaults
if cfg.MaxContainers != 100 {
t.Errorf("expected MaxContainers 100 (default), got %d", cfg.MaxContainers)
}
if cfg.MaxCPU != 1.0 {
t.Errorf("expected MaxCPU 1.0 (default), got %f", cfg.MaxCPU)
}
}
func TestConfigInitZeroMax(t *testing.T) {
// Test with zero max containers
os.Setenv("YAO_SANDBOX_MAX", "0")
defer os.Unsetenv("YAO_SANDBOX_MAX")
cfg := &Config{}
cfg.Init("/tmp/yao-test")
// Zero is rejected by v > 0 check, should use default
if cfg.MaxContainers != 100 {
t.Errorf("expected MaxContainers 100 (default), got %d", cfg.MaxContainers)
}
}
func TestContainerName(t *testing.T) {
tests := []struct {
userID string
chatID string
expected string
}{
{"user1", "chat1", "yao-sandbox-user1-chat1"},
{"u123", "c456", "yao-sandbox-u123-c456"},
{"test-user", "test-chat", "yao-sandbox-test-user-test-chat"},
{"", "", "yao-sandbox--"},
{"user_with_underscore", "chat-with-dash", "yao-sandbox-user_with_underscore-chat-with-dash"},
{"UPPERCASE", "lowercase", "yao-sandbox-UPPERCASE-lowercase"},
}
for _, tt := range tests {
result := containerName(tt.userID, tt.chatID)
if result != tt.expected {
t.Errorf("containerName(%s, %s) = %s, want %s", tt.userID, tt.chatID, result, tt.expected)
}
}
}
func TestConfigEnvPriority(t *testing.T) {
// Env vars should override preset config values
os.Setenv("YAO_SANDBOX_IMAGE", "env/override:latest")
defer os.Unsetenv("YAO_SANDBOX_IMAGE")
cfg := &Config{
Image: "preset/image:v1",
}
cfg.Init("/tmp/yao-test")
// Env should win
if cfg.Image != "env/override:latest" {
t.Errorf("expected Image 'env/override:latest' (from env), got '%s'", cfg.Image)
}
}

View file

@ -0,0 +1,33 @@
# Base image for Yao sandbox containers
# Supports both amd64 and arm64 architectures
FROM ubuntu:22.04
# Avoid interactive prompts
ENV DEBIAN_FRONTEND=noninteractive
# Base tools
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
ca-certificates \
gnupg \
lsb-release \
&& rm -rf /var/lib/apt/lists/*
# yao-bridge (architecture-specific binary)
# The build script copies the correct binary based on target architecture
ARG TARGETARCH
COPY yao-bridge-${TARGETARCH} /usr/local/bin/yao-bridge
RUN chmod +x /usr/local/bin/yao-bridge
# Working directory
WORKDIR /workspace
# Non-root user
RUN useradd -m -s /bin/bash sandbox && \
chown -R sandbox:sandbox /workspace
USER sandbox
CMD ["sleep", "infinity"]

144
sandbox/docker/build.sh Executable file
View file

@ -0,0 +1,144 @@
#!/bin/bash
# Build script for Yao sandbox Docker images
# Supports multi-architecture builds (amd64 and arm64)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOL=${1:-claude}
PUSH=${2:-false}
REGISTRY=${REGISTRY:-"yaoapp"} # Docker Hub registry
echo "=== Building Yao Sandbox Images ==="
echo "Tool: $TOOL"
echo "Push: $PUSH"
echo "Registry: $REGISTRY"
echo "Script dir: $SCRIPT_DIR"
# Build yao-bridge for both architectures
echo ""
echo "=== Building yao-bridge (multi-arch) ==="
cd "$SCRIPT_DIR/../bridge"
echo "Building for linux/amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/yao-bridge-amd64" .
echo "Building for linux/arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/yao-bridge-arm64" .
echo "Built: yao-bridge-amd64, yao-bridge-arm64"
cd "$SCRIPT_DIR"
# Check if buildx is available and set up
setup_buildx() {
echo ""
echo "=== Setting up Docker Buildx ==="
# Check if buildx is available
if ! docker buildx version > /dev/null 2>&1; then
echo "Error: Docker Buildx is not available. Please install it first."
exit 1
fi
# Create/use multi-arch builder
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
echo "Using existing builder: $BUILDER_NAME"
docker buildx use "$BUILDER_NAME"
fi
}
# Build multi-arch image
build_multiarch() {
local IMAGE_NAME=$1
local DOCKERFILE=$2
local PUSH_FLAG=$3
echo ""
echo "=== Building $IMAGE_NAME (linux/amd64,linux/arm64) ==="
BUILD_ARGS="--platform linux/amd64,linux/arm64 -t ${REGISTRY}/${IMAGE_NAME}:latest"
if [ "$PUSH_FLAG" = "true" ]; then
BUILD_ARGS="$BUILD_ARGS --push"
else
# Load to local Docker (only works for single platform)
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 "$DOCKERFILE" .
}
# Setup buildx for multi-arch builds
setup_buildx
# Build base image
echo ""
echo "=== Building base image ==="
build_multiarch "sandbox-base" "base/Dockerfile.base" "$PUSH"
# Build tool-specific images
case $TOOL in
claude)
echo ""
echo "=== Building Claude images ==="
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
;;
cursor)
echo ""
echo "=== Building Cursor images ==="
build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
;;
all)
echo ""
echo "=== Building all images ==="
# Claude
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
# Cursor (uncomment when ready)
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
;;
*)
echo "Unknown tool: $TOOL"
echo "Usage: $0 [claude|cursor|all] [true|false]"
echo " $0 claude # Build Claude images locally"
echo " $0 claude true # Build and push Claude images"
echo " $0 all true # Build and push all images"
exit 1
;;
esac
echo ""
echo "=== Build complete ==="
echo "Images built for tool: $TOOL"
if [ "$PUSH" = "true" ]; then
echo ""
echo "Images pushed to: $REGISTRY"
echo " - ${REGISTRY}/sandbox-base:latest"
case $TOOL in
claude)
echo " - ${REGISTRY}/sandbox-claude:latest"
echo " - ${REGISTRY}/sandbox-claude-full:latest"
;;
all)
echo " - ${REGISTRY}/sandbox-claude:latest"
echo " - ${REGISTRY}/sandbox-claude-full:latest"
;;
esac
fi
# Show local images
docker images | grep -E "(sandbox-base|sandbox-claude|sandbox-cursor)" | head -10 || true
# Cleanup
echo ""
echo "=== Cleanup ==="
rm -f "$SCRIPT_DIR/yao-bridge-amd64" "$SCRIPT_DIR/yao-bridge-arm64"
echo "Removed temporary binary files"

View file

@ -0,0 +1,42 @@
# Claude sandbox image: Claude CLI + Node.js + Python
# Supports both amd64 and arm64 architectures
ARG REGISTRY=yaoapp
FROM ${REGISTRY}/sandbox-base:latest
USER root
# Node.js 20 (automatically detects architecture)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Python 3.11
RUN apt-get update && apt-get install -y \
python3.11 \
python3-pip \
python3-venv \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/python3.11 /usr/bin/python3 \
&& ln -sf /usr/bin/python3 /usr/bin/python
# Claude CLI installation
# Using npm to install @anthropic-ai/claude-code globally
RUN npm install -g @anthropic-ai/claude-code || \
echo "Claude CLI installation skipped (may not be available yet)"
# npm global packages directory for sandbox user
RUN mkdir -p /home/sandbox/.npm-global && \
chown -R sandbox:sandbox /home/sandbox/.npm-global
USER sandbox
# Configure npm to use user directory
RUN npm config set prefix '/home/sandbox/.npm-global'
ENV PATH="/home/sandbox/.npm-global/bin:${PATH}"
# Verify installations
RUN node --version && npm --version && python3 --version
WORKDIR /workspace
CMD ["sleep", "infinity"]

View file

@ -0,0 +1,33 @@
# Full Claude sandbox image: + Go
# Supports both amd64 and arm64 architectures
ARG REGISTRY=yaoapp
FROM ${REGISTRY}/sandbox-claude:latest
USER root
# Go 1.23 - detect architecture and download appropriate version
RUN ARCH=$(dpkg --print-architecture) && \
case "$ARCH" in \
amd64) GOARCH="amd64" ;; \
arm64) GOARCH="arm64" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
curl -fsSL "https://go.dev/dl/go1.23.0.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xzf - && \
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
# Set up Go environment for sandbox user
RUN mkdir -p /home/sandbox/go && \
chown -R sandbox:sandbox /home/sandbox/go
USER sandbox
ENV GOPATH="/home/sandbox/go"
ENV PATH="${GOPATH}/bin:/usr/local/go/bin:${PATH}"
# Verify Go installation
RUN go version
WORKDIR /workspace
CMD ["sleep", "infinity"]

23
sandbox/errors.go Normal file
View file

@ -0,0 +1,23 @@
package sandbox
import "errors"
var (
// ErrTooManyContainers is returned when the maximum number of containers is reached
ErrTooManyContainers = errors.New("sandbox: too many running containers, please try again later")
// ErrContainerNotFound is returned when a container is not found
ErrContainerNotFound = errors.New("sandbox: container not found")
// ErrDockerNotAvailable is returned when Docker is not available
ErrDockerNotAvailable = errors.New("sandbox: Docker not available")
// ErrContainerNotRunning is returned when trying to execute on a non-running container
ErrContainerNotRunning = errors.New("sandbox: container is not running")
// ErrIPCSessionNotFound is returned when an IPC session is not found
ErrIPCSessionNotFound = errors.New("sandbox: IPC session not found")
// ErrToolNotAuthorized is returned when a tool is not authorized
ErrToolNotAuthorized = errors.New("sandbox: tool not found or not authorized")
)

314
sandbox/helpers.go Normal file
View file

@ -0,0 +1,314 @@
package sandbox
import (
"archive/tar"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// mapToSlice converts map to []string for environment variables
func mapToSlice(m map[string]string) []string {
if m == nil {
return nil
}
result := make([]string, 0, len(m))
for k, v := range m {
result = append(result, k+"="+v)
}
return result
}
// parseMemory converts string like "2g" to bytes
func parseMemory(s string) int64 {
if s == "" {
return 0
}
s = strings.ToLower(strings.TrimSpace(s))
if len(s) < 2 {
v, _ := strconv.ParseInt(s, 10, 64)
return v
}
unit := s[len(s)-1]
numStr := s[:len(s)-1]
num, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return 0
}
switch unit {
case 'k':
return int64(num * 1024)
case 'm':
return int64(num * 1024 * 1024)
case 'g':
return int64(num * 1024 * 1024 * 1024)
case 't':
return int64(num * 1024 * 1024 * 1024 * 1024)
default:
// Assume bytes if no unit
v, _ := strconv.ParseInt(s, 10, 64)
return v
}
}
// parseLS parses ls -la --time-style=+%s output to []FileInfo
func parseLS(output string) []FileInfo {
lines := strings.Split(strings.TrimSpace(output), "\n")
var result []FileInfo
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "total") {
continue
}
// Parse ls -la output: drwxr-xr-x 2 user group 4096 1234567890 filename
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
// Parse mode
modeStr := fields[0]
if len(modeStr) == 0 {
continue
}
mode := parseLSMode(modeStr)
// Parse size
size, _ := strconv.ParseInt(fields[4], 10, 64)
// Parse timestamp (Unix epoch)
timestamp, _ := strconv.ParseInt(fields[5], 10, 64)
modTime := time.Unix(timestamp, 0)
// Get filename (may contain spaces)
name := strings.Join(fields[6:], " ")
// Skip . and ..
if name == "." || name == ".." {
continue
}
result = append(result, FileInfo{
Name: name,
Size: size,
Mode: mode,
ModTime: modTime,
IsDir: modeStr[0] == 'd',
})
}
return result
}
// parseLSMode parses ls mode string to os.FileMode
func parseLSMode(s string) os.FileMode {
if len(s) < 10 {
return 0
}
var mode os.FileMode
// File type
switch s[0] {
case 'd':
mode |= os.ModeDir
case 'l':
mode |= os.ModeSymlink
case 'c':
mode |= os.ModeCharDevice
case 'b':
mode |= os.ModeDevice
case 'p':
mode |= os.ModeNamedPipe
case 's':
mode |= os.ModeSocket
}
// Permissions
perms := s[1:10]
permBits := []os.FileMode{
0400, 0200, 0100, // owner
0040, 0020, 0010, // group
0004, 0002, 0001, // other
}
for i, b := range perms {
if b != '-' && i < len(permBits) {
mode |= permBits[i]
}
}
return mode
}
// parseStat parses stat --format=%n|%s|%f|%Y|%F output to *FileInfo
func parseStat(output string) *FileInfo {
output = strings.TrimSpace(output)
parts := strings.Split(output, "|")
if len(parts) < 5 {
return nil
}
name := parts[0]
size, _ := strconv.ParseInt(parts[1], 10, 64)
modeHex, _ := strconv.ParseUint(parts[2], 16, 32)
timestamp, _ := strconv.ParseInt(parts[3], 10, 64)
fileType := parts[4]
return &FileInfo{
Name: filepath.Base(name),
Path: name,
Size: size,
Mode: os.FileMode(modeHex),
ModTime: time.Unix(timestamp, 0),
IsDir: strings.Contains(fileType, "directory"),
}
}
// createTarFromPath creates a tar archive from a host path
func createTarFromPath(hostPath string) (io.ReadCloser, error) {
// Validate path exists before starting goroutine
info, err := os.Stat(hostPath)
if err != nil {
return nil, fmt.Errorf("failed to stat path: %w", err)
}
pr, pw := io.Pipe()
go func() {
tw := tar.NewWriter(pw)
var finalErr error
defer func() {
tw.Close()
if finalErr != nil {
pw.CloseWithError(finalErr)
} else {
pw.Close()
}
}()
baseDir := filepath.Dir(hostPath)
walkFn := func(path string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
// Get relative path
relPath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
// Create header
header, err := tar.FileInfoHeader(fi, "")
if err != nil {
return err
}
header.Name = relPath
// Handle symlinks
if fi.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(path)
if err != nil {
return err
}
header.Linkname = link
}
if err := tw.WriteHeader(header); err != nil {
return err
}
// Write file content
if fi.Mode().IsRegular() {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(tw, f); err != nil {
return err
}
}
return nil
}
if info.IsDir() {
finalErr = filepath.Walk(hostPath, walkFn)
} else {
finalErr = walkFn(hostPath, info, nil)
}
}()
return pr, nil
}
// extractTarToPath extracts a tar archive to a host path
func extractTarToPath(reader io.Reader, hostPath string) error {
tr := tar.NewReader(reader)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("tar read error: %w", err)
}
target := filepath.Join(hostPath, header.Name)
// Security check: prevent path traversal
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(hostPath)) {
return fmt.Errorf("invalid tar path: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
return err
}
case tar.TypeReg:
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
f.Close()
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
os.Remove(target) // Remove existing symlink if any
if err := os.Symlink(header.Linkname, target); err != nil {
return err
}
}
}
return nil
}
// containerName generates a container name from userID and chatID
func containerName(userID, chatID string) string {
return fmt.Sprintf("yao-sandbox-%s-%s", userID, chatID)
}

189
sandbox/helpers_test.go Normal file
View file

@ -0,0 +1,189 @@
package sandbox
import (
"os"
"testing"
)
func TestParseMemory(t *testing.T) {
tests := []struct {
input string
expected int64
}{
{"1024", 1024},
{"1k", 1024},
{"1K", 1024},
{"1m", 1024 * 1024},
{"1M", 1024 * 1024},
{"2g", 2 * 1024 * 1024 * 1024},
{"2G", 2 * 1024 * 1024 * 1024},
{"1t", 1024 * 1024 * 1024 * 1024},
{"1.5g", int64(1.5 * 1024 * 1024 * 1024)},
{"", 0},
{"invalid", 0},
}
for _, tt := range tests {
result := parseMemory(tt.input)
if result != tt.expected {
t.Errorf("parseMemory(%s) = %d, want %d", tt.input, result, tt.expected)
}
}
}
func TestMapToSlice(t *testing.T) {
// Nil map
result := mapToSlice(nil)
if result != nil {
t.Errorf("mapToSlice(nil) should return nil")
}
// Empty map
result = mapToSlice(map[string]string{})
if len(result) != 0 {
t.Errorf("mapToSlice(empty) should return empty slice")
}
// Map with values
m := map[string]string{
"KEY1": "value1",
"KEY2": "value2",
}
result = mapToSlice(m)
if len(result) != 2 {
t.Errorf("expected 2 items, got %d", len(result))
}
// Check that all items are in format KEY=value
found := make(map[string]bool)
for _, item := range result {
found[item] = true
}
if !found["KEY1=value1"] || !found["KEY2=value2"] {
t.Errorf("unexpected result: %v", result)
}
}
func TestParseLS(t *testing.T) {
output := `total 8
drwxr-xr-x 2 sandbox sandbox 4096 1700000000 dir1
-rw-r--r-- 1 sandbox sandbox 100 1700000001 file1.txt
lrwxrwxrwx 1 sandbox sandbox 10 1700000002 link1 -> file1.txt
`
result := parseLS(output)
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
// Check dir1
if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
}
if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory")
}
// Check file1.txt
if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
}
if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size)
}
if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory")
}
// Check link1
if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name)
}
}
func TestParseStat(t *testing.T) {
output := "/workspace/test.txt|1024|81a4|1700000000|regular file"
result := parseStat(output)
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Name != "test.txt" {
t.Errorf("expected name 'test.txt', got '%s'", result.Name)
}
if result.Path != "/workspace/test.txt" {
t.Errorf("expected path '/workspace/test.txt', got '%s'", result.Path)
}
if result.Size != 1024 {
t.Errorf("expected size 1024, got %d", result.Size)
}
if result.IsDir {
t.Errorf("expected IsDir to be false")
}
}
func TestParseLSMode(t *testing.T) {
tests := []struct {
input string
isDir bool
readable bool
}{
{"drwxr-xr-x", true, true},
{"-rw-r--r--", false, true},
{"lrwxrwxrwx", false, true},
{"-rwx------", false, true},
}
for _, tt := range tests {
mode := parseLSMode(tt.input)
isDir := mode.IsDir()
if isDir != tt.isDir {
t.Errorf("parseLSMode(%s).IsDir() = %v, want %v", tt.input, isDir, tt.isDir)
}
}
}
func TestCreateAndExtractTar(t *testing.T) {
// Create temp directory with test files
tmpDir, err := os.MkdirTemp("", "sandbox-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create test file
testFile := tmpDir + "/test.txt"
if err := os.WriteFile(testFile, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
// Create tar from file
reader, err := createTarFromPath(testFile)
if err != nil {
t.Fatalf("createTarFromPath failed: %v", err)
}
defer reader.Close()
// Extract to new location
extractDir, err := os.MkdirTemp("", "sandbox-extract-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(extractDir)
if err := extractTarToPath(reader, extractDir); err != nil {
t.Fatalf("extractTarToPath failed: %v", err)
}
// Verify extracted file
extractedFile := extractDir + "/test.txt"
content, err := os.ReadFile(extractedFile)
if err != nil {
t.Fatalf("failed to read extracted file: %v", err)
}
if string(content) != "hello world" {
t.Errorf("expected 'hello world', got '%s'", string(content))
}
}

235
sandbox/ipc/jsonrpc_test.go Normal file
View file

@ -0,0 +1,235 @@
package ipc
import (
"encoding/json"
"testing"
)
func TestJSONRPCRequestParsing(t *testing.T) {
tests := []struct {
name string
input string
expected JSONRPCRequest
}{
{
name: "initialize request",
input: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}`,
expected: JSONRPCRequest{
JSONRPC: "2.0",
ID: float64(1), // JSON numbers are float64
Method: "initialize",
},
},
{
name: "tools/list request",
input: `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`,
expected: JSONRPCRequest{
JSONRPC: "2.0",
ID: float64(2),
Method: "tools/list",
},
},
{
name: "tools/call request",
input: `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"test","arguments":{}}}`,
expected: JSONRPCRequest{
JSONRPC: "2.0",
ID: float64(3),
Method: "tools/call",
},
},
{
name: "notification (no id)",
input: `{"jsonrpc":"2.0","method":"initialized"}`,
expected: JSONRPCRequest{
JSONRPC: "2.0",
Method: "initialized",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req JSONRPCRequest
if err := json.Unmarshal([]byte(tt.input), &req); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if req.JSONRPC != tt.expected.JSONRPC {
t.Errorf("JSONRPC = %s, want %s", req.JSONRPC, tt.expected.JSONRPC)
}
if req.Method != tt.expected.Method {
t.Errorf("Method = %s, want %s", req.Method, tt.expected.Method)
}
if tt.expected.ID != nil && req.ID != tt.expected.ID {
t.Errorf("ID = %v, want %v", req.ID, tt.expected.ID)
}
})
}
}
func TestJSONRPCResponseSerialization(t *testing.T) {
// Success response
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: 1,
Result: map[string]interface{}{
"protocolVersion": "2024-11-05",
},
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
// Verify it can be parsed back
var parsed JSONRPCResponse
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.JSONRPC != "2.0" {
t.Errorf("JSONRPC = %s, want 2.0", parsed.JSONRPC)
}
if parsed.Error != nil {
t.Errorf("Error should be nil")
}
}
func TestJSONRPCErrorResponse(t *testing.T) {
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: 1,
Error: &JSONRPCError{
Code: ErrCodeMethodNotFound,
Message: "Method not found",
},
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed JSONRPCResponse
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.Error == nil {
t.Fatal("Error should not be nil")
}
if parsed.Error.Code != ErrCodeMethodNotFound {
t.Errorf("Error.Code = %d, want %d", parsed.Error.Code, ErrCodeMethodNotFound)
}
if parsed.Error.Message != "Method not found" {
t.Errorf("Error.Message = %s, want 'Method not found'", parsed.Error.Message)
}
}
func TestToolCallParams(t *testing.T) {
input := `{"name":"my_tool","arguments":{"key":"value","num":42}}`
var params ToolCallParams
if err := json.Unmarshal([]byte(input), &params); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if params.Name != "my_tool" {
t.Errorf("Name = %s, want my_tool", params.Name)
}
if params.Arguments["key"] != "value" {
t.Errorf("Arguments[key] = %v, want value", params.Arguments["key"])
}
if params.Arguments["num"] != float64(42) {
t.Errorf("Arguments[num] = %v, want 42", params.Arguments["num"])
}
}
func TestToolResult(t *testing.T) {
result := ToolResult{
Content: []ToolContent{
{Type: "text", Text: "Hello, world!"},
},
}
data, err := json.Marshal(result)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed ToolResult
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(parsed.Content) != 1 {
t.Fatalf("expected 1 content item, got %d", len(parsed.Content))
}
if parsed.Content[0].Type != "text" {
t.Errorf("Content[0].Type = %s, want text", parsed.Content[0].Type)
}
if parsed.Content[0].Text != "Hello, world!" {
t.Errorf("Content[0].Text = %s, want 'Hello, world!'", parsed.Content[0].Text)
}
}
func TestToolsListResult(t *testing.T) {
result := ToolsListResult{
Tools: []Tool{
{
Name: "tool1",
Description: "Test tool",
InputSchema: json.RawMessage(`{"type":"object"}`),
},
},
}
data, err := json.Marshal(result)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed ToolsListResult
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(parsed.Tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(parsed.Tools))
}
if parsed.Tools[0].Name != "tool1" {
t.Errorf("Tools[0].Name = %s, want tool1", parsed.Tools[0].Name)
}
}
func TestInitializeResult(t *testing.T) {
result := InitializeResult{
ProtocolVersion: "2024-11-05",
Capabilities: Capabilities{
Tools: &ToolsCapability{},
},
ServerInfo: ServerInfo{
Name: "yao-sandbox",
Version: "1.0.0",
},
}
data, err := json.Marshal(result)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed InitializeResult
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.ProtocolVersion != "2024-11-05" {
t.Errorf("ProtocolVersion = %s, want 2024-11-05", parsed.ProtocolVersion)
}
if parsed.ServerInfo.Name != "yao-sandbox" {
t.Errorf("ServerInfo.Name = %s, want yao-sandbox", parsed.ServerInfo.Name)
}
}

100
sandbox/ipc/manager.go Normal file
View file

@ -0,0 +1,100 @@
package ipc
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"sync"
)
// Manager manages IPC sessions
type Manager struct {
sessions sync.Map // sessionID → *Session
sockDir string // Socket directory
}
// NewManager creates a new IPC manager
func NewManager(sockDir string) *Manager {
return &Manager{
sockDir: sockDir,
}
}
// Create creates a new IPC session
func (m *Manager) Create(ctx context.Context, sessionID string, agentCtx *AgentContext, mcpTools map[string]*MCPTool) (*Session, error) {
// Close existing session if any
m.Close(sessionID)
// Create socket path
socketPath := filepath.Join(m.sockDir, sessionID+".sock")
// Ensure directory exists
if err := os.MkdirAll(m.sockDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create socket directory: %w", err)
}
// Remove existing socket file if any
os.Remove(socketPath)
// Create Unix socket listener
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, fmt.Errorf("failed to create Unix socket: %w", err)
}
// Set socket permissions (readable/writable by owner and group)
if err := os.Chmod(socketPath, 0660); err != nil {
listener.Close()
os.Remove(socketPath)
return nil, fmt.Errorf("failed to set socket permissions: %w", err)
}
// Create cancellable context
sessionCtx, cancel := context.WithCancel(ctx)
session := &Session{
ID: sessionID,
SocketPath: socketPath,
Listener: listener,
Context: agentCtx,
MCPTools: mcpTools,
cancel: cancel,
}
// Start serving in background
go session.serve(sessionCtx)
// Store session
m.sessions.Store(sessionID, session)
return session, nil
}
// Close closes an IPC session
func (m *Manager) Close(sessionID string) error {
if s, ok := m.sessions.LoadAndDelete(sessionID); ok {
session := s.(*Session)
return session.Close()
}
return nil
}
// Get returns an existing session
func (m *Manager) Get(sessionID string) (*Session, bool) {
if s, ok := m.sessions.Load(sessionID); ok {
return s.(*Session), true
}
return nil, false
}
// CloseAll closes all sessions
func (m *Manager) CloseAll() {
m.sessions.Range(func(key, value interface{}) bool {
session := value.(*Session)
session.Close()
m.sessions.Delete(key)
return true
})
}

638
sandbox/ipc/manager_test.go Normal file
View file

@ -0,0 +1,638 @@
package ipc
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// TestNewManager tests IPC manager creation
func TestNewManager(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-manager-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
if m == nil {
t.Fatal("NewManager returned nil")
}
if m.sockDir != tmpDir {
t.Errorf("Expected sockDir %s, got %s", tmpDir, m.sockDir)
}
}
// TestCreateSession tests creating an IPC session
func TestCreateSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-session-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-session-1"
agentCtx := &AgentContext{
UserID: "user1",
ChatID: "chat1",
Locale: "en-US",
}
mcpTools := map[string]*MCPTool{
"test_tool": {
Name: "test_tool",
Description: "A test tool",
Process: "scripts.test.hello",
InputSchema: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"}}}`),
},
}
session, err := m.Create(ctx, sessionID, agentCtx, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
// Verify session properties
if session.ID != sessionID {
t.Errorf("Expected session ID %s, got %s", sessionID, session.ID)
}
expectedSocketPath := filepath.Join(tmpDir, sessionID+".sock")
if session.SocketPath != expectedSocketPath {
t.Errorf("Expected socket path %s, got %s", expectedSocketPath, session.SocketPath)
}
if session.Context.UserID != "user1" {
t.Errorf("Expected UserID user1, got %s", session.Context.UserID)
}
if len(session.MCPTools) != 1 {
t.Errorf("Expected 1 MCP tool, got %d", len(session.MCPTools))
}
// Verify socket file exists
if _, err := os.Stat(session.SocketPath); os.IsNotExist(err) {
t.Error("Socket file should exist")
}
}
// TestGetSession tests retrieving a session
func TestGetSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-get-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-get-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
// Get non-existent session
_, ok := m.Get(sessionID)
if ok {
t.Error("Get should return false for non-existent session")
}
// Create session
_, err = m.Create(ctx, sessionID, agentCtx, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
// Get existing session
session, ok := m.Get(sessionID)
if !ok {
t.Error("Get should return true for existing session")
}
if session.ID != sessionID {
t.Errorf("Expected session ID %s, got %s", sessionID, session.ID)
}
}
// TestCloseSession tests closing a session
func TestCloseSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-close-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-close-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
session, err := m.Create(ctx, sessionID, agentCtx, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
socketPath := session.SocketPath
// Close session
err = m.Close(sessionID)
if err != nil {
t.Fatalf("Close session failed: %v", err)
}
// Verify session is removed
_, ok := m.Get(sessionID)
if ok {
t.Error("Session should be removed after close")
}
// Verify socket file is removed (give it a moment)
time.Sleep(100 * time.Millisecond)
if _, err := os.Stat(socketPath); !os.IsNotExist(err) {
t.Error("Socket file should be removed after close")
}
}
// TestCloseNonExistentSession tests closing a non-existent session
func TestCloseNonExistentSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-close-nonexist-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
// Should not error
err = m.Close("nonexistent-session")
if err != nil {
t.Errorf("Close non-existent session should not error: %v", err)
}
}
// TestCloseAllSessions tests closing all sessions
func TestCloseAllSessions(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-closeall-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
// Create multiple sessions
sessionIDs := []string{"session-1", "session-2", "session-3"}
for _, id := range sessionIDs {
_, err := m.Create(ctx, id, &AgentContext{UserID: "user", ChatID: id}, nil)
if err != nil {
t.Fatalf("Create session %s failed: %v", id, err)
}
}
// Verify sessions exist
for _, id := range sessionIDs {
if _, ok := m.Get(id); !ok {
t.Errorf("Session %s should exist", id)
}
}
// Close all
m.CloseAll()
// Verify all sessions are removed
time.Sleep(100 * time.Millisecond)
for _, id := range sessionIDs {
if _, ok := m.Get(id); ok {
t.Errorf("Session %s should be removed after CloseAll", id)
}
}
}
// TestSessionReplace tests that creating a session with existing ID replaces it
func TestSessionReplace(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-replace-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-replace-session"
// Create first session
session1, err := m.Create(ctx, sessionID, &AgentContext{UserID: "user1", ChatID: "chat1"}, nil)
if err != nil {
t.Fatalf("Create first session failed: %v", err)
}
socketPath1 := session1.SocketPath
// Create second session with same ID
session2, err := m.Create(ctx, sessionID, &AgentContext{UserID: "user2", ChatID: "chat2"}, nil)
if err != nil {
t.Fatalf("Create second session failed: %v", err)
}
defer m.Close(sessionID)
// Verify second session replaced first
if session2.Context.UserID != "user2" {
t.Errorf("Expected UserID user2, got %s", session2.Context.UserID)
}
// Get session should return second
session, ok := m.Get(sessionID)
if !ok {
t.Error("Get should return session")
}
if session.Context.UserID != "user2" {
t.Errorf("Expected UserID user2 from Get, got %s", session.Context.UserID)
}
// Same socket path should be reused
if session2.SocketPath != socketPath1 {
t.Errorf("Expected same socket path, got %s vs %s", socketPath1, session2.SocketPath)
}
}
// TestConcurrentSessionAccess tests concurrent access to sessions
func TestConcurrentSessionAccess(t *testing.T) {
// Use /tmp for shorter socket path (macOS has 104 char limit for Unix sockets)
tmpDir, err := os.MkdirTemp("/tmp", "ipc-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
var wg sync.WaitGroup
var mu sync.Mutex
errors := make([]error, 0)
numGoroutines := 5 // Reduced for stability
// Concurrent creates
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sessionID := fmt.Sprintf("s%d", idx) // Short session ID
_, err := m.Create(ctx, sessionID, &AgentContext{UserID: "user", ChatID: sessionID}, nil)
if err != nil {
mu.Lock()
errors = append(errors, fmt.Errorf("session %s: %v", sessionID, err))
mu.Unlock()
}
}(i)
}
wg.Wait()
// Check errors
for _, err := range errors {
t.Errorf("Concurrent create error: %v", err)
}
// Verify all sessions exist
for i := 0; i < numGoroutines; i++ {
sessionID := fmt.Sprintf("s%d", i)
if _, ok := m.Get(sessionID); !ok {
t.Errorf("Session %s should exist", sessionID)
}
}
// Cleanup
m.CloseAll()
}
// TestSessionConnection tests connecting to a session socket
func TestSessionConnection(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-connect-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-connect-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
mcpTools := map[string]*MCPTool{}
session, err := m.Create(ctx, sessionID, agentCtx, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
// Give the listener time to start
time.Sleep(50 * time.Millisecond)
// Try to connect to the socket
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send initialize request
initReq := JSONRPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: "initialize",
Params: json.RawMessage(`{"protocolVersion":"2024-11-05"}`),
}
data, _ := json.Marshal(initReq)
// Write with newline (NDJSON)
_, err = conn.Write(append(data, '\n'))
if err != nil {
t.Fatalf("Failed to write to socket: %v", err)
}
// Set read deadline
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
// Read response
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
// Parse response
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v (raw: %s)", err, string(buf[:n]))
}
if resp.JSONRPC != "2.0" {
t.Errorf("Expected JSONRPC 2.0, got %s", resp.JSONRPC)
}
if resp.Error != nil {
t.Errorf("Unexpected error: %v", resp.Error)
}
if resp.Result == nil {
t.Error("Expected result, got nil")
}
}
// TestToolsList tests the tools/list method
func TestToolsList(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-tools-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
sessionID := "test-tools-session"
agentCtx := &AgentContext{UserID: "user1", ChatID: "chat1"}
mcpTools := map[string]*MCPTool{
"tool1": {
Name: "tool1",
Description: "First test tool",
Process: "scripts.test.tool1",
InputSchema: json.RawMessage(`{"type":"object"}`),
},
"tool2": {
Name: "tool2",
Description: "Second test tool",
Process: "scripts.test.tool2",
InputSchema: json.RawMessage(`{"type":"object","properties":{"arg":{"type":"string"}}}`),
},
}
session, err := m.Create(ctx, sessionID, agentCtx, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close(sessionID)
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send tools/list request
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 2,
Method: "tools/list",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
// Read response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
// Parse result as ToolsListResult
resultBytes, _ := json.Marshal(resp.Result)
var toolsResult ToolsListResult
if err := json.Unmarshal(resultBytes, &toolsResult); err != nil {
t.Fatalf("Failed to parse tools result: %v", err)
}
if len(toolsResult.Tools) != 2 {
t.Errorf("Expected 2 tools, got %d", len(toolsResult.Tools))
}
// Verify tool names
toolNames := make(map[string]bool)
for _, tool := range toolsResult.Tools {
toolNames[tool.Name] = true
}
if !toolNames["tool1"] {
t.Error("Expected tool1 in tools list")
}
if !toolNames["tool2"] {
t.Error("Expected tool2 in tools list")
}
}
// TestMethodNotFound tests handling of unknown methods
func TestMethodNotFound(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-notfound-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "test-notfound", &AgentContext{UserID: "user", ChatID: "chat"}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("test-notfound")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send unknown method
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 3,
Method: "unknown/method",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
// Read response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp.Error == nil {
t.Error("Expected error for unknown method")
}
if resp.Error != nil && resp.Error.Code != ErrCodeMethodNotFound {
t.Errorf("Expected error code %d, got %d", ErrCodeMethodNotFound, resp.Error.Code)
}
}
// TestParseError tests handling of invalid JSON
func TestParseError(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-parse-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "test-parse", &AgentContext{UserID: "user", ChatID: "chat"}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("test-parse")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send invalid JSON
conn.Write([]byte("not valid json\n"))
// Read response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Failed to read from socket: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp.Error == nil {
t.Error("Expected error for invalid JSON")
}
if resp.Error != nil && resp.Error.Code != ErrCodeParse {
t.Errorf("Expected error code %d, got %d", ErrCodeParse, resp.Error.Code)
}
}
// TestInitializedNotification tests that initialized notification doesn't return response
func TestInitializedNotification(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ipc-initialized-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "test-initialized", &AgentContext{UserID: "user", ChatID: "chat"}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("test-initialized")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect to socket: %v", err)
}
defer conn.Close()
// Send initialized notification (no ID = notification)
req := JSONRPCRequest{
JSONRPC: "2.0",
Method: "initialized",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
// Set short read deadline - we expect timeout since no response
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
buf := make([]byte, 4096)
_, err = conn.Read(buf)
// Expect timeout (no response for notifications)
if err == nil {
t.Error("Expected no response for notification")
}
}

275
sandbox/ipc/session.go Normal file
View file

@ -0,0 +1,275 @@
package ipc
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"os"
"github.com/yaoapp/gou/process"
)
// Close closes the session and cleans up resources
func (s *Session) Close() error {
if s.cancel != nil {
s.cancel()
}
if s.Conn != nil {
s.Conn.Close()
}
if s.Listener != nil {
s.Listener.Close()
}
// Remove socket file
os.Remove(s.SocketPath)
return nil
}
// serve handles incoming connections
func (s *Session) serve(ctx context.Context) {
defer s.cleanup()
for {
select {
case <-ctx.Done():
return
default:
}
// Accept connection with deadline to allow context cancellation check
conn, err := s.Listener.Accept()
if err != nil {
// Check if context was cancelled
select {
case <-ctx.Done():
return
default:
continue
}
}
s.Conn = conn
s.handleConnection(ctx, conn)
}
}
// cleanup cleans up session resources
func (s *Session) cleanup() {
if s.Conn != nil {
s.Conn.Close()
}
if s.Listener != nil {
s.Listener.Close()
}
os.Remove(s.SocketPath)
}
// handleConnection handles a single connection
func (s *Session) handleConnection(ctx context.Context, conn net.Conn) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
// Increase buffer size for large messages
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
line := scanner.Text()
if line == "" {
continue
}
response := s.handleMessage(line)
if response != "" {
if _, err := conn.Write([]byte(response + "\n")); err != nil {
// Connection error, stop processing
return
}
}
}
// Check for scanner errors (excluding EOF which is normal)
if err := scanner.Err(); err != nil {
// Log error but don't return it since this is a goroutine
// In production, consider adding structured logging
_ = err
}
}
// handleMessage processes a single JSON-RPC message
func (s *Session) handleMessage(line string) string {
var req JSONRPCRequest
if err := json.Unmarshal([]byte(line), &req); err != nil {
return s.errorResponse(nil, ErrCodeParse, "Parse error")
}
switch req.Method {
case "initialize":
return s.handleInitialize(req)
case "initialized":
return "" // notification, no response
case "tools/list":
return s.handleListTools(req)
case "tools/call":
return s.handleCallTool(req)
case "resources/list":
return s.handleListResources(req)
case "resources/read":
return s.handleReadResource(req)
default:
return s.errorResponse(req.ID, ErrCodeMethodNotFound, "Method not found: "+req.Method)
}
}
// handleInitialize handles the initialize method
func (s *Session) handleInitialize(req JSONRPCRequest) string {
result := InitializeResult{
ProtocolVersion: "2024-11-05",
Capabilities: Capabilities{
Tools: &ToolsCapability{},
},
ServerInfo: ServerInfo{
Name: "yao-sandbox",
Version: "1.0.0",
},
}
return s.successResponse(req.ID, result)
}
// handleListTools handles the tools/list method
func (s *Session) handleListTools(req JSONRPCRequest) string {
tools := make([]Tool, 0, len(s.MCPTools))
for _, mcpTool := range s.MCPTools {
tools = append(tools, Tool{
Name: mcpTool.Name,
Description: mcpTool.Description,
InputSchema: mcpTool.InputSchema,
})
}
return s.successResponse(req.ID, ToolsListResult{Tools: tools})
}
// handleCallTool handles the tools/call method
func (s *Session) handleCallTool(req JSONRPCRequest) string {
var params ToolCallParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return s.errorResponse(req.ID, ErrCodeInvalidParams, "Invalid params")
}
// Check authorization
tool, ok := s.MCPTools[params.Name]
if !ok {
return s.errorResponse(req.ID, ErrCodeInvalidParams, "Tool not found or not authorized: "+params.Name)
}
// Execute Yao Process
proc := process.New(tool.Process, params.Arguments)
// Set context if available
if s.Context != nil {
// TODO: Set process context with user info
}
result, err := proc.Exec()
if err != nil {
return s.toolErrorResponse(req.ID, params.Name, err)
}
return s.toolSuccessResponse(req.ID, result)
}
// handleListResources handles the resources/list method
func (s *Session) handleListResources(req JSONRPCRequest) string {
// Return empty resources list for now
return s.successResponse(req.ID, map[string]interface{}{
"resources": []interface{}{},
})
}
// handleReadResource handles the resources/read method
func (s *Session) handleReadResource(req JSONRPCRequest) string {
return s.errorResponse(req.ID, ErrCodeInvalidParams, "Resource not found")
}
// successResponse creates a JSON-RPC success response
func (s *Session) successResponse(id interface{}, result interface{}) string {
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
}
data, err := json.Marshal(resp)
if err != nil {
// Fallback to error response if marshaling fails
return s.errorResponse(id, ErrCodeInternal, "Failed to marshal response")
}
return string(data)
}
// errorResponse creates a JSON-RPC error response
func (s *Session) errorResponse(id interface{}, code int, message string) string {
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Error: &JSONRPCError{
Code: code,
Message: message,
},
}
data, err := json.Marshal(resp)
if err != nil {
// Absolute fallback - manually construct JSON
return fmt.Sprintf(`{"jsonrpc":"2.0","id":null,"error":{"code":%d,"message":"Internal error"}}`, ErrCodeInternal)
}
return string(data)
}
// toolSuccessResponse creates a tool success response
func (s *Session) toolSuccessResponse(id interface{}, result interface{}) string {
// Convert result to string
var text string
switch v := result.(type) {
case string:
text = v
case []byte:
text = string(v)
case nil:
text = "null"
default:
data, err := json.Marshal(result)
if err != nil {
text = fmt.Sprintf("%v", result)
} else {
text = string(data)
}
}
toolResult := ToolResult{
Content: []ToolContent{
{Type: "text", Text: text},
},
}
return s.successResponse(id, toolResult)
}
// toolErrorResponse creates a tool error response
func (s *Session) toolErrorResponse(id interface{}, toolName string, err error) string {
toolResult := ToolResult{
Content: []ToolContent{
{Type: "text", Text: fmt.Sprintf("Error executing %s: %v", toolName, err)},
},
IsError: true,
}
return s.successResponse(id, toolResult)
}

630
sandbox/ipc/session_test.go Normal file
View file

@ -0,0 +1,630 @@
package ipc
import (
"context"
"encoding/json"
"net"
"os"
"testing"
"time"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestSessionHandleInitialize tests the initialize handler
func TestSessionHandleInitialize(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "session-init-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "init-test", &AgentContext{
UserID: "user1",
ChatID: "chat1",
Locale: "en-US",
}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("init-test")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Send initialize
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: "initialize",
Params: json.RawMessage(`{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"clientInfo": {"name": "test-client", "version": "1.0.0"}
}`),
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
// Parse result
resultBytes, _ := json.Marshal(resp.Result)
var initResult InitializeResult
if err := json.Unmarshal(resultBytes, &initResult); err != nil {
t.Fatalf("Failed to parse init result: %v", err)
}
if initResult.ProtocolVersion != "2024-11-05" {
t.Errorf("Expected protocol version 2024-11-05, got %s", initResult.ProtocolVersion)
}
if initResult.ServerInfo.Name != "yao-sandbox" {
t.Errorf("Expected server name yao-sandbox, got %s", initResult.ServerInfo.Name)
}
if initResult.Capabilities.Tools == nil {
t.Error("Expected tools capability")
}
}
// TestSessionHandleResourcesList tests the resources/list handler
func TestSessionHandleResourcesList(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "session-resources-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "resources-test", &AgentContext{
UserID: "user1",
ChatID: "chat1",
}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("resources-test")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 2,
Method: "resources/list",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
// Result should have empty resources array
resultMap, ok := resp.Result.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result")
}
resources, ok := resultMap["resources"].([]interface{})
if !ok {
t.Fatalf("Expected resources array")
}
if len(resources) != 0 {
t.Errorf("Expected empty resources, got %d", len(resources))
}
}
// TestSessionHandleResourcesRead tests the resources/read handler
func TestSessionHandleResourcesRead(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "session-read-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "read-test", &AgentContext{
UserID: "user1",
ChatID: "chat1",
}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("read-test")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 3,
Method: "resources/read",
Params: json.RawMessage(`{"uri": "test://resource"}`),
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
// Should return error (resource not found)
if resp.Error == nil {
t.Error("Expected error for non-existent resource")
}
if resp.Error != nil && resp.Error.Code != ErrCodeInvalidParams {
t.Errorf("Expected error code %d, got %d", ErrCodeInvalidParams, resp.Error.Code)
}
}
// TestSessionHandleToolsCallInvalidParams tests tools/call with invalid params
func TestSessionHandleToolsCallInvalidParams(t *testing.T) {
// Use /tmp for shorter socket path
tmpDir, err := os.MkdirTemp("/tmp", "ipc-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "inv", &AgentContext{
UserID: "user1",
ChatID: "chat1",
}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("inv")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Invalid params (not valid JSON object)
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 4,
Method: "tools/call",
Params: json.RawMessage(`"not an object"`),
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if resp.Error == nil {
t.Error("Expected error for invalid params")
}
if resp.Error != nil && resp.Error.Code != ErrCodeInvalidParams {
t.Errorf("Expected error code %d, got %d", ErrCodeInvalidParams, resp.Error.Code)
}
}
// TestSessionHandleToolsCallUnauthorized tests tools/call with unauthorized tool
func TestSessionHandleToolsCallUnauthorized(t *testing.T) {
// Use /tmp for shorter socket path
tmpDir, err := os.MkdirTemp("/tmp", "ipc-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
// Create session with one tool
mcpTools := map[string]*MCPTool{
"allowed_tool": {
Name: "allowed_tool",
Description: "An allowed tool",
Process: "scripts.test.allowed",
InputSchema: json.RawMessage(`{"type":"object"}`),
},
}
session, err := m.Create(ctx, "una", &AgentContext{
UserID: "user1",
ChatID: "chat1",
}, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("una")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Try to call unauthorized tool
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 5,
Method: "tools/call",
Params: json.RawMessage(`{"name": "unauthorized_tool", "arguments": {}}`),
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if resp.Error == nil {
t.Error("Expected error for unauthorized tool")
}
if resp.Error != nil && resp.Error.Code != ErrCodeInvalidParams {
t.Errorf("Expected error code %d, got %d", ErrCodeInvalidParams, resp.Error.Code)
}
}
// TestSessionToolsCallWithYaoApp tests tools/call with Yao app loaded
// This is the full integration test
func TestSessionToolsCallWithYaoApp(t *testing.T) {
// Check if YAO_TEST_APPLICATION is set
if os.Getenv("YAO_TEST_APPLICATION") == "" {
t.Skip("Skipping: YAO_TEST_APPLICATION not set")
}
// Prepare Yao test environment
test.Prepare(t, config.Conf)
defer test.Clean()
tmpDir, err := os.MkdirTemp("", "session-yao-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
// Create session with a Yao process tool
mcpTools := map[string]*MCPTool{
"yao_utils_now": {
Name: "yao_utils_now",
Description: "Get current time",
Process: "utils.now.Timestamp",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
}
session, err := m.Create(ctx, "yao-tool-test", &AgentContext{
UserID: "user1",
ChatID: "chat1",
Locale: "en-US",
}, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("yao-tool-test")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Call Yao process
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 10,
Method: "tools/call",
Params: json.RawMessage(`{"name": "yao_utils_now", "arguments": {}}`),
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal failed: %v (raw: %s)", err, string(buf[:n]))
}
if resp.Error != nil {
t.Logf("Tool call error: %v", resp.Error)
// This is expected if the process doesn't exist in test app
// The important thing is the IPC communication worked
return
}
// Parse tool result
resultBytes, _ := json.Marshal(resp.Result)
var toolResult ToolResult
if err := json.Unmarshal(resultBytes, &toolResult); err != nil {
t.Fatalf("Failed to parse tool result: %v", err)
}
if len(toolResult.Content) == 0 {
t.Error("Expected tool result content")
}
if toolResult.IsError {
t.Errorf("Tool returned error: %v", toolResult.Content)
}
t.Logf("Tool result: %v", toolResult.Content)
}
// TestSessionMultipleRequests tests multiple requests over single connection
func TestSessionMultipleRequests(t *testing.T) {
// Use /tmp for shorter socket path
tmpDir, err := os.MkdirTemp("/tmp", "ipc-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
mcpTools := map[string]*MCPTool{
"test_tool": {
Name: "test_tool",
Description: "Test tool",
Process: "scripts.test.hello",
InputSchema: json.RawMessage(`{"type":"object"}`),
},
}
session, err := m.Create(ctx, "mul", &AgentContext{
UserID: "user1",
ChatID: "chat1",
}, mcpTools)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("mul")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Send multiple requests
requests := []JSONRPCRequest{
{JSONRPC: "2.0", ID: 1, Method: "initialize", Params: json.RawMessage(`{}`)},
{JSONRPC: "2.0", ID: 2, Method: "tools/list"},
{JSONRPC: "2.0", ID: 3, Method: "resources/list"},
}
for _, req := range requests {
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read for request %v failed: %v", req.ID, err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal for request %v failed: %v", req.ID, err)
}
if resp.Error != nil {
t.Errorf("Request %v returned error: %v", req.ID, resp.Error)
}
// Compare IDs as float64 since JSON numbers are decoded as float64
reqIDFloat := float64(req.ID.(int))
respIDFloat, ok := resp.ID.(float64)
if !ok {
t.Errorf("Response ID type is %T, expected float64", resp.ID)
} else if respIDFloat != reqIDFloat {
t.Errorf("Response ID %v doesn't match request ID %v", resp.ID, req.ID)
}
}
}
// TestSessionClose tests session close behavior
func TestSessionClose(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "session-close-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "close-test", &AgentContext{
UserID: "user1",
ChatID: "chat1",
}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
socketPath := session.SocketPath
time.Sleep(50 * time.Millisecond)
// Connect
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
// Close session
session.Close()
// Wait a bit for cleanup
time.Sleep(100 * time.Millisecond)
// Connection should be broken
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
buf := make([]byte, 4096)
_, err = conn.Read(buf)
// Either EOF or connection reset is expected
if err == nil {
t.Error("Expected connection to be closed")
}
conn.Close()
// Socket file should be removed
if _, err := os.Stat(socketPath); !os.IsNotExist(err) {
t.Error("Socket file should be removed after close")
}
}
// TestSessionEmptyLines tests handling of empty lines
func TestSessionEmptyLines(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "session-empty-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m := NewManager(tmpDir)
ctx := context.Background()
session, err := m.Create(ctx, "empty-test", &AgentContext{
UserID: "user1",
ChatID: "chat1",
}, nil)
if err != nil {
t.Fatalf("Create session failed: %v", err)
}
defer m.Close("empty-test")
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("unix", session.SocketPath)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Send empty lines followed by valid request
conn.Write([]byte("\n\n\n"))
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: "initialize",
}
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
// Should still get response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
var resp JSONRPCResponse
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if resp.Error != nil {
t.Errorf("Unexpected error: %v", resp.Error)
}
}

138
sandbox/ipc/types.go Normal file
View file

@ -0,0 +1,138 @@
package ipc
import (
"context"
"encoding/json"
"net"
)
// Session represents an IPC session for a sandbox container
type Session struct {
ID string // Session ID (usually equals chatID)
SocketPath string // Unix socket path
Listener net.Listener // Socket listener
Conn net.Conn // Current connection
Context *AgentContext // Agent context
MCPTools map[string]*MCPTool // Authorized MCP tools
cancel context.CancelFunc // Cancel function for cleanup
}
// AgentContext holds context information for the agent
type AgentContext struct {
UserID string // User identifier
ChatID string // Chat/session identifier
Locale string // Locale for i18n
}
// MCPTool represents an MCP tool that can be called
type MCPTool struct {
Name string // Tool name
Description string // Tool description
Process string // Yao process name to execute
InputSchema json.RawMessage // JSON Schema for input validation
}
// JSONRPCRequest represents a JSON-RPC 2.0 request
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// JSONRPCResponse represents a JSON-RPC 2.0 response
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Result interface{} `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
// JSONRPCError represents a JSON-RPC 2.0 error
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Standard JSON-RPC error codes
const (
ErrCodeParse = -32700 // Parse error
ErrCodeInvalidRequest = -32600 // Invalid request
ErrCodeMethodNotFound = -32601 // Method not found
ErrCodeInvalidParams = -32602 // Invalid params
ErrCodeInternal = -32603 // Internal error
)
// ToolCallParams represents parameters for tools/call
type ToolCallParams struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
// ToolResult represents the result of a tool call
type ToolResult struct {
Content []ToolContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// ToolContent represents content in a tool result
type ToolContent struct {
Type string `json:"type"` // "text" or "resource"
Text string `json:"text,omitempty"`
}
// InitializeParams represents parameters for initialize method
type InitializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities Capabilities `json:"capabilities"`
ClientInfo ClientInfo `json:"clientInfo"`
}
// Capabilities represents MCP capabilities
type Capabilities struct {
Tools *ToolsCapability `json:"tools,omitempty"`
Resources *ResourcesCapability `json:"resources,omitempty"`
}
// ToolsCapability represents tools capability
type ToolsCapability struct {
ListChanged bool `json:"listChanged,omitempty"`
}
// ResourcesCapability represents resources capability
type ResourcesCapability struct {
Subscribe bool `json:"subscribe,omitempty"`
ListChanged bool `json:"listChanged,omitempty"`
}
// ClientInfo represents client information
type ClientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// ServerInfo represents server information
type ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// InitializeResult represents the result of initialize
type InitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities Capabilities `json:"capabilities"`
ServerInfo ServerInfo `json:"serverInfo"`
}
// Tool represents a tool in tools/list response
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"inputSchema"`
}
// ToolsListResult represents the result of tools/list
type ToolsListResult struct {
Tools []Tool `json:"tools"`
}

578
sandbox/manager.go Normal file
View file

@ -0,0 +1,578 @@
package sandbox
import (
"archive/tar"
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/yaoapp/yao/sandbox/ipc"
)
// execReadCloser wraps a Reader with a Closer
type execReadCloser struct {
*bufio.Reader
closer io.Closer
}
func (e *execReadCloser) Close() error {
if e.closer != nil {
return e.closer.Close()
}
return nil
}
// Manager manages sandbox containers
type Manager struct {
mu sync.Mutex // Protects creation
containers sync.Map // containerName → *Container
running int32 // Running container count
ipcManager *ipc.Manager // IPC manager
dockerClient *client.Client // Docker client
config *Config // Configuration
}
// NewManager creates a new sandbox manager
func NewManager(config *Config) (*Manager, error) {
if config == nil {
config = DefaultConfig()
}
// Initialize Docker client
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
// Ping Docker to verify connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := cli.Ping(ctx); err != nil {
cli.Close()
return nil, fmt.Errorf("%w: %v", ErrDockerNotAvailable, err)
}
// Ensure directories exist
if err := os.MkdirAll(config.WorkspaceRoot, 0755); err != nil {
cli.Close()
return nil, fmt.Errorf("failed to create workspace directory: %w", err)
}
if err := os.MkdirAll(config.IPCDir, 0755); err != nil {
cli.Close()
return nil, fmt.Errorf("failed to create IPC directory: %w", err)
}
m := &Manager{
dockerClient: cli,
config: config,
ipcManager: ipc.NewManager(config.IPCDir),
}
// Start cleanup loop
go m.startCleanupLoop(context.Background())
return m, nil
}
// Close closes the manager and cleans up resources
func (m *Manager) Close() error {
m.ipcManager.CloseAll()
return m.dockerClient.Close()
}
// GetOrCreate returns existing container or creates new one
func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Container, error) {
name := containerName(userID, chatID)
// Check if container already exists (fast path)
if c, ok := m.containers.Load(name); ok {
cont := c.(*Container)
cont.LastUsedAt = time.Now()
return cont, nil
}
// Use mutex for creation to avoid race condition
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring lock
if c, ok := m.containers.Load(name); ok {
cont := c.(*Container)
cont.LastUsedAt = time.Now()
return cont, nil
}
// Check running container limit
if m.running >= int32(m.config.MaxContainers) {
return nil, ErrTooManyContainers
}
// Create new container
cont, err := m.createContainer(ctx, userID, chatID)
if err != nil {
return nil, err
}
// Store and increment counter
m.containers.Store(name, cont)
m.running++
return cont, nil
}
// createContainer creates a new Docker container
func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*Container, error) {
name := containerName(userID, chatID)
// Ensure image exists, pull if not
if err := m.ensureImage(ctx, m.config.Image); err != nil {
return nil, err
}
// Workspace directory
workspaceHost := filepath.Join(m.config.WorkspaceRoot, userID, chatID)
if err := os.MkdirAll(workspaceHost, 0755); err != nil {
return nil, fmt.Errorf("failed to create workspace: %w", err)
}
// IPC socket path
sessionID := chatID
ipcSocketHost := filepath.Join(m.config.IPCDir, sessionID+".sock")
// Container configuration
containerConfig := &container.Config{
Image: m.config.Image,
Cmd: []string{"sleep", "infinity"},
WorkingDir: "/workspace",
Env: []string{
"YAO_IPC_SOCKET=/tmp/yao.sock",
},
}
// Host configuration
hostConfig := &container.HostConfig{
Binds: []string{
workspaceHost + ":/workspace",
ipcSocketHost + ":/tmp/yao.sock",
},
Resources: container.Resources{
Memory: parseMemory(m.config.MaxMemory),
NanoCPUs: int64(m.config.MaxCPU * 1e9),
},
SecurityOpt: []string{"no-new-privileges"},
CapDrop: []string{"ALL"},
}
// Create container
resp, err := m.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, name)
if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err)
}
return &Container{
ID: resp.ID,
Name: name,
UserID: userID,
ChatID: chatID,
Status: StatusCreated,
CreatedAt: time.Now(),
LastUsedAt: time.Now(),
}, nil
}
// ensureImage ensures the image exists locally, pulls if not
func (m *Manager) ensureImage(ctx context.Context, imageName string) error {
// Check if image exists locally
_, _, err := m.dockerClient.ImageInspectWithRaw(ctx, imageName)
if err == nil {
return nil // Image exists
}
// Image not found, pull it
reader, err := m.dockerClient.ImagePull(ctx, imageName, image.PullOptions{})
if err != nil {
return fmt.Errorf("failed to pull image %s: %w", imageName, err)
}
defer reader.Close()
// Wait for pull to complete by reading the response
_, err = io.Copy(io.Discard, reader)
if err != nil {
return fmt.Errorf("failed to pull image %s: %w", imageName, err)
}
return nil
}
// ensureRunning ensures the container is running
func (m *Manager) ensureRunning(ctx context.Context, name string) error {
c, ok := m.containers.Load(name)
if !ok {
return ErrContainerNotFound
}
cont := c.(*Container)
if cont.Status == StatusRunning {
return nil
}
// Start the container
if err := m.dockerClient.ContainerStart(ctx, cont.ID, container.StartOptions{}); err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
m.mu.Lock()
cont.Status = StatusRunning
cont.LastUsedAt = time.Now()
m.mu.Unlock()
return nil
}
// Stream executes command and returns stdout reader
func (m *Manager) Stream(ctx context.Context, name string, cmd []string, opts *ExecOptions) (io.ReadCloser, error) {
// Ensure container is running
if err := m.ensureRunning(ctx, name); err != nil {
return nil, err
}
// Get container
c, ok := m.containers.Load(name)
if !ok {
return nil, ErrContainerNotFound
}
cont := c.(*Container)
// Update last used time
cont.LastUsedAt = time.Now()
// Default options
if opts == nil {
opts = &ExecOptions{}
}
if opts.WorkDir == "" {
opts.WorkDir = "/workspace"
}
// Create exec instance
execConfig := container.ExecOptions{
Cmd: cmd,
WorkingDir: opts.WorkDir,
Env: mapToSlice(opts.Env),
AttachStdout: true,
AttachStderr: true,
AttachStdin: opts.Stdin != nil,
}
execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig)
if err != nil {
return nil, fmt.Errorf("failed to create exec: %w", err)
}
// Attach to exec
attachResp, err := m.dockerClient.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{})
if err != nil {
return nil, fmt.Errorf("failed to attach to exec: %w", err)
}
// Handle stdin if provided
if opts.Stdin != nil {
go func() {
io.Copy(attachResp.Conn, opts.Stdin)
attachResp.CloseWrite()
}()
}
// Wrap in a ReadCloser
return &execReadCloser{
Reader: attachResp.Reader,
closer: attachResp.Conn,
}, nil
}
// Exec executes command and waits for completion
func (m *Manager) Exec(ctx context.Context, name string, cmd []string, opts *ExecOptions) (*ExecResult, error) {
if opts == nil {
opts = &ExecOptions{}
}
// Apply timeout if specified
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
reader, err := m.Stream(ctx, name, cmd, opts)
if err != nil {
return nil, err
}
defer reader.Close()
// Read all output
output, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read output: %w", err)
}
// Parse Docker multiplexed stream
// TODO: Properly demux stdout/stderr from Docker stream
stdout := string(output)
return &ExecResult{
ExitCode: 0,
Stdout: stdout,
Stderr: "",
}, nil
}
// Start starts a stopped container
func (m *Manager) Start(ctx context.Context, name string) error {
return m.ensureRunning(ctx, name)
}
// Stop stops container but preserves data
func (m *Manager) Stop(ctx context.Context, name string) error {
c, ok := m.containers.Load(name)
if !ok {
return nil
}
cont := c.(*Container)
// Only stop if running
if cont.Status != StatusRunning {
return nil
}
if err := m.dockerClient.ContainerStop(ctx, cont.ID, container.StopOptions{}); err != nil {
// Ignore "not running" error
if !strings.Contains(err.Error(), "is not running") {
return fmt.Errorf("failed to stop container: %w", err)
}
}
// Update status, decrement running count
m.mu.Lock()
if cont.Status == StatusRunning {
cont.Status = StatusStopped
m.running--
}
m.mu.Unlock()
return nil
}
// Remove deletes container and its data
func (m *Manager) Remove(ctx context.Context, name string) error {
// Stop first if running
m.Stop(ctx, name)
c, ok := m.containers.Load(name)
if !ok {
return nil
}
cont := c.(*Container)
// Close IPC session
m.ipcManager.Close(cont.ChatID)
if err := m.dockerClient.ContainerRemove(ctx, cont.ID, container.RemoveOptions{Force: true}); err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
// Remove from map
m.containers.Delete(name)
return nil
}
// List returns all containers for a user
func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) {
var result []*Container
prefix := fmt.Sprintf("yao-sandbox-%s-", userID)
m.containers.Range(func(key, value interface{}) bool {
name := key.(string)
if strings.HasPrefix(name, prefix) {
result = append(result, value.(*Container))
}
return true
})
return result, nil
}
// Cleanup stops idle containers
func (m *Manager) Cleanup(ctx context.Context) error {
now := time.Now()
m.containers.Range(func(key, value interface{}) bool {
name := key.(string)
c := value.(*Container)
// Stop idle containers
if c.Status == StatusRunning && now.Sub(c.LastUsedAt) > m.config.IdleTimeout {
m.Stop(ctx, name)
}
return true
})
return nil
}
// startCleanupLoop starts the periodic cleanup loop
func (m *Manager) startCleanupLoop(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.Cleanup(ctx)
}
}
}
// WriteFile writes content to a file in container
func (m *Manager) WriteFile(ctx context.Context, name, path string, content []byte) error {
c, ok := m.containers.Load(name)
if !ok {
return ErrContainerNotFound
}
cont := c.(*Container)
// Create a tar archive with the file
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
hdr := &tar.Header{
Name: filepath.Base(path),
Mode: 0644,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(content); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
// Copy to container
return m.dockerClient.CopyToContainer(ctx, cont.ID, filepath.Dir(path), &buf, container.CopyToContainerOptions{})
}
// ReadFile reads content from a file in container
func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, error) {
c, ok := m.containers.Load(name)
if !ok {
return nil, ErrContainerNotFound
}
cont := c.(*Container)
reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, path)
if err != nil {
return nil, err
}
defer reader.Close()
// Extract from tar
tr := tar.NewReader(reader)
_, err = tr.Next()
if err != nil {
return nil, err
}
return io.ReadAll(tr)
}
// ListDir lists directory contents in container
func (m *Manager) ListDir(ctx context.Context, name, path string) ([]FileInfo, error) {
result, err := m.Exec(ctx, name, []string{"ls", "-la", "--time-style=+%s", path}, nil)
if err != nil {
return nil, err
}
return parseLS(result.Stdout), nil
}
// Stat returns file info
func (m *Manager) Stat(ctx context.Context, name, path string) (*FileInfo, error) {
result, err := m.Exec(ctx, name, []string{"stat", "--format=%n|%s|%f|%Y|%F", path}, nil)
if err != nil {
return nil, err
}
return parseStat(result.Stdout), nil
}
// MkDir creates directory in container
func (m *Manager) MkDir(ctx context.Context, name, path string) error {
_, err := m.Exec(ctx, name, []string{"mkdir", "-p", path}, nil)
return err
}
// RemoveFile removes file or directory in container
func (m *Manager) RemoveFile(ctx context.Context, name, path string) error {
_, err := m.Exec(ctx, name, []string{"rm", "-rf", path}, nil)
return err
}
// CopyToContainer copies from host to container
func (m *Manager) CopyToContainer(ctx context.Context, name, hostPath, containerPath string) error {
c, ok := m.containers.Load(name)
if !ok {
return ErrContainerNotFound
}
cont := c.(*Container)
// Create tar archive from host path
archive, err := createTarFromPath(hostPath)
if err != nil {
return err
}
defer archive.Close()
return m.dockerClient.CopyToContainer(ctx, cont.ID, containerPath, archive, container.CopyToContainerOptions{})
}
// CopyFromContainer copies from container to host
func (m *Manager) CopyFromContainer(ctx context.Context, name, containerPath, hostPath string) error {
c, ok := m.containers.Load(name)
if !ok {
return ErrContainerNotFound
}
cont := c.(*Container)
reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, containerPath)
if err != nil {
return err
}
defer reader.Close()
return extractTarToPath(reader, hostPath)
}
// GetIPCManager returns the IPC manager
func (m *Manager) GetIPCManager() *ipc.Manager {
return m.ipcManager
}
// GetConfig returns the configuration
func (m *Manager) GetConfig() *Config {
return m.config
}

816
sandbox/manager_test.go Normal file
View file

@ -0,0 +1,816 @@
package sandbox
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// skipIfNoDocker skips the test if Docker is not available
func skipIfNoDocker(t *testing.T) *Manager {
t.Helper()
// Create temporary directories for test
tmpDir, err := os.MkdirTemp("", "sandbox-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: filepath.Join(tmpDir, "workspace"),
IPCDir: filepath.Join(tmpDir, "ipc"),
MaxContainers: 5,
IdleTimeout: 1 * time.Minute,
MaxMemory: "512m",
MaxCPU: 0.5,
}
m, err := NewManager(cfg)
if err != nil {
// Clean up temp dir
os.RemoveAll(tmpDir)
if strings.Contains(err.Error(), "Docker not available") ||
strings.Contains(err.Error(), "Cannot connect to the Docker daemon") {
t.Skipf("Skipping test: %v", err)
}
t.Fatalf("Failed to create manager: %v", err)
}
// Store tmpDir in test cleanup
t.Cleanup(func() {
m.Close()
os.RemoveAll(tmpDir)
})
return m
}
// TestNewManager tests manager creation
func TestNewManager(t *testing.T) {
m := skipIfNoDocker(t)
if m.dockerClient == nil {
t.Error("Docker client should not be nil")
}
if m.ipcManager == nil {
t.Error("IPC manager should not be nil")
}
if m.config == nil {
t.Error("Config should not be nil")
}
}
// TestNewManagerWithNilConfig tests manager creation with nil config
func TestNewManagerWithNilConfig(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "sandbox-test-nil-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Set environment variables for default paths
os.Setenv("YAO_SANDBOX_WORKSPACE", filepath.Join(tmpDir, "workspace"))
os.Setenv("YAO_SANDBOX_IPC", filepath.Join(tmpDir, "ipc"))
defer os.Unsetenv("YAO_SANDBOX_WORKSPACE")
defer os.Unsetenv("YAO_SANDBOX_IPC")
cfg := DefaultConfig()
cfg.Init(tmpDir)
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
if m.config.MaxContainers != 100 {
t.Errorf("Expected MaxContainers 100, got %d", m.config.MaxContainers)
}
}
// TestGetOrCreate tests container creation
func TestGetOrCreate(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "test-user"
chatID := "test-chat-" + time.Now().Format("20060102150405")
// Create container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
// Verify container properties
if container.UserID != userID {
t.Errorf("Expected UserID %s, got %s", userID, container.UserID)
}
if container.ChatID != chatID {
t.Errorf("Expected ChatID %s, got %s", chatID, container.ChatID)
}
expectedName := containerName(userID, chatID)
if container.Name != expectedName {
t.Errorf("Expected Name %s, got %s", expectedName, container.Name)
}
if container.Status != StatusCreated {
t.Errorf("Expected Status %s, got %s", StatusCreated, container.Status)
}
// Get same container again (should return existing)
container2, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate (second call) failed: %v", err)
}
if container.ID != container2.ID {
t.Error("Expected same container on second GetOrCreate call")
}
// Cleanup
if err := m.Remove(ctx, container.Name); err != nil {
t.Logf("Warning: failed to remove container: %v", err)
}
}
// TestContainerStartStopRemove tests container lifecycle
func TestContainerStartStopRemove(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "lifecycle-user"
chatID := "lifecycle-chat-" + time.Now().Format("20060102150405")
// Create container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
// Start container
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Verify status is running
c, ok := m.containers.Load(container.Name)
if !ok {
t.Fatal("Container not found in map")
}
if c.(*Container).Status != StatusRunning {
t.Errorf("Expected status %s, got %s", StatusRunning, c.(*Container).Status)
}
// Stop container
if err := m.Stop(ctx, container.Name); err != nil {
t.Fatalf("Stop failed: %v", err)
}
// Verify status is stopped
c, ok = m.containers.Load(container.Name)
if !ok {
t.Fatal("Container not found in map after stop")
}
if c.(*Container).Status != StatusStopped {
t.Errorf("Expected status %s, got %s", StatusStopped, c.(*Container).Status)
}
// Remove container
if err := m.Remove(ctx, container.Name); err != nil {
t.Fatalf("Remove failed: %v", err)
}
// Verify container is removed from map
if _, ok := m.containers.Load(container.Name); ok {
t.Error("Container should be removed from map")
}
}
// TestExec tests command execution
func TestExec(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "exec-user"
chatID := "exec-chat-" + time.Now().Format("20060102150405")
// Create and start container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Execute simple command
result, err := m.Exec(ctx, container.Name, []string{"echo", "hello world"}, nil)
if err != nil {
t.Fatalf("Exec failed: %v", err)
}
// Note: Docker multiplexed stream includes header bytes
if !strings.Contains(result.Stdout, "hello world") {
t.Errorf("Expected stdout to contain 'hello world', got: %s", result.Stdout)
}
}
// TestExecWithEnv tests command execution with environment variables
func TestExecWithEnv(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "exec-env-user"
chatID := "exec-env-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
result, err := m.Exec(ctx, container.Name, []string{"sh", "-c", "echo $TEST_VAR"}, &ExecOptions{
Env: map[string]string{
"TEST_VAR": "test_value_123",
},
})
if err != nil {
t.Fatalf("Exec with env failed: %v", err)
}
if !strings.Contains(result.Stdout, "test_value_123") {
t.Errorf("Expected stdout to contain 'test_value_123', got: %s", result.Stdout)
}
}
// TestExecWithTimeout tests command execution timeout
func TestExecWithTimeout(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "exec-timeout-user"
chatID := "exec-timeout-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Execute command with very short timeout
_, err = m.Exec(ctx, container.Name, []string{"sleep", "10"}, &ExecOptions{
Timeout: 100 * time.Millisecond,
})
// Should timeout
if err == nil {
t.Log("Expected timeout error, but command completed (may be fast system)")
}
}
// TestFileOperations tests filesystem operations
func TestFileOperations(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "file-user"
chatID := "file-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Start container first
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Test MkDir
testDir := "/workspace/testdir"
if err := m.MkDir(ctx, container.Name, testDir); err != nil {
t.Fatalf("MkDir failed: %v", err)
}
// Test WriteFile
testFile := "/workspace/testdir/test.txt"
testContent := []byte("Hello, Sandbox!")
if err := m.WriteFile(ctx, container.Name, testFile, testContent); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
// Test ReadFile
content, err := m.ReadFile(ctx, container.Name, testFile)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(content) != string(testContent) {
t.Errorf("Expected content '%s', got '%s'", testContent, content)
}
// Test Stat
info, err := m.Stat(ctx, container.Name, testFile)
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info == nil {
t.Fatal("Stat returned nil")
}
if info.Name != "test.txt" {
t.Errorf("Expected name 'test.txt', got '%s'", info.Name)
}
if info.Size != int64(len(testContent)) {
t.Errorf("Expected size %d, got %d", len(testContent), info.Size)
}
// Test ListDir
files, err := m.ListDir(ctx, container.Name, "/workspace/testdir")
if err != nil {
t.Fatalf("ListDir failed: %v", err)
}
found := false
for _, f := range files {
if f.Name == "test.txt" {
found = true
break
}
}
if !found {
t.Error("Expected to find test.txt in directory listing")
}
// Test RemoveFile
if err := m.RemoveFile(ctx, container.Name, testFile); err != nil {
t.Fatalf("RemoveFile failed: %v", err)
}
// Verify file is removed - check via ls instead of stat
// (stat command may still succeed with different output)
files2, err := m.ListDir(ctx, container.Name, "/workspace/testdir")
if err != nil {
t.Fatalf("ListDir after removal failed: %v", err)
}
found = false
for _, f := range files2 {
if f.Name == "test.txt" {
found = true
break
}
}
if found {
t.Error("File test.txt should be removed")
}
}
// TestCopyOperations tests copy to/from container
func TestCopyOperations(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "copy-user"
chatID := "copy-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Start container
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Create temp file on host
tmpDir, err := os.MkdirTemp("", "sandbox-copy-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hostFile := filepath.Join(tmpDir, "source.txt")
if err := os.WriteFile(hostFile, []byte("copy test content"), 0644); err != nil {
t.Fatalf("Failed to write host file: %v", err)
}
// Copy to container
if err := m.CopyToContainer(ctx, container.Name, hostFile, "/workspace/"); err != nil {
t.Fatalf("CopyToContainer failed: %v", err)
}
// Verify file exists in container
content, err := m.ReadFile(ctx, container.Name, "/workspace/source.txt")
if err != nil {
t.Fatalf("ReadFile after copy failed: %v", err)
}
if string(content) != "copy test content" {
t.Errorf("Expected 'copy test content', got '%s'", content)
}
// Copy from container
extractDir := filepath.Join(tmpDir, "extracted")
os.MkdirAll(extractDir, 0755)
if err := m.CopyFromContainer(ctx, container.Name, "/workspace/source.txt", extractDir); err != nil {
t.Fatalf("CopyFromContainer failed: %v", err)
}
// Verify extracted file
extractedContent, err := os.ReadFile(filepath.Join(extractDir, "source.txt"))
if err != nil {
t.Fatalf("Failed to read extracted file: %v", err)
}
if string(extractedContent) != "copy test content" {
t.Errorf("Expected 'copy test content', got '%s'", extractedContent)
}
}
// TestListContainers tests listing containers for a user
func TestListContainers(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "list-user"
chatIDs := []string{
"list-chat-1-" + time.Now().Format("20060102150405"),
"list-chat-2-" + time.Now().Format("20060102150405"),
}
// Create multiple containers for same user
for _, chatID := range chatIDs {
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed for %s: %v", chatID, err)
}
defer m.Remove(ctx, container.Name)
}
// List containers for user
containers, err := m.List(ctx, userID)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(containers) != 2 {
t.Errorf("Expected 2 containers, got %d", len(containers))
}
// Verify all containers belong to user
for _, c := range containers {
if c.UserID != userID {
t.Errorf("Expected UserID %s, got %s", userID, c.UserID)
}
}
}
// TestConcurrencyLimit tests the max containers limit
func TestConcurrencyLimit(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "sandbox-concurrency-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: filepath.Join(tmpDir, "workspace"),
IPCDir: filepath.Join(tmpDir, "ipc"),
MaxContainers: 2, // Low limit for testing
IdleTimeout: 1 * time.Minute,
MaxMemory: "256m",
MaxCPU: 0.25,
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create containers up to limit
containers := make([]*Container, 0)
for i := 0; i < cfg.MaxContainers; i++ {
c, err := m.GetOrCreate(ctx, "limit-user", "limit-chat-"+string(rune('a'+i)))
if err != nil {
t.Fatalf("GetOrCreate failed for container %d: %v", i, err)
}
containers = append(containers, c)
}
// Try to create one more - should fail
_, err = m.GetOrCreate(ctx, "limit-user", "limit-chat-extra")
if err != ErrTooManyContainers {
t.Errorf("Expected ErrTooManyContainers, got: %v", err)
}
// Cleanup
for _, c := range containers {
m.Remove(ctx, c.Name)
}
}
// TestConcurrentAccess tests concurrent container access
func TestConcurrentAccess(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "concurrent-user"
chatID := "concurrent-chat-" + time.Now().Format("20060102150405")
// Create container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Concurrent GetOrCreate calls should return same container
var wg sync.WaitGroup
results := make(chan *Container, 10)
errors := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
errors <- err
return
}
results <- c
}()
}
wg.Wait()
close(results)
close(errors)
// Check for errors
for err := range errors {
t.Errorf("Concurrent GetOrCreate error: %v", err)
}
// All results should have same ID
var firstID string
for c := range results {
if firstID == "" {
firstID = c.ID
} else if c.ID != firstID {
t.Errorf("Expected same container ID, got different: %s vs %s", firstID, c.ID)
}
}
}
// TestContainerNotFound tests operations on non-existent container
func TestContainerNotFound(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
fakeName := "yao-sandbox-fake-user-fake-chat"
// Test Stop on non-existent (should not error)
if err := m.Stop(ctx, fakeName); err != nil {
t.Errorf("Stop on non-existent container should not error: %v", err)
}
// Test Remove on non-existent (should not error)
if err := m.Remove(ctx, fakeName); err != nil {
t.Errorf("Remove on non-existent container should not error: %v", err)
}
// Test ensureRunning on non-existent (should error)
if err := m.ensureRunning(ctx, fakeName); err != ErrContainerNotFound {
t.Errorf("Expected ErrContainerNotFound, got: %v", err)
}
}
// TestCleanup tests the cleanup function
func TestCleanup(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "sandbox-cleanup-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: filepath.Join(tmpDir, "workspace"),
IPCDir: filepath.Join(tmpDir, "ipc"),
MaxContainers: 10,
IdleTimeout: 100 * time.Millisecond, // Very short for testing
MaxMemory: "256m",
MaxCPU: 0.25,
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create and start container
container, err := m.GetOrCreate(ctx, "cleanup-user", "cleanup-chat")
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Verify running
c, _ := m.containers.Load(container.Name)
if c.(*Container).Status != StatusRunning {
t.Fatalf("Container should be running")
}
// Set LastUsedAt to past
c.(*Container).LastUsedAt = time.Now().Add(-1 * time.Hour)
// Run cleanup
if err := m.Cleanup(ctx); err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
// Verify stopped
c, _ = m.containers.Load(container.Name)
if c.(*Container).Status != StatusStopped {
t.Errorf("Container should be stopped after cleanup, got: %s", c.(*Container).Status)
}
}
// TestManagerWithYaoApp tests sandbox with Yao application loaded
// This is the full integration test that loads the Yao application environment
func TestManagerWithYaoApp(t *testing.T) {
// Check if YAO_TEST_APPLICATION is set
if os.Getenv("YAO_TEST_APPLICATION") == "" {
t.Skip("Skipping: YAO_TEST_APPLICATION not set")
}
// Prepare Yao test environment
test.Prepare(t, config.Conf)
defer test.Clean()
// Now test with the Yao environment loaded
tmpDir, err := os.MkdirTemp("", "sandbox-yao-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: filepath.Join(tmpDir, "workspace"),
IPCDir: filepath.Join(tmpDir, "ipc"),
MaxContainers: 5,
IdleTimeout: 5 * time.Minute,
MaxMemory: "1g",
MaxCPU: 1.0,
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create container
container, err := m.GetOrCreate(ctx, "yao-user", "yao-chat")
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Start container
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Execute a command to verify container is working
result, err := m.Exec(ctx, container.Name, []string{"node", "--version"}, nil)
if err != nil {
t.Fatalf("Exec node --version failed: %v", err)
}
if !strings.Contains(result.Stdout, "v") {
t.Errorf("Expected node version output, got: %s", result.Stdout)
}
// Execute Python version check
result, err = m.Exec(ctx, container.Name, []string{"python3", "--version"}, nil)
if err != nil {
t.Fatalf("Exec python3 --version failed: %v", err)
}
if !strings.Contains(result.Stdout, "Python") {
t.Errorf("Expected Python version output, got: %s", result.Stdout)
}
t.Log("Sandbox integration with Yao app successful")
}
// TestGetAccessors tests getter methods
func TestGetAccessors(t *testing.T) {
m := skipIfNoDocker(t)
// Test GetIPCManager
ipcMgr := m.GetIPCManager()
if ipcMgr == nil {
t.Error("GetIPCManager should not return nil")
}
// Test GetConfig
cfg := m.GetConfig()
if cfg == nil {
t.Error("GetConfig should not return nil")
}
if cfg.MaxContainers != 5 {
t.Errorf("Expected MaxContainers 5, got %d", cfg.MaxContainers)
}
}
// TestEnsureImageAutoPull tests that missing images are automatically pulled
func TestEnsureImageAutoPull(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "sandbox-autopull-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Use a small known image for testing
cfg := &Config{
Image: "alpine:latest",
WorkspaceRoot: filepath.Join(tmpDir, "workspace"),
IPCDir: filepath.Join(tmpDir, "ipc"),
MaxContainers: 2,
IdleTimeout: 1 * time.Minute,
MaxMemory: "128m",
MaxCPU: 0.25,
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create container - should auto-pull alpine if not present
container, err := m.GetOrCreate(ctx, "autopull-user", "autopull-chat")
if err != nil {
t.Fatalf("GetOrCreate failed (should auto-pull image): %v", err)
}
defer m.Remove(ctx, container.Name)
// Verify container was created
if container.Status != StatusCreated {
t.Errorf("Expected status %s, got %s", StatusCreated, container.Status)
}
t.Log("Image auto-pull successful")
}

53
sandbox/types.go Normal file
View file

@ -0,0 +1,53 @@
package sandbox
import (
"io"
"os"
"time"
"github.com/yaoapp/yao/sandbox/ipc"
)
// Container represents a sandbox container
type Container struct {
ID string // Docker container ID
Name string // Container name: yao-sandbox-{userID}-{chatID}
UserID string // User identifier
ChatID string // Chat/session identifier
Status string // created, running, stopped
CreatedAt time.Time // Container creation time
LastUsedAt time.Time // Last activity time
IPCSession *ipc.Session // Associated IPC session
}
// ExecOptions configures command execution
type ExecOptions struct {
WorkDir string // Working directory inside container
Env map[string]string // Environment variables
Stdin io.Reader // Standard input
Timeout time.Duration // Execution timeout (0 = no timeout)
}
// ExecResult contains the result of command execution
type ExecResult struct {
ExitCode int // Exit code
Stdout string // Standard output
Stderr string // Standard error
}
// FileInfo represents file metadata
type FileInfo struct {
Name string // File name
Path string // Full path
Size int64 // Size in bytes
Mode os.FileMode // File mode
ModTime time.Time // Modification time
IsDir bool // Is directory
}
// ContainerStatus constants
const (
StatusCreated = "created"
StatusRunning = "running"
StatusStopped = "stopped"
)