Files
mudler-agentandEttore Di Giacinto 16acc8926b feat(addons): simplify Office and discover Tables (#13029)
* feat(tables): list accessible databases

Let agents discover existing databases through the host gateway.
Keep listing actor-scoped and separate from executable tools.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tables): verify database listing authority

Exercise model discovery through the real owner and carrier.
Check shared access, revocation, denial and resource limits.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(tables): bundle database search guidance

Teach agents to discover existing databases before querying records.
Package and verify the skill with the Tables add-on.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tables): cover packaged chat skill gates

Load the real receipt-verified Tables instructions through the temporary
Studio workspace. Check the index and load refusal after runtime, chat
switch and saved-agent grant changes.

This fixture starts at the receipt and broker boundary; packaging tests
cover artifact construction and integrity separately.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(office): parse Markdown and CSV inputs

Convert bounded text inputs into document and workbook structures.
Render supported Markdown formatting without fetching external content.
Preserve spreadsheet fields as strings rather than inferring formulas.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): preserve list markers, breaks and PDF page margins

Cover actual DOCX breaks and tabs, explicit zero list starts, empty list rejection, and PDF margin/pagination regressions.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): guard Markdown parsing work

Check cancellation and nesting during parser callbacks.
Validate table dimensions before padded table allocation.
Keep parser state isolated to each conversion.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): recognize tables before applying limits

Leave mismatched table-like paragraphs as ordinary Markdown.
Keep oversized real tables bounded before allocation.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(office): simplify document creation inputs

Accept Markdown for DOCX and PDF, and CSV for single-sheet XLSX files.
Replace nested creation schemas to reduce model tool grammar complexity.
Keep existing read, edit and PowerPoint behavior and internal office IDs.

Present the add-on as Simple Office and document the compatibility change.
Keep same-style Markdown fragments editable in generated DOCX files.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ci): align add-on fixtures and test inventories

Tables fixtures must carry the skill their manifest declares. Preserve
its bytes in package tests and mark only binaries executable in UI
fixtures. Assert that installation keeps the shipped skill intact.

Register the new Tables tests and the connector case from master in
exact suite inventories. Include the new database read in tool counts.

Run Office regressions through Ginkgo and check reader close errors.
Keep all document assertions and the existing CI gates intact.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: nib:gpt-6.1-sol

* fix(ci): restore Office and packaging checks

Keep the generated Office label consistent with Simple Office. Install
Poppler for the PDF layout regressions on Linux and macOS.

Separate successful runtime setup from the 300 ms deadline under test.
Keep the timeout, cancellation, stop and admission assertions intact.

Refresh the packaging inventory from all 209 executed cases, retaining
repeated platform checks and the Office and Tables skill checks.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-02 12:48:08 +01:00

490 lines
16 KiB
Go

// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
httpapi "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/http/routes"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTablesAdmin(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Tables resource management")
}
var binary string
var _ = BeforeSuite(func() {
binary = filepath.Join(GinkgoT().TempDir(), "tables")
cmd := exec.Command("go", "build", "-o", binary, "../../../addons/tables/cmd/tables")
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
out, e := cmd.CombinedOutput()
Expect(e).NotTo(HaveOccurred(), string(out))
})
const appSecret = "tables-admin-secret"
// transports builds the transport identities of one owner and one forwarding
// frontend: a CA, two pinned leaves and two signing keys.
func transports(port int) (owner, front *gateway.CarrierConfig) {
GinkgoHelper()
pub, key, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
Expect(e).NotTo(HaveOccurred())
ca, e = x509.ParseCertificate(der)
Expect(e).NotTo(HaveOccurred())
pool := x509.NewCertPool()
pool.AddCert(ca)
pins := map[string][32]byte{}
verify := map[string]ed25519.PublicKey{}
pairs := map[string]tls.Certificate{}
signing := map[string]ed25519.PrivateKey{}
for i, id := range []string{"owner", "front-a"} {
lp, lk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
leaf := &x509.Certificate{SerialNumber: big.NewInt(int64(i + 2)), Subject: pkix.Name{CommonName: id}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
d, e := x509.CreateCertificate(rand.Reader, leaf, ca, lp, key)
Expect(e).NotTo(HaveOccurred())
kd, e := x509.MarshalPKCS8PrivateKey(lk)
Expect(e).NotTo(HaveOccurred())
pair, e := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: d}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}))
Expect(e).NotTo(HaveOccurred())
sp, sk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
pins[id], verify[id], pairs[id], signing[id] = sha256.Sum256(d), sp, pair, sk
}
address := fmt.Sprintf("https://127.0.0.1:%d", port)
mk := func(id string) *gateway.CarrierConfig {
return &gateway.CarrierConfig{InstanceID: id, OwnerID: "owner", OwnerAddress: address, Certificate: pairs[id], Roots: pool, Pins: pins, SigningKey: signing[id], VerificationKeys: verify}
}
return mk("owner"), mk("front-a")
}
func freePort() int {
GinkgoHelper()
l, e := net.Listen("tcp", "127.0.0.1:0")
Expect(e).NotTo(HaveOccurred())
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port
}
// person is an account of the fixture.
type person struct {
id, role string
feature bool
status string
}
var people = []person{
{"alice", auth.RoleAdmin, false, auth.StatusActive},
{"bob", auth.RoleUser, true, auth.StatusActive},
{"carol", auth.RoleUser, true, auth.StatusActive},
{"dave", auth.RoleUser, true, auth.StatusActive},
{"erin", auth.RoleUser, false, auth.StatusActive},
{"frank", auth.RoleUser, true, auth.StatusPending},
}
// host is one owner in process: the real application bootstrap over real
// account storage, the real supervisor and the real Tables executable.
type host struct {
dir string
owner config.AddonConfig
front *gateway.CarrierConfig
app *application.Application
web *echo.Echo
cancel context.CancelFunc
local bool
tokens map[string]string
sessions map[string]string
keys map[string]string
}
func newHost(local bool) *host {
GinkgoHelper()
dir := GinkgoT().TempDir()
h := &host{dir: dir, local: local, tokens: map[string]string{}, sessions: map[string]string{}, keys: map[string]string{}}
DeferCleanup(func() {
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
})
port := freePort()
var owner *gateway.CarrierConfig
owner, h.front = transports(port)
h.owner = config.AddonConfig{
Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: owner.OwnerAddress, StorageID: "local",
Root: filepath.Join(dir, "addons"), ListenAddress: fmt.Sprintf("127.0.0.1:%d", port), Transport: owner,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH},
}
if local {
h.owner.OwnershipMode = auth.AddonModeLocalOwner
}
return h
}
func (h *host) mode() string {
if h.local {
return auth.AddonModeLocalOwner
}
return auth.AddonModeNamedUser
}
func (h *host) start() {
GinkgoHelper()
models, backends := filepath.Join(h.dir, "models"), filepath.Join(h.dir, "backends")
Expect(os.MkdirAll(models, 0o750)).To(Succeed())
Expect(os.MkdirAll(backends, 0o750)).To(Succeed())
state, e := system.GetSystemState(system.WithBackendPath(backends), system.WithModelPath(models))
Expect(e).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
opts := []config.AppOption{config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(h.dir), config.WithAddons(h.owner)}
if !h.local {
opts = append(opts, config.WithAuthEnabled(true), config.WithAuthDatabaseURL(filepath.Join(h.dir, "auth.db")), config.WithAuthAPIKeyHMACSecret(appSecret))
}
app, e := application.New(opts...)
Expect(e).NotTo(HaveOccurred())
h.app = app
h.web, e = httpapi.API(app)
Expect(e).NotTo(HaveOccurred())
}
// stop ends the application as a restart does and waits until the owner
// released its durable state.
func (h *host) stop() {
GinkgoHelper()
if h.cancel == nil {
return
}
Expect(h.app.Shutdown()).To(Succeed())
h.cancel()
h.cancel = nil
mode := policy.ModeNamedUser
if h.local {
mode = policy.ModeLocalOwner
}
Eventually(func() error {
s, e := policy.OpenStore(filepath.Join(h.owner.Root, "policy"), mode)
if e == nil {
_ = s.Close()
}
return e
}, 10*time.Second, 50*time.Millisecond).Should(Succeed())
}
func (h *host) actor(user string) contract.Actor {
if h.local {
return contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}
}
return contract.Actor{UserID: user, CredentialID: "key:" + h.keys[user], Mode: auth.AddonModeNamedUser}
}
// seed creates the accounts, installs the real Tables package from a local
// catalog and waits until it runs.
func (h *host) seed(lib *cluster.Library) {
GinkgoHelper()
ctx := context.Background()
if !h.local {
db := h.app.AuthDB()
Expect(db).NotTo(BeNil())
for _, u := range people {
Expect(db.Create(&auth.User{ID: u.id, Email: u.id + "@tables.test", Name: "Name of " + u.id, Provider: auth.ProviderLocal, Role: u.role, Status: u.status}).Error).To(Succeed())
if u.feature {
Expect(db.Create(&auth.UserPermission{ID: uuid.NewString(), UserID: u.id, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}).Error).To(Succeed())
}
token, key, e := auth.CreateAPIKey(db, u.id, "agent", u.role, appSecret, nil)
Expect(e).NotTo(HaveOccurred())
session, e := auth.CreateSession(db, u.id, appSecret)
Expect(e).NotTo(HaveOccurred())
h.tokens[u.id], h.sessions[u.id], h.keys[u.id] = token, session, key.ID
}
}
admin := h.actor("alice")
Expect(h.app.AddonSources().Save(ctx, admin, catalog.Source{ID: "local", Name: "Local", URL: lib.URL(), Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
_, e := h.app.AddonSources().Refresh(ctx, "local")
Expect(e).NotTo(HaveOccurred())
m := h.app.AddonManager()
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "install-tables", AddonID: "tables", Action: "install", SourceID: "local", Version: "0.1.0"})
Expect(e).NotTo(HaveOccurred())
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "enable-tables", AddonID: "tables", Action: "enable", Generation: 1})
Expect(e).NotTo(HaveOccurred())
h.running()
}
func (h *host) running() {
GinkgoHelper()
Eventually(func() string {
list, e := h.app.AddonManager().Apps(context.Background(), h.actor("alice"))
if e != nil {
return e.Error()
}
for _, a := range list {
if a.ID == "tables" {
return a.State
}
}
return "absent"
}, 30*time.Second, 100*time.Millisecond).Should(Equal("running"))
}
func (h *host) library() *cluster.Library {
GinkgoHelper()
lib, e := cluster.NewLibrary(binary)
Expect(e).NotTo(HaveOccurred())
DeferCleanup(lib.Close)
raw, e := os.ReadFile("../../../addons/tables/manifest.json")
Expect(e).NotTo(HaveOccurred())
var m struct {
Capabilities map[string]any `json:"capabilities"`
}
Expect(json.Unmarshal(raw, &m)).To(Succeed())
// These specs run the tools, not the page: the package carries no UI
// files, so the declaration of them is left out. The UI suite installs
// the whole package.
delete(m.Capabilities, "ui")
exe, e := os.ReadFile(binary)
Expect(e).NotTo(HaveOccurred())
skill, e := os.ReadFile("../../../addons/tables/skills/tables-readonly/SKILL.md")
Expect(e).NotTo(HaveOccurred())
Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: exe,
Files: map[string]string{"skills/tables-readonly/SKILL.md": string(skill)},
}, GinkgoT().TempDir())).To(Succeed())
return lib
}
// ready is the usual start of a spec: a running host with the accounts and
// the package.
func ready(local bool) *host {
GinkgoHelper()
h := newHost(local)
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
return h
}
// reply is one HTTP answer.
type reply struct {
code int
body map[string]any
raw string
}
func (r reply) errType() string {
e, _ := r.body["error"].(map[string]any)
s, _ := e["type"].(string)
return s
}
func (r reply) errOperation() string {
e, _ := r.body["error"].(map[string]any)
s, _ := e["operationId"].(string)
return s
}
func serve(e *echo.Echo, method, path, body string, headers ...func(*http.Request)) reply {
GinkgoHelper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
for _, h := range headers {
h(r)
}
w := httptest.NewRecorder()
e.ServeHTTP(w, r)
out := reply{code: w.Code, body: map[string]any{}, raw: w.Body.String()}
_ = json.Unmarshal(w.Body.Bytes(), &out.body)
return out
}
func (h *host) send(method, path, body string, headers ...func(*http.Request)) reply {
GinkgoHelper()
return serve(h.web, method, path, body, headers...)
}
func (h *host) bearer(user string) func(*http.Request) {
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+h.tokens[user]) }
}
// tab is what the host's own pages send: the user's browser session, a
// same-origin fetch and the identifier the action concerns in the header. In
// local-owner mode there is no session.
func (h *host) tab(user, id string) func(*http.Request) {
return func(r *http.Request) {
if !h.local {
r.AddCookie(&http.Cookie{Name: "session", Value: h.sessions[user]})
}
r.Header.Set("Sec-Fetch-Site", "same-origin")
r.Header.Set("Sec-Fetch-Mode", "cors")
r.Header.Set("Sec-Fetch-Dest", "empty")
r.Header.Set("X-LocalAI-Confirm", id)
}
}
// session is a plain same-origin session request with no confirmation header.
func (h *host) session(user string) func(*http.Request) {
return func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "session", Value: h.sessions[user]})
r.Header.Set("Sec-Fetch-Site", "same-origin")
r.Header.Set("Sec-Fetch-Mode", "cors")
r.Header.Set("Sec-Fetch-Dest", "empty")
}
}
// frontend is a second LocalAI instance that holds no addon state and
// forwards every resource call to the owner over the real carrier.
func (h *host) frontend() *echo.Echo {
GinkgoHelper()
carrier, e := gateway.NewCarrier(*h.front)
Expect(e).NotTo(HaveOccurred())
var db = h.app.AuthDB()
g := &gateway.Gateway{Forward: carrier, Revalidate: auth.AddonRevalidate(db, h.mode())}
web := echo.New()
web.Use(auth.Middleware(db, h.app.ApplicationConfig()))
routes.RegisterAddonRoutesFor(web, g, db, h.mode())
return web
}
// Typed calls of the management routes, as the person's own browser tab.
func (h *host) create(user, op, name string) reply {
GinkgoHelper()
body := fmt.Sprintf(`{"addonId":"tables","operationId":%q,"generation":1,"arguments":{"name":%q}}`, op, name)
return h.send("POST", "/api/addons/resources", body, h.tab(user, op))
}
func (h *host) list(user string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources?addonId=tables", "", h.bearer(user))
}
// resources returns the listed resources by ID.
func resourcesOf(r reply) map[string]map[string]any {
GinkgoHelper()
out := map[string]map[string]any{}
list, _ := r.body["resources"].([]any)
for _, x := range list {
m := x.(map[string]any)
out[m["id"].(string)] = m
}
return out
}
func (h *host) grant(user, res, target, role string) reply {
GinkgoHelper()
return h.send("PUT", "/api/addons/resources/"+res+"/grants/"+target, fmt.Sprintf(`{"role":%q}`, role), h.tab(user, res))
}
func (h *host) revoke(user, res, target string) reply {
GinkgoHelper()
return h.send("DELETE", "/api/addons/resources/"+res+"/grants/"+target, "", h.tab(user, res))
}
func (h *host) grants(user, res string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources/"+res+"/grants", "", h.tab(user, res))
}
func (h *host) users(user, res, q string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources/"+res+"/users?q="+q, "", h.tab(user, res))
}
func (h *host) rename(user, res, op, name string, rev map[string]any) reply {
GinkgoHelper()
b, _ := json.Marshal(map[string]any{"operationId": op, "generation": 1, "revision": rev, "name": name})
return h.send("POST", "/api/addons/resources/"+res+"/rename", string(b), h.tab(user, res))
}
func (h *host) remove(user, res, op, confirm string, rev map[string]any) reply {
GinkgoHelper()
b, _ := json.Marshal(map[string]any{"operationId": op, "generation": 1, "revision": rev, "confirm": confirm})
return h.send("POST", "/api/addons/resources/"+res+"/delete", string(b), h.tab(user, res))
}
// revision reads the revision the owner role is shown for a resource.
func (h *host) revision(user, res string) map[string]any {
GinkgoHelper()
got := resourcesOf(h.list(user))[res]
Expect(got).NotTo(BeNil())
rev, _ := got["revision"].(map[string]any)
Expect(rev).NotTo(BeNil())
return rev
}
// name reads the display name of a resource through the owner's gateway, as
// the person who may read it.
func (h *host) name(user, res string) (string, error) {
out, e := h.app.AddonGateway().Dispatch(context.Background(), h.actor(user), contract.Request{
AddonID: "tables", ResourceID: res, OperationID: "inspect-" + uuid.NewString()[:8], Generation: 1, Action: "read",
Arguments: json.RawMessage(`{"tool":"databases-inspect"}`),
})
if e != nil {
return "", e
}
var v struct {
Name string `json:"name"`
}
Expect(json.Unmarshal(out.Payload, &v)).To(Succeed())
return v.Name, nil
}
// database creates a database for the user and returns its ID.
func (h *host) database(user, op, name string) string {
GinkgoHelper()
got := h.create(user, op, name)
Expect(got.code).To(Equal(200), got.raw)
id, _ := got.body["id"].(string)
Expect(id).NotTo(BeEmpty())
return id
}
func (h *host) key(res string) policy.ResourceKey {
return policy.ResourceKey{AddonID: "tables", ResourceID: res}
}
func (h *host) dbFile(res string) string {
return filepath.Join(h.owner.Root, "data", "tables", "tables", "databases", res+".db")
}