diff --git a/components/backend/go.mod b/components/backend/go.mod old mode 100644 new mode 100755 index a0de7657f..084e972f0 --- a/components/backend/go.mod +++ b/components/backend/go.mod @@ -32,12 +32,14 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -58,23 +60,31 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/launchdarkly/eventsource v1.10.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.82 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twmb/murmur3 v1.1.8 // indirect github.com/ugorji/go/codec v1.3.0 // indirect diff --git a/components/backend/go.sum b/components/backend/go.sum old mode 100644 new mode 100755 index 3bf62b69e..c81bd3d77 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -35,6 +35,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -66,6 +68,8 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01 github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -163,9 +167,16 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -188,6 +199,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.82 h1:tWfICLhmp2aFPXL8Tli0XDTHj2VB/fNf0PC1f/i1gRo= +github.com/minio/minio-go/v7 v7.0.82/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0= +github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= +github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -204,6 +223,8 @@ github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -213,6 +234,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -237,6 +260,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= diff --git a/components/backend/handlers/file_uploads.go b/components/backend/handlers/file_uploads.go new file mode 100644 index 000000000..9c3fce75a --- /dev/null +++ b/components/backend/handlers/file_uploads.go @@ -0,0 +1,305 @@ +package handlers + +import ( + "fmt" + "io" + "log" + "net/http" + "path/filepath" + "strings" + + "ambient-code-backend/pathutil" + "ambient-code-backend/storage" + + "github.com/gin-gonic/gin" + authzv1 "k8s.io/api/authorization/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// S3Storage is the shared S3 client used for pre-upload file operations. +// Initialized at startup in main.go. If nil, pre-upload endpoints return 503. +var S3Storage *storage.S3Client + +// fileUploadS3Key returns the S3 object key for a pre-uploaded file. +// Layout: {namespace}/{sessionName}/file-uploads/{path} +// This matches the path that hydrate.sh downloads from, ensuring files +// uploaded before session start are available when the pod initializes. +func fileUploadS3Key(namespace, sessionName, filePath string) string { + return fmt.Sprintf("%s/%s/file-uploads/%s", namespace, sessionName, filePath) +} + +// fileUploadS3Prefix returns the S3 prefix for listing pre-uploaded files. +func fileUploadS3Prefix(namespace, sessionName string) string { + return fmt.Sprintf("%s/%s/file-uploads/", namespace, sessionName) +} + +// PreUploadFile uploads a file directly to S3 for a session that may not be running yet. +// The file is stored at the same S3 path that hydrate.sh downloads from, so it will +// be available in the session pod's workspace when it initializes. +func PreUploadFile(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + session := c.Param("sessionName") + + if project == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + + if S3Storage == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "S3 storage not configured"}) + return + } + + // Get user-scoped K8s clients for auth + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"}) + c.Abort() + return + } + + // Validate and sanitize path + sub := strings.TrimPrefix(c.Param("path"), "/") + if sub == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "File path required"}) + return + } + workspaceBase := "/workspace/file-uploads" + validationPath := filepath.Join(workspaceBase, sub) + if !pathutil.IsPathWithinBase(validationPath, workspaceBase) { + log.Printf("PreUploadFile: path traversal attempt detected - path=%q", sub) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path: must be within file-uploads directory"}) + return + } + filePath := filepath.ToSlash(sub) + + // RBAC check: verify user has update permission on agenticsessions + ssar := &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: "vteam.ambient-code", + Resource: "agenticsessions", + Verb: "update", + Namespace: project, + }, + }, + } + res, err := reqK8s.AuthorizationV1().SelfSubjectAccessReviews().Create(c.Request.Context(), ssar, v1.CreateOptions{}) + if err != nil { + log.Printf("RBAC check failed for pre-upload in project %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify permissions"}) + return + } + if !res.Status.Allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized to upload files to session"}) + return + } + + // Verify session exists (CR must exist, but pod doesn't need to be running) + gvr := GetAgenticSessionV1Alpha1Resource() + _, err = reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err != nil { + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + // Read request body + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("PreUploadFile: failed to read request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read file data"}) + return + } + if len(payload) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Empty file"}) + return + } + + contentType := c.GetHeader("Content-Type") + if contentType == "" { + contentType = http.DetectContentType(payload) + } + + // Upload to S3 + key := fileUploadS3Key(project, session, filePath) + reader := strings.NewReader(string(payload)) + if err := S3Storage.PutObject(c.Request.Context(), key, reader, int64(len(payload)), contentType); err != nil { + log.Printf("PreUploadFile: S3 upload failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload file to storage"}) + return + } + + log.Printf("PreUploadFile: uploaded %s for session %s/%s (%d bytes)", filePath, project, session, len(payload)) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "filename": filePath, + "size": len(payload), + }) +} + +// ListPreUploadedFiles lists files that have been pre-uploaded to S3 for a session. +func ListPreUploadedFiles(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + session := c.Param("sessionName") + + if project == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + + if S3Storage == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "S3 storage not configured"}) + return + } + + // Get user-scoped K8s clients for auth + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"}) + c.Abort() + return + } + + // RBAC check: verify user has get permission on agenticsessions + ssar := &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: "vteam.ambient-code", + Resource: "agenticsessions", + Verb: "get", + Namespace: project, + }, + }, + } + res, err := reqK8s.AuthorizationV1().SelfSubjectAccessReviews().Create(c.Request.Context(), ssar, v1.CreateOptions{}) + if err != nil { + log.Printf("RBAC check failed for listing pre-uploads in project %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify permissions"}) + return + } + if !res.Status.Allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized to list session files"}) + return + } + + // Verify session exists + gvr := GetAgenticSessionV1Alpha1Resource() + _, err = reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err != nil { + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + prefix := fileUploadS3Prefix(project, session) + files, err := S3Storage.ListObjects(c.Request.Context(), prefix) + if err != nil { + log.Printf("ListPreUploadedFiles: S3 list failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list files"}) + return + } + + if files == nil { + files = []storage.S3FileInfo{} + } + + c.JSON(http.StatusOK, gin.H{"files": files}) +} + +// DeletePreUploadedFile deletes a pre-uploaded file from S3. +func DeletePreUploadedFile(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + session := c.Param("sessionName") + + if project == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + + if S3Storage == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "S3 storage not configured"}) + return + } + + // Get user-scoped K8s clients for auth + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"}) + c.Abort() + return + } + + // Validate and sanitize path + sub := strings.TrimPrefix(c.Param("path"), "/") + if sub == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "File path required"}) + return + } + workspaceBase := "/workspace/file-uploads" + validationPath := filepath.Join(workspaceBase, sub) + if !pathutil.IsPathWithinBase(validationPath, workspaceBase) { + log.Printf("DeletePreUploadedFile: path traversal attempt detected - path=%q", sub) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"}) + return + } + filePath := filepath.ToSlash(sub) + + // RBAC check + ssar := &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: "vteam.ambient-code", + Resource: "agenticsessions", + Verb: "update", + Namespace: project, + }, + }, + } + res, err := reqK8s.AuthorizationV1().SelfSubjectAccessReviews().Create(c.Request.Context(), ssar, v1.CreateOptions{}) + if err != nil { + log.Printf("RBAC check failed for deleting pre-upload in project %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify permissions"}) + return + } + if !res.Status.Allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized to delete session files"}) + return + } + + // Verify session exists + gvr := GetAgenticSessionV1Alpha1Resource() + _, err = reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err != nil { + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + key := fileUploadS3Key(project, session, filePath) + if err := S3Storage.DeleteObject(c.Request.Context(), key); err != nil { + log.Printf("DeletePreUploadedFile: S3 delete failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/components/backend/handlers/file_uploads_test.go b/components/backend/handlers/file_uploads_test.go new file mode 100644 index 000000000..12930e1b8 --- /dev/null +++ b/components/backend/handlers/file_uploads_test.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "testing" +) + +func TestFileUploadS3Key(t *testing.T) { + tests := []struct { + name string + namespace string + session string + filePath string + expected string + }{ + { + name: "simple file", + namespace: "my-project", + session: "session-abc123", + filePath: "document.pdf", + expected: "my-project/session-abc123/file-uploads/document.pdf", + }, + { + name: "nested path", + namespace: "my-project", + session: "session-abc123", + filePath: "subdir/image.png", + expected: "my-project/session-abc123/file-uploads/subdir/image.png", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fileUploadS3Key(tt.namespace, tt.session, tt.filePath) + if got != tt.expected { + t.Errorf("fileUploadS3Key() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestFileUploadS3Prefix(t *testing.T) { + got := fileUploadS3Prefix("my-project", "session-abc123") + expected := "my-project/session-abc123/file-uploads/" + if got != expected { + t.Errorf("fileUploadS3Prefix() = %q, want %q", got, expected) + } +} diff --git a/components/backend/main.go b/components/backend/main.go old mode 100644 new mode 100755 index c75827a70..183e3962f --- a/components/backend/main.go +++ b/components/backend/main.go @@ -15,6 +15,7 @@ import ( "ambient-code-backend/k8s" "ambient-code-backend/ldap" "ambient-code-backend/server" + "ambient-code-backend/storage" "ambient-code-backend/websocket" "github.com/joho/godotenv" @@ -168,6 +169,16 @@ func main() { handlers.K8sClientProjects = server.K8sClient // Backend SA client for namespace operations handlers.DynamicClientProjects = server.DynamicClient // Backend SA dynamic client for Project operations + // Initialize S3 storage for pre-upload file support (optional - degrades gracefully) + if s3Cfg, err := storage.LoadS3ConfigFromEnv(); err != nil { + log.Printf("S3 storage not configured (pre-upload disabled): %v", err) + } else if s3Client, err := storage.NewS3Client(s3Cfg); err != nil { + log.Printf("Failed to initialize S3 client (pre-upload disabled): %v", err) + } else { + handlers.S3Storage = s3Client + log.Printf("S3 storage initialized for pre-upload support (endpoint: %s, bucket: %s)", s3Cfg.Endpoint, s3Cfg.Bucket) + } + // Initialize session handlers handlers.GetAgenticSessionV1Alpha1Resource = k8s.GetAgenticSessionV1Alpha1Resource handlers.DynamicClient = server.DynamicClient diff --git a/components/backend/routes.go b/components/backend/routes.go index 9fb63050f..e26741d4f 100755 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -46,6 +46,10 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/agentic-sessions/:sessionName/workspace/*path", handlers.GetSessionWorkspaceFile) projectGroup.PUT("/agentic-sessions/:sessionName/workspace/*path", handlers.PutSessionWorkspaceFile) projectGroup.DELETE("/agentic-sessions/:sessionName/workspace/*path", handlers.DeleteSessionWorkspaceFile) + // Pre-upload: write files to S3 before session pod starts (hydrate.sh seeds them) + projectGroup.GET("/agentic-sessions/:sessionName/file-uploads", handlers.ListPreUploadedFiles) + projectGroup.PUT("/agentic-sessions/:sessionName/file-uploads/*path", handlers.PreUploadFile) + projectGroup.DELETE("/agentic-sessions/:sessionName/file-uploads/*path", handlers.DeletePreUploadedFile) // Removed: github/push, github/abandon, github/diff - agent handles all git operations projectGroup.GET("/agentic-sessions/:sessionName/git/status", handlers.GetGitStatus) projectGroup.POST("/agentic-sessions/:sessionName/git/configure-remote", handlers.ConfigureGitRemote) diff --git a/components/backend/storage/s3.go b/components/backend/storage/s3.go new file mode 100644 index 000000000..487c8558b --- /dev/null +++ b/components/backend/storage/s3.go @@ -0,0 +1,154 @@ +// Package storage provides S3-compatible object storage operations for file uploads. +package storage + +import ( + "context" + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// S3Client wraps the MinIO client for S3-compatible storage operations. +type S3Client struct { + client *minio.Client + bucket string +} + +// S3Config holds the configuration for connecting to S3-compatible storage. +type S3Config struct { + Endpoint string + Bucket string + AccessKey string + SecretKey string + UseSSL bool +} + +// S3FileInfo represents metadata about a file stored in S3. +type S3FileInfo struct { + Key string `json:"key"` + Size int64 `json:"size"` + LastModified string `json:"lastModified"` + ContentType string `json:"contentType,omitempty"` +} + +// LoadS3ConfigFromEnv reads S3 configuration from environment variables. +func LoadS3ConfigFromEnv() (*S3Config, error) { + endpoint := os.Getenv("S3_ENDPOINT") + bucket := os.Getenv("S3_BUCKET") + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + + if endpoint == "" || bucket == "" { + return nil, fmt.Errorf("S3_ENDPOINT and S3_BUCKET must be set") + } + if accessKey == "" || secretKey == "" { + return nil, fmt.Errorf("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set") + } + + // Determine SSL from endpoint scheme + useSSL := strings.HasPrefix(endpoint, "https://") + + // Strip scheme for MinIO client (it adds its own) + endpoint = strings.TrimPrefix(endpoint, "https://") + endpoint = strings.TrimPrefix(endpoint, "http://") + + return &S3Config{ + Endpoint: endpoint, + Bucket: bucket, + AccessKey: accessKey, + SecretKey: secretKey, + UseSSL: useSSL, + }, nil +} + +// NewS3Client creates a new S3Client from the given config. +func NewS3Client(cfg *S3Config) (*S3Client, error) { + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create S3 client: %w", err) + } + + return &S3Client{ + client: client, + bucket: cfg.Bucket, + }, nil +} + +// PutObject uploads a file to the given S3 key. +func (s *S3Client) PutObject(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error { + opts := minio.PutObjectOptions{} + if contentType != "" { + opts.ContentType = contentType + } + + _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts) + if err != nil { + return fmt.Errorf("failed to upload to S3 key %q: %w", key, err) + } + + log.Printf("S3: uploaded %s (%d bytes)", key, size) + return nil +} + +// ListObjects lists all objects under a given prefix. +func (s *S3Client) ListObjects(ctx context.Context, prefix string) ([]S3FileInfo, error) { + var files []S3FileInfo + + objectCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{ + Prefix: prefix, + Recursive: true, + }) + + for obj := range objectCh { + if obj.Err != nil { + return nil, fmt.Errorf("failed to list S3 objects: %w", obj.Err) + } + + // Strip prefix from key for display + relKey := strings.TrimPrefix(obj.Key, prefix) + if relKey == "" { + continue + } + + files = append(files, S3FileInfo{ + Key: relKey, + Size: obj.Size, + LastModified: obj.LastModified.UTC().Format("2006-01-02T15:04:05Z"), + ContentType: obj.ContentType, + }) + } + + return files, nil +} + +// DeleteObject deletes a single object from S3. +func (s *S3Client) DeleteObject(ctx context.Context, key string) error { + err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}) + if err != nil { + return fmt.Errorf("failed to delete S3 key %q: %w", key, err) + } + + log.Printf("S3: deleted %s", key) + return nil +} + +// ObjectExists checks if an object exists at the given key. +func (s *S3Client) ObjectExists(ctx context.Context, key string) (bool, error) { + _, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}) + if err != nil { + errResp := minio.ToErrorResponse(err) + if errResp.Code == "NoSuchKey" { + return false, nil + } + return false, fmt.Errorf("failed to stat S3 key %q: %w", key, err) + } + return true, nil +} diff --git a/components/backend/storage/s3_test.go b/components/backend/storage/s3_test.go new file mode 100644 index 000000000..43b721d06 --- /dev/null +++ b/components/backend/storage/s3_test.go @@ -0,0 +1,103 @@ +package storage + +import ( + "os" + "testing" +) + +func TestLoadS3ConfigFromEnv(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantErr bool + check func(t *testing.T, cfg *S3Config) + }{ + { + name: "all env vars set with http endpoint", + envVars: map[string]string{ + "S3_ENDPOINT": "http://minio.svc:9000", + "S3_BUCKET": "test-bucket", + "AWS_ACCESS_KEY_ID": "testkey", + "AWS_SECRET_ACCESS_KEY": "testsecret", + }, + wantErr: false, + check: func(t *testing.T, cfg *S3Config) { + if cfg.Endpoint != "minio.svc:9000" { + t.Errorf("expected endpoint 'minio.svc:9000', got %q", cfg.Endpoint) + } + if cfg.Bucket != "test-bucket" { + t.Errorf("expected bucket 'test-bucket', got %q", cfg.Bucket) + } + if cfg.UseSSL { + t.Error("expected UseSSL=false for http endpoint") + } + }, + }, + { + name: "https endpoint enables SSL", + envVars: map[string]string{ + "S3_ENDPOINT": "https://s3.amazonaws.com", + "S3_BUCKET": "prod-bucket", + "AWS_ACCESS_KEY_ID": "key", + "AWS_SECRET_ACCESS_KEY": "secret", + }, + wantErr: false, + check: func(t *testing.T, cfg *S3Config) { + if cfg.Endpoint != "s3.amazonaws.com" { + t.Errorf("expected endpoint 's3.amazonaws.com', got %q", cfg.Endpoint) + } + if !cfg.UseSSL { + t.Error("expected UseSSL=true for https endpoint") + } + }, + }, + { + name: "missing endpoint and bucket", + envVars: map[string]string{ + "AWS_ACCESS_KEY_ID": "key", + "AWS_SECRET_ACCESS_KEY": "secret", + }, + wantErr: true, + }, + { + name: "missing credentials", + envVars: map[string]string{ + "S3_ENDPOINT": "http://minio:9000", + "S3_BUCKET": "bucket", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear env vars + for _, key := range []string{"S3_ENDPOINT", "S3_BUCKET", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"} { + os.Unsetenv(key) + } + // Set test env vars + for k, v := range tt.envVars { + os.Setenv(k, v) + } + defer func() { + for k := range tt.envVars { + os.Unsetenv(k) + } + }() + + cfg, err := LoadS3ConfigFromEnv() + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, cfg) + } + }) + } +} diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts index 30cf230e5..b5784b086 100755 --- a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts @@ -336,8 +336,42 @@ async function compressAndValidate( } } +// Helper function to upload file directly to S3 via the pre-upload endpoint. +// Used when the session pod is not running (e.g., before session start). +// Files uploaded this way are seeded into the workspace by the init container. +async function preUploadFileToS3( + buffer: ArrayBuffer, + filename: string, + contentType: string, + headers: HeadersInit, + name: string, + sessionName: string, + subpath?: string +): Promise { + // Build the upload path: [subpath/]filename + const pathParts: string[] = []; + if (subpath) { + pathParts.push(...subpath.split('/').map(s => encodeURIComponent(s))); + } + pathParts.push(encodeURIComponent(filename)); + const uploadPath = pathParts.join('/'); + + return fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/file-uploads/${uploadPath}`, + { + method: 'PUT', + headers: { + ...headers, + 'Content-Type': contentType, + }, + body: buffer, + } + ); +} + // Helper function to upload file to workspace with retry logic -// Handles 202 Accepted responses (content service starting) with retries +// Handles 202 Accepted responses (content service starting) with retries. +// Falls back to pre-upload (direct S3) when the session is not running (409). async function uploadFileToWorkspace( buffer: ArrayBuffer, filename: string, @@ -371,6 +405,12 @@ async function uploadFileToWorkspace( } ); + // If 409 Conflict (session not running), fall back to pre-upload via S3 + if (resp.status === 409) { + console.log('Session not running, falling back to pre-upload via S3'); + return preUploadFileToS3(buffer, filename, contentType, headers, name, sessionName, subpath); + } + // If 202 Accepted (content service starting), wait and retry if (resp.status === 202) { if (retries < maxRetries - 1) { diff --git a/components/frontend/src/components/__tests__/create-session-dialog-files.test.tsx b/components/frontend/src/components/__tests__/create-session-dialog-files.test.tsx new file mode 100644 index 000000000..505b244f8 --- /dev/null +++ b/components/frontend/src/components/__tests__/create-session-dialog-files.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CreateSessionDialog } from '../create-session-dialog'; + +const mockMutate = vi.fn(); + +// Mock all required hooks and dependencies +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), +})); + +vi.mock('@/lib/feature-flags', () => ({ + useFlag: vi.fn(() => false), +})); + +vi.mock('@/services/queries/use-sessions', () => ({ + useCreateSession: vi.fn(() => ({ + mutate: mockMutate, + isPending: false, + })), +})); + +vi.mock('@/services/queries/use-runner-types', () => ({ + useRunnerTypes: vi.fn(() => ({ + data: [{ id: 'claude-agent-sdk', displayName: 'Claude Agent SDK', description: 'Default', provider: 'anthropic' }], + isLoading: false, + isError: false, + refetch: vi.fn(), + })), +})); + +vi.mock('@/services/queries/use-integrations', () => ({ + useIntegrationsStatus: vi.fn(() => ({ + data: { github: { active: null }, gitlab: { connected: false }, jira: { connected: false }, google: { connected: false } }, + })), +})); + +vi.mock('@/services/queries/use-models', () => ({ + useModels: vi.fn(() => ({ + data: { models: [{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }], defaultModel: 'claude-sonnet-4-6' }, + isLoading: false, + isError: false, + })), +})); + +vi.mock('@/services/queries/use-workflows', () => ({ + useOOTBWorkflows: vi.fn(() => ({ + data: [], + isLoading: false, + })), +})); + +vi.mock('@/services/api/runner-types', () => ({ + DEFAULT_RUNNER_TYPE_ID: 'claude-agent-sdk', +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +describe('CreateSessionDialog - File Attachments', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders file attachment section', () => { + render( + Create} + /> + ); + + fireEvent.click(screen.getByText('Create')); + + expect(screen.getByText('Files (optional)')).toBeDefined(); + expect(screen.getByText('Click to attach files')).toBeDefined(); + }); + + it('adds files to the pending list', async () => { + render( + Create} + /> + ); + + fireEvent.click(screen.getByText('Create')); + + const fileInput = screen.getByLabelText('Attach files'); + const file = new File(['content'], 'test.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'size', { value: 1024 }); + + fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText(/test\.txt/)).toBeDefined(); + expect(screen.getByText(/1\.0 KB/)).toBeDefined(); + }); + }); + + it('removes files from the pending list', async () => { + render( + Create} + /> + ); + + fireEvent.click(screen.getByText('Create')); + + const fileInput = screen.getByLabelText('Attach files'); + const file = new File(['content'], 'removeme.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'size', { value: 512 }); + + fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText(/removeme\.txt/)).toBeDefined(); + }); + + // Find and click the remove button (X icon) + const removeButtons = screen.getAllByRole('button').filter( + btn => btn.querySelector('svg') && btn.closest('.bg-muted\\/50') + ); + if (removeButtons.length > 0) { + fireEvent.click(removeButtons[0]); + await waitFor(() => { + expect(screen.queryByText(/removeme\.txt/)).toBeNull(); + }); + } + }); + + it('rejects files exceeding 10MB', async () => { + const { toast } = await import('sonner'); + + render( + Create} + /> + ); + + fireEvent.click(screen.getByText('Create')); + + const fileInput = screen.getByLabelText('Attach files'); + const largeFile = new File(['x'], 'huge.bin', { type: 'application/octet-stream' }); + Object.defineProperty(largeFile, 'size', { value: 11 * 1024 * 1024 }); + + fireEvent.change(fileInput, { target: { files: [largeFile] } }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('exceeds 10MB')); + }); + }); +}); diff --git a/components/frontend/src/components/create-session-dialog.tsx b/components/frontend/src/components/create-session-dialog.tsx index eeb79cafa..5e9101285 100755 --- a/components/frontend/src/components/create-session-dialog.tsx +++ b/components/frontend/src/components/create-session-dialog.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useRef } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import Link from "next/link"; -import { AlertCircle, AlertTriangle, CheckCircle2, ChevronsUpDown, Loader2 } from "lucide-react"; +import { AlertCircle, AlertTriangle, CheckCircle2, ChevronsUpDown, FileUp, Loader2, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { useFlag } from "@/lib/feature-flags"; @@ -74,6 +74,9 @@ type CreateSessionDialogProps = { onSuccess?: () => void; }; +// Maximum file size for pre-uploads: 10MB +const MAX_PRE_UPLOAD_SIZE = 10 * 1024 * 1024; + export function CreateSessionDialog({ projectName, trigger, @@ -85,6 +88,9 @@ export function CreateSessionDialog({ const [customGitUrl, setCustomGitUrl] = useState(""); const [customBranch, setCustomBranch] = useState("main"); const [customPath, setCustomPath] = useState(""); + const [pendingFiles, setPendingFiles] = useState([]); + const [uploadingFiles, setUploadingFiles] = useState(false); + const fileInputRef = useRef(null); const router = useRouter(); const advancedAgentOptions = useFlag("advanced-agent-options") ?? false; const createSessionMutation = useCreateSession(); @@ -230,10 +236,34 @@ export function CreateSessionDialog({ createSessionMutation.mutate( { projectName, data: request }, { - onSuccess: (session) => { + onSuccess: async (session) => { const sessionName = session.metadata.name; + + // Upload pending files via pre-upload endpoint (direct to S3) + if (pendingFiles.length > 0) { + setUploadingFiles(true); + try { + for (const file of pendingFiles) { + const formData = new FormData(); + formData.append("type", "local"); + formData.append("file", file); + formData.append("filename", file.name); + + await fetch( + `/api/projects/${projectName}/agentic-sessions/${sessionName}/workspace/upload`, + { method: "POST", body: formData } + ); + } + } catch { + toast.error("Some files failed to upload. You can upload them after the session starts."); + } finally { + setUploadingFiles(false); + } + } + setOpen(false); form.reset(); + setPendingFiles([]); router.push(`/projects/${encodeURIComponent(projectName)}/sessions/${sessionName}`); onSuccess?.(); }, @@ -244,6 +274,34 @@ export function CreateSessionDialog({ ); }; + const handleFileAdd = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + + const newFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.size > MAX_PRE_UPLOAD_SIZE) { + toast.error(`File "${file.name}" exceeds 10MB limit`); + continue; + } + // Avoid duplicates by name + if (!pendingFiles.some(f => f.name === file.name)) { + newFiles.push(file); + } + } + setPendingFiles(prev => [...prev, ...newFiles]); + + // Reset input so the same file can be re-added after removal + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleFileRemove = (index: number) => { + setPendingFiles(prev => prev.filter((_, i) => i !== index)); + }; + const handleOpenChange = (newOpen: boolean) => { setOpen(newOpen); if (!newOpen) { @@ -253,6 +311,7 @@ export function CreateSessionDialog({ setCustomGitUrl(""); setCustomBranch("main"); setCustomPath(""); + setPendingFiles([]); agentOptionsForm.reset(); } }; @@ -474,6 +533,56 @@ export function CreateSessionDialog({ )} /> + {/* File Attachments (pre-upload to S3) */} +
+ Files (optional) + + + {pendingFiles.length > 0 && ( +
+ {pendingFiles.map((file, index) => ( +
+ + {file.name} ({(file.size / 1024).toFixed(1)} KB) + + +
+ ))} +
+ )} +
+ {/* Advanced Agent Options (behind feature flag) */} {advancedAgentOptions && ( @@ -642,11 +751,11 @@ export function CreateSessionDialog({ > Cancel -