From d4bed4f277b5af3807753dbf3e95eba40dfe3bce Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 29 Jan 2026 19:23:50 +0800 Subject: [PATCH] feat(sandbox): add persistent Docker sandbox for external CLI agents --- .github/workflows/pr-test.yml | 172 +++ .github/workflows/unit-test.yml | 113 +- .gitignore | 2 + Makefile | 51 +- go.mod | 17 + go.sum | 25 + sandbox/DESIGN.md | 1379 +++++++++++++++++++++++++ sandbox/PLAN.md | 486 +++++++++ sandbox/README.md | 151 +++ sandbox/bridge/main.go | 58 ++ sandbox/config.go | 94 ++ sandbox/config_test.go | 230 +++++ sandbox/docker/base/Dockerfile.base | 33 + sandbox/docker/build.sh | 144 +++ sandbox/docker/claude/Dockerfile | 42 + sandbox/docker/claude/Dockerfile.full | 33 + sandbox/errors.go | 23 + sandbox/helpers.go | 314 ++++++ sandbox/helpers_test.go | 189 ++++ sandbox/ipc/jsonrpc_test.go | 235 +++++ sandbox/ipc/manager.go | 100 ++ sandbox/ipc/manager_test.go | 638 ++++++++++++ sandbox/ipc/session.go | 275 +++++ sandbox/ipc/session_test.go | 630 +++++++++++ sandbox/ipc/types.go | 138 +++ sandbox/manager.go | 578 +++++++++++ sandbox/manager_test.go | 816 +++++++++++++++ sandbox/types.go | 53 + 28 files changed, 7014 insertions(+), 5 deletions(-) create mode 100644 sandbox/DESIGN.md create mode 100644 sandbox/PLAN.md create mode 100644 sandbox/README.md create mode 100644 sandbox/bridge/main.go create mode 100644 sandbox/config.go create mode 100644 sandbox/config_test.go create mode 100644 sandbox/docker/base/Dockerfile.base create mode 100755 sandbox/docker/build.sh create mode 100644 sandbox/docker/claude/Dockerfile create mode 100644 sandbox/docker/claude/Dockerfile.full create mode 100644 sandbox/errors.go create mode 100644 sandbox/helpers.go create mode 100644 sandbox/helpers_test.go create mode 100644 sandbox/ipc/jsonrpc_test.go create mode 100644 sandbox/ipc/manager.go create mode 100644 sandbox/ipc/manager_test.go create mode 100644 sandbox/ipc/session.go create mode 100644 sandbox/ipc/session_test.go create mode 100644 sandbox/ipc/types.go create mode 100644 sandbox/manager.go create mode 100644 sandbox/manager_test.go create mode 100644 sandbox/types.go diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 511226ae..1aae747f 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -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 # ============================================================================= diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index b51dddf9..9da196ef 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -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 # ============================================================================= diff --git a/.gitignore b/.gitignore index 030841f9..4bba6f20 100644 --- a/.gitignore +++ b/.gitignore @@ -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-* diff --git a/Makefile b/Makefile index 22cd5835..1f9ffc19 100644 --- a/Makefile +++ b/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: diff --git a/go.mod b/go.mod index 327c8e8f..71cca5a6 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index d0f6a7d8..a7118d72 100644 --- a/go.sum +++ b/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= diff --git a/sandbox/DESIGN.md b/sandbox/DESIGN.md new file mode 100644 index 00000000..500a9f45 --- /dev/null +++ b/sandbox/DESIGN.md @@ -0,0 +1,1379 @@ +# Sandbox Design + +## 1. Overview + +Sandbox provides **persistent Docker containers** as isolated execution environments for external CLI agents like Claude Code. + +### Why Docker? + +- **Persistence**: Claude installs dependencies (npm, pip, apt), which must persist across sessions +- **Cross-platform**: Works on Linux, macOS, and Windows +- **Strong isolation**: Process, filesystem, and network isolation +- **Mature ecosystem**: Well-documented, easy to maintain + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Yao Server │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Sandbox Manager │ │ +│ │ │ │ +│ │ containers: map[containerName]*Container │ │ +│ │ │ │ +│ │ - GetOrCreate(userID, chatID) → get or create container │ │ +│ │ - Exec(containerName, cmd) → execute command in container │ │ +│ │ - Stop(containerName) → stop container (preserve data) │ │ +│ │ - Remove(containerName) → delete container │ │ +│ │ │ │ +│ └──────────────────────────┬─────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────┼──────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Container-A │ │ Container-B │ │ Container-C │ │ +│ │ (user1-chat1)│ │ (user1-chat2)│ │ (user2-chat1)│ │ +│ │ │ │ │ │ │ │ +│ │ - Claude CLI │ │ - Claude CLI │ │ - Claude CLI │ │ +│ │ - Node.js │ │ - Python │ │ - Go │ │ +│ │ - User code │ │ - User code │ │ - User code │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ───────┴────────────────┴────────────────┴─────── │ +│ Unix Socket IPC │ +│ (one socket per container) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Container Lifecycle + +``` +Create ──────────► Running ──────────► Stopped ──────────► Removed +(docker create) (docker start) (docker stop) (docker rm) + │ │ │ │ + │ │ │ │ + ▼ ▼ ▼ ▼ +First request Execute tasks Idle timeout Cleanup policy + (persistent) (data preserved) (manual/scheduled) +``` + +### Container States + +| State | Description | +| --------- | ---------------------------------------------- | +| `created` | Container created, not started | +| `running` | Container running, can execute commands | +| `stopped` | Container stopped, data preserved, can restart | +| `removed` | Container deleted | + +### Naming Convention + +``` +yao-sandbox-{userID}-{chatID} + +Example: yao-sandbox-u123-c456 +``` + +--- + +## 3. IPC Communication + +### Problem + +Claude CLI runs inside the sandbox but needs to call Yao's MCP Tools (Yao Processes) which run outside. + +### Solution: Unix Socket + MCP JSON-RPC + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Docker Container │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Claude CLI │ │ +│ │ │ │ │ +│ │ │ .mcp.json: "yao" → stdio │ │ +│ │ ▼ │ │ +│ │ ┌──────────────────────────────────────────────────────────┐ │ │ +│ │ │ yao-bridge (lightweight binary) │ │ │ +│ │ │ stdin/stdout ↔ /tmp/yao.sock │ │ │ +│ │ └──────────────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ /tmp/yao.sock │ +│ │ │ +└──────────────────────────┼─────────────────────────────────────────┘ + │ + Unix Socket (bind mount) + │ +┌──────────────────────────┼─────────────────────────────────────────┐ +│ │ │ +│ {YAO_DATA_ROOT}/sandbox/ipc/{sessionID}.sock │ +│ │ │ +│ ┌───────────────────────▼──────────────────────────────────────┐ │ +│ │ IPC Server (goroutine) │ │ +│ │ │ │ +│ │ MCP JSON-RPC Methods: │ │ +│ │ - initialize → handshake │ │ +│ │ - tools/list → return authorized Yao MCP tools │ │ +│ │ - tools/call → execute process.New(name, args...) │ │ +│ │ - resources/list → list Yao resources │ │ +│ │ - resources/read → read Yao resource │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ Yao Server │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### Protocol + +- **Format**: MCP standard JSON-RPC 2.0 over NDJSON (newline-delimited JSON) +- **Transport**: Unix Socket +- **Bridge**: `yao-bridge` binary converts stdio ↔ socket + +--- + +## 4. Core Interfaces + +### 4.1 Sandbox Manager + +```go +// sandbox/manager.go +package sandbox + +type Manager struct { + mu sync.Mutex // Protects creation + containers sync.Map // containerName → *Container + running int32 // Running container count + ipcManager *ipc.Manager + dockerClient *docker.Client + config *Config +} + +var ErrTooManyContainers = errors.New("sandbox: too many running containers, please try again later") + +type Config struct { + Image string // Docker image, default: yao/sandbox:latest + WorkspaceRoot string // Host workspace root directory + IPCDir string // IPC socket directory + MaxContainers int // Maximum concurrent containers + IdleTimeout time.Duration // Idle timeout before stopping container + MaxMemory string // Memory limit, e.g., "2g" + MaxCPU float64 // CPU limit, e.g., 1.0 +} + +type Container struct { + ID string + Name string // yao-sandbox-{userID}-{chatID} + UserID string + ChatID string + Status string // created, running, stopped + CreatedAt time.Time + LastUsedAt time.Time + IPCSession *ipc.Session +} +``` + +### 4.2 Manager Methods + +```go +// GetOrCreate returns existing container or creates new one +// Returns ErrTooManyContainers if limit exceeded +func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Container, error) + +// Stream executes command and returns stdout reader +func (m *Manager) Stream(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (io.ReadCloser, error) + +// Exec executes command and waits for completion +func (m *Manager) Exec(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (*ExecResult, error) + +// Stop stops container but preserves data +func (m *Manager) Stop(ctx context.Context, containerName string) error + +// Start starts a stopped container +func (m *Manager) Start(ctx context.Context, containerName string) error + +// Remove deletes container and its data +func (m *Manager) Remove(ctx context.Context, containerName string) error + +// List returns all containers for a user +func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) + +// Cleanup stops idle containers +func (m *Manager) Cleanup(ctx context.Context) error +``` + +### 4.3 Filesystem Methods + +```go +// WriteFile writes content to a file in container +func (m *Manager) WriteFile(ctx context.Context, containerName, path string, content []byte) error + +// ReadFile reads content from a file in container +func (m *Manager) ReadFile(ctx context.Context, containerName, path string) ([]byte, error) + +// ListDir lists directory contents in container +func (m *Manager) ListDir(ctx context.Context, containerName, path string) ([]FileInfo, error) + +// Stat returns file info +func (m *Manager) Stat(ctx context.Context, containerName, path string) (*FileInfo, error) + +// MkDir creates directory in container +func (m *Manager) MkDir(ctx context.Context, containerName, path string) error + +// Remove removes file or directory in container +func (m *Manager) RemoveFile(ctx context.Context, containerName, path string) error + +// CopyToContainer copies file/directory from host to container +func (m *Manager) CopyToContainer(ctx context.Context, containerName, hostPath, containerPath string) error + +// CopyFromContainer copies file/directory from container to host +func (m *Manager) CopyFromContainer(ctx context.Context, containerName, containerPath, hostPath string) error + +// FileInfo represents file metadata +type FileInfo struct { + Name string + Path string + Size int64 + Mode os.FileMode + ModTime time.Time + IsDir bool +} +``` + +### 4.4 ExecOptions + +```go +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 +} + +type ExecResult struct { + ExitCode int + Stdout string + Stderr string +} +``` + +--- + +## 5. IPC System + +### 5.1 IPC Session + +```go +// ipc/session.go +package ipc + +type Session struct { + ID string // Usually equals chatID + SocketPath string // {IPCDir}/{id}.sock + Listener net.Listener + Conn net.Conn + Context *AgentContext + MCPTools map[string]*MCPTool + cancel context.CancelFunc +} + +type AgentContext struct { + UserID string + ChatID string + Locale string +} + +type MCPTool struct { + Name string + Description string + Process string // Yao process name + InputSchema json.RawMessage // JSON Schema +} +``` + +### 5.2 IPC Manager + +```go +// ipc/manager.go +type Manager struct { + sessions sync.Map // sessionID → *Session + sockDir string // {YAO_DATA_ROOT}/sandbox/ipc/ +} + +// Create creates new IPC session +func (m *Manager) Create(ctx context.Context, sessionID string, agentCtx *AgentContext, mcpTools map[string]*MCPTool) (*Session, error) + +// Close closes IPC session and cleans up +func (m *Manager) Close(sessionID string) error + +// Get returns existing session +func (m *Manager) Get(sessionID string) (*Session, bool) +``` + +### 5.3 JSON-RPC Message Handling + +```go +// JSON-RPC request structure +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// JSON-RPC response structure +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id,omitempty"` + Result interface{} `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} +``` + +### 5.4 Session Message Loop + +```go +func (s *Session) serve(ctx context.Context) { + defer s.cleanup() + + for { + select { + case <-ctx.Done(): + return + default: + } + + conn, err := s.Listener.Accept() + if err != nil { + continue + } + s.Conn = conn + s.handleConnection(ctx, conn) + } +} + +func (s *Session) handleConnection(ctx context.Context, conn net.Conn) { + defer conn.Close() + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + response := s.handleMessage(line) + if response != "" { + conn.Write([]byte(response + "\n")) + } + } +} + +func (s *Session) handleMessage(line string) string { + var req JSONRPCRequest + if err := json.Unmarshal([]byte(line), &req); err != nil { + return s.errorResponse(nil, -32700, "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, -32601, "Method not found") + } +} +``` + +### 5.5 Tool Call Handler + +```go +func (s *Session) handleCallTool(req JSONRPCRequest) string { + var params struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + } + json.Unmarshal(req.Params, ¶ms) + + // Check authorization + tool, ok := s.MCPTools[params.Name] + if !ok { + return s.errorResponse(req.ID, -32602, "Tool not found or not authorized") + } + + // Execute Yao Process + proc := process.New(tool.Process, params.Arguments) + proc.WithContext(s.Context) + + if err := proc.Execute(); err != nil { + return s.toolErrorResponse(req.ID, params.Name, err) + } + defer proc.Release() + + result := proc.Value() + return s.toolSuccessResponse(req.ID, result) +} +``` + +--- + +## 6. Docker Container Management + +### 6.1 NewManager Constructor + +```go +func NewManager(config *Config) (*Manager, error) { + // Initialize Docker client + cli, err := docker.NewClientWithOpts(docker.FromEnv, docker.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 { + return nil, fmt.Errorf("Docker not available: %w", err) + } + + // Ensure directories exist + os.MkdirAll(config.WorkspaceRoot, 0755) + os.MkdirAll(config.IPCDir, 0755) + + m := &Manager{ + dockerClient: cli, + config: config, + ipcManager: ipc.NewManager(config.IPCDir), + } + + // Start cleanup loop + go m.startCleanupLoop(context.Background()) + + return m, nil +} +``` + +### 6.2 GetOrCreate with Limit Check + +```go +func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Container, error) { + containerName := fmt.Sprintf("yao-sandbox-%s-%s", userID, chatID) + + // Check if container already exists (fast path) + if c, ok := m.containers.Load(containerName); ok { + container := c.(*Container) + container.LastUsedAt = time.Now() + return container, 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(containerName); ok { + container := c.(*Container) + container.LastUsedAt = time.Now() + return container, nil + } + + // Check running container limit + if m.running >= int32(m.config.MaxContainers) { + return nil, ErrTooManyContainers + } + + // Create new container + container, err := m.createContainer(ctx, userID, chatID) + if err != nil { + return nil, err + } + + // Store and increment counter + m.containers.Store(containerName, container) + m.running++ + + return container, nil +} +``` + +### 6.3 Create Container (internal) + +```go +func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*Container, error) { + containerName := fmt.Sprintf("yao-sandbox-%s-%s", userID, chatID) + + // Ensure image exists, auto-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) + os.MkdirAll(workspaceHost, 0755) + + // IPC socket path + sessionID := chatID + ipcSocketHost := filepath.Join(m.config.IPCDir, sessionID+".sock") + + // Create container + resp, err := m.dockerClient.ContainerCreate(ctx, + &container.Config{ + Image: m.config.Image, + Cmd: []string{"sleep", "infinity"}, // Keep running + WorkingDir: "/workspace", + Env: []string{ + "YAO_IPC_SOCKET=/tmp/yao.sock", + }, + }, + &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"}, + }, + nil, nil, containerName, + ) + + if err != nil { + return nil, err + } + + return &Container{ + ID: resp.ID, + Name: containerName, + UserID: userID, + ChatID: chatID, + Status: "created", + CreatedAt: 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 + io.Copy(io.Discard, reader) + return nil +} +``` + +### 6.4 Ensure Running + +```go +func (m *Manager) ensureRunning(ctx context.Context, containerName string) error { + c, ok := m.containers.Load(containerName) + if !ok { + return fmt.Errorf("container not found: %s", containerName) + } + cont := c.(*Container) + + if cont.Status == "running" { + return nil + } + + // Start the container + if err := m.dockerClient.ContainerStart(ctx, cont.ID, container.StartOptions{}); err != nil { + return err + } + + m.mu.Lock() + cont.Status = "running" + cont.LastUsedAt = time.Now() + m.mu.Unlock() + + return nil +} +``` + +### 6.5 Execute Command (Streaming) + +```go +func (m *Manager) Stream(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (io.ReadCloser, error) { + // Ensure container is running + if err := m.ensureRunning(ctx, containerName); err != nil { + return nil, err + } + + // Get container + c, _ := m.containers.Load(containerName) + cont := c.(*Container) + + // Create exec instance + execConfig := container.ExecOptions{ + Cmd: cmd, + WorkingDir: opts.WorkDir, + Env: mapToSlice(opts.Env), + AttachStdout: true, + AttachStderr: true, + } + + execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig) + if err != nil { + return nil, err + } + + // Attach to exec + attachResp, err := m.dockerClient.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) + if err != nil { + return nil, err + } + + return attachResp.Reader, nil +} + +// Exec executes command and waits for completion +func (m *Manager) Exec(ctx context.Context, containerName string, cmd []string, opts *ExecOptions) (*ExecResult, error) { + if opts == nil { + opts = &ExecOptions{} + } + + reader, err := m.Stream(ctx, containerName, cmd, opts) + if err != nil { + return nil, err + } + defer reader.Close() + + // Read all output + output, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + // TODO: Parse stdout/stderr from Docker multiplexed stream + // TODO: Get exit code from ContainerExecInspect + + return &ExecResult{ + ExitCode: 0, + Stdout: string(output), + Stderr: "", + }, nil +} +``` + +### 6.6 Filesystem Operations + +```go +// WriteFile writes content to a file in container using docker cp +func (m *Manager) WriteFile(ctx context.Context, containerName, path string, content []byte) error { + c, ok := m.containers.Load(containerName) + if !ok { + return fmt.Errorf("container not found: %s", containerName) + } + 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)), + } + tw.WriteHeader(hdr) + tw.Write(content) + tw.Close() + + // 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, containerName, path string) ([]byte, error) { + c, ok := m.containers.Load(containerName) + if !ok { + return nil, fmt.Errorf("container not found: %s", containerName) + } + 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 +func (m *Manager) ListDir(ctx context.Context, containerName, path string) ([]FileInfo, error) { + result, err := m.Exec(ctx, containerName, []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, containerName, path string) (*FileInfo, error) { + result, err := m.Exec(ctx, containerName, []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, containerName, path string) error { + _, err := m.Exec(ctx, containerName, []string{"mkdir", "-p", path}, nil) + return err +} + +// RemoveFile removes file or directory in container +func (m *Manager) RemoveFile(ctx context.Context, containerName, path string) error { + _, err := m.Exec(ctx, containerName, []string{"rm", "-rf", path}, nil) + return err +} + +// CopyToContainer copies from host to container +func (m *Manager) CopyToContainer(ctx context.Context, containerName, hostPath, containerPath string) error { + c, ok := m.containers.Load(containerName) + if !ok { + return fmt.Errorf("container not found: %s", containerName) + } + 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, containerName, containerPath, hostPath string) error { + c, ok := m.containers.Load(containerName) + if !ok { + return fmt.Errorf("container not found: %s", containerName) + } + cont := c.(*Container) + + reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, containerPath) + if err != nil { + return err + } + defer reader.Close() + + return extractTarToPath(reader, hostPath) +} +``` + +### 6.7 Cleanup Strategy + +```go +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) + } + } +} + +func (m *Manager) Cleanup(ctx context.Context) error { + now := time.Now() + + m.containers.Range(func(key, value interface{}) bool { + containerName := key.(string) + c := value.(*Container) + + // Stop idle containers + if c.Status == "running" && now.Sub(c.LastUsedAt) > m.config.IdleTimeout { + m.Stop(ctx, containerName) + } + + return true + }) + + return nil +} + +func (m *Manager) Start(ctx context.Context, containerName string) error { + return m.ensureRunning(ctx, containerName) +} + +func (m *Manager) Stop(ctx context.Context, containerName string) error { + c, ok := m.containers.Load(containerName) + if !ok { + return nil + } + cont := c.(*Container) + + if err := m.dockerClient.ContainerStop(ctx, cont.ID, container.StopOptions{}); err != nil { + return err + } + + // Update status, decrement running count + m.mu.Lock() + if cont.Status == "running" { + cont.Status = "stopped" + m.running-- + } + m.mu.Unlock() + + return nil +} + +func (m *Manager) Remove(ctx context.Context, containerName string) error { + // Stop first if running + m.Stop(ctx, containerName) + + c, ok := m.containers.Load(containerName) + if !ok { + return nil + } + cont := c.(*Container) + + if err := m.dockerClient.ContainerRemove(ctx, cont.ID, container.RemoveOptions{}); err != nil { + return err + } + + // Remove from map + m.containers.Delete(containerName) + + return nil +} + +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 { + containerName := key.(string) + if strings.HasPrefix(containerName, prefix) { + result = append(result, value.(*Container)) + } + return true + }) + + return result, nil +} +``` + +### 6.8 Helper Functions + +```go +// mapToSlice converts map to []string for env vars +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 { + // Implementation: parse "2g" → 2*1024*1024*1024 + // Use Docker's units package or manual parsing + return 0 // placeholder +} + +// parseLS parses ls -la output to []FileInfo +func parseLS(output string) []FileInfo { + // Implementation: parse ls output lines + return nil // placeholder +} + +// parseStat parses stat output to *FileInfo +func parseStat(output string) *FileInfo { + // Implementation: parse stat --format output + return nil // placeholder +} + +// createTarFromPath creates a tar archive from a host path +func createTarFromPath(hostPath string) (io.ReadCloser, error) { + // Implementation: walk directory, create tar entries + return nil, nil // placeholder +} + +// extractTarToPath extracts a tar archive to a host path +func extractTarToPath(reader io.Reader, hostPath string) error { + // Implementation: read tar entries, write to disk + return nil // placeholder +} +``` + +--- + +## 7. yao-bridge + +Lightweight binary inside container that bridges stdio to Unix socket. + +```go +// cmd/yao-bridge/main.go +package main + +import ( + "io" + "net" + "os" +) + +func main() { + if len(os.Args) < 2 { + os.Exit(1) + } + + sockPath := os.Args[1] + + // Connect to Unix socket + conn, err := net.Dial("unix", sockPath) + if err != nil { + os.Exit(1) + } + defer conn.Close() + + // stdin → socket + go func() { + io.Copy(conn, os.Stdin) + conn.(*net.UnixConn).CloseWrite() + }() + + // socket → stdout + io.Copy(os.Stdout, conn) +} +``` + +Build as static binary and include in Docker image. + +--- + +## 8. Docker Image + +### 8.1 Image Naming Convention + +``` +yao/sandbox-{tool}:{variant} + +Examples: + yao/sandbox-claude:latest # Claude CLI + Node.js + Python (default) + yao/sandbox-claude:full # + Go + yao/sandbox-cursor:latest # Cursor CLI + Node.js + Python (future) +``` + +### 8.2 Source Directory Structure + +``` +sandbox/ +├── docker/ +│ ├── base/ +│ │ └── Dockerfile.base # Common base image +│ ├── claude/ +│ │ ├── Dockerfile # Default: Claude + Node + Python +│ │ └── Dockerfile.full # + Go +│ ├── cursor/ # Future +│ │ └── Dockerfile +│ ├── build.sh +│ └── scripts/ +│ └── entrypoint.sh +├── bridge/ +│ └── main.go # yao-bridge source +├── ipc/ +│ ├── manager.go +│ └── session.go +├── manager.go +├── config.go +└── types.go +``` + +### 8.3 Base Image + +```dockerfile +# sandbox/docker/base/Dockerfile.base +FROM ubuntu:22.04 + +# Base tools +RUN apt-get update && apt-get install -y \ + curl \ + git \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# yao-bridge (common to all tools) +COPY yao-bridge /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 +USER sandbox + +CMD ["sleep", "infinity"] +``` + +### 8.4 Claude Tool Images + +```dockerfile +# sandbox/docker/claude/Dockerfile +# Default image: Claude CLI + Node.js + Python +FROM yao/sandbox-base:latest + +USER root + +# Claude CLI +RUN curl -fsSL https://claude.ai/install.sh | sh + +# Node.js 20 +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 \ + && rm -rf /var/lib/apt/lists/* + +USER sandbox +``` + +```dockerfile +# sandbox/docker/claude/Dockerfile.full +# Full image: + Go +FROM yao/sandbox-claude:latest + +USER root + +# Go 1.23 +RUN curl -fsSL https://go.dev/dl/go1.23.linux-amd64.tar.gz | tar -C /usr/local -xzf - \ + && ln -s /usr/local/go/bin/go /usr/local/bin/go + +USER sandbox +``` + +### 8.5 Build Script + +```bash +#!/bin/bash +# sandbox/docker/build.sh + +set -e + +TOOL=${1:-claude} + +# Build yao-bridge +cd ../bridge +CGO_ENABLED=0 go build -o ../docker/yao-bridge . +cd ../docker + +# Build base image +docker build -t yao/sandbox-base:latest -f base/Dockerfile.base . + +# Build tool-specific images +case $TOOL in + claude) + docker build -t yao/sandbox-claude:latest -f claude/Dockerfile . + docker build -t yao/sandbox-claude:full -f claude/Dockerfile.full . + ;; + cursor) + docker build -t yao/sandbox-cursor:latest -f cursor/Dockerfile . + ;; + all) + $0 claude + $0 cursor + ;; +esac + +echo "Images built for tool: $TOOL" +``` + +### 8.6 Image Variants + +| Image | Tool | Size | Pre-installed | +| --------------------------- | ------ | ------ | ----------------------------------- | +| `yao/sandbox-base:latest` | - | ~200MB | git, curl, yao-bridge | +| `yao/sandbox-claude:latest` | Claude | ~700MB | Claude CLI, Node.js 20, Python 3.11 | +| `yao/sandbox-claude:full` | Claude | ~1.3GB | + Go 1.23 | +| `yao/sandbox-cursor:latest` | Cursor | ~700MB | Cursor CLI, Node.js 20, Python 3.11 | + +Default: `yao/sandbox-claude:latest` (includes Node + Python) + +--- + +## 9. Configuration + +### 9.1 Environment Variables + +| Env Variable | Default | Description | +| -------------------------- | ----------------------------------- | ------------------------- | +| `YAO_SANDBOX_IMAGE` | `yao/sandbox-claude:latest` | Default Docker image | +| `YAO_SANDBOX_WORKSPACE` | `{YAO_DATA_ROOT}/sandbox/workspace` | Workspace root 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` | Default memory limit | +| `YAO_SANDBOX_CPU` | `1.0` | Default CPU limit | + +### 9.2 Go Config Struct + +```go +// sandbox/config.go +type Config struct { + Image string `json:"image,omitempty" env:"YAO_SANDBOX_IMAGE" envDefault:"yao/sandbox-claude:latest"` + WorkspaceRoot string `json:"workspace_root,omitempty" env:"YAO_SANDBOX_WORKSPACE"` + IPCDir string `json:"ipc_dir,omitempty" env:"YAO_SANDBOX_IPC"` + MaxContainers int `json:"max_containers,omitempty" env:"YAO_SANDBOX_MAX" envDefault:"100"` + IdleTimeout time.Duration `json:"idle_timeout,omitempty" env:"YAO_SANDBOX_IDLE_TIMEOUT" envDefault:"30m"` + MaxMemory string `json:"max_memory,omitempty" env:"YAO_SANDBOX_MEMORY" envDefault:"2g"` + MaxCPU float64 `json:"max_cpu,omitempty" env:"YAO_SANDBOX_CPU" envDefault:"1.0"` +} + +// Init sets defaults based on Yao config +func (c *Config) Init(dataRoot string) { + if c.WorkspaceRoot == "" { + c.WorkspaceRoot = filepath.Join(dataRoot, "sandbox", "workspace") + } + if c.IPCDir == "" { + c.IPCDir = filepath.Join(dataRoot, "sandbox", "ipc") + } +} +``` + +### 9.3 app.yao (optional override) + +```yaml +sandbox: + image: "yao/sandbox-claude:full" + max_memory: "4g" +``` + +### 9.4 Assistant-level Configuration (package.yao) + +```yaml +name: "My Coder" +type: claude + +sandbox: + image: "yao/sandbox-claude:full" # Override image + max_memory: "4g" # Override memory limit +``` + +### 9.5 Image Resolution + +``` +1. If package.yao sandbox.image is set → use it +2. Else if type is set → use yao/sandbox-{type}:latest +3. Else → use YAO_SANDBOX_IMAGE (or app.yao sandbox.image) +``` + +--- + +## 10. Data Persistence + +### Directory Structure + +``` +{YAO_DATA_ROOT}/sandbox/ +├── workspace/ +│ └── {userID}/ +│ ├── {chatID-1}/ # Mounted as /workspace in container +│ │ ├── .mcp.json # MCP configuration +│ │ ├── .claude/ # Claude configuration +│ │ │ └── skills/ # Skills symlink +│ │ ├── project/ # User project code +│ │ └── node_modules/ # Installed dependencies +│ │ +│ └── {chatID-2}/ +│ └── ... +│ +└── ipc/ + ├── {chatID-1}.sock # IPC socket + └── {chatID-2}.sock +``` + +### What Persists + +| Item | Location | Persists | +| ------------------ | --------------------- | ------------------------------ | +| User code | `/workspace/` | ✅ Yes (host mount) | +| Installed packages | Container filesystem | ✅ Yes (container persists) | +| Claude config | `/workspace/.claude/` | ✅ Yes | +| IPC socket | `/tmp/yao.sock` | ❌ No (recreated each session) | + +--- + +## 11. Stability Guarantees + +| Concern | Solution | +| ----------------------- | ----------------------------------------------------------------- | +| **Container isolation** | One container per user+chat | +| **IPC isolation** | One socket per session | +| **Resource limits** | Docker memory/CPU limits | +| **Idle cleanup** | Auto-stop after timeout (preserve data) | +| **Data persistence** | Workspace directory mount, container preserves installed packages | +| **Connection handling** | Goroutine detects EOF, auto-cleanup | +| **Concurrency safety** | sync.Map + dedicated goroutines | + +--- + +## 12. Claude Executor Integration + +### Execution Flow + +```go +func (e *ClaudeExecutor) Stream(ctx *context.Context, messages []context.Message, opts ...*context.Options) (*context.Response, error) { + + // 1. Get or create sandbox container + container, err := e.SandboxManager.GetOrCreate(ctx, ctx.User.ID, ctx.ChatID) + if err != nil { + return nil, fmt.Errorf("failed to get sandbox: %w", err) + } + + // 2. Create IPC session + mcpTools := e.getMCPTools() + ipcSession, err := e.IPCManager.Create(ctx, ctx.ChatID, &AgentContext{ + UserID: ctx.User.ID, + ChatID: ctx.ChatID, + Locale: ctx.Locale, + }, mcpTools) + if err != nil { + return nil, fmt.Errorf("failed to create IPC session: %w", err) + } + defer e.IPCManager.Close(ctx.ChatID) + + // 3. Generate .mcp.json + if err := e.writeMCPConfig(ctx, container); err != nil { + return nil, err + } + + // 4. Setup skills + if err := e.setupSkills(container); err != nil { + return nil, err + } + + // 5. Build Claude CLI arguments + args := e.buildArgs(ctx, messages) + + // 6. Execute Claude CLI in container + stdout, err := e.SandboxManager.Stream(ctx, container.Name, + append([]string{"claude"}, args...), + &ExecOptions{ + WorkDir: "/workspace", + Env: e.buildEnvMap(ctx), + Timeout: e.getTimeout(), + }, + ) + if err != nil { + return nil, err + } + defer stdout.Close() + + // 7. Parse stream-json output + return e.parseClaudeOutput(ctx, stdout) +} +``` + +### MCP Configuration Generation + +```go +func (e *ClaudeExecutor) writeMCPConfig(ctx context.Context, container *Container) error { + config := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "yao": map[string]interface{}{ + "command": "yao-bridge", + "args": []string{"/tmp/yao.sock"}, + }, + }, + } + + // Add other MCP servers (external stdio/sse) + for _, server := range e.Assistant.MCP.Servers { + if server.Transport != "process" { + config["mcpServers"].(map[string]interface{})[server.Name] = server.ToClaudeConfig() + } + } + + data, _ := json.MarshalIndent(config, "", " ") + return e.SandboxManager.WriteFile(ctx, container.Name, "/workspace/.mcp.json", data) +} +``` + +--- + +## 13. Security Considerations + +| Layer | Measures | +| -------------- | ------------------------------------------------------ | +| **Filesystem** | Only workspace mounted, host filesystem not accessible | +| **Network** | Can be restricted with `--network none` if needed | +| **Privileges** | `--cap-drop ALL`, `no-new-privileges` | +| **Resources** | Memory and CPU limits | +| **User** | Non-root user inside container | +| **IPC** | Per-session socket, authorized tools only | + +--- + +## 14. Summary + +| Aspect | Description | +| -------------------------- | ----------------------------------------- | +| **Core approach** | Persistent Docker containers | +| **Communication** | Unix Socket + MCP JSON-RPC | +| **Container granularity** | One container per user+chat | +| **Data persistence** | Workspace mount + container filesystem | +| **Dependency persistence** | npm/pip packages persist in container | +| **Security isolation** | Full isolation between users and sessions | +| **Resource control** | Memory, CPU, idle timeout | +| **Estimated code** | ~1200 lines | diff --git a/sandbox/PLAN.md b/sandbox/PLAN.md new file mode 100644 index 00000000..a900344b --- /dev/null +++ b/sandbox/PLAN.md @@ -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`) diff --git a/sandbox/README.md b/sandbox/README.md new file mode 100644 index 00000000..b2a7ca05 --- /dev/null +++ b/sandbox/README.md @@ -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 diff --git a/sandbox/bridge/main.go b/sandbox/bridge/main.go new file mode 100644 index 00000000..ef054ad9 --- /dev/null +++ b/sandbox/bridge/main.go @@ -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 ") + } + + 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: + } +} diff --git a/sandbox/config.go b/sandbox/config.go new file mode 100644 index 00000000..4b544a67 --- /dev/null +++ b/sandbox/config.go @@ -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 + } +} diff --git a/sandbox/config_test.go b/sandbox/config_test.go new file mode 100644 index 00000000..60bc0a78 --- /dev/null +++ b/sandbox/config_test.go @@ -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) + } +} diff --git a/sandbox/docker/base/Dockerfile.base b/sandbox/docker/base/Dockerfile.base new file mode 100644 index 00000000..4eef0bc7 --- /dev/null +++ b/sandbox/docker/base/Dockerfile.base @@ -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"] diff --git a/sandbox/docker/build.sh b/sandbox/docker/build.sh new file mode 100755 index 00000000..891d6e3a --- /dev/null +++ b/sandbox/docker/build.sh @@ -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" diff --git a/sandbox/docker/claude/Dockerfile b/sandbox/docker/claude/Dockerfile new file mode 100644 index 00000000..62e18ae1 --- /dev/null +++ b/sandbox/docker/claude/Dockerfile @@ -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"] diff --git a/sandbox/docker/claude/Dockerfile.full b/sandbox/docker/claude/Dockerfile.full new file mode 100644 index 00000000..98225c01 --- /dev/null +++ b/sandbox/docker/claude/Dockerfile.full @@ -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"] diff --git a/sandbox/errors.go b/sandbox/errors.go new file mode 100644 index 00000000..e705d410 --- /dev/null +++ b/sandbox/errors.go @@ -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") +) diff --git a/sandbox/helpers.go b/sandbox/helpers.go new file mode 100644 index 00000000..013d4989 --- /dev/null +++ b/sandbox/helpers.go @@ -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) +} diff --git a/sandbox/helpers_test.go b/sandbox/helpers_test.go new file mode 100644 index 00000000..1ae2c41a --- /dev/null +++ b/sandbox/helpers_test.go @@ -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)) + } +} diff --git a/sandbox/ipc/jsonrpc_test.go b/sandbox/ipc/jsonrpc_test.go new file mode 100644 index 00000000..4d9149d4 --- /dev/null +++ b/sandbox/ipc/jsonrpc_test.go @@ -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) + } +} diff --git a/sandbox/ipc/manager.go b/sandbox/ipc/manager.go new file mode 100644 index 00000000..57151901 --- /dev/null +++ b/sandbox/ipc/manager.go @@ -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 + }) +} diff --git a/sandbox/ipc/manager_test.go b/sandbox/ipc/manager_test.go new file mode 100644 index 00000000..e02bc2eb --- /dev/null +++ b/sandbox/ipc/manager_test.go @@ -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") + } +} diff --git a/sandbox/ipc/session.go b/sandbox/ipc/session.go new file mode 100644 index 00000000..eb3ae490 --- /dev/null +++ b/sandbox/ipc/session.go @@ -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) +} diff --git a/sandbox/ipc/session_test.go b/sandbox/ipc/session_test.go new file mode 100644 index 00000000..a216ac19 --- /dev/null +++ b/sandbox/ipc/session_test.go @@ -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) + } +} diff --git a/sandbox/ipc/types.go b/sandbox/ipc/types.go new file mode 100644 index 00000000..4219bff2 --- /dev/null +++ b/sandbox/ipc/types.go @@ -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"` +} diff --git a/sandbox/manager.go b/sandbox/manager.go new file mode 100644 index 00000000..93e9ca29 --- /dev/null +++ b/sandbox/manager.go @@ -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 +} diff --git a/sandbox/manager_test.go b/sandbox/manager_test.go new file mode 100644 index 00000000..c5bcfdea --- /dev/null +++ b/sandbox/manager_test.go @@ -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") +} diff --git a/sandbox/types.go b/sandbox/types.go new file mode 100644 index 00000000..d12d4ca9 --- /dev/null +++ b/sandbox/types.go @@ -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" +)