diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1e9a7919a2..acd206918c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -60,3 +60,5 @@ jobs: - name: Run go test run: go test ./... + env: + DOCKER_HOST: unix:///var/run/docker.sock diff --git a/Makefile b/Makefile index 8de98e9846..98881f88d5 100644 --- a/Makefile +++ b/Makefile @@ -171,6 +171,9 @@ build-all: generate GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) @echo "All builds complete" +build-sandbox: + ./scripts/build-sandbox.sh + ## install: Install picoclaw to system and copy builtin skills install: build @echo "Installing $(BINARY_NAME)..." diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 4f93b858a3..3b503c665e 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -236,7 +236,16 @@ func setupCronTool( var cronTool *tools.CronTool if cfg.Tools.IsToolEnabled("cron") { var err error - cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) + cronTool, err = tools.NewCronTool( + cronService, + agentLoop, + msgBus, + workspace, + restrict, + execTimeout, + cfg, + agentLoop.GetDefaultSandboxManager(), + ) if err != nil { log.Fatalf("Critical error during CronTool initialization: %v", err) } diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index ec10129594..5ba25f42b9 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" ) +//go:generate rm -rf workspace //go:generate cp -r ../../../../workspace . //go:embed workspace var embeddedFiles embed.FS diff --git a/docker/Dockerfile.sandbox b/docker/Dockerfile.sandbox new file mode 100644 index 0000000000..bd2de05fc8 --- /dev/null +++ b/docker/Dockerfile.sandbox @@ -0,0 +1,20 @@ +FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + python3 \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --shell /bin/bash sandbox +USER sandbox +WORKDIR /home/sandbox + +CMD ["sleep", "infinity"] \ No newline at end of file diff --git a/go.mod b/go.mod index f60be046f1..68f26ea28b 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( ) require ( + github.com/Microsoft/go-winio v0.4.21 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/beeper/argo-go v1.1.2 // indirect github.com/coder/websocket v1.8.14 // indirect @@ -65,20 +66,41 @@ require ( modernc.org/memory v1.11.0 // indirect rsc.io/qr v0.2.0 // indirect ) - require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/github/copilot-sdk/go v0.1.23 + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -87,10 +109,19 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.7 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.41.0 + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 4060997f8a..d05b16f96f 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,14 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= +github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= @@ -25,8 +31,12 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= @@ -37,6 +47,12 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -44,12 +60,22 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw= github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= @@ -58,21 +84,24 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= +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= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= -github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= @@ -100,6 +129,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -117,6 +148,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -136,27 +169,42 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +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/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -166,6 +214,8 @@ github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -179,31 +229,35 @@ github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQcc github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= -github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -235,6 +289,24 @@ go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +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.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -252,10 +324,10 @@ golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05 golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -266,13 +338,13 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= @@ -289,11 +361,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -301,29 +375,31 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -332,13 +408,19 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -363,6 +445,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk= maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/infra/homedir.go b/internal/infra/homedir.go new file mode 100644 index 0000000000..97ff32e7be --- /dev/null +++ b/internal/infra/homedir.go @@ -0,0 +1,22 @@ +package infra + +import ( + "os" + "path/filepath" + "strings" +) + +// ResolveHomeDir returns the effective home directory for PicoClaw. +// It checks the PICOCLAW_HOME environment variable first, +// falls back to ~/.picoclaw if not set or empty. +func ResolveHomeDir() string { + if envHome := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); envHome != "" { + return envHome + } + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + // Extreme fallback + return filepath.Join(os.TempDir(), ".picoclaw") + } + return filepath.Join(home, ".picoclaw") +} diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 719b0cb6d2..a340c62320 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -69,18 +69,23 @@ func NewContextBuilder(workspace string) *ContextBuilder { } } -func (cb *ContextBuilder) getIdentity() string { - workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) +type SandboxInfo struct { + IsHost bool + WorkspaceDir string +} +func (cb *ContextBuilder) getIdentity() string { return fmt.Sprintf(`# picoclaw 🦞 You are picoclaw, a helpful AI assistant. ## Workspace -Your workspace is at: %s -- Memory: %s/memory/MEMORY.md -- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md -- Skills: %s/skills/{skill-name}/SKILL.md +Your workspace is at: {{WORKSPACE}} +- Memory: {{WORKSPACE}}/memory/MEMORY.md +- Daily Notes: {{WORKSPACE}}/memory/YYYYMM/YYYYMMDD.md +- Skills: {{WORKSPACE}}/skills/{skill-name}/SKILL.md + +{{SANDBOX_GUIDANCE}} ## Important Rules @@ -88,10 +93,9 @@ Your workspace is at: %s 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. -3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md +3. **Memory** - When interacting with me if something seems memorable, update {{WORKSPACE}}/memory/MEMORY.md -4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, - workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) +4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`) } func (cb *ContextBuilder) BuildSystemPrompt() string { @@ -443,7 +447,8 @@ func (cb *ContextBuilder) BuildMessages( summary string, currentMessage string, media []string, - channel, chatID string, + channel, chatID, workspacePath string, + sb SandboxInfo, ) []providers.Message { messages := []providers.Message{} @@ -458,6 +463,25 @@ func (cb *ContextBuilder) BuildMessages( // - OpenAI-compat passes messages through as-is. staticPrompt := cb.BuildSystemPromptWithCache() + // Inject the actual workspace path into the static prompt template. + // This allows the bulk of the prompt to remain cached while the path + // remains dynamic based on the execution environment (host vs container). + if workspacePath == "" { + workspacePath, _ = filepath.Abs(cb.workspace) + } + staticPrompt = strings.ReplaceAll(staticPrompt, "{{WORKSPACE}}", workspacePath) + + // Inject dynamic sandbox guidance + sandboxGuidance := "" + if !sb.IsHost { + sandboxGuidance = `## Sandbox +You are running in a sandboxed runtime (tools execute in Docker container). +- **Guidance**: ALWAYS prefer relative paths (e.g., 'src/main.go') instead of absolute paths. +- **Why**: File tools (read_file/write_file) run on host bridge, while execution tools (exec) run inside container. Relative paths ensure consistency between both. +- **Constraints**: Some system-level tools or network access may be restricted by sandbox policy.` + } + staticPrompt = strings.ReplaceAll(staticPrompt, "{{SANDBOX_GUIDANCE}}", sandboxGuidance) + // Build short dynamic context (time, runtime, session) — changes per request dynamicCtx := cb.buildDynamicContext(channel, chatID) diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 707510820d..1cdfc4b3eb 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1") + msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", tmpDir, SandboxInfo{}) systemCount := 0 for _, m := range msgs { @@ -576,7 +576,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { } // Also exercise BuildMessages concurrently - msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat") + msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", tmpDir, SandboxInfo{}) if len(msgs) < 2 { errs <- "BuildMessages returned fewer than 2 messages" return @@ -664,6 +664,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test") + _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", tmpDir, SandboxInfo{}) } } diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 5756ed9114..edc4fd7459 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -1,6 +1,7 @@ package agent import ( + "strings" "testing" "github.com/sipeed/picoclaw/pkg/providers" @@ -10,6 +11,82 @@ func msg(role, content string) providers.Message { return providers.Message{Role: role, Content: content} } +func TestBuildMessages_DynamicWorkspace(t *testing.T) { + root := t.TempDir() + cb := NewContextBuilder(root) + + // Test host path + msgs := cb.BuildMessages(nil, "", "hi", nil, "cli", "chat1", root, SandboxInfo{IsHost: true}) + if len(msgs) == 0 || msgs[0].Role != "system" { + t.Fatal("expected system message") + } + if !strings.Contains(msgs[0].Content, "Your workspace is at: "+root) { + t.Errorf("system prompt missing host workspace path: %s", msgs[0].Content) + } + + // Test container path + containerPath := "/workspace" + msgs = cb.BuildMessages(nil, "", "hi", nil, "cli", "chat1", containerPath, SandboxInfo{IsHost: false}) + if !strings.Contains(msgs[0].Content, "Your workspace is at: "+containerPath) { + t.Errorf("system prompt missing container workspace path: %s", msgs[0].Content) + } +} + +func TestBuildMessages_SandboxInfo(t *testing.T) { + root := t.TempDir() + cb := NewContextBuilder(root) + + // Test with sandbox enabled (container) + sb := SandboxInfo{ + IsHost: false, + } + msgs := cb.BuildMessages(nil, "", "hi", nil, "cli", "chat1", "/workspace", sb) + content := msgs[0].Content + + if !strings.Contains(content, "## Sandbox") { + t.Error("expected ## Sandbox section in prompt") + } + if !strings.Contains(content, "Docker container") { + t.Error("expected 'Docker container' description in prompt") + } + if !strings.Contains(content, "ALWAYS prefer relative paths") { + t.Error("expected relative path guidance in prompt") + } + + // Test with sandbox disabled (host) + sb = SandboxInfo{ + IsHost: true, + } + msgs = cb.BuildMessages(nil, "", "hi", nil, "cli", "chat1", root, sb) + content = msgs[0].Content + if strings.Contains(content, "## Sandbox") { + t.Error("did not expect ## Sandbox section in prompt when disabled") + } +} + +func TestBuildMessages_CacheIntegrity_SandboxToggle(t *testing.T) { + root := t.TempDir() + cb := NewContextBuilder(root) + + // 1. Call with Host - should populate cache without sandbox guidance + msgs1 := cb.BuildMessages(nil, "", "hi", nil, "cli", "chat1", root, SandboxInfo{IsHost: true}) + if strings.Contains(msgs1[0].Content, "## Sandbox") { + t.Fatal("First call (Host) should not have sandbox guidance") + } + + // 2. Call with Container - should use SAME cache but inject guidance + msgs2 := cb.BuildMessages(nil, "", "hi", nil, "cli", "chat1", "/workspace", SandboxInfo{IsHost: false}) + if !strings.Contains(msgs2[0].Content, "## Sandbox") { + t.Fatal("Second call (Container) MUST have sandbox guidance via dynamic injection") + } + + // 3. Call with Host again - should still be correct (no guidance) + msgs3 := cb.BuildMessages(nil, "", "hi", nil, "cli", "chat1", root, SandboxInfo{IsHost: true}) + if strings.Contains(msgs3[0].Content, "## Sandbox") { + t.Fatal("Third call (Host) should not have sandbox guidance (cache must remain clean)") + } +} + func assistantWithTools(toolIDs ...string) providers.Message { calls := make([]providers.ToolCall, len(toolIDs)) for i, id := range toolIDs { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 97cf0fa059..2e8db5cd06 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/sipeed/picoclaw/pkg/agent/sandbox" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" @@ -45,6 +46,7 @@ type AgentInstance struct { // LightCandidates holds the resolved provider candidates for the light model. // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. LightCandidates []providers.FallbackCandidate + SandboxManager sandbox.Manager } // NewAgentInstance creates an agent instance from config. @@ -59,38 +61,57 @@ func NewAgentInstance( model := resolveAgentModel(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults) - restrict := defaults.RestrictToWorkspace readRestrict := restrict && !defaults.AllowReadOutsideWorkspace // Compile path whitelist patterns from config. allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) + agentID := routing.DefaultAgentID + agentName := "" + var subagents *config.SubagentsConfig + var skillsFilter []string + if agentCfg != nil { + agentID = routing.NormalizeAgentID(agentCfg.ID) + agentName = agentCfg.Name + subagents = agentCfg.Subagents + skillsFilter = agentCfg.Skills + } + roContainer := isContainerReadOnlySandbox(cfg) toolsRegistry := tools.NewToolRegistry() - if cfg.Tools.IsToolEnabled("read_file") { + sandboxManager := sandbox.NewFromConfigWithAgent(workspace, restrict, cfg, agentID) + isToolEnabled := func(toolName string) bool { + if cfg != nil && !cfg.Tools.IsToolEnabled(toolName) { + return false + } + return isSandboxModeOff(cfg) || sandbox.IsToolSandboxEnabled(cfg, toolName) + } + + if isToolEnabled("read_file") { toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths)) } - if cfg.Tools.IsToolEnabled("write_file") { + if !roContainer && isToolEnabled("write_file") { toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) } - if cfg.Tools.IsToolEnabled("list_dir") { + if isToolEnabled("list_dir") { toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) } - if cfg.Tools.IsToolEnabled("exec") { + if isToolEnabled("exec") { execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) if err != nil { log.Fatalf("Critical error: unable to initialize exec tool: %v", err) } toolsRegistry.Register(execTool) } - - if cfg.Tools.IsToolEnabled("edit_file") { - toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) - } - if cfg.Tools.IsToolEnabled("append_file") { - toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) + if !roContainer { + if isToolEnabled("edit_file") { + toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) + } + if isToolEnabled("append_file") { + toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) + } } sessionsDir := filepath.Join(workspace, "sessions") @@ -98,18 +119,6 @@ func NewAgentInstance( contextBuilder := NewContextBuilder(workspace) - agentID := routing.DefaultAgentID - agentName := "" - var subagents *config.SubagentsConfig - var skillsFilter []string - - if agentCfg != nil { - agentID = routing.NormalizeAgentID(agentCfg.ID) - agentName = agentCfg.Name - subagents = agentCfg.Subagents - skillsFilter = agentCfg.Skills - } - maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 @@ -224,6 +233,7 @@ func NewAgentInstance( Sessions: sessionsManager, ContextBuilder: contextBuilder, Tools: toolsRegistry, + SandboxManager: sandboxManager, Subagents: subagents, SkillsFilter: skillsFilter, Candidates: candidates, @@ -237,13 +247,13 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { return expandHome(strings.TrimSpace(agentCfg.Workspace)) } - // Use the configured default workspace (respects PICOCLAW_HOME) + defaultWS := expandHome(defaults.Workspace) if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" { - return expandHome(defaults.Workspace) + return defaultWS } - // For named agents without explicit workspace, use default workspace with agent ID suffix + parent := filepath.Dir(defaultWS) id := routing.NormalizeAgentID(agentCfg.ID) - return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id) + return filepath.Join(parent, "workspace-"+id) } // resolveAgentModel resolves the primary model for an agent. @@ -262,6 +272,21 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD return defaults.ModelFallbacks } +func isContainerReadOnlySandbox(cfg *config.Config) bool { + if cfg == nil { + return false + } + return cfg.Agents.Defaults.Sandbox.Mode == config.SandboxModeAll && + cfg.Agents.Defaults.Sandbox.WorkspaceAccess == config.WorkspaceAccessRO +} + +func isSandboxModeOff(cfg *config.Config) bool { + if cfg == nil { + return false + } + return cfg.Agents.Defaults.Sandbox.Mode == config.SandboxModeOff +} + func compilePatterns(patterns []string) []*regexp.Regexp { compiled := make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 4f41ecd1cc..8d928a573f 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -1,7 +1,9 @@ package agent import ( + "context" "os" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" @@ -94,6 +96,66 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { } } +func TestNewAgentInstance_ReadOnlyContainerOmitsWriteTools(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.Model = "test-model" + cfg.Agents.Defaults.MaxTokens = 1234 + cfg.Agents.Defaults.MaxToolIterations = 5 + cfg.Agents.Defaults.Sandbox = config.AgentSandboxConfig{ + Mode: "all", + WorkspaceAccess: "ro", + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + for _, name := range []string{"write_file", "edit_file", "append_file"} { + if _, ok := agent.Tools.Get(name); ok { + t.Fatalf("%s should not be registered in ro sandbox", name) + } + } + + writeRes := agent.Tools.Execute(context.Background(), "write_file", map[string]any{ + "path": "a.txt", + "content": "hello", + }) + if !writeRes.IsError || !strings.Contains(writeRes.ForLLM, "not found") { + t.Fatalf("write_file should be absent in ro sandbox, got: %+v", writeRes) + } +} + +func TestNewAgentInstance_SandboxModeOffRegistersFullToolSet(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.Model = "test-model" + cfg.Agents.Defaults.MaxTokens = 1234 + cfg.Agents.Defaults.MaxToolIterations = 5 + cfg.Agents.Defaults.Sandbox = config.AgentSandboxConfig{Mode: "off"} + cfg.Tools.Sandbox.Tools.Allow = []string{"exec"} + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + for _, name := range []string{"read_file", "write_file", "list_dir", "exec", "edit_file", "append_file"} { + if _, ok := agent.Tools.Get(name); !ok { + t.Fatalf("%s should be registered when sandbox.mode=off", name) + } + } +} + func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { tests := []struct { name string @@ -128,7 +190,6 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) - cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 9a54f5077c..686c364ed7 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -19,6 +19,7 @@ import ( "time" "unicode/utf8" + "github.com/sipeed/picoclaw/pkg/agent/sandbox" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" @@ -740,13 +741,38 @@ func (al *AgentLoop) runAgentLoop( } } - // 1. Build messages (skip history for heartbeat) + // 1. Prepare sandbox context for this run. + // Inject the routing session key so sandbox.shouldSandbox() can compare + // it against the main session key for non-main mode. + ctx = sandbox.WithSessionKey(ctx, opts.SessionKey) + + // Resolve sandbox environment for this run. + // We guarantee SandboxManager is non-nil (fallback to host manager) in NewAgentInstance. + ctx = sandbox.WithManager(ctx, agent.SandboxManager) + + sb, err := agent.SandboxManager.Resolve(ctx) + if err != nil { + logger.ErrorCF("agent", "Failed to resolve sandbox", map[string]any{"error": err.Error()}) + return "", fmt.Errorf("failed to resolve sandbox: %w", err) + } + // Add pre-resolved sandbox to context for thread-safe access in tools + ctx = sandbox.WithSandbox(ctx, sb) + + // 2. Build messages (skip history for heartbeat) var history []providers.Message var summary string if !opts.NoHistory { history = agent.Sessions.GetHistory(opts.SessionKey) summary = agent.Sessions.GetSummary(opts.SessionKey) } + workspacePath := sb.GetWorkspace(ctx) + _, isHost := sb.(*sandbox.HostSandbox) + + sbInfo := SandboxInfo{ + IsHost: isHost, + WorkspaceDir: workspacePath, + } + messages := agent.ContextBuilder.BuildMessages( history, summary, @@ -754,6 +780,8 @@ func (al *AgentLoop) runAgentLoop( opts.Media, opts.Channel, opts.ChatID, + workspacePath, + sbInfo, ) // Resolve media:// refs to base64 data URLs (streaming) @@ -763,8 +791,8 @@ func (al *AgentLoop) runAgentLoop( // 2. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) - // 3. Run LLM iteration loop - finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) + // 4. Run LLM iteration loop + finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts, workspacePath, sbInfo) if err != nil { return "", err } @@ -870,6 +898,8 @@ func (al *AgentLoop) runLLMIteration( agent *AgentInstance, messages []providers.Message, opts processOptions, + workspacePath string, + sbInfo SandboxInfo, ) (string, int, error) { iteration := 0 var finalContent string @@ -1021,7 +1051,8 @@ func (al *AgentLoop) runLLMIteration( newSummary := agent.Sessions.GetSummary(opts.SessionKey) messages = agent.ContextBuilder.BuildMessages( newHistory, newSummary, "", - nil, opts.Channel, opts.ChatID, + nil, opts.Channel, opts.ChatID, workspacePath, + sbInfo, ) continue } @@ -1369,6 +1400,16 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { }) } +// GetDefaultSandboxManager returns the SandboxManager of the default agent. +// Returns nil if no default agent is registered. +func (al *AgentLoop) GetDefaultSandboxManager() sandbox.Manager { + agent := al.registry.GetDefaultAgent() + if agent == nil { + return nil + } + return agent.SandboxManager +} + // GetStartupInfo returns information about loaded tools and skills for logging. func (al *AgentLoop) GetStartupInfo() map[string]any { info := make(map[string]any) diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index 518bb441f9..5abc0b5d88 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -24,11 +24,12 @@ func (m *mockRegistryProvider) GetDefaultModel() string { return "mock-model" } -func testCfg(agents []config.AgentConfig) *config.Config { +func testCfg(t *testing.T, agents []config.AgentConfig) *config.Config { + workspace := t.TempDir() return &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: "/tmp/picoclaw-test-registry", + Workspace: workspace, Model: "gpt-4", MaxTokens: 8192, MaxToolIterations: 10, @@ -39,7 +40,7 @@ func testCfg(agents []config.AgentConfig) *config.Config { } func TestNewAgentRegistry_ImplicitMain(t *testing.T) { - cfg := testCfg(nil) + cfg := testCfg(t, nil) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) ids := registry.ListAgentIDs() @@ -57,7 +58,7 @@ func TestNewAgentRegistry_ImplicitMain(t *testing.T) { } func TestNewAgentRegistry_ExplicitAgents(t *testing.T) { - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ {ID: "sales", Default: true, Name: "Sales Bot"}, {ID: "support", Name: "Support Bot"}, }) @@ -83,7 +84,7 @@ func TestNewAgentRegistry_ExplicitAgents(t *testing.T) { } func TestAgentRegistry_GetAgent_Normalize(t *testing.T) { - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ {ID: "my-agent", Default: true}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) @@ -98,7 +99,7 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) { } func TestAgentRegistry_GetDefaultAgent(t *testing.T) { - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ {ID: "alpha"}, {ID: "beta", Default: true}, }) @@ -112,7 +113,7 @@ func TestAgentRegistry_GetDefaultAgent(t *testing.T) { } func TestAgentRegistry_CanSpawnSubagent(t *testing.T) { - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ { ID: "parent", Default: true, @@ -141,7 +142,7 @@ func TestAgentRegistry_CanSpawnSubagent(t *testing.T) { } func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) { - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ { ID: "admin", Default: true, @@ -163,7 +164,7 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) { func TestAgentInstance_Model(t *testing.T) { model := &config.AgentModelConfig{Primary: "claude-opus"} - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ {ID: "custom", Default: true, Model: model}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) @@ -175,7 +176,7 @@ func TestAgentInstance_Model(t *testing.T) { } func TestAgentInstance_FallbackInheritance(t *testing.T) { - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ {ID: "inherit", Default: true}, }) cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"} @@ -192,7 +193,7 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { Primary: "gpt-4", Fallbacks: []string{}, // explicitly empty = disable } - cfg := testCfg([]config.AgentConfig{ + cfg := testCfg(t, []config.AgentConfig{ {ID: "no-fallback", Default: true, Model: model}, }) cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"} diff --git a/pkg/agent/sandbox/container.go b/pkg/agent/sandbox/container.go new file mode 100644 index 0000000000..408e23f1b0 --- /dev/null +++ b/pkg/agent/sandbox/container.go @@ -0,0 +1,1082 @@ +package sandbox + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "math" + "os" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/docker/go-units" + + "github.com/sipeed/picoclaw/internal/infra" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// ContainerSandboxConfig defines runtime and docker settings for container sandbox execution. +type ContainerSandboxConfig struct { + Image string + ContainerName string + ContainerPrefix string + Workspace string + AgentWorkspace string + WorkspaceAccess string + WorkspaceRoot string + PruneIdleHours int + PruneMaxAgeDays int + Workdir string + ReadOnlyRoot bool + Tmpfs []string + Network string + User string + CapDrop []string + Env map[string]string + SetupCommand string + PidsLimit int64 + Memory string + MemorySwap string + Cpus float64 + Ulimits map[string]config.AgentSandboxDockerUlimitValue + SeccompProfile string + ApparmorProfile string + DNS []string + ExtraHosts []string + Binds []string +} + +// ContainerSandbox executes commands and filesystem operations inside a managed docker container. +type ContainerSandbox struct { + mu sync.Mutex + cfg ContainerSandboxConfig + cli *client.Client + startErr error + fs FsBridge + hash string +} + +const ( + defaultSandboxRegistryFile = "containers.json" + DefaultSandboxImage = "picoclaw-sandbox:bookworm-slim" + FallbackSandboxImage = "debian:bookworm-slim" +) + +// NewContainerSandbox creates a container sandbox with normalized defaults and precomputed config hash. +func NewContainerSandbox(cfg ContainerSandboxConfig) *ContainerSandbox { + if strings.TrimSpace(cfg.Image) == "" { + cfg.Image = DefaultSandboxImage + } + if strings.TrimSpace(cfg.ContainerPrefix) == "" { + cfg.ContainerPrefix = "picoclaw-sandbox-" + } + if strings.TrimSpace(cfg.ContainerName) == "" { + cfg.ContainerName = cfg.ContainerPrefix + "default" + } + if strings.TrimSpace(cfg.Workdir) == "" { + cfg.Workdir = "/workspace" + } + if len(cfg.Tmpfs) == 0 { + cfg.Tmpfs = []string{"/tmp", "/var/tmp", "/run"} + } + if strings.TrimSpace(cfg.Network) == "" { + cfg.Network = "none" + } + if cfg.CapDrop == nil { + cfg.CapDrop = []string{"ALL"} + } + if cfg.Env == nil { + cfg.Env = map[string]string{"LANG": "C.UTF-8"} + } + cfg.Env = sanitizeEnvVars(cfg.Env) + if len(cfg.Env) == 0 { + cfg.Env = map[string]string{"LANG": "C.UTF-8"} + } + cfg.WorkspaceAccess = string(normalizeWorkspaceAccess(config.WorkspaceAccess(cfg.WorkspaceAccess))) + cfg.WorkspaceRoot = strings.TrimSpace(cfg.WorkspaceRoot) + sb := &ContainerSandbox{cfg: cfg} + sb.hash = computeContainerConfigHash(cfg) + sb.fs = &containerFS{sb: sb} + return sb +} + +// Start initializes docker connectivity and validates sandbox runtime requirements. +func (c *ContainerSandbox) Start(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.startErr != nil { + return c.startErr + } + if c.cli != nil { + return nil + } + + if err := validateSandboxSecurity(c.cfg); err != nil { + c.startErr = err + return err + } + if strings.TrimSpace(c.cfg.Workspace) != "" && c.cfg.WorkspaceAccess == string(config.WorkspaceAccessNone) { + if err := os.MkdirAll(c.cfg.Workspace, 0o755); err != nil { + c.startErr = fmt.Errorf("sandbox workspace init failed: %w", err) + return c.startErr + } + } + if strings.TrimSpace(c.cfg.WorkspaceRoot) != "" { + if err := os.MkdirAll(c.cfg.WorkspaceRoot, 0o755); err != nil { + c.startErr = fmt.Errorf("sandbox workspace_root init failed: %w", err) + return c.startErr + } + } + + if c.cli == nil { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + c.startErr = fmt.Errorf("docker client init failed: %w", err) + return c.startErr + } + c.cli = cli + } + + if _, err := c.cli.Ping(ctx); err != nil { + c.startErr = fmt.Errorf("docker daemon unavailable: %w", err) + return c.startErr + } + + if _, err := c.cli.ImageInspect(ctx, c.cfg.Image); err != nil { + if c.cfg.Image == DefaultSandboxImage { + // If default image is missing, try to pull fallback and tag it + rc, pullErr := c.cli.ImagePull(ctx, FallbackSandboxImage, image.PullOptions{}) + if pullErr != nil { + c.startErr = fmt.Errorf("docker fallback image unavailable (%s): %w", FallbackSandboxImage, pullErr) + return c.startErr + } + defer rc.Close() + if _, err := io.Copy(io.Discard, rc); err != nil { + c.startErr = fmt.Errorf("failed to pull fallback image: %w", err) + return c.startErr + } + + // Tag debian:bookworm-slim as picoclaw-sandbox:bookworm-slim + if err := c.cli.ImageTag(ctx, FallbackSandboxImage, DefaultSandboxImage); err != nil { + c.startErr = fmt.Errorf("failed to tag fallback image: %w", err) + return c.startErr + } + } else { + // For non-default images, just try to pull directly + rc, pullErr := c.cli.ImagePull(ctx, c.cfg.Image, image.PullOptions{}) + if pullErr != nil { + c.startErr = fmt.Errorf("docker image unavailable (%s): %w", c.cfg.Image, pullErr) + return c.startErr + } + defer rc.Close() + if _, err := io.Copy(io.Discard, rc); err != nil { + c.startErr = fmt.Errorf("failed to pull image: %w", err) + return c.startErr + } + } + } + + return nil +} + +// Resolve returns the container sandbox itself. +func (c *ContainerSandbox) Resolve(ctx context.Context) (Sandbox, error) { + return c, nil +} + +// Prune reclaims container sandbox resources. +// This is the container-specific cleanup boundary where implementations should +// stop and remove this sandbox container. +func (c *ContainerSandbox) Prune(ctx context.Context) error { + containerName := strings.TrimSpace(c.cfg.ContainerName) + if containerName == "" { + return nil + } + + var firstErr error + if c.cli != nil { + if err := c.stopAndRemoveContainer(ctx, containerName); err != nil { + firstErr = err + } + } + if err := removeRegistryEntry(c.registryPath(), containerName); err != nil && firstErr == nil { + firstErr = err + } + return firstErr +} + +// Exec ensures the container is ready and runs the requested command inside the sandbox. +func (c *ContainerSandbox) Exec(ctx context.Context, req ExecRequest) (*ExecResult, error) { + return aggregateExecStream(func(onEvent func(ExecEvent) error) (*ExecResult, error) { + return c.ExecStream(ctx, req, onEvent) + }) +} + +func (c *ContainerSandbox) ExecStream( + ctx context.Context, + req ExecRequest, + onEvent func(ExecEvent) error, +) (*ExecResult, error) { + if c.startErr != nil { + return nil, c.startErr + } + execCtx := ctx + cancel := func() {} + if req.TimeoutMs > 0 { + execCtx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutMs)*time.Millisecond) + } else if _, hasDeadline := ctx.Deadline(); !hasDeadline { + execCtx, cancel = context.WithTimeout(ctx, 30*time.Second) + } + defer cancel() + if err := c.ensureContainer(execCtx); err != nil { + return nil, err + } + + cmd, wd, err := c.buildExecCommand(req) + if err != nil { + return nil, err + } + + execResp, err := c.cli.ContainerExecCreate(execCtx, c.cfg.ContainerName, container.ExecOptions{ + Cmd: cmd, + WorkingDir: wd, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return nil, fmt.Errorf("docker exec create failed: %w", err) + } + + attach, err := c.cli.ContainerExecAttach(execCtx, execResp.ID, container.ExecStartOptions{}) + if err != nil { + return nil, fmt.Errorf("docker exec attach failed: %w", err) + } + + // Prevent stdcopy.StdCopy from blocking indefinitely if the container hangs. + // We force close the hijacked connection when the context times out. + go func() { + <-execCtx.Done() + attach.Close() + }() + defer attach.Close() + + var stdout, stderr bytes.Buffer + stdoutWriter := &execStreamWriter{ + eventType: ExecEventStdout, + onEvent: onEvent, + buffer: &stdout, + } + stderrWriter := &execStreamWriter{ + eventType: ExecEventStderr, + onEvent: onEvent, + buffer: &stderr, + } + _, err = stdcopy.StdCopy(stdoutWriter, stderrWriter, attach.Reader) + if err != nil && err != io.EOF { + if execCtx.Err() != nil { + return nil, execCtx.Err() + } + return nil, fmt.Errorf("docker exec output read failed: %w", err) + } + + exitCode, err := c.waitExecDone(execCtx, execResp.ID) + if err != nil { + return nil, err + } + if onEvent != nil { + if err := onEvent(ExecEvent{Type: ExecEventExit, ExitCode: exitCode}); err != nil { + return nil, err + } + } + + return &ExecResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + }, nil +} + +// Fs returns the filesystem bridge bound to the sandbox container. +func (c *ContainerSandbox) Fs() FsBridge { + if c.startErr != nil { + return &errorFS{err: c.startErr} + } + return c.fs +} + +func (c *ContainerSandbox) GetWorkspace(ctx context.Context) string { + return c.cfg.Workdir +} + +func (c *ContainerSandbox) ensureContainer(ctx context.Context) error { + inspect, err := c.cli.ContainerInspect(ctx, c.cfg.ContainerName) + if err != nil { + return c.createAndStart(ctx) + } + + now := time.Now().UnixMilli() + regPath := c.registryPath() + registryMu.Lock() + data, regErr := loadRegistry(regPath) + registryMu.Unlock() + if regErr != nil { + return fmt.Errorf("sandbox registry load failed: %w", regErr) + } + + var existing *registryEntry + for i := range data.Entries { + if data.Entries[i].ContainerName == c.cfg.ContainerName { + existing = &data.Entries[i] + break + } + } + + if c.cfg.WorkspaceAccess == string(config.WorkspaceAccessNone) && strings.TrimSpace(c.cfg.Workspace) != "" && + strings.TrimSpace(c.cfg.AgentWorkspace) != "" { + if err := syncAgentWorkspace(c.cfg.AgentWorkspace, c.cfg.Workspace); err != nil { + logger.WarnCF("sandbox", "failed to sync agent workspace", map[string]any{"error": err}) + } + } + + hashMismatch := existing != nil && existing.ConfigHash != "" && existing.ConfigHash != c.hash + if hashMismatch { + hot := inspect.State.Running && (now-existing.LastUsedAtMs) < int64((5*time.Minute)/time.Millisecond) + if !hot { + _ = c.cli.ContainerRemove(ctx, c.cfg.ContainerName, container.RemoveOptions{Force: true}) + _ = removeRegistryEntry(regPath, c.cfg.ContainerName) + return c.createAndStart(ctx) + } + // Container is actively running; recreating it now would disrupt + // in-flight work. Log a warning so operators can detect configuration drift. + // The container will be recreated on the next cold start or prune cycle. + logger.WarnCF( + "sandbox", + "container config hash mismatch but container is hot; skipping recreate", + map[string]any{ + "container": c.cfg.ContainerName, + "want": c.hash, + "got": existing.ConfigHash, + }, + ) + } + + if !inspect.State.Running { + if err := c.cli.ContainerStart(ctx, c.cfg.ContainerName, container.StartOptions{}); err != nil { + return fmt.Errorf("docker container start failed: %w", err) + } + } + + createdAt := now + if existing != nil && existing.CreatedAtMs > 0 { + createdAt = existing.CreatedAtMs + } + return upsertRegistryEntry(regPath, registryEntry{ + ContainerName: c.cfg.ContainerName, + Image: c.cfg.Image, + ConfigHash: c.hash, + CreatedAtMs: createdAt, + LastUsedAtMs: now, + }) +} + +func (c *ContainerSandbox) createAndStart(ctx context.Context) error { + cfg := &container.Config{ + Image: c.cfg.Image, + Cmd: []string{"sleep", "infinity"}, + Entrypoint: []string{}, + WorkingDir: c.cfg.Workdir, + User: strings.TrimSpace(c.cfg.User), + Env: c.containerEnv(), + } + hostCfg, err := c.hostConfig() + if err != nil { + return err + } + resp, createErr := c.cli.ContainerCreate(ctx, cfg, hostCfg, &network.NetworkingConfig{}, nil, c.cfg.ContainerName) + if createErr != nil { + return fmt.Errorf("docker container create failed: %w", createErr) + } + if resp.ID == "" { + return fmt.Errorf("docker container create returned empty id") + } + if err := c.cli.ContainerStart(ctx, c.cfg.ContainerName, container.StartOptions{}); err != nil { + return fmt.Errorf("docker container start failed: %w", err) + } + if err := c.runSetupCommand(ctx); err != nil { + _ = c.cli.ContainerRemove(ctx, c.cfg.ContainerName, container.RemoveOptions{Force: true}) + return err + } + + now := time.Now().UnixMilli() + return upsertRegistryEntry(c.registryPath(), registryEntry{ + ContainerName: c.cfg.ContainerName, + Image: c.cfg.Image, + ConfigHash: c.hash, + CreatedAtMs: now, + LastUsedAtMs: now, + }) +} + +func (c *ContainerSandbox) binds() []string { + binds := make([]string, 0, 1+len(c.cfg.Binds)) + workspace := strings.TrimSpace(c.cfg.Workspace) + + // Determine the effective host directory to mount + var hostDir string + if workspace != "" { + if abs, err := filepath.Abs(workspace); err == nil { + hostDir = abs + } + } + + if hostDir != "" { + if c.cfg.WorkspaceAccess == string(config.WorkspaceAccessNone) { + // Ensure the isolated directory exists on the host so Docker doesn't create it as root + _ = os.MkdirAll(hostDir, 0o755) + } + // Add :Z flag for SELinux (Podman) to label the content with a private unshared label. + // This fixes errors like: "crun: getcwd: Operation not permitted: OCI permission denied" + switch config.WorkspaceAccess(c.cfg.WorkspaceAccess) { + case config.WorkspaceAccessRO: + binds = append(binds, fmt.Sprintf("%s:%s:ro,Z", hostDir, c.cfg.Workdir)) + case config.WorkspaceAccessRW, config.WorkspaceAccessNone: + binds = append(binds, fmt.Sprintf("%s:%s:rw,Z", hostDir, c.cfg.Workdir)) + default: + // Default to no mount for unknown access types + } + } + for _, bind := range c.cfg.Binds { + if strings.TrimSpace(bind) != "" { + binds = append(binds, strings.TrimSpace(bind)) + } + } + return binds +} + +func (c *ContainerSandbox) registryPath() string { + return filepath.Join(c.sandboxStateDir(), defaultSandboxRegistryFile) +} + +func (c *ContainerSandbox) sandboxStateDir() string { + return filepath.Join(infra.ResolveHomeDir(), "sandbox") +} + +func (c *ContainerSandbox) stopAndRemoveContainer(ctx context.Context, containerName string) error { + timeout := 5 + _ = c.cli.ContainerStop(ctx, containerName, container.StopOptions{Timeout: &timeout}) + if err := c.cli.ContainerRemove(ctx, containerName, container.RemoveOptions{Force: true}); err != nil { + return err + } + return nil +} + +func stopAndRemoveContainerByName(ctx context.Context, containerName string) error { + name := strings.TrimSpace(containerName) + if name == "" { + return nil + } + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return err + } + defer cli.Close() + + timeout := 5 + _ = cli.ContainerStop(ctx, name, container.StopOptions{Timeout: &timeout}) + if err := cli.ContainerRemove(ctx, name, container.RemoveOptions{Force: true}); err != nil { + return err + } + return nil +} + +func shouldPruneEntry(cfg ContainerSandboxConfig, nowMs int64, e registryEntry) bool { + idleMs := nowMs - e.LastUsedAtMs + ageMs := nowMs - e.CreatedAtMs + return (cfg.PruneIdleHours > 0 && idleMs > int64(cfg.PruneIdleHours)*int64(time.Hour/time.Millisecond)) || + (cfg.PruneMaxAgeDays > 0 && ageMs > int64(cfg.PruneMaxAgeDays)*24*int64(time.Hour/time.Millisecond)) +} + +func osTempDir() string { + if d := strings.TrimSpace(os.TempDir()); d != "" { + return d + } + return "." +} + +func (c *ContainerSandbox) buildExecCommand(req ExecRequest) ([]string, string, error) { + workingDir := c.cfg.Workdir + if strings.TrimSpace(req.WorkingDir) != "" { + resolved, err := resolveContainerPathWithRoot(c.cfg.Workdir, req.WorkingDir) + if err != nil { + return nil, "", err + } + workingDir = resolved + } + + if len(req.Args) > 0 { + return append([]string{req.Command}, req.Args...), workingDir, nil + } + if strings.TrimSpace(req.Command) == "" { + return nil, "", fmt.Errorf("empty command") + } + return []string{"sh", "-lc", req.Command}, workingDir, nil +} + +func (c *ContainerSandbox) waitExecDone(ctx context.Context, execID string) (int, error) { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return 1, ctx.Err() + case <-ticker.C: + ins, err := c.cli.ContainerExecInspect(ctx, execID) + if err != nil { + return 1, fmt.Errorf("docker exec inspect failed: %w", err) + } + if !ins.Running { + return ins.ExitCode, nil + } + } + } +} + +type execStreamWriter struct { + eventType ExecEventType + onEvent func(ExecEvent) error + buffer *bytes.Buffer +} + +func (w *execStreamWriter) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + chunk := append([]byte(nil), p...) + if w.buffer != nil { + _, _ = w.buffer.Write(chunk) + } + if w.onEvent != nil { + if err := w.onEvent(ExecEvent{Type: w.eventType, Chunk: chunk}); err != nil { + return 0, err + } + } + return len(p), nil +} + +type containerFS struct { + sb *ContainerSandbox +} + +func (f *containerFS) ReadFile(ctx context.Context, p string) ([]byte, error) { + if err := f.sb.ensureContainer(ctx); err != nil { + return nil, err + } + containerPath, err := resolveContainerPathWithRoot(f.sb.cfg.Workdir, p) + if err != nil { + return nil, err + } + + rc, _, err := f.sb.cli.CopyFromContainer(ctx, f.sb.cfg.ContainerName, containerPath) + if err != nil { + return nil, fmt.Errorf("docker copy from container failed: %w", err) + } + defer rc.Close() + + tr := tar.NewReader(rc) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("tar read failed: %w", err) + } + if hdr.Typeflag == tar.TypeReg || hdr.Typeflag == tar.TypeRegA { + content, err := io.ReadAll(tr) + if err != nil { + return nil, fmt.Errorf("tar file read failed: %w", err) + } + return content, nil + } + } + return nil, fmt.Errorf("file not found in container %s: %w", containerPath, fs.ErrNotExist) +} + +func (f *containerFS) WriteFile(ctx context.Context, p string, data []byte, mkdir bool) error { + if err := f.sb.ensureContainer(ctx); err != nil { + return err + } + containerPath, err := resolveContainerPathWithRoot(f.sb.cfg.Workdir, p) + if err != nil { + return err + } + + // Build a script that optionally creates the parent directory and then writes the file via cat. + // This ensures proper ownership and permissions as the configured container user. + script := `set -eu; cat >"$1"` + if mkdir { + script = `set -eu; dir=$(dirname -- "$1"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; cat >"$1"` + } + + execResp, err := f.sb.cli.ContainerExecCreate(ctx, f.sb.cfg.ContainerName, container.ExecOptions{ + Cmd: []string{"sh", "-c", script, "picoclaw-fs-write", containerPath}, + User: f.sb.cfg.User, // Use configured user to preserve ownership + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return fmt.Errorf("docker exec create failed: %w", err) + } + + attach, err := f.sb.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{}) + if err != nil { + return fmt.Errorf("docker exec attach failed: %w", err) + } + defer attach.Close() + + // Write data to the hijacked connection's stdin + if _, err = attach.Conn.Write(data); err != nil { + return fmt.Errorf("failed to write data to container: %w", err) + } + + // Close the write side of the connection so cat receives EOF and terminates + if conn, ok := attach.Conn.(interface{ CloseWrite() error }); ok { + _ = conn.CloseWrite() + } + + // Wait for the exec process to complete and check the exit code + var stdout, stderr bytes.Buffer + _, _ = stdcopy.StdCopy(&stdout, &stderr, attach.Reader) + + inspect, err := f.sb.cli.ContainerExecInspect(ctx, execResp.ID) + if err != nil { + return fmt.Errorf("failed to inspect write process: %w", err) + } + if inspect.ExitCode != 0 { + return fmt.Errorf("file write failed with code %d: %s", inspect.ExitCode, stderr.String()) + } + + return nil +} + +func (f *containerFS) ReadDir(ctx context.Context, p string) ([]os.DirEntry, error) { + if err := f.sb.ensureContainer(ctx); err != nil { + return nil, err + } + containerPath, err := resolveContainerPathWithRoot(f.sb.cfg.Workdir, p) + if err != nil { + return nil, err + } + + rc, _, err := f.sb.cli.CopyFromContainer(ctx, f.sb.cfg.ContainerName, containerPath) + if err != nil { + return nil, fmt.Errorf("docker copy from container failed: %w", err) + } + defer rc.Close() + + var entries []os.DirEntry + tr := tar.NewReader(rc) + entries, err = parseTopLevelDirEntriesFromTar(tr) + if err != nil { + return nil, err + } + return entries, nil +} + +func parseTopLevelDirEntriesFromTar(tr *tar.Reader) ([]os.DirEntry, error) { + var entries []os.DirEntry + seen := make(map[string]struct{}) + rootName := "" + first := true + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("tar read failed: %w", err) + } + + cleanName := path.Clean(hdr.Name) + if first { + first = false + rootName = cleanName + continue + } + + relName := cleanName + if rootName != "" && rootName != "." { + if relName == rootName { + continue + } + prefix := rootName + "/" + relName = strings.TrimPrefix(relName, prefix) + } + relName = strings.TrimPrefix(relName, "./") + if relName == "" || relName == "." { + continue + } + if strings.Contains(relName, "/") { + // CopyFromContainer is recursive; keep only immediate children. + continue + } + if _, ok := seen[relName]; ok { + continue + } + seen[relName] = struct{}{} + + entries = append(entries, &containerDirEntry{ + name: relName, + info: hdr.FileInfo(), + }) + } + + return entries, nil +} + +type containerDirEntry struct { + name string + info os.FileInfo +} + +func (d *containerDirEntry) Name() string { return d.name } +func (d *containerDirEntry) IsDir() bool { return d.info.IsDir() } +func (d *containerDirEntry) Type() os.FileMode { return d.info.Mode().Type() } +func (d *containerDirEntry) Info() (os.FileInfo, error) { return d.info, nil } + +func (c *ContainerSandbox) hostDirForContainerPath(containerDir string) (string, bool) { + if c.cfg.WorkspaceAccess == string(config.WorkspaceAccessRO) { + return "", false + } + workspace := strings.TrimSpace(c.cfg.Workspace) + if workspace == "" { + return "", false + } + workdir := path.Clean(c.cfg.Workdir) + if workdir == "" || workdir == "." || workdir == "/" { + workdir = "/workspace" + } + clean := path.Clean(containerDir) + if clean == workdir { + abs, err := filepath.Abs(workspace) + if err != nil { + return "", false + } + return abs, true + } + if !strings.HasPrefix(clean, workdir+"/") { + return "", false + } + rel := strings.TrimPrefix(clean, workdir+"/") + abs, err := filepath.Abs(workspace) + if err != nil { + return "", false + } + return filepath.Join(abs, filepath.FromSlash(rel)), true +} + +func resolveContainerPath(p string) (string, error) { + return resolveContainerPathWithRoot("/workspace", p) +} + +func resolveContainerPathWithRoot(root, p string) (string, error) { + raw := strings.TrimSpace(p) + if raw == "" { + return "", fmt.Errorf("path is required") + } + base := path.Clean(strings.TrimSpace(root)) + if base == "" || base == "." || base == "/" { + base = "/workspace" + } + var candidate string + if strings.HasPrefix(raw, "/") { + candidate = path.Clean(raw) + } else { + candidate = path.Clean(path.Join(base, raw)) + } + if candidate != base && !strings.HasPrefix(candidate, base+"/") { + return "", fmt.Errorf("access denied: path is outside container workspace") + } + return candidate, nil +} + +func shellEscape(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +func (c *ContainerSandbox) containerEnv() []string { + if len(c.cfg.Env) == 0 { + return nil + } + keys := make([]string, 0, len(c.cfg.Env)) + for k := range c.cfg.Env { + keys = append(keys, k) + } + sort.Strings(keys) + out := make([]string, 0, len(keys)) + for _, k := range keys { + out = append(out, k+"="+c.cfg.Env[k]) + } + return out +} + +func (c *ContainerSandbox) hostConfig() (*container.HostConfig, error) { + hostCfg := &container.HostConfig{ + Binds: c.binds(), + ReadonlyRootfs: c.cfg.ReadOnlyRoot, + NetworkMode: container.NetworkMode(strings.TrimSpace(c.cfg.Network)), + CapDrop: c.cfg.CapDrop, + DNS: c.cfg.DNS, + ExtraHosts: c.cfg.ExtraHosts, + SecurityOpt: []string{"no-new-privileges"}, + } + if c.cfg.PidsLimit > 0 { + p := c.cfg.PidsLimit + hostCfg.Resources.PidsLimit = &p + } + + tmpfs := map[string]string{} + for _, entry := range c.cfg.Tmpfs { + e := strings.TrimSpace(entry) + if e == "" { + continue + } + parts := strings.SplitN(e, ":", 2) + mountPoint := strings.TrimSpace(parts[0]) + if mountPoint == "" { + continue + } + opts := "" + if len(parts) > 1 { + opts = strings.TrimSpace(parts[1]) + } + tmpfs[mountPoint] = opts + } + if len(tmpfs) > 0 { + hostCfg.Tmpfs = tmpfs + } + + if sec := strings.TrimSpace(c.cfg.SeccompProfile); sec != "" { + hostCfg.SecurityOpt = append(hostCfg.SecurityOpt, "seccomp="+sec) + } + if app := strings.TrimSpace(c.cfg.ApparmorProfile); app != "" { + hostCfg.SecurityOpt = append(hostCfg.SecurityOpt, "apparmor="+app) + } + if mem := strings.TrimSpace(c.cfg.Memory); mem != "" { + v, err := parseByteLimit(mem) + if err != nil { + return nil, fmt.Errorf("invalid docker.memory: %w", err) + } + hostCfg.Memory = v + } + if swap := strings.TrimSpace(c.cfg.MemorySwap); swap != "" { + v, err := parseByteLimit(swap) + if err != nil { + return nil, fmt.Errorf("invalid docker.memory_swap: %w", err) + } + hostCfg.MemorySwap = v + } + if c.cfg.Cpus > 0 { + hostCfg.NanoCPUs = int64(math.Round(c.cfg.Cpus * 1_000_000_000)) + } + if len(c.cfg.Ulimits) > 0 { + keys := make([]string, 0, len(c.cfg.Ulimits)) + for k := range c.cfg.Ulimits { + keys = append(keys, k) + } + sort.Strings(keys) + hostCfg.Resources.Ulimits = make([]*container.Ulimit, 0, len(keys)) + for _, name := range keys { + ul := c.cfg.Ulimits[name] + value, ok := buildDockerUlimit(name, ul) + if ok { + hostCfg.Resources.Ulimits = append(hostCfg.Resources.Ulimits, value) + } + } + } + return hostCfg, nil +} + +func parseByteLimit(raw string) (int64, error) { + if n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); err == nil { + return n, nil + } + return units.RAMInBytes(strings.TrimSpace(raw)) +} + +func buildDockerUlimit(name string, in config.AgentSandboxDockerUlimitValue) (*container.Ulimit, bool) { + n := strings.TrimSpace(name) + if n == "" { + return nil, false + } + if in.Value != nil { + v := *in.Value + return &container.Ulimit{Name: n, Soft: v, Hard: v}, true + } + if in.Soft == nil && in.Hard == nil { + return nil, false + } + soft := int64(0) + hard := int64(0) + if in.Soft != nil { + soft = *in.Soft + } + if in.Hard != nil { + hard = *in.Hard + } + if in.Soft == nil { + soft = hard + } + if in.Hard == nil { + hard = soft + } + return &container.Ulimit{Name: n, Soft: soft, Hard: hard}, true +} + +func (c *ContainerSandbox) runSetupCommand(ctx context.Context) error { + cmd := strings.TrimSpace(c.cfg.SetupCommand) + if cmd == "" { + return nil + } + execResp, err := c.cli.ContainerExecCreate(ctx, c.cfg.ContainerName, container.ExecOptions{ + Cmd: []string{"sh", "-lc", cmd}, + AttachStdout: true, + AttachStderr: true, + WorkingDir: c.cfg.Workdir, + }) + if err != nil { + return fmt.Errorf("docker setup_command create failed: %w", err) + } + attach, err := c.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{}) + if err != nil { + return fmt.Errorf("docker setup_command attach failed: %w", err) + } + defer attach.Close() + var stdout, stderr bytes.Buffer + _, _ = stdcopy.StdCopy(&stdout, &stderr, attach.Reader) + exitCode, err := c.waitExecDone(ctx, execResp.ID) + if err != nil { + return fmt.Errorf("docker setup_command wait failed: %w", err) + } + if exitCode != 0 { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = strings.TrimSpace(stdout.String()) + } + if msg == "" { + msg = "unknown error" + } + return fmt.Errorf("docker setup_command failed (exit=%d): %s", exitCode, msg) + } + return nil +} + +func computeContainerConfigHash(cfg ContainerSandboxConfig) string { + type hashUlimit struct { + Name string `json:"name"` + Value *int64 `json:"value,omitempty"` + Soft *int64 `json:"soft,omitempty"` + Hard *int64 `json:"hard,omitempty"` + } + ulimits := make([]hashUlimit, 0, len(cfg.Ulimits)) + for k, v := range cfg.Ulimits { + name := strings.TrimSpace(k) + if name == "" { + continue + } + ulimits = append(ulimits, hashUlimit{ + Name: name, + Value: v.Value, + Soft: v.Soft, + Hard: v.Hard, + }) + } + sort.Slice(ulimits, func(i, j int) bool { return ulimits[i].Name < ulimits[j].Name }) + + envKeys := make([]string, 0, len(cfg.Env)) + for k := range cfg.Env { + envKeys = append(envKeys, k) + } + sort.Strings(envKeys) + envPairs := make([][2]string, 0, len(envKeys)) + for _, k := range envKeys { + envPairs = append(envPairs, [2]string{k, cfg.Env[k]}) + } + + payload := struct { + Image string `json:"image"` + ContainerPrefix string `json:"container_prefix"` + Workspace string `json:"workspace"` + AgentWorkspace string `json:"agent_workspace"` + WorkspaceAccess string `json:"workspace_access"` + WorkspaceRoot string `json:"workspace_root"` + Workdir string `json:"workdir"` + ReadOnlyRoot bool `json:"read_only_root"` + Tmpfs []string `json:"tmpfs"` + Network string `json:"network"` + User string `json:"user"` + CapDrop []string `json:"cap_drop"` + Env [][2]string `json:"env"` + SetupCommand string `json:"setup_command"` + PidsLimit int64 `json:"pids_limit"` + Memory string `json:"memory"` + MemorySwap string `json:"memory_swap"` + Cpus float64 `json:"cpus"` + Ulimits []hashUlimit `json:"ulimits"` + SeccompProfile string `json:"seccomp_profile"` + ApparmorProfile string `json:"apparmor_profile"` + DNS []string `json:"dns"` + ExtraHosts []string `json:"extra_hosts"` + Binds []string `json:"binds"` + Cmd []string `json:"cmd"` + Entrypoint []string `json:"entrypoint"` + }{ + Image: strings.TrimSpace(cfg.Image), + ContainerPrefix: strings.TrimSpace(cfg.ContainerPrefix), + Workspace: strings.TrimSpace(cfg.Workspace), + AgentWorkspace: strings.TrimSpace(cfg.AgentWorkspace), + WorkspaceAccess: strings.TrimSpace(cfg.WorkspaceAccess), + WorkspaceRoot: strings.TrimSpace(cfg.WorkspaceRoot), + Workdir: strings.TrimSpace(cfg.Workdir), + ReadOnlyRoot: cfg.ReadOnlyRoot, + Tmpfs: cfg.Tmpfs, + Network: strings.TrimSpace(cfg.Network), + User: strings.TrimSpace(cfg.User), + CapDrop: cfg.CapDrop, + Env: envPairs, + SetupCommand: strings.TrimSpace(cfg.SetupCommand), + PidsLimit: cfg.PidsLimit, + Memory: strings.TrimSpace(cfg.Memory), + MemorySwap: strings.TrimSpace(cfg.MemorySwap), + Cpus: cfg.Cpus, + Ulimits: ulimits, + SeccompProfile: strings.TrimSpace(cfg.SeccompProfile), + ApparmorProfile: strings.TrimSpace(cfg.ApparmorProfile), + DNS: cfg.DNS, + ExtraHosts: cfg.ExtraHosts, + Binds: cfg.Binds, + Cmd: []string{"sleep", "infinity"}, + Entrypoint: []string{}, + } + raw, _ := json.Marshal(payload) + return computeConfigHash(string(raw)) +} diff --git a/pkg/agent/sandbox/container_integration_test.go b/pkg/agent/sandbox/container_integration_test.go new file mode 100644 index 0000000000..e45cd4e1da --- /dev/null +++ b/pkg/agent/sandbox/container_integration_test.go @@ -0,0 +1,400 @@ +package sandbox + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/docker/docker/client" +) + +// skipIfNoDocker checks if a Docker daemon is available and skips the test if not. +// It returns a functional client and a cleanup function if successful. +func skipIfNoDocker(t *testing.T) (*client.Client, func()) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + t.Skipf("Docker client setup failed: %v", err) + } + + _, err = cli.Ping(ctx) + if err != nil { + cli.Close() + t.Skip("Docker daemon unavailable (ping failed), skipping integration test") + } + + return cli, func() { cli.Close() } +} + +func getTestImage() string { + image := strings.TrimSpace(os.Getenv("PICOCLAW_DOCKER_TEST_IMAGE")) + if image == "" { + image = "debian:bookworm-slim" + } + return image +} + +func TestContainerSandbox_Integration_ExecReadWrite(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + workspace := t.TempDir() + containerName := fmt.Sprintf("picoclaw-test-%d", time.Now().UnixNano()) + image := getTestImage() + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + Workspace: workspace, + User: fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), + }) + err := sb.Start(ctx) + if err != nil { + t.Fatalf("sandbox start failed: %v", err) + } + defer sb.Prune(ctx) + + // 1. Write file via FsBridge + testData := []byte("hello from host") + err = sb.Fs().WriteFile(ctx, "hello.txt", testData, false) + if err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // 2. Read back via Exec (command line) + res, err := sb.Exec(ctx, ExecRequest{ + Command: "cat hello.txt", + }) + if err != nil { + t.Fatalf("Exec failed: %v", err) + } + if strings.TrimSpace(res.Stdout) != string(testData) { + t.Errorf("Exec output mismatch: got %q, want %q", res.Stdout, string(testData)) + } + + // 3. Write via Exec + res, err = sb.Exec(ctx, ExecRequest{ + Command: "echo 'modified in container' > hello.txt", + }) + if err != nil || res.ExitCode != 0 { + t.Fatalf("Exec write failed: %v, exit=%d", err, res.ExitCode) + } + + // 4. Read back via FsBridge + readData, err := sb.Fs().ReadFile(ctx, "hello.txt") + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if strings.TrimSpace(string(readData)) != "modified in container" { + t.Errorf("ReadFile output mismatch: got %q", string(readData)) + } + + // 5. Verify ReadDir + entries, err := sb.Fs().ReadDir(ctx, ".") + if err != nil { + t.Fatalf("ReadDir failed: %v", err) + } + found := false + for _, e := range entries { + if e.Name() == "hello.txt" { + found = true + break + } + } + if !found { + t.Error("hello.txt not found in ReadDir") + } +} + +func TestContainerSandbox_Integration_WriteFileOwnership(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + workspace := t.TempDir() + containerName := fmt.Sprintf("picoclaw-test-ownership-%d", time.Now().UnixNano()) + image := getTestImage() + + // Use the current host user's UID:GID to test real-world mapping compatibility + testUser := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + Workspace: workspace, + User: testUser, + }) + if err := sb.Start(ctx); err != nil { + t.Fatalf("sandbox start failed: %v", err) + } + defer sb.Prune(ctx) + + // Write file via FsBridge with mkdir enabled + testPath := "auth/test.txt" + content := []byte("ownership verification") + if err := sb.Fs().WriteFile(ctx, testPath, content, true); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // Verify identity (UID:GID) and modification time via stat + res, err := sb.Exec(ctx, ExecRequest{ + Command: fmt.Sprintf("stat -c '%%u:%%g %%Y' %s", testPath), + }) + if err != nil || res.ExitCode != 0 { + t.Fatalf("Stat failed: %v, stderr=%s", err, res.Stderr) + } + + // Output format: "UID:GID UnixTimestamp" + parts := strings.Fields(res.Stdout) + if len(parts) < 2 { + t.Fatalf("unexpected stat output: %q", res.Stdout) + } + + // Check UID:GID + if parts[0] != testUser { + t.Errorf("ownership mismatch: got %q, want %q", parts[0], testUser) + } + + // Check Timestamp (should not be 1970 Epoch) + timestamp := parts[1] + if strings.HasPrefix(timestamp, "0") || strings.HasPrefix(timestamp, "1 ") { + // Specifically check for very small timestamps that indicate 1970 + t.Errorf("file created with suspicious 1970-era timestamp: %q", timestamp) + } + + t.Logf("Stat verified: %s", res.Stdout) +} + +func TestContainerSandbox_Integration_WriteFileMkdirInContainerTmp(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + containerName := fmt.Sprintf("picoclaw-test-mkdir-%d", time.Now().UnixNano()) + image := getTestImage() + workspace := t.TempDir() + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + Workspace: workspace, + User: fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), + }) + err := sb.Start(ctx) + if err != nil { + t.Fatalf("sandbox start failed: %v", err) + } + defer sb.Prune(ctx) + + // Write to a directory that definitely doesn't exist in the container (under /workspace) + // This forces FsBridge to use Exec fallback for mkdir -p. + testPath := "/workspace/a/b/c/test.txt" + content := []byte("mkdir test") + err = sb.Fs().WriteFile(ctx, testPath, content, true) + if err != nil { + t.Fatalf("WriteFile with mkdir failed: %v", err) + } + + // Verify it exists + res, err := sb.Exec(ctx, ExecRequest{Command: "cat " + testPath}) + if err != nil || res.ExitCode != 0 { + t.Fatalf("Verify cat failed: %v", err) + } + if strings.TrimSpace(res.Stdout) != string(content) { + t.Errorf("cat mismatch: got %q", res.Stdout) + } +} + +func TestContainerSandbox_Integration_SetupCommandSuccess(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + containerName := fmt.Sprintf("picoclaw-test-setup-ok-%d", time.Now().UnixNano()) + image := getTestImage() + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + SetupCommand: "touch /tmp/setup_done", + }) + if err := sb.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer sb.Prune(ctx) + + res, err := sb.Exec(ctx, ExecRequest{Command: "ls /tmp/setup_done"}) + if err != nil || res.ExitCode != 0 { + t.Errorf("Setup command didn't run or fail to create file: %v", err) + } +} + +func TestContainerSandbox_Integration_SetupCommandFailureRemovesContainer(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + containerName := fmt.Sprintf("picoclaw-test-setup-fail-%d", time.Now().UnixNano()) + image := getTestImage() + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + SetupCommand: "false", // Force setup to fail + }) + if err := sb.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + + // Trigger container creation and setup command execution + _, err := sb.Exec(ctx, ExecRequest{Command: "echo test"}) + if err == nil { + t.Fatal("expected Exec to fail due to failing setup_command") + } + + // Verify container was removed by the error handler in createAndStart + cli, _ := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + defer cli.Close() + _, err = cli.ContainerInspect(ctx, containerName) + if !client.IsErrNotFound(err) { + t.Errorf("expected container to be removed after failed setup, got err: %v", err) + } +} + +func TestContainerSandbox_Integration_MaybePruneRemovesOldContainer(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + root := t.TempDir() + containerName := fmt.Sprintf("picoclaw-test-prune-%d", time.Now().UnixNano()) + image := getTestImage() + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + Workspace: root, + PruneMaxAgeDays: -1, // Force immediate prune eligibility + }) + + if err := sb.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + + // Trigger container creation + if _, err := sb.Exec(ctx, ExecRequest{Command: "echo test"}); err != nil { + t.Fatalf("Exec failed: %v", err) + } + + cli, _ := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + defer cli.Close() + + _, err := cli.ContainerInspect(ctx, containerName) + if err != nil { + t.Fatalf("container missing before prune: %v", err) + } + + // Explicitly call Prune (scoped manager would normally do this in loop) + if pruneErr := sb.Prune(ctx); pruneErr != nil { + t.Fatalf("Prune failed: %v", pruneErr) + } + + // Verify container is gone + _, err = cli.ContainerInspect(ctx, containerName) + if !client.IsErrNotFound(err) { + t.Errorf("expected container gone after prune, got err: %v", err) + } +} + +func TestContainerSandbox_Integration_ExecTimeoutRespectsRequest(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + containerName := fmt.Sprintf("picoclaw-test-timeout-%d", time.Now().UnixNano()) + image := getTestImage() + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + }) + if err := sb.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer sb.Prune(ctx) + + start := time.Now() + _, err := sb.Exec(ctx, ExecRequest{ + Command: "sleep 10", + TimeoutMs: 100, // Very short timeout + }) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected timeout error") + } + if elapsed > 2*time.Second { + t.Errorf("Exec took too long to time out: %v", elapsed) + } +} + +func TestContainerSandbox_Integration_ExecTimeoutBreaksStdCopyBlock(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + containerName := fmt.Sprintf("picoclaw-test-timeout-block-%d", time.Now().UnixNano()) + image := getTestImage() + + sb := NewContainerSandbox(ContainerSandboxConfig{ + Image: image, + ContainerName: containerName, + }) + if err := sb.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer sb.Prune(ctx) + + // Ensure container is started so the timeout only applies to command execution + if _, err := sb.Exec(ctx, ExecRequest{Command: "true"}); err != nil { + t.Fatalf("warmup failed: %v", err) + } + + // Simulate a command that hangs and might block output readers + start := time.Now() + _, err := sb.Exec(ctx, ExecRequest{ + Command: "sleep 10", // Guaranteed to hang + TimeoutMs: 500, + }) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected timeout error for hanging command") + } + if elapsed > 3*time.Second { + t.Errorf("Exec took too long to break block: %v", elapsed) + } +} diff --git a/pkg/agent/sandbox/container_test.go b/pkg/agent/sandbox/container_test.go new file mode 100644 index 0000000000..eb545083c3 --- /dev/null +++ b/pkg/agent/sandbox/container_test.go @@ -0,0 +1,659 @@ +package sandbox + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestResolveContainerPath_Relative(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{Workdir: "/app"}) + if got := sb.GetWorkspace(context.Background()); got != "/app" { + t.Errorf("GetWorkspace() = %q, want /app", got) + } + + sb2 := NewContainerSandbox(ContainerSandboxConfig{}) + if got := sb2.GetWorkspace(context.Background()); got != "/workspace" { + t.Errorf("GetWorkspace() default = %q, want /workspace", got) + } + + got, err := resolveContainerPath("foo/bar.txt") + if err != nil { + t.Fatalf("resolveContainerPath returned error: %v", err) + } + if got != "/workspace/foo/bar.txt" { + t.Fatalf("resolveContainerPath = %q, want %q", got, "/workspace/foo/bar.txt") + } +} + +func TestResolveContainerPath_AbsoluteInWorkspace(t *testing.T) { + got, err := resolveContainerPath("/workspace/a/b.txt") + if err != nil { + t.Fatalf("resolveContainerPath returned error: %v", err) + } + if got != "/workspace/a/b.txt" { + t.Fatalf("resolveContainerPath = %q, want %q", got, "/workspace/a/b.txt") + } +} + +func TestResolveContainerPath_RejectsEscape(t *testing.T) { + _, err := resolveContainerPath("../../etc/passwd") + if err == nil { + t.Fatal("expected error for path traversal") + } + if !strings.Contains(err.Error(), "outside container workspace") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveContainerPath_RejectsAbsoluteOutsideWorkspace(t *testing.T) { + _, err := resolveContainerPath("/etc/passwd") + if err == nil { + t.Fatal("expected error for absolute path outside workspace") + } + if !strings.Contains(err.Error(), "outside container workspace") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseTopLevelDirEntriesFromTar_KeepImmediateChildren(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + writeHeader := func(hdr *tar.Header, content []byte) { + t.Helper() + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write header failed: %v", err) + } + if len(content) > 0 { + if _, err := tw.Write(content); err != nil { + t.Fatalf("write content failed: %v", err) + } + } + } + + writeHeader(&tar.Header{Name: "it", Typeflag: tar.TypeDir, Mode: 0o755}, nil) + writeHeader(&tar.Header{Name: "it/write.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}, []byte("x")) + writeHeader(&tar.Header{Name: "it/sub", Typeflag: tar.TypeDir, Mode: 0o755}, nil) + writeHeader(&tar.Header{Name: "it/sub/nested.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}, []byte("y")) + if err := tw.Close(); err != nil { + t.Fatalf("tar close failed: %v", err) + } + + entries, err := parseTopLevelDirEntriesFromTar(tar.NewReader(&buf)) + if err != nil { + t.Fatalf("parseTopLevelDirEntriesFromTar failed: %v", err) + } + + got := make(map[string]struct{}, len(entries)) + for _, e := range entries { + got[e.Name()] = struct{}{} + } + for _, want := range []string{"write.txt", "sub"} { + if _, ok := got[want]; !ok { + t.Fatalf("missing top-level entry %q in %+v", want, got) + } + } + if _, ok := got["nested.txt"]; ok { + t.Fatalf("nested file should not appear in top-level entries: %+v", got) + } +} + +func TestBuildExecCommand_DefaultShell(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{}) + cmd, wd, err := sb.buildExecCommand(ExecRequest{ + Command: "echo hi", + }) + if err != nil { + t.Fatalf("buildExecCommand returned error: %v", err) + } + if wd != "/workspace" { + t.Fatalf("working dir = %q, want /workspace", wd) + } + if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" || cmd[2] != "echo hi" { + t.Fatalf("unexpected command: %#v", cmd) + } +} + +func TestBuildExecCommand_WithArgs(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{}) + cmd, wd, err := sb.buildExecCommand(ExecRequest{ + Command: "ls", + Args: []string{"-la", "/workspace"}, + }) + if err != nil { + t.Fatalf("buildExecCommand returned error: %v", err) + } + if wd != "/workspace" { + t.Fatalf("working dir = %q, want /workspace", wd) + } + if len(cmd) != 3 || cmd[0] != "ls" || cmd[1] != "-la" || cmd[2] != "/workspace" { + t.Fatalf("unexpected command: %#v", cmd) + } +} + +func TestBuildExecCommand_WorkingDirUsesResolvedDirectory(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{}) + cmd, wd, err := sb.buildExecCommand(ExecRequest{ + Command: "cat foo.txt", + WorkingDir: "subdir", + }) + if err != nil { + t.Fatalf("buildExecCommand returned error: %v", err) + } + if wd != "/workspace/subdir" { + t.Fatalf("working dir = %q, want /workspace/subdir", wd) + } + if len(cmd) == 0 { + t.Fatal("expected command to be populated") + } +} + +func TestContainerSandbox_FailClosedOnStartError(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{}) + sb.startErr = errors.New("docker unavailable") + + _, err := sb.Exec(context.Background(), ExecRequest{Command: "echo hi"}) + if err == nil { + t.Fatal("expected exec error") + } + if !strings.Contains(err.Error(), "docker unavailable") { + t.Fatalf("unexpected exec error: %v", err) + } + + _, err = sb.Fs().ReadFile(context.Background(), "a.txt") + if err == nil { + t.Fatal("expected fs read error") + } + if !strings.Contains(err.Error(), "docker unavailable") { + t.Fatalf("unexpected fs read error: %v", err) + } + + err = sb.Fs().WriteFile(context.Background(), "a.txt", []byte("x"), true) + if err == nil { + t.Fatal("expected fs write error") + } + if !strings.Contains(err.Error(), "docker unavailable") { + t.Fatalf("unexpected fs write error: %v", err) + } +} + +func TestHostDirForContainerPath_Workspace(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{Workspace: "/tmp/ws"}) + + got, ok := sb.hostDirForContainerPath("/workspace/a/b") + if !ok { + t.Fatal("expected workspace path to resolve") + } + if got != "/tmp/ws/a/b" { + t.Fatalf("host path = %q, want %q", got, "/tmp/ws/a/b") + } +} + +func TestHostDirForContainerPath_OutsideWorkspace(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{Workspace: "/tmp/ws"}) + + if got, ok := sb.hostDirForContainerPath("/etc"); ok || got != "" { + t.Fatalf("expected outside path to be rejected, got (%q, %v)", got, ok) + } +} + +func TestHostDirForContainerPath_ReadOnlyWorkspace(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{ + Workspace: "/tmp/ws", + WorkspaceAccess: "ro", + }) + if got, ok := sb.hostDirForContainerPath("/workspace/a"); ok || got != "" { + t.Fatalf("expected read-only workspace to disable host path mapping, got (%q, %v)", got, ok) + } +} + +func TestComputeContainerConfigHash_EnvOrderInsensitive(t *testing.T) { + base := ContainerSandboxConfig{ + Image: "img", + Workspace: "/tmp/ws", + Workdir: "/workspace", + Env: map[string]string{ + "A": "1", + "B": "2", + }, + } + left := computeContainerConfigHash(base) + base.Env = map[string]string{ + "B": "2", + "A": "1", + } + right := computeContainerConfigHash(base) + if left != right { + t.Fatalf("expected hash to ignore env key order: %q != %q", left, right) + } +} + +func TestComputeContainerConfigHash_ArrayOrderSensitive(t *testing.T) { + base := ContainerSandboxConfig{ + Image: "img", + Workspace: "/tmp/ws", + Workdir: "/workspace", + DNS: []string{"1.1.1.1", "8.8.8.8"}, + } + left := computeContainerConfigHash(base) + base.DNS = []string{"8.8.8.8", "1.1.1.1"} + right := computeContainerConfigHash(base) + if left == right { + t.Fatal("expected hash to change when array order changes") + } +} + +func TestComputeContainerConfigHash_WorkspaceAccessAndRootAffectHash(t *testing.T) { + base := ContainerSandboxConfig{ + Image: "img", + Workspace: "/tmp/ws", + Workdir: "/workspace", + WorkspaceAccess: "none", + WorkspaceRoot: "/tmp/sbx-a", + } + left := computeContainerConfigHash(base) + base.WorkspaceAccess = "ro" + if left == computeContainerConfigHash(base) { + t.Fatal("expected hash to change when workspace_access changes") + } + base.WorkspaceAccess = "none" + base.WorkspaceRoot = "/tmp/sbx-b" + if left == computeContainerConfigHash(base) { + t.Fatal("expected hash to change when workspace_root changes") + } +} + +func TestBuildDockerUlimit_NumberValue(t *testing.T) { + value := int64(256) + ul, ok := buildDockerUlimit("nproc", config.AgentSandboxDockerUlimitValue{Value: &value}) + if !ok || ul == nil { + t.Fatal("expected ulimit to be built") + } + if ul.Soft != 256 || ul.Hard != 256 { + t.Fatalf("unexpected ulimit values: soft=%d hard=%d", ul.Soft, ul.Hard) + } +} + +func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) { + root := t.TempDir() + + ro := NewContainerSandbox(ContainerSandboxConfig{ + Workspace: filepath.Join(root, "ws-ro"), + WorkspaceAccess: "ro", + Workdir: "/workspace", + }) + roBinds := ro.binds() + if len(roBinds) == 0 || !strings.HasSuffix(roBinds[0], ":/workspace:ro,Z") { + t.Fatalf("unexpected ro bind: %#v", roBinds) + } + + rw := NewContainerSandbox(ContainerSandboxConfig{ + Workspace: filepath.Join(root, "ws-rw"), + WorkspaceAccess: "rw", + Workdir: "/workspace", + }) + rwBinds := rw.binds() + if len(rwBinds) == 0 || !strings.HasSuffix(rwBinds[0], ":/workspace:rw,Z") { + t.Fatalf("unexpected rw bind: %#v", rwBinds) + } + + none := NewContainerSandbox(ContainerSandboxConfig{ + Workspace: filepath.Join(root, "ws-none"), + WorkspaceAccess: "none", + Workdir: "/workspace", + ContainerName: "test-none", + }) + noneBinds := none.binds() + if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace:rw,Z") { + t.Fatalf("expected none bind to isolated workspace, got: %#v", noneBinds) + } +} + +func TestContainerSandbox_RegistryPath_UsesSandboxStateDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sb := NewContainerSandbox(ContainerSandboxConfig{ + Workspace: "/tmp/ws", + WorkspaceRoot: "/tmp/sbx", + }) + want := filepath.Join(home, ".picoclaw", "sandbox", "containers.json") + if got := sb.registryPath(); got != want { + t.Fatalf("registryPath = %q, want %q", got, want) + } +} + +func TestContainerSandbox_RegistryPath_UsesPicoClawHomeOverride(t *testing.T) { + picoHome := t.TempDir() + t.Setenv("PICOCLAW_HOME", picoHome) + sb := NewContainerSandbox(ContainerSandboxConfig{}) + want := filepath.Join(picoHome, "sandbox", "containers.json") + if got := sb.registryPath(); got != want { + t.Fatalf("registryPath = %q, want %q", got, want) + } +} + +func TestContainerSandbox_Start_BlockedSecurityConfig(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{ + Network: "host", + }) + err := sb.Start(context.Background()) + if err == nil { + t.Fatal("expected start to fail for blocked network mode") + } + if !strings.Contains(err.Error(), "network mode") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestContainerSandbox_StartCreatesWorkspaceBeforeDockerPing(t *testing.T) { + workspace := filepath.Join(t.TempDir(), "workspace") + workspaceRoot := filepath.Join(t.TempDir(), "sandbox-root") + sb := NewContainerSandbox(ContainerSandboxConfig{ + Workspace: workspace, + WorkspaceRoot: workspaceRoot, + WorkspaceAccess: "none", + }) + + err := sb.Start(context.Background()) + if err == nil { + _ = sb.Prune(context.Background()) + t.Skip("docker daemon available in this environment; skip unavailable-path assertion") + } + if !strings.Contains(err.Error(), "docker daemon unavailable") { + t.Fatalf("Start() unexpected error: %v", err) + } + if _, stErr := os.Stat(workspace); stErr != nil { + t.Fatalf("workspace should be created before docker ping: %v", stErr) + } + if _, stErr := os.Stat(workspaceRoot); stErr != nil { + t.Fatalf("workspaceRoot should be created before docker ping: %v", stErr) + } +} + +func TestContainerSandbox_NoopPruneWithoutClient(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{ + PruneIdleHours: 1, + PruneMaxAgeDays: 0, + }) + + if err := sb.Prune(context.Background()); err != nil { + t.Fatalf("Prune() with nil client should be noop, got: %v", err) + } +} + +func TestParseByteLimitAndHostConfig(t *testing.T) { + if got, err := parseByteLimit("1024"); err != nil || got != 1024 { + t.Fatalf("parseByteLimit numeric got (%d,%v), want (1024,nil)", got, err) + } + if got, err := parseByteLimit("1g"); err != nil || got <= 0 { + t.Fatalf("parseByteLimit unit got (%d,%v), want positive", got, err) + } + if _, err := parseByteLimit("not-a-size"); err == nil { + t.Fatal("expected parseByteLimit invalid input error") + } + + soft := int64(256) + hard := int64(512) + sb := NewContainerSandbox(ContainerSandboxConfig{ + Workspace: t.TempDir(), + Workdir: "/workspace", + ReadOnlyRoot: true, + Network: "none", + CapDrop: []string{"ALL"}, + Tmpfs: []string{"/tmp:rw,noexec,nosuid", " ", "/run"}, + PidsLimit: 123, + Memory: "1g", + MemorySwap: "2g", + Cpus: 1.5, + SeccompProfile: "sec-profile.json", + ApparmorProfile: "apparmor-profile", + Ulimits: map[string]config.AgentSandboxDockerUlimitValue{ + "b": {Soft: &soft}, + "a": {Hard: &hard}, + }, + }) + hc, err := sb.hostConfig() + if err != nil { + t.Fatalf("hostConfig() error: %v", err) + } + if !hc.ReadonlyRootfs { + t.Fatal("expected readonly rootfs") + } + if hc.Resources.PidsLimit == nil || *hc.Resources.PidsLimit != 123 { + t.Fatalf("unexpected pids limit: %#v", hc.Resources.PidsLimit) + } + if hc.Memory <= 0 || hc.MemorySwap <= 0 || hc.NanoCPUs <= 0 { + t.Fatalf( + "expected memory/swap/cpu limits set, got mem=%d swap=%d cpu=%d", + hc.Memory, + hc.MemorySwap, + hc.NanoCPUs, + ) + } + if len(hc.Tmpfs) != 2 || hc.Tmpfs["/run"] != "" { + t.Fatalf("unexpected tmpfs map: %#v", hc.Tmpfs) + } + if got := strings.Join( + hc.SecurityOpt, + ",", + ); !strings.Contains(got, "seccomp=sec-profile.json") || + !strings.Contains(got, "apparmor=apparmor-profile") { + t.Fatalf("security options missing expected profiles: %v", hc.SecurityOpt) + } + if len(hc.Resources.Ulimits) != 2 { + t.Fatalf("expected 2 ulimits, got %d", len(hc.Resources.Ulimits)) + } + gotNames := []string{hc.Resources.Ulimits[0].Name, hc.Resources.Ulimits[1].Name} + sorted := append([]string{}, gotNames...) + sort.Strings(sorted) + if gotNames[0] != sorted[0] || gotNames[1] != sorted[1] { + t.Fatalf("expected deterministic sorted ulimits, got %v", gotNames) + } +} + +func TestHostConfigRejectsInvalidMemorySettings(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{ + Memory: "bad", + }) + if _, err := sb.hostConfig(); err == nil || !strings.Contains(err.Error(), "invalid docker.memory") { + t.Fatalf("expected invalid docker.memory error, got %v", err) + } + + sb = NewContainerSandbox(ContainerSandboxConfig{ + MemorySwap: "bad", + }) + if _, err := sb.hostConfig(); err == nil || !strings.Contains(err.Error(), "invalid docker.memory_swap") { + t.Fatalf("expected invalid docker.memory_swap error, got %v", err) + } +} + +func TestBuildDockerUlimitVariants(t *testing.T) { + if _, ok := buildDockerUlimit(" ", config.AgentSandboxDockerUlimitValue{}); ok { + t.Fatal("expected empty-name ulimit to be rejected") + } + if _, ok := buildDockerUlimit("nofile", config.AgentSandboxDockerUlimitValue{}); ok { + t.Fatal("expected empty ulimit value to be rejected") + } + + soft := int64(10) + ul, ok := buildDockerUlimit("nofile", config.AgentSandboxDockerUlimitValue{Soft: &soft}) + if !ok || ul == nil || ul.Soft != 10 || ul.Hard != 10 { + t.Fatalf("expected soft-only to mirror hard, got %#v ok=%v", ul, ok) + } + + hard := int64(20) + ul, ok = buildDockerUlimit("nofile", config.AgentSandboxDockerUlimitValue{Hard: &hard}) + if !ok || ul == nil || ul.Soft != 20 || ul.Hard != 20 { + t.Fatalf("expected hard-only to mirror soft, got %#v ok=%v", ul, ok) + } +} + +func TestContainerHelpers(t *testing.T) { + if got := shellEscape("a'b"); got != "'a'\"'\"'b'" { + t.Fatalf("shellEscape() got %q", got) + } + if osTempDir() == "" { + t.Fatal("osTempDir() should not be empty") + } + + sb := NewContainerSandbox(ContainerSandboxConfig{SetupCommand: " "}) + if err := sb.runSetupCommand(context.Background()); err != nil { + t.Fatalf("runSetupCommand() empty command should be nil, got %v", err) + } +} + +func TestWaitExecDoneContextCancel(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + code, err := sb.waitExecDone(ctx, "unused") + if err == nil { + t.Fatal("expected context cancellation error") + } + if code != 1 { + t.Fatalf("unexpected exit code: %d", code) + } +} + +func TestContainerSandbox_StopWithoutClient(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{ + PruneIdleHours: 1, + PruneMaxAgeDays: 1, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := sb.Prune(ctx); err != nil { + t.Fatalf("Prune() error: %v", err) + } +} + +func TestNewContainerSandbox_SanitizesEnv(t *testing.T) { + sb := NewContainerSandbox(ContainerSandboxConfig{ + Env: map[string]string{ + "LANG": "C.UTF-8", + "OPENAI_API_KEY": "secret", + "SAFE_NAME": "ok", + }, + }) + + got := sb.containerEnv() + joined := strings.Join(got, "\n") + if strings.Contains(joined, "OPENAI_API_KEY=") { + t.Fatalf("sensitive env key should be filtered, got: %v", got) + } + if !strings.Contains(joined, "SAFE_NAME=ok") { + t.Fatalf("safe env key should be preserved, got: %v", got) + } + if !strings.Contains(joined, "LANG=C.UTF-8") { + t.Fatalf("LANG should be preserved or defaulted, got: %v", got) + } +} + +func TestSyncAgentWorkspace_SeedsFilesAndPreservesExisting(t *testing.T) { + agentWs := t.TempDir() + containerWs := t.TempDir() + + // Setup agent workspace with seed files + agentAgentsFile := filepath.Join(agentWs, "AGENTS.md") + if err := os.WriteFile(agentAgentsFile, []byte("agent content"), 0o644); err != nil { + t.Fatalf("failed to create agent AGENTS.md: %v", err) + } + + agentUserFile := filepath.Join(agentWs, "USER.md") + if err := os.WriteFile(agentUserFile, []byte("user content"), 0o644); err != nil { + t.Fatalf("failed to create agent USER.md: %v", err) + } + + // Setup container workspace with PRE-EXISTING AGENTS.md (should not be overwritten) + containerAgentsFile := filepath.Join(containerWs, "AGENTS.md") + if err := os.WriteFile(containerAgentsFile, []byte("PRESERVED CONTENT"), 0o644); err != nil { + t.Fatalf("failed to create container AGENTS.md: %v", err) + } + + // Run Sync + if err := syncAgentWorkspace(agentWs, containerWs); err != nil { + t.Fatalf("syncAgentWorkspace failed: %v", err) + } + + // Verify existing file was preserved + content, err := os.ReadFile(containerAgentsFile) + if err != nil { + t.Fatalf("failed to read container AGENTS.md: %v", err) + } + if string(content) != "PRESERVED CONTENT" { + t.Fatalf("existing file was overwritten. expected PRESERVED CONTENT, got: %s", string(content)) + } + + // Verify missing file was seeded + content, err = os.ReadFile(filepath.Join(containerWs, "USER.md")) + if err != nil { + t.Fatalf("failed to read container USER.md: %v", err) + } + if string(content) != "user content" { + t.Fatalf("missing file was not seeded correctly. got: %s", string(content)) + } + + // Verify non-existent seed files are handled cleanly (TOOLS.md, MEMORY.md) + if _, err := os.Stat(filepath.Join(containerWs, "MEMORY.md")); !os.IsNotExist(err) { + t.Fatalf("expected MEMORY.md to not exist, got: %v", err) + } +} + +func TestSyncAgentWorkspace_SyncsSkillsDirectory(t *testing.T) { + agentWs := t.TempDir() + containerWs := t.TempDir() + + // Setup agent workspace with skills + agentSkillsDir := filepath.Join(agentWs, "skills") + if err := os.MkdirAll(agentSkillsDir, 0o755); err != nil { + t.Fatalf("failed to create agent skills dir: %v", err) + } + if err := os.WriteFile(filepath.Join(agentSkillsDir, "skill1.txt"), []byte("new skill"), 0o644); err != nil { + t.Fatalf("failed to create skill1: %v", err) + } + + // Setup container workspace with OLD skills directory that should be overwritten + containerSkillsDir := filepath.Join(containerWs, "skills") + if err := os.MkdirAll(containerSkillsDir, 0o755); err != nil { + t.Fatalf("failed to create container skills dir: %v", err) + } + if err := os.WriteFile(filepath.Join(containerSkillsDir, "old-skill.txt"), []byte("old skill"), 0o644); err != nil { + t.Fatalf("failed to create old skill: %v", err) + } + + // Run Sync + if err := syncAgentWorkspace(agentWs, containerWs); err != nil { + t.Fatalf("syncAgentWorkspace failed: %v", err) + } + + // Verify old skills are gone and new skills are present + if _, err := os.Stat(filepath.Join(containerSkillsDir, "old-skill.txt")); !os.IsNotExist(err) { + t.Fatalf("old skill file was not removed during sync") + } + + content, err := os.ReadFile(filepath.Join(containerSkillsDir, "skill1.txt")) + if err != nil { + t.Fatalf("failed to read synced skill: %v", err) + } + if string(content) != "new skill" { + t.Fatalf("skill file content mismatch. got: %s", string(content)) + } +} + +func TestContainerSandbox_Resolve(t *testing.T) { + c := NewContainerSandbox(ContainerSandboxConfig{}) + sb, err := c.Resolve(context.Background()) + if err != nil || sb != c { + t.Fatal("expected Resolve to return self") + } +} diff --git a/pkg/agent/sandbox/error.go b/pkg/agent/sandbox/error.go new file mode 100644 index 0000000000..59bb52c1e5 --- /dev/null +++ b/pkg/agent/sandbox/error.go @@ -0,0 +1,5 @@ +package sandbox + +import "errors" + +var ErrOutsideWorkspace = errors.New("access denied: symlink resolves outside workspace") diff --git a/pkg/agent/sandbox/host.go b/pkg/agent/sandbox/host.go new file mode 100644 index 0000000000..3fe755c770 --- /dev/null +++ b/pkg/agent/sandbox/host.go @@ -0,0 +1,337 @@ +package sandbox + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +type HostSandbox struct { + workspace string + restrict bool + fs FsBridge +} + +func NewHostSandbox(workspace string, restrict bool) *HostSandbox { + return &HostSandbox{ + workspace: workspace, + restrict: restrict, + fs: &hostFS{workspace: workspace, restrict: restrict}, + } +} + +func (h *HostSandbox) Start(ctx context.Context) error { + // Initialize os.Root for restricted workspace mode to centrally mitigate TOCTOU (Time-of-Check-Time-of-Use) attacks. + if h.restrict && h.workspace != "" { + r, err := os.OpenRoot(h.workspace) + if err != nil { + return fmt.Errorf("failed to open workspace root: %w", err) + } + if hsFS, ok := h.fs.(*hostFS); ok { + hsFS.root = r + } + } + return nil +} + +func (h *HostSandbox) Prune(ctx context.Context) error { + // Clean up os.Root file descriptors securely. + if h.restrict && h.workspace != "" { + if hsFS, ok := h.fs.(*hostFS); ok && hsFS.root != nil { + err := hsFS.root.Close() + hsFS.root = nil + return err + } + } + return nil +} + +func (h *HostSandbox) Fs() FsBridge { + return h.fs +} + +func (h *HostSandbox) GetWorkspace(ctx context.Context) string { + return h.workspace +} + +func (h *HostSandbox) Exec(ctx context.Context, req ExecRequest) (*ExecResult, error) { + return aggregateExecStream(func(onEvent func(ExecEvent) error) (*ExecResult, error) { + return h.ExecStream(ctx, req, onEvent) + }) +} + +func (h *HostSandbox) ExecStream( + ctx context.Context, + req ExecRequest, + onEvent func(ExecEvent) error, +) (*ExecResult, error) { + if strings.TrimSpace(req.Command) == "" { + return nil, fmt.Errorf("empty command") + } + + cmdCtx := ctx + cancel := func() {} + if req.TimeoutMs > 0 { + cmdCtx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutMs)*time.Millisecond) + } + defer cancel() + + var cmd *exec.Cmd + if len(req.Args) > 0 { + cmd = exec.CommandContext(cmdCtx, req.Command, req.Args...) + } else if runtime.GOOS == "windows" { + cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", req.Command) + } else { + cmd = exec.CommandContext(cmdCtx, "sh", "-c", req.Command) + } + + if req.WorkingDir != "" { + dir, err := ValidatePath(req.WorkingDir, h.workspace, h.restrict) + if err != nil { + return nil, err + } + cmd.Dir = dir + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe setup failed: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe setup failed: %w", err) + } + + prepareCommandForTermination(cmd) + cmd.Cancel = func() error { + return terminateProcessTree(cmd) + } + + if err := cmd.Start(); err != nil { + return nil, err + } + + var stdout, stderr bytes.Buffer + var callbackMu sync.Mutex + emit := func(event ExecEvent) error { + if onEvent == nil { + return nil + } + callbackMu.Lock() + defer callbackMu.Unlock() + return onEvent(event) + } + + readStream := func(r io.Reader, typ ExecEventType, dst *bytes.Buffer) error { + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + chunk := append([]byte(nil), buf[:n]...) + _, _ = dst.Write(chunk) + if emitErr := emit(ExecEvent{Type: typ, Chunk: chunk}); emitErr != nil { + return emitErr + } + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } + } + + streamErrs := make(chan error, 2) + go func() { + streamErrs <- readStream(stdoutPipe, ExecEventStdout, &stdout) + }() + go func() { + streamErrs <- readStream(stderrPipe, ExecEventStderr, &stderr) + }() + + var streamErr error + for i := 0; i < 2; i++ { + if err := <-streamErrs; err != nil && streamErr == nil { + streamErr = err + cancel() + } + } + + waitErr := cmd.Wait() + if streamErr != nil { + return nil, streamErr + } + if cmdCtx.Err() != nil { + return nil, cmdCtx.Err() + } + + exitCode := 0 + if waitErr != nil { + ee, ok := waitErr.(*exec.ExitError) + if ok { + exitCode = ee.ExitCode() + } else { + return nil, waitErr + } + } + if err := emit(ExecEvent{Type: ExecEventExit, ExitCode: exitCode}); err != nil { + return nil, err + } + + return &ExecResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + }, nil +} + +type hostFS struct { + workspace string + restrict bool + root *os.Root // OS-level directory file descriptor to safely confine operations and prevent TOCTOU escapes. +} + +func (h *hostFS) getSafeRelPath(path string) (string, error) { + if !filepath.IsAbs(path) { + return filepath.Clean(path), nil + } + if !isWithinWorkspace(path, h.workspace) { + return "", ErrOutsideWorkspace + } + // Rel is safe because isWithinWorkspace returned true + rel, _ := filepath.Rel(h.workspace, path) + return rel, nil +} + +func (h *hostFS) ReadFile(ctx context.Context, path string) ([]byte, error) { + if !h.restrict || h.workspace == "" || h.root == nil { + // Unrestricted mode continues to use traditional resolution + resolved, err := ValidatePath(path, h.workspace, h.restrict) + if err != nil { + return nil, err + } + return os.ReadFile(resolved) + } + + relPath, err := h.getSafeRelPath(path) + if err != nil { + return nil, err + } + + // os.Root guarantees that the read operation strictly happens within the workspace directory, + // effectively and atomically mitigating TOCTOU (Time-of-Check-Time-of-Use) vulnerabilities via symlinks. + return h.root.ReadFile(relPath) +} + +func (h *hostFS) WriteFile(ctx context.Context, path string, data []byte, mkdir bool) error { + if !h.restrict || h.workspace == "" || h.root == nil { + // Unrestricted mode continues to use traditional resolution + resolved, err := ValidatePath(path, h.workspace, h.restrict) + if err != nil { + return err + } + + parent := filepath.Dir(resolved) + if !mkdir { + parentInfo, err := os.Stat(parent) + if err != nil { + return err + } + if !parentInfo.IsDir() { + return fmt.Errorf("parent path is not a directory: %s", parent) + } + } + + return fileutil.WriteFileAtomic(resolved, data, 0o644) + } + + relPath, err := h.getSafeRelPath(path) + if err != nil { + return err + } + + if mkdir { + // MkdirAll natively resolves inside os.Root to avoid escapes. + if err := h.root.MkdirAll(filepath.Dir(relPath), 0o755); err != nil { + return err + } + } + return writeFileAtomicInRoot(h.root, relPath, data) +} + +func writeFileAtomicInRoot(root *os.Root, relPath string, data []byte) error { + dir := filepath.Dir(relPath) + tmpName := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) + tmpRelPath := tmpName + if dir != "." && dir != "/" { + tmpRelPath = filepath.Join(dir, tmpName) + } + + tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + _ = root.Remove(tmpRelPath) + return fmt.Errorf("failed to open temp file: %w", err) + } + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + _ = root.Remove(tmpRelPath) + return fmt.Errorf("failed to write temp file: %w", err) + } + + if err := tmpFile.Sync(); err != nil { + _ = tmpFile.Close() + _ = root.Remove(tmpRelPath) + return fmt.Errorf("failed to sync temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + _ = root.Remove(tmpRelPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := root.Rename(tmpRelPath, relPath); err != nil { + _ = root.Remove(tmpRelPath) + return fmt.Errorf("failed to rename temp file over target: %w", err) + } + + syncDir := "." + if dir != "" && dir != "/" { + syncDir = dir + } + if dirFile, err := root.Open(syncDir); err == nil { + _ = dirFile.Sync() + _ = dirFile.Close() + } + + return nil +} + +func (h *hostFS) ReadDir(ctx context.Context, path string) ([]os.DirEntry, error) { + if !h.restrict || h.workspace == "" || h.root == nil { + resolved, err := ValidatePath(path, h.workspace, h.restrict) + if err != nil { + return nil, err + } + return os.ReadDir(resolved) + } + + relPath, err := h.getSafeRelPath(path) + if err != nil { + return nil, err + } + + return fs.ReadDir(h.root.FS(), relPath) +} diff --git a/pkg/tools/shell_process_unix.go b/pkg/agent/sandbox/host_process_unix.go similarity index 77% rename from pkg/tools/shell_process_unix.go rename to pkg/agent/sandbox/host_process_unix.go index 7b29a81bf7..6cc18ef941 100644 --- a/pkg/tools/shell_process_unix.go +++ b/pkg/agent/sandbox/host_process_unix.go @@ -1,17 +1,18 @@ //go:build !windows -package tools +package sandbox import ( "os/exec" - "syscall" + + "golang.org/x/sys/unix" ) func prepareCommandForTermination(cmd *exec.Cmd) { if cmd == nil { return } - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.SysProcAttr = &unix.SysProcAttr{Setpgid: true} } func terminateProcessTree(cmd *exec.Cmd) error { @@ -25,7 +26,7 @@ func terminateProcessTree(cmd *exec.Cmd) error { } // Kill the entire process group spawned by the shell command. - _ = syscall.Kill(-pid, syscall.SIGKILL) + _ = unix.Kill(-pid, unix.SIGKILL) // Fallback kill on the shell process itself. _ = cmd.Process.Kill() return nil diff --git a/pkg/agent/sandbox/host_process_unix_test.go b/pkg/agent/sandbox/host_process_unix_test.go new file mode 100644 index 0000000000..e34da9e269 --- /dev/null +++ b/pkg/agent/sandbox/host_process_unix_test.go @@ -0,0 +1,65 @@ +//go:build !windows + +package sandbox + +import ( + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPrepareCommandForTermination(t *testing.T) { + // Should not panic on nil + prepareCommandForTermination(nil) + + cmd := exec.Command("echo", "test") + prepareCommandForTermination(cmd) + + if cmd.SysProcAttr == nil { + t.Fatal("expected SysProcAttr to be initialized") + } + if !cmd.SysProcAttr.Setpgid { + t.Fatal("expected Setpgid to be true") + } +} + +func TestTerminateProcessTree(t *testing.T) { + // Should not panic on nil cmd or nil process + if err := terminateProcessTree(nil); err != nil { + t.Fatalf("expected nil error for nil cmd, got: %v", err) + } + + cmdUnstarted := exec.Command("echo", "test") + if err := terminateProcessTree(cmdUnstarted); err != nil { + t.Fatalf("expected nil error for unstarted cmd, got: %v", err) + } + + // Start a real dummy process to test killing + cmd := exec.Command("sleep", "1") + prepareCommandForTermination(cmd) + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start cmd: %v", err) + } + + errChan := make(chan error, 1) + go func() { + errChan <- cmd.Wait() + }() + + if err := terminateProcessTree(cmd); err != nil { + t.Fatalf("terminateProcessTree failed: %v", err) + } + + // Verify the process is dead by waiting for Wait() to return + require.Eventually(t, func() bool { + select { + case err := <-errChan: + // Process died (killed), err should not be nil + return err != nil + default: + return false + } + }, 2*time.Second, 50*time.Millisecond, "expected process Wait to finish after termination") +} diff --git a/pkg/tools/shell_process_windows.go b/pkg/agent/sandbox/host_process_windows.go similarity index 96% rename from pkg/tools/shell_process_windows.go rename to pkg/agent/sandbox/host_process_windows.go index fe23b5c96f..34da5849f5 100644 --- a/pkg/tools/shell_process_windows.go +++ b/pkg/agent/sandbox/host_process_windows.go @@ -1,6 +1,6 @@ //go:build windows -package tools +package sandbox import ( "os/exec" diff --git a/pkg/agent/sandbox/host_test.go b/pkg/agent/sandbox/host_test.go new file mode 100644 index 0000000000..51f3a86352 --- /dev/null +++ b/pkg/agent/sandbox/host_test.go @@ -0,0 +1,432 @@ +package sandbox + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestHostSandbox_StartStopFs(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, true) + if err := sb.Start(context.Background()); err != nil { + t.Fatalf("Start() error: %v", err) + } + + if got := sb.GetWorkspace(context.Background()); got != root { + t.Errorf("GetWorkspace() = %q, want %q", got, root) + } + + if err := sb.Prune(context.Background()); err != nil { + t.Fatalf("Prune() error: %v", err) + } + if sb.Fs() == nil { + t.Fatal("Fs() returned nil") + } +} + +func TestHostSandbox_ExecAndFs(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, true) + + if _, err := sb.Exec(context.Background(), ExecRequest{Command: " "}); err == nil { + t.Fatal("expected empty command error") + } + + res, err := sb.Exec(context.Background(), ExecRequest{ + Command: "sh", + Args: []string{"-c", "printf hello"}, + }) + if err != nil { + t.Fatalf("Exec() error: %v", err) + } + if res.ExitCode != 0 || res.Stdout != "hello" { + t.Fatalf("unexpected exec result: %#v", res) + } + + if runtime.GOOS != "windows" { + _, err = sb.Exec(context.Background(), ExecRequest{ + Command: "sh", + Args: []string{"-c", "sleep 1"}, + TimeoutMs: 10, + }) + if err == nil { + t.Fatal("expected timeout-related error") + } + } + + _, err = sb.Exec(context.Background(), ExecRequest{ + Command: "sh", + Args: []string{"-c", "echo bad"}, + WorkingDir: "../outside", + }) + if err == nil || !errors.Is(err, ErrOutsideWorkspace) { + t.Fatalf("expected working dir restriction error, got: %v", err) + } + + err = sb.Fs().WriteFile(context.Background(), "dir/a.txt", []byte("x"), true) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + b, err := sb.Fs().ReadFile(context.Background(), "dir/a.txt") + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(b) != "x" { + t.Fatalf("ReadFile() got %q, want x", string(b)) + } +} + +func TestHostSandbox_ResolvePathRestrictions(t *testing.T) { + root := t.TempDir() + + got, err := ValidatePath("a/b.txt", root, true) + if err != nil { + t.Fatalf("resolvePath relative error: %v", err) + } + want := filepath.Join(root, "a", "b.txt") + if got != want { + t.Fatalf("resolvePath relative got %q, want %q", got, want) + } + + _, err = ValidatePath(filepath.Join(root, "..", "outside.txt"), root, true) + if err == nil || !errors.Is(err, ErrOutsideWorkspace) { + t.Fatalf("expected outside workspace error, got: %v", err) + } + + target := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(target, []byte("x"), 0o644); err != nil { + t.Fatalf("write target file: %v", err) + } + link := filepath.Join(root, "link.txt") + if err := os.Symlink(target, link); err == nil { + _, err = ValidatePath("link.txt", root, true) + if err == nil || !errors.Is(err, ErrOutsideWorkspace) { + t.Fatalf("expected symlink outside error, got: %v", err) + } + } +} + +func TestUnavailableSandboxAndUtilHelpers(t *testing.T) { + sb := NewUnavailableSandboxManager(nil) + if err := sb.Start(context.Background()); err == nil { + t.Fatal("expected Start() error") + } + if err := sb.Prune(context.Background()); err != nil { + t.Fatalf("Prune() error: %v", err) + } + if _, err := sb.Exec(context.Background(), ExecRequest{Command: "echo hi"}); err == nil { + t.Fatal("expected Exec() error") + } + if _, err := sb.Fs().ReadFile(context.Background(), "a.txt"); err == nil { + t.Fatal("expected Fs().ReadFile error") + } + if err := sb.Fs().WriteFile(context.Background(), "a.txt", []byte("x"), true); err == nil { + t.Fatal("expected Fs().WriteFile error") + } +} + +func TestHostFS_ReadFileWriteFile_Restricted(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, true) + if err := sb.Start(context.Background()); err != nil { + t.Fatal(err) + } + + content := []byte("hello restrict") + if err := sb.Fs().WriteFile(context.Background(), "a/b/c.txt", content, true); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + readContent, err := sb.Fs().ReadFile(context.Background(), "a/b/c.txt") + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(readContent) != string(content) { + t.Fatalf("content mismatch") + } + + // Should not be able to write root path + if err := sb.Fs().WriteFile(context.Background(), "/etc/passwd_not_exist", []byte("a"), false); err == nil { + t.Fatalf("expected access denied error writing outside workspace") + } + + // Should not be able to read root path + if _, err := sb.Fs().ReadFile(context.Background(), "/etc/passwd"); err == nil { + t.Fatalf("expected access denied error reading outside workspace") + } + + if err := sb.Prune(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestHostFS_ReadDir(t *testing.T) { + root := t.TempDir() + os.MkdirAll(filepath.Join(root, "a/b"), 0o755) + os.WriteFile(filepath.Join(root, "a/f1.txt"), []byte("1"), 0o644) + os.WriteFile(filepath.Join(root, "a/b/f2.txt"), []byte("2"), 0o644) + + sb := NewHostSandbox(root, true) + if err := sb.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer sb.Prune(context.Background()) + + // Test restricted ReadDir on "a" + entries, err := sb.Fs().ReadDir(context.Background(), "a") + if err != nil { + t.Fatalf("ReadDir failed: %v", err) + } + + foundF1 := false + foundB := false + for _, e := range entries { + if e.Name() == "f1.txt" && !e.IsDir() { + foundF1 = true + } + if e.Name() == "b" && e.IsDir() { + foundB = true + } + } + if !foundF1 || !foundB { + t.Errorf("ReadDir result missing expected entries: foundF1=%v, foundB=%v", foundF1, foundB) + } + + // Test unrestricted ReadDir + sb2 := NewHostSandbox(root, false) + entries2, err := sb2.Fs().ReadDir(context.Background(), root) + if err != nil { + t.Fatalf("ReadDir unrestricted failed: %v", err) + } + foundA := false + for _, e := range entries2 { + if e.Name() == "a" { + foundA = true + break + } + } + if !foundA { + t.Errorf("ReadDir unrestricted missing 'a'") + } +} + +func TestHostFS_ReadFileWriteFile_Unrestricted(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, false) + + // Write file directly into workspace since there's no restrictions + content := []byte("hello unrestricted") + if err := sb.Fs().WriteFile(context.Background(), "a/b/c_unrestricted.txt", content, true); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + readContent, err := sb.Fs().ReadFile(context.Background(), "a/b/c_unrestricted.txt") + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(readContent) != string(content) { + t.Fatalf("content mismatch") + } +} + +func TestHostFS_WriteFile_Unrestricted_NoMkdirMissingParent(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, false) + + err := sb.Fs().WriteFile(context.Background(), "missing/parent/file.txt", []byte("x"), false) + if err == nil { + t.Fatalf("expected WriteFile to fail when parent directory is missing and mkdir=false") + } +} + +func TestHostFS_WriteFileMKdir(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, true) + sb.Start(context.Background()) + defer sb.Prune(context.Background()) + + err := sb.Fs().WriteFile(context.Background(), "deep/nested/dir/file.txt", []byte("a"), true) + if err != nil { + t.Fatalf("WriteFile with mkdir failed: %v", err) + } +} + +func TestHostFS_WriteFileMKdirFailure(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, true) + sb.Start(context.Background()) + defer sb.Prune(context.Background()) + + // write to a path where parent is a file instead of dir + err := sb.Fs().WriteFile(context.Background(), "a.txt", []byte("a"), false) + if err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + err = sb.Fs().WriteFile(context.Background(), "a.txt/b.txt", []byte("b"), true) + if err == nil { + t.Fatalf("Expected MkdirAll to fail because a.txt is a file") + } +} + +func TestHostFS_ReadFileFailure(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, true) + sb.Start(context.Background()) + defer sb.Prune(context.Background()) + + _, err := sb.Fs().ReadFile(context.Background(), "does_not_exist.txt") + if err == nil { + t.Fatalf("Expected ReadFile to fail for non-existent file") + } +} + +func TestHostFS_WriteFileFailure(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, true) + sb.Start(context.Background()) + defer sb.Prune(context.Background()) + + // Create a read-only directory + roDir := filepath.Join(root, "ro") + os.Mkdir(roDir, 0o500) + + err := sb.Fs().WriteFile(context.Background(), "ro/failed.txt", []byte("a"), false) + if err == nil { + t.Fatalf("Expected WriteFile to fail in read-only dir") + } +} + +func TestHostSandbox_PruneWhenNilFs(t *testing.T) { + // simulate prune condition for code coverage + sb := NewHostSandbox(t.TempDir(), true) + sb.fs.(*hostFS).root = nil + err := sb.Prune(context.Background()) + if err != nil { + t.Fatalf("Prune failed when fs root is nil (%v)", err) + } +} + +func TestHostSandbox_StartBadWorkspace(t *testing.T) { + sb := NewHostSandbox("/this_should_not_exist_normally_12345/abc", true) + err := sb.Start(context.Background()) + if err == nil { + t.Fatalf("Start should fail for non-existing workspace root") + } +} + +func TestHostFS_ReadFileWriteFile_WithoutWorkspaceOrRoot(t *testing.T) { + root := t.TempDir() + + // Test blank workspace + sb := NewHostSandbox("", true) + if err := sb.Start(context.Background()); err != nil { + t.Fatal(err) + } + + content := []byte("hello empty workspace") + target := filepath.Join(root, "empty.txt") + if err := sb.Fs().WriteFile(context.Background(), target, content, true); err == nil { + t.Fatalf("expected WriteFile to fail due to empty workspace with restrict=true") + } + + _, err := sb.Fs().ReadFile(context.Background(), target) + if err == nil { + t.Fatalf("expected ReadFile to fail due to empty workspace with restrict=true") + } + + // Test case where root is nil explicitly + sb2 := NewHostSandbox(root, true) + sb2.fs.(*hostFS).root = nil + err = sb2.Fs().WriteFile(context.Background(), "nil_root_test.txt", content, true) + if err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + readContent, err := sb2.Fs().ReadFile(context.Background(), "nil_root_test.txt") + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(readContent) != string(content) { + t.Fatalf("content mismatch") + } +} + +func TestValidatePathErrors(t *testing.T) { + _, err := ValidatePath("/a/b/c", "", true) + if err == nil { + t.Fatalf("expected err for empty workspace with restrict=true") + } + + absTarget := filepath.Join(t.TempDir(), "abs.txt") + got, err := ValidatePath(absTarget, "", false) + if err != nil { + t.Fatalf("expected unrestricted empty-workspace absolute path to pass, got: %v", err) + } + if got != absTarget { + t.Fatalf("ValidatePath(abs, empty, false) = %q, want %q", got, absTarget) + } + + relTarget := "rel.txt" + got, err = ValidatePath(relTarget, "", false) + if err != nil { + t.Fatalf("expected unrestricted empty-workspace relative path to pass, got: %v", err) + } + if !filepath.IsAbs(got) { + t.Fatalf("ValidatePath(rel, empty, false) should return abs path, got %q", got) + } + + root := t.TempDir() + + // target parent is file, evalSymlinks should fail + err = os.WriteFile(filepath.Join(root, "a.txt"), []byte("a"), 0o644) + if err != nil { + t.Fatal(err) + } + _, err = ValidatePath("a.txt/b.txt", root, true) + if err == nil { + t.Fatalf("expected error when ancestor is file") + } +} + +func TestHostSandbox_ExecStream_Cancellation(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, false) + + // Test graceful timeout/cancel. + // We'll run a bash script that sleeps indefinitely in the foreground. + // We expect the command to be terminated and streamErr or ctx.Err() returned. + req := ExecRequest{ + Command: "sleep 3600", + TimeoutMs: 100, // Very short timeout + } + + start := time.Now() + res, err := sb.Exec(context.Background(), req) + elapsed := time.Since(start) + + if err == nil { + t.Fatalf("expected timeout error for sleep command, got nil. Res: %v", res) + } + if elapsed > 5*time.Second { + t.Fatalf("command failed to time out reasonably quickly: %v", elapsed) + } +} + +func TestHostSandbox_ExecStream_NoCommand(t *testing.T) { + root := t.TempDir() + sb := NewHostSandbox(root, false) + + req := ExecRequest{ + Command: "", + } + _, err := sb.Exec(context.Background(), req) + if err == nil || err.Error() != "empty command" { + t.Fatalf("expected 'empty command', got: %v", err) + } +} diff --git a/pkg/agent/sandbox/manager.go b/pkg/agent/sandbox/manager.go new file mode 100644 index 0000000000..a2cca33cdf --- /dev/null +++ b/pkg/agent/sandbox/manager.go @@ -0,0 +1,643 @@ +package sandbox + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/internal/infra" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/routing" +) + +// NewFromConfig builds a host sandbox from config for host-level execution (e.g. cron jobs). +// It does not return a Manager; use NewFromConfigWithAgent when sandbox routing is needed. +func NewFromConfig(workspace string, restrict bool, cfg *config.Config) Sandbox { + host := NewHostSandbox(workspace, restrict) + _ = host.Start(context.Background()) + return host +} + +// NewFromConfigAsManager returns a Manager backed by a HostSandbox from config. +// Use this when you need the Manager interface but have no agent-level manager available. +func NewFromConfigAsManager(workspace string, restrict bool, cfg *config.Config) Manager { + host := NewHostSandbox(workspace, restrict) + _ = host.Start(context.Background()) + return &hostOnlyManager{host: host} +} + +// NewFromConfigWithAgent builds the sandbox Manager for an agent. +// It always returns a non-nil Manager (falling back to a host manager or error manager if needed). +func NewFromConfigWithAgent(workspace string, restrict bool, cfg *config.Config, agentID string) Manager { + mode := config.SandboxModeAll + scope := config.SandboxScopeAgent + workspaceAccess := config.WorkspaceAccessNone + workspaceRoot := "~/.picoclaw/sandboxes" + image := "picoclaw-sandbox:bookworm-slim" + containerPrefix := "picoclaw-sandbox-" + pruneIdleHours := 24 + pruneMaxAgeDays := 7 + dockerCfg := config.AgentSandboxDockerConfig{} + + if cfg != nil { + sb := cfg.Agents.Defaults.Sandbox + if sb.Mode != "" { + mode = sb.Mode + } + if sb.Scope != "" { + scope = sb.Scope + } + if sb.WorkspaceAccess != "" { + workspaceAccess = sb.WorkspaceAccess + } + if strings.TrimSpace(sb.WorkspaceRoot) != "" { + workspaceRoot = strings.TrimSpace(sb.WorkspaceRoot) + } + if strings.TrimSpace(sb.Docker.Image) != "" { + image = strings.TrimSpace(sb.Docker.Image) + } + if strings.TrimSpace(sb.Docker.ContainerPrefix) != "" { + containerPrefix = strings.TrimSpace(sb.Docker.ContainerPrefix) + } + if sb.Prune.IdleHours != nil { + pruneIdleHours = *sb.Prune.IdleHours + } + if sb.Prune.MaxAgeDays != nil { + pruneMaxAgeDays = *sb.Prune.MaxAgeDays + } + dockerCfg = sb.Docker + } + + agentID = routing.NormalizeAgentID(agentID) + + resolvedMode := normalizeSandboxMode(mode) + host := NewHostSandbox(workspace, restrict) + _ = host.Start(context.Background()) + + // When sandbox is disabled, skip building container infrastructure entirely. + if resolvedMode == config.SandboxModeOff { + return &hostOnlyManager{host: host} + } + + resolvedScope := normalizeSandboxScope(scope) + normalizedAccess := normalizeWorkspaceAccess(workspaceAccess) + workspaceRootAbs := resolveAbsPath(expandHomePath(workspaceRoot)) + agentWorkspaceAbs := resolveAbsPath(workspace) + + manager := &scopedSandboxManager{ + mode: resolvedMode, + scope: resolvedScope, + agentID: agentID, + host: host, + image: image, + containerPrefix: containerPrefix, + workspaceAccess: normalizedAccess, + workspaceRoot: workspaceRootAbs, + agentWorkspace: agentWorkspaceAbs, + pruneIdleHours: pruneIdleHours, + pruneMaxAgeDays: pruneMaxAgeDays, + dockerCfg: dockerCfg, + scoped: map[string]Sandbox{}, + } + manager.fs = &managerFS{m: manager} + if err := manager.Start(context.Background()); err != nil { + return NewUnavailableSandboxManager(fmt.Errorf("container sandbox unavailable: %w", err)) + } + return manager +} + +func normalizeWorkspaceAccess(access config.WorkspaceAccess) config.WorkspaceAccess { + v := config.WorkspaceAccess(strings.ToLower(strings.TrimSpace(string(access)))) + switch v { + case config.WorkspaceAccessRO, config.WorkspaceAccessRW: + return v + default: + return config.WorkspaceAccessNone + } +} + +func normalizeSandboxMode(mode config.SandboxMode) config.SandboxMode { + v := config.SandboxMode(strings.ToLower(strings.TrimSpace(string(mode)))) + switch v { + case config.SandboxModeAll, config.SandboxModeNonMain: + return v + default: + return config.SandboxModeOff + } +} + +func normalizeSandboxScope(scope config.SandboxScope) config.SandboxScope { + v := config.SandboxScope(strings.ToLower(strings.TrimSpace(string(scope)))) + switch v { + case config.SandboxScopeSession, config.SandboxScopeShared: + return v + default: + return config.SandboxScopeAgent + } +} + +func expandHomePath(p string) string { + raw := strings.TrimSpace(p) + if raw == "" { + return raw + } + if raw == "~" { + home, _ := os.UserHomeDir() + return home + } + if strings.HasPrefix(raw, "~/") { + home, _ := os.UserHomeDir() + return filepath.Join(home, raw[2:]) + } + return raw +} + +func resolveAbsPath(p string) string { + trimmed := strings.TrimSpace(p) + if trimmed == "" { + return "" + } + if filepath.IsAbs(trimmed) { + return trimmed + } + abs, err := filepath.Abs(trimmed) + if err != nil { + return trimmed + } + return abs +} + +type scopedSandboxManager struct { + mode config.SandboxMode + scope config.SandboxScope + agentID string + host Sandbox + image string + containerPrefix string + workspaceAccess config.WorkspaceAccess + workspaceRoot string + agentWorkspace string + pruneIdleHours int + pruneMaxAgeDays int + dockerCfg config.AgentSandboxDockerConfig + + mu sync.Mutex + scoped map[string]Sandbox + fs FsBridge + + loopMu sync.Mutex + loopStop context.CancelFunc + loopDone chan struct{} +} + +func (m *scopedSandboxManager) Start(ctx context.Context) error { + if m.mode == config.SandboxModeOff { + return nil + } + if _, err := m.getOrCreateSandbox(ctx, m.defaultScopeKey()); err != nil { + return err + } + m.ensurePruneLoop() + return nil +} + +func (m *scopedSandboxManager) Prune(ctx context.Context) error { + m.stopPruneLoop(ctx) + + m.mu.Lock() + scoped := make([]Sandbox, 0, len(m.scoped)) + for _, sb := range m.scoped { + scoped = append(scoped, sb) + } + m.mu.Unlock() + + var firstErr error + for _, sb := range scoped { + if err := sb.Prune(ctx); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (m *scopedSandboxManager) ensurePruneLoop() { + if m.pruneIdleHours <= 0 && m.pruneMaxAgeDays <= 0 { + return + } + m.loopMu.Lock() + defer m.loopMu.Unlock() + if m.loopStop != nil { + return + } + + loopCtx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + m.loopStop = cancel + m.loopDone = done + + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer func() { + ticker.Stop() + close(done) + }() + for { + select { + case <-loopCtx.Done(): + return + case <-ticker.C: + _ = m.pruneOnce(loopCtx) + } + } + }() +} + +func (m *scopedSandboxManager) stopPruneLoop(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + m.loopMu.Lock() + stop := m.loopStop + done := m.loopDone + m.loopStop = nil + m.loopDone = nil + m.loopMu.Unlock() + if stop == nil { + return + } + stop() + if done == nil { + return + } + select { + case <-done: + case <-ctx.Done(): + } +} + +func (m *scopedSandboxManager) pruneOnce(ctx context.Context) error { + if m.pruneIdleHours <= 0 && m.pruneMaxAgeDays <= 0 { + return nil + } + regPath := filepath.Join(infra.ResolveHomeDir(), "sandbox", defaultSandboxRegistryFile) + registryMu.Lock() + data, err := loadRegistry(regPath) + registryMu.Unlock() + if err != nil { + return err + } + + pruneCfg := ContainerSandboxConfig{ + PruneIdleHours: m.pruneIdleHours, + PruneMaxAgeDays: m.pruneMaxAgeDays, + } + now := time.Now().UnixMilli() + + m.mu.Lock() + byContainer := make(map[string]Sandbox, len(m.scoped)) + for _, sb := range m.scoped { + if containerSb, ok := sb.(*ContainerSandbox); ok { + byContainer[containerSb.cfg.ContainerName] = sb + } + } + m.mu.Unlock() + + var firstErr error + for _, entry := range data.Entries { + if !shouldPruneEntry(pruneCfg, now, entry) { + continue + } + + // Best-effort cleanup: we attempt to prune or remove all eligible entries. + // If one fails, we record the error and continue to the next to prevent + // a single bad container from halting the entire garbage collection process. + if sb, ok := byContainer[entry.ContainerName]; ok { + if err := sb.Prune(ctx); err != nil && firstErr == nil { + firstErr = err + } + continue + } + + if err := stopAndRemoveContainerByName(ctx, entry.ContainerName); err != nil && firstErr == nil { + firstErr = err + } + if err := removeRegistryEntry(regPath, entry.ContainerName); err != nil && firstErr == nil { + firstErr = err + } + } + + return firstErr +} + +func (m *scopedSandboxManager) Exec(ctx context.Context, req ExecRequest) (*ExecResult, error) { + if !m.shouldSandbox(ctx) { + return m.host.Exec(ctx, req) + } + sb, err := m.getOrCreateSandbox(ctx, m.scopeKeyFromContext(ctx)) + if err != nil { + return nil, err + } + return sb.Exec(ctx, req) +} + +func (m *scopedSandboxManager) ExecStream( + ctx context.Context, + req ExecRequest, + onEvent func(ExecEvent) error, +) (*ExecResult, error) { + if !m.shouldSandbox(ctx) { + return m.host.ExecStream(ctx, req, onEvent) + } + sb, err := m.getOrCreateSandbox(ctx, m.scopeKeyFromContext(ctx)) + if err != nil { + return nil, err + } + return sb.ExecStream(ctx, req, onEvent) +} + +func (m *scopedSandboxManager) Fs() FsBridge { + return m.fs +} + +func (m *scopedSandboxManager) GetWorkspace(ctx context.Context) string { + if !m.shouldSandbox(ctx) { + return m.host.GetWorkspace(ctx) + } + sb, err := m.getOrCreateSandbox(ctx, m.scopeKeyFromContext(ctx)) + if err != nil { + return m.host.GetWorkspace(ctx) + } + return sb.GetWorkspace(ctx) +} + +// Resolve returns the specific sandbox instance to be used for the given context. +func (m *scopedSandboxManager) Resolve(ctx context.Context) (Sandbox, error) { + if !m.shouldSandbox(ctx) { + return m.host, nil + } + return m.getOrCreateSandbox(ctx, m.scopeKeyFromContext(ctx)) +} + +func (m *scopedSandboxManager) shouldSandbox(ctx context.Context) bool { + switch m.mode { + case config.SandboxModeAll: + return true + case config.SandboxModeNonMain: + // Sandbox all sessions except the agent's main session. + // Normalize before comparing to handle aliases like "main" or bare agent keys + return m.normalizeSessionKey(SessionKeyFromContext(ctx)) != m.mainSessionKey() + default: + return false + } +} + +func (m *scopedSandboxManager) mainSessionKey() string { + return routing.BuildAgentMainSessionKey(m.agentID) +} + +func (m *scopedSandboxManager) normalizeSessionKey(raw string) string { + trimmed := strings.TrimSpace(raw) + main := m.mainSessionKey() + if trimmed == "" { + return main + } + if strings.EqualFold(trimmed, "main") || strings.EqualFold(trimmed, main) { + return main + } + if parsed := routing.ParseAgentSessionKey(trimmed); parsed != nil { + if routing.NormalizeAgentID(parsed.AgentID) == m.agentID && + strings.EqualFold(strings.TrimSpace(parsed.Rest), "main") { + return main + } + } + return trimmed +} + +func (m *scopedSandboxManager) scopeKeyFromContext(ctx context.Context) string { + sessionKey := m.normalizeSessionKey(SessionKeyFromContext(ctx)) + switch m.scope { + case config.SandboxScopeShared: + return "shared" + case config.SandboxScopeSession: + return sessionKey + default: + if parsed := routing.ParseAgentSessionKey(sessionKey); parsed != nil { + return "agent:" + routing.NormalizeAgentID(parsed.AgentID) + } + return "agent:" + m.agentID + } +} + +func (m *scopedSandboxManager) defaultScopeKey() string { + return m.scopeKeyFromContext(WithSessionKey(context.Background(), m.mainSessionKey())) +} + +func (m *scopedSandboxManager) getOrCreateSandbox(ctx context.Context, scopeKey string) (Sandbox, error) { + m.mu.Lock() + if sb, ok := m.scoped[scopeKey]; ok { + m.mu.Unlock() + return sb, nil + } + m.mu.Unlock() + + sb := m.buildScopedContainerSandbox(scopeKey) + if err := sb.Start(ctx); err != nil { + return nil, err + } + + // Re-acquire lock and perform a second check to guard against a concurrent + // goroutine that also passed the fast path and completed Start() first. + m.mu.Lock() + if existing, ok := m.scoped[scopeKey]; ok { + m.mu.Unlock() + // Another goroutine won the race; clean up our duplicate and return theirs. + _ = sb.Prune(context.Background()) + return existing, nil + } + m.scoped[scopeKey] = sb + m.mu.Unlock() + return sb, nil +} + +func (m *scopedSandboxManager) buildScopedContainerSandbox(scopeKey string) Sandbox { + workspace := m.agentWorkspace + if m.workspaceAccess == config.WorkspaceAccessNone || strings.TrimSpace(workspace) == "" { + workspace = filepath.Join(m.workspaceRoot, slugScopeKey(scopeKey), "workspace") + } + return NewContainerSandbox(ContainerSandboxConfig{ + Image: m.image, + ContainerName: strings.TrimSpace(m.containerPrefix) + slugScopeKey(scopeKey), + ContainerPrefix: m.containerPrefix, + Workspace: workspace, + AgentWorkspace: m.agentWorkspace, + WorkspaceAccess: string(m.workspaceAccess), + WorkspaceRoot: m.workspaceRoot, + PruneIdleHours: m.pruneIdleHours, + PruneMaxAgeDays: m.pruneMaxAgeDays, + Workdir: m.dockerCfg.Workdir, + ReadOnlyRoot: m.dockerCfg.ReadOnlyRoot, + Tmpfs: m.dockerCfg.Tmpfs, + Network: m.dockerCfg.Network, + User: m.dockerCfg.User, + CapDrop: m.dockerCfg.CapDrop, + Env: m.dockerCfg.Env, + SetupCommand: m.dockerCfg.SetupCommand, + PidsLimit: m.dockerCfg.PidsLimit, + Memory: m.dockerCfg.Memory, + MemorySwap: m.dockerCfg.MemorySwap, + Cpus: m.dockerCfg.Cpus, + Ulimits: m.dockerCfg.Ulimits, + SeccompProfile: m.dockerCfg.SeccompProfile, + ApparmorProfile: m.dockerCfg.ApparmorProfile, + DNS: m.dockerCfg.DNS, + ExtraHosts: m.dockerCfg.ExtraHosts, + Binds: m.dockerCfg.Binds, + }) +} + +type managerFS struct { + m *scopedSandboxManager +} + +func (f *managerFS) ReadFile(ctx context.Context, path string) ([]byte, error) { + if !f.m.shouldSandbox(ctx) { + return f.m.host.Fs().ReadFile(ctx, path) + } + sb, err := f.m.getOrCreateSandbox(ctx, f.m.scopeKeyFromContext(ctx)) + if err != nil { + return nil, err + } + return sb.Fs().ReadFile(ctx, path) +} + +func (f *managerFS) WriteFile(ctx context.Context, path string, data []byte, mkdir bool) error { + if !f.m.shouldSandbox(ctx) { + return f.m.host.Fs().WriteFile(ctx, path, data, mkdir) + } + sb, err := f.m.getOrCreateSandbox(ctx, f.m.scopeKeyFromContext(ctx)) + if err != nil { + return err + } + return sb.Fs().WriteFile(ctx, path, data, mkdir) +} + +func (f *managerFS) ReadDir(ctx context.Context, path string) ([]os.DirEntry, error) { + if !f.m.shouldSandbox(ctx) { + return f.m.host.Fs().ReadDir(ctx, path) + } + sb, err := f.m.getOrCreateSandbox(ctx, f.m.scopeKeyFromContext(ctx)) + if err != nil { + return nil, err + } + return sb.Fs().ReadDir(ctx, path) +} + +var nonAlnum = regexp.MustCompile(`[^a-z0-9._-]+`) + +func slugScopeKey(scopeKey string) string { + raw := strings.ToLower(strings.TrimSpace(scopeKey)) + if raw == "" { + raw = "default" + } + safe := nonAlnum.ReplaceAllString(raw, "-") + safe = strings.Trim(safe, "-") + if safe == "" { + safe = "default" + } + if len(safe) > 32 { + safe = safe[:32] + } + sum := sha256.Sum256([]byte(raw)) + return safe + "-" + hex.EncodeToString(sum[:4]) +} + +// hostOnlyManager is a lightweight Manager used when sandbox mode is "off". +// It delegates all operations directly to the HostSandbox, avoiding +// unnecessary container infrastructure setup. +type hostOnlyManager struct { + host Sandbox +} + +func (h *hostOnlyManager) Start(ctx context.Context) error { return nil } +func (h *hostOnlyManager) Prune(ctx context.Context) error { return h.host.Prune(ctx) } +func (h *hostOnlyManager) Resolve(ctx context.Context) (Sandbox, error) { return h.host, nil } +func (h *hostOnlyManager) Fs() FsBridge { return h.host.Fs() } +func (h *hostOnlyManager) GetWorkspace(ctx context.Context) string { return h.host.GetWorkspace(ctx) } +func (h *hostOnlyManager) Exec(ctx context.Context, req ExecRequest) (*ExecResult, error) { + return h.host.Exec(ctx, req) +} + +func (h *hostOnlyManager) ExecStream( + ctx context.Context, + req ExecRequest, + onEvent func(ExecEvent) error, +) (*ExecResult, error) { + return h.host.ExecStream(ctx, req, onEvent) +} + +type unavailableSandboxManager struct { + err error + fs FsBridge +} + +func NewUnavailableSandboxManager(err error) Manager { + if err == nil { + err = errors.New("sandbox unavailable") + } + return &unavailableSandboxManager{ + err: err, + fs: &errorFS{err: err}, + } +} + +func (u *unavailableSandboxManager) Start(ctx context.Context) error { return u.err } +func (u *unavailableSandboxManager) Prune(ctx context.Context) error { return nil } + +// Resolve returns an error because the sandbox is unavailable. +func (u *unavailableSandboxManager) Resolve(ctx context.Context) (Sandbox, error) { + return nil, u.err +} + +func (u *unavailableSandboxManager) Fs() FsBridge { return u.fs } + +func (u *unavailableSandboxManager) GetWorkspace(ctx context.Context) string { + return "" +} + +func (u *unavailableSandboxManager) Exec(ctx context.Context, req ExecRequest) (*ExecResult, error) { + return aggregateExecStream(func(onEvent func(ExecEvent) error) (*ExecResult, error) { + return u.ExecStream(ctx, req, onEvent) + }) +} + +func (u *unavailableSandboxManager) ExecStream( + ctx context.Context, + req ExecRequest, + onEvent func(ExecEvent) error, +) (*ExecResult, error) { + return nil, u.err +} + +type errorFS struct { + err error +} + +func (e *errorFS) ReadFile(ctx context.Context, path string) ([]byte, error) { + return nil, fmt.Errorf("sandbox unavailable: %w", e.err) +} + +func (e *errorFS) WriteFile(ctx context.Context, path string, data []byte, mkdir bool) error { + return fmt.Errorf("sandbox unavailable: %w", e.err) +} + +func (e *errorFS) ReadDir(ctx context.Context, path string) ([]os.DirEntry, error) { + return nil, fmt.Errorf("sandbox unavailable: %w", e.err) +} diff --git a/pkg/agent/sandbox/manager_test.go b/pkg/agent/sandbox/manager_test.go new file mode 100644 index 0000000000..262e36f2a7 --- /dev/null +++ b/pkg/agent/sandbox/manager_test.go @@ -0,0 +1,442 @@ +package sandbox + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/routing" +) + +func TestNormalizeWorkspaceAccess(t *testing.T) { + if got := normalizeWorkspaceAccess("ro"); got != "ro" { + t.Fatalf("normalizeWorkspaceAccess(ro) = %q", got) + } + if got := normalizeWorkspaceAccess("RW"); got != "rw" { + t.Fatalf("normalizeWorkspaceAccess(RW) = %q", got) + } + if got := normalizeWorkspaceAccess("invalid"); got != "none" { + t.Fatalf("normalizeWorkspaceAccess(invalid) = %q", got) + } +} + +func TestExpandHomePath(t *testing.T) { + if got := expandHomePath(""); got != "" { + t.Fatalf("expandHomePath(\"\") = %q, want empty", got) + } + if got := expandHomePath("abc"); got != "abc" { + t.Fatalf("expandHomePath(abc) = %q", got) + } + if got := expandHomePath("~"); got == "" { + t.Fatal("expandHomePath(~) should resolve to home") + } + if got := expandHomePath("~/x"); got == "" || got == "~/x" { + t.Fatalf("expandHomePath(~/x) = %q, expected resolved path", got) + } +} + +func TestNewFromConfig_HostMode(t *testing.T) { + // NewFromConfig always returns a HostSandbox regardless of mode config. + sb := NewFromConfig(t.TempDir(), true, nil) + if _, ok := sb.(*HostSandbox); !ok { + t.Fatalf("expected HostSandbox, got %T", sb) + } + if err := sb.Prune(context.Background()); err != nil { + t.Fatalf("Prune() error: %v", err) + } +} + +func TestNewFromConfig_AllModeReturnsUnavailableWhenBlocked(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Sandbox.Mode = "all" + cfg.Agents.Defaults.Sandbox.Docker.Network = "host" + cfg.Agents.Defaults.Sandbox.Prune.IdleHours = config.IntPtr(0) + cfg.Agents.Defaults.Sandbox.Prune.MaxAgeDays = config.IntPtr(0) + + // NewFromConfigWithAgent is the manager factory; when Docker is unavailable + // it should return an unavailableSandbox that implements Manager. + mgr := NewFromConfigWithAgent(t.TempDir(), true, cfg, "test") + if mgr == nil { + t.Fatal("expected non-nil Manager when mode=all") + } + if _, ok := mgr.(*unavailableSandboxManager); !ok { + t.Fatalf("expected unavailableSandbox, got %T", mgr) + } + // Resolve() must propagate the unavailability error. + if _, err := mgr.Resolve(context.Background()); err == nil { + t.Fatal("expected unavailable sandbox Resolve() to return error") + } +} + +func TestScopedSandboxManager_PruneLoopLifecycle(t *testing.T) { + m := &scopedSandboxManager{ + mode: "all", + pruneIdleHours: 1, + pruneMaxAgeDays: 0, + scoped: map[string]Sandbox{}, + } + + m.ensurePruneLoop() + if m.loopStop == nil || m.loopDone == nil { + t.Fatal("expected manager prune loop to start") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + m.stopPruneLoop(ctx) + if m.loopStop != nil || m.loopDone != nil { + t.Fatal("expected manager prune loop state reset after stop") + } +} + +func TestScopedSandboxManager_PruneOnceLoadRegistryError(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + stateDir := filepath.Join(home, ".picoclaw", "sandbox") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatalf("mkdir state dir: %v", err) + } + regPath := filepath.Join(stateDir, "containers.json") + if err := os.WriteFile(regPath, []byte("{not-json"), 0o644); err != nil { + t.Fatalf("write invalid registry: %v", err) + } + + m := &scopedSandboxManager{ + mode: "all", + pruneIdleHours: 1, + pruneMaxAgeDays: 0, + scoped: map[string]Sandbox{}, + } + if err := m.pruneOnce(context.Background()); err == nil { + t.Fatal("expected pruneOnce() to return registry load error") + } +} + +func TestScopedSandboxManager_ShouldSandbox_NonMain(t *testing.T) { + m := &scopedSandboxManager{ + mode: config.SandboxModeNonMain, + agentID: "default", + } + + if m.shouldSandbox(context.Background()) { + t.Fatal("expected background context to map to main session (host path)") + } + + if m.shouldSandbox(WithSessionKey(context.Background(), "main")) { + t.Fatal("expected explicit main alias to remain host path") + } + + mainKey := routing.BuildAgentMainSessionKey("default") + if m.shouldSandbox(WithSessionKey(context.Background(), mainKey)) { + t.Fatal("expected agent main session key to remain host path") + } + + nonMainKey := "agent:default:direct:user-1" + if !m.shouldSandbox(WithSessionKey(context.Background(), nonMainKey)) { + t.Fatal("expected non-main session to use sandbox path") + } +} + +func TestHostOnlyManager(t *testing.T) { + // hostOnlyManager just delegates to its inner host sandbox. + workspace := t.TempDir() + host := NewHostSandbox(workspace, false) + mgr := &hostOnlyManager{host: host} + + ctx := context.Background() + if err := mgr.Start(ctx); err != nil { + t.Fatalf("Start() returned error: %v", err) + } + if err := mgr.Prune(ctx); err != nil { + t.Fatalf("Prune() returned error: %v", err) + } + + sb, err := mgr.Resolve(ctx) + if err != nil { + t.Fatalf("Resolve() returned error: %v", err) + } + if sb == nil { + t.Fatal("Resolve() returned nil sandbox") + } + + if fs := mgr.Fs(); fs == nil { + t.Fatal("Fs() returned nil") + } + + gotWs := mgr.GetWorkspace(ctx) + if gotWs != host.GetWorkspace(ctx) { + t.Fatalf("GetWorkspace() = %q, want %q", gotWs, host.GetWorkspace(ctx)) + } + + // Exec and ExecStream should also just delegate without panic. + // Executing a simple command like "echo" + req := ExecRequest{Command: "echo", Args: []string{"test"}} + res, err := mgr.Exec(ctx, req) + if err != nil { + t.Fatalf("Exec() returned error: %v", err) + } + if res.ExitCode != 0 { + t.Fatalf("Exec() returned non-zero exit code: %d", res.ExitCode) + } + + streamRes, streamErr := mgr.ExecStream(ctx, req, func(e ExecEvent) error { return nil }) + if streamErr != nil { + t.Fatalf("ExecStream() returned error: %v", streamErr) + } + if streamRes.ExitCode != 0 { + t.Fatalf("ExecStream() returned non-zero exit code: %d", streamRes.ExitCode) + } +} + +func TestUnavailableSandboxManager(t *testing.T) { + errReason := os.ErrPermission + mgr := NewUnavailableSandboxManager(errReason) + ctx := context.Background() + + if err := mgr.Start(ctx); err != errReason { + t.Fatalf("Start() = %v, want %v", err, errReason) + } + + // Prune is a no-op, shouldn't return error + if err := mgr.Prune(ctx); err != nil { + t.Fatalf("Prune() = %v, want nil", err) + } + + _, err := mgr.Resolve(ctx) + if err == nil { + t.Fatal("Resolve() expected error, got nil") + } + + if ws := mgr.GetWorkspace(ctx); ws != "" { + t.Fatalf("GetWorkspace() = %q, want empty", ws) + } + + req := ExecRequest{Command: "ls"} + _, err = mgr.Exec(ctx, req) + if err == nil { + t.Fatal("Exec() expected error, got nil") + } + + _, err = mgr.ExecStream(ctx, req, nil) + if err == nil { + t.Fatal("ExecStream() expected error, got nil") + } + + fs := mgr.Fs() + if fs == nil { + t.Fatal("Fs() returned nil") + } + + _, err = fs.ReadFile(ctx, "test.txt") + if err == nil { + t.Fatal("ReadFile() expected error, got nil") + } + + err = fs.WriteFile(ctx, "test.txt", []byte("a"), false) + if err == nil { + t.Fatal("WriteFile() expected error, got nil") + } + + _, err = fs.ReadDir(ctx, ".") + if err == nil { + t.Fatal("ReadDir() expected error, got nil") + } +} + +func TestScopedSandboxManager_Delegates(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + ws := filepath.Join(home, "default_ws") + + m := &scopedSandboxManager{ + mode: config.SandboxModeNonMain, + agentID: "agent-1", + host: NewHostSandbox(ws, false), + scoped: map[string]Sandbox{}, + } + _ = m.host.Start(context.Background()) + m.fs = &managerFS{m: m} + + // 1. When ShouldSandbox is false (Context is main session), it delegates to HostSandbox. + ctxMain := WithSessionKey(context.Background(), routing.BuildAgentMainSessionKey("agent-1")) + + if got := m.GetWorkspace(ctxMain); got != ws { + t.Fatalf("GetWorkspace(main) = %q, want %q", got, ws) + } + + req := ExecRequest{Command: "echo", Args: []string{"hello"}} + res, err := m.Exec(ctxMain, req) + if err != nil { + t.Fatalf("Exec(main) error: %v", err) + } + if res.ExitCode != 0 { + t.Fatalf("Exec(main) exit code: %d", res.ExitCode) + } + + _, err = m.ExecStream(ctxMain, req, func(e ExecEvent) error { return nil }) + if err != nil { + t.Fatalf("ExecStream(main) error: %v", err) + } + + fs := m.Fs() + testFile := "test_delegate.txt" + err = fs.WriteFile(ctxMain, testFile, []byte("ok"), true) + if err != nil { + t.Fatalf("WriteFile(main) error: %v", err) + } + defer os.Remove(filepath.Join(ws, testFile)) + + data, err := fs.ReadFile(ctxMain, testFile) + if err != nil || string(data) != "ok" { + t.Fatalf("ReadFile(main) error: %v, data: %q", err, string(data)) + } + + entries, err := fs.ReadDir(ctxMain, ".") + if err != nil || len(entries) == 0 { + t.Fatalf("ReadDir(main) error: %v, len: %d", err, len(entries)) + } + + sb, err := m.Resolve(ctxMain) + if err != nil { + t.Fatalf("Resolve(main) error: %v", err) + } + if sb != m.host { + t.Fatal("Resolve(main) should return host sandbox") + } +} + +func TestScopedSandboxManager_ContainerDelegates(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + home := t.TempDir() + t.Setenv("HOME", home) + ws := filepath.Join(home, "container_ws") + os.MkdirAll(ws, 0o755) + + mockContainer := NewHostSandbox(ws, false) + _ = mockContainer.Start(context.Background()) + + m := &scopedSandboxManager{ + mode: config.SandboxModeAll, + agentID: "agent-1", + scoped: map[string]Sandbox{}, + } + m.fs = &managerFS{m: m} + + ctx := WithSessionKey(context.Background(), "test-session") + scopeKey := m.scopeKeyFromContext(ctx) + + // Pre-inject the mock container to bypass actual docker creation + m.scoped[scopeKey] = mockContainer + + // Now shouldSandbox(ctx) is true, so manager methods should delegate to mockContainer + if got := m.GetWorkspace(ctx); got != ws { + t.Fatalf("GetWorkspace(container) = %q, want %q", got, ws) + } + + req := ExecRequest{Command: "echo", Args: []string{"hello"}} + res, err := m.Exec(ctx, req) + if err != nil { + t.Fatalf("Exec(container) error: %v", err) + } + if res.ExitCode != 0 { + t.Fatalf("Exec(container) exit code: %d", res.ExitCode) + } + + _, err = m.ExecStream(ctx, req, func(e ExecEvent) error { return nil }) + if err != nil { + t.Fatalf("ExecStream(container) error: %v", err) + } + + fs := m.Fs() + testFile := "test_container_delegate.txt" + err = fs.WriteFile(ctx, testFile, []byte("ok"), true) + if err != nil { + t.Fatalf("WriteFile(container) error: %v", err) + } + defer os.Remove(filepath.Join(ws, testFile)) + + data, err := fs.ReadFile(ctx, testFile) + if err != nil || string(data) != "ok" { + t.Fatalf("ReadFile(container) error: %v, data: %q", err, string(data)) + } + + entries, err := fs.ReadDir(ctx, ".") + if err != nil || len(entries) == 0 { + t.Fatalf("ReadDir(container) error: %v, len: %d", err, len(entries)) + } + + sb, err := m.Resolve(ctx) + if err != nil { + t.Fatalf("Resolve(container) error: %v", err) + } + if sb != mockContainer { + t.Fatal("Resolve(container) should return the mock container sandbox") + } +} + +func TestScopedSandboxManager_ContainerCreationError(t *testing.T) { + _, cleanup := skipIfNoDocker(t) + defer cleanup() + + home := t.TempDir() + t.Setenv("HOME", home) + + m := &scopedSandboxManager{ + mode: config.SandboxModeAll, + workspaceRoot: home, + image: "non-existent-image-12345", + dockerCfg: config.AgentSandboxDockerConfig{Image: "non-existent-image-12345"}, + scoped: map[string]Sandbox{}, + } + + ctx := WithSessionKey(context.Background(), "error-session") + + // Resolve calls getOrCreateSandbox, which calls Start(). + // Start should fail because the image doesn't exist. + sb, err := m.Resolve(ctx) + if err != nil { + // If Resolve fails here, it's also a valid failure for this test Case, + // but historically we expected it to happen during Exec. + // Since Start is now called in Resolve, it's expected to fail here. + return + } + + res, err := sb.Exec(ctx, ExecRequest{Command: "echo"}) + if err == nil && res.ExitCode == 0 { + t.Fatalf("expected Exec() to fail when container creation fails, but it succeeded: %#v", res) + } +} + +func TestScopedSandboxManager_PruneOnceFull(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + stateDir := filepath.Join(home, ".picoclaw", "sandbox") + _ = os.MkdirAll(stateDir, 0o755) + + m := &scopedSandboxManager{ // this is correct initialization for the test + mode: config.SandboxModeAll, + pruneIdleHours: 1, + pruneMaxAgeDays: 0, + scoped: map[string]Sandbox{}, + } + + regPath := filepath.Join(stateDir, "containers.json") + oldTime := time.Now().Add(-2 * time.Hour).UnixMilli() + + data := fmt.Sprintf(`{"entries": [{"container_name": "prune-me", "last_active_at": %d}]}`, oldTime) + _ = os.WriteFile(regPath, []byte(data), 0o644) + + _ = m.pruneOnce(context.Background()) + + b, _ := os.ReadFile(regPath) + if strings.Contains(string(b), "prune-me") { + t.Fatalf("expected prune-me to be removed from registry, got %s", string(b)) + } +} diff --git a/pkg/agent/sandbox/path.go b/pkg/agent/sandbox/path.go new file mode 100644 index 0000000000..52e1e95a04 --- /dev/null +++ b/pkg/agent/sandbox/path.go @@ -0,0 +1,88 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" +) + +// ValidatePath ensures the given path is within the workspace if restrict is true. +func ValidatePath(path, workspace string, restrict bool) (string, error) { + if workspace == "" { + if restrict { + return "", fmt.Errorf("workspace is not defined") + } + if filepath.IsAbs(path) { + return filepath.Clean(path), nil + } + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to resolve file path: %w", err) + } + return absPath, nil + } + + absWorkspace, err := filepath.Abs(workspace) + if err != nil { + return "", fmt.Errorf("failed to resolve workspace path: %w", err) + } + + var absPath string + if filepath.IsAbs(path) { + absPath = filepath.Clean(path) + } else { + absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) + if err != nil { + return "", fmt.Errorf("failed to resolve file path: %w", err) + } + } + + if restrict { + if !isWithinWorkspace(absPath, absWorkspace) { + return "", ErrOutsideWorkspace + } + + var resolved string + workspaceReal := absWorkspace + if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { + workspaceReal = resolved + } + + if resolved, err = filepath.EvalSymlinks(absPath); err == nil { + if !isWithinWorkspace(resolved, workspaceReal) { + return "", ErrOutsideWorkspace + } + } else if os.IsNotExist(err) { + var parentResolved string + if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { + if !isWithinWorkspace(parentResolved, workspaceReal) { + return "", fmt.Errorf("access denied: symlink resolves outside workspace") + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + } else { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + } + + return absPath, nil +} + +func resolveExistingAncestor(path string) (string, error) { + for current := filepath.Clean(path); ; current = filepath.Dir(current) { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + return resolved, nil + } else if !os.IsNotExist(err) { + return "", err + } + if filepath.Dir(current) == current { + return "", os.ErrNotExist + } + } +} + +func isWithinWorkspace(candidate, workspace string) bool { + rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) + return err == nil && filepath.IsLocal(rel) +} diff --git a/pkg/agent/sandbox/registry.go b/pkg/agent/sandbox/registry.go new file mode 100644 index 0000000000..f5b7b58202 --- /dev/null +++ b/pkg/agent/sandbox/registry.go @@ -0,0 +1,155 @@ +package sandbox + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +type registryEntry struct { + ContainerName string `json:"container_name"` + Image string `json:"image"` + ConfigHash string `json:"config_hash"` + CreatedAtMs int64 `json:"created_at_ms"` + LastUsedAtMs int64 `json:"last_used_at_ms"` +} + +type registryData struct { + Entries []registryEntry `json:"entries"` +} + +var registryMu sync.Mutex + +const registryLockTimeout = 3 * time.Second + +type registryFileLock struct { + path string +} + +func acquireRegistryFileLock(registryPath string) (*registryFileLock, error) { + lockPath := registryPath + ".lock" + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return nil, err + } + deadline := time.Now().Add(registryLockTimeout) + for { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + _ = f.Close() + return ®istryFileLock{path: lockPath}, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, err + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timeout acquiring registry lock: %s", lockPath) + } + time.Sleep(20 * time.Millisecond) + } +} + +func (l *registryFileLock) release() { + if l == nil || l.path == "" { + return + } + _ = os.Remove(l.path) +} + +func computeConfigHash(parts ...string) string { + h := sha256.New() + for _, p := range parts { + _, _ = h.Write([]byte(p)) + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +func loadRegistry(path string) (*registryData, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return ®istryData{Entries: []registryEntry{}}, nil + } + return nil, err + } + var data registryData + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + if data.Entries == nil { + data.Entries = []registryEntry{} + } + return &data, nil +} + +func saveRegistry(path string, data *registryData) error { + raw, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(path, append(raw, '\n'), 0o644) +} + +func upsertRegistryEntry(path string, entry registryEntry) error { + registryMu.Lock() + defer registryMu.Unlock() + lock, err := acquireRegistryFileLock(path) + if err != nil { + return err + } + defer lock.release() + + data, err := loadRegistry(path) + if err != nil { + return err + } + + replaced := false + for i := range data.Entries { + if data.Entries[i].ContainerName == entry.ContainerName { + createdAt := data.Entries[i].CreatedAtMs + if createdAt > 0 { + entry.CreatedAtMs = createdAt + } + data.Entries[i] = entry + replaced = true + break + } + } + if !replaced { + data.Entries = append(data.Entries, entry) + } + + return saveRegistry(path, data) +} + +func removeRegistryEntry(path, containerName string) error { + registryMu.Lock() + defer registryMu.Unlock() + lock, err := acquireRegistryFileLock(path) + if err != nil { + return err + } + defer lock.release() + + data, err := loadRegistry(path) + if err != nil { + return err + } + next := make([]registryEntry, 0, len(data.Entries)) + for _, e := range data.Entries { + if e.ContainerName != containerName { + next = append(next, e) + } + } + data.Entries = next + return saveRegistry(path, data) +} diff --git a/pkg/agent/sandbox/registry_test.go b/pkg/agent/sandbox/registry_test.go new file mode 100644 index 0000000000..c1305e02a1 --- /dev/null +++ b/pkg/agent/sandbox/registry_test.go @@ -0,0 +1,149 @@ +package sandbox + +import ( + "path/filepath" + "testing" + "time" +) + +func TestRegistryUpsertAndRemove(t *testing.T) { + path := filepath.Join(t.TempDir(), "sandbox", "registry.json") + now := time.Now().UnixMilli() + + err := upsertRegistryEntry(path, registryEntry{ + ContainerName: "c1", + Image: "img", + ConfigHash: "h1", + CreatedAtMs: now, + LastUsedAtMs: now, + }) + if err != nil { + t.Fatalf("upsertRegistryEntry create failed: %v", err) + } + + data, err := loadRegistry(path) + if err != nil { + t.Fatalf("loadRegistry failed: %v", err) + } + if len(data.Entries) != 1 { + t.Fatalf("entries len = %d, want 1", len(data.Entries)) + } + + err = upsertRegistryEntry(path, registryEntry{ + ContainerName: "c1", + Image: "img2", + ConfigHash: "h2", + CreatedAtMs: now + 1000, + LastUsedAtMs: now + 1000, + }) + if err != nil { + t.Fatalf("upsertRegistryEntry update failed: %v", err) + } + data, err = loadRegistry(path) + if err != nil { + t.Fatalf("loadRegistry failed: %v", err) + } + if len(data.Entries) != 1 { + t.Fatalf("entries len after update = %d, want 1", len(data.Entries)) + } + if data.Entries[0].ConfigHash != "h2" { + t.Fatalf("config hash = %q, want h2", data.Entries[0].ConfigHash) + } + if data.Entries[0].CreatedAtMs != now { + t.Fatalf("createdAt preserved = %d, want %d", data.Entries[0].CreatedAtMs, now) + } + + err = removeRegistryEntry(path, "c1") + if err != nil { + t.Fatalf("removeRegistryEntry failed: %v", err) + } + data, err = loadRegistry(path) + if err != nil { + t.Fatalf("loadRegistry failed: %v", err) + } + if len(data.Entries) != 0 { + t.Fatalf("entries len after remove = %d, want 0", len(data.Entries)) + } +} + +func TestComputeConfigHashDeterministic(t *testing.T) { + a := computeConfigHash("img", "/workspace") + b := computeConfigHash("img", "/workspace") + c := computeConfigHash("img2", "/workspace") + + if a != b { + t.Fatalf("same input hash mismatch: %q vs %q", a, b) + } + if a == c { + t.Fatalf("different input should produce different hash: %q", a) + } +} + +func TestShouldPruneEntry(t *testing.T) { + now := time.Now().UnixMilli() + cfg := ContainerSandboxConfig{ + PruneIdleHours: 1, + PruneMaxAgeDays: 2, + } + oldIdle := registryEntry{ + CreatedAtMs: now, + LastUsedAtMs: now - int64(2*time.Hour/time.Millisecond), + } + if !shouldPruneEntry(cfg, now, oldIdle) { + t.Fatal("expected old idle entry to be pruned") + } + + oldAge := registryEntry{ + CreatedAtMs: now - int64(3*24*time.Hour/time.Millisecond), + LastUsedAtMs: now, + } + if !shouldPruneEntry(cfg, now, oldAge) { + t.Fatal("expected old age entry to be pruned") + } + + fresh := registryEntry{ + CreatedAtMs: now, + LastUsedAtMs: now, + } + if shouldPruneEntry(cfg, now, fresh) { + t.Fatal("did not expect fresh entry to be pruned") + } +} + +func TestRegistryFileLock_AcquireRelease(t *testing.T) { + regPath := filepath.Join(t.TempDir(), "sandbox", "registry.json") + lock, err := acquireRegistryFileLock(regPath) + if err != nil { + t.Fatalf("acquireRegistryFileLock failed: %v", err) + } + lock.release() +} + +func TestRegistryFileLock_WaitsUntilReleased(t *testing.T) { + regPath := filepath.Join(t.TempDir(), "sandbox", "registry.json") + first, err := acquireRegistryFileLock(regPath) + if err != nil { + t.Fatalf("first lock failed: %v", err) + } + + done := make(chan error, 1) + go func() { + lock, err := acquireRegistryFileLock(regPath) + if err == nil && lock != nil { + lock.release() + } + done <- err + }() + + time.Sleep(80 * time.Millisecond) + first.release() + + select { + case err := <-done: + if err != nil { + t.Fatalf("second lock should succeed after release, got: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for second lock acquisition") + } +} diff --git a/pkg/agent/sandbox/sandbox.go b/pkg/agent/sandbox/sandbox.go new file mode 100644 index 0000000000..7e34a6c036 --- /dev/null +++ b/pkg/agent/sandbox/sandbox.go @@ -0,0 +1,198 @@ +package sandbox + +import ( + "context" + "os" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// Sandbox abstracts command execution and filesystem access. +type Sandbox interface { + // Start initializes sandbox runtime dependencies. + // Implementations should prepare resources that are expensive to set up lazily + // (for example, container client connectivity checks). + Start(ctx context.Context) error + // Prune performs sandbox resource reclamation. + // Implementations should release reclaimable runtime resources and remove + // sandbox artifacts (for example containers) according to their policy. + // It should be safe to call multiple times. + Prune(ctx context.Context) error + // Exec runs a command in sandbox context. + // Command/Args semantics follow ExecRequest; a non-zero exit code should be + // returned in ExecResult.ExitCode, while transport/runtime failures return error. + Exec(ctx context.Context, req ExecRequest) (*ExecResult, error) + // ExecStream runs a command and emits runtime events. + // Implementations should emit stdout/stderr chunks as they arrive and a final + // exit event when command execution completes. + ExecStream(ctx context.Context, req ExecRequest, onEvent func(ExecEvent) error) (*ExecResult, error) + // For host sandboxes, this is the absolute host path. + // For container sandboxes, this is typically "/workspace". + GetWorkspace(ctx context.Context) string + // Fs returns the sandbox-aware filesystem bridge. + Fs() FsBridge +} + +// Manager is implemented by the scoped sandbox manager. +// It resolves the specific Sandbox instance to use for the current execution context +// (e.g. based on session key / scope). Only the manager needs this; leaf sandboxes +// (HostSandbox, ContainerSandbox) implement Sandbox directly and are the resolved result. +type Manager interface { + Sandbox + // Resolve returns the concrete Sandbox instance to use for the given context. + Resolve(ctx context.Context) (Sandbox, error) +} + +// ExecRequest describes a command execution request for Sandbox.Exec. +type ExecRequest struct { + // Command is the program or shell command to execute. + Command string + // Args are optional argv items; when empty, implementations may execute + // Command through a shell. + Args []string + // WorkingDir is an optional path scoped to the sandbox workspace. + WorkingDir string + // TimeoutMs is an optional timeout in milliseconds; 0 means implementation default. + TimeoutMs int64 +} + +// ExecResult is the normalized result returned by Sandbox.Exec. +type ExecResult struct { + Stdout string + Stderr string + ExitCode int +} + +// ExecEventType identifies the class of event emitted by Sandbox.ExecStream. +type ExecEventType string + +const ( + ExecEventStdout ExecEventType = "stdout" + ExecEventStderr ExecEventType = "stderr" + ExecEventExit ExecEventType = "exit" +) + +// ExecEvent is the streaming payload emitted during Sandbox.ExecStream. +type ExecEvent struct { + Type ExecEventType + Chunk []byte + ExitCode int +} + +type sessionContextKey struct{} + +// WithSessionKey returns a derived context carrying the current routing session key. +func WithSessionKey(ctx context.Context, sessionKey string) context.Context { + return context.WithValue(ctx, sessionContextKey{}, sessionKey) +} + +// SessionKeyFromContext returns the session key attached by WithSessionKey. +func SessionKeyFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + v, _ := ctx.Value(sessionContextKey{}).(string) + return v +} + +type sandboxContextKey struct{} + +// WithSandbox returns a derived context carrying the current sandbox instance. +func WithSandbox(ctx context.Context, sb Sandbox) context.Context { + return context.WithValue(ctx, sandboxContextKey{}, sb) +} + +// FromContext returns the sandbox instance attached by WithSandbox. +// If no pre-resolved sandbox exists, it attempts to resolve one via the Manager in context. +func FromContext(ctx context.Context) Sandbox { + if ctx == nil { + return nil + } + // 1. Try pre-resolved sandbox + if v, ok := ctx.Value(sandboxContextKey{}).(Sandbox); ok && v != nil { + return v + } + // 2. Try on-demand resolution via Manager + if m := managerFromContext(ctx); m != nil { + sb, err := m.Resolve(ctx) + if err != nil { + logger.WarnCF("sandbox", "FromContext: manager.Resolve failed", map[string]any{"error": err.Error()}) + } else if sb != nil { + return sb + } + } + return nil +} + +type managerContextKey struct{} + +// WithManager returns a derived context carrying the sandbox manager. +func WithManager(ctx context.Context, m Manager) context.Context { + return context.WithValue(ctx, managerContextKey{}, m) +} + +// managerFromContext returns the manager attached by WithManager. +func managerFromContext(ctx context.Context) Manager { + if ctx == nil { + return nil + } + v, _ := ctx.Value(managerContextKey{}).(Manager) + return v +} + +// FsBridge abstracts sandbox-scoped file I/O. +type FsBridge interface { + // ReadFile reads a file from sandbox-visible filesystem. + ReadFile(ctx context.Context, path string) ([]byte, error) + // WriteFile writes data to a sandbox-visible path. + // When mkdir is true, missing parent directories should be created. + WriteFile(ctx context.Context, path string, data []byte, mkdir bool) error + // ReadDir reads the named directory and returns a list of directory entries. + ReadDir(ctx context.Context, path string) ([]os.DirEntry, error) +} + +func aggregateExecStream(execFn func(onEvent func(ExecEvent) error) (*ExecResult, error)) (*ExecResult, error) { + var stdoutBuilder strings.Builder + var stderrBuilder strings.Builder + exitCode := 0 + + res, err := execFn(func(event ExecEvent) error { + switch event.Type { + case ExecEventStdout: + _, _ = stdoutBuilder.Write(event.Chunk) + case ExecEventStderr: + _, _ = stderrBuilder.Write(event.Chunk) + case ExecEventExit: + exitCode = event.ExitCode + } + return nil + }) + if err != nil { + return nil, err + } + + out := &ExecResult{ + Stdout: stdoutBuilder.String(), + Stderr: stderrBuilder.String(), + ExitCode: exitCode, + } + if res == nil { + return out, nil + } + // Streaming output takes priority; only fall back to res if the streaming + // path produced no data at all (e.g. the implementation does not emit events). + if out.Stdout == "" && res.Stdout != "" { + out.Stdout = res.Stdout + } + if out.Stderr == "" && res.Stderr != "" { + out.Stderr = res.Stderr + } + // Prefer the streaming exit code captured from ExecEventExit; fall back to + // res.ExitCode only when no exit event was received (exitCode stayed at 0) + // and res carries a non-zero code. + if exitCode == 0 && res.ExitCode != 0 { + out.ExitCode = res.ExitCode + } + return out, nil +} diff --git a/pkg/agent/sandbox/sandbox_test.go b/pkg/agent/sandbox/sandbox_test.go new file mode 100644 index 0000000000..bc8ecbb3fb --- /dev/null +++ b/pkg/agent/sandbox/sandbox_test.go @@ -0,0 +1,50 @@ +package sandbox + +import ( + "context" + "testing" +) + +func TestContextHelpers(t *testing.T) { + // Test SessionKey + ctx := context.Background() + if got := SessionKeyFromContext(ctx); got != "" { + t.Fatalf("expected empty session key, got %q", got) + } + + ctx = WithSessionKey(ctx, "session-123") + if got := SessionKeyFromContext(ctx); got != "session-123" { + t.Fatalf("expected session-123, got %q", got) + } + + // Test nil contexts + if got := SessionKeyFromContext(nil); got != "" { //nolint:staticcheck + t.Fatalf("SessionKeyFromContext(nil) = %q, want empty", got) + } + if got := FromContext(nil); got != nil { //nolint:staticcheck + t.Fatalf("FromContext(nil) = %v, want nil", got) + } + if got := managerFromContext(nil); got != nil { //nolint:staticcheck + t.Fatalf("managerFromContext(nil) = %v, want nil", got) + } + + // Test Sandbox context + mockSb := &unavailableSandboxManager{} + ctx = WithSandbox(context.Background(), mockSb) + if got := FromContext(ctx); got != mockSb { + t.Fatalf("expected to retrieve mock sandbox from context") + } + + // Test Manager context resolving + mockMgr := NewUnavailableSandboxManager(nil) + ctx = WithManager(context.Background(), mockMgr) + + if got := managerFromContext(ctx); got != mockMgr { + t.Fatalf("expected to retrieve mock manager from context") + } + + // FromContext with Manager only should attempt to Resolve (which returns error/nil here) + if got := FromContext(ctx); got != nil { + t.Fatalf("expected nil from FromContext when Resolve fails, got %v", got) + } +} diff --git a/pkg/agent/sandbox/security.go b/pkg/agent/sandbox/security.go new file mode 100644 index 0000000000..26c9359c86 --- /dev/null +++ b/pkg/agent/sandbox/security.go @@ -0,0 +1,234 @@ +package sandbox + +import ( + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +var blockedHostPaths = []string{ + "/boot", + "/dev", + "/etc", + "/private/etc", + "/private/var/run", + "/private/var/run/docker.sock", + "/proc", + "/root", + "/run", + "/run/containerd", + "/run/crio", + "/run/docker.sock", + "/run/podman", + "/run/user", + "/sys", + "/tmp/podman.sock", + "/var/run", + "/var/run/containerd", + "/var/run/crio", + "/var/run/docker.sock", + "/xdg_runtime_dir", +} + +var blockedHostPathSuffixes = []string{ + "/.docker/run/docker.sock", + "/.docker/desktop/docker.sock", + "/.colima/default/docker.sock", + "/.rd/docker.sock", +} + +var blockedEnvVarPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^ANTHROPIC_API_KEY$`), + regexp.MustCompile(`(?i)^OPENAI_API_KEY$`), + regexp.MustCompile(`(?i)^GEMINI_API_KEY$`), + regexp.MustCompile(`(?i)^OPENROUTER_API_KEY$`), + regexp.MustCompile(`(?i)^MINIMAX_API_KEY$`), + regexp.MustCompile(`(?i)^ELEVENLABS_API_KEY$`), + regexp.MustCompile(`(?i)^SYNTHETIC_API_KEY$`), + regexp.MustCompile(`(?i)^TELEGRAM_BOT_TOKEN$`), + regexp.MustCompile(`(?i)^DISCORD_BOT_TOKEN$`), + regexp.MustCompile(`(?i)^SLACK_(BOT|APP)_TOKEN$`), + regexp.MustCompile(`(?i)^LINE_CHANNEL_SECRET$`), + regexp.MustCompile(`(?i)^LINE_CHANNEL_ACCESS_TOKEN$`), + regexp.MustCompile(`(?i)^OPENCLAW_GATEWAY_(TOKEN|PASSWORD)$`), + regexp.MustCompile(`(?i)^AWS_(SECRET_ACCESS_KEY|SECRET_KEY|SESSION_TOKEN)$`), + regexp.MustCompile(`(?i)^(GH|GITHUB)_TOKEN$`), + regexp.MustCompile(`(?i)^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$`), + regexp.MustCompile(`(?i)_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$`), +} + +func validateSandboxSecurity(cfg ContainerSandboxConfig) error { + if err := validateBindMounts(cfg.Binds); err != nil { + return err + } + if err := validateNetworkMode(cfg.Network); err != nil { + return err + } + if err := validateSeccompProfile(cfg.SeccompProfile); err != nil { + return err + } + if err := validateApparmorProfile(cfg.ApparmorProfile); err != nil { + return err + } + return nil +} + +func validateBindMounts(binds []string) error { + for _, raw := range binds { + bind := strings.TrimSpace(raw) + if bind == "" { + continue + } + source := parseBindSourcePath(bind) + if !strings.HasPrefix(source, "/") { + return fmt.Errorf("sandbox security: bind mount %q uses a non-absolute source path %q", bind, source) + } + normalized := normalizeHostPath(source) + if err := validateBindSourcePath(bind, normalized); err != nil { + return err + } + if resolvedPath := tryRealpathAbsolute(normalized); resolvedPath != normalized { + if err := validateBindSourcePath(bind, resolvedPath); err != nil { + return err + } + } + } + return nil +} + +func validateBindSourcePath(bind, source string) error { + if source == "/" { + return fmt.Errorf("sandbox security: bind mount %q covers blocked path %q", bind, "/") + } + for _, blocked := range blockedHostPaths { + if source == blocked || strings.HasPrefix(source, blocked+"/") { + return fmt.Errorf("sandbox security: bind mount %q targets blocked path %q", bind, blocked) + } + } + for _, suffix := range blockedHostPathSuffixes { + if source == suffix || strings.HasSuffix(source, suffix) { + return fmt.Errorf("sandbox security: bind mount %q targets blocked path suffix %q", bind, suffix) + } + } + isSocket, err := isUnixSocketPath(source) + if err != nil { + return fmt.Errorf("sandbox security: bind mount %q source %q cannot be validated: %w", bind, source, err) + } + if isSocket { + return fmt.Errorf("sandbox security: bind mount %q targets unix socket %q", bind, source) + } + return nil +} + +func parseBindSourcePath(bind string) string { + trimmed := strings.TrimSpace(bind) + idx := strings.Index(trimmed, ":") + if idx <= 0 { + return trimmed + } + return trimmed[:idx] +} + +func normalizeHostPath(raw string) string { + normalized := path.Clean(strings.TrimSpace(raw)) + if normalized == "." || normalized == "" { + return "/" + } + if normalized != "/" { + normalized = strings.TrimRight(normalized, "/") + if normalized == "" { + return "/" + } + } + return normalized +} + +func tryRealpathAbsolute(p string) string { + if !strings.HasPrefix(p, "/") { + return p + } + if _, err := os.Stat(p); err != nil { + return p + } + resolved, err := filepathEvalSymlinks(p) + if err != nil { + return p + } + return normalizeHostPath(resolved) +} + +func validateNetworkMode(network string) error { + if strings.EqualFold(strings.TrimSpace(network), "host") { + return fmt.Errorf("sandbox security: network mode %q is blocked", network) + } + return nil +} + +func validateSeccompProfile(profile string) error { + if strings.EqualFold(strings.TrimSpace(profile), "unconfined") { + return fmt.Errorf("sandbox security: seccomp profile %q is blocked", profile) + } + return nil +} + +func validateApparmorProfile(profile string) error { + if strings.EqualFold(strings.TrimSpace(profile), "unconfined") { + return fmt.Errorf("sandbox security: apparmor profile %q is blocked", profile) + } + return nil +} + +func sanitizeEnvVars(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for rawKey, value := range in { + key := strings.TrimSpace(rawKey) + if key == "" { + continue + } + if isBlockedEnvVarKey(key) { + continue + } + if strings.Contains(value, "\x00") { + continue + } + out[key] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func isBlockedEnvVarKey(key string) bool { + for _, pattern := range blockedEnvVarPatterns { + if pattern.MatchString(key) { + return true + } + } + return false +} + +var filepathEvalSymlinks = func(path string) (string, error) { + return filepath.EvalSymlinks(path) +} + +var osLstat = func(path string) (os.FileInfo, error) { + return os.Lstat(path) +} + +func isUnixSocketPath(p string) (bool, error) { + fi, err := osLstat(p) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return fi.Mode()&os.ModeSocket != 0, nil +} diff --git a/pkg/agent/sandbox/security_test.go b/pkg/agent/sandbox/security_test.go new file mode 100644 index 0000000000..6a22b56398 --- /dev/null +++ b/pkg/agent/sandbox/security_test.go @@ -0,0 +1,247 @@ +package sandbox + +import ( + "net" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateBindMounts_BlocksDangerousPath(t *testing.T) { + err := validateBindMounts([]string{"/etc/passwd:/mnt/passwd:ro"}) + if err == nil { + t.Fatal("expected blocked bind path error") + } + if !strings.Contains(err.Error(), "blocked path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateBindMounts_BlocksAdditionalRuntimePaths(t *testing.T) { + err := validateBindMounts([]string{"/run/containerd/containerd.sock:/mnt/runtime.sock:ro"}) + if err == nil { + t.Fatal("expected blocked runtime bind path error") + } + if !strings.Contains(err.Error(), "blocked path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateBindMounts_BlocksDangerousSocketSuffixes(t *testing.T) { + err := validateBindMounts([]string{"/home/user/.docker/run/docker.sock:/mnt/docker.sock:ro"}) + if err == nil { + t.Fatal("expected blocked dangerous socket suffix error") + } + if !strings.Contains(err.Error(), "blocked path suffix") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateBindMounts_BlocksNonAbsoluteSource(t *testing.T) { + err := validateBindMounts([]string{"myvol:/mnt"}) + if err == nil { + t.Fatal("expected non-absolute bind error") + } + if !strings.Contains(err.Error(), "non-absolute") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateBindMounts_AllowsProjectPath(t *testing.T) { + if err := validateBindMounts([]string{"/home/user/project:/workspace:rw"}); err != nil { + t.Fatalf("expected bind to pass, got %v", err) + } +} + +func TestValidateBindMounts_BlocksUnixSocketSource(t *testing.T) { + tmpDir := t.TempDir() + socketPath := filepath.Join(tmpDir, "agent.sock") + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + defer ln.Close() + + err = validateBindMounts([]string{socketPath + ":/workspace/agent.sock:ro"}) + if err == nil { + t.Fatal("expected unix socket source to be blocked") + } + if !strings.Contains(err.Error(), "unix socket") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateBindMounts_BlocksSymlinkToBlockedPath(t *testing.T) { + tmpDir := t.TempDir() + link := filepath.Join(tmpDir, "etc-link") + if err := os.Symlink("/etc", link); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + err := validateBindMounts([]string{link + ":/workspace/etc:ro"}) + if err == nil { + t.Fatal("expected symlink-resolved blocked path error") + } + if !strings.Contains(err.Error(), "blocked path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateNetworkMode_BlocksHost(t *testing.T) { + if err := validateNetworkMode("HOST"); err == nil { + t.Fatal("expected host network mode to be blocked") + } +} + +func TestValidateProfiles_BlockUnconfined(t *testing.T) { + if err := validateSeccompProfile("Unconfined"); err == nil { + t.Fatal("expected seccomp unconfined to be blocked") + } + if err := validateApparmorProfile("unconfined"); err == nil { + t.Fatal("expected apparmor unconfined to be blocked") + } +} + +func TestSanitizeEnvVars_BlocksSensitiveKeys(t *testing.T) { + in := map[string]string{ + "LANG": "C.UTF-8", + "OPENAI_API_KEY": "secret", + "GITHUB_TOKEN": "secret2", + "SAFE_NAME": "ok", + "NULLY": "a\x00b", + } + got := sanitizeEnvVars(in) + if got["LANG"] != "C.UTF-8" { + t.Fatalf("LANG should be kept, got %q", got["LANG"]) + } + if got["SAFE_NAME"] != "ok" { + t.Fatalf("SAFE_NAME should be kept, got %q", got["SAFE_NAME"]) + } + if _, ok := got["OPENAI_API_KEY"]; ok { + t.Fatal("OPENAI_API_KEY should be blocked") + } + if _, ok := got["GITHUB_TOKEN"]; ok { + t.Fatal("GITHUB_TOKEN should be blocked") + } + if _, ok := got["NULLY"]; ok { + t.Fatal("NULLY should be blocked due to null byte") + } +} + +func TestValidateSandboxSecurity_AllowsSafeConfig(t *testing.T) { + cfg := ContainerSandboxConfig{ + Binds: []string{"/tmp:/workspace:rw"}, + Network: "none", + SeccompProfile: "default", + ApparmorProfile: "docker-default", + } + if err := validateSandboxSecurity(cfg); err != nil { + t.Fatalf("validateSandboxSecurity() error: %v", err) + } +} + +func TestValidateSandboxSecurity_ReturnsFirstPolicyError(t *testing.T) { + if err := validateSandboxSecurity(ContainerSandboxConfig{ + Network: "host", + }); err == nil { + t.Fatal("expected network policy error") + } + + if err := validateSandboxSecurity(ContainerSandboxConfig{ + SeccompProfile: "unconfined", + }); err == nil { + t.Fatal("expected seccomp policy error") + } + + if err := validateSandboxSecurity(ContainerSandboxConfig{ + ApparmorProfile: "unconfined", + }); err == nil { + t.Fatal("expected apparmor policy error") + } +} + +func TestParseAndNormalizeHelpers(t *testing.T) { + if got := parseBindSourcePath("/a:/b:ro"); got != "/a" { + t.Fatalf("parseBindSourcePath() got %q, want /a", got) + } + if got := parseBindSourcePath("just-source"); got != "just-source" { + t.Fatalf("parseBindSourcePath() got %q", got) + } + + if got := normalizeHostPath(" "); got != "/" { + t.Fatalf("normalizeHostPath(empty) got %q, want /", got) + } + if got := normalizeHostPath("/tmp///a/"); got != "/tmp/a" { + t.Fatalf("normalizeHostPath() got %q, want /tmp/a", got) + } +} + +func TestTryRealpathAbsolute_Branches(t *testing.T) { + if got := tryRealpathAbsolute("relative/path"); got != "relative/path" { + t.Fatalf("tryRealpathAbsolute(relative) got %q", got) + } + + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink create: %v", err) + } + + old := filepathEvalSymlinks + oldLstat := osLstat + t.Cleanup(func() { filepathEvalSymlinks = old }) + t.Cleanup(func() { osLstat = oldLstat }) + + filepathEvalSymlinks = old + if got := tryRealpathAbsolute(link); got == link { + t.Fatalf("tryRealpathAbsolute(existing symlink) should resolve, got %q", got) + } + + filepathEvalSymlinks = func(path string) (string, error) { return "", os.ErrPermission } + if got := tryRealpathAbsolute(link); got != normalizeHostPath(link) { + t.Fatalf("tryRealpathAbsolute(eval error) got %q", got) + } + + nonexistent := filepath.Join(root, "does-not-exist") + if got := tryRealpathAbsolute(nonexistent); got != nonexistent { + t.Fatalf("tryRealpathAbsolute(nonexistent) got %q", got) + } +} + +func TestIsUnixSocketPath_Branches(t *testing.T) { + tmpDir := t.TempDir() + socketPath := filepath.Join(tmpDir, "s.sock") + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + defer ln.Close() + + ok, err := isUnixSocketPath(socketPath) + if err != nil { + t.Fatalf("isUnixSocketPath(socket) error: %v", err) + } + if !ok { + t.Fatal("expected socket path to be detected") + } + + ok, err = isUnixSocketPath(filepath.Join(tmpDir, "missing.sock")) + if err != nil { + t.Fatalf("isUnixSocketPath(missing) error: %v", err) + } + if ok { + t.Fatal("missing path should not be detected as socket") + } + + old := osLstat + t.Cleanup(func() { osLstat = old }) + osLstat = func(path string) (os.FileInfo, error) { return nil, os.ErrPermission } + if _, err := isUnixSocketPath(socketPath); err == nil { + t.Fatal("expected lstat permission error to be returned") + } +} diff --git a/pkg/agent/sandbox/sync.go b/pkg/agent/sandbox/sync.go new file mode 100644 index 0000000000..bbf21cab53 --- /dev/null +++ b/pkg/agent/sandbox/sync.go @@ -0,0 +1,126 @@ +package sandbox + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +var defaultSeedFiles = []string{ + "AGENTS.md", + "MEMORY.md", + "IDENTITY.md", + "TOOLS.md", + "SOUL.md", + "BOOTSTRAP.md", + "USER.md", +} + +// syncAgentWorkspace copies base agent files and the skills directory +// from the agentWorkspace to the isolated container workspace. +func syncAgentWorkspace(agentWorkspace, containerWorkspace string) error { + if agentWorkspace == "" || containerWorkspace == "" { + return nil + } + + // 1. Seed base agent files + for _, file := range defaultSeedFiles { + src := filepath.Join(agentWorkspace, file) + dst := filepath.Join(containerWorkspace, file) + + // Check if source exists + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + continue + } + logger.WarnCF("sandbox", "failed to stat seed source file", map[string]any{"file": src, "error": err}) + continue + } + + // Check if destination already exists. If yes, preserve it. + if _, err := os.Stat(dst); err == nil { + continue // preserved + } else if !os.IsNotExist(err) { + logger.WarnCF("sandbox", "failed to stat seed destination file", map[string]any{"file": dst, "error": err}) + continue + } + + if err := copyFile(src, dst); err != nil { + logger.WarnCF("sandbox", "failed to seed file", map[string]any{"file": file, "error": err}) + } + } + + // 2. Sync skills directory (complete overwrite) + skillsSrc := filepath.Join(agentWorkspace, "skills") + skillsDst := filepath.Join(containerWorkspace, "skills") + + if _, err := os.Stat(skillsSrc); err == nil { + // Remove existing destination to ensure clean sync + _ = os.RemoveAll(skillsDst) + if errCopy := copyDir(skillsSrc, skillsDst); errCopy != nil { + return fmt.Errorf("failed to sync skills directory: %w", errCopy) + } + } else if !os.IsNotExist(err) { + logger.WarnCF( + "sandbox", + "failed to stat skills source directory", + map[string]any{"dir": skillsSrc, "error": err}, + ) + } + + return nil +} + +// copyFile copies a single file from src to dst. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return err + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} + +// copyDir recursively copies a directory tree, creating directories and copying files. +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + targetPath := filepath.Join(dst, relPath) + + if d.IsDir() { + info, err := d.Info() + if err != nil { + return err + } + return os.MkdirAll(targetPath, info.Mode()) + } + + return copyFile(path, targetPath) + }) +} diff --git a/pkg/agent/sandbox/tool_policy.go b/pkg/agent/sandbox/tool_policy.go new file mode 100644 index 0000000000..4651d9d0a3 --- /dev/null +++ b/pkg/agent/sandbox/tool_policy.go @@ -0,0 +1,41 @@ +package sandbox + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +var defaultSandboxAllow = []string{"exec", "read_file", "write_file", "list_dir", "edit_file", "append_file"} + +func IsToolSandboxEnabled(cfg *config.Config, tool string) bool { + name := strings.ToLower(strings.TrimSpace(tool)) + if name == "" { + return false + } + + allow := defaultSandboxAllow + deny := []string{} + if cfg != nil { + allow = cfg.Tools.Sandbox.Tools.Allow + deny = cfg.Tools.Sandbox.Tools.Deny + } + + if containsTool(deny, name) { + return false + } + if len(allow) == 0 { + // Empty allow list falls back to built-in defaults. + allow = defaultSandboxAllow + } + return containsTool(allow, name) +} + +func containsTool(list []string, tool string) bool { + for _, v := range list { + if strings.EqualFold(strings.TrimSpace(v), tool) { + return true + } + } + return false +} diff --git a/pkg/agent/sandbox/tool_policy_test.go b/pkg/agent/sandbox/tool_policy_test.go new file mode 100644 index 0000000000..e1dddd9167 --- /dev/null +++ b/pkg/agent/sandbox/tool_policy_test.go @@ -0,0 +1,50 @@ +package sandbox + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestIsToolSandboxEnabled_Default(t *testing.T) { + if !IsToolSandboxEnabled(nil, "exec") { + t.Fatal("expected exec to be sandbox-enabled by default") + } + if !IsToolSandboxEnabled(nil, "list_dir") { + t.Fatal("expected list_dir to be sandbox-enabled by default") + } +} + +func TestIsToolSandboxEnabled_AllowDeny(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Sandbox.Tools.Allow = []string{"exec", "write_file"} + cfg.Tools.Sandbox.Tools.Deny = []string{"write_file"} + + if !IsToolSandboxEnabled(cfg, "exec") { + t.Fatal("expected exec to be enabled") + } + if IsToolSandboxEnabled(cfg, "read_file") { + t.Fatal("expected read_file to be disabled by allow list") + } + if IsToolSandboxEnabled(cfg, "write_file") { + t.Fatal("expected deny to override allow") + } +} + +// TestIsToolSandboxEnabled_EmptyAllowUsesDefault verifies that an explicitly +// empty allow list falls back to built-in defaults. +func TestIsToolSandboxEnabled_EmptyAllowUsesDefault(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Sandbox.Tools.Allow = []string{} + cfg.Tools.Sandbox.Tools.Deny = []string{"cron"} + + for _, tool := range []string{"exec", "read_file", "write_file", "list_dir", "edit_file", "append_file"} { + if !IsToolSandboxEnabled(cfg, tool) { + t.Fatalf("expected %s to use default allow list when allow is empty", tool) + } + } + + if IsToolSandboxEnabled(cfg, "cron") { + t.Fatal("expected denied tool to be disabled") + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b3ad050b75..9639a9e906 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -180,22 +180,23 @@ type RoutingConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` - SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` - MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` - Routing *RoutingConfig `json:"routing,omitempty"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` + SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` + MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` + Routing *RoutingConfig `json:"routing,omitempty"` + Sandbox AgentSandboxConfig `json:"sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB @@ -644,7 +645,127 @@ type ExecConfig struct { EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` - TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) + TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` +} + +type AgentSandboxPruneConfig struct { + // IdleHours: prune containers idle for this many hours. nil = use default (24). + // Set to 0 to disable idle-based pruning. + IdleHours *int `json:"idle_hours" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_PRUNE_IDLE_HOURS"` + // MaxAgeDays: prune containers older than this many days. nil = use default (7). + // Set to 0 to disable age-based pruning. + MaxAgeDays *int `json:"max_age_days" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_PRUNE_MAX_AGE_DAYS"` +} + +type AgentSandboxDockerUlimitValue struct { + Value *int64 `json:"-"` + Soft *int64 `json:"soft,omitempty"` + Hard *int64 `json:"hard,omitempty"` +} + +func (v *AgentSandboxDockerUlimitValue) UnmarshalJSON(data []byte) error { + var num int64 + if err := json.Unmarshal(data, &num); err == nil { + v.Value = &num + v.Soft = nil + v.Hard = nil + return nil + } + + type raw struct { + Soft *int64 `json:"soft"` + Hard *int64 `json:"hard"` + } + var r raw + if err := json.Unmarshal(data, &r); err != nil { + return err + } + v.Value = nil + v.Soft = r.Soft + v.Hard = r.Hard + return nil +} + +func (v AgentSandboxDockerUlimitValue) MarshalJSON() ([]byte, error) { + if v.Value != nil { + return json.Marshal(*v.Value) + } + type raw struct { + Soft *int64 `json:"soft,omitempty"` + Hard *int64 `json:"hard,omitempty"` + } + return json.Marshal(raw{ + Soft: v.Soft, + Hard: v.Hard, + }) +} + +type AgentSandboxDockerConfig struct { + Image string `json:"image" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_IMAGE"` + ContainerPrefix string `json:"container_prefix" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_CONTAINER_PREFIX"` + Workdir string `json:"workdir" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_WORKDIR"` + ReadOnlyRoot bool `json:"read_only_root" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_READ_ONLY_ROOT"` + Tmpfs []string `json:"tmpfs" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_TMPFS"` + Network string `json:"network" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_NETWORK"` + User string `json:"user" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_USER"` + CapDrop []string `json:"cap_drop" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_CAP_DROP"` + Env map[string]string `json:"env" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_ENV"` + SetupCommand string `json:"setup_command" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_SETUP_COMMAND"` + PidsLimit int64 `json:"pids_limit" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_PIDS_LIMIT"` + Memory string `json:"memory" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_MEMORY"` + MemorySwap string `json:"memory_swap" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_MEMORY_SWAP"` + Cpus float64 `json:"cpus" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_CPUS"` + Ulimits map[string]AgentSandboxDockerUlimitValue `json:"ulimits" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_ULIMITS"` + SeccompProfile string `json:"seccomp_profile" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_SECCOMP_PROFILE"` + ApparmorProfile string `json:"apparmor_profile" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_APPARMOR_PROFILE"` + DNS []string `json:"dns" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_DNS"` + ExtraHosts []string `json:"extra_hosts" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_EXTRA_HOSTS"` + Binds []string `json:"binds" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_BINDS"` +} + +// SandboxMode defines the operational mode of the agent sandbox. +type SandboxMode string + +const ( + SandboxModeOff SandboxMode = "off" // Sandbox disabled (host execution) + SandboxModeNonMain SandboxMode = "non-main" // Sandbox all sessions except main + SandboxModeAll SandboxMode = "all" // Sandbox all sessions +) + +// SandboxScope defines the isolation scope of the sandbox container. +type SandboxScope string + +const ( + SandboxScopeSession SandboxScope = "session" // One container per session + SandboxScopeAgent SandboxScope = "agent" // One container per agent (shared across sessions) + SandboxScopeShared SandboxScope = "shared" // One container shared by all agents +) + +// WorkspaceAccess defines how the agent workspace is exposed to the sandbox. +type WorkspaceAccess string + +const ( + WorkspaceAccessNone WorkspaceAccess = "none" // No workspace access + WorkspaceAccessRO WorkspaceAccess = "ro" // Read-only access + WorkspaceAccessRW WorkspaceAccess = "rw" // Read-write access +) + +type AgentSandboxConfig struct { + Mode SandboxMode `json:"mode" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_MODE"` + Scope SandboxScope `json:"scope" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_SCOPE"` + WorkspaceAccess WorkspaceAccess `json:"workspace_access" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_WORKSPACE_ACCESS"` + WorkspaceRoot string `json:"workspace_root" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_WORKSPACE_ROOT"` + Docker AgentSandboxDockerConfig `json:"docker" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER"` + Prune AgentSandboxPruneConfig `json:"prune" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_PRUNE"` +} + +type SandboxToolPolicyConfig struct { + Allow []string `json:"allow" env:"PICOCLAW_TOOLS_SANDBOX_TOOLS_ALLOW"` + Deny []string `json:"deny" env:"PICOCLAW_TOOLS_SANDBOX_TOOLS_DENY"` +} + +type SandboxToolsConfig struct { + Tools SandboxToolPolicyConfig `json:"tools" env:"PICOCLAW_TOOLS_SANDBOX_TOOLS"` } type SkillsToolsConfig struct { @@ -656,8 +777,8 @@ type SkillsToolsConfig struct { type MediaCleanupConfig struct { ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"` - MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"` - Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` + MaxAge int ` json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` + Interval int ` json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` } type ToolsConfig struct { @@ -666,6 +787,7 @@ type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` + Sandbox SandboxToolsConfig `json:"sandbox"` Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` @@ -961,3 +1083,10 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return true } } + +// IntPtr is a convenience helper that returns a pointer to the provided int value. +func IntPtr(v int) *int { + return &v +} + +func intPtr(v int) *int { return IntPtr(v) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 47f79c6f0c..59708e309e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -189,6 +189,43 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { } } +func TestAgentSandboxConfig_ParseWorkspaceAccessRoot(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "sandbox": { + "mode": "all", + "scope": "session", + "workspace_access": "ro", + "workspace_root": "~/.picoclaw/sandboxes", + "docker": { + "image": "debian:bookworm-slim", + "workdir": "/workspace" + } + } + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if cfg.Agents.Defaults.Sandbox.Mode != "all" { + t.Fatalf("sandbox.mode = %q, want all", cfg.Agents.Defaults.Sandbox.Mode) + } + if cfg.Agents.Defaults.Sandbox.Scope != "session" { + t.Fatalf("sandbox.scope = %q, want session", cfg.Agents.Defaults.Sandbox.Scope) + } + if cfg.Agents.Defaults.Sandbox.WorkspaceAccess != "ro" { + t.Fatalf("sandbox.workspace_access = %q, want ro", cfg.Agents.Defaults.Sandbox.WorkspaceAccess) + } + if cfg.Agents.Defaults.Sandbox.WorkspaceRoot != "~/.picoclaw/sandboxes" { + t.Fatalf("sandbox.workspace_root = %q, want ~/.picoclaw/sandboxes", cfg.Agents.Defaults.Sandbox.WorkspaceRoot) + } +} + // TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default func TestDefaultConfig_HeartbeatEnabled(t *testing.T) { cfg := DefaultConfig() @@ -304,6 +341,68 @@ func TestDefaultConfig_WebTools(t *testing.T) { } } +func TestDefaultConfig_SandboxTools(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.Sandbox.Mode != "off" { + t.Fatalf("Expected sandbox mode off, got %q", cfg.Agents.Defaults.Sandbox.Mode) + } + if cfg.Agents.Defaults.Sandbox.Scope != "agent" { + t.Fatalf("Expected sandbox scope agent, got %q", cfg.Agents.Defaults.Sandbox.Scope) + } + if cfg.Agents.Defaults.Sandbox.WorkspaceAccess != "none" { + t.Fatalf("Expected sandbox workspace_access none, got %q", cfg.Agents.Defaults.Sandbox.WorkspaceAccess) + } + if cfg.Agents.Defaults.Sandbox.WorkspaceRoot == "" { + t.Fatal("Expected sandbox workspace_root to be configured") + } + if cfg.Agents.Defaults.Sandbox.Docker.Image == "" { + t.Fatal("Expected sandbox image to be configured") + } + if cfg.Agents.Defaults.Sandbox.Docker.ContainerPrefix == "" { + t.Fatal("Expected sandbox container prefix to be configured") + } + if cfg.Agents.Defaults.Sandbox.Docker.Workdir == "" { + t.Fatal("Expected sandbox workdir to be configured") + } + if !cfg.Agents.Defaults.Sandbox.Docker.ReadOnlyRoot { + t.Fatal("Expected sandbox read_only_root default to true") + } + if cfg.Agents.Defaults.Sandbox.Docker.Env["LANG"] == "" { + t.Fatal("Expected sandbox docker env LANG to be configured") + } + if len(cfg.Agents.Defaults.Sandbox.Docker.CapDrop) == 0 { + t.Fatal("Expected sandbox cap_drop to be configured") + } + if len(cfg.Agents.Defaults.Sandbox.Docker.Ulimits) != 0 { + t.Fatal("Expected sandbox docker ulimits to be empty by default (use Docker defaults)") + } + if len(cfg.Tools.Sandbox.Tools.Allow) == 0 { + t.Fatal("Expected sandbox allow tools to be configured") + } + for _, tool := range []string{"exec", "read_file", "write_file", "list_dir", "edit_file", "append_file"} { + found := false + for _, v := range cfg.Tools.Sandbox.Tools.Allow { + if v == tool { + found = true + break + } + } + if !found { + t.Fatalf("Expected sandbox allow tools to include %q, got %v", tool, cfg.Tools.Sandbox.Tools.Allow) + } + } + if cfg.Agents.Defaults.Sandbox.Prune.IdleHours == nil || *cfg.Agents.Defaults.Sandbox.Prune.IdleHours <= 0 { + t.Fatal("Expected sandbox prune idle hours > 0") + } + if cfg.Agents.Defaults.Sandbox.Prune.MaxAgeDays == nil || *cfg.Agents.Defaults.Sandbox.Prune.MaxAgeDays <= 0 { + t.Fatal("Expected sandbox prune max age days > 0") + } + if cfg.Tools.Sandbox.Tools.Deny == nil { + t.Fatal("Expected sandbox deny tools to be configured") + } +} + func TestSaveConfig_FilePermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("file permission bits are not enforced on Windows") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7fb3daa483..1fa3feeeca 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -28,6 +28,7 @@ func DefaultConfig() *Config { Defaults: AgentDefaults{ Workspace: workspacePath, RestrictToWorkspace: true, + AllowReadOutsideWorkspace: false, Provider: "", Model: "", MaxTokens: 32768, @@ -35,6 +36,40 @@ func DefaultConfig() *Config { MaxToolIterations: 50, SummarizeMessageThreshold: 20, SummarizeTokenPercent: 75, + Sandbox: AgentSandboxConfig{ + Mode: SandboxModeOff, + Scope: SandboxScopeAgent, + WorkspaceAccess: WorkspaceAccessNone, + WorkspaceRoot: filepath.Join(homePath, "sandboxes"), + Docker: AgentSandboxDockerConfig{ + Image: "picoclaw-sandbox:bookworm-slim", + ContainerPrefix: "picoclaw-sbx-", + Workdir: "/workspace", + ReadOnlyRoot: true, + Tmpfs: []string{"/tmp", "/var/tmp", "/run"}, + Network: "none", + User: "", + Env: map[string]string{ + "LANG": "C.UTF-8", + }, + SetupCommand: "", + PidsLimit: 0, + Memory: "", + MemorySwap: "", + Cpus: 0, + Ulimits: map[string]AgentSandboxDockerUlimitValue{}, + SeccompProfile: "", + ApparmorProfile: "", + DNS: []string{}, + ExtraHosts: []string{}, + CapDrop: []string{"ALL"}, + Binds: []string{}, + }, + Prune: AgentSandboxPruneConfig{ + IdleHours: intPtr(24), + MaxAgeDays: intPtr(7), + }, + }, }, }, Bindings: []AgentBinding{}, @@ -412,6 +447,12 @@ func DefaultConfig() *Config { EnableDenyPatterns: true, TimeoutSeconds: 60, }, + Sandbox: SandboxToolsConfig{ + Tools: SandboxToolPolicyConfig{ + Allow: []string{"exec", "read_file", "write_file", "list_dir", "edit_file", "append_file"}, + Deny: []string{"cron"}, + }, + }, Skills: SkillsToolsConfig{ ToolConfig: ToolConfig{ Enabled: true, diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 08f0b0ad21..3f66fbf85f 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -197,40 +198,7 @@ func (sm *SessionManager) Save(key string) error { } sessionPath := filepath.Join(sm.storage, filename+".json") - tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") - if err != nil { - return err - } - - tmpPath := tmpFile.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tmpPath) - } - }() - - if _, err := tmpFile.Write(data); err != nil { - _ = tmpFile.Close() - return err - } - if err := tmpFile.Chmod(0o644); err != nil { - _ = tmpFile.Close() - return err - } - if err := tmpFile.Sync(); err != nil { - _ = tmpFile.Close() - return err - } - if err := tmpFile.Close(); err != nil { - return err - } - - if err := os.Rename(tmpPath, sessionPath); err != nil { - return err - } - cleanup = false - return nil + return fileutil.WriteFileAtomic(sessionPath, data, 0o644) } func (sm *SessionManager) loadSessions() error { diff --git a/pkg/tools/base.go b/pkg/tools/base.go index ec743e1645..37ab85ad12 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -1,6 +1,8 @@ package tools -import "context" +import ( + "context" +) // Tool is the interface that all tools must implement. type Tool interface { diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 6af0aa9e14..3010c1a3aa 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/agent/sandbox" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" @@ -22,26 +23,47 @@ type CronTool struct { cronService *cron.CronService executor JobExecutor msgBus *bus.MessageBus - execTool *ExecTool + // sandboxManager is the agent-level sandbox manager used to execute + // scheduled commands. It respects the configured sandbox mode so that + // cron jobs run inside the same isolation boundary as regular tool calls. + sandboxManager sandbox.Manager + execGuard *ExecTool + execTimeout time.Duration } -// NewCronTool creates a new CronTool -// execTimeout: 0 means no timeout, >0 sets the timeout duration +// NewCronTool creates a new CronTool. +// mgr is the agent's sandbox.Manager used to execute scheduled shell commands. +// If mgr is nil, commands run on the host sandbox (equivalent to sandbox mode off). +// execTimeout: 0 means no timeout, >0 sets the timeout duration. func NewCronTool( - cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, - execTimeout time.Duration, config *config.Config, + cronService *cron.CronService, + executor JobExecutor, + msgBus *bus.MessageBus, + workspace string, + restrict bool, + execTimeout time.Duration, + config *config.Config, + mgr sandbox.Manager, ) (*CronTool, error) { - execTool, err := NewExecToolWithConfig(workspace, restrict, config) + var sandboxManager sandbox.Manager + if mgr != nil { + sandboxManager = mgr + } else { + // Fallback: build a host-only sandbox manager when no manager is provided. + sandboxManager = sandbox.NewFromConfigAsManager(workspace, restrict, config) + } + guard, err := NewExecToolWithConfig(workspace, restrict, config) if err != nil { return nil, fmt.Errorf("unable to configure exec tool: %w", err) } - - execTool.SetTimeout(execTimeout) + guard.SetTimeout(execTimeout) return &CronTool{ - cronService: cronService, - executor: executor, - msgBus: msgBus, - execTool: execTool, + cronService: cronService, + executor: executor, + msgBus: msgBus, + sandboxManager: sandboxManager, + execGuard: guard, + execTimeout: execTimeout, }, nil } @@ -280,16 +302,48 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // Execute command if present if job.Payload.Command != "" { - args := map[string]any{ - "command": job.Payload.Command, - } - - result := t.execTool.Execute(ctx, args) var output string - if result.IsError { - output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM) + if t.execGuard != nil { + cwd := t.execGuard.workingDir + if guardError := t.execGuard.guardCommand(job.Payload.Command, cwd); guardError != "" { + output = fmt.Sprintf("Error executing scheduled command: %s", guardError) + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: output, + }) + return "ok" + } + } + res, err := t.sandboxManager.Exec(ctx, sandbox.ExecRequest{ + Command: job.Payload.Command, + WorkingDir: func() string { + if t.execGuard == nil { + return "." + } + workspace := t.sandboxManager.GetWorkspace(ctx) + cwd := workspace + if cwd == "" { + cwd = "." + } + return t.execGuard.resolveSandboxWorkingDir(cwd, workspace) + }(), + TimeoutMs: t.execTimeout.Milliseconds(), + }) + if err != nil { + output = fmt.Sprintf("Error executing scheduled command: %v", err) } else { - output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM) + cmdOutput := res.Stdout + if res.Stderr != "" { + cmdOutput += "\nSTDERR:\n" + res.Stderr + } + if res.ExitCode != 0 { + output = fmt.Sprintf("Error executing scheduled command: %s\nExit code: %d", cmdOutput, res.ExitCode) + } else { + output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, cmdOutput) + } } pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/pkg/tools/cron_additional_test.go b/pkg/tools/cron_additional_test.go new file mode 100644 index 0000000000..acd5c0aadb --- /dev/null +++ b/pkg/tools/cron_additional_test.go @@ -0,0 +1,134 @@ +package tools + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/agent/sandbox" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/cron" +) + +type cronStubSandbox struct { + calls int + last sandbox.ExecRequest + res *sandbox.ExecResult + err error +} + +func (s *cronStubSandbox) Start(ctx context.Context) error { return nil } +func (s *cronStubSandbox) Prune(ctx context.Context) error { return nil } + +func (s *cronStubSandbox) Resolve(ctx context.Context) (sandbox.Sandbox, error) { + return s, nil +} +func (s *cronStubSandbox) Fs() sandbox.FsBridge { return nil } +func (s *cronStubSandbox) GetWorkspace(ctx context.Context) string { return "" } +func (s *cronStubSandbox) Exec(ctx context.Context, req sandbox.ExecRequest) (*sandbox.ExecResult, error) { + return s.ExecStream(ctx, req, nil) +} + +func (s *cronStubSandbox) ExecStream( + ctx context.Context, + req sandbox.ExecRequest, + onEvent func(sandbox.ExecEvent) error, +) (*sandbox.ExecResult, error) { + s.calls++ + s.last = req + if s.err != nil { + return nil, s.err + } + if s.res != nil { + if onEvent != nil { + if s.res.Stdout != "" { + if err := onEvent( + sandbox.ExecEvent{Type: sandbox.ExecEventStdout, Chunk: []byte(s.res.Stdout)}, + ); err != nil { + return nil, err + } + } + if s.res.Stderr != "" { + if err := onEvent( + sandbox.ExecEvent{Type: sandbox.ExecEventStderr, Chunk: []byte(s.res.Stderr)}, + ); err != nil { + return nil, err + } + } + if err := onEvent(sandbox.ExecEvent{Type: sandbox.ExecEventExit, ExitCode: s.res.ExitCode}); err != nil { + return nil, err + } + } + return s.res, nil + } + if onEvent != nil { + if err := onEvent(sandbox.ExecEvent{Type: sandbox.ExecEventStdout, Chunk: []byte("ok")}); err != nil { + return nil, err + } + if err := onEvent(sandbox.ExecEvent{Type: sandbox.ExecEventExit, ExitCode: 0}); err != nil { + return nil, err + } + } + return &sandbox.ExecResult{Stdout: "ok", ExitCode: 0}, nil +} + +func TestCronTool_ExecuteJob_BlocksDangerousCommandViaGuard(t *testing.T) { + msgBus := bus.NewMessageBus() + sb := &cronStubSandbox{} + tool := &CronTool{ + msgBus: msgBus, + sandboxManager: sb, + execGuard: mustNewExecTool(t, "", true), + } + + job := &cron.CronJob{ + ID: "j1", + Payload: cron.CronPayload{ + Command: "rm -rf /", + Channel: "cli", + To: "direct", + }, + } + + tool.ExecuteJob(context.Background(), job) + if sb.calls != 0 { + t.Fatalf("sandbox should not be called for blocked command, got %d calls", sb.calls) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + out, ok := msgBus.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected outbound message") + } + if !strings.Contains(out.Content, "blocked") { + t.Fatalf("expected blocked message, got: %s", out.Content) + } +} + +func TestCronTool_ExecuteJob_AllowsSafeCommand(t *testing.T) { + msgBus := bus.NewMessageBus() + sb := &cronStubSandbox{res: &sandbox.ExecResult{Stdout: "safe", ExitCode: 0}} + tool := &CronTool{ + msgBus: msgBus, + sandboxManager: sb, + execGuard: mustNewExecTool(t, "/tmp/ws", true), + } + + job := &cron.CronJob{ + ID: "j2", + Payload: cron.CronPayload{ + Command: "echo safe", + Channel: "cli", + To: "direct", + }, + } + tool.ExecuteJob(context.Background(), job) + if sb.calls != 1 { + t.Fatalf("expected sandbox call, got %d", sb.calls) + } + if sb.last.WorkingDir != "." { + t.Fatalf("expected sandbox working dir '.', got %q", sb.last.WorkingDir) + } +} diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index d5bebf4a2d..112dcdaedd 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -2,26 +2,21 @@ package tools import ( "context" - "errors" "fmt" - "io/fs" + "os" "regexp" "strings" + + "github.com/sipeed/picoclaw/pkg/agent/sandbox" ) // EditFileTool edits a file by replacing old_text with new_text. -// The old_text must exist exactly in the file. type EditFileTool struct { - fs fileSystem + allowPaths []*regexp.Regexp } -// NewEditFileTool creates a new EditFileTool with optional directory restriction. func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &EditFileTool{fs: buildFs(workspace, restrict, patterns)} + return &EditFileTool{allowPaths: firstPatternSet(allowPaths)} } func (t *EditFileTool) Name() string { @@ -58,33 +53,55 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe if !ok { return ErrorResult("path is required") } - oldText, ok := args["old_text"].(string) if !ok { return ErrorResult("old_text is required") } - newText, ok := args["new_text"].(string) if !ok { return ErrorResult("new_text is required") } - if err := editFile(t.fs, path, oldText, newText); err != nil { + if hostSandboxFromContext(ctx) != nil && matchesAnyPattern(path, t.allowPaths) { + content, err := os.ReadFile(path) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) + } + newContent, err := replaceEditContent(content, oldText, newText) + if err != nil { + return ErrorResult(err.Error()) + } + if _, err := writeAllowedHostPath(ctx, path, newContent, t.allowPaths); err != nil { + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) + } + return SilentResult(fmt.Sprintf("File edited: %s", path)) + } + + sb := sandbox.FromContext(ctx) + if sb == nil { + return ErrorResult("sandbox environment unavailable") + } + + content, err := sb.Fs().ReadFile(ctx, path) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) + } + newContent, err := replaceEditContent(content, oldText, newText) + if err != nil { return ErrorResult(err.Error()) } + if err := sb.Fs().WriteFile(ctx, path, newContent, true); err != nil { + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) + } return SilentResult(fmt.Sprintf("File edited: %s", path)) } type AppendFileTool struct { - fs fileSystem + allowPaths []*regexp.Regexp } func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)} + return &AppendFileTool{allowPaths: firstPatternSet(allowPaths)} } func (t *AppendFileTool) Name() string { @@ -117,58 +134,45 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool if !ok { return ErrorResult("path is required") } - content, ok := args["content"].(string) if !ok { return ErrorResult("content is required") } - if err := appendFile(t.fs, path, content); err != nil { - return ErrorResult(err.Error()) + if hostSandboxFromContext(ctx) != nil && matchesAnyPattern(path, t.allowPaths) { + oldContent, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return ErrorResult(fmt.Sprintf("failed to read file for append: %v", err)) + } + if _, err := writeAllowedHostPath(ctx, path, append(oldContent, []byte(content)...), t.allowPaths); err != nil { + return ErrorResult(fmt.Sprintf("failed to append (write) to file: %v", err)) + } + return SilentResult(fmt.Sprintf("Appended to %s", path)) } - return SilentResult(fmt.Sprintf("Appended to %s", path)) -} -// editFile reads the file via sysFs, performs the replacement, and writes back. -// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes. -func editFile(sysFs fileSystem, path, oldText, newText string) error { - content, err := sysFs.ReadFile(path) - if err != nil { - return err + sb := sandbox.FromContext(ctx) + if sb == nil { + return ErrorResult("sandbox environment unavailable") } - newContent, err := replaceEditContent(content, oldText, newText) - if err != nil { - return err + oldContent, err := sb.Fs().ReadFile(ctx, path) + if err != nil && !os.IsNotExist(err) && !strings.Contains(err.Error(), "not found") { + return ErrorResult(fmt.Sprintf("failed to read file for append: %v", err)) } - - return sysFs.WriteFile(path, newContent) -} - -// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back. -func appendFile(sysFs fileSystem, path, appendContent string) error { - content, err := sysFs.ReadFile(path) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return err + if err := sb.Fs().WriteFile(ctx, path, append(oldContent, []byte(content)...), true); err != nil { + return ErrorResult(fmt.Sprintf("failed to append (write) to file: %v", err)) } - - newContent := append(content, []byte(appendContent)...) - return sysFs.WriteFile(path, newContent) + return SilentResult(fmt.Sprintf("Appended to %s", path)) } -// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText. func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) { contentStr := string(content) - if !strings.Contains(contentStr, oldText) { return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly") } - count := strings.Count(contentStr, oldText) if count > 1 { return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) } - - newContent := strings.Replace(contentStr, oldText, newText, 1) - return []byte(newContent), nil + return []byte(strings.Replace(contentStr, oldText, newText, 1)), nil } diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go index 83a7e778ca..141d23c9ea 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/edit_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/sipeed/picoclaw/pkg/agent/sandbox" ) // TestEditTool_EditFile_Success verifies successful file editing @@ -17,7 +19,8 @@ func TestEditTool_EditFile_Success(t *testing.T) { os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644) tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(tmpDir, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "old_text": "World", @@ -61,7 +64,8 @@ func TestEditTool_EditFile_NotFound(t *testing.T) { testFile := filepath.Join(tmpDir, "nonexistent.txt") tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(tmpDir, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "old_text": "old", @@ -75,10 +79,9 @@ func TestEditTool_EditFile_NotFound(t *testing.T) { t.Errorf("Expected error for non-existent file") } - // Should mention file not found - if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { - t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM) - } + // Should mention file not found or no such file + assert.True(t, strings.Contains(result.ForLLM, "not found") || strings.Contains(result.ForLLM, "no such file"), + "Expected 'not found' or 'no such file' message, got ForLLM: %s", result.ForLLM) } // TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist @@ -88,7 +91,8 @@ func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { os.WriteFile(testFile, []byte("Hello World"), 0o644) tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(tmpDir, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "old_text": "Goodbye", @@ -115,7 +119,8 @@ func TestEditTool_EditFile_MultipleMatches(t *testing.T) { os.WriteFile(testFile, []byte("test test test"), 0o644) tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(tmpDir, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "old_text": "test", @@ -143,7 +148,8 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { os.WriteFile(testFile, []byte("content"), 0o644) tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir - ctx := context.Background() + sb := sandbox.NewHostSandbox(tmpDir, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "old_text": "content", @@ -169,8 +175,9 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { // TestEditTool_EditFile_MissingPath verifies error handling for missing path func TestEditTool_EditFile_MissingPath(t *testing.T) { - tool := NewEditFileTool("", false) - ctx := context.Background() + tool := NewEditFileTool("/", false) + sb := sandbox.NewHostSandbox("/", false) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "old_text": "old", "new_text": "new", @@ -186,8 +193,9 @@ func TestEditTool_EditFile_MissingPath(t *testing.T) { // TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text func TestEditTool_EditFile_MissingOldText(t *testing.T) { - tool := NewEditFileTool("", false) - ctx := context.Background() + tool := NewEditFileTool("/", false) + sb := sandbox.NewHostSandbox("/", false) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": "/tmp/test.txt", "new_text": "new", @@ -203,8 +211,9 @@ func TestEditTool_EditFile_MissingOldText(t *testing.T) { // TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text func TestEditTool_EditFile_MissingNewText(t *testing.T) { - tool := NewEditFileTool("", false) - ctx := context.Background() + tool := NewEditFileTool("/", false) + sb := sandbox.NewHostSandbox("/", false) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": "/tmp/test.txt", "old_text": "old", @@ -224,8 +233,9 @@ func TestEditTool_AppendFile_Success(t *testing.T) { testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("Initial content"), 0o644) - tool := NewAppendFileTool("", false) - ctx := context.Background() + tool := NewAppendFileTool("/", false) + sb := sandbox.NewHostSandbox("/", false) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "content": "\nAppended content", @@ -264,7 +274,7 @@ func TestEditTool_AppendFile_Success(t *testing.T) { // TestEditTool_AppendFile_MissingPath verifies error handling for missing path func TestEditTool_AppendFile_MissingPath(t *testing.T) { - tool := NewAppendFileTool("", false) + tool := NewAppendFileTool("/", false) ctx := context.Background() args := map[string]any{ "content": "test", @@ -280,7 +290,7 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) { // TestEditTool_AppendFile_MissingContent verifies error handling for missing content func TestEditTool_AppendFile_MissingContent(t *testing.T) { - tool := NewAppendFileTool("", false) + tool := NewAppendFileTool("/", false) ctx := context.Background() args := map[string]any{ "path": "/tmp/test.txt", @@ -349,7 +359,8 @@ func TestReplaceEditContent(t *testing.T) { func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) { workspace := t.TempDir() tool := NewAppendFileTool(workspace, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(workspace, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": "brand_new_file.txt", @@ -379,7 +390,8 @@ func TestAppendFileTool_Restricted_Success(t *testing.T) { assert.NoError(t, err) tool := NewAppendFileTool(workspace, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(workspace, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "content": " appended", @@ -403,7 +415,8 @@ func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) { assert.NoError(t, err) tool := NewEditFileTool(workspace, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(workspace, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": testFile, "old_text": "World", @@ -424,7 +437,8 @@ func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) { func TestEditFileTool_Restricted_FileNotFound(t *testing.T) { workspace := t.TempDir() tool := NewEditFileTool(workspace, true) - ctx := context.Background() + sb := sandbox.NewHostSandbox(workspace, true) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ "path": "no_such_file.txt", "old_text": "old", @@ -433,5 +447,6 @@ func TestEditFileTool_Restricted_FileNotFound(t *testing.T) { result := tool.Execute(ctx, args) assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "not found") + assert.True(t, strings.Contains(result.ForLLM, "not found") || strings.Contains(result.ForLLM, "no such file"), + "Expected 'not found' or 'no such file' message, got ForLLM: %s", result.ForLLM) } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index cd8da31959..30835ccaa3 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -3,97 +3,21 @@ package tools import ( "context" "fmt" - "io/fs" "os" "path/filepath" "regexp" "strings" - "time" + "github.com/sipeed/picoclaw/pkg/agent/sandbox" "github.com/sipeed/picoclaw/pkg/fileutil" ) -// validatePath ensures the given path is within the workspace if restrict is true. -func validatePath(path, workspace string, restrict bool) (string, error) { - if workspace == "" { - return path, fmt.Errorf("workspace is not defined") - } - - absWorkspace, err := filepath.Abs(workspace) - if err != nil { - return "", fmt.Errorf("failed to resolve workspace path: %w", err) - } - - var absPath string - if filepath.IsAbs(path) { - absPath = filepath.Clean(path) - } else { - absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) - if err != nil { - return "", fmt.Errorf("failed to resolve file path: %w", err) - } - } - - if restrict { - if !isWithinWorkspace(absPath, absWorkspace) { - return "", fmt.Errorf("access denied: path is outside the workspace") - } - - var resolved string - workspaceReal := absWorkspace - if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { - workspaceReal = resolved - } - - if resolved, err = filepath.EvalSymlinks(absPath); err == nil { - if !isWithinWorkspace(resolved, workspaceReal) { - return "", fmt.Errorf("access denied: symlink resolves outside workspace") - } - } else if os.IsNotExist(err) { - var parentResolved string - if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { - if !isWithinWorkspace(parentResolved, workspaceReal) { - return "", fmt.Errorf("access denied: symlink resolves outside workspace") - } - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - } else { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - } - - return absPath, nil -} - -func resolveExistingAncestor(path string) (string, error) { - for current := filepath.Clean(path); ; current = filepath.Dir(current) { - if resolved, err := filepath.EvalSymlinks(current); err == nil { - return resolved, nil - } else if !os.IsNotExist(err) { - return "", err - } - if filepath.Dir(current) == current { - return "", os.ErrNotExist - } - } -} - -func isWithinWorkspace(candidate, workspace string) bool { - rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) - return err == nil && filepath.IsLocal(rel) -} - type ReadFileTool struct { - fs fileSystem + allowPaths []*regexp.Regexp } func NewReadFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ReadFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &ReadFileTool{fs: buildFs(workspace, restrict, patterns)} + return &ReadFileTool{allowPaths: firstPatternSet(allowPaths)} } func (t *ReadFileTool) Name() string { @@ -123,23 +47,31 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("path is required") } - content, err := t.fs.ReadFile(path) + if content, handled, err := readAllowedHostPath(ctx, path, t.allowPaths); handled { + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) + } + return NewToolResult(string(content)) + } + + sb := sandbox.FromContext(ctx) + if sb == nil { + return ErrorResult("sandbox environment unavailable") + } + + content, err := sb.Fs().ReadFile(ctx, path) if err != nil { - return ErrorResult(err.Error()) + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) } return NewToolResult(string(content)) } type WriteFileTool struct { - fs fileSystem + allowPaths []*regexp.Regexp } func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)} + return &WriteFileTool{allowPaths: firstPatternSet(allowPaths)} } func (t *WriteFileTool) Name() string { @@ -178,23 +110,30 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR return ErrorResult("content is required") } - if err := t.fs.WriteFile(path, []byte(content)); err != nil { - return ErrorResult(err.Error()) + if handled, err := writeAllowedHostPath(ctx, path, []byte(content), t.allowPaths); handled { + if err != nil { + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) + } + return SilentResult(fmt.Sprintf("File written: %s", path)) + } + + sb := sandbox.FromContext(ctx) + if sb == nil { + return ErrorResult("sandbox environment unavailable") } + if err := sb.Fs().WriteFile(ctx, path, []byte(content), true); err != nil { + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) + } return SilentResult(fmt.Sprintf("File written: %s", path)) } type ListDirTool struct { - fs fileSystem + allowPaths []*regexp.Regexp } func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &ListDirTool{fs: buildFs(workspace, restrict, patterns)} + return &ListDirTool{allowPaths: firstPatternSet(allowPaths)} } func (t *ListDirTool) Name() string { @@ -224,7 +163,19 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes path = "." } - entries, err := t.fs.ReadDir(path) + if entries, handled, err := readAllowedHostDir(ctx, path, t.allowPaths); handled { + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) + } + return formatDirEntries(entries) + } + + sb := sandbox.FromContext(ctx) + if sb == nil { + return ErrorResult("sandbox environment unavailable") + } + + entries, err := sb.Fs().ReadDir(ctx, path) if err != nil { return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) } @@ -243,221 +194,64 @@ func formatDirEntries(entries []os.DirEntry) *ToolResult { return NewToolResult(result.String()) } -// fileSystem abstracts reading, writing, and listing files, allowing both -// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. -type fileSystem interface { - ReadFile(path string) ([]byte, error) - WriteFile(path string, data []byte) error - ReadDir(path string) ([]os.DirEntry, error) -} - -// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. -type hostFs struct{} - -func (h *hostFs) ReadFile(path string) ([]byte, error) { - content, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read file: file not found: %w", err) - } - if os.IsPermission(err) { - return nil, fmt.Errorf("failed to read file: access denied: %w", err) - } - return nil, fmt.Errorf("failed to read file: %w", err) +func firstPatternSet(sets [][]*regexp.Regexp) []*regexp.Regexp { + if len(sets) == 0 { + return nil } - return content, nil -} - -func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { - return os.ReadDir(path) -} - -func (h *hostFs) WriteFile(path string, data []byte) error { - // Use unified atomic write utility with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - return fileutil.WriteFileAtomic(path, data, 0o600) + return sets[0] } -// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. -type sandboxFs struct { - workspace string +func validatePath(path, workspace string, restrict bool) (string, error) { + return sandbox.ValidatePath(path, workspace, restrict) } -func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error { - if r.workspace == "" { - return fmt.Errorf("workspace is not defined") +func hostSandboxFromContext(ctx context.Context) *sandbox.HostSandbox { + sb := sandbox.FromContext(ctx) + if sb == nil { + return nil } + host, _ := sb.(*sandbox.HostSandbox) + return host +} - root, err := os.OpenRoot(r.workspace) - if err != nil { - return fmt.Errorf("failed to open workspace: %w", err) +func matchesAnyPattern(path string, patterns []*regexp.Regexp) bool { + if len(patterns) == 0 { + return false } - defer root.Close() - - relPath, err := getSafeRelPath(r.workspace, path) + absPath, err := filepath.Abs(path) if err != nil { - return err + absPath = path } - - return fn(root, relPath) -} - -func (r *sandboxFs) ReadFile(path string) ([]byte, error) { - var content []byte - err := r.execute(path, func(root *os.Root, relPath string) error { - fileContent, err := root.ReadFile(relPath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("failed to read file: file not found: %w", err) - } - // os.Root returns "escapes from parent" for paths outside the root - if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || - strings.Contains(err.Error(), "permission denied") { - return fmt.Errorf("failed to read file: access denied: %w", err) - } - return fmt.Errorf("failed to read file: %w", err) - } - content = fileContent - return nil - }) - return content, err -} - -func (r *sandboxFs) WriteFile(path string, data []byte) error { - return r.execute(path, func(root *os.Root, relPath string) error { - dir := filepath.Dir(relPath) - if dir != "." && dir != "/" { - if err := root.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create parent directories: %w", err) - } - } - - // Use atomic write pattern with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) - - tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to open temp file: %w", err) - } - - if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to write temp file: %w", err) - } - - // CRITICAL: Force sync to storage medium before rename. - // This ensures data is physically written to disk, not just cached. - if err := tmpFile.Sync(); err != nil { - tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to sync temp file: %w", err) - } - - if err := tmpFile.Close(); err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to close temp file: %w", err) - } - - if err := root.Rename(tmpRelPath, relPath); err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to rename temp file over target: %w", err) - } - - // Sync directory to ensure rename is durable - if dirFile, err := root.Open("."); err == nil { - _ = dirFile.Sync() - dirFile.Close() - } - - return nil - }) -} - -func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { - var entries []os.DirEntry - err := r.execute(path, func(root *os.Root, relPath string) error { - dirEntries, err := fs.ReadDir(root.FS(), relPath) - if err != nil { - return err - } - entries = dirEntries - return nil - }) - return entries, err -} - -// whitelistFs wraps a sandboxFs and allows access to specific paths outside -// the workspace when they match any of the provided patterns. -type whitelistFs struct { - sandbox *sandboxFs - host hostFs - patterns []*regexp.Regexp -} - -func (w *whitelistFs) matches(path string) bool { - for _, p := range w.patterns { - if p.MatchString(path) { + for _, pattern := range patterns { + if pattern != nil && (pattern.MatchString(path) || pattern.MatchString(absPath)) { return true } } return false } -func (w *whitelistFs) ReadFile(path string) ([]byte, error) { - if w.matches(path) { - return w.host.ReadFile(path) +func readAllowedHostPath(ctx context.Context, path string, patterns []*regexp.Regexp) ([]byte, bool, error) { + if hostSandboxFromContext(ctx) == nil || !matchesAnyPattern(path, patterns) { + return nil, false, nil } - return w.sandbox.ReadFile(path) -} - -func (w *whitelistFs) WriteFile(path string, data []byte) error { - if w.matches(path) { - return w.host.WriteFile(path, data) - } - return w.sandbox.WriteFile(path, data) -} - -func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { - if w.matches(path) { - return w.host.ReadDir(path) - } - return w.sandbox.ReadDir(path) + content, err := os.ReadFile(path) + return content, true, err } -// buildFs returns the appropriate fileSystem implementation based on restriction -// settings and optional path whitelist patterns. -func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { - if !restrict { - return &hostFs{} +func writeAllowedHostPath(ctx context.Context, path string, data []byte, patterns []*regexp.Regexp) (bool, error) { + if hostSandboxFromContext(ctx) == nil || !matchesAnyPattern(path, patterns) { + return false, nil } - sandbox := &sandboxFs{workspace: workspace} - if len(patterns) > 0 { - return &whitelistFs{sandbox: sandbox, patterns: patterns} + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return true, err } - return sandbox + return true, fileutil.WriteFileAtomic(path, data, 0o644) } -// Helper to get a safe relative path for os.Root usage -func getSafeRelPath(workspace, path string) (string, error) { - if workspace == "" { - return "", fmt.Errorf("workspace is not defined") +func readAllowedHostDir(ctx context.Context, path string, patterns []*regexp.Regexp) ([]os.DirEntry, bool, error) { + if hostSandboxFromContext(ctx) == nil || !matchesAnyPattern(path, patterns) { + return nil, false, nil } - - rel := filepath.Clean(path) - if filepath.IsAbs(rel) { - var err error - rel, err = filepath.Rel(workspace, rel) - if err != nil { - return "", fmt.Errorf("failed to calculate relative path: %w", err) - } - } - - if !filepath.IsLocal(rel) { - return "", fmt.Errorf("path escapes workspace: %s", path) - } - - return rel, nil + entries, err := os.ReadDir(path) + return entries, true, err } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 666004cd40..483250c750 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -2,14 +2,15 @@ package tools import ( "context" - "io" + "fmt" "os" "path/filepath" - "regexp" "strings" "testing" "github.com/stretchr/testify/assert" + + "github.com/sipeed/picoclaw/pkg/agent/sandbox" ) // TestFilesystemTool_ReadFile_Success verifies successful file reading @@ -19,7 +20,11 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { os.WriteFile(testFile, []byte("test content"), 0o644) tool := NewReadFileTool("", false) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(tmpDir, false).Fs(), + }) + // We must ensure the mock FsBridge can actually read the TempDir. + // but stubSandbox uses hostFs internally for Fs(). ReadFile so this just works if injected. args := map[string]any{ "path": testFile, } @@ -46,7 +51,9 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { tool := NewReadFileTool("", false) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + err: fmt.Errorf("failed to read file: file not found"), + }) args := map[string]any{ "path": "/nonexistent_file_12345.txt", } @@ -67,7 +74,7 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { // TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { tool := &ReadFileTool{} - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{}) args := map[string]any{} result := tool.Execute(ctx, args) @@ -89,7 +96,9 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) { testFile := filepath.Join(tmpDir, "newfile.txt") tool := NewWriteFileTool("", false) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(tmpDir, false).Fs(), + }) args := map[string]any{ "path": testFile, "content": "hello world", @@ -128,7 +137,9 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") tool := NewWriteFileTool("", false) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(tmpDir, false).Fs(), + }) args := map[string]any{ "path": testFile, "content": "test", @@ -154,7 +165,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { // TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { tool := NewWriteFileTool("", false) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{}) args := map[string]any{ "content": "test", } @@ -170,7 +181,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { // TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { tool := NewWriteFileTool("", false) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{}) args := map[string]any{ "path": "/tmp/test.txt", } @@ -196,8 +207,10 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) - tool := NewListDirTool("", false) - ctx := context.Background() + tool := NewListDirTool(tmpDir, false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(tmpDir, false).Fs(), + }) args := map[string]any{ "path": tmpDir, } @@ -220,8 +233,12 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { // TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory func TestFilesystemTool_ListDir_NotFound(t *testing.T) { - tool := NewListDirTool("", false) - ctx := context.Background() + tmpDir := t.TempDir() + tool := NewListDirTool(tmpDir, false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(tmpDir, false).Fs(), + err: fmt.Errorf("failed to read directory: file not found"), + }) args := map[string]any{ "path": "/nonexistent_directory_12345", } @@ -241,8 +258,10 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { // TestFilesystemTool_ListDir_DefaultPath verifies default to current directory func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { - tool := NewListDirTool("", false) - ctx := context.Background() + tool := NewListDirTool(".", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(".", false).Fs(), + }) args := map[string]any{} result := tool.Execute(ctx, args) @@ -272,7 +291,9 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { } tool := NewReadFileTool(workspace, true) - result := tool.Execute(context.Background(), map[string]any{ + result := tool.Execute(sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(workspace, true).Fs(), + }), map[string]any{ "path": link, }) @@ -296,7 +317,9 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { secretFile := filepath.Join(tmpDir, "shadow") os.WriteFile(secretFile, []byte("secret data"), 0o600) - result := tool.Execute(context.Background(), map[string]any{ + result := tool.Execute(sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox("", true).Fs(), + }), map[string]any{ "path": secretFile, }) @@ -307,6 +330,25 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") } +func TestFilesystemTool_EmptyWorkspace_UnrestrictedAllowed(t *testing.T) { + tool := NewReadFileTool("", false) // restrict=false and workspace="" + + tmpDir := t.TempDir() + secretFile := filepath.Join(tmpDir, "public.txt") + if err := os.WriteFile(secretFile, []byte("public data"), 0o644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + result := tool.Execute(sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox("", false).Fs(), + }), map[string]any{ + "path": secretFile, + }) + + assert.False(t, result.IsError, "Expected unrestricted empty-workspace read to succeed, got: %s", result.ForLLM) + assert.Contains(t, result.ForLLM, "public data") +} + // TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: // single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path. func TestRootMkdirAll(t *testing.T) { @@ -343,7 +385,9 @@ func TestRootMkdirAll(t *testing.T) { func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { workspace := t.TempDir() tool := NewWriteFileTool(workspace, true) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + fs: sandbox.NewHostSandbox(workspace, true).Fs(), + }) testFile := "deep/nested/path/to/file.txt" content := "deep content" @@ -361,162 +405,3 @@ func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { assert.NoError(t, err) assert.Equal(t, content, string(data)) } - -// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors. -func TestHostRW_Read_PermissionDenied(t *testing.T) { - if os.Getuid() == 0 { - t.Skip("skipping permission test: running as root") - } - tmpDir := t.TempDir() - protected := filepath.Join(tmpDir, "protected.txt") - err := os.WriteFile(protected, []byte("secret"), 0o000) - assert.NoError(t, err) - defer os.Chmod(protected, 0o644) // ensure cleanup - - _, err = (&hostFs{}).ReadFile(protected) - assert.Error(t, err) - assert.Contains(t, err.Error(), "access denied") -} - -// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path. -func TestHostRW_Read_Directory(t *testing.T) { - tmpDir := t.TempDir() - - _, err := (&hostFs{}).ReadFile(tmpDir) - assert.Error(t, err, "expected error when reading a directory as a file") -} - -// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory. -func TestRootRW_Read_Directory(t *testing.T) { - workspace := t.TempDir() - root, err := os.OpenRoot(workspace) - assert.NoError(t, err) - defer root.Close() - - // Create a subdirectory - err = root.Mkdir("subdir", 0o755) - assert.NoError(t, err) - - _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") - assert.Error(t, err, "expected error when reading a directory as a file") -} - -// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically. -func TestHostRW_Write_ParentDirMissing(t *testing.T) { - tmpDir := t.TempDir() - target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") - - err := (&hostFs{}).WriteFile(target, []byte("hello")) - assert.NoError(t, err) - - data, err := os.ReadFile(target) - assert.NoError(t, err) - assert.Equal(t, "hello", string(data)) -} - -// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates -// nested parent directories automatically within the sandbox. -func TestRootRW_Write_ParentDirMissing(t *testing.T) { - workspace := t.TempDir() - - relPath := "x/y/z/file.txt" - err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) - assert.NoError(t, err) - - data, err := os.ReadFile(filepath.Join(workspace, relPath)) - assert.NoError(t, err) - assert.Equal(t, "nested", string(data)) -} - -// TestHostRW_Write verifies the hostRW.Write helper function -func TestHostRW_Write(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "atomic_test.txt") - testData := []byte("atomic test content") - - err := (&hostFs{}).WriteFile(testFile, testData) - assert.NoError(t, err) - - content, err := os.ReadFile(testFile) - assert.NoError(t, err) - assert.Equal(t, testData, content) - - // Verify it overwrites correctly - newData := []byte("new atomic content") - err = (&hostFs{}).WriteFile(testFile, newData) - assert.NoError(t, err) - - content, err = os.ReadFile(testFile) - assert.NoError(t, err) - assert.Equal(t, newData, content) -} - -// TestRootRW_Write verifies the rootRW.Write helper function -func TestRootRW_Write(t *testing.T) { - tmpDir := t.TempDir() - - relPath := "atomic_root_test.txt" - testData := []byte("atomic root test content") - - erw := &sandboxFs{workspace: tmpDir} - err := erw.WriteFile(relPath, testData) - assert.NoError(t, err) - - root, err := os.OpenRoot(tmpDir) - assert.NoError(t, err) - defer root.Close() - - f, err := root.Open(relPath) - assert.NoError(t, err) - defer f.Close() - - content, err := io.ReadAll(f) - assert.NoError(t, err) - assert.Equal(t, testData, content) - - // Verify it overwrites correctly - newData := []byte("new root atomic content") - err = erw.WriteFile(relPath, newData) - assert.NoError(t, err) - - f2, err := root.Open(relPath) - assert.NoError(t, err) - defer f2.Close() - - content, err = io.ReadAll(f2) - assert.NoError(t, err) - assert.Equal(t, newData, content) -} - -// TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to -// paths matching the whitelist patterns while blocking non-matching paths. -func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) { - workspace := t.TempDir() - outsideDir := t.TempDir() - outsideFile := filepath.Join(outsideDir, "allowed.txt") - os.WriteFile(outsideFile, []byte("outside content"), 0o644) - - // Pattern allows access to the outsideDir. - patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))} - - tool := NewReadFileTool(workspace, true, patterns) - - // Read from whitelisted path should succeed. - result := tool.Execute(context.Background(), map[string]any{"path": outsideFile}) - if result.IsError { - t.Errorf("expected whitelisted path to be readable, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "outside content") { - t.Errorf("expected file content, got: %s", result.ForLLM) - } - - // Read from non-whitelisted path outside workspace should fail. - otherDir := t.TempDir() - otherFile := filepath.Join(otherDir, "blocked.txt") - os.WriteFile(otherFile, []byte("blocked"), 0o644) - - result = tool.Execute(context.Background(), map[string]any{"path": otherFile}) - if !result.IsError { - t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM) - } -} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index b8a811d038..fdb22480e3 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -1,18 +1,17 @@ package tools import ( - "bytes" "context" "errors" "fmt" "os" - "os/exec" + "path" "path/filepath" "regexp" - "runtime" "strings" "time" + "github.com/sipeed/picoclaw/pkg/agent/sandbox" "github.com/sipeed/picoclaw/pkg/config" ) @@ -177,97 +176,77 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult return ErrorResult("command is required") } - cwd := t.workingDir - if wd, ok := args["working_dir"].(string); ok && wd != "" { - if t.restrictToWorkspace && t.workingDir != "" { - resolvedWD, err := validatePath(wd, t.workingDir, true) - if err != nil { - return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") - } - cwd = resolvedWD - } else { - cwd = wd - } - } + wd, _ := args["working_dir"].(string) - if cwd == "" { - wd, err := os.Getwd() - if err == nil { - cwd = wd - } + sb := sandbox.FromContext(ctx) + if sb == nil { + return ErrorResult("sandbox environment unavailable") } - if guardError := t.guardCommand(command, cwd); guardError != "" { - return ErrorResult(guardError) - } + effectiveWorkspace := sb.GetWorkspace(ctx) - // timeout == 0 means no timeout - var cmdCtx context.Context - var cancel context.CancelFunc - if t.timeout > 0 { - cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) - } else { - cmdCtx, cancel = context.WithCancel(ctx) + // Resolve the working directory + cwd := wd + if cwd == "" { + cwd = effectiveWorkspace } - defer cancel() - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) - } else { - cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) - } - if cwd != "" { - cmd.Dir = cwd + if cwd == "" { + if dir, err := os.Getwd(); err == nil { + cwd = dir + } } - prepareCommandForTermination(cmd) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - return ErrorResult(fmt.Sprintf("failed to start command: %v", err)) + if cwd == "" { + cwd = "." } - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() - - var err error - select { - case err = <-done: - case <-cmdCtx.Done(): - _ = terminateProcessTree(cmd) - select { - case err = <-done: - case <-time.After(2 * time.Second): - if cmd.Process != nil { - _ = cmd.Process.Kill() + if wd != "" && t.restrictToWorkspace && effectiveWorkspace != "" { + resolvedWD, err := sandbox.ValidatePath(wd, effectiveWorkspace, true) + if err != nil { + // If ValidatePath explicitly found the path is outside (e.g. symlink escape), + // block it immediately and do NOT fall back to prefix matching. + if errors.Is(err, sandbox.ErrOutsideWorkspace) { + return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } - err = <-done + + // In sandbox mode, allow explicit container workspace paths when + // restrict_to_workspace is enabled, but only for paths that don't exist on host. + if filepath.IsAbs(wd) && isSandboxWorkspaceAbsolutePath(wd, effectiveWorkspace) { + cwd = wd + } else { + return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") + } + } else { + cwd = resolvedWD } } - output := stdout.String() - if stderr.Len() > 0 { - output += "\nSTDERR:\n" + stderr.String() + if guardError := t.guardCommand(command, cwd); guardError != "" { + return ErrorResult(guardError) } + sandboxWD := t.resolveSandboxWorkingDir(cwd, effectiveWorkspace) + res, err := sb.Exec(ctx, sandbox.ExecRequest{ + Command: command, + WorkingDir: sandboxWD, + TimeoutMs: t.timeout.Milliseconds(), + }) if err != nil { - if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { - msg := fmt.Sprintf("Command timed out after %v", t.timeout) + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "context deadline exceeded") { + msg := fmt.Sprintf("command timed out after %v", t.timeout) return &ToolResult{ ForLLM: msg, ForUser: msg, IsError: true, } } - output += fmt.Sprintf("\nExit code: %v", err) + return ErrorResult(fmt.Sprintf("sandbox exec failed: %v", err)) + } + output := res.Stdout + if res.Stderr != "" { + output += "\nSTDERR:\n" + res.Stderr } - if output == "" { output = "(no output)" } @@ -277,14 +256,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) } - if err != nil { + if res.ExitCode != 0 { + output += fmt.Sprintf("\nExit code: %d", res.ExitCode) return &ToolResult{ - ForLLM: output, + ForLLM: fmt.Sprintf("Command failed with exit code %d:\n%s", res.ExitCode, output), ForUser: output, IsError: true, } } - return &ToolResult{ ForLLM: output, ForUser: output, @@ -293,29 +272,38 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } func (t *ExecTool) guardCommand(command, cwd string) string { - cmd := strings.TrimSpace(command) - lower := strings.ToLower(cmd) - - // Custom allow patterns exempt a command from deny checks. - explicitlyAllowed := false + lower := strings.ToLower(strings.TrimSpace(command)) for _, pattern := range t.customAllowPatterns { if pattern.MatchString(lower) { - explicitlyAllowed = true - break + return guardCommandWithPolicy(command, cwd, t.restrictToWorkspace, nil, t.allowPatterns) } } + return guardCommandWithPolicy( + command, + cwd, + t.restrictToWorkspace, + t.denyPatterns, + t.allowPatterns, + ) +} - if !explicitlyAllowed { - for _, pattern := range t.denyPatterns { - if pattern.MatchString(lower) { - return "Command blocked by safety guard (dangerous pattern detected)" - } +func guardCommandWithPolicy( + command, cwd string, + restrictToWorkspace bool, + denyPatterns, allowPatterns []*regexp.Regexp, +) string { + cmd := strings.TrimSpace(command) + lower := strings.ToLower(cmd) + + for _, pattern := range denyPatterns { + if pattern.MatchString(lower) { + return "Command blocked by safety guard (dangerous pattern detected)" } } - if len(t.allowPatterns) > 0 { + if len(allowPatterns) > 0 { allowed := false - for _, pattern := range t.allowPatterns { + for _, pattern := range allowPatterns { if pattern.MatchString(lower) { allowed = true break @@ -326,7 +314,7 @@ func (t *ExecTool) guardCommand(command, cwd string) string { } } - if t.restrictToWorkspace { + if restrictToWorkspace { if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") { return "Command blocked by safety guard (path traversal detected)" } @@ -362,6 +350,44 @@ func (t *ExecTool) guardCommand(command, cwd string) string { return "" } +func (t *ExecTool) resolveSandboxWorkingDir(cwd, workspace string) string { + trimmed := strings.TrimSpace(cwd) + if trimmed == "" { + return "." + } + if !filepath.IsAbs(trimmed) { + return trimmed + } + base := strings.TrimSpace(workspace) + if base != "" { + absBase, err := filepath.Abs(base) + if err == nil { + rel, err := filepath.Rel(absBase, trimmed) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + if rel == "." { + return "." + } + return filepath.ToSlash(rel) + } + } + } + if isSandboxWorkspaceAbsolutePath(trimmed, workspace) { + return filepath.ToSlash(trimmed) + } + // Preserve explicit absolute paths in sandbox mode (e.g. /tmp/logs), + // instead of silently downgrading to ".". + return filepath.ToSlash(trimmed) +} + +func isSandboxWorkspaceAbsolutePath(wd, workspace string) bool { + if workspace == "" { + return false + } + cleanWD := path.Clean(filepath.ToSlash(strings.TrimSpace(wd))) + cleanWS := path.Clean(filepath.ToSlash(strings.TrimSpace(workspace))) + return cleanWD == cleanWS || strings.HasPrefix(cleanWD, cleanWS+"/") +} + func (t *ExecTool) SetTimeout(timeout time.Duration) { t.timeout = timeout } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index ff9ea4a152..9d8e046326 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -8,79 +8,150 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/agent/sandbox" "github.com/sipeed/picoclaw/pkg/config" ) -// TestShellTool_Success verifies successful command execution -func TestShellTool_Success(t *testing.T) { - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } +type stubSandbox struct { + lastReq sandbox.ExecRequest + err error + res *sandbox.ExecResult + fs sandbox.FsBridge + workspace string +} - ctx := context.Background() - args := map[string]any{ - "command": "echo 'hello world'", - } +func (s *stubSandbox) Start(ctx context.Context) error { return nil } +func (s *stubSandbox) Prune(ctx context.Context) error { return nil } +func (s *stubSandbox) Resolve(ctx context.Context) (sandbox.Sandbox, error) { + return s, nil +} - result := tool.Execute(ctx, args) +func (s *stubSandbox) GetWorkspace(ctx context.Context) string { + return s.workspace +} - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) +func (s *stubSandbox) Fs() sandbox.FsBridge { + if s.fs != nil { + return s.fs } + return sandbox.NewHostSandbox("", false).Fs() +} - // ForUser should contain command output - if !strings.Contains(result.ForUser, "hello world") { - t.Errorf("Expected ForUser to contain 'hello world', got: %s", result.ForUser) - } +func (s *stubSandbox) Exec(ctx context.Context, req sandbox.ExecRequest) (*sandbox.ExecResult, error) { + return sandboxAggregateFromStub(ctx, req, s.ExecStream) +} - // ForLLM should contain full output - if !strings.Contains(result.ForLLM, "hello world") { - t.Errorf("Expected ForLLM to contain 'hello world', got: %s", result.ForLLM) +func (s *stubSandbox) ExecStream( + ctx context.Context, + req sandbox.ExecRequest, + onEvent func(sandbox.ExecEvent) error, +) (*sandbox.ExecResult, error) { + s.lastReq = req + if s.err != nil { + return nil, s.err + } + if s.res != nil { + if onEvent != nil { + if s.res.Stdout != "" { + if err := onEvent( + sandbox.ExecEvent{Type: sandbox.ExecEventStdout, Chunk: []byte(s.res.Stdout)}, + ); err != nil { + return nil, err + } + } + if s.res.Stderr != "" { + if err := onEvent( + sandbox.ExecEvent{Type: sandbox.ExecEventStderr, Chunk: []byte(s.res.Stderr)}, + ); err != nil { + return nil, err + } + } + if err := onEvent(sandbox.ExecEvent{Type: sandbox.ExecEventExit, ExitCode: s.res.ExitCode}); err != nil { + return nil, err + } + } + return s.res, nil } + if onEvent != nil { + if err := onEvent(sandbox.ExecEvent{Type: sandbox.ExecEventStdout, Chunk: []byte("ok")}); err != nil { + return nil, err + } + if err := onEvent(sandbox.ExecEvent{Type: sandbox.ExecEventExit, ExitCode: 0}); err != nil { + return nil, err + } + } + return &sandbox.ExecResult{Stdout: "ok", ExitCode: 0}, nil } -// TestShellTool_Failure verifies failed command execution -func TestShellTool_Failure(t *testing.T) { - tool, err := NewExecTool("", false) +func sandboxAggregateFromStub( + ctx context.Context, + req sandbox.ExecRequest, + streamFn func(context.Context, sandbox.ExecRequest, func(sandbox.ExecEvent) error) (*sandbox.ExecResult, error), +) (*sandbox.ExecResult, error) { + var stdout strings.Builder + var stderr strings.Builder + exitCode := 0 + res, err := streamFn(ctx, req, func(event sandbox.ExecEvent) error { + switch event.Type { + case sandbox.ExecEventStdout: + _, _ = stdout.Write(event.Chunk) + case sandbox.ExecEventStderr: + _, _ = stderr.Write(event.Chunk) + case sandbox.ExecEventExit: + exitCode = event.ExitCode + } + return nil + }) if err != nil { - t.Errorf("unable to configure exec tool: %s", err) + return nil, err } + if res != nil { + return res, nil + } + return &sandbox.ExecResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, nil +} - ctx := context.Background() - args := map[string]any{ - "command": "ls /nonexistent_directory_12345", +func mustNewExecTool(t *testing.T, workingDir string, restrict bool) *ExecTool { + t.Helper() + tool, err := NewExecTool(workingDir, restrict) + if err != nil { + t.Fatalf("unable to configure exec tool: %v", err) } + return tool +} +func TestShellTool_Success(t *testing.T) { + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + res: &sandbox.ExecResult{Stdout: "hello world", ExitCode: 0}, + }) + args := map[string]any{"command": "echo 'hello world'"} result := tool.Execute(ctx, args) - - // Failure should be marked as error - if !result.IsError { - t.Errorf("Expected error for failed command, got IsError=false") + if result.IsError { + t.Errorf("Expected success, got error: %s", result.ForLLM) } - - // ForUser should contain error information - if result.ForUser == "" { - t.Errorf("Expected ForUser to contain error info, got empty string") + if !strings.Contains(result.ForUser, "hello world") { + t.Errorf("Expected ForUser to contain 'hello world', got: %s", result.ForUser) } +} - // ForLLM should contain exit code or error - if !strings.Contains(result.ForLLM, "Exit code") && result.ForUser == "" { - t.Errorf("Expected ForLLM to contain exit code or error, got: %s", result.ForLLM) +func TestShellTool_Failure(t *testing.T) { + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + res: &sandbox.ExecResult{Stderr: "error", ExitCode: 2}, + }) + args := map[string]any{"command": "ls /fail"} + result := tool.Execute(ctx, args) + if !result.IsError { + t.Errorf("Expected error, got success") } } // TestShellTool_Timeout verifies command timeout handling func TestShellTool_Timeout(t *testing.T) { - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - + tool := mustNewExecTool(t, "", false) tool.SetTimeout(100 * time.Millisecond) - - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{err: context.DeadlineExceeded}) args := map[string]any{ "command": "sleep 10", } @@ -100,17 +171,12 @@ func TestShellTool_Timeout(t *testing.T) { // TestShellTool_WorkingDir verifies custom working directory func TestShellTool_WorkingDir(t *testing.T) { - // Create temp directory tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test content"), 0o644) - - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - - ctx := context.Background() + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + workspace: tmpDir, + res: &sandbox.ExecResult{Stdout: "test content", ExitCode: 0}, + }) args := map[string]any{ "command": "cat test.txt", "working_dir": tmpDir, @@ -129,12 +195,8 @@ func TestShellTool_WorkingDir(t *testing.T) { // TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands func TestShellTool_DangerousCommand(t *testing.T) { - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - - ctx := context.Background() + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{}) args := map[string]any{ "command": "rm -rf /", } @@ -152,12 +214,8 @@ func TestShellTool_DangerousCommand(t *testing.T) { } func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - - ctx := context.Background() + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{}) args := map[string]any{ "command": "kill 12345", } @@ -173,12 +231,8 @@ func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { // TestShellTool_MissingCommand verifies error handling for missing command func TestShellTool_MissingCommand(t *testing.T) { - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - - ctx := context.Background() + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{}) args := map[string]any{} result := tool.Execute(ctx, args) @@ -191,12 +245,10 @@ func TestShellTool_MissingCommand(t *testing.T) { // TestShellTool_StderrCapture verifies stderr is captured and included func TestShellTool_StderrCapture(t *testing.T) { - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - - ctx := context.Background() + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + res: &sandbox.ExecResult{Stdout: "stdout", Stderr: "stderr", ExitCode: 0}, + }) args := map[string]any{ "command": "sh -c 'echo stdout; echo stderr >&2'", } @@ -214,15 +266,12 @@ func TestShellTool_StderrCapture(t *testing.T) { // TestShellTool_OutputTruncation verifies long output is truncated func TestShellTool_OutputTruncation(t *testing.T) { - tool, err := NewExecTool("", false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - - ctx := context.Background() - // Generate long output (>10000 chars) + tool := mustNewExecTool(t, "", false) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + res: &sandbox.ExecResult{Stdout: strings.Repeat("x", 20000), ExitCode: 0}, + }) args := map[string]any{ - "command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000), + "command": "echo large-output", } result := tool.Execute(ctx, args) @@ -233,102 +282,119 @@ func TestShellTool_OutputTruncation(t *testing.T) { } } -// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "workspace") outsideDir := filepath.Join(root, "outside") - if err := os.MkdirAll(workspace, 0o755); err != nil { - t.Fatalf("failed to create workspace: %v", err) - } - if err := os.MkdirAll(outsideDir, 0o755); err != nil { - t.Fatalf("failed to create outside dir: %v", err) - } - - tool, err := NewExecTool(workspace, true) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } + os.MkdirAll(workspace, 0o755) + os.MkdirAll(outsideDir, 0o755) - result := tool.Execute(context.Background(), map[string]any{ + tool := mustNewExecTool(t, workspace, true) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + workspace: workspace, + res: &sandbox.ExecResult{ExitCode: 0}, + }) + result := tool.Execute(ctx, map[string]any{ "command": "pwd", "working_dir": outsideDir, }) - if !result.IsError { - t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "blocked") { - t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) + if !result.IsError || !strings.Contains(result.ForLLM, "blocked") { + t.Fatalf("expected blocked error, got: %s", result.ForLLM) } } -// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace -// pointing outside cannot be used as working_dir to escape the sandbox. func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "workspace") secretDir := filepath.Join(root, "secret") - if err := os.MkdirAll(workspace, 0o755); err != nil { - t.Fatalf("failed to create workspace: %v", err) - } - if err := os.MkdirAll(secretDir, 0o755); err != nil { - t.Fatalf("failed to create secret dir: %v", err) - } + os.MkdirAll(workspace, 0o755) + os.MkdirAll(secretDir, 0o755) os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644) - // symlink lives inside the workspace but resolves to secretDir outside it link := filepath.Join(workspace, "escape") if err := os.Symlink(secretDir, link); err != nil { - t.Skipf("symlinks not supported in this environment: %v", err) + t.Skip("symlinks not supported") } - tool, err := NewExecTool(workspace, true) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) - } - - result := tool.Execute(context.Background(), map[string]any{ + tool := mustNewExecTool(t, workspace, true) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + workspace: workspace, + }) + result := tool.Execute(ctx, map[string]any{ "command": "cat secret.txt", "working_dir": link, }) - if !result.IsError { - t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "blocked") { - t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) + if !result.IsError || !strings.Contains(result.ForLLM, "blocked") { + t.Fatalf("expected blocked error, got: %s", result.ForLLM) } } -// TestShellTool_RestrictToWorkspace verifies workspace restriction func TestShellTool_RestrictToWorkspace(t *testing.T) { tmpDir := t.TempDir() - tool, err := NewExecTool(tmpDir, false) - if err != nil { - t.Errorf("unable to configure exec tool: %s", err) + tool := mustNewExecTool(t, tmpDir, true) + ctx := sandbox.WithSandbox(context.Background(), &stubSandbox{ + workspace: tmpDir, + }) + args := map[string]any{"command": "cat ../../etc/passwd"} + result := tool.Execute(ctx, args) + if !result.IsError || !strings.Contains(result.ForLLM, "blocked") { + t.Errorf("Expected path traversal to be blocked") } +} - tool.SetRestrictToWorkspace(true) +func TestShellTool_SandboxMapsHostWorkingDirToRelative(t *testing.T) { + workspace := t.TempDir() + sb := &stubSandbox{workspace: workspace} + tool := mustNewExecTool(t, workspace, true) - ctx := context.Background() + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ - "command": "cat ../../etc/passwd", + "command": "echo test", + "working_dir": filepath.Join(workspace, "subdir"), } - result := tool.Execute(ctx, args) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if sb.lastReq.WorkingDir != "subdir" { + t.Fatalf("sandbox working_dir = %q, want subdir", sb.lastReq.WorkingDir) + } +} - // Path traversal should be blocked - if !result.IsError { - t.Errorf("Expected path traversal to be blocked with restrictToWorkspace=true") +func TestShellTool_SandboxAllowsAbsoluteWorkspaceWorkingDir(t *testing.T) { + workspace := "/workspace" + sb := &stubSandbox{workspace: workspace} + tool := mustNewExecTool(t, workspace, true) + + ctx := sandbox.WithSandbox(context.Background(), sb) + args := map[string]any{ + "command": "echo test", + "working_dir": "/workspace/subdir", + } + result := tool.Execute(ctx, args) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) } + if sb.lastReq.WorkingDir != "subdir" && sb.lastReq.WorkingDir != "/workspace/subdir" { + t.Fatalf("sandbox working_dir = %q, want subdir or /workspace/subdir", sb.lastReq.WorkingDir) + } +} - if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { - t.Errorf( - "Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", - result.ForLLM, - result.ForUser, - ) +func TestShellTool_SandboxBlocksAbsoluteNonWorkspaceWorkingDirWhenRestricted(t *testing.T) { + workspace := t.TempDir() + sb := &stubSandbox{workspace: workspace} + tool := mustNewExecTool(t, workspace, true) + + ctx := sandbox.WithSandbox(context.Background(), sb) + args := map[string]any{ + "command": "echo test", + "working_dir": "/tmp/logs", + } + result := tool.Execute(ctx, args) + if !result.IsError || !strings.Contains(result.ForLLM, "blocked") { + t.Fatalf("expected blocked error, got: %s", result.ForLLM) } } diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go index 357e1276ef..d371b95304 100644 --- a/pkg/tools/shell_timeout_unix_test.go +++ b/pkg/tools/shell_timeout_unix_test.go @@ -11,6 +11,8 @@ import ( "syscall" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/agent/sandbox" ) func processExists(pid int) bool { @@ -22,19 +24,25 @@ func processExists(pid int) bool { } func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { - tool, err := NewExecTool(t.TempDir(), false) + workspace := t.TempDir() + tool := mustNewExecTool(t, workspace, false) + tool.SetTimeout(500 * time.Millisecond) + + sb := sandbox.NewHostSandbox(workspace, false) + err := sb.Start(context.Background()) if err != nil { - t.Errorf("unable to configure exec tool: %s", err) + t.Fatalf("failed to start sandbox: %v", err) } + defer sb.Prune(context.Background()) - tool.SetTimeout(500 * time.Millisecond) + ctx := sandbox.WithSandbox(context.Background(), sb) args := map[string]any{ // Spawn a child process that would outlive the shell unless process-group kill is used. "command": "sleep 60 & echo $! > child.pid; wait", } - result := tool.Execute(context.Background(), args) + result := tool.Execute(ctx, args) if !result.IsError { t.Fatalf("expected timeout error, got success: %s", result.ForLLM) } diff --git a/scripts/build-sandbox.sh b/scripts/build-sandbox.sh new file mode 100755 index 0000000000..eed8c911aa --- /dev/null +++ b/scripts/build-sandbox.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# PicoClaw Sandbox Build Script + +set -e + +# Base directory +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOCKERFILE="${REPO_ROOT}/docker/Dockerfile.sandbox" +IMAGE_NAME="picoclaw-sandbox:bookworm-slim" + +echo "Building PicoClaw sandbox image: ${IMAGE_NAME}..." + +if [ ! -f "${DOCKERFILE}" ]; then + echo "Error: Dockerfile.sandbox not found at ${DOCKERFILE}" + exit 1 +fi + +docker build --no-cache -t "${IMAGE_NAME}" -f "${DOCKERFILE}" "${REPO_ROOT}" + +echo "Successfully built ${IMAGE_NAME}"