From 3d737a0755451208ea6a0bdf2868e5505e664feb Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 22:30:15 +0000 Subject: [PATCH] feat(cli): add local-ai addons inspect and install inspect verifies an add-on zip on this machine and shows what it holds. install checks it, uploads it to a running server and prints the page where the administrator confirms the install and the host access. The command never installs by itself. Assisted-by: Claude:claude-sonnet-5-5 go gofmt --- core/cli/addons.go | 166 ++++++++++++++++++++++++++++++++++++++++ core/cli/addons_test.go | 117 ++++++++++++++++++++++++++++ core/cli/cli.go | 1 + 3 files changed, 284 insertions(+) create mode 100644 core/cli/addons.go create mode 100644 core/cli/addons_test.go diff --git a/core/cli/addons.go b/core/cli/addons.go new file mode 100644 index 0000000000..3fba5040c3 --- /dev/null +++ b/core/cli/addons.go @@ -0,0 +1,166 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + cliContext "github.com/mudler/LocalAI/core/cli/context" + "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/core/services/addons/packages" +) + +// AddonsCMD works with add-on zip files, the ones `make addons-zip` writes. +type AddonsCMD struct { + Inspect AddonsInspectCMD `cmd:"" help:"Check an add-on zip file and show what it holds, without installing it"` + Install AddonsInstallCMD `cmd:"" help:"Upload an add-on zip file to a running server. You finish the install in the browser"` +} + +// AddonsInspectCMD verifies a zip on this machine. +type AddonsInspectCMD struct { + File string `arg:"" name:"file" type:"existingfile" help:"The add-on zip file"` + + // output receives the report. It is stdout when nil. + output io.Writer +} + +func (c *AddonsInspectCMD) Run(_ *cliContext.Context) error { + sum, err := packages.InspectFile(context.Background(), c.File, 0, 0) + if err != nil { + return fmt.Errorf("%s: %w", c.File, err) + } + w := c.output + if w == nil { + w = os.Stdout + } + writeAddonFileSummary(w, sum) + return nil +} + +func writeAddonFileSummary(w io.Writer, sum packages.FileSummary) { + m := sum.Package + _, _ = fmt.Fprintf(w, "add-on: %s %s\n", sum.ID, sum.Version) + _, _ = fmt.Fprintf(w, "platform: %s/%s\n", sum.OS, sum.Arch) + _, _ = fmt.Fprintf(w, "host API: %d to %d\n", m.API.Min, m.API.Max) + _, _ = fmt.Fprintf(w, "files: %d, %d bytes\n", sum.Files, sum.Size) + _, _ = fmt.Fprintf(w, "sha256: %s\n", sum.SHA256) + var req, opt []string + if h := m.Capabilities.HostOperations; h != nil { + req, opt = h.Required, h.Optional + } + _, _ = fmt.Fprintf(w, "host access: required %s; optional %s\n", listOrNone(req), listOrNone(opt)) + _, _ = fmt.Fprintln(w, "origin: local file, unsigned. Nothing signs this file; install it only if you trust whoever made it.") +} + +func listOrNone(l []string) string { + if len(l) == 0 { + return "none" + } + return strings.Join(l, ", ") +} + +// AddonsInstallCMD uploads a zip to a running server. +type AddonsInstallCMD struct { + File string `arg:"" name:"file" type:"existingfile" help:"The add-on zip file"` + Target string `env:"LOCALAI_TARGET,LOCALAI_MCP_TARGET" default:"http://localhost:8080" help:"Base URL of the LocalAI server"` + APIKey string `env:"LOCALAI_API_KEY" help:"Administrator API key of the server, when the server asks for one"` + + output io.Writer + client *http.Client +} + +// The install itself is a decision about what the add-on may do on this host, +// so it is not made here. The command checks the file, hands it to the +// server's staging area and prints the page where the administrator confirms +// the install and the host operations. +func (c *AddonsInstallCMD) Run(_ *cliContext.Context) error { + sum, err := packages.InspectFile(context.Background(), c.File, 0, 0) + if err != nil { + return fmt.Errorf("%s: %w", c.File, err) + } + w := c.output + if w == nil { + w = os.Stdout + } + writeAddonFileSummary(w, sum) + base, err := url.Parse(strings.TrimRight(c.Target, "/")) + if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" { + return fmt.Errorf("--target: %q is not a http or https URL", c.Target) + } + view, err := c.upload(base) + if err != nil { + return err + } + if !strings.EqualFold(view.SHA256, sum.SHA256) { + return errors.New("the server holds a file with another digest than the one sent; nothing was installed") + } + _, _ = fmt.Fprintf(w, "\nUploaded to %s as %s (%s).\n", base.Host, view.FileID, view.Relation) + _, _ = fmt.Fprintf(w, "Finish the install in the browser, where you review the host access it asks for:\n %s/app/addons?file=%s\n", base.String(), view.FileID) + _, _ = fmt.Fprintln(w, "The file waits there for one hour.") + return nil +} + +func (c *AddonsInstallCMD) upload(base *url.URL) (addons.FileView, error) { + var view addons.FileView + f, err := os.Open(c.File) // #nosec G304 -- the file the administrator named on the command line + if err != nil { + return view, err + } + defer func() { _ = f.Close() }() + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + go func() { + part, e := mw.CreateFormFile("file", filepath.Base(c.File)) + if e == nil { + _, e = io.Copy(part, f) + } + if e == nil { + e = mw.Close() + } + _ = pw.CloseWithError(e) + }() + req, err := http.NewRequest(http.MethodPost, base.String()+"/api/addons/files", pr) + if err != nil { + return view, err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + client := c.client + if client == nil { + client = &http.Client{Timeout: 30 * time.Minute} + } + resp, err := client.Do(req) + if err != nil { + return view, fmt.Errorf("upload to %s: %w", base.Host, err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + var e struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(body, &e) == nil && e.Error.Message != "" { + return view, fmt.Errorf("the server refused the file (%d %s): %s", resp.StatusCode, e.Error.Type, e.Error.Message) + } + return view, fmt.Errorf("the server refused the file (%d)", resp.StatusCode) + } + if err = json.NewDecoder(bytes.NewReader(body)).Decode(&view); err != nil || view.FileID == "" { + return view, errors.New("the server answered with something that is not an add-on file") + } + return view, nil +} diff --git a/core/cli/addons_test.go b/core/cli/addons_test.go new file mode 100644 index 0000000000..4d6c62d437 --- /dev/null +++ b/core/cli/addons_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +package cli + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + + "github.com/alecthomas/kong" + "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/pkg/addons/zipformat" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Add-on zip commands", func() { + manifest := `{"schemaVersion":1,"id":"sample","version":"1.2.3","executable":"bin/sample","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":2,"max":2},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"capabilities":{"service":true,"hostOperations":{"required":["workspace.materialize"],"optional":["egress.check"]}}}` + writeZip := func() string { + var b bytes.Buffer + Expect(zipformat.Write(&b, "sample", "1.2.3", "linux", "amd64", "bin/sample", []zipformat.Entry{ + {Name: "manifest.json", Data: []byte(manifest)}, {Name: "bin/sample", Data: []byte("x"), Exec: true}, + })).To(Succeed()) + p := filepath.Join(GinkgoT().TempDir(), "sample-1.2.3-linux-amd64.zip") + Expect(os.WriteFile(p, b.Bytes(), 0o600)).To(Succeed()) + return p + } + parse := func(args ...string) (*AddonsCMD, error) { + var command struct { + Addons AddonsCMD `cmd:"" name:"addons"` + } + p, err := kong.New(&command) + if err != nil { + return nil, err + } + _, err = p.Parse(append([]string{"addons"}, args...)) + return &command.Addons, err + } + + It("shows what a zip holds and that nothing signs it", func() { + c, err := parse("inspect", writeZip()) + Expect(err).NotTo(HaveOccurred()) + var out bytes.Buffer + c.Inspect.output = &out + Expect(c.Inspect.Run(nil)).To(Succeed()) + Expect(out.String()).To(ContainSubstring("sample 1.2.3")) + Expect(out.String()).To(ContainSubstring("linux/amd64")) + Expect(out.String()).To(ContainSubstring("required workspace.materialize; optional egress.check")) + Expect(out.String()).To(MatchRegexp(`sha256:\s+[0-9a-f]{64}`)) + Expect(out.String()).To(ContainSubstring("local file, unsigned")) + }) + + It("refuses a file that is not an add-on zip", func() { + p := filepath.Join(GinkgoT().TempDir(), "x.zip") + Expect(os.WriteFile(p, []byte("nope"), 0o600)).To(Succeed()) + c, err := parse("inspect", p) + Expect(err).NotTo(HaveOccurred()) + Expect(c.Inspect.Run(nil)).To(MatchError(ContainSubstring("not a zip"))) + }) + + It("uploads the file with the key, and prints the page that finishes the install", func() { + path := writeZip() + raw, _ := os.ReadFile(path) + var gotAuth, gotName string + var gotBytes []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + Expect(r.Method + " " + r.URL.Path).To(Equal("POST /api/addons/files")) + f, h, err := r.FormFile("file") + Expect(err).NotTo(HaveOccurred()) + gotName = h.Filename + gotBytes, _ = io.ReadAll(f) + _ = json.NewEncoder(w).Encode(addons.FileView{FileID: "0123456789abcdef0123456789abcdef", SHA256: zipSHA(raw), Relation: "new"}) + })) + DeferCleanup(srv.Close) + c, err := parse("install", path, "--target", srv.URL, "--api-key", "k1") + Expect(err).NotTo(HaveOccurred()) + var out bytes.Buffer + c.Install.output = &out + Expect(c.Install.Run(nil)).To(Succeed()) + Expect(gotAuth).To(Equal("Bearer k1")) + Expect(gotName).To(Equal("sample-1.2.3-linux-amd64.zip")) + Expect(gotBytes).To(Equal(raw)) + Expect(out.String()).To(ContainSubstring(srv.URL + "/app/addons?file=0123456789abcdef0123456789abcdef")) + Expect(out.String()).To(ContainSubstring("in the browser")) + }) + + It("reports the reason the server gives", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + _, _ = w.Write([]byte(`{"error":{"type":"wrong_platform","code":400,"message":"This file is for linux/arm64; this host is linux/amd64"}}`)) + })) + DeferCleanup(srv.Close) + c, err := parse("install", writeZip(), "--target", srv.URL) + Expect(err).NotTo(HaveOccurred()) + var out bytes.Buffer + c.Install.output = &out + Expect(c.Install.Run(nil)).To(MatchError(ContainSubstring("wrong_platform"))) + }) + + It("rejects a target that is not a web address", func() { + c, err := parse("install", writeZip(), "--target", "ftp://x") + Expect(err).NotTo(HaveOccurred()) + c.Install.output = io.Discard + Expect(c.Install.Run(nil)).To(MatchError(ContainSubstring("--target"))) + }) +}) + +func zipSHA(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} diff --git a/core/cli/cli.go b/core/cli/cli.go index 12958531e3..677f54945b 100644 --- a/core/cli/cli.go +++ b/core/cli/cli.go @@ -15,6 +15,7 @@ var CLI struct { Federated FederatedCLI `cmd:"" help:"Run LocalAI in federated mode"` Models ModelsCMD `cmd:"" help:"Manage LocalAI models and definitions"` Backends BackendsCMD `cmd:"" help:"Manage LocalAI backends and definitions"` + Addons AddonsCMD `cmd:"" help:"Check an add-on zip file and upload it to a running server to install it"` Library LibraryCMD `cmd:"" help:"Manage the library sources LocalAI reads add-ons, backends and models from"` TTS TTSCMD `cmd:"" help:"Convert text to speech"` SoundGeneration SoundGenerationCMD `cmd:"" help:"Generates audio files from text or audio"`