feat(sandbox): add persistent Docker sandbox for external CLI agents
This commit is contained in:
parent
d21dee07c4
commit
d4bed4f277
28 changed files with 7014 additions and 5 deletions
172
.github/workflows/pr-test.yml
vendored
172
.github/workflows/pr-test.yml
vendored
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
113
.github/workflows/unit-test.yml
vendored
113
.github/workflows/unit-test.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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-*
|
||||
|
|
|
|||
51
Makefile
51
Makefile
|
|
@ -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
17
go.mod
|
|
@ -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
25
go.sum
|
|
@ -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
1379
sandbox/DESIGN.md
Normal file
File diff suppressed because it is too large
Load diff
486
sandbox/PLAN.md
Normal file
486
sandbox/PLAN.md
Normal 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
151
sandbox/README.md
Normal 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
58
sandbox/bridge/main.go
Normal 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
94
sandbox/config.go
Normal 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
230
sandbox/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
33
sandbox/docker/base/Dockerfile.base
Normal file
33
sandbox/docker/base/Dockerfile.base
Normal 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
144
sandbox/docker/build.sh
Executable 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"
|
||||
42
sandbox/docker/claude/Dockerfile
Normal file
42
sandbox/docker/claude/Dockerfile
Normal 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"]
|
||||
33
sandbox/docker/claude/Dockerfile.full
Normal file
33
sandbox/docker/claude/Dockerfile.full
Normal 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
23
sandbox/errors.go
Normal 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
314
sandbox/helpers.go
Normal 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
189
sandbox/helpers_test.go
Normal 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
235
sandbox/ipc/jsonrpc_test.go
Normal 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), ¶ms); 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
100
sandbox/ipc/manager.go
Normal 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
638
sandbox/ipc/manager_test.go
Normal 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
275
sandbox/ipc/session.go
Normal 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, ¶ms); 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
630
sandbox/ipc/session_test.go
Normal 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
138
sandbox/ipc/types.go
Normal 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
578
sandbox/manager.go
Normal 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
816
sandbox/manager_test.go
Normal 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
53
sandbox/types.go
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue