feat: inital commit for sandbox support

This commit is contained in:
0x5487 2026-02-20 22:45:20 +08:00
parent e599573ed4
commit e3dfe5f95c
36 changed files with 4507 additions and 56 deletions

33
go.mod
View file

@ -21,24 +21,46 @@ require (
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
require (
github.com/Microsoft/go-winio v0.4.21 // indirect
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/cloudwego/base64x v0.1.6 // 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 // indirect
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/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/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/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@ -47,9 +69,14 @@ 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/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

89
go.sum
View file

@ -1,4 +1,8 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
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/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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@ -15,8 +19,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=
@ -25,15 +33,36 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/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/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=
@ -71,6 +100,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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@ -80,14 +111,25 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
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/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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@ -102,18 +144,29 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
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/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=
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=
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/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/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.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=
@ -149,6 +202,24 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
@ -196,11 +267,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=
@ -224,6 +297,8 @@ 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/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -237,6 +312,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
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=
@ -245,6 +326,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -259,3 +342,5 @@ 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=

1
openclaw-temp Submodule

@ -0,0 +1 @@
Subproject commit beb2b74b5b1c4e0d47c1d7952a9473fae4380134

View file

@ -5,6 +5,7 @@ import (
"path/filepath"
"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,14 +46,54 @@ func NewAgentInstance(
model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
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
}
restrict := defaults.RestrictToWorkspace
sb := sandbox.NewFromConfigWithAgent(workspace, restrict, cfg, agentID)
readSb := sb
if !sandbox.IsToolSandboxEnabled(cfg, "read_file") {
readSb = nil
}
writeSb := sb
if !sandbox.IsToolSandboxEnabled(cfg, "write_file") {
writeSb = nil
}
execSb := sb
if !sandbox.IsToolSandboxEnabled(cfg, "exec") {
execSb = nil
}
roContainer := isContainerReadOnlySandbox(cfg)
toolsRegistry := tools.NewToolRegistry()
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewReadFileToolWithSandbox(workspace, restrict, readSb))
if roContainer {
toolsRegistry.Register(tools.NewDisabledTool(
"write_file",
"Write content to a file",
"write_file is disabled when sandbox workspace_access=ro",
))
} else {
toolsRegistry.Register(tools.NewWriteFileToolWithSandbox(workspace, restrict, writeSb))
}
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewExecToolWithSandbox(workspace, restrict, cfg, execSb))
if roContainer {
toolsRegistry.Register(tools.NewDisabledTool(
"edit_file",
"Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file.",
"edit_file is disabled when sandbox workspace_access=ro",
))
} else {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
}
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
sessionsDir := filepath.Join(workspace, "sessions")
@ -61,18 +102,6 @@ func NewAgentInstance(
contextBuilder := NewContextBuilder(workspace)
contextBuilder.SetToolsRegistry(toolsRegistry)
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
@ -144,6 +173,14 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD
return defaults.ModelFallbacks
}
func isContainerReadOnlySandbox(cfg *config.Config) bool {
if cfg == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(cfg.Agents.Defaults.Sandbox.Mode), "all") &&
strings.EqualFold(strings.TrimSpace(cfg.Agents.Defaults.Sandbox.WorkspaceAccess), "ro")
}
func expandHome(path string) string {
if path == "" {
return path

View file

@ -1,7 +1,9 @@
package agent
import (
"context"
"os"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
@ -93,3 +95,46 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
}
}
func TestNewAgentInstance_ReadOnlyContainerDisablesWriteAndEdit(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.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
Sandbox: config.AgentSandboxConfig{
Mode: "all",
WorkspaceAccess: "ro",
},
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
writeRes := agent.Tools.Execute(context.Background(), "write_file", map[string]interface{}{
"path": "a.txt",
"content": "hello",
})
if !writeRes.IsError || !strings.Contains(writeRes.ForLLM, "workspace_access=ro") {
t.Fatalf("write_file should be disabled in ro sandbox, got: %+v", writeRes)
}
editRes := agent.Tools.Execute(context.Background(), "edit_file", map[string]interface{}{
"path": "a.txt",
"old_text": "h",
"new_text": "H",
})
if !editRes.IsError || !strings.Contains(editRes.ForLLM, "workspace_access=ro") {
t.Fatalf("edit_file should be disabled in ro sandbox, got: %+v", editRes)
}
}

View file

@ -16,6 +16,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/config"
@ -653,7 +654,8 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
}
}
toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
toolCtx := sandbox.WithSessionKey(ctx, opts.SessionKey)
toolResult := agent.Tools.ExecuteWithContext(toolCtx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {

View file

@ -0,0 +1,938 @@
package sandbox
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"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/pkg/config"
)
// 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 {
cfg ContainerSandboxConfig
cli *client.Client
startErr error
fs FsBridge
hash string
loopMu sync.Mutex
loopStop context.CancelFunc
loopDone chan struct{}
}
// NewContainerSandbox creates a container sandbox with normalized defaults and precomputed config hash.
func NewContainerSandbox(cfg ContainerSandboxConfig) *ContainerSandbox {
if strings.TrimSpace(cfg.Image) == "" {
cfg.Image = "debian:bookworm-slim"
}
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 len(cfg.CapDrop) == 0 {
cfg.CapDrop = []string{"ALL"}
}
if cfg.Env == nil {
cfg.Env = map[string]string{"LANG": "C.UTF-8"}
}
cfg.WorkspaceAccess = normalizeWorkspaceAccess(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, validates config, and starts background prune scheduling.
func (c *ContainerSandbox) Start(ctx context.Context) error {
if err := validateSandboxSecurity(c.cfg); err != nil {
c.startErr = err
return err
}
c.cfg.Env = sanitizeEnvVars(c.cfg.Env)
if strings.TrimSpace(c.cfg.Workspace) != "" && c.cfg.WorkspaceAccess == "none" {
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 {
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()
_, _ = io.Copy(io.Discard, rc)
}
c.startErr = nil
_ = c.maybePrune(ctx)
c.ensurePruneLoop()
return nil
}
// Stop terminates background prune scheduling and stops the managed container if present.
func (c *ContainerSandbox) Stop(ctx context.Context) error {
c.stopPruneLoop(ctx)
if c.cli == nil {
return nil
}
if c.cfg.ContainerName != "" {
_ = c.cli.ContainerStop(ctx, c.cfg.ContainerName, container.StopOptions{})
}
return nil
}
func (c *ContainerSandbox) ensurePruneLoop() {
if c.cfg.PruneIdleHours <= 0 && c.cfg.PruneMaxAgeDays <= 0 {
return
}
c.loopMu.Lock()
defer c.loopMu.Unlock()
if c.loopStop != nil {
return
}
loopCtx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
c.loopStop = cancel
c.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:
_ = c.maybePrune(loopCtx)
}
}
}()
}
func (c *ContainerSandbox) stopPruneLoop(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
c.loopMu.Lock()
stop := c.loopStop
done := c.loopDone
c.loopStop = nil
c.loopDone = nil
c.loopMu.Unlock()
if stop == nil {
return
}
stop()
if done == nil {
return
}
select {
case <-done:
case <-ctx.Done():
}
}
// 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, durationMs(req.TimeoutMs))
} 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)
}
defer attach.Close()
var stdout, stderr bytes.Buffer
stdoutWriter := &execStreamWriter{
eventType: ExecEventStdout,
onEvent: onEvent,
buffer: &stdout,
}
stderrWriter := &execStreamWriter{
eventType: ExecEventStderr,
onEvent: onEvent,
buffer: &stderr,
}
if _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, attach.Reader); 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) ensureContainer(ctx context.Context) error {
_ = c.maybePrune(ctx)
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
}
}
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)
}
}
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"},
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)
if workspace != "" {
abs, err := filepath.Abs(workspace)
if err == nil {
switch c.cfg.WorkspaceAccess {
case "ro":
binds = append(binds, fmt.Sprintf("%s:%s:ro", abs, c.cfg.Workdir))
case "rw":
binds = append(binds, fmt.Sprintf("%s:%s:rw", abs, c.cfg.Workdir))
default:
binds = append(binds, fmt.Sprintf("%s:%s", abs, c.cfg.Workdir))
}
}
}
for _, bind := range c.cfg.Binds {
if strings.TrimSpace(bind) != "" {
binds = append(binds, strings.TrimSpace(bind))
}
}
return binds
}
func (c *ContainerSandbox) registryPath() string {
root := strings.TrimSpace(c.cfg.WorkspaceRoot)
if root == "" {
root = strings.TrimSpace(c.cfg.Workspace)
}
if root == "" {
root = osTempDir()
}
return filepath.Join(root, "state", "registry.json")
}
func (c *ContainerSandbox) maybePrune(ctx context.Context) error {
if c.cfg.PruneIdleHours <= 0 && c.cfg.PruneMaxAgeDays <= 0 {
return nil
}
if c.cli == nil {
return nil
}
regPath := c.registryPath()
registryMu.Lock()
data, err := loadRegistry(regPath)
registryMu.Unlock()
if err != nil {
return err
}
now := time.Now().UnixMilli()
for _, e := range data.Entries {
if !shouldPruneEntry(c.cfg, now, e) {
continue
}
_ = c.cli.ContainerRemove(ctx, e.ContainerName, container.RemoveOptions{Force: true})
_ = removeRegistryEntry(regPath, e.ContainerName)
}
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", containerPath)
}
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
}
dir := path.Dir(containerPath)
base := path.Base(containerPath)
if mkdir {
hostDir, ok := f.sb.hostDirForContainerPath(dir)
if ok {
if err := os.MkdirAll(hostDir, 0o755); err != nil {
return fmt.Errorf("host mkdir failed: %w", err)
}
} else {
_, err := f.sb.Exec(ctx, ExecRequest{
Command: "mkdir -p " + shellEscape(dir),
})
if err != nil {
return err
}
}
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tw.WriteHeader(&tar.Header{
Name: base,
Mode: 0644,
Size: int64(len(data)),
}); err != nil {
_ = tw.Close()
return fmt.Errorf("tar header write failed: %w", err)
}
if _, err := tw.Write(data); err != nil {
_ = tw.Close()
return fmt.Errorf("tar content write failed: %w", err)
}
if err := tw.Close(); err != nil {
return fmt.Errorf("tar close failed: %w", err)
}
if err := f.sb.cli.CopyToContainer(ctx, f.sb.cfg.ContainerName, dir, &buf, container.CopyToContainerOptions{
AllowOverwriteDirWithFile: true,
}); err != nil {
return fmt.Errorf("docker copy to container failed: %w", err)
}
return nil
}
func (c *ContainerSandbox) hostDirForContainerPath(containerDir string) (string, bool) {
if c.cfg.WorkspaceAccess == "ro" {
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"`
}{
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,
}
raw, _ := json.Marshal(payload)
return computeConfigHash(string(raw))
}

View file

@ -0,0 +1,225 @@
package sandbox
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/docker/docker/client"
"github.com/sipeed/picoclaw/pkg/config"
)
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.Stop(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_PruneLoopLifecycleAndNoopPrune(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{
PruneIdleHours: 1,
PruneMaxAgeDays: 0,
})
sb.ensurePruneLoop()
if sb.loopStop == nil || sb.loopDone == nil {
t.Fatal("expected prune loop to start")
}
sb.stopPruneLoop(nil)
if sb.loopStop != nil || sb.loopDone != nil {
t.Fatal("expected prune loop state reset after stop")
}
if err := sb.maybePrune(context.Background()); err != nil {
t.Fatalf("maybePrune() with nil client should be noop, got: %v", err)
}
}
func TestContainerSandbox_MaybePruneDisabledAndLoadError(t *testing.T) {
disabled := NewContainerSandbox(ContainerSandboxConfig{})
if err := disabled.maybePrune(context.Background()); err != nil {
t.Fatalf("maybePrune() should return nil when both prune rules disabled: %v", err)
}
root := t.TempDir()
stateDir := filepath.Join(root, "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatalf("mkdir state dir: %v", err)
}
regPath := filepath.Join(stateDir, "registry.json")
if err := os.WriteFile(regPath, []byte("{not-json"), 0o644); err != nil {
t.Fatalf("write invalid registry: %v", err)
}
sb := NewContainerSandbox(ContainerSandboxConfig{
WorkspaceRoot: root,
PruneIdleHours: 1,
PruneMaxAgeDays: 0,
})
sb.cli = &client.Client{}
if err := sb.maybePrune(context.Background()); err == nil {
t.Fatal("expected maybePrune() to return registry load error")
}
}
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,
})
sb.ensurePruneLoop()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := sb.Stop(ctx); err != nil {
t.Fatalf("Stop() error: %v", err)
}
}

View file

@ -0,0 +1,353 @@
package sandbox
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
func TestContainerSandbox_Integration_ExecReadWrite(t *testing.T) {
if os.Getenv("PICOCLAW_RUN_DOCKER_TESTS") != "1" {
t.Skip("set PICOCLAW_RUN_DOCKER_TESTS=1 to run docker integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
t.Skipf("docker client unavailable: %v", err)
}
defer cli.Close()
if _, err := cli.Ping(ctx); err != nil {
t.Skipf("docker daemon unavailable: %v", err)
}
workspace := t.TempDir()
containerName := fmt.Sprintf("picoclaw-test-%d", time.Now().UnixNano())
image := strings.TrimSpace(os.Getenv("PICOCLAW_DOCKER_TEST_IMAGE"))
if image == "" {
image = "debian:bookworm-slim"
}
sb := NewContainerSandbox(ContainerSandboxConfig{
Image: image,
ContainerName: containerName,
Workspace: workspace,
})
if err := sb.Start(ctx); err != nil {
t.Fatalf("sandbox start failed: %v", err)
}
defer func() {
_ = sb.Stop(context.Background())
if sb.cli != nil {
_ = sb.cli.ContainerRemove(context.Background(), containerName, container.RemoveOptions{Force: true})
}
}()
content := []byte("hello from integration test")
if err := sb.Fs().WriteFile(ctx, "it/write.txt", content, true); err != nil {
t.Fatalf("write file failed: %v", err)
}
readBack, err := sb.Fs().ReadFile(ctx, "it/write.txt")
if err != nil {
t.Fatalf("read file failed: %v", err)
}
if string(readBack) != string(content) {
t.Fatalf("read content mismatch: got %q want %q", string(readBack), string(content))
}
hostBytes, err := os.ReadFile(filepath.Join(workspace, "it", "write.txt"))
if err != nil {
t.Fatalf("host workspace read failed: %v", err)
}
if string(hostBytes) != string(content) {
t.Fatalf("host content mismatch: got %q want %q", string(hostBytes), string(content))
}
execRes, err := sb.Exec(ctx, ExecRequest{
Command: "cat /workspace/it/write.txt",
})
if err != nil {
t.Fatalf("exec cat failed: %v", err)
}
if execRes.ExitCode != 0 {
t.Fatalf("exec cat exit code = %d, stderr = %q", execRes.ExitCode, execRes.Stderr)
}
if strings.TrimSpace(execRes.Stdout) != string(content) {
t.Fatalf("exec cat stdout mismatch: got %q want %q", strings.TrimSpace(execRes.Stdout), string(content))
}
pwdRes, err := sb.Exec(ctx, ExecRequest{
Command: "pwd",
WorkingDir: "it/",
})
if err != nil {
t.Fatalf("exec pwd failed: %v", err)
}
if pwdRes.ExitCode != 0 {
t.Fatalf("exec pwd exit code = %d, stderr = %q", pwdRes.ExitCode, pwdRes.Stderr)
}
if strings.TrimSpace(pwdRes.Stdout) != "/workspace/it" {
t.Fatalf("pwd mismatch: got %q want %q", strings.TrimSpace(pwdRes.Stdout), "/workspace/it")
}
}
func TestContainerSandbox_Integration_WriteFileMkdirInContainerTmp(t *testing.T) {
if os.Getenv("PICOCLAW_RUN_DOCKER_TESTS") != "1" {
t.Skip("set PICOCLAW_RUN_DOCKER_TESTS=1 to run docker integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
t.Skipf("docker client unavailable: %v", err)
}
defer cli.Close()
if _, err := cli.Ping(ctx); err != nil {
t.Skipf("docker daemon unavailable: %v", err)
}
containerName := fmt.Sprintf("picoclaw-test-mkdir-%d", time.Now().UnixNano())
image := strings.TrimSpace(os.Getenv("PICOCLAW_DOCKER_TEST_IMAGE"))
if image == "" {
image = "debian:bookworm-slim"
}
sb := NewContainerSandbox(ContainerSandboxConfig{
Image: image,
ContainerName: containerName,
})
if err := sb.Start(ctx); err != nil {
t.Fatalf("sandbox start failed: %v", err)
}
defer func() {
_ = sb.Stop(context.Background())
if sb.cli != nil {
_ = sb.cli.ContainerRemove(context.Background(), containerName, container.RemoveOptions{Force: true})
}
}()
content := []byte("mkdir path works")
if err := sb.Fs().WriteFile(ctx, "/workspace/it_mkdir/nested/file.txt", content, true); err != nil {
t.Fatalf("write with mkdir failed: %v", err)
}
out, err := sb.Exec(ctx, ExecRequest{
Command: "cat /workspace/it_mkdir/nested/file.txt",
})
if err != nil {
t.Fatalf("exec cat failed: %v", err)
}
if out.ExitCode != 0 {
t.Fatalf("exec cat exit code = %d, stderr = %q", out.ExitCode, out.Stderr)
}
if strings.TrimSpace(out.Stdout) != string(content) {
t.Fatalf("exec cat stdout mismatch: got %q want %q", strings.TrimSpace(out.Stdout), string(content))
}
}
func TestContainerSandbox_Integration_SetupCommandSuccess(t *testing.T) {
if os.Getenv("PICOCLAW_RUN_DOCKER_TESTS") != "1" {
t.Skip("set PICOCLAW_RUN_DOCKER_TESTS=1 to run docker integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
containerName := fmt.Sprintf("picoclaw-test-setup-ok-%d", time.Now().UnixNano())
image := strings.TrimSpace(os.Getenv("PICOCLAW_DOCKER_TEST_IMAGE"))
if image == "" {
image = "debian:bookworm-slim"
}
sb := NewContainerSandbox(ContainerSandboxConfig{
Image: image,
ContainerName: containerName,
Workspace: t.TempDir(),
SetupCommand: "true",
})
if err := sb.Start(ctx); err != nil {
t.Fatalf("sandbox start failed: %v", err)
}
defer func() {
_ = sb.Stop(context.Background())
if sb.cli != nil {
_ = sb.cli.ContainerRemove(context.Background(), containerName, container.RemoveOptions{Force: true})
}
}()
out, err := sb.Exec(ctx, ExecRequest{
Command: "echo setup-ok",
})
if err != nil {
t.Fatalf("exec after setup_command failed: %v", err)
}
if out.ExitCode != 0 {
t.Fatalf("unexpected exit code=%d stderr=%q", out.ExitCode, out.Stderr)
}
if strings.TrimSpace(out.Stdout) != "setup-ok" {
t.Fatalf("unexpected setup content: %q", out.Stdout)
}
}
func TestContainerSandbox_Integration_SetupCommandFailureRemovesContainer(t *testing.T) {
if os.Getenv("PICOCLAW_RUN_DOCKER_TESTS") != "1" {
t.Skip("set PICOCLAW_RUN_DOCKER_TESTS=1 to run docker integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
containerName := fmt.Sprintf("picoclaw-test-setup-fail-%d", time.Now().UnixNano())
image := strings.TrimSpace(os.Getenv("PICOCLAW_DOCKER_TEST_IMAGE"))
if image == "" {
image = "debian:bookworm-slim"
}
sb := NewContainerSandbox(ContainerSandboxConfig{
Image: image,
ContainerName: containerName,
Workspace: t.TempDir(),
SetupCommand: "echo boom >&2; exit 7",
})
if err := sb.Start(ctx); err != nil {
t.Fatalf("sandbox start failed: %v", err)
}
defer func() {
_ = sb.Stop(context.Background())
if sb.cli != nil {
_ = sb.cli.ContainerRemove(context.Background(), containerName, container.RemoveOptions{Force: true})
}
}()
_, err := sb.Exec(ctx, ExecRequest{Command: "echo never"})
if err == nil || !strings.Contains(err.Error(), "setup_command failed") {
t.Fatalf("expected setup_command failed error, got: %v", err)
}
_, inspectErr := sb.cli.ContainerInspect(ctx, containerName)
if inspectErr == nil {
t.Fatal("expected failed setup to remove container, but container still exists")
}
}
func TestContainerSandbox_Integration_MaybePruneRemovesOldContainer(t *testing.T) {
if os.Getenv("PICOCLAW_RUN_DOCKER_TESTS") != "1" {
t.Skip("set PICOCLAW_RUN_DOCKER_TESTS=1 to run docker integration tests")
}
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 := strings.TrimSpace(os.Getenv("PICOCLAW_DOCKER_TEST_IMAGE"))
if image == "" {
image = "debian:bookworm-slim"
}
sb := NewContainerSandbox(ContainerSandboxConfig{
Image: image,
ContainerName: containerName,
WorkspaceRoot: root,
WorkspaceAccess: "none",
PruneIdleHours: 1,
PruneMaxAgeDays: 0,
})
if err := sb.Start(ctx); err != nil {
t.Fatalf("sandbox start failed: %v", err)
}
defer func() {
_ = sb.Stop(context.Background())
if sb.cli != nil {
_ = sb.cli.ContainerRemove(context.Background(), containerName, container.RemoveOptions{Force: true})
}
}()
if _, err := sb.Exec(ctx, ExecRequest{Command: "echo alive"}); err != nil {
t.Fatalf("exec create failed: %v", err)
}
now := time.Now().UnixMilli()
if err := upsertRegistryEntry(sb.registryPath(), registryEntry{
ContainerName: containerName,
Image: image,
ConfigHash: sb.hash,
CreatedAtMs: now - int64(2*time.Hour/time.Millisecond),
LastUsedAtMs: now - int64(2*time.Hour/time.Millisecond),
}); err != nil {
t.Fatalf("upsert old registry entry failed: %v", err)
}
if err := sb.maybePrune(ctx); err != nil {
t.Fatalf("maybePrune failed: %v", err)
}
if _, err := sb.cli.ContainerInspect(ctx, containerName); err == nil {
t.Fatal("expected container to be removed by prune")
}
data, err := loadRegistry(sb.registryPath())
if err != nil {
t.Fatalf("loadRegistry failed: %v", err)
}
for _, e := range data.Entries {
if e.ContainerName == containerName {
t.Fatal("expected pruned container to be removed from registry")
}
}
}
func TestContainerSandbox_Integration_ExecTimeoutRespectsRequest(t *testing.T) {
if os.Getenv("PICOCLAW_RUN_DOCKER_TESTS") != "1" {
t.Skip("set PICOCLAW_RUN_DOCKER_TESTS=1 to run docker integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
containerName := fmt.Sprintf("picoclaw-test-timeout-%d", time.Now().UnixNano())
image := strings.TrimSpace(os.Getenv("PICOCLAW_DOCKER_TEST_IMAGE"))
if image == "" {
image = "debian:bookworm-slim"
}
sb := NewContainerSandbox(ContainerSandboxConfig{
Image: image,
ContainerName: containerName,
Workspace: t.TempDir(),
})
if err := sb.Start(ctx); err != nil {
t.Fatalf("sandbox start failed: %v", err)
}
defer func() {
_ = sb.Stop(context.Background())
if sb.cli != nil {
_ = sb.cli.ContainerRemove(context.Background(), containerName, container.RemoveOptions{Force: true})
}
}()
start := time.Now()
_, err := sb.Exec(ctx, ExecRequest{
Command: "sleep 3",
TimeoutMs: 200,
})
if err == nil {
t.Fatal("expected timeout error")
}
if time.Since(start) > 2*time.Second {
t.Fatalf("expected timeout to trigger early, took %v", time.Since(start))
}
}

View file

@ -0,0 +1,284 @@
package sandbox
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestResolveContainerPath_Relative(t *testing.T) {
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 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") {
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") {
t.Fatalf("unexpected rw bind: %#v", rwBinds)
}
none := NewContainerSandbox(ContainerSandboxConfig{
Workspace: filepath.Join(root, "ws-none"),
WorkspaceAccess: "none",
Workdir: "/workspace",
})
noneBinds := none.binds()
if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace") {
t.Fatalf("unexpected none bind: %#v", noneBinds)
}
}
func TestContainerSandbox_RegistryPath_UsesWorkspaceRoot(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{
Workspace: "/tmp/ws",
WorkspaceRoot: "/tmp/sbx",
})
if got := sb.registryPath(); got != "/tmp/sbx/state/registry.json" {
t.Fatalf("registryPath = %q, want %q", got, "/tmp/sbx/state/registry.json")
}
}
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)
}
}

View file

@ -0,0 +1,380 @@
package sandbox
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/routing"
)
// NewFromConfig builds a sandbox instance from config and starts it before returning.
func NewFromConfig(workspace string, restrict bool, cfg *config.Config) Sandbox {
return NewFromConfigWithAgent(workspace, restrict, cfg, routing.DefaultAgentID)
}
// NewFromConfigWithAgent builds a sandbox instance with an explicit agent ID context.
func NewFromConfigWithAgent(workspace string, restrict bool, cfg *config.Config, agentID string) Sandbox {
mode := "off"
scope := "agent"
workspaceAccess := "none"
workspaceRoot := "~/.picoclaw/sandboxes"
image := "debian:bookworm-slim"
containerPrefix := "picoclaw-sandbox-"
pruneIdleHours := 24
pruneMaxAgeDays := 7
dockerCfg := config.AgentSandboxDockerConfig{}
if cfg != nil {
sb := cfg.Agents.Defaults.Sandbox
if strings.TrimSpace(sb.Mode) != "" {
mode = strings.TrimSpace(sb.Mode)
}
if strings.TrimSpace(sb.Scope) != "" {
scope = strings.TrimSpace(sb.Scope)
}
if strings.TrimSpace(sb.WorkspaceAccess) != "" {
workspaceAccess = strings.TrimSpace(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 >= 0 {
pruneIdleHours = sb.Prune.IdleHours
}
if sb.Prune.MaxAgeDays >= 0 {
pruneMaxAgeDays = sb.Prune.MaxAgeDays
}
dockerCfg = sb.Docker
}
agentID = routing.NormalizeAgentID(agentID)
host := NewHostSandbox(workspace, restrict)
_ = host.Start(context.Background())
resolvedMode := normalizeSandboxMode(mode)
if resolvedMode == "off" {
return 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 NewUnavailableSandbox(fmt.Errorf("container sandbox unavailable: %w", err))
}
return manager
}
func normalizeWorkspaceAccess(access string) string {
v := strings.ToLower(strings.TrimSpace(access))
switch v {
case "ro", "rw":
return v
default:
return "none"
}
}
func normalizeSandboxMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "all", "non-main":
return strings.ToLower(strings.TrimSpace(mode))
default:
return "off"
}
}
func normalizeSandboxScope(scope string) string {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "session", "shared":
return strings.ToLower(strings.TrimSpace(scope))
default:
return "agent"
}
}
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 string
scope string
agentID string
host Sandbox
image string
containerPrefix string
workspaceAccess string
workspaceRoot string
agentWorkspace string
pruneIdleHours int
pruneMaxAgeDays int
dockerCfg config.AgentSandboxDockerConfig
mu sync.Mutex
scoped map[string]Sandbox
fs FsBridge
}
func (m *scopedSandboxManager) Start(ctx context.Context) error {
if m.mode == "off" {
return nil
}
_, err := m.getOrCreateSandbox(ctx, m.defaultScopeKey())
return err
}
func (m *scopedSandboxManager) Stop(ctx context.Context) error {
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.Stop(ctx); 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) shouldSandbox(ctx context.Context) bool {
switch m.mode {
case "all":
return true
case "non-main":
// Phase 2 deferred: `non-main` requires stable session-key propagation
// across all tool execution paths. For now, keep behavior disabled until
// the execution context plumbing is finalized.
return false
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 "shared":
return "shared"
case "session":
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
}
sb := m.buildScopedContainerSandbox(scopeKey)
m.scoped[scopeKey] = sb
m.mu.Unlock()
if err := sb.Start(ctx); err != nil {
m.mu.Lock()
delete(m.scoped, scopeKey)
m.mu.Unlock()
return nil, err
}
return sb, nil
}
func (m *scopedSandboxManager) buildScopedContainerSandbox(scopeKey string) Sandbox {
workspace := m.agentWorkspace
if m.workspaceAccess == "none" || 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: 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)
}
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])
}

View file

@ -0,0 +1,52 @@
package sandbox
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
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) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Sandbox.Mode = "off"
sb := NewFromConfig(t.TempDir(), true, cfg)
if _, ok := sb.(*HostSandbox); !ok {
t.Fatalf("expected HostSandbox, got %T", sb)
}
if err := sb.Stop(context.Background()); err != nil {
t.Fatalf("Stop() 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 = 0
cfg.Agents.Defaults.Sandbox.Prune.MaxAgeDays = 0
sb := NewFromConfig(t.TempDir(), true, cfg)
if _, ok := sb.(*unavailableSandbox); !ok {
t.Fatalf("expected unavailableSandbox, got %T", sb)
}
if err := sb.Start(context.Background()); err == nil {
t.Fatal("expected unavailable sandbox start error")
}
}

View file

@ -0,0 +1,15 @@
package sandbox
import "testing"
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)
}
}

239
pkg/agent/sandbox/host.go Normal file
View file

@ -0,0 +1,239 @@
package sandbox
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
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 {
return nil
}
func (h *HostSandbox) Stop(ctx context.Context) error {
return nil
}
func (h *HostSandbox) Fs() FsBridge {
return h.fs
}
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, durationMs(req.TimeoutMs))
}
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 := h.resolvePath(req.WorkingDir)
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)
}
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 {
var ee *exec.ExitError
if ok := asExitError(waitErr, &ee); 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
}
func (h *hostFS) ReadFile(ctx context.Context, path string) ([]byte, error) {
resolved, err := resolvePath(path, h.workspace, h.restrict)
if err != nil {
return nil, err
}
return os.ReadFile(resolved)
}
func (h *hostFS) WriteFile(ctx context.Context, path string, data []byte, mkdir bool) error {
resolved, err := resolvePath(path, h.workspace, h.restrict)
if err != nil {
return err
}
if mkdir {
if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil {
return err
}
}
return os.WriteFile(resolved, data, 0644)
}
func (h *HostSandbox) resolvePath(path string) (string, error) {
return resolvePath(path, h.workspace, h.restrict)
}
func resolvePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
return path, 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 {
return absPath, nil
}
rel, err := filepath.Rel(absWorkspace, absPath)
if err != nil {
return "", fmt.Errorf("failed to resolve relative path: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
workspaceReal := absWorkspace
if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
relResolved, err := filepath.Rel(workspaceReal, resolved)
if err != nil || relResolved == ".." || strings.HasPrefix(relResolved, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
}
return absPath, nil
}

View file

@ -0,0 +1,132 @@
package sandbox
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestHostSandbox_StartStopFs(t *testing.T) {
sb := NewHostSandbox(t.TempDir(), true)
if err := sb.Start(context.Background()); err != nil {
t.Fatalf("Start() error: %v", err)
}
if err := sb.Stop(context.Background()); err != nil {
t.Fatalf("Stop() 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 || !strings.Contains(err.Error(), "outside the workspace") {
t.Fatalf("expected working dir restriction error, got: %v", err)
}
if err := sb.Fs().WriteFile(context.Background(), "dir/a.txt", []byte("x"), true); 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()
sb := NewHostSandbox(root, true)
got, err := sb.resolvePath("a/b.txt")
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 = sb.resolvePath(filepath.Join(root, "..", "outside.txt"))
if err == nil || !strings.Contains(err.Error(), "outside the workspace") {
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 = sb.resolvePath("link.txt")
if err == nil || !strings.Contains(err.Error(), "symlink resolves outside workspace") {
t.Fatalf("expected symlink outside error, got: %v", err)
}
}
}
func TestUnavailableSandboxAndUtilHelpers(t *testing.T) {
sb := NewUnavailableSandbox(nil)
if err := sb.Start(context.Background()); err == nil {
t.Fatal("expected Start() error")
}
if err := sb.Stop(context.Background()); err != nil {
t.Fatalf("Stop() 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")
}
if got := durationMs(123).Milliseconds(); got != 123 {
t.Fatalf("durationMs() got %d, want 123", got)
}
if asExitError(errors.New("x"), nil) {
t.Fatal("asExitError should be false for non-exit errors")
}
}

View file

@ -0,0 +1,164 @@
package sandbox
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
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), 0755); 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, 0600)
if err == nil {
_ = f.Close()
return &registryFileLock{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 &registryData{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 {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
raw, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
tmp := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
if err := os.WriteFile(tmp, append(raw, '\n'), 0644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
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)
}

View file

@ -0,0 +1,45 @@
package sandbox
import (
"path/filepath"
"testing"
"time"
)
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")
}
}

View file

@ -0,0 +1,110 @@
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)
}
if err := removeRegistryEntry(path, "c1"); 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")
}
}

View file

@ -0,0 +1,19 @@
package sandbox
import "context"
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
}

View file

@ -0,0 +1,112 @@
package sandbox
import (
"context"
"strings"
)
// 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
// Stop releases runtime resources acquired by Start.
// It should be safe to call multiple times.
Stop(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)
// Fs returns the sandbox-aware filesystem bridge.
Fs() FsBridge
}
// 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
}
// 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
}
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
}
if res.Stdout != "" || out.Stdout == "" {
out.Stdout = res.Stdout
}
if res.Stderr != "" || out.Stderr == "" {
out.Stderr = res.Stderr
}
if res.ExitCode != 0 || exitCode == 0 {
out.ExitCode = res.ExitCode
}
return out, nil
}

View file

@ -0,0 +1,192 @@
package sandbox
import (
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
)
var blockedHostPaths = []string{
"/etc",
"/private/etc",
"/proc",
"/sys",
"/dev",
"/root",
"/boot",
"/run",
"/var/run",
"/private/var/run",
"/var/run/docker.sock",
"/private/var/run/docker.sock",
"/run/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 real := tryRealpathAbsolute(normalized); real != normalized {
if err := validateBindSourcePath(bind, real); 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)
}
}
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)
}

View file

@ -0,0 +1,89 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
)
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
t.Cleanup(func() { filepathEvalSymlinks = old })
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)
}
}

View file

@ -0,0 +1,73 @@
package sandbox
import (
"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_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 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")
}
}

View file

@ -0,0 +1,44 @@
package sandbox
import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
)
var defaultSandboxAllow = []string{"exec", "read_file", "write_file"}
func IsToolSandboxEnabled(cfg *config.Config, tool string) bool {
name := strings.ToLower(strings.TrimSpace(tool))
if name == "" {
return false
}
allow, hasAllow := defaultSandboxAllow, false
deny := []string{}
if cfg != nil {
allow = cfg.Tools.Sandbox.Tools.Allow
deny = cfg.Tools.Sandbox.Tools.Deny
hasAllow = cfg.Tools.Sandbox.Tools.Allow != nil
}
if containsTool(deny, name) {
return false
}
if !hasAllow {
return containsTool(defaultSandboxAllow, name)
}
if len(allow) == 0 {
return true
}
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
}

View file

@ -0,0 +1,45 @@
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 host 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")
}
}
func TestIsToolSandboxEnabled_EmptyAllowMeansAll(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Sandbox.Tools.Allow = []string{}
cfg.Tools.Sandbox.Tools.Deny = []string{"cron"}
if !IsToolSandboxEnabled(cfg, "read_file") {
t.Fatal("expected read_file to be enabled when allow is empty")
}
if IsToolSandboxEnabled(cfg, "cron") {
t.Fatal("expected denied tool to be disabled")
}
}

View file

@ -0,0 +1,47 @@
package sandbox
import (
"context"
"errors"
"fmt"
)
type unavailableSandbox struct {
err error
fs FsBridge
}
func NewUnavailableSandbox(err error) Sandbox {
if err == nil {
err = errors.New("sandbox unavailable")
}
return &unavailableSandbox{
err: err,
fs: &errorFS{err: err},
}
}
func (u *unavailableSandbox) Start(ctx context.Context) error { return u.err }
func (u *unavailableSandbox) Stop(ctx context.Context) error { return nil }
func (u *unavailableSandbox) Fs() FsBridge { return u.fs }
func (u *unavailableSandbox) 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 *unavailableSandbox) 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)
}

18
pkg/agent/sandbox/util.go Normal file
View file

@ -0,0 +1,18 @@
package sandbox
import (
"os/exec"
"time"
)
func durationMs(ms int64) time.Duration {
return time.Duration(ms) * time.Millisecond
}
func asExitError(err error, target **exec.ExitError) bool {
ee, ok := err.(*exec.ExitError)
if ok {
*target = ee
}
return ok
}

View file

@ -167,16 +167,17 @@ type SessionConfig 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"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
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"`
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
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"`
Sandbox AgentSandboxConfig `json:"sandbox"`
}
type ChannelsConfig struct {
@ -415,10 +416,100 @@ type ExecConfig struct {
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
}
type AgentSandboxPruneConfig struct {
IdleHours int `json:"idle_hours" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_PRUNE_IDLE_HOURS"`
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"`
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 map[string]string `json:"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"`
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"`
ExtraHosts []string `json:"extra_hosts"`
Binds []string `json:"binds"`
}
type AgentSandboxConfig struct {
Mode string `json:"mode" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_MODE"`
Scope string `json:"scope" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_SCOPE"`
WorkspaceAccess string `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"`
Prune AgentSandboxPruneConfig `json:"prune"`
}
type SandboxToolPolicyConfig struct {
Allow []string `json:"allow"`
Deny []string `json:"deny"`
}
type SandboxToolsConfig struct {
Tools SandboxToolPolicyConfig `json:"tools"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Sandbox SandboxToolsConfig `json:"sandbox"`
}
func LoadConfig(path string) (*Config, error) {

View file

@ -188,6 +188,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()
@ -300,6 +337,56 @@ 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")
}
if cfg.Agents.Defaults.Sandbox.Prune.IdleHours <= 0 {
t.Fatal("Expected sandbox prune idle hours > 0")
}
if 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")

View file

@ -17,6 +17,40 @@ func DefaultConfig() *Config {
MaxTokens: 8192,
Temperature: nil, // nil means use provider default
MaxToolIterations: 20,
Sandbox: AgentSandboxConfig{
Mode: "off",
Scope: "agent",
WorkspaceAccess: "none",
WorkspaceRoot: "~/.picoclaw/sandboxes",
Docker: AgentSandboxDockerConfig{
Image: "debian:bookworm-slim",
ContainerPrefix: "picoclaw-sandbox-",
Workdir: "/workspace",
ReadOnlyRoot: true,
Tmpfs: []string{"/tmp", "/var/tmp", "/run"},
Network: "none",
User: "",
CapDrop: []string{"ALL"},
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{},
Binds: []string{},
},
Prune: AgentSandboxPruneConfig{
IdleHours: 24,
MaxAgeDays: 7,
},
},
},
},
Bindings: []AgentBinding{},
@ -262,6 +296,12 @@ func DefaultConfig() *Config {
Cron: CronToolsConfig{
ExecTimeoutMinutes: 5,
},
Sandbox: SandboxToolsConfig{
Tools: SandboxToolPolicyConfig{
Allow: []string{"exec", "read_file", "write_file"},
Deny: []string{"cron"},
},
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,
@ -273,3 +313,7 @@ func DefaultConfig() *Config {
},
}
}
func int64Ptr(v int64) *int64 {
return &v
}

View file

@ -6,6 +6,7 @@ import (
"sync"
"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,7 +23,9 @@ type CronTool struct {
cronService *cron.CronService
executor JobExecutor
msgBus *bus.MessageBus
execTool *ExecTool
sandbox sandbox.Sandbox
execGuard *ExecTool
execTimeout time.Duration
channel string
chatID string
mu sync.RWMutex
@ -31,13 +34,15 @@ type CronTool struct {
// NewCronTool creates a new CronTool
// 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) *CronTool {
execTool := NewExecToolWithConfig(workspace, restrict, config)
execTool.SetTimeout(execTimeout)
sb := sandbox.NewFromConfig(workspace, restrict, config)
guard := NewExecToolWithSandbox(workspace, restrict, config, nil)
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
execTool: execTool,
sandbox: sb,
execGuard: guard,
execTimeout: execTimeout,
}
}
@ -279,16 +284,42 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
// Execute command if present
if job.Payload.Command != "" {
args := map[string]interface{}{
"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)
cwd := ""
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)
t.msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: output,
})
return "ok"
}
}
res, err := t.sandbox.Exec(ctx, sandbox.ExecRequest{
Command: job.Payload.Command,
WorkingDir: func() string {
if t.execGuard == nil {
return "."
}
return t.execGuard.resolveSandboxWorkingDir(cwd)
}(),
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)
}
}
t.msgBus.PublishOutbound(bus.OutboundMessage{

View file

@ -0,0 +1,127 @@
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) Stop(ctx context.Context) error { return nil }
func (s *cronStubSandbox) Fs() sandbox.FsBridge { return nil }
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
}
type noopExecutor struct{}
func (n *noopExecutor) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) {
return "ok", nil
}
func TestCronTool_ExecuteJob_BlocksDangerousCommandViaGuard(t *testing.T) {
msgBus := bus.NewMessageBus()
sb := &cronStubSandbox{}
tool := &CronTool{
msgBus: msgBus,
sandbox: sb,
execGuard: NewExecTool("", 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,
sandbox: sb,
execGuard: NewExecTool("/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)
}
}

46
pkg/tools/disabled.go Normal file
View file

@ -0,0 +1,46 @@
package tools
import (
"context"
)
// DisabledTool keeps a stable tool surface but always returns an error result.
type DisabledTool struct {
name string
description string
reason string
}
func NewDisabledTool(name, description, reason string) *DisabledTool {
return &DisabledTool{
name: name,
description: description,
reason: reason,
}
}
func (t *DisabledTool) Name() string {
return t.name
}
func (t *DisabledTool) Description() string {
if t.description != "" {
return t.description
}
return "This tool is disabled in current sandbox policy."
}
func (t *DisabledTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
}
func (t *DisabledTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
msg := t.reason
if msg == "" {
msg = "tool is disabled"
}
return ErrorResult(msg)
}

View file

@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/sipeed/picoclaw/pkg/agent/sandbox"
)
// validatePath ensures the given path is within the workspace if restrict is true.
@ -80,12 +82,25 @@ func isWithinWorkspace(candidate, workspace string) bool {
type ReadFileTool struct {
workspace string
restrict bool
fsBridge sandbox.FsBridge
}
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
return &ReadFileTool{workspace: workspace, restrict: restrict}
}
func NewReadFileToolWithSandbox(workspace string, restrict bool, sb sandbox.Sandbox) *ReadFileTool {
var fsBridge sandbox.FsBridge
if sb != nil {
fsBridge = sb.Fs()
}
return &ReadFileTool{workspace: workspace, restrict: restrict, fsBridge: fsBridge}
}
func NewReadFileToolWithFsBridge(workspace string, restrict bool, fsBridge sandbox.FsBridge) *ReadFileTool {
return &ReadFileTool{workspace: workspace, restrict: restrict, fsBridge: fsBridge}
}
func (t *ReadFileTool) Name() string {
return "read_file"
}
@ -113,12 +128,19 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{})
return ErrorResult("path is required")
}
resolvedPath, err := validatePath(path, t.workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())
var (
content []byte
err error
)
if t.fsBridge != nil {
content, err = t.fsBridge.ReadFile(ctx, path)
} else {
var resolvedPath string
resolvedPath, err = validatePath(path, t.workspace, t.restrict)
if err == nil {
content, err = os.ReadFile(resolvedPath)
}
}
content, err := os.ReadFile(resolvedPath)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
}
@ -129,12 +151,25 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{})
type WriteFileTool struct {
workspace string
restrict bool
fsBridge sandbox.FsBridge
}
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
return &WriteFileTool{workspace: workspace, restrict: restrict}
}
func NewWriteFileToolWithSandbox(workspace string, restrict bool, sb sandbox.Sandbox) *WriteFileTool {
var fsBridge sandbox.FsBridge
if sb != nil {
fsBridge = sb.Fs()
}
return &WriteFileTool{workspace: workspace, restrict: restrict, fsBridge: fsBridge}
}
func NewWriteFileToolWithFsBridge(workspace string, restrict bool, fsBridge sandbox.FsBridge) *WriteFileTool {
return &WriteFileTool{workspace: workspace, restrict: restrict, fsBridge: fsBridge}
}
func (t *WriteFileTool) Name() string {
return "write_file"
}
@ -171,6 +206,13 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}
return ErrorResult("content is required")
}
if t.fsBridge != nil {
if err := t.fsBridge.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))
}
resolvedPath, err := validatePath(path, t.workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())

View file

@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/agent/sandbox"
"github.com/sipeed/picoclaw/pkg/config"
)
@ -21,6 +22,7 @@ type ExecTool struct {
denyPatterns []*regexp.Regexp
allowPatterns []*regexp.Regexp
restrictToWorkspace bool
sandbox sandbox.Sandbox
}
var defaultDenyPatterns = []*regexp.Regexp{
@ -73,6 +75,10 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool {
}
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
return NewExecToolWithSandbox(workingDir, restrict, config, nil)
}
func NewExecToolWithSandbox(workingDir string, restrict bool, config *config.Config, sb sandbox.Sandbox) *ExecTool {
denyPatterns := make([]*regexp.Regexp, 0)
enableDenyPatterns := true
@ -107,6 +113,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
denyPatterns: denyPatterns,
allowPatterns: nil,
restrictToWorkspace: restrict,
sandbox: sb,
}
}
@ -157,6 +164,38 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
return ErrorResult(guardError)
}
if t.sandbox != nil {
sandboxWD := t.resolveSandboxWorkingDir(cwd)
res, err := t.sandbox.Exec(ctx, sandbox.ExecRequest{
Command: command,
WorkingDir: sandboxWD,
TimeoutMs: t.timeout.Milliseconds(),
})
if err != nil {
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)"
}
if res.ExitCode != 0 {
output += fmt.Sprintf("\nExit code: %d", res.ExitCode)
return &ToolResult{
ForLLM: output,
ForUser: output,
IsError: true,
}
}
return &ToolResult{
ForLLM: output,
ForUser: output,
IsError: false,
}
}
// timeout == 0 means no timeout
var cmdCtx context.Context
var cancel context.CancelFunc
@ -224,18 +263,32 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
}
func (t *ExecTool) guardCommand(command, cwd string) string {
return guardCommandWithPolicy(
command,
cwd,
t.restrictToWorkspace,
t.denyPatterns,
t.allowPatterns,
)
}
func guardCommandWithPolicy(
command, cwd string,
restrictToWorkspace bool,
denyPatterns, allowPatterns []*regexp.Regexp,
) string {
cmd := strings.TrimSpace(command)
lower := strings.ToLower(cmd)
for _, pattern := range t.denyPatterns {
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
@ -246,7 +299,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)"
}
@ -279,6 +332,33 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return ""
}
func (t *ExecTool) resolveSandboxWorkingDir(cwd string) string {
trimmed := strings.TrimSpace(cwd)
if trimmed == "" {
return "."
}
if !filepath.IsAbs(trimmed) {
return trimmed
}
if strings.HasPrefix(filepath.ToSlash(trimmed), "/workspace") {
return filepath.ToSlash(trimmed)
}
base := strings.TrimSpace(t.workingDir)
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)
}
}
}
return "."
}
func (t *ExecTool) SetTimeout(timeout time.Duration) {
t.timeout = timeout
}

View file

@ -2,13 +2,91 @@ package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/agent/sandbox"
)
type stubSandbox struct {
lastReq sandbox.ExecRequest
err error
res *sandbox.ExecResult
}
func (s *stubSandbox) Start(ctx context.Context) error { return nil }
func (s *stubSandbox) Stop(ctx context.Context) error { return nil }
func (s *stubSandbox) Fs() sandbox.FsBridge { return nil }
func (s *stubSandbox) Exec(ctx context.Context, req sandbox.ExecRequest) (*sandbox.ExecResult, error) {
return sandboxAggregateFromStub(ctx, req, s.ExecStream)
}
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
}
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 {
return nil, err
}
if res != nil {
return res, nil
}
return &sandbox.ExecResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, nil
}
// TestShellTool_Success verifies successful command execution
func TestShellTool_Success(t *testing.T) {
tool := NewExecTool("", false)
@ -208,3 +286,55 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
t.Errorf("Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
}
}
func TestShellTool_SandboxMapsHostWorkingDirToRelative(t *testing.T) {
workspace := t.TempDir()
sb := &stubSandbox{}
tool := NewExecToolWithSandbox(workspace, true, nil, sb)
ctx := context.Background()
args := map[string]interface{}{
"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)
}
}
func TestShellTool_SandboxUsesDotForUnmappedAbsoluteDir(t *testing.T) {
workspace := t.TempDir()
sb := &stubSandbox{}
tool := NewExecToolWithSandbox(workspace, true, nil, sb)
ctx := context.Background()
args := map[string]interface{}{
"command": "echo test",
"working_dir": "/outside/path",
}
result := tool.Execute(ctx, args)
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if sb.lastReq.WorkingDir != "." {
t.Fatalf("sandbox working_dir = %q, want .", sb.lastReq.WorkingDir)
}
}
func TestShellTool_SandboxExecError(t *testing.T) {
workspace := t.TempDir()
sb := &stubSandbox{err: fmt.Errorf("sandbox down")}
tool := NewExecToolWithSandbox(workspace, true, nil, sb)
result := tool.Execute(context.Background(), map[string]interface{}{"command": "echo test"})
if !result.IsError {
t.Fatal("expected sandbox error result")
}
if !strings.Contains(result.ForLLM, "sandbox exec failed") {
t.Fatalf("unexpected error message: %s", result.ForLLM)
}
}