diff --git a/go.mod b/go.mod index 1f88639c8..f3c8e7851 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 0e95bf5cd..588e0cfb6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/openclaw-temp b/openclaw-temp new file mode 160000 index 000000000..beb2b74b5 --- /dev/null +++ b/openclaw-temp @@ -0,0 +1 @@ +Subproject commit beb2b74b5b1c4e0d47c1d7952a9473fae4380134 diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 37b253685..8156069e4 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -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 diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index fcc8e9bea..e2f89c90d 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -1,7 +1,9 @@ package agent import ( + "context" "os" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" @@ -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) + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e7b48d47a..c42a7617d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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 { diff --git a/pkg/agent/sandbox/container.go b/pkg/agent/sandbox/container.go new file mode 100644 index 000000000..a068ee23e --- /dev/null +++ b/pkg/agent/sandbox/container.go @@ -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)) +} diff --git a/pkg/agent/sandbox/container_additional_test.go b/pkg/agent/sandbox/container_additional_test.go new file mode 100644 index 000000000..6c5225d84 --- /dev/null +++ b/pkg/agent/sandbox/container_additional_test.go @@ -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) + } +} diff --git a/pkg/agent/sandbox/container_integration_test.go b/pkg/agent/sandbox/container_integration_test.go new file mode 100644 index 000000000..b2046158d --- /dev/null +++ b/pkg/agent/sandbox/container_integration_test.go @@ -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)) + } +} diff --git a/pkg/agent/sandbox/container_test.go b/pkg/agent/sandbox/container_test.go new file mode 100644 index 000000000..d71e97759 --- /dev/null +++ b/pkg/agent/sandbox/container_test.go @@ -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) + } +} diff --git a/pkg/agent/sandbox/factory.go b/pkg/agent/sandbox/factory.go new file mode 100644 index 000000000..463da934d --- /dev/null +++ b/pkg/agent/sandbox/factory.go @@ -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]) +} diff --git a/pkg/agent/sandbox/factory_additional_test.go b/pkg/agent/sandbox/factory_additional_test.go new file mode 100644 index 000000000..87d3c1b67 --- /dev/null +++ b/pkg/agent/sandbox/factory_additional_test.go @@ -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") + } +} diff --git a/pkg/agent/sandbox/factory_test.go b/pkg/agent/sandbox/factory_test.go new file mode 100644 index 000000000..daca55d41 --- /dev/null +++ b/pkg/agent/sandbox/factory_test.go @@ -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) + } +} diff --git a/pkg/agent/sandbox/host.go b/pkg/agent/sandbox/host.go new file mode 100644 index 000000000..cfb48d982 --- /dev/null +++ b/pkg/agent/sandbox/host.go @@ -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 +} diff --git a/pkg/agent/sandbox/host_unavailable_util_test.go b/pkg/agent/sandbox/host_unavailable_util_test.go new file mode 100644 index 000000000..47570b8be --- /dev/null +++ b/pkg/agent/sandbox/host_unavailable_util_test.go @@ -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") + } +} diff --git a/pkg/agent/sandbox/registry.go b/pkg/agent/sandbox/registry.go new file mode 100644 index 000000000..e50421767 --- /dev/null +++ b/pkg/agent/sandbox/registry.go @@ -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 ®istryFileLock{path: lockPath}, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, err + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timeout acquiring registry lock: %s", lockPath) + } + time.Sleep(20 * time.Millisecond) + } +} + +func (l *registryFileLock) release() { + if l == nil || l.path == "" { + return + } + _ = os.Remove(l.path) +} + +func computeConfigHash(parts ...string) string { + h := sha256.New() + for _, p := range parts { + _, _ = h.Write([]byte(p)) + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +func loadRegistry(path string) (*registryData, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return ®istryData{Entries: []registryEntry{}}, nil + } + return nil, err + } + var data registryData + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + if data.Entries == nil { + data.Entries = []registryEntry{} + } + return &data, nil +} + +func saveRegistry(path string, data *registryData) error { + 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) +} diff --git a/pkg/agent/sandbox/registry_lock_test.go b/pkg/agent/sandbox/registry_lock_test.go new file mode 100644 index 000000000..4747382fc --- /dev/null +++ b/pkg/agent/sandbox/registry_lock_test.go @@ -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") + } +} diff --git a/pkg/agent/sandbox/registry_test.go b/pkg/agent/sandbox/registry_test.go new file mode 100644 index 000000000..1466684be --- /dev/null +++ b/pkg/agent/sandbox/registry_test.go @@ -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") + } +} diff --git a/pkg/agent/sandbox/runtime_context.go b/pkg/agent/sandbox/runtime_context.go new file mode 100644 index 000000000..6312a12e8 --- /dev/null +++ b/pkg/agent/sandbox/runtime_context.go @@ -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 +} diff --git a/pkg/agent/sandbox/sandbox.go b/pkg/agent/sandbox/sandbox.go new file mode 100644 index 000000000..79594c61e --- /dev/null +++ b/pkg/agent/sandbox/sandbox.go @@ -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 +} diff --git a/pkg/agent/sandbox/security.go b/pkg/agent/sandbox/security.go new file mode 100644 index 000000000..68214e05a --- /dev/null +++ b/pkg/agent/sandbox/security.go @@ -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) +} diff --git a/pkg/agent/sandbox/security_additional_test.go b/pkg/agent/sandbox/security_additional_test.go new file mode 100644 index 000000000..cef39ea23 --- /dev/null +++ b/pkg/agent/sandbox/security_additional_test.go @@ -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) + } +} diff --git a/pkg/agent/sandbox/security_test.go b/pkg/agent/sandbox/security_test.go new file mode 100644 index 000000000..d497b4241 --- /dev/null +++ b/pkg/agent/sandbox/security_test.go @@ -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") + } +} diff --git a/pkg/agent/sandbox/tool_policy.go b/pkg/agent/sandbox/tool_policy.go new file mode 100644 index 000000000..dbba91247 --- /dev/null +++ b/pkg/agent/sandbox/tool_policy.go @@ -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 +} diff --git a/pkg/agent/sandbox/tool_policy_test.go b/pkg/agent/sandbox/tool_policy_test.go new file mode 100644 index 000000000..6e47e17c1 --- /dev/null +++ b/pkg/agent/sandbox/tool_policy_test.go @@ -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") + } +} diff --git a/pkg/agent/sandbox/unavailable.go b/pkg/agent/sandbox/unavailable.go new file mode 100644 index 000000000..8d9917c0e --- /dev/null +++ b/pkg/agent/sandbox/unavailable.go @@ -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) +} diff --git a/pkg/agent/sandbox/util.go b/pkg/agent/sandbox/util.go new file mode 100644 index 000000000..98adbc290 --- /dev/null +++ b/pkg/agent/sandbox/util.go @@ -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 +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 0d41796a4..a25c6c3f9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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) { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7e706d8ce..9c8e2ccc7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 70ba67adf..3169a8775 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -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 +} diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index e2764d8ac..7ecc03504 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -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{ diff --git a/pkg/tools/cron_additional_test.go b/pkg/tools/cron_additional_test.go new file mode 100644 index 000000000..d26a978f6 --- /dev/null +++ b/pkg/tools/cron_additional_test.go @@ -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) + } +} diff --git a/pkg/tools/disabled.go b/pkg/tools/disabled.go new file mode 100644 index 000000000..f3a26e678 --- /dev/null +++ b/pkg/tools/disabled.go @@ -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) +} diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 09063ea0a..6dc4655c1 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -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()) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d9430672f..946bd9b2f 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -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 } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index c06468a39..4c9d576a6 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -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) + } +}