feat(addons): upload a zip to the owner and install it from the Add-ons API

POST /api/addons/files streams the zip to the owner in chunks over the
host access call, so a frontend of a split deployment keeps no copy and
the owner enforces the size limit while it receives. GET and DELETE read
and remove a staged file. The install is the existing host access
preview and install with sourceId local-file, so it keeps the human
surface rule, the nonce and the digest binding. The install body gains a
downgrade confirmation.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 22:52:50 +00:00
1 parent c604f3a133
commit cbec432ed6
15 files changed
+801 -10

No files matched your search

+2 -1
View File
@@ -647,6 +647,7 @@ func (a *Application) wireHostGrantsOwner(g *gateway.Gateway) error {
if err != nil {
return err
}
g.HostGrantsOwner = owner
// The same service takes the zip files of an install from a file.
g.HostGrantsOwner = owner.WithFiles(a.addonManager)
return nil
}
+4
View File
@@ -82,6 +82,10 @@ func bodyLimitSkipper(cfg *config.ApplicationConfig) func(echo.Context) bool {
switch {
case r.Method == http.MethodPut && c.Path() == "/api/chats/files/content":
return true
case r.Method == http.MethodPost && c.Path() == "/api/addons/files":
// An add-on zip streams to the owner in chunks, which enforces
// the add-on size limit while it receives.
return true
case r.Method == http.MethodPost && c.Path() == "/api/chats/:id/messages":
// Only the multipart form carries files; a JSON message keeps the
// general limit.
+3
View File
@@ -236,6 +236,9 @@ var RouteFeatureRegistry = []RouteFeature{
{"GET", "/api/addons/:id/update", FeatureAddons},
{"POST", "/api/addons/:id/update/check", FeatureAddons},
{"POST", "/api/addons/:id/upgrade", FeatureAddons},
{"POST", "/api/addons/files", FeatureAddons},
{"GET", "/api/addons/files/:fileId", FeatureAddons},
{"DELETE", "/api/addons/files/:fileId", FeatureAddons},
{"GET", "/api/addons/host-grants", FeatureAddons},
{"POST", "/api/addons/host-grants/preview", FeatureAddons},
{"POST", "/api/addons/host-grants/install", FeatureAddons},
+139
View File
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: MIT
package addons
import (
"errors"
"io"
"mime"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/pkg/addons/contract"
"gorm.io/gorm"
)
// The file routes stage an add-on zip on the owner. Staging runs nothing and
// changes no add-on, so an administrator's API key may use them, which lets
// `local-ai addons install` hand a file to a running server. The install that
// follows is the host access install: it needs the administrator's own browser
// session, the preview nonce and the digest of the file, so no script can
// install a staged file by itself. A file is bound to the user that uploaded
// it and expires after an hour.
// UploadAddonFile stages an add-on zip file.
// @Summary Upload an add-on zip file to install from a file (administrators)
// @Description Administrators only (an administrator's session or API key). Multipart form with one part named file, the add-on zip made by `make addons-zip`. The bytes stream to the add-on owner, which keeps them in a private staging area for one hour, bound to the uploading user, and verifies the file: its digest manifest, its paths and sizes, its platform and its add-on manifest. Nothing is installed. Returns the file ID, the sha256 of the file, the add-on ID, version, platform and API range, the host operations the manifest asks for, how the file relates to the installed version (new, upgrade, same or downgrade) and the origin "local file, unsigned": nothing signs a local file, so the administrator chooses what to install. The install is host-grants/preview and host-grants/install with sourceId local-file and version equal to the file ID; it needs the administrator's own browser session. Refusals carry the rule that failed as the error type: bad_zip, unsafe_path, unsupported_entry, too_large, too_many_files, compression_ratio, bad_manifest, digest_mismatch, wrong_platform or bad_mode. No MCP tool maps to this route.
// @Tags Addons
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "The add-on zip"
// @Success 200 {object} addons.FileView "The verified file"
// @Failure 400 {object} AddonErrorResponse "Not a multipart form with a file, or the file broke a rule"
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied"
// @Failure 413 {object} AddonErrorResponse "The file is over the size limit"
// @Failure 429 {object} AddonErrorResponse "Too many staged files"
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
// @Router /api/addons/files [post]
func UploadAddonFile(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
s := resolve(opts)
return func(c echo.Context) error {
a, err, ok := hostGrantsAdmin(c, db, s)
if !ok {
return err
}
if g == nil {
return failure(c, 503, "Addons unavailable")
}
noteRequest(c, contract.Request{Action: "addon-file-upload"})
mt, params, merr := mime.ParseMediaType(c.Request().Header.Get("Content-Type"))
if merr != nil || !strings.HasPrefix(mt, "multipart/") || params["boundary"] == "" {
return failure(c, 400, "Send the file as multipart form data")
}
mr, merr := c.Request().MultipartReader()
if merr != nil {
return failure(c, 400, "Send the file as multipart form data")
}
for {
part, perr := mr.NextPart()
if errors.Is(perr, io.EOF) {
return failure(c, 400, "The form has no part named file")
}
if perr != nil {
return failure(c, 400, "The form could not be read")
}
if part.FormName() != "file" {
_ = part.Close()
continue
}
view, uerr := g.UploadFile(c.Request().Context(), a, part.FileName(), part)
_ = part.Close()
if uerr != nil {
return hostGrantsFailure(c, uerr)
}
return c.JSON(200, view)
}
}
}
// GetAddonFile reads a staged file.
// @Summary Read a staged add-on file (administrators)
// @Description Administrators only. The same answer as the upload, for a file of the calling user that has not expired. 404 when the file is gone or belongs to another user.
// @Tags Addons
// @Produce json
// @Param fileId path string true "File ID from the upload"
// @Success 200 {object} addons.FileView "The staged file"
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied"
// @Failure 404 {object} AddonErrorResponse "No such file"
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
// @Router /api/addons/files/{fileId} [get]
func GetAddonFile(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
s := resolve(opts)
return func(c echo.Context) error {
a, err, ok := hostGrantsAdmin(c, db, s)
if !ok {
return err
}
if g == nil {
return failure(c, 503, "Addons unavailable")
}
noteRequest(c, contract.Request{Action: "addon-file-read"})
view, e := g.DescribeFile(c.Request().Context(), a, c.Param("fileId"))
if e != nil {
return hostGrantsFailure(c, e)
}
return c.JSON(200, view)
}
}
// DeleteAddonFile removes a staged file.
// @Summary Remove a staged add-on file (administrators)
// @Description Administrators only. Removes a file of the calling user that waits for an install. 404 when the file is gone or belongs to another user.
// @Tags Addons
// @Produce json
// @Param fileId path string true "File ID from the upload"
// @Success 204 "Removed"
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied"
// @Failure 404 {object} AddonErrorResponse "No such file"
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
// @Router /api/addons/files/{fileId} [delete]
func DeleteAddonFile(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
s := resolve(opts)
return func(c echo.Context) error {
a, err, ok := hostGrantsAdmin(c, db, s)
if !ok {
return err
}
if g == nil {
return failure(c, 503, "Addons unavailable")
}
noteRequest(c, contract.Request{Action: "addon-file-delete"})
if e := g.DropFile(c.Request().Context(), a, c.Param("fileId")); e != nil {
return hostGrantsFailure(c, e)
}
return c.NoContent(204)
}
}
+207
View File
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: MIT
package addons
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/auth"
managerpkg "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
"github.com/mudler/LocalAI/core/services/addons/packages"
"github.com/mudler/LocalAI/core/services/addons/uploads"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// fileOwner is the owner's file service over a real staging store.
type fileOwner struct {
store *uploads.Store
max int64
reject error
}
func (f *fileOwner) FileBegin(_ context.Context, a contract.Actor, name string) (string, int64, error) {
id, err := f.store.Begin(a.UserID, name, f.max)
return id, f.max, err
}
func (f *fileOwner) FileChunk(_ context.Context, a contract.Actor, id string, off int64, p []byte) error {
return f.store.Append(id, a.UserID, off, p)
}
func (f *fileOwner) FileFinish(_ context.Context, a contract.Actor, id string) (managerpkg.FileView, error) {
e, err := f.store.Finish(id, a.UserID)
if err == nil && f.reject != nil {
_ = f.store.Remove(id, a.UserID)
err = f.reject
}
if err != nil {
return managerpkg.FileView{}, err
}
return managerpkg.FileView{FileID: e.ID, Name: e.Name, Size: e.Size, SHA256: e.SHA256, AddonID: "sample", Version: "1.0.0", Relation: managerpkg.FileNew,
Required: []string{}, Optional: []string{}, Source: "local-file", Origin: "local file, unsigned"}, nil
}
func (f *fileOwner) FileAbort(_ context.Context, a contract.Actor, id string) {
f.store.Abort(id, a.UserID)
}
func (f *fileOwner) FileDrop(_ context.Context, a contract.Actor, id string) error {
return f.store.Remove(id, a.UserID)
}
func (f *fileOwner) FileDescribe(_ context.Context, a contract.Actor, id string) (managerpkg.FileView, error) {
_, e, err := f.store.Open(id, a.UserID)
return managerpkg.FileView{FileID: e.ID, SHA256: e.SHA256}, err
}
var _ = Describe("add-on file routes", func() {
const host = "192.0.2.10:8080"
var (
e *echo.Echo
owner *fileOwner
)
BeforeEach(func() {
store, err := hostgrants.Open(filepath.Join(GinkgoT().TempDir(), "hostgrants.json"))
Expect(err).NotTo(HaveOccurred())
DeferCleanup(store.Close)
owner = &fileOwner{store: &uploads.Store{Root: filepath.Join(GinkgoT().TempDir(), "uploads")}, max: 1 << 20}
svc, err := gateway.NewHostGrantsOwner(gateway.HostGrantsConfig{Store: store, Manager: &hgManager{},
Admin: func(context.Context, contract.Actor) error { return nil }})
Expect(err).NotTo(HaveOccurred())
svc.WithFiles(owner)
gw := &gateway.Gateway{HostGrantsOwner: svc, Revalidate: func(context.Context, contract.Actor) error { return nil }}
e = echo.New()
mode := WithOwnership(auth.AddonModeLocalOwner)
g := e.Group("/api/addons", BrowserProtection())
g.POST("/files", UploadAddonFile(gw, nil, mode))
g.GET("/files/:fileId", GetAddonFile(gw, nil, mode))
g.DELETE("/files/:fileId", DeleteAddonFile(gw, nil, mode))
registerHostGrants(e, gw)
})
form := func(name string, content []byte) (*bytes.Buffer, string) {
var b bytes.Buffer
mw := multipart.NewWriter(&b)
if name != "" {
part, err := mw.CreateFormFile(name, "sample.zip")
Expect(err).NotTo(HaveOccurred())
_, _ = part.Write(content)
}
Expect(mw.Close()).To(Succeed())
return &b, mw.FormDataContentType()
}
do := func(method, path string, body *bytes.Buffer, ctype string, headers map[string]string) (int, map[string]any) {
var r *http.Request
if body != nil {
r = httptest.NewRequest(method, path, body)
} else {
r = httptest.NewRequest(method, path, nil)
}
r.Host = host
if ctype != "" {
r.Header.Set("Content-Type", ctype)
}
r.Header.Set("Sec-Fetch-Site", "same-origin")
for k, v := range headers {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
e.ServeHTTP(w, r)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
upload := func(content []byte) (int, map[string]any) {
b, ct := form("file", content)
return do("POST", "/api/addons/files", b, ct, nil)
}
It("stages a file and answers with its digest", func() {
payload := bytes.Repeat([]byte("zip"), 1000)
code, out := upload(payload)
Expect(code).To(Equal(200), "%v", out)
sum := sha256.Sum256(payload)
Expect(out["sha256"]).To(Equal(hex.EncodeToString(sum[:])))
Expect(out["origin"]).To(Equal("local file, unsigned"))
Expect(out["source"]).To(Equal("local-file"))
for _, k := range []string{"fileId", "name", "size", "addonId", "version", "relation", "required", "optional"} {
Expect(out).To(HaveKey(k), k)
}
id := out["fileId"].(string)
code, got := do("GET", "/api/addons/files/"+id, nil, "", nil)
Expect(code).To(Equal(200))
Expect(got["fileId"]).To(Equal(id))
code, _ = do("DELETE", "/api/addons/files/"+id, nil, "", nil)
Expect(code).To(Equal(204))
code, got = do("GET", "/api/addons/files/"+id, nil, "", nil)
Expect(code).To(Equal(404))
Expect(got["error"].(map[string]any)["type"]).To(Equal("not_found"))
})
It("accepts an administrator's API key to stage a file, as the command line does", func() {
b, ct := form("file", []byte("zip"))
code, _ := do("POST", "/api/addons/files", b, ct, map[string]string{"Sec-Fetch-Site": "", "Authorization": "Bearer key"})
Expect(code).To(Equal(200))
})
It("refuses a cross-site or unproven request that rides a session cookie", func() {
cookie := map[string]string{"Cookie": "session=abc"}
with := func(h map[string]string) map[string]string {
out := map[string]string{}
for k, v := range cookie {
out[k] = v
}
for k, v := range h {
out[k] = v
}
return out
}
b, ct := form("file", []byte("zip"))
code, _ := do("POST", "/api/addons/files", b, ct, with(map[string]string{"Sec-Fetch-Site": "cross-site"}))
Expect(code).To(Equal(403))
b, ct = form("file", []byte("zip"))
code, _ = do("POST", "/api/addons/files", b, ct, with(map[string]string{"Sec-Fetch-Site": ""}))
Expect(code).To(Equal(403))
ents, _ := filepath.Glob(filepath.Join(owner.store.Root, "*"))
Expect(ents).To(BeEmpty())
})
It("refuses a request without a file part", func() {
b, ct := form("", nil)
code, _ := do("POST", "/api/addons/files", b, ct, nil)
Expect(code).To(Equal(400))
b, ct = form("other", []byte("zip"))
code, _ = do("POST", "/api/addons/files", b, ct, nil)
Expect(code).To(Equal(400))
code, _ = do("POST", "/api/addons/files", bytes.NewBufferString(`{"file":"x"}`), "application/json", nil)
Expect(code).To(Equal(400))
})
It("types a file over the limit and a file that broke a rule", func() {
code, out := upload(bytes.Repeat([]byte("a"), (1<<20)+1))
Expect(code).To(Equal(413))
Expect(out["error"].(map[string]any)["type"]).To(Equal("too_large"))
owner.reject = &packages.FileError{Code: packages.FileUnsafePath, Msg: "The zip holds an unsafe file path"}
code, out = upload([]byte("zip"))
Expect(code).To(Equal(400))
Expect(out["error"].(map[string]any)["type"]).To(Equal("unsafe_path"))
Expect(out["error"].(map[string]any)["message"]).To(Equal("The zip holds an unsafe file path"))
})
It("does not let a script install a staged file: the install keeps its human surface rule", func() {
_, out := upload([]byte("zip"))
id := out["fileId"].(string)
body := `{"operationId":"f1","action":"install","sourceId":"local-file","addonId":"sample","version":"` + id + `","downgrade":true,"approve":{"digest":"sha256:aa","ops":[],"revision":0,"nonce":"n"}}`
code, res := do("POST", "/api/addons/host-grants/install", bytes.NewBufferString(body), "application/json", map[string]string{"Authorization": "Bearer key", ConfirmHeader: "sample@sha256:aa"})
Expect(code).To(Equal(403))
Expect(res["error"].(map[string]any)["type"]).To(Equal("not_human_surface"))
Expect(strings.Contains(body, "downgrade")).To(BeTrue())
})
})
+6
View File
@@ -209,6 +209,12 @@ func operationCauseMessage(code string) string {
return "The package could not be downloaded. Try again with a new operation ID. Nothing was installed"
case managerpkg.CauseInstallStorage:
return "The package could not be written to the add-on storage. Check the free space and the permissions of the add-on folder. Nothing was installed"
case managerpkg.CauseFileMissing:
return "The uploaded file is gone: it expired, or another user uploaded it. Upload it again. Nothing was installed"
case managerpkg.CauseVersionNotNewer:
return "This version of the add-on is installed already. Nothing was installed"
case managerpkg.CauseDowngradeRefused:
return "The file holds an older version than the installed one. Confirm the downgrade to install it. Nothing was installed"
}
return "The operation failed"
}
File diff suppressed because one or more lines are too long.
+5
View File
@@ -103,6 +103,11 @@ func RegisterAddonRoutesFor(e *echo.Echo, gw *gateway.Gateway, db *gorm.DB, owne
// only the administrator's own browser session, and the frontend forwards
// that decision to the owner as a signed field. Never tools: no MCP tool
// maps to them.
// Install from a zip file: the file is staged on the owner first. The install
// itself is host-grants/install with sourceId local-file.
group.POST("/files", endpoints.UploadAddonFile(gw, db, mode))
group.GET("/files/:fileId", endpoints.GetAddonFile(gw, db, mode))
group.DELETE("/files/:fileId", endpoints.DeleteAddonFile(gw, db, mode))
group.GET("/host-grants", endpoints.GetHostGrants(gw, db, mode))
group.POST("/host-grants/preview", endpoints.PreviewHostGrants(gw, db, mode))
group.POST("/host-grants/install", endpoints.InstallWithHostGrants(gw, db, mode))
+4 -2
View File
@@ -641,7 +641,8 @@ func operationCause(e error) string {
// operation that cross the wire.
func IsOperationCause(code string) bool {
switch code {
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage:
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage,
addons.CauseFileMissing, addons.CauseVersionNotNewer, addons.CauseDowngradeRefused:
return true
}
return false
@@ -823,7 +824,8 @@ func (c *Carrier) send(ctx context.Context, a contract.Actor, wire wireRequest)
return nil, SDKRefusalError(&contract.SDKRefusal{Min: b.Min, Max: b.Max, Host: b.Host})
case wirePackage:
return nil, fmt.Errorf("%w: %w", addons.ErrOperationFailed, packages.ErrPackage)
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage:
case addons.CauseLibraryUnavailable, addons.CauseLibraryConflict, addons.CauseReleaseNotFound, addons.CausePackageFetch, addons.CauseInstallStorage,
addons.CauseFileMissing, addons.CauseVersionNotNewer, addons.CauseDowngradeRefused:
return nil, fmt.Errorf("%w: %w", addons.ErrOperationFailed, &addons.FailureCause{Code: b.Type})
case wireNotPermitted:
return nil, ErrNotPermitted
+220
View File
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: MIT
package gateway
import (
"context"
"encoding/json"
"errors"
"io"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/uploads"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// The calls that move an add-on zip file to the owner. They ride the host
// access carrier call, so a frontend forwards them like any other: signed,
// over the pinned channel, one chunk per request. The owner keeps the bytes;
// a frontend keeps none. They are not writes of the host access policy: the
// install that follows is, and it keeps its human surface rule.
const (
HostGrantsFileBegin = "file-begin"
HostGrantsFileChunk = "file-chunk"
HostGrantsFileFinish = "file-finish"
HostGrantsFileAbort = "file-abort"
HostGrantsFileDrop = "file-drop"
HostGrantsFileDescribe = "file-describe"
)
// FileChunkBytes is the size of one chunk. Base64 makes it a third larger on
// the wire, which stays under the 2 MiB envelope.
const FileChunkBytes = 1 << 20
// FileService is what the owner needs from the add-on manager to take files.
type FileService interface {
FileBegin(ctx context.Context, actor contract.Actor, name string) (id string, max int64, err error)
FileChunk(ctx context.Context, actor contract.Actor, id string, offset int64, data []byte) error
FileFinish(ctx context.Context, actor contract.Actor, id string) (addons.FileView, error)
FileAbort(ctx context.Context, actor contract.Actor, id string)
FileDrop(ctx context.Context, actor contract.Actor, id string) error
FileDescribe(ctx context.Context, actor contract.Actor, id string) (addons.FileView, error)
}
func isFileOp(op string) bool {
switch op {
case HostGrantsFileBegin, HostGrantsFileChunk, HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe:
return true
}
return false
}
func init() {
// A lost answer to a file call is a plain failure: the upload starts again.
for _, op := range []string{HostGrantsFileBegin, HostGrantsFileChunk, HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe} {
hostGrantsRead["hostgrants-"+op] = true
}
}
type fileBeginRequest struct {
Name string `json:"name"`
}
// FileBeginView is the answer of file-begin.
type FileBeginView struct {
ID string `json:"id"`
MaxBytes int64 `json:"maxBytes"`
}
type fileChunkRequest struct {
ID string `json:"id"`
Offset int64 `json:"offset"`
Data []byte `json:"data"`
}
type fileIDRequest struct {
ID string `json:"id"`
}
// WithFiles sets the service that takes staged files. Set it on the owner.
func (o *HostGrantsOwner) WithFiles(f FileService) *HostGrantsOwner {
o.files = f
return o
}
// fileCall serves one file call. The administrator check has run already.
func (o *HostGrantsOwner) fileCall(ctx context.Context, a contract.Actor, call HostGrantsCall) (any, error) {
if o.files == nil {
return nil, ErrUnavailable
}
bad := func() (any, error) { return nil, o.invalid() }
switch call.Op {
case HostGrantsFileBegin:
var r fileBeginRequest
if !strictBody(call.Body, &r) || r.Name == "" || len(r.Name) > 512 {
return bad()
}
id, max, err := o.files.FileBegin(ctx, a, r.Name)
return FileBeginView{ID: id, MaxBytes: max}, o.fileError(err)
case HostGrantsFileChunk:
var r fileChunkRequest
if !strictBody(call.Body, &r) || !uploads.ValidID(r.ID) || r.Offset < 0 || len(r.Data) == 0 || len(r.Data) > FileChunkBytes {
return bad()
}
return struct{}{}, o.fileError(o.files.FileChunk(ctx, a, r.ID, r.Offset, r.Data))
case HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe:
var r fileIDRequest
if !strictBody(call.Body, &r) || !uploads.ValidID(r.ID) {
return bad()
}
switch call.Op {
case HostGrantsFileFinish:
v, err := o.files.FileFinish(ctx, a, r.ID)
return v, o.fileError(err)
case HostGrantsFileDescribe:
v, err := o.files.FileDescribe(ctx, a, r.ID)
return v, o.fileError(err)
case HostGrantsFileDrop:
return struct{}{}, o.fileError(o.files.FileDrop(ctx, a, r.ID))
}
o.files.FileAbort(ctx, a, r.ID)
return struct{}{}, nil
}
return bad()
}
// fileError types the refusals of the file calls for the routes. A refusal of
// the content keeps the code of the rule it broke.
func (o *HostGrantsOwner) fileError(err error) error {
if err == nil {
return nil
}
if fe, ok := addons.IsFileRefusal(err); ok {
return hgErr(400, fe.Code, fe.Msg)
}
switch {
case errors.Is(err, uploads.ErrNotFound):
return hgErr(404, HostGrantsNotFound, "No such file. It may have expired; upload it again")
case errors.Is(err, uploads.ErrTooLarge):
return hgErr(413, "too_large", "The file is over the size limit")
case errors.Is(err, uploads.ErrBusy):
return hgErr(429, "busy", "Too many files wait for an install. Remove one or wait")
case errors.Is(err, uploads.ErrOffset):
return hgErr(400, HostGrantsInvalidRequest, "The upload chunks arrived out of order")
case errors.Is(err, addons.ErrNoFileInstall):
return ErrUnavailable
}
return err
}
func (g *Gateway) fileDo(ctx context.Context, a contract.Actor, op string, req any, out any) error {
body, err := json.Marshal(req)
if err != nil {
return ErrForbidden
}
raw, err := g.HostGrants(ctx, a, HostGrantsCall{Op: op, Body: body})
if err != nil {
return err
}
if out == nil {
return nil
}
return json.Unmarshal(raw, out)
}
// UploadFile sends a zip file to the owner chunk by chunk and returns what the
// owner made of it. In a split deployment the chunks cross the carrier; on the
// owner they go straight to the service. Nothing is kept here. The owner
// enforces the byte limit while it receives, so a client that sends more is cut.
func (g *Gateway) UploadFile(ctx context.Context, a contract.Actor, name string, r io.Reader) (addons.FileView, error) {
var begin FileBeginView
if err := g.fileDo(ctx, a, HostGrantsFileBegin, fileBeginRequest{Name: name}, &begin); err != nil {
return addons.FileView{}, err
}
abort := func() {
// The caller's context may be the reason for the failure.
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), contract.DefaultLimits().ReadTimeout)
defer cancel()
_ = g.fileDo(c, a, HostGrantsFileAbort, fileIDRequest{ID: begin.ID}, nil)
}
buf := make([]byte, FileChunkBytes)
var offset int64
for {
n, rerr := io.ReadFull(r, buf)
if n > 0 {
if err := g.fileDo(ctx, a, HostGrantsFileChunk, fileChunkRequest{ID: begin.ID, Offset: offset, Data: buf[:n]}, nil); err != nil {
abort()
return addons.FileView{}, err
}
offset += int64(n)
}
if rerr == io.EOF || rerr == io.ErrUnexpectedEOF {
break
}
if rerr != nil {
abort()
return addons.FileView{}, rerr
}
}
if offset == 0 {
abort()
return addons.FileView{}, hgErr(400, "bad_zip", "The file is empty")
}
var view addons.FileView
if err := g.fileDo(ctx, a, HostGrantsFileFinish, fileIDRequest{ID: begin.ID}, &view); err != nil {
abort()
return addons.FileView{}, err
}
return view, nil
}
// DescribeFile reads a staged file again.
func (g *Gateway) DescribeFile(ctx context.Context, a contract.Actor, id string) (addons.FileView, error) {
var view addons.FileView
err := g.fileDo(ctx, a, HostGrantsFileDescribe, fileIDRequest{ID: id}, &view)
return view, err
}
// DropFile removes a staged file.
func (g *Gateway) DropFile(ctx context.Context, a contract.Actor, id string) error {
return g.fileDo(ctx, a, HostGrantsFileDrop, fileIDRequest{ID: id}, nil)
}
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: MIT
package gateway
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing/iotest"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/packages"
"github.com/mudler/LocalAI/core/services/addons/uploads"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// fileSim is the owner's file service over a real staging store. Finish
// checks nothing about the zip itself: the manager tests do that.
type fileSim struct {
store *uploads.Store
max int64
aborted int
reject error
}
func (f *fileSim) FileBegin(_ context.Context, a contract.Actor, name string) (string, int64, error) {
id, e := f.store.Begin(a.UserID, name, f.max)
return id, f.max, e
}
func (f *fileSim) FileChunk(_ context.Context, a contract.Actor, id string, off int64, p []byte) error {
return f.store.Append(id, a.UserID, off, p)
}
func (f *fileSim) FileFinish(_ context.Context, a contract.Actor, id string) (addons.FileView, error) {
e, err := f.store.Finish(id, a.UserID)
if err != nil {
return addons.FileView{}, err
}
if f.reject != nil {
_ = f.store.Remove(id, a.UserID)
return addons.FileView{}, f.reject
}
return addons.FileView{FileID: e.ID, Name: e.Name, Size: e.Size, SHA256: e.SHA256, AddonID: "sample", Relation: addons.FileNew}, nil
}
func (f *fileSim) FileAbort(_ context.Context, a contract.Actor, id string) {
f.aborted++
f.store.Abort(id, a.UserID)
}
func (f *fileSim) FileDrop(_ context.Context, a contract.Actor, id string) error {
return f.store.Remove(id, a.UserID)
}
func (f *fileSim) FileDescribe(_ context.Context, a contract.Actor, id string) (addons.FileView, error) {
_, e, err := f.store.Open(id, a.UserID)
if err != nil {
return addons.FileView{}, err
}
return addons.FileView{FileID: e.ID, SHA256: e.SHA256, Size: e.Size}, nil
}
var _ = Describe("add-on zip files reach the owner", func() {
ok := func(context.Context, contract.Actor) error { return nil }
alice := contract.Actor{UserID: "alice", CredentialID: "session:a", Mode: "named-user"}
bob := contract.Actor{UserID: "bob", CredentialID: "session:b", Mode: "named-user"}
type rig struct {
owner, front, other *Gateway
sim *fileSim
}
build := func(max int64) rig {
sim := &fileSim{store: &uploads.Store{Root: filepath.Join(GinkgoT().TempDir(), "uploads")}, max: max}
fx := newHGFixture()
fx.svc.WithFiles(sim)
var og *Gateway
_, cs := carrierFixture(func(c *Carrier) http.Handler {
og = &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok}
h, e := c.OwnerHandler(og)
Expect(e).NotTo(HaveOccurred())
return h
})
return rig{owner: og, front: &Gateway{Forward: cs["front-a"], Revalidate: ok}, other: &Gateway{Forward: cs["front-b"], Revalidate: ok}, sim: sim}
}
random := func(n int) []byte {
b := make([]byte, n)
_, _ = rand.Read(b)
return b
}
for _, topology := range []string{"on the owner", "through a frontend"} {
Describe(topology, func() {
gw := func(r rig) *Gateway {
if topology == "on the owner" {
return r.owner
}
return r.front
}
It("moves a file in chunks and keeps its digest", func() {
r := build(64 << 20)
// Three chunks and a bit: the last one is short.
payload := random(3*FileChunkBytes + 12345)
v, err := gw(r).UploadFile(context.Background(), alice, "sample.zip", bytes.NewReader(payload))
Expect(err).NotTo(HaveOccurred())
sum := sha256.Sum256(payload)
Expect(v.SHA256).To(Equal(hex.EncodeToString(sum[:])))
Expect(v.Size).To(Equal(int64(len(payload))))
p, _, err := r.sim.store.Open(v.FileID, "alice")
Expect(err).NotTo(HaveOccurred())
onDisk, _ := os.ReadFile(p)
Expect(onDisk).To(Equal(payload))
})
It("cuts an upload that goes over the limit and keeps nothing", func() {
r := build(int64(FileChunkBytes) + 10)
_, err := gw(r).UploadFile(context.Background(), alice, "big.zip", bytes.NewReader(random(2*FileChunkBytes)))
Expect(typeOf(err)).To(Equal("too_large"))
var he *HostGrantsError
Expect(errors.As(err, &he)).To(BeTrue())
Expect(he.Status).To(Equal(413))
ents, _ := os.ReadDir(r.sim.store.Root)
Expect(ents).To(BeEmpty())
})
It("passes the rule a refused file broke to the caller", func() {
r := build(64 << 20)
r.sim.reject = &packages.FileError{Code: packages.FileWrongPlatform, Msg: "This file is for linux/arm64; this host is linux/amd64"}
_, err := gw(r).UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
Expect(typeOf(err)).To(Equal("wrong_platform"))
Expect(err.Error()).To(ContainSubstring("linux/arm64"))
ents, _ := os.ReadDir(r.sim.store.Root)
Expect(ents).To(BeEmpty(), "a refused file is not kept")
})
It("aborts when the client's stream breaks, and rejects an empty file", func() {
r := build(64 << 20)
broken := iotest.ErrReader(errors.New("connection reset"))
_, err := gw(r).UploadFile(context.Background(), alice, "x.zip", broken)
Expect(err).To(HaveOccurred())
Expect(r.sim.aborted).To(Equal(1))
_, err = gw(r).UploadFile(context.Background(), alice, "x.zip", bytes.NewReader(nil))
Expect(typeOf(err)).To(Equal("bad_zip"))
ents, _ := os.ReadDir(r.sim.store.Root)
Expect(ents).To(BeEmpty())
})
It("binds a staged file to the user that uploaded it", func() {
r := build(64 << 20)
v, err := gw(r).UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
Expect(err).NotTo(HaveOccurred())
_, err = gw(r).DescribeFile(context.Background(), bob, v.FileID)
Expect(typeOf(err)).To(Equal("not_found"))
Expect(gw(r).DropFile(context.Background(), bob, v.FileID)).NotTo(Succeed())
got, err := gw(r).DescribeFile(context.Background(), alice, v.FileID)
Expect(err).NotTo(HaveOccurred())
Expect(got.FileID).To(Equal(v.FileID))
Expect(gw(r).DropFile(context.Background(), alice, v.FileID)).To(Succeed())
_, err = gw(r).DescribeFile(context.Background(), alice, v.FileID)
Expect(typeOf(err)).To(Equal("not_found"))
})
})
}
It("lets a second frontend read the file the first one uploaded", func() {
r := build(64 << 20)
v, err := r.front.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
Expect(err).NotTo(HaveOccurred())
got, err := r.other.DescribeFile(context.Background(), alice, v.FileID)
Expect(err).NotTo(HaveOccurred())
Expect(got.SHA256).To(Equal(v.SHA256))
})
It("needs no human mark to stage a file, and never changes the policy by it", func() {
r := build(64 << 20)
_, err := r.front.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("zip bytes"))
Expect(err).NotTo(HaveOccurred())
// The install is another call and keeps its rule.
_, err = r.front.HostGrants(context.Background(), alice, call(HostGrantsInstall, "i1", "", HostGrantsInstallRequest{OperationID: "i1", Action: "install", SourceID: "local-file", AddonID: "sample", Version: strings.Repeat("a", 32), Approve: HostGrantsApprove{Digest: "sha256:aa", Nonce: "n"}}))
Expect(typeOf(err)).To(Equal("not_human_surface"))
})
It("answers unavailable when the owner has no file service", func() {
fx := newHGFixture()
g := &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok}
_, err := g.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("x"))
Expect(err).To(MatchError(ErrUnavailable))
})
})
+9 -3
View File
@@ -59,6 +59,9 @@ func (c HostGrantsCall) write() bool {
}
func (c HostGrantsCall) valid() bool {
if isFileOp(c.Op) {
return c.OperationID == ""
}
switch c.Op {
case HostGrantsRead, HostGrantsPreview, HostGrantsDenyPreview:
return c.OperationID == ""
@@ -277,9 +280,12 @@ type HostGrantsInstallRequest struct {
AddonID string `json:"addonId"`
Version string `json:"version"`
// ExpectedDigest and CandidateDigest pin an upgrade; both or neither.
ExpectedDigest string `json:"expectedDigest,omitempty"`
CandidateDigest string `json:"candidateDigest,omitempty"`
Approve HostGrantsApprove `json:"approve"`
ExpectedDigest string `json:"expectedDigest,omitempty"`
CandidateDigest string `json:"candidateDigest,omitempty"`
// Downgrade confirms that a local file may install an older version than
// the installed one. Only a sourceId of local-file reads it.
Downgrade bool `json:"downgrade,omitempty"`
Approve HostGrantsApprove `json:"approve"`
}
// HostGrantsEditRequest is the body of PUT /api/addons/host-grants/{addonId}.
@@ -16,6 +16,7 @@ import (
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
"github.com/mudler/LocalAI/core/services/addons/hostops"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/addons/zipformat"
)
// nonceLife is how long a preview nonce stays valid.
@@ -63,6 +64,7 @@ type HostGrantsConfig struct {
type HostGrantsOwner struct {
cfg HostGrantsConfig
nonces nonceStore
files FileService
}
// NewHostGrantsOwner returns the owner service. A store and a manager are required.
@@ -152,6 +154,8 @@ func (o *HostGrantsOwner) Serve(ctx context.Context, a contract.Actor, call Host
if o.cfg.Admin == nil {
return nil, ErrForbidden
}
// A staged local file is read for the user that uploaded it only.
ctx = addons.WithFileOwner(ctx, a.UserID)
if e := ClassifyCheck(o.cfg.Admin(ctx, a)); e != nil {
return nil, e
}
@@ -210,6 +214,8 @@ func (o *HostGrantsOwner) Serve(ctx context.Context, a contract.Actor, call Host
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
out, err = o.deny(ctx, a, r)
case HostGrantsFileBegin, HostGrantsFileChunk, HostGrantsFileFinish, HostGrantsFileAbort, HostGrantsFileDrop, HostGrantsFileDescribe:
out, err = o.fileCall(ctx, a, call)
default:
return nil, ErrForbidden
}
@@ -550,7 +556,7 @@ func (o *HostGrantsOwner) install(ctx context.Context, a contract.Actor, r HostG
return nil, hgErr(409, HostGrantsApprovalStale, "The approval expired or does not match; review the package again")
}
cmd := addons.LifecycleCommand{OperationID: r.OperationID, AddonID: r.AddonID, Action: r.Action, SourceID: r.SourceID, Version: r.Version,
ExpectedDigest: r.ExpectedDigest, CandidateDigest: r.CandidateDigest}
ExpectedDigest: r.ExpectedDigest, CandidateDigest: r.CandidateDigest, Downgrade: r.Downgrade && r.SourceID == zipformat.SourceID}
st, err := o.cfg.Manager.SubmitWithApproval(ctx, a, cmd, HostApproval{Digest: r.Approve.Digest, Ops: slices.Clone(r.Approve.Ops), Revision: r.Approve.Revision, Nonce: r.Approve.Nonce})
if err != nil {
return nil, err
+1 -1
View File
@@ -302,7 +302,7 @@ var assetPath = regexp.MustCompile(`^[a-zA-Z0-9_./-]+$`)
// so an add-on with one of these IDs would have its asset URLs captured by the
// host route of the same name. A test derives the registered segments from the
// real routes and requires each to be listed here.
var reservedAddonIDs = []string{"apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants"}
var reservedAddonIDs = []string{"apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants", "files"}
// ReservedAddonIDs returns a copy of the reserved add-on IDs.
func ReservedAddonIDs() []string { return append([]string(nil), reservedAddonIDs...) }
+1 -1
View File
@@ -143,7 +143,7 @@ var _ = Describe("Manifest", func() {
It("rejects add-on IDs that the host serves as a route segment", func() {
// Every first path segment the host uses under /api/addons/ would
// otherwise capture the asset URL of an add-on with that ID.
Expect(ReservedAddonIDs()).To(ConsistOf("apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants"))
Expect(ReservedAddonIDs()).To(ConsistOf("apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants", "files"))
for _, id := range ReservedAddonIDs() {
m, _ := ParseManifest(fixture())
m.ID = id