feat(addons): run the add-on owner in the normal binary

The owner was only enabled when an embedding host set the add-on config,
so a normal run answered every add-on request as unavailable.

Add --addons-enabled (default true) and --addons-ownership-mode
(default named-user, or local-owner; anything else stops startup).
The node is its own owner: state lives under the data path, and the
carrier uses loopback keys made at each start and kept in memory.
--addons-enabled=false turns the owner off completely.

Named-user mode needs the account database. With authentication off
the owner stays off and a warning says how to use local-owner.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-20 20:54:44 +00:00
1 parent 01a3ce41ff
commit 49995e4aff
5 files changed
+258

No files matched your search

+10
View File
@@ -34,6 +34,8 @@ import (
type RunCMD struct {
AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" default:"fine-tuning=finetune.,quantization=quantize." help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"`
AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"`
AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" default:"named-user" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner)" group:"addons"`
Pprof bool `name:"pprof" env:"LOCALAI_PPROF" default:"false" help:"Enable the private loopback profiling listener" group:"diagnostics"`
PprofAddress string `name:"pprof-address" env:"LOCALAI_PPROF_ADDRESS" default:"127.0.0.1:6060" help:"Numeric loopback address and port for profiling" group:"diagnostics"`
PprofMutexProfileFraction int `name:"pprof-mutex-profile-fraction" env:"LOCALAI_PPROF_MUTEX_PROFILE_FRACTION" default:"0" help:"Mutex sampling fraction (0 disables sampling); requires pprof" group:"diagnostics"`
@@ -654,6 +656,14 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
// Authentication
authEnabled := r.AuthEnabled || r.GitHubClientID != "" || r.OIDCClientID != ""
addonCfg, err := addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, r.DataPath, authEnabled)
if err != nil {
cancelRun(nil)
return err
}
if addonCfg.Enabled {
opts = append(opts, config.WithAddons(addonCfg))
}
if authEnabled {
opts = append(opts, config.WithAuthEnabled(true))
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MIT
package cli
import (
"fmt"
"path/filepath"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/xlog"
)
// localAddonOwnerID names the one instance of a single-node run. It is also the
// owner, so the node supervises the add-ons itself.
const localAddonOwnerID = "local"
// addonConfigFor derives the add-on owner configuration of a single node from
// the run flags. Only the on/off switch and the ownership mode are flags; the
// rest follows from the node being its own owner.
//
// The default mode, named-user, needs the account database. Without
// authentication there is no such database, so the owner stays off with a
// warning instead of stopping the server: local-owner mode is never inferred
// and an operator must select it.
func addonConfigFor(enabled bool, mode, dataPath string, authEnabled bool) (config.AddonConfig, error) {
if !enabled {
return config.AddonConfig{}, nil
}
switch mode {
case auth.AddonModeNamedUser, auth.AddonModeLocalOwner:
default:
return config.AddonConfig{}, fmt.Errorf("--addons-ownership-mode: %q is not valid; use %q or %q", mode, auth.AddonModeNamedUser, auth.AddonModeLocalOwner)
}
if mode == auth.AddonModeNamedUser && !authEnabled {
xlog.Warn("Add-ons are off: named-user ownership needs authentication. Enable authentication, or set --addons-ownership-mode=local-owner to let every caller share the owner's authority")
return config.AddonConfig{}, nil
}
root, err := filepath.Abs(filepath.Join(dataPath, "addons"))
if err != nil {
return config.AddonConfig{}, err
}
transport, address, err := gateway.NewLoopbackCarrierConfig(localAddonOwnerID)
if err != nil {
return config.AddonConfig{}, fmt.Errorf("add-on owner transport: %w", err)
}
return config.AddonConfig{
Enabled: true,
InstanceID: localAddonOwnerID, OwnerID: localAddonOwnerID, OwnerAddress: transport.OwnerAddress,
StorageID: "local", Root: root, ListenAddress: address, Transport: transport,
OwnershipMode: mode,
}, nil
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT
package cli
import (
"os"
"path/filepath"
"github.com/alecthomas/kong"
cliContext "github.com/mudler/LocalAI/core/cli/context"
"github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Add-on owner flags", Serial, func() {
parse := func(args ...string) (*RunCMD, error) {
command := struct {
cliContext.Context `embed:""`
Run RunCMD `cmd:"" default:"withargs"`
}{}
p, err := kong.New(&command, kong.Vars{
"basepath": kong.ExpandPath("."),
"generatedcontentpath": DefaultGeneratedContentPath(),
"uploadpath": DefaultUploadPath(),
"galleries": config.DefaultGalleriesJSON,
"backends": config.DefaultBackendGalleriesJSON,
})
if err != nil {
return &command.Run, err
}
_, err = p.Parse(args)
return &command.Run, err
}
BeforeEach(func() {
for _, key := range []string{"LOCALAI_ADDONS_ENABLED", "LOCALAI_ADDONS_OWNERSHIP_MODE"} {
value, present := os.LookupEnv(key)
DeferCleanup(func() {
if present {
Expect(os.Setenv(key, value)).To(Succeed())
} else {
Expect(os.Unsetenv(key)).To(Succeed())
}
})
Expect(os.Unsetenv(key)).To(Succeed())
}
})
It("turns the owner on by default, in named-user mode", func() {
r, err := parse()
Expect(err).NotTo(HaveOccurred())
Expect(r.AddonsEnabled).To(BeTrue())
Expect(r.AddonsOwnershipMode).To(Equal("named-user"))
dir := GinkgoT().TempDir()
c, err := addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, dir, true)
Expect(err).NotTo(HaveOccurred())
Expect(c.Enabled).To(BeTrue())
Expect(c.InstanceID).To(Equal(c.OwnerID))
Expect(c.OwnershipMode).To(Equal("named-user"))
Expect(c.SharedStorage).To(BeFalse())
Expect(c.StorageID).To(Equal("local"))
Expect(c.Root).To(Equal(filepath.Join(dir, "addons")))
Expect(c.Transport).NotTo(BeNil())
Expect(c.ListenAddress).NotTo(BeEmpty())
Expect(c.OwnerAddress).To(Equal("https://" + c.ListenAddress))
Expect(c.Transport.OwnerAddress).To(Equal(c.OwnerAddress))
})
It("turns the owner off completely with --addons-enabled=false", func() {
r, err := parse("--addons-enabled=false")
Expect(err).NotTo(HaveOccurred())
Expect(r.AddonsEnabled).To(BeFalse())
c, err := addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, GinkgoT().TempDir(), true)
Expect(err).NotTo(HaveOccurred())
Expect(c).To(Equal(config.AddonConfig{}))
Expect(c.Transport).To(BeNil())
})
It("turns the owner off from the environment", func() {
Expect(os.Setenv("LOCALAI_ADDONS_ENABLED", "false")).To(Succeed())
r, err := parse()
Expect(err).NotTo(HaveOccurred())
Expect(r.AddonsEnabled).To(BeFalse())
})
It("refuses an unknown ownership mode", func() {
r, err := parse("--addons-ownership-mode=everyone")
Expect(err).NotTo(HaveOccurred())
_, err = addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, GinkgoT().TempDir(), true)
Expect(err).To(MatchError(ContainSubstring("--addons-ownership-mode")))
})
It("accepts local-owner without authentication", func() {
c, err := addonConfigFor(true, "local-owner", GinkgoT().TempDir(), false)
Expect(err).NotTo(HaveOccurred())
Expect(c.Enabled).To(BeTrue())
Expect(c.OwnershipMode).To(Equal("local-owner"))
})
It("keeps named-user off, without failing, when authentication is off", func() {
c, err := addonConfigFor(true, "named-user", GinkgoT().TempDir(), false)
Expect(err).NotTo(HaveOccurred())
Expect(c.Enabled).To(BeFalse())
})
})
+73
View File
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: MIT
package gateway
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"time"
)
// NewLoopbackCarrierConfig builds the carrier identity of a deployment with one
// instance, which is its own owner. The keys and the certificate are made fresh
// in memory, never written to disk and never leave the process, so the carrier
// is reachable only by this process on the loopback interface. It returns the
// config and the loopback address the owner listens on.
func NewLoopbackCarrierConfig(instanceID string) (*CarrierConfig, string, error) {
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, "", err
}
address := probe.Addr().String()
_ = probe.Close()
caPub, caKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
notBefore := time.Now().Add(-time.Hour)
notAfter := time.Now().AddDate(10, 0, 0)
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "local addon owner CA"}, NotBefore: notBefore, NotAfter: notAfter, IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, err := x509.CreateCertificate(rand.Reader, ca, ca, caPub, caKey)
if err != nil {
return nil, "", err
}
if ca, err = x509.ParseCertificate(der); err != nil {
return nil, "", err
}
pool := x509.NewCertPool()
pool.AddCert(ca)
leafPub, leafKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: instanceID}, NotBefore: notBefore, NotAfter: notAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, ca, leafPub, caKey)
if err != nil {
return nil, "", err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
if err != nil {
return nil, "", err
}
pair, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))
if err != nil {
return nil, "", err
}
signPub, signKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
return &CarrierConfig{
InstanceID: instanceID, OwnerID: instanceID, OwnerAddress: "https://" + address,
Certificate: pair, Roots: pool,
Pins: map[string][32]byte{instanceID: sha256.Sum256(leafDER)},
SigningKey: signKey,
VerificationKeys: map[string]ed25519.PublicKey{instanceID: signPub},
}, address, nil
}
+18
View File
@@ -64,6 +64,24 @@ store and forward the typed operation to the owner. The consequences are by desi
A deployment with one instance is the owner and has no frontend; everything above
still holds.
## Turning the owner on or off
A normal `local-ai run` starts the addon owner, so Apps, Add-ons and Tables work
without extra setup. The node is its own owner: it keeps addon state under the
`addons` folder of the data path (`--data-path`) and reaches the owner over a
loopback carrier with keys that are made at each start and never written to disk.
| Flag | Environment variable | Default | What it does |
|---|---|---|---|
| `--addons-enabled` | `LOCALAI_ADDONS_ENABLED` | `true` | Runs the addon owner. `--addons-enabled=false` turns it off completely: no owner starts, no addon runs, and every addon request is refused as unavailable. |
| `--addons-ownership-mode` | `LOCALAI_ADDONS_OWNERSHIP_MODE` | `named-user` | `named-user` gives each account its own access. `local-owner` makes every caller share one owner. Any other value stops startup with an error. |
`named-user` needs authentication. If authentication is off, the owner stays off and
LocalAI logs a warning; it does not stop. To use addons without accounts, select
`--addons-ownership-mode=local-owner` explicitly (see
[Explicit local-owner mode](#explicit-local-owner-mode)). The mode is never chosen
for you.
## Manifest
`ParseManifest` accepts a single JSON object, at most 64 KiB, and rejects unknown