mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
The chat's network view, the administrator view and the line the model reads about the network of its commands now take their name from the capabilities.network an add-on declares, or from the title of its commands tool when it declares none. The sentences no longer say "Sandbox" or "the sandbox". The API gains a network object with the title and the preset titles next to the existing addon field. The text of the built-in bash and of the host-exec setting names an add-on that provides bash instead of one add-on. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
871 lines
33 KiB
Go
871 lines
33 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package application
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
|
|
"github.com/mudler/LocalAI/core/services/addons"
|
|
"github.com/mudler/LocalAI/core/services/addons/artifacts"
|
|
"github.com/mudler/LocalAI/core/services/addons/backendlib"
|
|
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
|
"github.com/mudler/LocalAI/core/services/addons/hostops"
|
|
"github.com/mudler/LocalAI/core/services/addons/modelsprobe"
|
|
"github.com/mudler/LocalAI/core/services/addons/policy"
|
|
"github.com/mudler/LocalAI/core/services/addons/resources"
|
|
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
|
|
addontools "github.com/mudler/LocalAI/core/services/addons/tools"
|
|
"github.com/mudler/LocalAI/core/services/studio"
|
|
"github.com/mudler/LocalAI/core/services/studio/files"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
"github.com/mudler/xlog"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func (a *Application) AddonGateway() *gateway.Gateway { return a.addonGateway }
|
|
|
|
// AddonToolProvider binds the tools of installed addons to the principal of a
|
|
// chat request. It is nil when the host has no addon gateway: the chat
|
|
// handler then rejects an opt-in with a typed error. The provider reads the
|
|
// ownership mode from configuration, never from a request.
|
|
func (a *Application) AddonToolProvider() *mcpTools.AddonToolProvider {
|
|
if a.addonGateway == nil {
|
|
return nil
|
|
}
|
|
p := mcpTools.NewAddonToolProvider(addontools.NewToolAdapter(a.addonGateway), a.AddonOwnershipMode())
|
|
if db := a.AuthDB(); db != nil {
|
|
has := addonFeatureCheck(db)
|
|
hostOps := a.applicationConfig.AddonHostOps
|
|
p = p.WithHidden(func(actor contract.Actor) map[string]bool { return addonsWithoutFeature(hostOps, has, actor) })
|
|
}
|
|
return p
|
|
}
|
|
|
|
// AddonHostOps is the host operation channel of the add-on supervisor, or nil
|
|
// when this process offers none.
|
|
func (a *Application) AddonHostOps() *addonruntime.HostOps { return a.addonHostOps }
|
|
|
|
// newAddonHostOps builds the host operation broker for the supervisor of the
|
|
// owner. A frontend without the add-on manager, or a host whose setting names
|
|
// no add-on, builds nothing, so the channel fails closed. The setting is a
|
|
// host setting read at startup; no manifest or request can add to it.
|
|
func (a *Application) newAddonHostOps(m *addons.Manager, limits contract.Limits) *addonruntime.HostOps {
|
|
allowed := a.applicationConfig.AddonHostOps
|
|
if m == nil || len(allowed) == 0 {
|
|
return nil
|
|
}
|
|
in := hostops.NewInFlight()
|
|
broker := hostops.New(in, addonFeatureCheck(a.authDB), limits)
|
|
broker.SetAdminCheck(addonAdminCheck(a.authDB))
|
|
return &addonruntime.HostOps{Broker: broker, InFlight: in, Allowed: allowed}
|
|
}
|
|
|
|
// RegisterAddonEngineJobs registers the engine job operations with the services
|
|
// the routes use. The broker is built at startup and the services after it, so
|
|
// the HTTP setup calls this once they exist. A frontend without the broker has
|
|
// nothing to register, and the channel then fails closed.
|
|
func (a *Application) RegisterAddonEngineJobs(ft hostops.FineTuner, q hostops.Quantizer, backends hostops.BackendLister) error {
|
|
if a.addonHostOps == nil {
|
|
return nil
|
|
}
|
|
return hostops.RegisterEngineJobs(a.addonHostOps.Broker, ft, q, backends)
|
|
}
|
|
|
|
// RegisterAddonEngineProgress registers the progress and stop operations over
|
|
// the same services as RegisterAddonEngineJobs. The sources are wrapped in the
|
|
// bounded-wait reader here; a nil source registers nothing for its kind.
|
|
func (a *Application) RegisterAddonEngineProgress(ft, q hostops.ProgressSource) error {
|
|
if a.addonHostOps == nil {
|
|
return nil
|
|
}
|
|
var ftp, qp hostops.Progresser
|
|
if ft != nil {
|
|
ftp = hostops.NewProgresser(ft)
|
|
}
|
|
if q != nil {
|
|
qp = hostops.NewProgresser(q)
|
|
}
|
|
return hostops.RegisterEngineProgress(a.addonHostOps.Broker, ftp, qp)
|
|
}
|
|
|
|
// RegisterAddonEngineModels registers the model registration operations over
|
|
// the fine-tuning and quantization services. A nil source registers nothing
|
|
// for its kind.
|
|
func (a *Application) RegisterAddonEngineModels(ex hostops.Exporter, im hostops.Importer) error {
|
|
if a.addonHostOps == nil {
|
|
return nil
|
|
}
|
|
// The owner writes the exports, so it refuses them while its models path
|
|
// does not hold its probe.
|
|
ok := a.ownerProbeCurrent
|
|
if ex != nil {
|
|
ex = guardedExporter{Exporter: ex, ok: ok}
|
|
}
|
|
if im != nil {
|
|
im = guardedImporter{Importer: im, ok: ok}
|
|
}
|
|
return hostops.RegisterEngineModels(a.addonHostOps.Broker, ex, im)
|
|
}
|
|
|
|
// RegisterAddonEngineDatasets registers the inline dataset operation over the
|
|
// fine-tuning dataset store. A nil store registers nothing.
|
|
func (a *Application) RegisterAddonEngineDatasets(ds hostops.DatasetStore) error {
|
|
if a.addonHostOps == nil {
|
|
return nil
|
|
}
|
|
return hostops.RegisterEngineDatasets(a.addonHostOps.Broker, ds)
|
|
}
|
|
|
|
// RegisterAddonWorkspace registers the workspace operations over the files of
|
|
// the chats. It registers nothing when this process has no host operation
|
|
// channel, no add-on runs here, or the files are off.
|
|
func (a *Application) RegisterAddonWorkspace(f *files.Service) error {
|
|
if a.addonHostOps == nil || a.addonDataRoot == "" || !f.Available() {
|
|
return nil
|
|
}
|
|
root := a.addonDataRoot
|
|
return hostops.RegisterWorkspace(a.addonHostOps.Broker, &hostops.WorkspaceHost{
|
|
Store: f.Store(),
|
|
Principal: func(actor contract.Actor) string { return addonWorkspacePrincipal(a.authDB != nil)(actor) },
|
|
DataDir: func(id string) string { return filepath.Join(root, id) },
|
|
// The chats service is built after the broker: it is looked up at
|
|
// each call. A chat's call gets the chat's access, so a chat whose
|
|
// agent has a folder reaches only that folder through an add-on;
|
|
// without the chats service a call is refused.
|
|
Access: func(ctx context.Context, principal, chat string) (session.Access, error) {
|
|
if svc := a.StudioService(); svc != nil {
|
|
return svc.AddonAccess(ctx, principal, chat)
|
|
}
|
|
return session.Access{}, errors.New("the chats service is not running")
|
|
},
|
|
Chat: func(ctx context.Context, principal, rid string) string {
|
|
if svc := a.StudioService(); svc != nil {
|
|
return svc.ChatOfAction(ctx, principal, rid)
|
|
}
|
|
return ""
|
|
},
|
|
})
|
|
}
|
|
|
|
// addonWorkspacePrincipal maps an add-on actor to the owner of the files.
|
|
// Without accounts the chats and the Files page use the anonymous principal,
|
|
// so the synthetic local-owner actor must land there too, or the add-ons would
|
|
// work in a home the user never sees. With accounts the local-owner actor is
|
|
// no person: it has no files, and the workspace operations refuse it rather
|
|
// than share one account's home. An empty answer means refuse.
|
|
func addonWorkspacePrincipal(hasAccounts bool) func(contract.Actor) string {
|
|
return func(actor contract.Actor) string {
|
|
if actor.UserID == "" || actor.UserID == auth.AddonLocalOwnerID {
|
|
if hasAccounts {
|
|
return ""
|
|
}
|
|
return studio.AnonymousPrincipal
|
|
}
|
|
return actor.UserID
|
|
}
|
|
}
|
|
|
|
// addonFeatureCheck answers whether an active account holds a feature, with
|
|
// the same rule as the route middleware. Without an account database, or for
|
|
// an account that is not active, the answer is no.
|
|
func addonFeatureCheck(db *gorm.DB) func(userID, feature string) bool {
|
|
return func(userID, feature string) bool {
|
|
if db == nil || userID == "" {
|
|
return false
|
|
}
|
|
var u auth.User
|
|
if db.Where("id = ? AND status = ?", userID, auth.StatusActive).First(&u).Error != nil {
|
|
return false
|
|
}
|
|
return auth.HasFeatureAccess(db, &u, feature)
|
|
}
|
|
}
|
|
|
|
// addonAdminCheck answers whether an actor is an administrator. With an account
|
|
// database the role of the active account decides in every mode, with the rule
|
|
// of the admin routes (auth.RequireAdmin); a local-owner actor is not an
|
|
// administrator by mode alone. Without an account database there are no roles,
|
|
// so the local owner of the install is the administrator and no other actor is.
|
|
func addonAdminCheck(db *gorm.DB) func(actor contract.Actor) bool {
|
|
return func(actor contract.Actor) bool {
|
|
if db == nil {
|
|
return actor.Mode == "local-owner"
|
|
}
|
|
if actor.UserID == "" {
|
|
return false
|
|
}
|
|
var u auth.User
|
|
if db.Where("id = ? AND status = ?", actor.UserID, auth.StatusActive).First(&u).Error != nil {
|
|
return false
|
|
}
|
|
return u.Role == auth.RoleAdmin
|
|
}
|
|
}
|
|
|
|
// AddonPolicy is the owner-local resource policy. It is nil on frontends and on
|
|
// hosts without the addon transport, which then deny every resource.
|
|
func (a *Application) AddonPolicy() *policy.Policy { return a.addonPolicy }
|
|
|
|
// AddonOwnershipMode is the configured ownership mode. Named-user is the
|
|
// default; local-owner exists only when the deployment asked for it.
|
|
func (a *Application) AddonOwnershipMode() string {
|
|
if m := a.applicationConfig.Addons.OwnershipMode; m != "" {
|
|
return m
|
|
}
|
|
return auth.AddonModeNamedUser
|
|
}
|
|
|
|
// addonStatus is the manager view that creation authority consults.
|
|
type addonStatus interface {
|
|
Status(ctx context.Context, id string) (addons.Status, error)
|
|
}
|
|
|
|
// addonCreateAuthority lets an actor create a resource only for an addon that
|
|
// is installed and enabled on the owner, with no lifecycle change in flight.
|
|
// A manager that cannot answer is unavailable, never a grant.
|
|
func addonCreateAuthority(status addonStatus, revalidate func(context.Context, contract.Actor) error) func(context.Context, contract.Actor, string) error {
|
|
return func(ctx context.Context, actor contract.Actor, addonID string) error {
|
|
if status == nil || revalidate == nil {
|
|
return gateway.ErrForbidden
|
|
}
|
|
if err := revalidate(ctx, actor); err != nil {
|
|
return err
|
|
}
|
|
s, err := status.Status(ctx, addonID)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("addon status: %w: %w", gateway.ErrUnavailable, err)
|
|
}
|
|
if !s.Installed || !s.DesiredEnabled || !s.StartEligible {
|
|
return gateway.ErrForbidden
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// confirmCredential says which credentials may confirm a proposal: the
|
|
// browser session or the local owner, as before, and the credential of a chat
|
|
// turn. An API key and an MCP session never can.
|
|
func confirmCredential(mode string) func(contract.Actor) bool {
|
|
return func(a contract.Actor) bool {
|
|
if a.CredentialID == auth.AddonChatsCredential {
|
|
return mode == auth.AddonModeNamedUser && a.Mode == auth.AddonModeNamedUser
|
|
}
|
|
if mode == auth.AddonModeLocalOwner {
|
|
return a.CredentialID == auth.AddonLocalOwnerID
|
|
}
|
|
id, ok := strings.CutPrefix(a.CredentialID, "session:")
|
|
return ok && id != ""
|
|
}
|
|
}
|
|
|
|
func (a *Application) startAddonGateway() error {
|
|
cfg := a.applicationConfig.Addons
|
|
if cfg.Transport == nil {
|
|
return nil
|
|
}
|
|
mode := a.AddonOwnershipMode()
|
|
if mode == auth.AddonModeNamedUser && a.authDB == nil {
|
|
return gateway.ErrForbidden
|
|
}
|
|
c := *cfg.Transport
|
|
if c.InstanceID != cfg.InstanceID || c.OwnerID != cfg.OwnerID || c.OwnerAddress != cfg.OwnerAddress {
|
|
return gateway.ErrForbidden
|
|
}
|
|
carrier, e := gateway.NewCarrier(c)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if mode == auth.AddonModeLocalOwner {
|
|
xlog.Warn(localOwnerWarning)
|
|
}
|
|
g := &gateway.Gateway{Limits: c.Limits, Manager: a.addonManager, ConnectorAuthority: connectorAuthority{a}, Revalidate: auth.AddonRevalidate(a.authDB, mode)}
|
|
if cfg.InstanceID != cfg.OwnerID {
|
|
g.Forward = carrier
|
|
a.addonGateway = g
|
|
// The frontend installs backends on its own node and takes the
|
|
// resolved plan from the owner.
|
|
a.backendLibrary = &backendlib.Installer{
|
|
Remote: remoteBackends{a}, SystemState: a.applicationConfig.SystemState, ModelLoader: a.modelLoader,
|
|
MaxBytes: cfg.BackendMaxBytes, RequireIntegrity: a.applicationConfig.RequireBackendIntegrity,
|
|
}
|
|
go a.runLibraryRefresh(a.applicationConfig.Context)
|
|
go a.runModelsProbe(a.applicationConfig.Context)
|
|
return nil
|
|
}
|
|
g.HostSettings = a.addonHostSettings
|
|
g.HostSettingsAdmin = func(ctx context.Context, actor contract.Actor) error {
|
|
if mode == auth.AddonModeLocalOwner {
|
|
return auth.ValidateLocalOwnerActor(actor)
|
|
}
|
|
return auth.ValidateAddonActor(ctx, a.authDB, actor, true)
|
|
}
|
|
if e := a.wireExecutionBinding(g); e != nil {
|
|
return e
|
|
}
|
|
// Only the owner holds the policy store. Other frontends reach it through the
|
|
// gateway, and without it the gateway keeps denying every resource.
|
|
if !filepath.IsAbs(cfg.Root) || cfg.ListenAddress == "" {
|
|
return gateway.ErrForbidden
|
|
}
|
|
store, e := policy.OpenStore(filepath.Join(cfg.Root, "policy"), policy.Mode(mode))
|
|
if e != nil {
|
|
return e
|
|
}
|
|
// Proposals are durable beside the policy store and held only here. The
|
|
// broker is the policy's proposal invalidator, so a revocation cancels the
|
|
// pending proposals of that user before it is acknowledged.
|
|
brokerOpts := policy.BrokerOptions{}
|
|
if a.addonManager != nil {
|
|
brokerOpts.Runtime = a.addonManager
|
|
}
|
|
// The same existence check settles the registrations a crash left and the
|
|
// reservations of create proposals the proposer stops waiting for.
|
|
exists := a.addonResourceExists
|
|
if exists == nil {
|
|
exists = a.addonBackingExists(contract.DefaultLimits())
|
|
}
|
|
brokerOpts.BackingExists = exists
|
|
// Enabling an add-on for a chat or an agent is the consent to its changes,
|
|
// so the chats' own credential may confirm what the chats adapter has just
|
|
// stored. No request carries it: sessions and API keys build their own.
|
|
brokerOpts.ConfirmCredential = confirmCredential(mode)
|
|
// A deletion trusts only an explicit answer of the add-on.
|
|
confirmed := a.addonResourceExists
|
|
if confirmed == nil {
|
|
confirmed = a.addonBackingConfirmed(contract.DefaultLimits())
|
|
}
|
|
broker, e := policy.OpenBroker(filepath.Join(cfg.Root, "proposals"), brokerOpts)
|
|
if e != nil {
|
|
_ = store.Close()
|
|
return e
|
|
}
|
|
p := policy.New(store, policy.Options{
|
|
Revalidate: g.Revalidate,
|
|
CreateAuthority: addonCreateAuthority(a.addonManager, g.Revalidate),
|
|
Proposals: broker,
|
|
})
|
|
broker.Attach(p)
|
|
// Recovery gets the owner's access state. A restore or a purge that a
|
|
// crash stopped is finished now, before anything is served; an add-on
|
|
// that needs recovery stays unavailable until an administrator acts.
|
|
if a.addonManager != nil {
|
|
if rec := a.addonManager.DataRecovery(); rec != nil {
|
|
rec.SetPolicy(&policy.RecoveryPort{Policy: p, Broker: broker})
|
|
if e = rec.Reconcile(a.applicationConfig.Context); e != nil {
|
|
xlog.Warn("addons: a data recovery journal could not be finished yet", "error", e)
|
|
}
|
|
}
|
|
}
|
|
var files *artifacts.Artifacts
|
|
var selections *resources.SelectionStore
|
|
closeState := func() {
|
|
if files != nil {
|
|
_ = files.Close()
|
|
}
|
|
if selections != nil {
|
|
_ = selections.Close()
|
|
}
|
|
if a.addonReconcile != nil {
|
|
a.addonReconcile.Close()
|
|
}
|
|
_ = broker.Close()
|
|
_ = store.Close()
|
|
}
|
|
// Registrations left pending by a crash are settled before anything is
|
|
// served. A record whose backing cannot be confirmed now (its addon is
|
|
// disabled or failed to start) stays pending and hidden; it never stops
|
|
// the host from starting, and the loop below settles it later.
|
|
rep, e := p.ReconcileReport(a.applicationConfig.Context, exists)
|
|
if e != nil {
|
|
closeState()
|
|
return errors.Join(e, addons.ErrRecoveryRequired)
|
|
}
|
|
if rep.Kept > 0 {
|
|
xlog.Warn("addons: pending resource registrations could not be settled yet and stay pending", "settled", rep.Settled, "kept", rep.Kept)
|
|
}
|
|
// The conversation selections and the UI-only resource management sit
|
|
// beside the policy store and are held only here.
|
|
selections, e = resources.OpenSelections(filepath.Join(cfg.Root, "selections"), resources.SelectionOptions{})
|
|
if e != nil {
|
|
closeState()
|
|
return errors.Join(e, addons.ErrRecoveryRequired)
|
|
}
|
|
resourceOpts := resources.Options{Policy: p, Broker: broker, Users: auth.NewAddonUsers(a.authDB), Selections: selections, BackingConfirmed: confirmed, Limits: contract.DefaultLimits()}
|
|
if a.addonManager != nil {
|
|
resourceOpts.Runtime, resourceOpts.Capabilities, resourceOpts.Tools = a.addonManager, a.addonManager, a.addonManager
|
|
}
|
|
manage := resources.New(resourceOpts)
|
|
g.Resources = manage
|
|
// The owner serves the library calls of the frontends. A node with no
|
|
// source store (no catalog) has nothing to serve.
|
|
if a.addonSources != nil {
|
|
g.Libraries = a.newLibraryOwner()
|
|
g.OS, g.Arch = cfg.Host.OS, cfg.Host.Arch
|
|
}
|
|
g.ModelsProbe = func() (modelsprobe.Probe, bool) {
|
|
p := a.modelsProbe.own.Load()
|
|
if p == nil {
|
|
return modelsprobe.Probe{}, false
|
|
}
|
|
return *p, true
|
|
}
|
|
loop := policy.NewReconcileLoop(p, exists, a.addonReconcileTick)
|
|
loop.Resume = manage.Resume
|
|
loop.Report = func(r policy.ReconcileReport, err error) {
|
|
if err != nil || r.Kept > 0 {
|
|
xlog.Warn("addons: pending resource registrations remain", "settled", r.Settled, "kept", r.Kept, "error", err)
|
|
}
|
|
}
|
|
// A deletion a crash left is resumed before anything is served; one that
|
|
// cannot be finished now stays inaccessible and the loop retries it.
|
|
manage.Resume(a.applicationConfig.Context)
|
|
loop.Start(a.applicationConfig.Context)
|
|
a.addonReconcile = loop
|
|
// A proposal that was executing when the owner stopped is settled by
|
|
// receipt lookup, or recorded as outcome-unknown. Nothing is applied again.
|
|
if e = broker.Recover(a.applicationConfig.Context); e != nil {
|
|
closeState()
|
|
return errors.Join(e, addons.ErrRecoveryRequired)
|
|
}
|
|
// Unresolved outcomes are looked up again with backoff for a finite
|
|
// window, and expiry stays recorded. Run ends with the application
|
|
// context or when the broker is closed.
|
|
go broker.Run(a.applicationConfig.Context)
|
|
provider := p.Gateway()
|
|
g.Admission, g.Resolver = provider, provider
|
|
g.Proposals = broker.Gateway()
|
|
// Private file results are staged beside the other owner state. Without a
|
|
// manager there is no runtime to read them from, and the gateway then
|
|
// denies every file result and download.
|
|
if a.addonManager != nil {
|
|
files, e = artifacts.Open(artifacts.Options{
|
|
Dir: filepath.Join(cfg.Root, "artifacts"),
|
|
Policy: p,
|
|
Runtime: a.addonManager,
|
|
Addon: artifactAddonState(a.addonManager),
|
|
Limits: artifacts.DefaultLimits().WithExpiry(cfg.ArtifactExpiry),
|
|
})
|
|
if e != nil {
|
|
closeState()
|
|
return e
|
|
}
|
|
provider.Outputs = files
|
|
g.Artifacts = files.Gateway()
|
|
go files.Run(a.applicationConfig.Context)
|
|
}
|
|
handler, e := carrier.OwnerHandler(g)
|
|
if e != nil {
|
|
closeState()
|
|
return e
|
|
}
|
|
listener, e := net.Listen("tcp", cfg.ListenAddress)
|
|
if e != nil {
|
|
closeState()
|
|
return e
|
|
}
|
|
server := &http.Server{Handler: handler, TLSConfig: carrier.ServerTLS(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 65 * time.Second, IdleTimeout: 10 * time.Second, MaxHeaderBytes: 16 << 10}
|
|
a.addonServer = server
|
|
a.addonGateway = g
|
|
a.addonPolicy, a.addonStore = p, store
|
|
if pinStore, err := a.AddonPinStore(); err != nil {
|
|
xlog.Warn("addon pin sweep not started", "error", err)
|
|
} else {
|
|
a.startAddonPinSweep(a.applicationConfig.Context, pinStore)
|
|
}
|
|
go func() { _ = server.Serve(tls.NewListener(listener, server.TLSConfig)) }()
|
|
go func() { <-a.applicationConfig.Context.Done(); _ = server.Close(); closeState() }()
|
|
return nil
|
|
}
|
|
|
|
// addonBackingExists is the real existence check for resources whose backing
|
|
// lives in an addon (Tables databases). Without a manager there is no addon
|
|
// that could back a resource, so a pending record is dropped as before.
|
|
func (a *Application) addonBackingExists(limits contract.Limits) func(context.Context, policy.ResourceKey) (bool, error) {
|
|
m := a.addonManager
|
|
if m == nil {
|
|
return func(context.Context, policy.ResourceKey) (bool, error) { return false, nil }
|
|
}
|
|
return policy.BackingExists(policy.BackingOptions{
|
|
Runtime: m,
|
|
Tools: m,
|
|
Installed: func(ctx context.Context, id string) (bool, error) {
|
|
s, err := m.Status(ctx, id)
|
|
return s.Installed, err
|
|
},
|
|
Limits: limits,
|
|
})
|
|
}
|
|
|
|
// addonBackingConfirmed is the existence check of a deletion. Unlike
|
|
// addonBackingExists it never reads a missing manager, an add-on that is not
|
|
// installed or one that does not declare the existence tool as "gone".
|
|
func (a *Application) addonBackingConfirmed(limits contract.Limits) func(context.Context, policy.ResourceKey) (bool, error) {
|
|
m := a.addonManager
|
|
if m == nil {
|
|
return func(context.Context, policy.ResourceKey) (bool, error) { return false, policy.ErrBackingUnanswered }
|
|
}
|
|
return policy.BackingConfirmed(policy.BackingOptions{
|
|
Runtime: m,
|
|
Tools: m,
|
|
Installed: func(ctx context.Context, id string) (bool, error) {
|
|
s, err := m.Status(ctx, id)
|
|
return s.Installed, err
|
|
},
|
|
Limits: limits,
|
|
})
|
|
}
|
|
|
|
// artifactAddonState reports the lifecycle state the artifact store needs. A
|
|
// manager that cannot answer is an error, never an enabled addon.
|
|
func artifactAddonState(status addonStatus) func(context.Context, string) (artifacts.AddonState, error) {
|
|
return func(ctx context.Context, addonID string) (artifacts.AddonState, error) {
|
|
s, err := status.Status(ctx, addonID)
|
|
if err != nil {
|
|
return artifacts.AddonState{}, err
|
|
}
|
|
return artifacts.AddonState{Installed: s.Installed, Enabled: s.DesiredEnabled, Generation: s.Generation}, nil
|
|
}
|
|
}
|
|
|
|
// localOwnerWarning is logged at startup in local-owner mode. There is no
|
|
// session in that mode, so the confirmation boundary is the browser headers
|
|
// only and does not stop a script that sets them.
|
|
const localOwnerWarning = "addons: local-owner mode is enabled; unauthenticated callers share one authority over every addon resource and sharing is disabled; any caller that can reach this port can confirm addon proposals"
|
|
|
|
// StudioAddonTools is the add-on broker as the chats use it, or nil when this
|
|
// host has no add-on gateway. A chat is not a request: its turn runs after the
|
|
// request ended and, in a cluster, can run on another frontend. The actor is
|
|
// therefore built from the chat's user and the credential of a chat turn, and
|
|
// the gateway checks the account and the add-ons feature again on every call.
|
|
// The ownership mode comes from configuration, never from a request.
|
|
func (a *Application) StudioAddonTools() studio.AddonTools {
|
|
if a.addonGateway == nil {
|
|
return nil
|
|
}
|
|
var has func(userID, feature string) bool
|
|
if db := a.AuthDB(); db != nil {
|
|
has = addonFeatureCheck(db)
|
|
}
|
|
return &studioAddons{adapter: addontools.NewChatToolAdapter(a.addonGateway), mode: a.AddonOwnershipMode(), hostOps: a.applicationConfig.AddonHostOps, has: has}
|
|
}
|
|
|
|
// lateStudioAddons asks for the broker at each call: the chats service starts
|
|
// before the add-on gateway does.
|
|
type lateStudioAddons struct{ a *Application }
|
|
|
|
func (l lateStudioAddons) Tools(ctx context.Context, user string) ([]studio.AddonTool, error) {
|
|
b := l.a.StudioAddonTools()
|
|
if b == nil {
|
|
return nil, nil
|
|
}
|
|
return b.Tools(ctx, user)
|
|
}
|
|
|
|
// Unavailable forwards to the broker when it can say why an add-on cannot work.
|
|
func (l lateStudioAddons) Unavailable(ctx context.Context, user, addonID string, workspace bool) string {
|
|
if g, ok := l.a.StudioAddonTools().(studio.AddonGate); ok {
|
|
return g.Unavailable(ctx, user, addonID, workspace)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AddonSkills forwards to the broker when it knows the skills of the add-ons.
|
|
func (l lateStudioAddons) AddonSkills(ctx context.Context, user string) ([]studio.AddonSkillSet, error) {
|
|
if g, ok := l.a.StudioAddonTools().(studio.AddonSkillLister); ok {
|
|
return g.AddonSkills(ctx, user)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Isolation forwards to the broker when it can say how commands are isolated.
|
|
func (l lateStudioAddons) Isolation(ctx context.Context, user, addonID string) string {
|
|
if g, ok := l.a.StudioAddonTools().(studio.CommandIsolation); ok {
|
|
return g.Isolation(ctx, user, addonID)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Controls forwards to the broker when it knows the host controls of the
|
|
// add-ons.
|
|
func (l lateStudioAddons) Controls(ctx context.Context, user, addonID string) []string {
|
|
if g, ok := l.a.StudioAddonTools().(studio.AddonControls); ok {
|
|
return g.Controls(ctx, user, addonID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Network forwards to the broker when it knows the network the add-ons declare.
|
|
func (l lateStudioAddons) Network(ctx context.Context, user, addonID string) (studio.AddonNetwork, bool) {
|
|
if g, ok := l.a.StudioAddonTools().(studio.AddonNetworks); ok {
|
|
return g.Network(ctx, user, addonID)
|
|
}
|
|
return studio.AddonNetwork{}, false
|
|
}
|
|
|
|
func (l lateStudioAddons) Call(ctx context.Context, user, conv, name string, args json.RawMessage) (string, error) {
|
|
b := l.a.StudioAddonTools()
|
|
if b == nil {
|
|
return "", gateway.ErrUnavailable
|
|
}
|
|
return b.Call(ctx, user, conv, name, args)
|
|
}
|
|
|
|
type studioAddons struct {
|
|
adapter *addontools.ToolAdapter
|
|
mode string
|
|
// hostOps maps an add-on ID to the host operation prefixes it may call. An
|
|
// add-on that may call the workspace operations needs the Files workspace.
|
|
hostOps map[string][]string
|
|
// has answers whether a user holds a host feature. Nil offers every tool.
|
|
has func(userID, feature string) bool
|
|
}
|
|
|
|
// hostOpFeatures maps the prefix of a host operation to the feature the host
|
|
// requires for it (see hostops). An add-on that may call such an operation
|
|
// does nothing for a person without the feature, so its tools are not offered.
|
|
var hostOpFeatures = map[string]string{"finetune.": auth.FeatureFineTuning, "quantize.": auth.FeatureQuantization}
|
|
|
|
// addonsWithoutFeature names the add-ons whose host operations need a feature
|
|
// the actor does not hold. Local-owner mode has no accounts and hides none.
|
|
func addonsWithoutFeature(hostOps map[string][]string, has func(userID, feature string) bool, actor contract.Actor) map[string]bool {
|
|
if has == nil || actor.Mode == auth.AddonModeLocalOwner {
|
|
return nil
|
|
}
|
|
denied := map[string]bool{}
|
|
for addon, prefixes := range hostOps {
|
|
for _, p := range prefixes {
|
|
if f, ok := hostOpFeatures[p]; ok && !has(actor.UserID, f) {
|
|
denied[addon] = true
|
|
}
|
|
}
|
|
}
|
|
return denied
|
|
}
|
|
|
|
// filterByFeature drops the tools of the add-ons whose host operations need a
|
|
// feature the actor does not hold, so a model is not offered a tool that can
|
|
// only answer "no permission". Local-owner mode has no accounts and keeps all.
|
|
func filterByFeature(tools []studio.AddonTool, hostOps map[string][]string, has func(userID, feature string) bool, actor contract.Actor) []studio.AddonTool {
|
|
denied := addonsWithoutFeature(hostOps, has, actor)
|
|
if len(denied) == 0 {
|
|
return tools
|
|
}
|
|
out := make([]studio.AddonTool, 0, len(tools))
|
|
for _, t := range tools {
|
|
if !denied[t.AddonID] {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// needsWorkspace says whether the add-on works on the files of the user.
|
|
func (s *studioAddons) needsWorkspace(addonID string) bool {
|
|
return hasWorkspaceOperation(s.hostOps[addonID])
|
|
}
|
|
|
|
func hasWorkspaceOperation(operations []string) bool {
|
|
for _, p := range operations {
|
|
if strings.HasPrefix(p, "workspace.") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Unavailable consumes only the owner-selected request-local observation.
|
|
func (s *studioAddons) Unavailable(ctx context.Context, user, addonID string, workspace bool) string {
|
|
if !workspace && s.needsWorkspace(addonID) {
|
|
return "the Files workspace is off on this server, so there is no place for files or code"
|
|
}
|
|
value, explanation := studio.ExecutionObservation(ctx, user, addonID)
|
|
if value.Readiness == "unavailable" {
|
|
if explanation != "" {
|
|
return explanation
|
|
}
|
|
return "the selected execution provider is unavailable"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Isolation is unknown outside an admitted scope or after freshness is lost.
|
|
func (s *studioAddons) Isolation(ctx context.Context, user, addonID string) string {
|
|
value, _ := studio.ExecutionObservation(ctx, user, addonID)
|
|
if value.Readiness != "available" || value.Isolation == "unknown" {
|
|
return ""
|
|
}
|
|
return string(value.Isolation)
|
|
}
|
|
|
|
// Controls returns the host controls the apps list draws for the add-on, as
|
|
// the user reads the list, or nil.
|
|
func (s *studioAddons) Controls(ctx context.Context, user, addonID string) []string {
|
|
actor, err := s.actor(user)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
out, err := s.adapter.Controls(ctx, actor, addonID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Network returns the network the add-on declares while the apps list draws
|
|
// its network-policy control, as the user reads the list.
|
|
func (s *studioAddons) Network(ctx context.Context, user, addonID string) (studio.AddonNetwork, bool) {
|
|
actor, err := s.actor(user)
|
|
if err != nil {
|
|
return studio.AddonNetwork{}, false
|
|
}
|
|
v, ok, err := s.adapter.Network(ctx, actor, addonID)
|
|
if err != nil || !ok {
|
|
return studio.AddonNetwork{}, false
|
|
}
|
|
return studio.AddonNetwork{Title: v.Title, Presets: v.Presets}, true
|
|
}
|
|
|
|
func (s *studioAddons) actor(user string) (contract.Actor, error) {
|
|
switch s.mode {
|
|
case auth.AddonModeLocalOwner:
|
|
return contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}, nil
|
|
case auth.AddonModeNamedUser:
|
|
if user == "" || user == studio.AnonymousPrincipal || user == auth.AddonLocalOwnerID || user == "legacy-api-key" {
|
|
return contract.Actor{}, gateway.ErrForbidden
|
|
}
|
|
return contract.Actor{UserID: user, CredentialID: auth.AddonChatsCredential, Mode: auth.AddonModeNamedUser}, nil
|
|
}
|
|
return contract.Actor{}, gateway.ErrForbidden
|
|
}
|
|
|
|
func (s *studioAddons) Tools(ctx context.Context, user string) ([]studio.AddonTool, error) {
|
|
actor, err := s.actor(user)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
defs, err := s.adapter.Discover(ctx, actor)
|
|
if errors.Is(err, gateway.ErrForbidden) {
|
|
// A user without the add-ons feature has no add-on tools. The chat
|
|
// and its tools panel still work.
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]studio.AddonTool, 0, len(defs))
|
|
for _, d := range defs {
|
|
raw, err := json.Marshal(d.Parameters)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, studio.AddonTool{Name: d.Name, AddonID: d.AddonID, Description: d.Description, Schema: raw, Action: d.Kind != contract.ToolKindRead,
|
|
Shadows: d.Shadows, Execution: d.Execution, InstalledAt: d.InstalledAt})
|
|
}
|
|
return filterByFeature(out, s.hostOps, s.has, actor), nil
|
|
}
|
|
|
|
// AddonSkills lists the add-ons that ship skills and that the user can see,
|
|
// less those whose host operations need a feature the user lacks: the same
|
|
// rule as their tools.
|
|
func (s *studioAddons) AddonSkills(ctx context.Context, user string) ([]studio.AddonSkillSet, error) {
|
|
actor, err := s.actor(user)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
list, err := s.adapter.Skills(ctx, actor)
|
|
if errors.Is(err, gateway.ErrForbidden) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
denied := addonsWithoutFeature(s.hostOps, s.has, actor)
|
|
out := make([]studio.AddonSkillSet, 0, len(list))
|
|
for _, a := range list {
|
|
if denied[a.AddonID] {
|
|
continue
|
|
}
|
|
out = append(out, studio.AddonSkillSet{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: contract.SkillNames(a.Skills), Isolated: a.Isolated,
|
|
Shadows: contract.ShadowedSkills(a.Skills), Running: a.Running})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AddonShadowers lists the add-ons that replace a built-in chat tool and that
|
|
// the user can see, in any state, less those whose host operations need a
|
|
// feature the user lacks: the same rule as their tools.
|
|
func (s *studioAddons) AddonShadowers(ctx context.Context, user string) ([]studio.AddonShadower, error) {
|
|
actor, err := s.actor(user)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
list, err := s.adapter.Shadowers(ctx, actor)
|
|
if errors.Is(err, gateway.ErrForbidden) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
denied := addonsWithoutFeature(s.hostOps, s.has, actor)
|
|
out := make([]studio.AddonShadower, 0, len(list))
|
|
for _, a := range list {
|
|
if denied[a.AddonID] {
|
|
continue
|
|
}
|
|
out = append(out, studio.AddonShadower{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Tools: a.Tools, Running: a.Running})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *studioAddons) Call(ctx context.Context, user, conv, name string, args json.RawMessage) (string, error) {
|
|
actor, err := s.actor(user)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// The call is bound to the chat on the host side, so the add-on's
|
|
// workspace operations act with the chat's access (a chat whose agent has
|
|
// a folder reaches only that folder), for every tool kind.
|
|
res, err := s.adapter.ExecuteChat(ctx, actor, addontools.NewChatCall(user, conv), name, args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return studioAddonText(res), nil
|
|
}
|
|
|
|
// studioAddonText is what the model reads for a result. A change the chat made
|
|
// comes as an "applied" document whose note says what was done and whether the
|
|
// person can undo it (undoable, changeId and a one-line undo, or a reason). A change that stays a proposal (the owner could not apply
|
|
// it) is not applied, and the text says who can apply it.
|
|
func studioAddonText(res addontools.ToolResult) string {
|
|
if res.Kind == addontools.ResultProposal && res.Proposal != nil {
|
|
return fmt.Sprintf("The add-on %s proposed a change (%s, proposal %s). Nothing has been changed. "+
|
|
"Tell the user to review and confirm it on the Add-ons page. Do not say the change was made.",
|
|
res.Proposal.AddonID, res.Proposal.Action, res.Proposal.ID)
|
|
}
|
|
return res.JSON()
|
|
}
|
|
|
|
// Resolve at call time: the connector service can start after the private gateway.
|
|
type connectorAuthority struct{ a *Application }
|
|
|
|
func (c connectorAuthority) AuthorizeConnector(ctx context.Context, scope studio.ConnectorCallScope, req contract.ConnectorRequest) (contract.ConnectorRequest, error) {
|
|
if c.a.studioConnectors == nil {
|
|
return req, gateway.ErrUnavailable
|
|
}
|
|
return c.a.studioConnectors.AuthorizeConnector(ctx, scope, req)
|
|
}
|