From 16acc8926bb8a27b67dc4d419dbceaeb4110f7af Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Mon, 2 Nov 2026 12:48:08 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 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 --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- .agents/building-and-testing.md | 7 + .github/workflows/test.yml | 4 +- README.md | 2 +- addons/office/internal/docx/docx.go | 67 +++- addons/office/internal/docx/docx_test.go | 2 +- addons/office/internal/docx/package.go | 94 ++++- addons/office/internal/pdf/create.go | 304 +++++++++++--- addons/office/internal/pdf/pdf_test.go | 2 +- addons/office/internal/textinput/csv.go | 99 +++++ .../internal/textinput/docx_edit_test.go | 59 +++ addons/office/internal/textinput/markdown.go | 375 ++++++++++++++++++ .../office/internal/textinput/parse_guard.go | 374 +++++++++++++++++ .../internal/textinput/parse_guard_test.go | 276 +++++++++++++ .../textinput/pdf_layout_review_test.go | 171 ++++++++ .../textinput/review_regression_test.go | 129 ++++++ .../textinput/textinput_suite_test.go | 10 + .../internal/textinput/textinput_test.go | 271 +++++++++++++ addons/office/internal/tools/create.go | 192 ++++----- addons/office/internal/tools/definitions.go | 41 +- addons/office/internal/tools/manifest_test.go | 37 ++ .../internal/tools/text_creation_test.go | 140 +++++++ addons/office/internal/tools/tools_test.go | 25 +- addons/office/manifest.json | 164 ++------ addons/office/skills/docx/SKILL.md | 18 +- addons/office/skills/pdf/SKILL.md | 16 +- addons/office/skills/pptx/SKILL.md | 6 +- addons/office/skills/xlsx/SKILL.md | 25 +- addons/office/ui/src/index.jsx | 10 +- addons/tables/manifest.json | 25 +- addons/tables/package/platforms.yaml | 1 + addons/tables/skills/tables-readonly/SKILL.md | 51 +++ core/services/addons/gateway/resources.go | 2 + core/services/addons/runtime/client_test.go | 9 + core/services/addons/tools/adapter.go | 20 +- .../services/addons/tools/database_listing.go | 85 ++++ .../addons/tools/database_listing_test.go | 247 ++++++++++++ core/services/addons/tools/office_creation.go | 44 ++ .../addons/tools/office_creation_test.go | 44 ++ .../studio/tables_skill_internal_test.go | 126 ++++++ docs/content/features/addons.md | 10 +- docs/content/features/simple-office.md | 70 ++++ docs/content/features/tables.md | 67 +++- gallery/addons/index.yaml | 32 +- gallery/addons/office.yaml.tmpl | 2 +- pkg/addons/packaging/packaging_test.go | 7 +- .../packaging/tables_readonly_skill_test.go | 89 +++++ scripts/addons/recovery.sh | 1 + scripts/addons/suites.json | 110 ++++- scripts/addons/tables_csv.sh | 1 + scripts/addons/tables_ui.sh | 4 +- scripts/addons/updates.sh | 1 + tests/addons/actione2e/actions_e2e_test.go | 15 +- tests/addons/packaging/packaging_test.go | 7 +- tests/addons/recovery/host_test.go | 6 +- tests/addons/shipped/shipped_test.go | 6 +- tests/addons/tables/application_test.go | 4 +- tests/addons/tables/tools_test.go | 6 +- tests/addons/tablesadmin/host_test.go | 6 +- .../addons/tablesadmin/model_listing_test.go | 232 +++++++++++ tests/addons/tablesexport/export_test.go | 4 +- tests/e2e-ui/addons_fixture.go | 6 +- 61 files changed, 3763 insertions(+), 497 deletions(-) create mode 100644 addons/office/internal/textinput/csv.go create mode 100644 addons/office/internal/textinput/docx_edit_test.go create mode 100644 addons/office/internal/textinput/markdown.go create mode 100644 addons/office/internal/textinput/parse_guard.go create mode 100644 addons/office/internal/textinput/parse_guard_test.go create mode 100644 addons/office/internal/textinput/pdf_layout_review_test.go create mode 100644 addons/office/internal/textinput/review_regression_test.go create mode 100644 addons/office/internal/textinput/textinput_suite_test.go create mode 100644 addons/office/internal/textinput/textinput_test.go create mode 100644 addons/office/internal/tools/text_creation_test.go create mode 100644 addons/tables/skills/tables-readonly/SKILL.md create mode 100644 core/services/addons/tools/database_listing.go create mode 100644 core/services/addons/tools/database_listing_test.go create mode 100644 core/services/addons/tools/office_creation.go create mode 100644 core/services/addons/tools/office_creation_test.go create mode 100644 core/services/studio/tables_skill_internal_test.go create mode 100644 docs/content/features/simple-office.md create mode 100644 pkg/addons/packaging/tables_readonly_skill_test.go create mode 100644 tests/addons/tablesadmin/model_listing_test.go diff --git a/.agents/building-and-testing.md b/.agents/building-and-testing.md index f3915fe33..adaca297d 100644 --- a/.agents/building-and-testing.md +++ b/.agents/building-and-testing.md @@ -55,3 +55,10 @@ Rules (both gates): - **Shipped bundle:** specs tagged `@production-chunks` (stale chunk recovery, `e2e/production-bundle.spec.js`) run on the production code-split build through `make test-ui-stale-chunk`; the coverage build inlines dynamic imports and skips them. - **Distributed:** `tests-e2e-distributed.yml` runs `make test-e2e-distributed` (PostgreSQL and NATS in containers) and `make test-distributed-fakebus` (the in-process bus, race detector) nightly and on changes to the distributed code. - **Auth modes:** `core/http/route_authmodes_test.go` runs every `/api/chats` and `/api/addons` route as the local owner, with a shared API key, and with accounts (session, per-user API key, revoked feature, non-administrator). + +### Office PDF regression tests + +The Office text-input tests inspect actual PDF glyphs with Poppler’s +`pdftotext`. Install `poppler-utils` on Ubuntu/Debian or `poppler` with +Homebrew before running `go test ./addons/office/...` or the main Go suites. +Missing PDF inspection tools are test failures, not skipped checks. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28064c8e9..8cfe6cfa3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,7 +72,7 @@ jobs: - name: Dependencies run: | sudo apt-get update - sudo apt-get install curl ffmpeg libopus-dev + sudo apt-get install curl ffmpeg libopus-dev poppler-utils - name: Setup Node.js uses: actions/setup-node@v7 with: @@ -143,7 +143,7 @@ jobs: run: go version - name: Dependencies run: | - brew install protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm opus ffmpeg + brew install protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm opus ffmpeg poppler pip install --user --no-cache-dir grpcio-tools grpcio - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/README.md b/README.md index 329f3c129..d0a6a2c56 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ - **Any hardware**: NVIDIA, AMD, Intel, Apple Silicon, Vulkan, or CPU-only - **Multi-user ready**: API key auth, user quotas, role-based access - **Chats with saved agents**: conversations the server keeps, with agents that use tools, MCP, skills and files, triggers, and a Telegram connector -- **Add-ons**: Studio, Talk, Voice, Face, Office, Sandbox and more install from a signed catalog, and run beside the server +- **Add-ons**: Studio, Talk, Voice, Face, Simple Office, Sandbox and more install from a signed catalog, and run beside the server - **Privacy-first**: your data never leaves your infrastructure ![A small LocalAI core with backends (llama.cpp, vLLM, MLX, whisper.cpp, stable-diffusion, kokoro, parakeet.cpp...) plugged in as separate on-demand images](docs/static/images/diagrams/composable-core.png) diff --git a/addons/office/internal/docx/docx.go b/addons/office/internal/docx/docx.go index 9a52a0eca..ddffc3ff9 100644 --- a/addons/office/internal/docx/docx.go +++ b/addons/office/internal/docx/docx.go @@ -25,7 +25,7 @@ const ( // Heading levels the writer supports. const ( minHeadingLevel = 1 - maxHeadingLevel = 3 + maxHeadingLevel = 6 ) // Block is one element of the document body. The block types of this package @@ -34,10 +34,11 @@ type Block interface { validate() error } -// Heading is a section title. Level is 1 to 3. +// Heading is a section title. Level is 1 to 6. type Heading struct { Level int Text string + Runs []Run } // Run is a piece of paragraph text with its own emphasis. @@ -59,6 +60,14 @@ type Paragraph struct { // empty. type Bullets struct { Items []string + // RichItems is an alternative to Items. Ordered defaults to a bullet list; + // Start is the first number (zero defaults to 1). Indent is the nesting level. + RichItems [][]Run + Ordered bool + Start int + // StartSet distinguishes an explicit zero from the legacy default of one. + StartSet bool + Indent int } // Table is a table with a header row and body rows. Every row has as many @@ -67,9 +76,11 @@ type Bullets struct { // width gives that column an automatic share. An empty paragraph follows each // table, because Word needs a paragraph after a table at the end of the body. type Table struct { - Header []string - Rows [][]string - Widths []int + Header []string + Rows [][]string + Widths []int + HeaderRuns [][]Run + RowRuns [][][]Run } // Style is the house look. Colours are six digit hex RGB values such as @@ -102,7 +113,10 @@ func (h Heading) validate() error { if h.Level < minHeadingLevel || h.Level > maxHeadingLevel { return fmt.Errorf("heading level %d is not between %d and %d", h.Level, minHeadingLevel, maxHeadingLevel) } - if strings.TrimSpace(h.Text) == "" { + if h.Text != "" && len(h.Runs) > 0 { + return errors.New("heading sets both Text and Runs") + } + if strings.TrimSpace(plain(h.Text, h.Runs)) == "" { return errors.New("heading text is empty") } return nil @@ -116,10 +130,16 @@ func (p Paragraph) validate() error { } func (b Bullets) validate() error { - if len(b.Items) == 0 { + if len(b.Items) > 0 && len(b.RichItems) > 0 { + return errors.New("list sets both Items and RichItems") + } + if b.Start < 0 || b.Indent < 0 || b.Indent > 64 { + return errors.New("invalid list position") + } + if len(b.Items) == 0 && len(b.RichItems) == 0 { return errors.New("bullet list has no item") } - for i, it := range b.Items { + for i, it := range b.texts() { if strings.TrimSpace(it) == "" { return fmt.Errorf("bullet item %d is empty", i+1) } @@ -127,7 +147,38 @@ func (b Bullets) validate() error { return nil } +func plain(text string, runs []Run) string { + for _, r := range runs { + text += r.Text + } + return text +} +func (b Bullets) texts() []string { + if len(b.RichItems) == 0 { + return b.Items + } + out := make([]string, len(b.RichItems)) + for i, r := range b.RichItems { + out[i] = plain("", r) + } + return out +} + func (t Table) validate() error { + if t.HeaderRuns != nil && len(t.HeaderRuns) != len(t.Header) { + return errors.New("table header runs shape mismatch") + } + if t.RowRuns != nil { + if len(t.RowRuns) != len(t.Rows) { + return errors.New("table row runs shape mismatch") + } + for i, r := range t.RowRuns { + if len(r) != len(t.Rows[i]) { + return errors.New("table cell runs shape mismatch") + } + } + } + if len(t.Header) == 0 { return errors.New("table has no header") } diff --git a/addons/office/internal/docx/docx_test.go b/addons/office/internal/docx/docx_test.go index 1f62b949a..29523bfcd 100644 --- a/addons/office/internal/docx/docx_test.go +++ b/addons/office/internal/docx/docx_test.go @@ -375,7 +375,7 @@ var _ = Describe("docx writer", func() { Entry("empty heading", Document{Blocks: []Block{Heading{Level: 1, Text: ""}}}), Entry("blank heading", Document{Blocks: []Block{Heading{Level: 1, Text: " "}}}), Entry("level 0", Document{Blocks: []Block{Heading{Level: 0, Text: "x"}}}), - Entry("level 4", Document{Blocks: []Block{Heading{Level: 4, Text: "x"}}}), + Entry("level 7", Document{Blocks: []Block{Heading{Level: 7, Text: "x"}}}), Entry("bad heading colour", Document{Style: Style{HeadingColor: "12345"}, Blocks: nil}), Entry("colour with hash", Document{Style: Style{HeadingColor: "#112233"}}), Entry("non-hex colour", Document{Style: Style{HeadingColor: "GG0000"}}), diff --git a/addons/office/internal/docx/package.go b/addons/office/internal/docx/package.go index 9e09f90ec..cdd07f8d2 100644 --- a/addons/office/internal/docx/package.go +++ b/addons/office/internal/docx/package.go @@ -89,7 +89,7 @@ func (b *builder) parts() []part { add("word/_rels/document.xml.rels", relsXML(rels), "") add("word/styles.xml", b.stylesXML(), ctStyles) if b.hasBullets { - add("word/numbering.xml", numberingXML(), ctNumbering) + add("word/numbering.xml", b.numberingXML(), ctNumbering) } ct.WriteString(``) @@ -116,18 +116,30 @@ func appXML(application string) []byte { `xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">` + app + ``) } -func numberingXML() []byte { +func (b *builder) numberingXML() []byte { + var ordered strings.Builder + id := 2 + for _, blk := range b.doc.Blocks { + if v, ok := blk.(Bullets); ok && v.Ordered { + start := v.Start + if start == 0 && !v.StartSet { + start = 1 + } + fmt.Fprintf(&ordered, ``, id, start, id, id) + id++ + } + } return []byte(xmlHeader + `` + `` + `` + `` + `` + fmt.Sprintf(``, bulletNumID) + - ``) + ordered.String() + ``) } // Heading sizes in half-points. -var headingSize = map[int]int{1: 32, 2: 28, 3: 26} +var headingSize = map[int]int{1: 32, 2: 28, 3: 26, 4: 24, 5: 22, 6: 22} func (b *builder) stylesXML() []byte { st := b.style @@ -166,10 +178,11 @@ func (b *builder) documentXML() []byte { var x strings.Builder x.WriteString(xmlHeader) x.WriteString(``) + orderedID := 2 for _, blk := range b.doc.Blocks { switch v := blk.(type) { case Heading: - fmt.Fprintf(&x, `%s`, v.Level, runXML(Run{Text: v.Text})) + fmt.Fprintf(&x, `%s`, v.Level, runsXML(v.Text, v.Runs, false)) case Paragraph: x.WriteString(``) if v.Text != "" { @@ -180,9 +193,20 @@ func (b *builder) documentXML() []byte { } x.WriteString(``) case Bullets: - for _, it := range v.Items { - fmt.Fprintf(&x, `%s`, - bulletNumID, runXML(Run{Text: it})) + id := bulletNumID + style := `` + if v.Ordered { + style = "" + id = orderedID + orderedID++ + } + for i, it := range v.texts() { + var runs []Run + if len(v.RichItems) > 0 { + runs = v.RichItems[i] + it = "" + } + fmt.Fprintf(&x, `%s%s`, style, id, 720*(v.Indent+1), runsXML(it, runs, false)) } case Table: b.tableXML(&x, v) @@ -206,7 +230,28 @@ func runXML(r Run) string { if rpr != "" { rpr = `` + rpr + `` } - return `` + rpr + `` + ooxml.Escape(r.Text) + `` + // WordprocessingML represents layout controls as elements, not character + // references inside w:t. Keep them in the same run to retain emphasis. + var content strings.Builder + start := 0 + for i, ch := range r.Text { + if ch != '\n' && ch != '\t' { + continue + } + if i > start { + content.WriteString(`` + ooxml.Escape(r.Text[start:i]) + ``) + } + if ch == '\n' { + content.WriteString(``) + } else { + content.WriteString(``) + } + start = i + 1 + } + if start < len(r.Text) || len(r.Text) == 0 { + content.WriteString(`` + ooxml.Escape(r.Text[start:]) + ``) + } + return `` + rpr + content.String() + `` } // columnWidths returns the grid widths: the given widths, or an equal split of @@ -232,7 +277,7 @@ func (b *builder) tableXML(x *strings.Builder, t Table) { fmt.Fprintf(x, ``, w) } x.WriteString(``) - row := func(cells []string, header bool) { + row := func(cells []string, rich [][]Run, header bool) { x.WriteString(``) if header { x.WriteString(``) @@ -242,13 +287,34 @@ func (b *builder) tableXML(x *strings.Builder, t Table) { if header && b.style.TableHeaderFill != "" { fmt.Fprintf(x, ``, strings.ToUpper(b.style.TableHeaderFill)) } - x.WriteString(`` + runXML(Run{Text: c, Bold: header}) + ``) + var runs []Run + if rich != nil { + runs = rich[i] + c = "" + } + x.WriteString(`` + runsXML(c, runs, header) + ``) } x.WriteString(``) } - row(t.Header, true) - for _, r := range t.Rows { - row(r, false) + row(t.Header, t.HeaderRuns, true) + for i, r := range t.Rows { + var rich [][]Run + if t.RowRuns != nil { + rich = t.RowRuns[i] + } + row(r, rich, false) } x.WriteString(``) } + +func runsXML(text string, runs []Run, bold bool) string { + if len(runs) == 0 { + return runXML(Run{Text: text, Bold: bold}) + } + var b strings.Builder + for _, r := range runs { + r.Bold = r.Bold || bold + b.WriteString(runXML(r)) + } + return b.String() +} diff --git a/addons/office/internal/pdf/create.go b/addons/office/internal/pdf/create.go index e42d8c80e..adf40b1d0 100644 --- a/addons/office/internal/pdf/create.go +++ b/addons/office/internal/pdf/create.go @@ -12,7 +12,7 @@ import ( // Heading levels the writer supports. const ( minHeadingLevel = 1 - maxHeadingLevel = 3 + maxHeadingLevel = 6 ) // Layout in millimetres (A4, portrait). Body text is 11 pt, table text 9 pt. @@ -32,8 +32,8 @@ const ( // headingSize and headingLine give the font size (pt) and line height (mm) // of each heading level. var ( - headingSize = [...]float64{0, 20, 16, 13} - headingLine = [...]float64{0, 9, 7.5, 6.5} + headingSize = [...]float64{0, 20, 16, 13, 12, 11, 11} + headingLine = [...]float64{0, 9, 7.5, 6.5, 6, 5.5, 5.5} ) // Block is one element of a page. The block types are Heading, Paragraph, @@ -49,26 +49,42 @@ type Block interface { type Heading struct { Level int Text string + Runs []Run } // Paragraph is a body paragraph. It wraps to the page width. An empty Text // is an empty line. +type Run struct { + Text string + Bold bool + Italic bool +} + type Paragraph struct { Text string + Runs []Run } // Bullets is a bulleted list. It needs at least one item and no item may be // empty. type Bullets struct { - Items []string + Items []string + RichItems [][]Run + Ordered bool + Start int + // StartSet distinguishes an explicit zero from the legacy default of one. + StartSet bool + Indent int } // Table is a grid with a header row and body rows. Every row has as many // cells as Header. Columns share the text width equally. Cell text wraps, // and the row grows to fit it. type Table struct { - Header []string - Rows [][]string + Header []string + Rows [][]string + HeaderRuns [][]Run + RowRuns [][][]Run } // Page is one page of a Spec. Each Page starts on a new PDF page. Text that @@ -90,22 +106,36 @@ type Spec struct { } func (h Heading) validate() error { + if h.Text != "" && len(h.Runs) > 0 { + return errors.New("heading sets both Text and Runs") + } if h.Level < minHeadingLevel || h.Level > maxHeadingLevel { return fmt.Errorf("heading level %d is not between %d and %d", h.Level, minHeadingLevel, maxHeadingLevel) } - if strings.TrimSpace(h.Text) == "" { + if strings.TrimSpace(plain(h.Text, h.Runs)) == "" { return errors.New("heading text is empty") } return nil } -func (p Paragraph) validate() error { return nil } +func (p Paragraph) validate() error { + if p.Text != "" && len(p.Runs) > 0 { + return errors.New("paragraph sets both Text and Runs") + } + return nil +} func (b Bullets) validate() error { - if len(b.Items) == 0 { + if len(b.Items) > 0 && len(b.RichItems) > 0 { + return errors.New("list sets both Items and RichItems") + } + if b.Start < 0 || b.Indent < 0 || b.Indent > 64 { + return errors.New("invalid list position") + } + if len(b.texts()) == 0 { return errors.New("bullet list has no item") } - for i, it := range b.Items { + for i, it := range b.texts() { if strings.TrimSpace(it) == "" { return fmt.Errorf("bullet item %d is empty", i+1) } @@ -114,6 +144,20 @@ func (b Bullets) validate() error { } func (t Table) validate() error { + if t.HeaderRuns != nil && len(t.HeaderRuns) != len(t.Header) { + return errors.New("table header runs shape mismatch") + } + if t.RowRuns != nil { + if len(t.RowRuns) != len(t.Rows) { + return errors.New("table row runs shape mismatch") + } + for i, r := range t.RowRuns { + if len(r) != len(t.Rows[i]) { + return errors.New("table cell runs shape mismatch") + } + } + } + if len(t.Header) == 0 { return errors.New("table has no header") } @@ -125,14 +169,49 @@ func (t Table) validate() error { return nil } -func (h Heading) texts() []string { return []string{h.Text} } -func (p Paragraph) texts() []string { return []string{p.Text} } -func (b Bullets) texts() []string { return b.Items } - +func plain(text string, runs []Run) string { + var b strings.Builder + b.WriteString(text) + for _, r := range runs { + b.WriteString(r.Text) + } + return b.String() +} +func rich(text string, runs []Run) []Run { + if len(runs) > 0 { + return runs + } + return []Run{{Text: text}} +} +func (h Heading) texts() []string { return []string{plain(h.Text, h.Runs)} } +func (p Paragraph) texts() []string { return []string{plain(p.Text, p.Runs)} } +func (b Bullets) texts() []string { + if len(b.RichItems) == 0 { + return b.Items + } + out := make([]string, len(b.RichItems)) + for i, r := range b.RichItems { + out[i] = plain("", r) + } + return out +} func (t Table) texts() []string { - out := append([]string{}, t.Header...) - for _, r := range t.Rows { - out = append(out, r...) + out := []string{} + for i, c := range t.Header { + var r []Run + if t.HeaderRuns != nil { + r = t.HeaderRuns[i] + } + out = append(out, plain(c, r)) + } + for i, row := range t.Rows { + for j, c := range row { + var r []Run + if t.RowRuns != nil { + r = t.RowRuns[i][j] + } + out = append(out, plain(c, r)) + } } return out } @@ -345,93 +424,204 @@ func render(spec Spec) ([]byte, error) { return buf.Bytes(), nil } +// styledFont selects a built-in emphasis variant. Existing single-style TTF +// users keep their existing behaviour; Markdown creation uses the core fonts. +func (l *layout) styledFont(size float64, bold, italic bool) { + style := "" + if !l.ttf { + if bold { + style += "B" + } + if italic { + style += "I" + } + } + l.pdf.SetFont(l.family, style, size) +} + +// richLines wraps using the actual font metrics of every run, retaining style +// on both sides of a wrap. Long words split at rune boundaries. +func (l *layout) richLines(runs []Run, width, size float64, bold bool) [][]Run { + lines := [][]Run{{}} + used := 0.0 + add := func(r Run) { + last := len(lines) - 1 + line := lines[last] + if len(line) > 0 && line[len(line)-1].Bold == r.Bold && line[len(line)-1].Italic == r.Italic { + line[len(line)-1].Text += r.Text + lines[last] = line + } else { + lines[last] = append(line, r) + } + } + newline := func() { lines = append(lines, []Run{}); used = 0 } + // Words include their trailing spaces, allowing normal word wrapping. + for _, run := range runs { + l.styledFont(size, bold || run.Bold, run.Italic) + var word strings.Builder + flush := func() { + s := word.String() + word.Reset() + if s == "" { + return + } + w := l.pdf.GetStringWidth(l.text(s)) + if used > 0 && used+w > width { + newline() + } + for _, ch := range s { + t := string(ch) + cw := l.pdf.GetStringWidth(l.text(t)) + if used > 0 && used+cw > width { + newline() + } + r := run + r.Text = t + add(r) + used += cw + } + } + for _, ch := range run.Text { + if ch == '\n' { + flush() + newline() + } else { + word.WriteRune(ch) + if ch == ' ' || ch == '\t' { + flush() + } + } + } + flush() + } + return lines +} +func (l *layout) drawLines(lines [][]Run, x, line, size float64, bold, paginate bool) { + // Widths above are glyph advances, and table padding is already explicit. + // CellFormat's implicit inset otherwise pushes the final glyph past the + // measured right edge, especially when mixed styles meet at a cell wrap. + margin := l.pdf.GetCellMargin() + l.pdf.SetCellMargin(0) + defer l.pdf.SetCellMargin(margin) + for _, runs := range lines { + if paginate { + l.ensure(line) + } + y := l.pdf.GetY() + l.pdf.SetX(x) + for _, r := range runs { + l.styledFont(size, bold || r.Bold, r.Italic) + s := l.text(r.Text) + l.pdf.CellFormat(l.pdf.GetStringWidth(s), line, s, "", 0, "L", false, 0, "") + } + l.pdf.SetXY(x, y+line) + } +} func (h Heading) render(l *layout) { size, line := headingSize[h.Level], headingLine[h.Level] l.pdf.Ln(2) l.ensure(2 * line) - l.pdf.SetX(l.left) - l.font(size, true) - l.pdf.MultiCell(0, line, l.text(h.Text), "", "L", false) + l.drawLines(l.richLines(rich(h.Text, h.Runs), l.width, size, true), l.left, line, size, true, true) l.pdf.Ln(2) } - func (p Paragraph) render(l *layout) { - l.ensure(bodyLine) - l.pdf.SetX(l.left) - l.font(bodySize, false) - if p.Text == "" { - l.pdf.Ln(bodyLine) - return - } - l.pdf.MultiCell(0, bodyLine, l.text(p.Text), "", "L", false) + l.drawLines(l.richLines(rich(p.Text, p.Runs), l.width, bodySize, false), l.left, bodyLine, bodySize, false, true) l.pdf.Ln(2) } - func (b Bullets) render(l *layout) { - l.font(bodySize, false) - for _, item := range b.Items { + number := b.Start + if number == 0 && !b.StartSet { + number = 1 + } + x := l.left + float64(b.Indent)*bulletIndent + for i, item := range b.texts() { + var r []Run + if len(b.RichItems) > 0 { + r = b.RichItems[i] + item = "" + } + width := l.width - (x - l.left) - bulletIndent + if width < 10 { + l.fail("list nesting leaves insufficient text width") + return + } l.ensure(bodyLine) y := l.pdf.GetY() - l.pdf.SetXY(l.left+1, y) - l.pdf.CellFormat(4, bodyLine, l.bullet(), "", 0, "L", false, 0, "") - l.pdf.SetXY(l.left+bulletIndent, y) - l.pdf.MultiCell(l.width-bulletIndent, bodyLine, l.text(item), "", "L", false) + l.styledFont(bodySize, false, false) + l.pdf.SetXY(x+1, y) + marker := l.bullet() + if b.Ordered { + marker = fmt.Sprintf("%d.", number+i) + } + l.pdf.CellFormat(bulletIndent, bodyLine, marker, "", 0, "L", false, 0, "") + l.pdf.SetY(y) + l.drawLines(l.richLines(rich(item, r), width, bodySize, false), x+bulletIndent, bodyLine, bodySize, false, true) } l.pdf.SetX(l.left) l.pdf.Ln(2) } - func (t Table) render(l *layout) { cw := l.width / float64(len(t.Header)) - hh := l.rowHeight(t.Header, cw, true) + header := l.tableLines(t.Header, t.HeaderRuns, cw, true) + hh := rowHeight(header) if hh > l.room() { l.fail("table header is taller than a page") return } l.ensure(hh) - l.drawRow(t.Header, cw, hh, true) - for _, row := range t.Rows { - h := l.rowHeight(row, cw, false) + l.drawRow(header, cw, hh, true) + for i, row := range t.Rows { + var runs [][]Run + if t.RowRuns != nil { + runs = t.RowRuns[i] + } + lines := l.tableLines(row, runs, cw, false) + h := rowHeight(lines) if h+hh > l.room() { l.fail("table row does not fit on a page together with the header") return } if l.ensure(h) { - // The row starts a new page, so the header repeats there. - l.drawRow(t.Header, cw, hh, true) + l.drawRow(header, cw, hh, true) } - l.drawRow(row, cw, h, false) + l.drawRow(lines, cw, h, false) } l.pdf.SetX(l.left) l.pdf.Ln(3) } - -// rowHeight returns the height of a row, which fits its tallest wrapped cell. -func (l *layout) rowHeight(cells []string, cw float64, header bool) float64 { - l.font(tableSize, header) +func (l *layout) tableLines(cells []string, runs [][]Run, cw float64, header bool) [][][]Run { + out := make([][][]Run, len(cells)) + for i, c := range cells { + var r []Run + if runs != nil { + r = runs[i] + c = "" + } + out[i] = l.richLines(rich(c, r), cw-2*tablePad, tableSize, header) + } + return out +} +func rowHeight(cells [][][]Run) float64 { n := 1 - for _, c := range cells { - if k := len(l.pdf.SplitLines([]byte(l.text(c)), cw-2*tablePad)); k > n { - n = k + for _, lines := range cells { + if len(lines) > n { + n = len(lines) } } return float64(n)*tableLine + 2*tablePad } - -// drawRow draws one row of h millimetres at the current y, then moves below it. -func (l *layout) drawRow(cells []string, cw, h float64, header bool) { +func (l *layout) drawRow(cells [][][]Run, cw, h float64, header bool) { y := l.pdf.GetY() - l.font(tableSize, header) border := "D" if header { border = "DF" l.pdf.SetFillColor(230, 230, 230) } - for i, c := range cells { + for i, lines := range cells { x := l.left + float64(i)*cw l.pdf.Rect(x, y, cw, h, border) l.pdf.SetXY(x+tablePad, y+tablePad) - l.pdf.MultiCell(cw-2*tablePad, tableLine, l.text(c), "", "L", false) + l.drawLines(lines, x+tablePad, tableLine, tableSize, header, false) } l.pdf.SetXY(l.left, y+h) } diff --git a/addons/office/internal/pdf/pdf_test.go b/addons/office/internal/pdf/pdf_test.go index bf06f3447..d5e59d636 100644 --- a/addons/office/internal/pdf/pdf_test.go +++ b/addons/office/internal/pdf/pdf_test.go @@ -232,7 +232,7 @@ var _ = Describe("PDF creation and editing", func() { } cases := map[string]Spec{ "no pages": {}, - "heading level 4": {Pages: []Page{{Blocks: []Block{Heading{Level: 4, Text: "x"}}}}}, + "heading level 7": {Pages: []Page{{Blocks: []Block{Heading{Level: 7, Text: "x"}}}}}, "heading level 0": {Pages: []Page{{Blocks: []Block{Heading{Level: 0, Text: "x"}}}}}, "empty heading": {Pages: []Page{{Blocks: []Block{Heading{Level: 1, Text: " "}}}}}, "empty bullets": {Pages: []Page{{Blocks: []Block{Bullets{}}}}}, diff --git a/addons/office/internal/textinput/csv.go b/addons/office/internal/textinput/csv.go new file mode 100644 index 000000000..833e8a240 --- /dev/null +++ b/addons/office/internal/textinput/csv.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +package textinput + +import ( + "context" + "encoding/csv" + "errors" + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/mudler/LocalAI/addons/office/internal/xlsx" +) + +const maxSourceBytes = 1 << 20 + +// validXML rejects invalid UTF-8 and characters XML 1.0 cannot represent. +func validXML(s string) bool { + if !utf8.ValidString(s) { + return false + } + for _, r := range s { + if !(r == 9 || r == 10 || r == 13 || r >= 0x20 && r <= 0xd7ff || r >= 0xe000 && r <= 0xfffd || r >= 0x10000 && r <= 0x10ffff) { + return false + } + } + return true +} + +// ParseCSV creates a single sheet of explicit string cells, never formulas. +// Validate the entire bounded grid before allocating workbook cells. +func ParseCSV(ctx context.Context, source, sheet, title string) (xlsx.Workbook, error) { + fail := func(msg string) (xlsx.Workbook, error) { return xlsx.Workbook{}, errors.New("csv: " + msg) } + if err := ctx.Err(); err != nil { + return xlsx.Workbook{}, err + } + if len(source) > maxSourceBytes { + return fail("input exceeds 1 MiB") + } + if !validXML(source) || !validXML(sheet) || !validXML(title) { + return fail("invalid XML text in content or metadata") + } + if sheet == "" { + sheet = "Sheet1" + } + if utf8.RuneCountInString(sheet) > 31 || strings.ContainsAny(sheet, `[]:*?/\`) || strings.HasPrefix(sheet, "'") || strings.HasSuffix(sheet, "'") { + return fail("invalid sheet name: use at most 31 characters without []:*?/\\ or boundary apostrophes") + } + reader := csv.NewReader(strings.NewReader(source)) + var rows [][]string + columns, nonempty := 0, 0 + for { + if err := ctx.Err(); err != nil { + return xlsx.Workbook{}, err + } + row, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return fail(fmt.Sprintf("invalid quoting or row width near record %d", len(rows)+1)) + } + if columns == 0 { + columns = len(row) + } + if len(rows) >= 5000 || columns > 200 || (len(rows)+1)*columns > 100000 { + return fail("grid exceeds 5000 rows, 200 columns or 100000 cells") + } + for _, s := range row { + if s != "" { + nonempty++ + } + } + rows = append(rows, row) + } + if nonempty == 0 { + return fail("input has no nonempty cells") + } + if err := ctx.Err(); err != nil { + return xlsx.Workbook{}, err + } + cells := make([]xlsx.Cell, 0, nonempty) + for r, row := range rows { + if err := ctx.Err(); err != nil { + return xlsx.Workbook{}, err + } + for c, value := range row { + if value != "" { + col := "" + for n := c + 1; n > 0; n = (n - 1) / 26 { + col = string(rune('A'+(n-1)%26)) + col + } + cells = append(cells, xlsx.Cell{Ref: fmt.Sprintf("%s%d", col, r+1), Value: xlsx.Str(value)}) + } + } + } + return xlsx.Workbook{Title: title, Sheets: []xlsx.Sheet{{Name: sheet, Cells: cells}}}, nil +} diff --git a/addons/office/internal/textinput/docx_edit_test.go b/addons/office/internal/textinput/docx_edit_test.go new file mode 100644 index 000000000..09c31488e --- /dev/null +++ b/addons/office/internal/textinput/docx_edit_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +package textinput_test + +import ( + "bytes" + "context" + + "github.com/mudler/LocalAI/addons/office/internal/docx" + "github.com/mudler/LocalAI/addons/office/internal/docxedit" + "github.com/mudler/LocalAI/addons/office/internal/textinput" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Markdown DOCX edit compatibility", func() { + DescribeTable("keeps same-style parser fragments editable as actual text", + func(markdown, find, replacement string, before, after []docx.Run) { + parsed, err := textinput.ParseMarkdown(context.Background(), markdown) + Expect(err).NotTo(HaveOccurred()) + // Exercise the generated file, not just the intermediate run model. + var buf bytes.Buffer + Expect(docx.Write(&buf, docx.Document{Blocks: parsed.Docx})).To(Succeed()) + editor, err := docxedit.Open(buf.Bytes()) + Expect(err).NotTo(HaveOccurred()) + paragraphs := editor.Paragraphs() + Expect(paragraphs[len(paragraphs)-1].Text).To(ContainSubstring(find)) + n, err := editor.ReplaceAll(find, replacement) + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(1)) + Expect(parsed.Docx[len(parsed.Docx)-1].(docx.Paragraph).Runs).To(Equal(before)) + + data, err := editor.Bytes() + Expect(err).NotTo(HaveOccurred()) + reopened, err := docxedit.Open(data) + Expect(err).NotTo(HaveOccurred()) + paragraphs = reopened.Paragraphs() + Expect(paragraphs[len(paragraphs)-1].Text).To(ContainSubstring(replacement)) + // Compare against a fresh rendering to prove emphasis is preserved. + expected := parsed.Docx + expected[len(expected)-1] = docx.Paragraph{Runs: after} + var want bytes.Buffer + Expect(docx.Write(&want, docx.Document{Blocks: expected})).To(Succeed()) + Expect(parts(data)["word/document.xml"]).To(Equal(parts(want.Bytes())["word/document.xml"])) + }, + Entry("plain paragraph with punctuation", "# Summary\n\nAll good.", "All good.", "Mostly good.", + []docx.Run{{Text: "All good."}}, []docx.Run{{Text: "Mostly good."}}), + Entry("punctuation inside a bold run", "A **very good.** finish.", "very good.", "mostly good.", + []docx.Run{{Text: "A "}, {Text: "very good.", Bold: true}, {Text: " finish."}}, + []docx.Run{{Text: "A "}, {Text: "mostly good.", Bold: true}, {Text: " finish."}}), + Entry("label inside an italic run", "A *very [good](https://example.invalid).* finish.", "very good.", "mostly good.", + []docx.Run{{Text: "A "}, {Text: "very good.", Italic: true}, {Text: " finish."}}, + []docx.Run{{Text: "A "}, {Text: "mostly good.", Italic: true}, {Text: " finish."}}), + Entry("label inside a bold italic run", "A ***very [good](https://example.invalid).*** finish.", "very good.", "mostly good.", + []docx.Run{{Text: "A "}, {Text: "very good.", Bold: true, Italic: true}, {Text: " finish."}}, + []docx.Run{{Text: "A "}, {Text: "mostly good.", Bold: true, Italic: true}, {Text: " finish."}}), + Entry("label and literal code with the same style", "All [good](https://example.invalid) `today`.", "All good today.", "Mostly good.", + []docx.Run{{Text: "All good today."}}, []docx.Run{{Text: "Mostly good."}}), + ) +}) diff --git a/addons/office/internal/textinput/markdown.go b/addons/office/internal/textinput/markdown.go new file mode 100644 index 000000000..3da8c1ca5 --- /dev/null +++ b/addons/office/internal/textinput/markdown.go @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: MIT +package textinput + +import ( + "context" + "errors" + "fmt" + "html" + "strings" + + "github.com/mudler/LocalAI/addons/office/internal/docx" + "github.com/mudler/LocalAI/addons/office/internal/pdf" + "github.com/yuin/goldmark/ast" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/util" +) + +// MarkdownDocument contains equivalent bounded documents for both writers. +type MarkdownDocument struct { + Docx []docx.Block + PDF []pdf.Block +} + +type markdownParser struct { + ctx context.Context + source []byte + out MarkdownDocument + blocks int +} + +// ParseMarkdown accepts only the documented basic-file subset of Markdown. +func ParseMarkdown(ctx context.Context, source string) (MarkdownDocument, error) { + if err := ctx.Err(); err != nil { + return MarkdownDocument{}, err + } + if len(source) > maxSourceBytes { + return MarkdownDocument{}, errors.New("markdown: input exceeds 1 MiB") + } + if !validXML(source) { + return MarkdownDocument{}, errors.New("markdown: invalid XML text") + } + if strings.TrimSpace(source) == "" { + return MarkdownDocument{}, errors.New("markdown: supply nonempty text") + } + p := markdownParser{ctx: ctx, source: []byte(source)} + root, err := guardedParse(ctx, p.source) + if err != nil { + return MarkdownDocument{}, err + } + // Bound the whole AST, including inline containers and semantic children. + depth := 0 + err = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { + if err := ctx.Err(); err != nil { + return ast.WalkStop, err + } + if !enter { + depth-- + return ast.WalkContinue, nil + } + depth++ + if depth > 64 { + return ast.WalkStop, p.fail(n, "nesting exceeds 64") + } + if n.Type() == ast.TypeBlock && n.Kind() != ast.KindDocument { + p.blocks++ + if p.blocks > 2000 { + return ast.WalkStop, p.fail(n, "semantic blocks exceed 2000") + } + } + switch n.(type) { + case *ast.Document, *ast.Heading, *ast.Paragraph, *ast.TextBlock, *ast.List, *ast.ListItem, *ast.FencedCodeBlock, *ast.CodeBlock, *east.Table, *east.TableHeader, *east.TableRow, *east.TableCell, *ast.Text, *ast.String, *ast.Emphasis, *ast.CodeSpan, *ast.Link, *ast.AutoLink: + default: + return ast.WalkStop, p.fail(n, "unsupported construct; use headings, paragraphs, lists or simple tables") + } + return ast.WalkContinue, nil + }) + if err != nil { + return MarkdownDocument{}, err + } + for n := root.FirstChild(); n != nil; n = n.NextSibling() { + if err := p.block(n, 0); err != nil { + return MarkdownDocument{}, err + } + } + if err := ctx.Err(); err != nil { + return MarkdownDocument{}, err + } + return p.out, nil +} +func (p *markdownParser) fail(n ast.Node, msg string) error { + line := 1 + for node := n; node != nil; node = node.Parent() { + if node.Type() == ast.TypeBlock && node.Lines().Len() > 0 { + seg := node.Lines().At(0) + line += strings.Count(string(p.source[:seg.Start]), "\n") + break + } + } + return fmt.Errorf("markdown: line %d: %s", line, msg) +} +func (p *markdownParser) add(d docx.Block, b pdf.Block) { + p.out.Docx = append(p.out.Docx, d) + p.out.PDF = append(p.out.PDF, b) +} +func pdfRuns(r []docx.Run) []pdf.Run { + out := make([]pdf.Run, len(r)) + for i, v := range r { + out[i] = pdf.Run{Text: v.Text, Bold: v.Bold, Italic: v.Italic} + } + return out +} +func runText(r []docx.Run) string { + var b strings.Builder + for _, v := range r { + b.WriteString(v.Text) + } + return b.String() +} +func (p *markdownParser) inline(parent ast.Node) ([]docx.Run, error) { + var runs []docx.Run + var visible strings.Builder + var visit func(ast.Node, bool, bool) error + visit = func(n ast.Node, bold, italic bool) error { + if err := p.ctx.Err(); err != nil { + return err + } + appendText := func(s string) { + if s != "" { + runs = append(runs, docx.Run{Text: s, Bold: bold, Italic: italic}) + visible.WriteString(s) + } + } + switch v := n.(type) { + case *ast.Text: + s := string(v.Segment.Value(p.source)) + s = html.UnescapeString(string(util.UnescapePunctuations([]byte(s)))) + if v.SoftLineBreak() || v.HardLineBreak() { + s += "\n" + } + appendText(s) + return nil + case *ast.String: + appendText(string(v.Value)) + return nil + case *ast.AutoLink: + appendText(string(v.Label(p.source))) + return nil + case *ast.CodeSpan: + var b strings.Builder + for c := v.FirstChild(); c != nil; c = c.NextSibling() { + t := c.(*ast.Text) + b.Write(t.Segment.Value(p.source)) + } + // Code is literal, not an include directive. Break detection across it. + visible.WriteByte(0) + runs = append(runs, docx.Run{Text: strings.ReplaceAll(b.String(), "\n", " "), Bold: bold, Italic: italic}) + visible.WriteByte(0) + return nil + case *ast.Emphasis: + if v.Level == 2 { + bold = true + } else { + italic = true + } + case *ast.Link: + default: + return p.fail(n, "unsupported inline construct") + } + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + if err := visit(c, bold, italic); err != nil { + return err + } + } + return nil + } + for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + if err := visit(c, false, false); err != nil { + return nil, err + } + } + // Includes are not standard Markdown nodes. Match the aggregate visible + // text so emphasis and label-only links cannot split a directive. + isInclude := func(s string) bool { + return strings.Contains(s, "!include") || strings.Contains(s, "{{<") || strings.Contains(s, "{{%") + } + if isInclude(visible.String()) { + return nil, p.fail(parent, "includes are unsupported; supply literal content") + } + // Goldmark can interpret shortcode delimiters as an autolink. Retain + // detection of original syntax, but exclude literal inline code spans. + var raw strings.Builder + var rawVisit func(ast.Node) + rawVisit = func(n ast.Node) { + if _, code := n.(*ast.CodeSpan); code { + raw.WriteByte(0) + return + } + if t, ok := n.(*ast.Text); ok { + raw.Write(t.Segment.Value(p.source)) + } + if a, ok := n.(*ast.AutoLink); ok { + raw.WriteByte('<') + raw.Write(a.Label(p.source)) + raw.WriteByte('>') + } + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + rawVisit(c) + } + } + rawVisit(parent) + if isInclude(raw.String()) { + return nil, p.fail(parent, "includes are unsupported; supply literal content") + } + // Parser fragments with identical formatting should share one text run so + // the DOCX editor can find text across punctuation, labels and inline code. + coalesced := runs[:0] + for i := 0; i < len(runs); { + run := runs[i] + var text strings.Builder + for i < len(runs) && runs[i].Bold == run.Bold && runs[i].Italic == run.Italic { + if err := p.ctx.Err(); err != nil { + return nil, err + } + text.WriteString(runs[i].Text) + endsLine := strings.HasSuffix(runs[i].Text, "\n") + i++ + // Keep semantic line-break boundaries in separate runs. + if endsLine { + break + } + } + run.Text = text.String() + coalesced = append(coalesced, run) + } + return coalesced, nil +} +func (p *markdownParser) block(n ast.Node, indent int) error { + if err := p.ctx.Err(); err != nil { + return err + } + switch v := n.(type) { + case *ast.Heading: + r, e := p.inline(v) + if e != nil { + return e + } + p.add(docx.Heading{Level: v.Level, Runs: r}, pdf.Heading{Level: v.Level, Runs: pdfRuns(r)}) + case *ast.Paragraph, *ast.TextBlock: + r, e := p.inline(n) + if e != nil { + return e + } + p.add(docx.Paragraph{Runs: r}, pdf.Paragraph{Runs: pdfRuns(r)}) + case *ast.FencedCodeBlock, *ast.CodeBlock: + var b strings.Builder + for i := 0; i < n.Lines().Len(); i++ { + seg := n.Lines().At(i) + b.Write(seg.Value(p.source)) + } + s := strings.TrimSuffix(b.String(), "\n") + p.add(docx.Paragraph{Text: s}, pdf.Paragraph{Text: s}) + case *ast.List: + if v.ChildCount() > 100 { + return p.fail(n, "list exceeds 100 items") + } + number := v.Start + for item := v.FirstChild(); item != nil; item = item.NextSibling() { + // Reject marker-only items, including parents containing only a nested + // list, rather than silently dropping their marker or returning no file. + firstText := item.FirstChild() + if firstText == nil || (firstText.Kind() != ast.KindParagraph && firstText.Kind() != ast.KindTextBlock) { + return p.fail(item, "empty list item; supply text after each marker") + } + first := true + for c := item.FirstChild(); c != nil; c = c.NextSibling() { + if c.Kind() == ast.KindList { + if err := p.block(c, indent+1); err != nil { + return err + } + continue + } + if c.Kind() != ast.KindParagraph && c.Kind() != ast.KindTextBlock { + return p.fail(c, "list items must contain text or nested lists") + } + r, e := p.inline(c) + if e != nil { + return e + } + if first { + if strings.TrimSpace(runText(r)) == "" { + return p.fail(item, "empty list item; supply text after each marker") + } + p.add(docx.Bullets{RichItems: [][]docx.Run{r}, Ordered: v.IsOrdered(), Start: number, StartSet: true, Indent: indent}, pdf.Bullets{RichItems: [][]pdf.Run{pdfRuns(r)}, Ordered: v.IsOrdered(), Start: number, StartSet: true, Indent: indent}) + first = false + } else { + p.add(docx.Paragraph{Runs: r}, pdf.Paragraph{Runs: pdfRuns(r)}) + } + } + number++ + } + case *east.Table: + cols := len(v.Alignments) + if cols > 20 || v.ChildCount()-1 > 500 { + return p.fail(n, "table exceeds 20 columns or 500 body rows") + } + d := docx.Table{} + b := pdf.Table{} + for row := v.FirstChild(); row != nil; row = row.NextSibling() { + // Goldmark pads missing cells and drops excess ones. Check original row + // delimiters so ragged input never silently loses or fabricates cells. + if !p.rectangular(row, cols) { + return p.fail(row, "table must be rectangular") + } + var values []string + var rich [][]docx.Run + var prich [][]pdf.Run + for c := row.FirstChild(); c != nil; c = c.NextSibling() { + r, e := p.inline(c) + if e != nil { + return e + } + values = append(values, runText(r)) + rich = append(rich, r) + prich = append(prich, pdfRuns(r)) + } + if row.Kind() == east.KindTableHeader { + d.Header = values + d.HeaderRuns = rich + b.Header = values + b.HeaderRuns = prich + } else { + d.Rows = append(d.Rows, values) + d.RowRuns = append(d.RowRuns, rich) + b.Rows = append(b.Rows, values) + b.RowRuns = append(b.RowRuns, prich) + } + } + p.add(d, b) + default: + return p.fail(n, "unsupported block") + } + return nil +} +func (p *markdownParser) rectangular(row ast.Node, cols int) bool { + if row.ChildCount() != cols { + return false + } + first := row.FirstChild() + if first == nil || first.Lines().Len() == 0 { + return false + } + seg := first.Lines().At(0) + start := seg.Start + for start > 0 && p.source[start-1] != '\n' { + start-- + } + end := seg.Start + for end < len(p.source) && p.source[end] != '\n' { + end++ + } + line := strings.TrimSpace(string(p.source[start:end])) + if strings.HasPrefix(line, "|") { + line = line[1:] + } + if strings.HasSuffix(line, "|") && !(len(line) > 1 && line[len(line)-2] == '\\') { + line = line[:len(line)-1] + } + count := 1 + for i := 0; i < len(line); i++ { + if line[i] == '|' && (i == 0 || line[i-1] != '\\') { + count++ + } + } + return count == cols +} diff --git a/addons/office/internal/textinput/parse_guard.go b/addons/office/internal/textinput/parse_guard.go new file mode 100644 index 000000000..8437a78b0 --- /dev/null +++ b/addons/office/internal/textinput/parse_guard.go @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: MIT +package textinput + +import ( + "context" + "errors" + "strings" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// Goldmark's public parser callbacks provide synchronous checkpoints, not a +// deadline on its internal scans. Keep all state local to this parse. In +// particular, allocating a ContextKey per request would mutate Goldmark globals. +type parseAbort struct{ err error } +type parseGuard struct { + ctx context.Context + // Only completed delimiter subtrees are cached; open blocks remain mutable. + heights map[ast.Node]int +} + +func (g *parseGuard) poll() { + if err := g.ctx.Err(); err != nil { + panic(parseAbort{err}) + } +} +func (g *parseGuard) reject(message string) { panic(parseAbort{errors.New("markdown: " + message)}) } +func (g *parseGuard) depth(n ast.Node) int { + d := 0 + for ; n != nil; n = n.Parent() { + g.poll() + d++ + if d > 64 { + g.reject("nesting exceeds 64") + } + } + return d +} + +// Iterative validation never recursively visits an unchecked subtree. +func (g *parseGuard) check(n ast.Node, parentDepth int) int { + if n == nil { + return 0 + } + type entry struct { + n ast.Node + depth int + } + stack := []entry{{n, 1}} + height := 0 + for len(stack) > 0 { + g.poll() + e := stack[len(stack)-1] + stack = stack[:len(stack)-1] + h := g.heights[e.n] + if h != 0 { + if parentDepth+e.depth+h-1 > 64 { + g.reject("nesting exceeds 64") + } + if e.depth+h-1 > height { + height = e.depth + h - 1 + } + continue + } + if parentDepth+e.depth > 64 { + g.reject("nesting exceeds 64") + } + if e.depth > height { + height = e.depth + } + for c := e.n.FirstChild(); c != nil; c = c.NextSibling() { + stack = append(stack, entry{c, e.depth + 1}) + } + } + return height +} + +func guardedParse(ctx context.Context, source []byte) (root ast.Node, err error) { + return runGuardedParse(ctx, source, nil) +} + +// configure is used by package tests to exercise public callback boundaries. +func runGuardedParse(ctx context.Context, source []byte, configure func(parser.Parser)) (root ast.Node, err error) { + defer func() { + if v := recover(); v != nil { + if abort, ok := v.(parseAbort); ok { + root = nil + err = abort.err + } else { + panic(v) + } + } + }() + g := &parseGuard{ctx: ctx, heights: make(map[ast.Node]int)} + blocks := parser.DefaultBlockParsers() + for i := range blocks { + blocks[i].Value = &guardBlock{BlockParser: blocks[i].Value.(parser.BlockParser), g: g} + } + inlines := parser.DefaultInlineParsers() + for i := range inlines { + ip := inlines[i].Value.(parser.InlineParser) + wrapped := &guardInline{InlineParser: ip, g: g} + if cb, ok := ip.(parser.CloseBlocker); ok { + inlines[i].Value = &guardCloseInline{guardInline: wrapped, close: cb} + } else { + inlines[i].Value = wrapped + } + } + // Goldmark dispatches the space trigger at the beginning of plain-text lines. + inlines = append(inlines, util.Prioritized(&guardText{g: g}, 0)) + paragraphs := parser.DefaultParagraphTransformers() + for i := range paragraphs { + paragraphs[i].Value = &guardParagraph{ParagraphTransformer: paragraphs[i].Value.(parser.ParagraphTransformer), g: g} + } + paragraphs = append(paragraphs, util.Prioritized(&guardParagraph{ParagraphTransformer: extension.NewTableParagraphTransformer(), g: g, table: true}, 200)) + p := parser.NewParser(parser.WithBlockParsers(blocks...), parser.WithInlineParsers(inlines...), parser.WithParagraphTransformers(paragraphs...), parser.WithASTTransformers(util.Prioritized(&guardAST{ASTTransformer: extension.NewTableASTTransformer(), g: g}, 0))) + if configure != nil { + configure(p) + } + g.poll() + root = p.Parse(text.NewReader(source), parser.WithContext(&guardContext{Context: parser.NewContext(), g: g})) + g.poll() + g.check(root, 0) + return root, nil +} + +type guardBlock struct { + parser.BlockParser + g *parseGuard +} + +func (b *guardBlock) Open(parent ast.Node, r text.Reader, pc parser.Context) (ast.Node, parser.State) { + b.g.poll() + n, s := b.BlockParser.Open(parent, r, pc) + b.g.poll() + b.g.check(n, b.g.depth(parent)) + return n, s +} +func (b *guardBlock) Continue(n ast.Node, r text.Reader, pc parser.Context) parser.State { + b.g.poll() + s := b.BlockParser.Continue(n, r, pc) + b.g.poll() + return s +} +func (b *guardBlock) Close(n ast.Node, r text.Reader, pc parser.Context) { + b.g.poll() + b.BlockParser.Close(n, r, pc) + b.g.poll() +} + +type guardInline struct { + parser.InlineParser + g *parseGuard +} + +func (b *guardInline) Parse(parent ast.Node, r text.Reader, pc parser.Context) ast.Node { + b.g.poll() + n := b.InlineParser.Parse(parent, r, pc) + b.g.poll() + b.g.check(n, b.g.depth(parent)) + return n +} + +type guardCloseInline struct { + *guardInline + close parser.CloseBlocker +} + +func (b *guardCloseInline) CloseBlock(parent ast.Node, r text.Reader, pc parser.Context) { + b.g.poll() + b.close.CloseBlock(parent, r, pc) + b.g.poll() + b.g.check(parent, b.g.depth(parent.Parent())) +} + +type guardText struct{ g *parseGuard } + +func (b *guardText) Trigger() []byte { return []byte{' '} } +func (b *guardText) Parse(parent ast.Node, r text.Reader, pc parser.Context) ast.Node { + b.g.poll() + if b.g.depth(parent)+1 > 64 { + b.g.reject("nesting exceeds 64") + } + return nil +} + +type guardContext struct { + parser.Context + g *parseGuard +} + +func (c *guardContext) SetOpenedBlocks(blocks []parser.Block) { + c.g.poll() + if len(blocks) > 64 { + c.g.reject("nesting exceeds 64") + } + if len(blocks) > 0 { + c.g.depth(blocks[len(blocks)-1].Node) + } + c.Context.SetOpenedBlocks(blocks) +} +func (c *guardContext) PushDelimiter(d *parser.Delimiter) { + c.g.poll() + d.Processor = &guardDelimiter{DelimiterProcessor: d.Processor, g: c.g} + c.Context.PushDelimiter(d) +} + +type guardDelimiter struct { + parser.DelimiterProcessor + g *parseGuard + opener, closer *parser.Delimiter +} + +func (d *guardDelimiter) CanOpenCloser(opener, closer *parser.Delimiter) bool { + d.g.poll() + ok := d.DelimiterProcessor.CanOpenCloser(opener, closer) + if ok { + d.opener = opener + d.closer = closer + } + return ok +} +func (d *guardDelimiter) OnMatch(consumes int) ast.Node { + d.g.poll() + parentDepth := d.g.depth(d.opener.Parent()) + height := 0 + for c := d.opener.NextSibling(); c != nil && c != d.closer; c = c.NextSibling() { + h := d.g.check(c, parentDepth+1) + if h > height { + height = h + } + } + if parentDepth+1+height > 64 { + d.g.reject("nesting exceeds 64") + } + n := d.DelimiterProcessor.OnMatch(consumes) + d.g.check(n, parentDepth) + d.g.heights[n] = height + 1 + return n +} + +type guardParagraph struct { + parser.ParagraphTransformer + g *parseGuard + table bool +} + +func (b *guardParagraph) Transform(n *ast.Paragraph, r text.Reader, pc parser.Context) { + b.g.poll() + parent, prev, next := n.Parent(), n.PreviousSibling(), n.NextSibling() + if b.table { + b.preflight(n, r) + } + b.ParagraphTransformer.Transform(n, r, pc) + b.g.poll() + // The transformer may detach n; anchor the changed sibling range first. + if parent == nil { + return + } + first := parent.FirstChild() + if prev != nil { + first = prev.NextSibling() + } + depth := b.g.depth(parent) + for c := first; c != nil && c != next; c = c.NextSibling() { + b.g.check(c, depth) + } +} + +// Recognize only Goldmark's table-delimiter syntax, before its parseRow pads +// every body row to the delimiter width. Do not allocate a split of huge rows. +func (b *guardParagraph) preflight(n *ast.Paragraph, r text.Reader) { + source := r.Source() + lines := n.Lines() + for i := 1; i < lines.Len(); i++ { + b.g.poll() + segment := lines.At(i) + line := segment.Value(source) + if w, _ := util.IndentWidth(line, 0); w > 3 { + continue + } + valid := true + for _, c := range line { + if !(util.IsSpace(c) || c == '-' || c == '|' || c == ':') { + valid = false + break + } + } + if !valid { + continue + } + s := strings.Trim(string(line), " \t\n\r") + s = strings.TrimPrefix(s, "|") + s = strings.TrimSuffix(s, "|") + cols := 0 + for { + cell, rest, more := strings.Cut(s, "|") + cell = strings.Trim(cell, " \t\n\r") + cell = strings.TrimPrefix(cell, ":") + cell = strings.TrimSuffix(cell, ":") + if cell == "" { + valid = false + break + } + for _, c := range cell { + if c != '-' { + valid = false + break + } + } + if !valid { + break + } + cols++ + if !more { + break + } + s = rest + } + if !valid { + continue + } + headerSegment := lines.At(i - 1) + headerSegment = headerSegment.TrimLeftSpace(source) + headerSegment = headerSegment.TrimRightSpace(source) + header := headerSegment.Value(source) + pos, limit := 0, len(header) + if limit > 0 && header[0] == '|' { + pos++ + } + if limit > 0 && header[limit-1] == '|' { + limit-- + } + // Mirror Goldmark's parseRow, including its unconditional trailing-pipe + // removal and lack of an extra cell after a final separator. It pads + // short headers, but a wider header stops the transformer altogether. + headerCols := 0 + for pos < limit { + b.g.poll() + headerCols++ + for pos < limit && (header[pos] != '|' || (pos > 0 && header[pos-1] == '\\')) { + pos++ + } + pos++ + } + if headerCols > cols { + return + } + if cols > 20 || lines.Len()-i-1 > 500 { + b.g.reject("table exceeds 20 columns or 500 body rows") + } + if b.g.depth(n.Parent())+3 > 64 { + b.g.reject("nesting exceeds 64") + } + return + } +} + +type guardAST struct { + parser.ASTTransformer + g *parseGuard +} + +func (b *guardAST) Transform(n *ast.Document, r text.Reader, pc parser.Context) { + b.g.poll() + b.g.check(n, 0) + b.ASTTransformer.Transform(n, r, pc) + b.g.poll() + b.g.check(n, 0) +} diff --git a/addons/office/internal/textinput/parse_guard_test.go b/addons/office/internal/textinput/parse_guard_test.go new file mode 100644 index 000000000..328c20b13 --- /dev/null +++ b/addons/office/internal/textinput/parse_guard_test.go @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: MIT +package textinput + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type callbackBlock struct { + parser.BlockParser + after func() +} + +func (b *callbackBlock) Open(n ast.Node, r text.Reader, c parser.Context) (ast.Node, parser.State) { + n, s := b.BlockParser.Open(n, r, c) + b.after() + return n, s +} + +type callbackInline struct { + parser.InlineParser + after func(ast.Node) +} + +func (b *callbackInline) Parse(n ast.Node, r text.Reader, c parser.Context) ast.Node { + v := b.InlineParser.Parse(n, r, c) + b.after(v) + return v +} + +type callbackDelimiter struct { + parser.DelimiterProcessor + after func() +} + +func (d *callbackDelimiter) OnMatch(n int) ast.Node { + v := d.DelimiterProcessor.OnMatch(n) + d.after() + return v +} + +type callbackParagraph struct{ called *bool } + +func (p callbackParagraph) Transform(*ast.Paragraph, text.Reader, parser.Context) { *p.called = true } + +func guardTable(cols, rows int, header string) string { + if header == "" { + header = "h" + } + line := "|" + strings.Repeat(header+"|", cols) + "\n" + return line + "|" + strings.Repeat("---|", cols) + "\n" + strings.Repeat("|"+strings.Repeat("v|", cols)+"\n", rows) +} +func guardMaxDepth(root ast.Node) int { + max, depth := 0, 0 + _ = ast.Walk(root, func(_ ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + depth++ + if depth > max { + max = depth + } + } else { + depth-- + } + return ast.WalkContinue, nil + }) + return max +} + +var _ = Describe("Synchronous Markdown parse guards", func() { + It("observes cancellation inside actual block, inline and delimiter callbacks", func() { + for _, phase := range []string{"block", "inline", "delimiter"} { + ctx, cancel := context.WithCancel(context.Background()) + called := false + root, err := runGuardedParse(ctx, []byte("*value*"), func(p parser.Parser) { + switch phase { + case "block": + p.AddOptions(parser.WithBlockParsers(util.Prioritized(&guardBlock{BlockParser: &callbackBlock{BlockParser: parser.NewParagraphParser(), after: func() { called = true; cancel() }}, g: &parseGuard{ctx: ctx}}, -10))) + case "inline": + p.AddOptions(parser.WithInlineParsers(util.Prioritized(&guardInline{InlineParser: &callbackInline{InlineParser: parser.NewEmphasisParser(), after: func(ast.Node) { called = true; cancel() }}, g: &parseGuard{ctx: ctx}}, -10))) + case "delimiter": + p.AddOptions(parser.WithInlineParsers(util.Prioritized(&callbackInline{InlineParser: parser.NewEmphasisParser(), after: func(n ast.Node) { + if d, ok := n.(*parser.Delimiter); ok { + g := d.Processor.(*guardDelimiter) + g.DelimiterProcessor = &callbackDelimiter{DelimiterProcessor: g.DelimiterProcessor, after: func() { called = true; cancel() }} + } + }}, -10))) + } + }) + cancel() + Expect(called).To(BeTrue(), phase) + Expect(root).To(BeNil(), phase) + Expect(errors.Is(err, context.Canceled)).To(BeTrue(), phase) + } + }) + It("rejects the first overdepth delimiter match but accepts depth 64", func() { + for _, marker := range []string{"*", "_"} { + for _, depth := range []int{64, 65, 1000} { + source := strings.Repeat(marker, 2*(depth-3)) + "x" + strings.Repeat(marker, 2*(depth-3)) + root, err := guardedParse(context.Background(), []byte(source)) + if depth == 64 { + Expect(err).NotTo(HaveOccurred()) + Expect(guardMaxDepth(root)).To(Equal(64)) + _, err = ParseMarkdown(context.Background(), source) + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(MatchError(ContainSubstring("nesting exceeds 64"))) + Expect(root).To(BeNil()) + } + } + } + }) + It("accepts flat delimiter pairs and mixed legal nesting without counting total matches as depth", func() { + for _, source := range []string{strings.Repeat("*a* **b** _c_ __d__ ", 1000), "**a *b _c_ b* a**", "***x***", strings.Repeat("*", 121) + "x" + strings.Repeat("*", 121)} { + root, err := guardedParse(context.Background(), []byte(source)) + Expect(err).NotTo(HaveOccurred()) + Expect(guardMaxDepth(root)).To(BeNumerically("<=", 64)) + _, err = ParseMarkdown(context.Background(), source) + Expect(err).NotTo(HaveOccurred()) + } + }) + It("enforces depth on block openings, including mixed block and inline nesting", func() { + for _, depth := range []int{64, 65} { + // Document + block quotes + paragraph + text. + source := strings.Repeat("> ", depth-3) + "x\n" + root, err := guardedParse(context.Background(), []byte(source)) + if depth == 64 { + Expect(err).NotTo(HaveOccurred()) + Expect(guardMaxDepth(root)).To(Equal(depth)) + } else { + Expect(err).To(MatchError(ContainSubstring("nesting exceeds 64"))) + } + } + _, err := guardedParse(context.Background(), []byte(strings.Repeat("> ", 60)+"****x****")) + Expect(err).To(MatchError(ContainSubstring("nesting exceeds 64"))) + }) + It("rejects oversize tables before invoking the allocating table transformer", func() { + for _, dimensions := range [][2]int{{21, 1}, {1, 501}, {20000, 1}} { + source := []byte(guardTable(dimensions[0], dimensions[1], "")) + root := goldmark.DefaultParser().Parse(text.NewReader(source)) + paragraph := root.FirstChild().(*ast.Paragraph) + called := false + g := &parseGuard{ctx: context.Background()} + transform := &guardParagraph{ParagraphTransformer: callbackParagraph{&called}, g: g, table: true} + Expect(func() { transform.Transform(paragraph, text.NewReader(source), parser.NewContext()) }).To(Panic()) + Expect(called).To(BeFalse()) + } + }) + It("keeps oversized-looking non-tables as Goldmark paragraphs", func() { + for _, source := range []string{ + "|" + strings.Repeat("heading|", 21) + "\n|---|\n", + "|a|b|\n|---|\n" + strings.Repeat("|value|\n", 501), + "|" + strings.Repeat("heading|", 22) + "\n|" + strings.Repeat("---|", 21) + "\n", + // A mismatched first delimiter stops Goldmark, even when a later + // delimiter could form an oversized table. + "|a|b|\n|---|\n|" + strings.Repeat("---|", 21) + "\n", + // Unicode whitespace is content, not Goldmark's row-edge whitespace. + "\u00a0|" + strings.Repeat("h|", 20) + "\n|" + strings.Repeat("---|", 20) + "\n" + strings.Repeat("|v|\n", 501), + } { + plain := goldmark.New(goldmark.WithExtensions(extension.Table)).Parser().Parse(text.NewReader([]byte(source))) + Expect(plain.FirstChild().Kind()).To(Equal(ast.KindParagraph), source) + root, err := guardedParse(context.Background(), []byte(source)) + Expect(err).NotTo(HaveOccurred(), source) + Expect(root.FirstChild().Kind()).To(Equal(ast.KindParagraph), source) + _, err = ParseMarkdown(context.Background(), source) + Expect(err).NotTo(HaveOccurred(), source) + } + }) + It("matches Goldmark row counting and short-header padding before table limits", func() { + for _, header := range []string{"h", "|h|", " |h|| ", `|a\|b|`, `|a\\|b|`, `|a\|`, "|", "|" + strings.Repeat("h|", 20) + "|", "|" + strings.Repeat("h|", 19) + `a\|`} { + source := header + "\n|" + strings.Repeat("---|", 20) + "\n|v|\n" + plain := goldmark.New(goldmark.WithExtensions(extension.Table)).Parser().Parse(text.NewReader([]byte(source))) + Expect(plain.FirstChild().Kind().String()).To(Equal("Table"), source) + Expect(plain.FirstChild().FirstChild().ChildCount()).To(Equal(20), source) + root, err := guardedParse(context.Background(), []byte(source)) + Expect(err).NotTo(HaveOccurred(), source) + Expect(root.FirstChild().FirstChild().ChildCount()).To(Equal(20), source) + // Padding is parser behavior only; the converter still rejects + // nonrectangular source instead of silently fabricating cells. + _, err = ParseMarkdown(context.Background(), source) + Expect(err).To(MatchError(ContainSubstring("table must be rectangular")), source) + } + for _, source := range []string{ + "h\n|" + strings.Repeat("---|", 21) + "\n", + "h\n|---|---|\n" + strings.Repeat("|v|\n", 501), + } { + plain := goldmark.New(goldmark.WithExtensions(extension.Table)).Parser().Parse(text.NewReader([]byte(source))) + Expect(plain.FirstChild().Kind().String()).To(Equal("Table"), source) + _, err := guardedParse(context.Background(), []byte(source)) + Expect(err).To(MatchError(ContainSubstring("table exceeds")), source) + // The callback stands in for the allocating Goldmark transformer. + root := goldmark.DefaultParser().Parse(text.NewReader([]byte(source))) + called := false + transform := &guardParagraph{ParagraphTransformer: callbackParagraph{&called}, g: &parseGuard{ctx: context.Background()}, table: true} + Expect(func() { + transform.Transform(root.FirstChild().(*ast.Paragraph), text.NewReader([]byte(source)), parser.NewContext()) + }).To(Panic()) + Expect(called).To(BeFalse()) + } + }) + It("accepts the exact table dimensions and escaped pipes in legal headers", func() { + for _, source := range []string{guardTable(20, 1, ""), guardTable(1, 500, ""), guardTable(20, 1, `a\|b`)} { + _, err := ParseMarkdown(context.Background(), source) + Expect(err).NotTo(HaveOccurred()) + } + // The dimension guards are independent of the later 2000 semantic-block limit. + _, err := guardedParse(context.Background(), []byte(guardTable(20, 500, ""))) + Expect(err).NotTo(HaveOccurred()) + }) + It("preserves link CloseBlocker cleanup and label-only semantics", func() { + for _, source := range []string{"[label](https://invalid.example)", "[label][ref]\n\n[ref]: https://invalid.example", "unmatched [label", "[outer [inner](target)](other)", "[**bold** *italic*](target)"} { + guarded, err := guardedParse(context.Background(), []byte(source)) + Expect(err).NotTo(HaveOccurred()) + plain := goldmark.New(goldmark.WithExtensions(extension.Table)).Parser().Parse(text.NewReader([]byte(source))) + var shape func(ast.Node) string + shape = func(n ast.Node) string { + s := n.Kind().String() + "(" + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + s += shape(c) + } + return s + ")" + } + Expect(shape(guarded)).To(Equal(shape(plain)), source) + _, err = ParseMarkdown(context.Background(), source) + Expect(err).NotTo(HaveOccurred()) + } + }) + It("rethrows unrelated callback panics unchanged", func() { + sentinel := &struct{ message string }{"not a guard abort"} + Expect(func() { + _, _ = runGuardedParse(context.Background(), []byte("text"), func(parser.Parser) { panic(sentinel) }) + }).To(PanicWith(sentinel)) + Expect(func() { + _, _ = runGuardedParse(context.Background(), []byte("text"), func(p parser.Parser) { + p.AddOptions(parser.WithBlockParsers(util.Prioritized(&callbackBlock{BlockParser: parser.NewParagraphParser(), after: func() { panic(sentinel) }}, -10))) + }) + }).To(PanicWith(sentinel)) + }) + It("isolates cancellation, delimiter caches and parser contexts across concurrent parses", func() { + var wg sync.WaitGroup + errs := make(chan error, 64) + for i := 0; i < 64; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if i%2 == 0 { + cancel() + } + _, err := ParseMarkdown(ctx, "**bold *italic***\n\n"+guardTable(2, 2, `a\|b`)) + if i%2 == 0 { + if !errors.Is(err, context.Canceled) { + errs <- fmt.Errorf("cancelled parse: %v", err) + } + } else if err != nil { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + Expect(errs).To(BeEmpty()) + }) +}) diff --git a/addons/office/internal/textinput/pdf_layout_review_test.go b/addons/office/internal/textinput/pdf_layout_review_test.go new file mode 100644 index 000000000..2db5f4525 --- /dev/null +++ b/addons/office/internal/textinput/pdf_layout_review_test.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +package textinput_test + +import ( + "context" + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/addons/office/internal/pdf" + "github.com/mudler/LocalAI/addons/office/internal/textinput" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type layoutWord struct { + Text string `xml:",chardata"` + XMin float64 `xml:"xMin,attr"` + XMax float64 `xml:"xMax,attr"` + YMin float64 `xml:"yMin,attr"` + YMax float64 `xml:"yMax,attr"` +} +type layoutPage struct { + Words []layoutWord `xml:"word"` +} + +// These fixtures use only public renderer/parser APIs. Poppler measures the +// actual PDF glyph placement; no renderer layout helper is used as an oracle. +func inspectLayout(blocks []pdf.Block) ([]layoutPage, []byte) { + data, err := pdf.Create(pdf.Spec{Pages: []pdf.Page{{Blocks: blocks}}}) + Expect(err).NotTo(HaveOccurred()) + dir, err := os.MkdirTemp("", "office-layout-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(os.RemoveAll, dir) + path := filepath.Join(dir, "layout.pdf") + Expect(os.WriteFile(path, data, 0600)).To(Succeed()) + out, err := exec.Command("pdftotext", "-bbox", path, "-").CombinedOutput() + Expect(err).NotTo(HaveOccurred(), string(out)) + var doc struct { + Pages []layoutPage `xml:"body>doc>page"` + } + Expect(xml.Unmarshal(out, &doc)).To(Succeed()) + Expect(doc.Pages).NotTo(BeEmpty()) + for _, page := range doc.Pages { + for _, w := range page.Words { + Expect(w.XMin).To(BeNumerically(">=", 56.0), w.Text) + Expect(w.XMax).To(BeNumerically("<=", 540.0), w.Text) + Expect(w.YMin).To(BeNumerically(">=", 56.0), w.Text) + Expect(w.YMax).To(BeNumerically("<=", 786.0), w.Text) + } + } + return doc.Pages, data +} +func layoutText(p layoutPage) string { + var words []string + for _, w := range p.Words { + words = append(words, w.Text) + } + return strings.Join(words, " ") +} + +var _ = Describe("review: actual rich PDF pagination", func() { + It("wraps mixed emphasis and long words across pages without text loss", func() { + var source strings.Builder + for i := 0; i < 180; i++ { + fmt.Fprintf(&source, "regular%03d **bold%03d** *italic%03d* ", i, i, i) + } + source.WriteString("\n\n**" + strings.Repeat("W", 180) + "** tail") + d, err := textinput.ParseMarkdown(context.Background(), source.String()) + Expect(err).NotTo(HaveOccurred()) + pages, data := inspectLayout(d.PDF) + Expect(len(pages)).To(BeNumerically(">", 1)) + var text strings.Builder + for _, p := range pages { + text.WriteString(layoutText(p)) + text.WriteByte(' ') + } + for i := 0; i < 180; i++ { + for _, prefix := range []string{"regular", "bold", "italic"} { + Expect(strings.Count(text.String(), fmt.Sprintf("%s%03d", prefix, i))).To(Equal(1)) + } + } + Expect(strings.Count(text.String(), "W")).To(Equal(180)) + Expect(text.String()).To(ContainSubstring("tail")) + Expect(string(data)).To(ContainSubstring("Helvetica-Bold")) + Expect(string(data)).To(ContainSubstring("Helvetica-Oblique")) + }) + It("repeats table headers and keeps each rich row together across pages", func() { + var source strings.Builder + source.WriteString("| **HeaderLeft** | *HeaderRight* |\n|---|---|\n") + for i := 0; i < 100; i++ { + fmt.Fprintf(&source, "| **left%03d** | *right%03d* |\n", i, i) + } + d, err := textinput.ParseMarkdown(context.Background(), source.String()) + Expect(err).NotTo(HaveOccurred()) + pages, _ := inspectLayout(d.PDF) + Expect(len(pages)).To(BeNumerically(">", 2)) + counts := map[string]int{} + for _, p := range pages { + text := layoutText(p) + Expect(strings.Count(text, "HeaderLeft")).To(Equal(1)) + Expect(strings.Count(text, "HeaderRight")).To(Equal(1)) + for _, w := range p.Words { + counts[w.Text]++ + } + for i := 0; i < 100; i++ { + left, right := fmt.Sprintf("left%03d", i), fmt.Sprintf("right%03d", i) + Expect(strings.Contains(text, left)).To(Equal(strings.Contains(text, right))) + } + } + for i := 0; i < 100; i++ { + Expect(counts[fmt.Sprintf("left%03d", i)]).To(Equal(1)) + Expect(counts[fmt.Sprintf("right%03d", i)]).To(Equal(1)) + } + }) + It("moves a near-bottom multi-line row below a repeated header", func() { + blocks := []pdf.Block{pdf.Paragraph{Runs: []pdf.Run{{Text: strings.Repeat("filler\n", 47) + "filler", Italic: true}}}, pdf.Table{Header: []string{"NearHeader", "OtherHeader"}, Rows: [][]string{{"rowtop\nrowmiddle\nrowbottom", "neighbor"}}}} + pages, _ := inspectLayout(blocks) + Expect(pages).To(HaveLen(2)) + Expect(layoutText(pages[0])).To(ContainSubstring("NearHeader")) + Expect(layoutText(pages[0])).NotTo(ContainSubstring("rowtop")) + Expect(layoutText(pages[1])).To(ContainSubstring("NearHeader")) + for _, word := range []string{"rowtop", "rowmiddle", "rowbottom", "neighbor"} { + Expect(layoutText(pages[1])).To(ContainSubstring(word)) + } + var headerY, rowY float64 + for _, w := range pages[1].Words { + if w.Text == "NearHeader" { + headerY = w.YMax + } + if w.Text == "rowtop" { + rowY = w.YMin + } + } + Expect(rowY).To(BeNumerically(">", headerY)) + }) + It("rejects an oversized rich row with no partial PDF bytes", func() { + data, err := pdf.Create(pdf.Spec{Pages: []pdf.Page{{Blocks: []pdf.Block{pdf.Table{Header: []string{"Header"}, Rows: [][]string{{""}}, RowRuns: [][][]pdf.Run{{{{Text: strings.Repeat("secret-row\n", 80), Bold: true}}}}}}}}}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("table row does not fit")) + Expect(err.Error()).NotTo(ContainSubstring("secret-row")) + Expect(data).To(BeEmpty()) + }) + It("keeps mixed-style cell glyphs inside their own column at wraps", func() { + left := []pdf.Run{{Text: strings.Repeat("W", 35), Bold: true}, {Text: strings.Repeat("i", 35), Italic: true}, {Text: strings.Repeat("M", 35), Bold: true, Italic: true}} + pages, data := inspectLayout([]pdf.Block{pdf.Table{Header: []string{"Left", "Right"}, Rows: [][]string{{"", "neighbor"}}, RowRuns: [][][]pdf.Run{{left, {{Text: "neighbor"}}}}}}) + Expect(pages).To(HaveLen(1)) + var body strings.Builder + lines := map[float64]bool{} + for _, w := range pages[0].Words { + if strings.Trim(w.Text, "WiM") == "" { + body.WriteString(w.Text) + lines[w.YMin] = true + Expect(w.XMin).To(BeNumerically(">=", 60.0)) + Expect(w.XMax).To(BeNumerically("<", 294.0)) + } + if w.Text == "neighbor" { + // A4 midpoint (105 mm) plus the explicit 1.5 mm cell padding. + Expect(w.XMin).To(BeNumerically("~", 106.5*72/25.4, 0.1)) + } + } + Expect(strings.Count(body.String(), "W")).To(Equal(35)) + Expect(strings.Count(body.String(), "i")).To(Equal(35)) + Expect(strings.Count(body.String(), "M")).To(Equal(35)) + Expect(len(lines)).To(BeNumerically(">", 1)) + Expect(string(data)).To(ContainSubstring("Helvetica-BoldOblique")) + }) +}) diff --git a/addons/office/internal/textinput/review_regression_test.go b/addons/office/internal/textinput/review_regression_test.go new file mode 100644 index 000000000..82a7238ed --- /dev/null +++ b/addons/office/internal/textinput/review_regression_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +package textinput_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/addons/office/internal/docx" + "github.com/mudler/LocalAI/addons/office/internal/pdf" + "github.com/mudler/LocalAI/addons/office/internal/textinput" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func reviewDOCX(blocks []docx.Block) map[string]string { + GinkgoHelper() + var out bytes.Buffer + Expect(docx.Write(&out, docx.Document{Blocks: blocks})).To(Succeed()) + z, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).NotTo(HaveOccurred()) + result := map[string]string{} + for _, f := range z.File { + r, err := f.Open() + Expect(err).NotTo(HaveOccurred()) + b, err := io.ReadAll(r) + Expect(r.Close()).To(Succeed()) + Expect(err).NotTo(HaveOccurred()) + result[f.Name] = string(b) + } + return result +} +func reviewPDFText(blocks []pdf.Block, bbox bool) string { + GinkgoHelper() + data, err := pdf.Create(pdf.Spec{Pages: []pdf.Page{{Blocks: blocks}}}) + Expect(err).NotTo(HaveOccurred()) + path := filepath.Join(GinkgoT().TempDir(), "review.pdf") + Expect(os.WriteFile(path, data, 0600)).To(Succeed()) + flag := "-layout" + if bbox { + flag = "-bbox" + } + out, err := exec.Command("pdftotext", flag, path, "-").CombinedOutput() + if err != nil { + Fail(fmt.Sprintf("required PDF extraction: %v: %s", err, out)) + } + return string(out) +} + +var _ = Describe("rendered Markdown regressions", func() { + It("preserves semantic DOCX breaks, tabs, styles and escaping", func() { + d, err := textinput.ParseMarkdown(context.Background(), "**bold \nline** and *italic*\n\n```\nfirst\t<&\nsecond\tend\n```\n") + Expect(err).NotTo(HaveOccurred()) + x := reviewDOCX(d.Docx)["word/document.xml"] + if strings.Count(x, "") != 2 || strings.Count(x, "") != 2 { + Fail(fmt.Sprintf("missing semantic breaks/tabs: %s", x)) + } + if strings.Contains(x, " ") || strings.Contains(x, " ") { + Fail(fmt.Sprintf("literal controls in text: %s", x)) + } + if !strings.Contains(x, `boldline`) { + Fail(fmt.Sprintf("bold style did not span break: %s", x)) + } + if !strings.Contains(x, "<&") || !strings.Contains(x, "") { + Fail(fmt.Sprintf("lost escaping/styles: %s", x)) + } + }) + It("preserves ordered sequences and legacy defaults in DOCX and PDF", func() { + d, err := textinput.ParseMarkdown(context.Background(), "0. zero\n1. one\n\n 7. nestedseven\n 8. nestedeight\n\n2. two\n\nseparator\n\n5. five\n6. six\n\nseparator\n\n0. restartzero\n") + Expect(err).NotTo(HaveOccurred()) + p := reviewDOCX(d.Docx) + dec := xml.NewDecoder(strings.NewReader(p["word/numbering.xml"])) + var starts []string + for { + token, err := dec.Token() + if err == io.EOF { + break + } + Expect(err).NotTo(HaveOccurred()) + if e, ok := token.(xml.StartElement); ok && e.Name.Local == "start" { + for _, a := range e.Attr { + if a.Name.Local == "val" { + starts = append(starts, a.Value) + } + } + } + } + if strings.Join(starts, ",") != "1,0,1,7,8,2,5,6,0" { + Fail(fmt.Sprintf("DOCX numbering starts = %v", starts)) + } + text := reviewPDFText(d.PDF, false) + for _, pair := range []string{"0. zero", "1. one", "7. nestedseven", "8. nestedeight", "2. two", "5. five", "6. six", "0. restartzero"} { + if !strings.Contains(strings.Join(strings.Fields(text), " "), pair) { + Fail(fmt.Sprintf("missing %q in PDF: %s", pair, text)) + } + } + // Existing structured callers use zero as the omitted/default start. + legacy := reviewDOCX([]docx.Block{docx.Bullets{Ordered: true, Items: []string{"legacy"}}}) + if strings.Contains(legacy["word/numbering.xml"], ``) { + Fail("legacy default changed") + } + if !strings.Contains(strings.Join(strings.Fields(reviewPDFText([]pdf.Block{pdf.Bullets{Ordered: true, Items: []string{"legacy"}}}, false)), " "), "1. legacy") { + Fail("legacy PDF default changed") + } + }) + Describe("empty list items", func() { + for _, source := range []string{"- \n", "0. \n", "- \n- secret-marker\n", "1. secret-marker\n2. \n", "- parent\n\n - \n", "1. parent\n\n 2. \n", "-\n - child\n"} { + It("rejects "+source, func() { + d, err := textinput.ParseMarkdown(context.Background(), source) + if err == nil { + Fail(fmt.Sprintf("empty item accepted: %#v", d)) + } + if !strings.Contains(err.Error(), "empty list item") || strings.Contains(err.Error(), "secret-marker") { + Fail(fmt.Sprintf("not actionable/content-free: %v", err)) + } + if len(d.Docx) != 0 || len(d.PDF) != 0 { + Fail("partial document returned") + } + }) + } + }) +}) diff --git a/addons/office/internal/textinput/textinput_suite_test.go b/addons/office/internal/textinput/textinput_suite_test.go new file mode 100644 index 000000000..4b890919d --- /dev/null +++ b/addons/office/internal/textinput/textinput_suite_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +package textinput_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "testing" +) + +func TestTextInput(t *testing.T) { RegisterFailHandler(Fail); RunSpecs(t, "Office text input suite") } diff --git a/addons/office/internal/textinput/textinput_test.go b/addons/office/internal/textinput/textinput_test.go new file mode 100644 index 000000000..01a17f63f --- /dev/null +++ b/addons/office/internal/textinput/textinput_test.go @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +package textinput_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/addons/office/internal/docx" + "github.com/mudler/LocalAI/addons/office/internal/pdf" + "github.com/mudler/LocalAI/addons/office/internal/textinput" + "github.com/mudler/LocalAI/addons/office/internal/xlsx" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// cancelAfterChecks makes cancellation during conversion deterministic, without +// timing-sensitive sleeps or leaked goroutines. +type cancelAfterChecks struct { + context.Context + cancel context.CancelFunc + remaining int +} + +func (c *cancelAfterChecks) Err() error { + c.remaining-- + if c.remaining == 0 { + c.cancel() + } + return c.Context.Err() +} + +func nestedList(n int, emphasis bool) string { + var b strings.Builder + for i := 0; i < n; i++ { + text := "x" + if emphasis && i == n-1 { + text = "*x*" + } + fmt.Fprintf(&b, "%s- %s\n", strings.Repeat(" ", i), text) + } + return b.String() +} + +func parts(data []byte) map[string]string { + z, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + Expect(err).NotTo(HaveOccurred()) + out := map[string]string{} + for _, f := range z.File { + r, e := f.Open() + Expect(e).NotTo(HaveOccurred()) + b, e := io.ReadAll(r) + Expect(e).NotTo(HaveOccurred()) + Expect(r.Close()).To(Succeed()) + out[f.Name] = string(b) + } + return out +} +func table(cols, rows int) string { + line := "|" + strings.Repeat(" x |", cols) + "\n" + return line + "|" + strings.Repeat("---|", cols) + "\n" + strings.Repeat(line, rows) +} +func csvGrid(rows, cols int) string { + return strings.Repeat("x"+strings.Repeat(",", cols-1)+"\n", rows) +} + +var _ = Describe("bounded Office text conversion", func() { + It("renders actual headings, rich text, nested and ordered lists and tables in both files", func() { + source := "" + for i := 1; i <= 6; i++ { + source += strings.Repeat("#", i) + fmt.Sprintf(" H%d *italic*\n\n", i) + } + source += "Body **strong** *slant* ***both*** [label](https://invalid.example/secret) `literal` & \\*star\\*.\n\n3. **first**\n4. second\n - *nested*\n\n- bullet\n\n| **Name** | Value |\n|---|---|\n| *cell* | **data** |\n\n```sh\nnot executed\n```\n" + d, err := textinput.ParseMarkdown(context.Background(), source) + Expect(err).NotTo(HaveOccurred()) + var out bytes.Buffer + Expect(docx.Write(&out, docx.Document{Blocks: d.Docx})).To(Succeed()) + p := parts(out.Bytes()) + xml := p["word/document.xml"] + for i := 1; i <= 6; i++ { + Expect(xml).To(ContainSubstring(fmt.Sprintf(`w:val="Heading%d"`, i))) + } + for _, want := range []string{"", "", "", "", "nested", "literal", "not executed", "label", "&", "*star*"} { + Expect(xml).To(ContainSubstring(want)) + } + Expect(xml).NotTo(ContainSubstring("invalid.example")) + Expect(p["word/numbering.xml"]).To(ContainSubstring(`w:val="decimal"`)) + Expect(p["word/numbering.xml"]).To(ContainSubstring(`w:val="3"`)) + data, err := pdf.Create(pdf.Spec{Pages: []pdf.Page{{Blocks: d.PDF}}}) + Expect(err).NotTo(HaveOccurred()) + for _, font := range []string{"Helvetica-Bold", "Helvetica-Oblique", "Helvetica-BoldOblique"} { + Expect(string(data)).To(ContainSubstring(font)) + } + dir, err := os.MkdirTemp("", "office-text-output-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(os.RemoveAll, dir) + path := filepath.Join(dir, "out.pdf") + Expect(os.WriteFile(path, data, 0600)).To(Succeed()) + cmd := exec.Command("pdftotext", "-layout", path, "-") + extracted, err := cmd.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), string(extracted)) + text := string(extracted) + for _, want := range []string{"H1", "H6", "strong", "slant", "both", "3.", "4.", "first", "nested", "bullet", "Name", "Value", "cell", "data", "not executed"} { + Expect(text).To(ContainSubstring(want)) + } + Expect(text).To(MatchRegexp(`Name\s+Value`)) + Expect(text).To(MatchRegexp(`cell\s+data`)) + Expect(text).NotTo(ContainSubstring("invalid.example")) + }) + It("keeps CSV strings, quoting, newlines and blank coordinates in actual XLSX", func() { + w, e := textinput.ParseCSV(context.Background(), "00123,=x,+x,-x,@x,\r\n\"a,b\",\"say \"\"hi\"\"\",\"multi\r\nline\",,,end\r\n", "", "Title") + Expect(e).NotTo(HaveOccurred()) + Expect(w.Sheets).To(HaveLen(1)) + Expect(w.Sheets[0].Name).To(Equal("Sheet1")) + Expect(w.Sheets[0].HeaderRow).To(BeZero()) + Expect(w.Title).To(Equal("Title")) + var out bytes.Buffer + Expect(xlsx.Write(&out, w)).To(Succeed()) + p := parts(out.Bytes()) + sheet := p["xl/worksheets/sheet1.xml"] + Expect(sheet).NotTo(ContainSubstring("PRIVATE"), Entry("inline HTML", "a PRIVATE"), Entry("image", "![PRIVATE](x)"), Entry("blockquote", "> PRIVATE"), Entry("thematic break", "PRIVATE\n\n---\n"), Entry("include", "!include PRIVATE"), Entry("include directive", "{{< include PRIVATE >}}"), Entry("include split by emphasis", "!**include** PRIVATE"), Entry("include split by link", "{{[<](https://invalid.example) include PRIVATE >}}"), Entry("empty", " \n\t"), Entry("XML control", "PRIVATE\x00"), Entry("invalid UTF8", "PRIVATE\xff"), Entry("ragged table", "| a | b |\n|---|---|\n| PRIVATE |\n")) + DescribeTable("rejects CSV safely", func(source, sheet, title string) { + _, e := textinput.ParseCSV(context.Background(), source, sheet, title) + Expect(e).To(HaveOccurred()) + Expect(e.Error()).NotTo(ContainSubstring("PRIVATE")) + }, + Entry("empty", "", "", ""), Entry("all empty", ",\n,\n", "", ""), Entry("ragged", "a,b\nPRIVATE\n", "", ""), Entry("quotes", "\"PRIVATE", "", ""), Entry("bare quote", "PRI\"VATE", "", ""), Entry("long sheet", "a", strings.Repeat("x", 32), ""), Entry("bad sheet", "a", "PRIVATE/", ""), Entry("apostrophe", "a", "'PRIVATE", ""), Entry("sheet control", "a", "PRIVATE\x00", ""), Entry("title control", "a", "", "PRIVATE\x00"), Entry("cell control", "PRIVATE\x00", "", ""), Entry("invalid UTF8", "PRIVATE\xff", "", "")) + It("accepts a 31-rune sheet and XML-safe Unicode metadata", func() { + w, e := textinput.ParseCSV(context.Background(), "a", strings.Repeat("é", 31), "<&é") + Expect(e).NotTo(HaveOccurred()) + var b bytes.Buffer + Expect(xlsx.Write(&b, w)).To(Succeed()) + }) + It("checks cancellation before parsing", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, e := textinput.ParseMarkdown(ctx, "hello") + Expect(e).To(MatchError(context.Canceled)) + _, e = textinput.ParseCSV(ctx, "hello", "", "") + Expect(e).To(MatchError(context.Canceled)) + }) + DescribeTable("preserves cancellation during conversion", func(checks int) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + md, e := textinput.ParseMarkdown(&cancelAfterChecks{ctx, cancel, checks}, strings.Repeat("- x\n", 50)) + Expect(e).To(MatchError(context.Canceled)) + Expect(md.Docx).To(BeEmpty()) + Expect(md.PDF).To(BeEmpty()) + + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + wb, e := textinput.ParseCSV(&cancelAfterChecks{ctx, cancel, checks}, csvGrid(100, 2), "", "") + Expect(e).To(MatchError(context.Canceled)) + Expect(wb.Sheets).To(BeEmpty()) + }, Entry("during traversal/records", 10), Entry("during mapping/cells", 150)) + DescribeTable("bounds full AST nesting at depth 64", func(levels int, emphasis, ok bool) { + _, e := textinput.ParseMarkdown(context.Background(), nestedList(levels, emphasis)) + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(MatchError(ContainSubstring("nesting exceeds 64"))) + } + }, Entry("below: depth 63", 30, false, true), Entry("at: depth 64", 30, true, true), Entry("above: depth 65", 31, false, false)) + DescribeTable("counts list children toward semantic blocks", func(total int, ok bool) { + // Nine lists contribute 9*(1 list + 100 items + 100 text blocks). + s := strings.Repeat(strings.Repeat("- x\n", 100)+"\nparagraph\n\n", 9) + // Each separator paragraph contributes one additional block. + s += strings.Repeat("x\n\n", total-9*202) + _, e := textinput.ParseMarkdown(context.Background(), s) + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(MatchError(ContainSubstring("semantic blocks exceed 2000"))) + } + }, Entry("below", 1999, true), Entry("at", 2000, true), Entry("above", 2001, false)) + It("keeps include-looking code literal and links label-only", func() { + for _, s := range []string{"`!include PRIVATE`", "`{{< include PRIVATE >}}`", "```\n!include PRIVATE\n{{% include PRIVATE %}}\n```", "[safe](file:///PRIVATE)", "[safe](https://invalid.example/!include)"} { + _, e := textinput.ParseMarkdown(context.Background(), s) + Expect(e).NotTo(HaveOccurred()) + } + }) + It("preserves PDF encoding validation for every rich context", func() { + for _, s := range []string{"**中文**", "# *中文*", "- **中文**", "| a |\n|---|\n| *中文* |"} { + d, e := textinput.ParseMarkdown(context.Background(), s) + Expect(e).NotTo(HaveOccurred()) + _, e = pdf.Create(pdf.Spec{Pages: []pdf.Page{{Blocks: d.PDF}}}) + Expect(e).To(HaveOccurred()) + } + }) + DescribeTable("bounds Markdown bytes", func(n int, ok bool) { + _, e := textinput.ParseMarkdown(context.Background(), strings.Repeat("x", n)) + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(HaveOccurred()) + } + }, Entry("below", (1<<20)-1, true), Entry("at", 1<<20, true), Entry("above", (1<<20)+1, false)) + DescribeTable("bounds semantic blocks", func(n int, ok bool) { + _, e := textinput.ParseMarkdown(context.Background(), strings.Repeat("x\n\n", n)) + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(HaveOccurred()) + } + }, Entry("below", 1999, true), Entry("at", 2000, true), Entry("above", 2001, false)) + DescribeTable("bounds list items", func(n int, ok bool) { + _, e := textinput.ParseMarkdown(context.Background(), strings.Repeat("- x\n", n)) + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(HaveOccurred()) + } + }, Entry("below", 99, true), Entry("at", 100, true), Entry("above", 101, false)) + DescribeTable("bounds table columns and body rows", func(c, r int, ok bool) { + _, e := textinput.ParseMarkdown(context.Background(), table(c, r)) + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(HaveOccurred()) + } + }, Entry("columns below", 19, 1, true), Entry("columns at", 20, 1, true), Entry("columns above", 21, 1, false), Entry("rows below", 1, 499, true), Entry("rows at", 1, 500, true), Entry("rows above", 1, 501, false)) + DescribeTable("bounds CSV rows columns and grid including blanks", func(r, c int, ok bool) { + s := csvGrid(r, c) + Expect(len(s)).To(BeNumerically("<=", 1<<20)) + _, e := textinput.ParseCSV(context.Background(), s, "", "") + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(HaveOccurred()) + } + }, Entry("rows below", 4999, 1, true), Entry("rows at", 5000, 1, true), Entry("rows above", 5001, 1, false), Entry("columns below", 1, 199, true), Entry("columns at", 1, 200, true), Entry("columns above", 1, 201, false), Entry("grid below", 999, 100, true), Entry("grid at", 1000, 100, true), Entry("grid above", 1001, 100, false)) + DescribeTable("bounds CSV bytes", func(n int, ok bool) { + _, e := textinput.ParseCSV(context.Background(), strings.Repeat("x", n), "", "") + if ok { + Expect(e).NotTo(HaveOccurred()) + } else { + Expect(e).To(HaveOccurred()) + } + }, Entry("below", (1<<20)-1, true), Entry("at", 1<<20, true), Entry("above", (1<<20)+1, false)) +}) diff --git a/addons/office/internal/tools/create.go b/addons/office/internal/tools/create.go index 4583b334e..ed8ac06a8 100644 --- a/addons/office/internal/tools/create.go +++ b/addons/office/internal/tools/create.go @@ -5,133 +5,109 @@ import ( "bytes" "encoding/json" "fmt" - "strconv" - "strings" "github.com/mudler/LocalAI/addons/office/internal/deck" "github.com/mudler/LocalAI/addons/office/internal/deck/pptx" "github.com/mudler/LocalAI/addons/office/internal/docx" "github.com/mudler/LocalAI/addons/office/internal/pdf" + "github.com/mudler/LocalAI/addons/office/internal/textinput" "github.com/mudler/LocalAI/addons/office/internal/xlsx" ) // A created file is saved as a new file (version zero). If the path is taken, // the store keeps the old file and the new one is saved next to it. +// decodeTextCreation is deliberately local to the three string creation tools. +// Bound raw JSON before decoding, and never echo decoder errors or user content. +func decodeTextCreation(raw json.RawMessage, v any, field, legacy string) error { + if len(raw) > 1<<20 { + return refuse("arguments exceed 1 MiB; shorten %s", field) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return refuse("arguments must be a JSON object with path and %s strings", field) + } + if _, ok := fields[legacy]; ok { + return refuse("%s is no longer supported; use the %s string", legacy, field) + } + for _, key := range []string{"path", field, "title", "sheet"} { + value, ok := fields[key] + if !ok { + continue + } + var text string + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) || json.Unmarshal(value, &text) != nil { + return refuse("%s must be a string", key) + } + } + if err := decode(raw, v); err != nil { + return refuse("use only path, %s and the optional metadata strings", field) + } + return nil +} + func (w *work) createDocx(raw json.RawMessage) (any, error) { var a struct { pathArgs - Title string `json:"title"` - Blocks []struct { - Kind string `json:"kind"` - Level int `json:"level"` - Text string `json:"text"` - Items []string `json:"items"` - } `json:"blocks"` + Title string `json:"title"` + Markdown string `json:"markdown"` } - if err := decode(raw, &a); err != nil { + if err := decodeTextCreation(raw, &a, "markdown", "blocks"); err != nil { return nil, err } if err := checkPath(a.Path, ".docx"); err != nil { return nil, err } - if len(a.Blocks) == 0 || len(a.Blocks) > 2000 { - return nil, refuse("blocks must hold 1 to 2000 entries") - } - d := docx.Document{Title: a.Title} - for i, b := range a.Blocks { - switch b.Kind { - case "heading": - level := b.Level - if level == 0 { - level = 1 - } - d.Blocks = append(d.Blocks, docx.Heading{Level: level, Text: b.Text}) - case "paragraph": - d.Blocks = append(d.Blocks, docx.Paragraph{Text: b.Text}) - case "bullets": - d.Blocks = append(d.Blocks, docx.Bullets{Items: b.Items}) - default: - return nil, refuse("block %d: kind must be heading, paragraph or bullets", i+1) - } - } - var buf bytes.Buffer - if err := docx.Write(&buf, d); err != nil { - return nil, refuse("the document cannot be written: %v", err) - } - s, err := w.store(a.Path, ".docx", buf.Bytes(), 0) - if err != nil { + d, err := textinput.ParseMarkdown(w.ctx, a.Markdown) + if err := w.ctx.Err(); err != nil { return nil, err } - return s, nil -} - -// cellRef is the A1 name of a zero-based row and column. -func cellRef(row, col int) string { - name := "" - for c := col + 1; c > 0; c = (c - 1) / 26 { - name = string(rune('A'+(c-1)%26)) + name + if err != nil { + return nil, refuse("markdown: %v", err) } - return name + strconv.Itoa(row+1) -} - -// cellOf reads a text the model wrote: "=..." is a formula, a number is a -// number, anything else is text. -func cellOf(ref, s string) (xlsx.Cell, bool) { - switch { - case s == "": - return xlsx.Cell{}, false - case strings.HasPrefix(s, "="): - return xlsx.Cell{Ref: ref, Formula: s}, true + if err := w.ctx.Err(); err != nil { + return nil, err } - if n, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { - return xlsx.Cell{Ref: ref, Value: xlsx.Num(n)}, true + var buf bytes.Buffer + if err := docx.Write(&buf, docx.Document{Title: a.Title, Blocks: d.Docx}); err != nil { + return nil, refuse("the document cannot be written: %v", err) } - return xlsx.Cell{Ref: ref, Value: xlsx.Str(s)}, true + if err := w.ctx.Err(); err != nil { + return nil, err + } + return w.store(a.Path, ".docx", buf.Bytes(), 0) } func (w *work) createXlsx(raw json.RawMessage) (any, error) { var a struct { pathArgs - Title string `json:"title"` - Sheets []struct { - Name string `json:"name"` - Rows [][]string `json:"rows"` - } `json:"sheets"` + Title string `json:"title"` + CSV string `json:"csv"` + Sheet string `json:"sheet"` } - if err := decode(raw, &a); err != nil { + if err := decodeTextCreation(raw, &a, "csv", "sheets"); err != nil { return nil, err } if err := checkPath(a.Path, ".xlsx"); err != nil { return nil, err } - if len(a.Sheets) == 0 || len(a.Sheets) > 32 { - return nil, refuse("sheets must hold 1 to 32 entries") + wb, err := textinput.ParseCSV(w.ctx, a.CSV, a.Sheet, a.Title) + if err := w.ctx.Err(); err != nil { + return nil, err } - wb := xlsx.Workbook{Title: a.Title, HeaderStyle: xlsx.Style{Bold: true}} - cells := 0 - for _, s := range a.Sheets { - sh := xlsx.Sheet{Name: s.Name} - if len(s.Rows) > 1 { - sh.HeaderRow = 1 - } - for r, row := range s.Rows { - for c, text := range row { - if cell, ok := cellOf(cellRef(r, c), text); ok { - sh.Cells = append(sh.Cells, cell) - cells++ - } - } - } - if cells > 100000 { - return nil, refuse("the workbook has more than 100000 cells") - } - wb.Sheets = append(wb.Sheets, sh) + if err != nil { + return nil, refuse("csv: %v", err) + } + if err := w.ctx.Err(); err != nil { + return nil, err } var buf bytes.Buffer if err := xlsx.Write(&buf, wb); err != nil { return nil, refuse("the workbook cannot be written: %v", err) } + if err := w.ctx.Err(); err != nil { + return nil, err + } return w.store(a.Path, ".xlsx", buf.Bytes(), 0) } @@ -177,47 +153,31 @@ func (w *work) createPptx(raw json.RawMessage) (any, error) { func (w *work) createPdf(raw json.RawMessage) (any, error) { var a struct { pathArgs - Title string `json:"title"` - Blocks []struct { - Kind string `json:"kind"` - Level int `json:"level"` - Text string `json:"text"` - Items []string `json:"items"` - Header []string `json:"header"` - Rows [][]string `json:"rows"` - } `json:"blocks"` + Title string `json:"title"` + Markdown string `json:"markdown"` } - if err := decode(raw, &a); err != nil { + if err := decodeTextCreation(raw, &a, "markdown", "blocks"); err != nil { return nil, err } if err := checkPath(a.Path, ".pdf"); err != nil { return nil, err } - if len(a.Blocks) == 0 || len(a.Blocks) > 2000 { - return nil, refuse("blocks must hold 1 to 2000 entries") + d, err := textinput.ParseMarkdown(w.ctx, a.Markdown) + if err := w.ctx.Err(); err != nil { + return nil, err } - blocks := make([]pdf.Block, 0, len(a.Blocks)) - for i, b := range a.Blocks { - switch b.Kind { - case "heading": - level := b.Level - if level == 0 { - level = 1 - } - blocks = append(blocks, pdf.Heading{Level: level, Text: b.Text}) - case "paragraph": - blocks = append(blocks, pdf.Paragraph{Text: b.Text}) - case "bullets": - blocks = append(blocks, pdf.Bullets{Items: b.Items}) - case "table": - blocks = append(blocks, pdf.Table{Header: b.Header, Rows: b.Rows}) - default: - return nil, refuse("block %d: kind must be heading, paragraph, bullets or table", i+1) - } + if err != nil { + return nil, refuse("markdown: %v", err) } - data, err := pdf.Create(pdf.Spec{Title: a.Title, Pages: []pdf.Page{{Blocks: blocks}}}) + if err := w.ctx.Err(); err != nil { + return nil, err + } + data, err := pdf.Create(pdf.Spec{Title: a.Title, Pages: []pdf.Page{{Blocks: d.PDF}}}) if err != nil { return nil, refuse("the PDF cannot be written: %v", err) } + if err := w.ctx.Err(); err != nil { + return nil, err + } return w.store(a.Path, ".pdf", data, 0) } diff --git a/addons/office/internal/tools/definitions.go b/addons/office/internal/tools/definitions.go index 51dbb5b42..98b01b615 100644 --- a/addons/office/internal/tools/definitions.go +++ b/addons/office/internal/tools/definitions.go @@ -94,26 +94,12 @@ func Definitions() []contract.ToolDefinition { "bullets": list(obj{"type": "string"}, "add: the bullet lines.", 30), }, "op"), "The changes, at most 200.", maxEdits), }, "path", "edits")), - def(ToolCreateDocx, act, "Create a Word file from blocks. A block has a kind: heading (text, level 1 to 3), paragraph (text) or bullets (items). If the path is taken, the old file stays and the new one is saved next to it.", - object(obj{ - "path": pathProp(".docx"), - "title": str("The document title. Optional."), - "blocks": list(object(obj{ - "kind": enum("The kind of block.", "heading", "paragraph", "bullets"), - "level": integer("heading: 1 to 3."), - "text": str("heading and paragraph: the text."), - "items": list(obj{"type": "string"}, "bullets: the lines.", 100), - }, "kind"), "The blocks, in order.", 2000), - }, "path", "blocks")), - def(ToolCreateXlsx, act, "Create an Excel file. Each sheet has a name and rows of text values. A value starting with = is a formula, a number is stored as a number. The first row is the header. If the path is taken, the old file stays.", - object(obj{ - "path": pathProp(".xlsx"), - "title": str("The workbook title. Optional."), - "sheets": list(object(obj{ - "name": str("The sheet name, at most 31 characters."), - "rows": list(list(obj{"type": "string"}, "One row.", 200), "The rows, top to bottom.", 5000), - }, "name", "rows"), "The sheets.", 32), - }, "path", "sheets")), + def(ToolCreateDocx, act, "Create a Word document from Markdown. Supports headings, paragraphs, lists, bold, italic and simple tables. If the path is taken, the old file stays and the new one is saved next to it.", + object(obj{"path": pathProp(".docx"), "markdown": str("The markdown content."), "title": str("Optional title metadata; does not add a heading.")}, "path", "markdown")), + def(ToolCreateXlsx, act, "Create one Excel sheet from comma-separated CSV. Every nonempty value is text, never a formula or number. Sheet defaults to Sheet1. If the path is taken, the old file stays and the new one is saved next to it.", + object(obj{"path": pathProp(".xlsx"), "csv": str("The csv content."), "title": str("Optional title metadata; does not add a heading."), + "sheet": str("The sheet name. Optional; defaults to Sheet1."), + }, "path", "csv")), def(ToolCreatePptx, act, "Create a PowerPoint file from slides, each with a title and bullet lines. A slide without bullets is a title slide. If the path is taken, the old file stays and the new one is saved next to it.", object(obj{ "path": pathProp(".pptx"), @@ -123,18 +109,7 @@ func Definitions() []contract.ToolDefinition { "bullets": list(obj{"type": "string"}, "The bullet lines.", 30), }, "title"), "The slides, in order.", 200), }, "path", "slides")), - def(ToolCreatePdf, act, "Create a PDF file from blocks. A block has a kind: heading (text, level 1 to 3), paragraph (text), bullets (items) or table (header and rows). The text must use Latin-1 characters only. If the path is taken, the old file stays and the new one is saved next to it.", - object(obj{ - "path": pathProp(".pdf"), - "title": str("The document title. Optional."), - "blocks": list(object(obj{ - "kind": enum("The kind of block.", "heading", "paragraph", "bullets", "table"), - "level": integer("heading: 1 to 3."), - "text": str("heading and paragraph: the text."), - "items": list(obj{"type": "string"}, "bullets: the lines.", 100), - "header": list(obj{"type": "string"}, "table: the column titles.", 20), - "rows": list(list(obj{"type": "string"}, "One row.", 20), "table: the rows, top to bottom.", 500), - }, "kind"), "The blocks, in order.", 2000), - }, "path", "blocks")), + def(ToolCreatePdf, act, "Create a PDF from Markdown headings, paragraphs, lists, bold, italic and simple tables. Text must use Latin-1 characters only. If the path is taken, the old file stays and the new one is saved next to it.", + object(obj{"path": pathProp(".pdf"), "markdown": str("The markdown content."), "title": str("Optional title metadata; does not add a heading.")}, "path", "markdown")), } } diff --git a/addons/office/internal/tools/manifest_test.go b/addons/office/internal/tools/manifest_test.go index 5dba0c54d..d99753d13 100644 --- a/addons/office/internal/tools/manifest_test.go +++ b/addons/office/internal/tools/manifest_test.go @@ -49,6 +49,43 @@ var _ = Describe("manifest", func() { } }) + It("advertises exactly the three string-only text creation contracts", func() { + raw, err := os.ReadFile(manifestPath) + Expect(err).NotTo(HaveOccurred()) + m, err := contract.ParseManifest(raw) + Expect(err).NotTo(HaveOccurred()) + for _, defs := range [][]contract.ToolDefinition{tools.Definitions(), m.Capabilities.ToolDefinitions} { + seen := 0 + for _, d := range defs { + field := "markdown" + keys := []string{"path", "markdown", "title"} + switch d.Name { + case tools.ToolCreateDocx, tools.ToolCreatePdf: + case tools.ToolCreateXlsx: + field, keys = "csv", []string{"path", "csv", "sheet", "title"} + default: + continue + } + seen++ + var schema map[string]any + Expect(json.Unmarshal(d.InputSchema, &schema)).To(Succeed()) + Expect(schema["type"]).To(Equal("object")) + Expect(schema["additionalProperties"]).To(Equal(false)) + Expect(schema["required"]).To(ConsistOf("path", field)) + props := schema["properties"].(map[string]any) + Expect(props).To(HaveLen(len(keys))) + for _, key := range keys { + Expect(props).To(HaveKey(key)) + Expect(props[key].(map[string]any)["type"]).To(Equal("string")) + } + for _, forbidden := range []string{`"array"`, `"items"`, `"oneOf"`, `"anyOf"`, `"allOf"`, `"blocks"`, `"sheets"`, `"rows"`, "2000"} { + Expect(string(d.InputSchema)).NotTo(ContainSubstring(forbidden), d.Name) + } + } + Expect(seen).To(Equal(3)) + } + }) + It("makes only the edit and create tools actions", func() { for _, d := range tools.Definitions() { wantAction := d.Name[:4] == "edit" || d.Name[:6] == "create" diff --git a/addons/office/internal/tools/text_creation_test.go b/addons/office/internal/tools/text_creation_test.go new file mode 100644 index 000000000..e7ed9da3a --- /dev/null +++ b/addons/office/internal/tools/text_creation_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +package tools_test + +import ( + "archive/zip" + "bytes" + "context" + "github.com/mudler/LocalAI/addons/internal/protocol" + "github.com/mudler/LocalAI/pkg/addons/contract" + "io" + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/addons/office/internal/tools" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func creationZip(data []byte, name string) string { + z, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + Expect(err).NotTo(HaveOccurred()) + for _, f := range z.File { + if f.Name == name { + r, err := f.Open() + Expect(err).NotTo(HaveOccurred()) + defer func() { Expect(r.Close()).To(Succeed()) }() + b, err := io.ReadAll(r) + Expect(err).NotTo(HaveOccurred()) + return string(b) + } + } + Fail("missing zip member: " + name) + return "" +} + +type deniedCreationHost struct{ *fakeHost } + +func (h deniedCreationHost) Do(_ context.Context, _ string, op string, _, _ any) error { + h.calls = append(h.calls, op) + return &contract.HostError{Code: contract.HostScope, Message: "denied"} +} + +var _ = Describe("text creation contracts", func() { + It("honors cancellation and host permission denial without workspace or scratch output", func() { + for _, tc := range []struct{ tool, path, field, text string }{{tools.ToolCreateDocx, "/a.docx", "markdown", "hello"}, {tools.ToolCreatePdf, "/a.pdf", "markdown", "hello"}, {tools.ToolCreateXlsx, "/a.xlsx", "csv", "00123"}} { + for _, cancelled := range []bool{true, false} { + h := newHost() + var engine tools.Engine = deniedCreationHost{h} + ctx := context.Background() + if cancelled { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + engine = h + } + rt := tools.New(engine, h.dir).(contract.ActRuntime) + res, err := rt.Act(ctx, env(tc.tool, map[string]any{"path": tc.path, tc.field: tc.text})) + if cancelled { + Expect(err).To(MatchError(context.Canceled)) + Expect(h.calls).To(BeEmpty()) + } else { + Expect(err).To(MatchError(ContainSubstring(protocol.ErrForbidden.Error()))) + Expect(h.calls).To(ConsistOf("workspace.import")) + } + Expect(res.Payload).To(BeEmpty()) + Expect(h.files).To(BeEmpty()) + entries, err := os.ReadDir(filepath.Join(h.dir, "work")) + if !os.IsNotExist(err) { + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(BeEmpty()) + } + } + } + }) + + It("creates styled DOCX and PDF from Markdown in the workspace", func() { + h := newHost() + md := "# Summary\n\nA **bold** and *italic* paragraph.\n\n1. first\n2. second\n\n| Name | Value |\n| --- | --- |\n| item | 00123 |\n" + for _, tc := range []struct{ tool, path string }{{tools.ToolCreateDocx, "/text.docx"}, {tools.ToolCreatePdf, "/text.pdf"}} { + o, raw := call(h, tc.tool, map[string]any{"path": tc.path, "markdown": md, "title": "Metadata only"}) + Expect(o.OK).To(BeTrue(), raw) + } + xml := creationZip(h.files["/text.docx"], "word/document.xml") + for _, s := range []string{"Heading1", " 0 && len(a.ToolDefinitions) > 0 { + if a.Enabled && a.State == "running" && a.Generation > 0 && (len(a.ToolDefinitions) > 0 || (a.ID == "tables" && t.hasResourceLister())) { + if err := validateTablesListing(a); err != nil { + return nil, err + } out = append(out, a) } } @@ -494,6 +497,9 @@ func (t *ToolAdapter) Discover(ctx context.Context, actor contract.Actor) ([]Too } var out []ToolDefinition for _, a := range list { + if a.ID == "tables" && t.hasResourceLister() && len(out) < MaxTools { + out = append(out, databaseListingDefinition()) + } for _, d := range a.ToolDefinitions { if d.Kind == contract.ToolKindAction && !t.actions { continue @@ -571,6 +577,9 @@ func (t *ToolAdapter) execute(ctx context.Context, actor contract.Actor, convers if a.ID != addonID { continue } + if addonID == "tables" && tool == "databases-list" && t.hasResourceLister() { + return t.listDatabases(ctx, actor, name, arguments) + } for _, d := range a.ToolDefinitions { if d.Name != tool { continue @@ -588,6 +597,11 @@ func (t *ToolAdapter) execute(ctx context.Context, actor contract.Actor, convers } resource, body, err := toolargs.Bind(def.Tool, def.Parameters, arguments, conversationID != "") if err != nil { + if errors.Is(err, ErrInvalidArguments) { + if message := officeCreationDiagnostic(addonID, tool, arguments); message != "" { + return ToolResult{Kind: ResultError, Tool: name, Code: CodeInvalidRequest, Message: message}, nil + } + } return ToolResult{}, err } if resource == "" && conversationID != "" && requiresResource(def) { diff --git a/core/services/addons/tools/database_listing.go b/core/services/addons/tools/database_listing.go new file mode 100644 index 000000000..bab2e1727 --- /dev/null +++ b/core/services/addons/tools/database_listing.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +package tools + +import ( + "context" + "encoding/json" + "slices" + "strings" + + "github.com/mudler/LocalAI/core/services/addons/apps" + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/core/services/addons/toolargs" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// ResourceLister is the optional host-owned read boundary. It does not expose +// resource management or let the add-on executable enumerate caller grants. +type ResourceLister interface { + ListResources(context.Context, contract.Actor, string) (gateway.ResourceList, error) +} + +var _ ResourceLister = (*gateway.Gateway)(nil) + +func (t *ToolAdapter) hasResourceLister() bool { + _, ok := t.backend.(ResourceLister) + return ok +} + +func databaseListingDefinition() ToolDefinition { + return ToolDefinition{Name: ToolName("tables", "databases-list"), AddonID: "tables", Tool: "databases-list", Kind: contract.ToolKindRead, + Description: "List databases you may read. At most 100, ordered by resource ID; truncated means incomplete. No pagination. Inspect a chosen database before querying; never create one as a discovery fallback.", + Parameters: map[string]any{"type": "object", "properties": map[string]any{}, "additionalProperties": false}} +} + +func validateTablesListing(a apps.App) error { + if a.ID == "tables" { + for _, d := range a.ToolDefinitions { + if d.Name == "databases-list" { + return gateway.ErrInvalidRequest + } + } + } + return nil +} + +func (t *ToolAdapter) listDatabases(ctx context.Context, actor contract.Actor, name string, arguments json.RawMessage) (ToolResult, error) { + def := databaseListingDefinition() + if _, _, err := toolargs.Bind(def.Tool, def.Parameters, arguments, false); err != nil { + return ToolResult{}, err + } + list, err := t.backend.(ResourceLister).ListResources(ctx, actor, "tables") + if err != nil { + return ToolResult{}, err + } + if list.AddonID != "" && list.AddonID != "tables" { + return ToolResult{}, gateway.ErrInvalidRequest + } + type database struct { + ResourceID string `json:"resourceId"` + Name string `json:"name,omitempty"` + Role string `json:"role"` + } + rows := make([]database, 0, len(list.Resources)) + seen := make(map[string]bool, len(list.Resources)) + for _, r := range list.Resources { + if !toolargs.ValidResourceID(r.ID) || seen[r.ID] || (r.AddonID != "" && r.AddonID != "tables") || (r.Role != "viewer" && r.Role != "editor" && r.Role != "owner") { + return ToolResult{}, gateway.ErrInvalidRequest + } + seen[r.ID] = true + rows = append(rows, database{r.ID, r.Name, r.Role}) + } + slices.SortFunc(rows, func(a, b database) int { return strings.Compare(a.ResourceID, b.ResourceID) }) + truncated := list.Truncated || len(rows) > 100 + if len(rows) > 100 { + rows = rows[:100] + } + payload, err := json.Marshal(struct { + Databases []database `json:"databases"` + Truncated bool `json:"truncated"` + }{rows, truncated}) + if err != nil { + return ToolResult{}, gateway.ErrInvalidRequest + } + return readResult(name, contract.Result{Payload: payload}) +} diff --git a/core/services/addons/tools/database_listing_test.go b/core/services/addons/tools/database_listing_test.go new file mode 100644 index 000000000..2ed5bfe46 --- /dev/null +++ b/core/services/addons/tools/database_listing_test.go @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +package tools_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/http/auth" + mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp" + "strings" + + "github.com/mudler/LocalAI/core/services/addons/apps" + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/core/services/addons/tools" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const databaseListName = "addon__tables__databases-list" + +type listingBackend struct { + *fake + resources gateway.ResourceList + err error + actors []contract.Actor + ids []string + selections int +} + +func (b *listingBackend) ListResources(_ context.Context, a contract.Actor, id string) (gateway.ResourceList, error) { + b.actors = append(b.actors, a) + b.ids = append(b.ids, id) + return b.resources, b.err +} +func (b *listingBackend) ResolveSelection(context.Context, contract.Actor, string, string) (string, error) { + b.selections++ + return "selected", nil +} + +var _ = Describe("Tables host database listing", func() { + ctx := context.Background() + var b *listingBackend + var a *tools.ToolAdapter + BeforeEach(func() { + b = &listingBackend{fake: newFake(), resources: gateway.ResourceList{AddonID: "tables"}} + a = tools.NewToolAdapter(b) + }) + It("synthesizes exactly one strict host read, without enumerating resources at discovery", func() { + defs, err := a.Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + var found []tools.ToolDefinition + for _, d := range defs { + if d.Name == databaseListName { + found = append(found, d) + } + } + Expect(found).To(HaveLen(1)) + d := found[0] + Expect(d.AddonID).To(Equal("tables")) + Expect(d.Tool).To(Equal("databases-list")) + Expect(d.Kind).To(Equal(contract.ToolKindRead)) + Expect(d.Parameters).To(Equal(map[string]any{"type": "object", "properties": map[string]any{}, "additionalProperties": false})) + Expect(b.ids).To(BeEmpty()) + }) + It("projects only opaque IDs, optional names and caller roles through a normal data envelope", func() { + n := 4 + b.resources = gateway.ResourceList{AddonID: "tables", Generation: 9, Sharing: true, Operations: []string{"delete"}, Resources: []gateway.ResourceInfo{ + {ID: "b", AddonID: "tables", Role: "editor", Owner: "private", SharedWith: &n}, + {ID: "a", AddonID: "tables", Name: "Budget", Role: "viewer"}, + {ID: "c", AddonID: "tables", Name: "Budget", Role: "owner"}, + }} + out, err := a.ExecuteIn(ctx, member, "conv1", databaseListName, args(`{}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(out.Kind).To(Equal(tools.ResultData)) + Expect(out.Tool).To(Equal(databaseListName)) + Expect(string(out.Result)).To(MatchJSON(`{"databases":[{"resourceId":"a","name":"Budget","role":"viewer"},{"resourceId":"b","role":"editor"},{"resourceId":"c","name":"Budget","role":"owner"}],"truncated":false}`)) + Expect(b.actors).To(Equal([]contract.Actor{member})) + Expect(b.ids).To(Equal([]string{"tables"})) + Expect(b.calls()).To(BeZero()) + Expect(b.selections).To(BeZero()) + }) + It("returns an array even for empty success and truthfully bounds sorted 100/101 results", func() { + for _, n := range []int{0, 100, 101} { + for _, truncated := range []bool{false, true} { + b.resources.Resources = nil + b.resources.Truncated = truncated + for i := n; i > 0; i-- { + b.resources.Resources = append(b.resources.Resources, gateway.ResourceInfo{ID: fmt.Sprintf("db-%03d", i), Role: "owner"}) + } + out, err := a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).NotTo(HaveOccurred()) + var doc struct { + Databases []struct { + ResourceID string `json:"resourceId"` + } + Truncated bool + } + Expect(json.Unmarshal(out.Result, &doc)).To(Succeed()) + Expect(doc.Databases).NotTo(BeNil()) + Expect(doc.Databases).To(HaveLen(min(n, 100))) + Expect(doc.Truncated).To(Equal(truncated || n > 100)) + if n > 0 { + Expect(doc.Databases[0].ResourceID).To(Equal("db-001")) + } + } + } + }) + It("rejects every input property and non-object rather than resolving selection", func() { + for _, input := range []string{`{"resourceId":"a"}`, `{"cursor":"x"}`, `{"offset":0}`, `{"addonId":"tables"}`, `{"name":"x"}`, `{"role":"owner"}`, `{"actor":{}}`, `{"tool":"databases-list"}`, `null`, `[]`, `""`} { + _, err := a.ExecuteIn(ctx, member, "conv1", databaseListName, args(input)) + Expect(err).To(HaveOccurred(), input) + } + Expect(b.ids).To(BeEmpty()) + Expect(b.calls()).To(BeZero()) + Expect(b.selections).To(BeZero()) + }) + It("fails closed without the optional backend interface and never synthesizes for other add-ons", func() { + plain := tools.NewToolAdapter(newFake()) + defs, err := plain.Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + for _, d := range defs { + Expect(d.Name).NotTo(Equal(databaseListName)) + } + _, err = plain.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(HaveOccurred()) + b.list.Apps[0].ID = "notes" + defs, err = a.Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + for _, d := range defs { + Expect(d.Tool).NotTo(Equal("databases-list")) + } + }) + It("rejects a reserved Tables manifest collision in discovery and execution even if not advertised", func() { + b.list.Apps[0].ToolDefinitions = append(b.list.Apps[0].ToolDefinitions, contract.ToolDefinition{Name: "databases-list", Kind: contract.ToolKindRead, UIOnly: true}) + _, err := a.Discover(ctx, member) + Expect(err).To(HaveOccurred()) + _, err = a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(HaveOccurred()) + Expect(b.ids).To(BeEmpty()) + Expect(b.calls()).To(BeZero()) + }) + It("rechecks missing, hidden, disabled, stopped and uninitialized Tables at execution", func() { + for _, mutate := range []func(){func() { b.list.Apps = nil }, func() { b.list.Apps[0].Enabled = false }, func() { b.list.Apps[0].State = "stopped" }, func() { b.list.Apps[0].Generation = 0 }} { + b.list = newFake().list + _, err := a.Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + mutate() + _, err = a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(MatchError(tools.ErrUnknownTool)) + defs, err := a.Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + Expect(defs).To(BeEmpty()) + } + Expect(b.ids).To(BeEmpty()) + }) + It("preserves typed credential, feature, owner, forwarding and registry failures after discovery", func() { + for _, failure := range []error{gateway.ErrForbidden, gateway.ErrNotPermitted, gateway.ErrUnavailable, gateway.ErrResourceUnreachable} { + b.err = nil + _, err := a.Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + b.err = failure + out, err := a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(MatchError(failure)) + Expect(out.Result).To(BeEmpty()) + b.appsErr = failure + _, err = a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(HaveOccurred()) + b.appsErr = nil + } + }) + It("rejects missing principals and malformed projections instead of successful empty data", func() { + for _, actor := range []contract.Actor{{}, {UserID: "bob", Mode: "named-user"}, {UserID: "bob", CredentialID: "x", Mode: "anonymous"}} { + _, err := a.Execute(ctx, actor, databaseListName, args(`{}`)) + Expect(err).To(MatchError(gateway.ErrForbidden)) + } + for _, r := range []gateway.ResourceInfo{{Role: "owner"}, {ID: "a", Role: "admin"}, {ID: "a", Role: ""}, {ID: "a", Role: "viewer", AddonID: "notes"}} { + b.resources.Resources = []gateway.ResourceInfo{r} + _, err := a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(HaveOccurred()) + } + b.resources.Resources = []gateway.ResourceInfo{{ID: "a", Role: "viewer"}, {ID: "a", Role: "owner"}} + _, err := a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(HaveOccurred()) + }) + It("keeps synthesized listing behind consumer allowlists and hidden add-ons", func() { + exec, err := mcpTools.NewAddonToolExecutor(ctx, a, member) + Expect(err).NotTo(HaveOccurred()) + for _, restricted := range []*mcpTools.AddonToolExecutor{exec.Only([]string{}), exec.Only([]string{"other"}), exec.Without(map[string]bool{"tables": true})} { + defs, err := restricted.DiscoverTools(ctx) + Expect(err).NotTo(HaveOccurred()) + for _, d := range defs { + Expect(d.Name).NotTo(Equal(databaseListName)) + } + _, err = restricted.ExecuteTool(ctx, databaseListName, `{}`) + Expect(err).To(MatchError(tools.ErrUnknownTool)) + } + Expect(b.ids).To(BeEmpty()) + _, err = exec.Only([]string{"tables"}).ExecuteTool(ctx, databaseListName, `{}`) + Expect(err).NotTo(HaveOccurred()) + Expect(b.actors).To(Equal([]contract.Actor{member})) + Expect(b.calls()).To(BeZero()) + }) + It("does not let a listing-capable provider invent a named-user principal", func() { + provider := mcpTools.NewAddonToolProvider(a, auth.AddonModeNamedUser) + r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + r.Header.Set("X-Forwarded-User", "alice") + r.Header.Set("X-LocalAI-Actor", "alice") + _, err := provider.ExecutorFor(echo.New().NewContext(r, httptest.NewRecorder())) + Expect(err).To(HaveOccurred()) + Expect(mcpTools.AddonToolsHTTPError(err).(*echo.HTTPError).Code).To(Equal(http.StatusForbidden)) + Expect(b.ids).To(BeEmpty()) + }) + It("revalidates listing failures through an already-bound consumer without namespace fallback", func() { + exec, err := mcpTools.NewAddonToolExecutor(ctx, a, member) + Expect(err).NotTo(HaveOccurred()) + other := &greedy{} + combined := mcpTools.CombineExecutors(other, exec) + for _, failure := range []error{gateway.ErrForbidden, gateway.ErrNotPermitted, gateway.ErrUnavailable} { + b.err = failure + _, err = combined.ExecuteTool(ctx, databaseListName, `{}`) + Expect(err).To(MatchError(failure)) + } + Expect(b.actors).To(Equal([]contract.Actor{member, member, member})) + Expect(other.ran).To(BeEmpty()) + Expect(b.calls()).To(BeZero()) + Expect(b.selections).To(BeZero()) + }) + It("preserves the model read byte bound", func() { + b.resources.Resources = []gateway.ResourceInfo{{ID: "a", Role: "viewer", Name: strings.Repeat("x", tools.MaxResultBytes)}} + _, err := a.Execute(ctx, member, databaseListName, args(`{}`)) + Expect(err).To(MatchError(tools.ErrResultTooLarge)) + }) + // A visible Tables installation can have no executable model tools: this host + // read depends on the registry, not on the executable's tool inventory. + It("offers the host read even when Tables has no executable definitions", func() { + b.list = apps.List{Apps: []apps.App{{ID: "tables", Enabled: true, State: "running", Generation: 1}}} + defs, err := a.Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + Expect(defs).To(HaveLen(1)) + Expect(defs[0].Name).To(Equal(databaseListName)) + }) +}) diff --git a/core/services/addons/tools/office_creation.go b/core/services/addons/tools/office_creation.go new file mode 100644 index 000000000..d664061f2 --- /dev/null +++ b/core/services/addons/tools/office_creation.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +package tools + +import ( + "encoding/json" + "fmt" + + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/core/services/addons/toolargs" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// Give old Office callers a migration hint without accepting their old schema. +// Structural and reserved-field refusals keep their existing semantics. +func officeCreationDiagnostic(addonID, tool string, raw json.RawMessage) string { + if addonID != "office" { + return "" + } + var legacy, replacement string + switch tool { + case "create-docx", "create-pdf": + legacy, replacement = "blocks", "markdown" + case "create-xlsx": + legacy, replacement = "sheets", "csv" + default: + return "" + } + if len(raw) == 0 || len(raw) > toolargs.MaxArgumentBytes || gateway.CheckToolArguments(raw) != nil { + return "" + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return "" + } + for key := range fields { + if contract.ForbiddenToolProperty(key) { + return "" + } + } + if _, ok := fields[legacy]; !ok { + return "" + } + return fmt.Sprintf("%s is no longer supported; use the %s string", legacy, replacement) +} diff --git a/core/services/addons/tools/office_creation_test.go b/core/services/addons/tools/office_creation_test.go new file mode 100644 index 000000000..1bc6cc5e5 --- /dev/null +++ b/core/services/addons/tools/office_creation_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +package tools_test + +import ( + "context" + "encoding/json" + "os" + + "github.com/mudler/LocalAI/core/services/addons/apps" + "github.com/mudler/LocalAI/core/services/addons/tools" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Office creation compatibility", func() { + It("names the replacement string for legacy arguments at the chat boundary without sending them", func() { + raw, err := os.ReadFile("../../../../addons/office/manifest.json") + Expect(err).NotTo(HaveOccurred()) + m, err := contract.ParseManifest(raw) + Expect(err).NotTo(HaveOccurred()) + f := &actFake{fake: &fake{list: apps.List{Apps: []apps.App{{ID: "office", Enabled: true, State: "running", Generation: 1, Tools: m.Capabilities.Tools, ToolDefinitions: m.Capabilities.ToolDefinitions}}}}} + a := tools.NewChatToolAdapter(f) + for _, tc := range []struct{ tool, legacy, field string }{{"create-docx", "blocks", "markdown"}, {"create-pdf", "blocks", "markdown"}, {"create-xlsx", "sheets", "csv"}} { + for _, mixed := range []bool{false, true} { + args := map[string]any{"path": "/new", tc.legacy: []any{map[string]any{"text": "PRIVATE"}}} + if mixed { + args[tc.field] = "new" + } + b, err := json.Marshal(args) + Expect(err).NotTo(HaveOccurred()) + res, err := a.ExecuteChat(context.Background(), member, tools.NewChatCall("bob", "conv1"), tools.ToolName("office", tc.tool), b) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Kind).To(Equal(tools.ResultError)) + Expect(res.Code).To(Equal(tools.CodeInvalidRequest)) + Expect(res.Message).To(ContainSubstring(tc.legacy)) + Expect(res.Message).To(ContainSubstring(tc.field)) + Expect(res.JSON()).NotTo(ContainSubstring("PRIVATE")) + } + } + Expect(f.acts).To(BeEmpty()) + Expect(f.calls()).To(BeZero()) + }) +}) diff --git a/core/services/studio/tables_skill_internal_test.go b/core/services/studio/tables_skill_internal_test.go new file mode 100644 index 000000000..45b84f747 --- /dev/null +++ b/core/services/studio/tables_skill_internal_test.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +package studio + +import ( + "context" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/core/services/addons/packages" + "github.com/mudler/LocalAI/core/services/addons/skilltree" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/skills" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Packaging tests cover artifact construction and installation integrity. This +// fixture starts at the verified receipt boundary and copies the real shipped +// skill into a temporary workspace; it never starts Tables or queries live data. +var _ = Describe("packaged Tables skill in a chat", func() { + It("syncs the verified instructions and rechecks runtime, chat switches and grants before loading", func() { + ctx := context.Background() + raw, err := os.ReadFile("../../../addons/tables/manifest.json") + Expect(err).NotTo(HaveOccurred()) + manifest, err := contract.ParseManifest(raw) + Expect(err).NotTo(HaveOccurred()) + source := os.DirFS("../../../addons/tables/skills") + verified, err := skilltree.Verify(source, manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(verified).To(HaveLen(1)) + installed := GinkgoT().TempDir() + Expect(os.CopyFS(filepath.Join(installed, "skills"), source)).To(Succeed()) + receipt := packages.PackageReceipt{Directory: installed, Manifest: manifest, Skills: verified} + tree, err := packages.SkillFS(receipt) + Expect(err).NotTo(HaveOccurred()) + data, err := fs.ReadFile(tree, "tables-readonly/SKILL.md") + Expect(err).NotTo(HaveOccurred()) + name, description, err := skills.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(name).To(Equal("tables-readonly")) + Expect(skills.Requires(data)).To(BeEmpty()) + body := strings.TrimSpace(strings.SplitN(string(data), "---", 3)[2]) + Expect(body).To(ContainSubstring("addon__tables__databases-list")) + + b := &skillBroker{} + svc, f := newSkillService(b) + Expect(f.SyncAddonSkills(ctx, "tables", tree)).To(Succeed()) + running := AddonSkillSet{AddonID: "tables", Title: "Tables", Skills: contract.SkillNames(manifest.Capabilities.SkillDefinitions), Running: true} + b.set(running) + rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + load := svc.loadSkillRun(userChatActor("alice", rec.ID), loadOpts{}) + args := json.RawMessage(`{"name":"tables-readonly"}`) + index := func(off, addonsOff []string, sc *scope) string { + prompt, _, _, _ := svc.skillTurn(ctx, "alice", off, addonsOff, sc, nil) + return prompt + } + allow := func() { + Expect(index(nil, nil, nil)).To(ContainSubstring("- " + name + ": " + description)) + got, err := load(ctx, args) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(body), "load actual instructions without frontmatter or command/file tools") + } + deny := func() { + got, err := load(ctx, args) + Expect(got).To(BeEmpty()) + Expect(err).To(MatchError(HavePrefix(`unknown skill "tables-readonly".`))) + } + allow() + // Disabled and stopped states are projected to Running=false by the adapter. + for _, state := range []string{"disabled", "stopped"} { + unavailable := running + unavailable.Running = false + b.set(unavailable) + Expect(index(nil, nil, nil)).NotTo(ContainSubstring("- tables-readonly:"), state) + deny() + b.set(running) + allow() + } + Expect(svc.SetAddonsOff(ctx, "alice", rec.ID, []string{"tables"})).To(Succeed()) + Expect(index(nil, []string{"tables"}, nil)).NotTo(ContainSubstring("- tables-readonly:")) + deny() + Expect(svc.SetAddonsOff(ctx, "alice", rec.ID, nil)).To(Succeed()) + Expect(svc.SetSkillsOff(ctx, "alice", rec.ID, []string{"addon:tables-readonly"})).To(Succeed()) + Expect(index([]string{"addon:tables-readonly"}, nil, nil)).NotTo(ContainSubstring("- tables-readonly:")) + deny() + Expect(svc.SetSkillsOff(ctx, "alice", rec.ID, nil)).To(Succeed()) + allow() + agentStore, err := agents.NewJSON("") + Expect(err).NotTo(HaveOccurred()) + svc.d.Agents = agentStore + agent := agents.Agent{ID: "tables-reader", Principal: "alice", Name: "Tables reader", Skills: []string{name}} + Expect(agentStore.Put(ctx, agent)).To(Succeed()) + agentRec, err := svc.Create(ctx, "alice", CreateInput{Model: "m", AgentID: agent.ID}) + Expect(err).NotTo(HaveOccurred()) + agentLoad := svc.loadSkillRun(userChatActor("alice", agentRec.ID), loadOpts{}) + for _, grant := range []bool{false, true, false} { + agent.Addons = nil + if grant { + agent.Addons = []string{"tables"} + } + Expect(agentStore.Put(ctx, agent)).To(Succeed()) + prompt := index(nil, nil, svc.scopeOf(ctx, agentRec)) + got, err := agentLoad(ctx, args) + if grant { + Expect(prompt).To(ContainSubstring("- tables-readonly:")) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(body)) + } else { + Expect(prompt).NotTo(ContainSubstring("- tables-readonly:")) + Expect(got).To(BeEmpty()) + Expect(err).To(MatchError(HavePrefix(`unknown skill "tables-readonly".`))) + } + } + // A revoked broker grant removes the root even though the synced bytes stay. + b.set() + Expect(index(nil, nil, nil)).NotTo(ContainSubstring("- tables-readonly:")) + deny() + b.set(running) + allow() + }) +}) diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index b052b249e..3d99a1ebb 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -383,7 +383,7 @@ consequences: On the first start, when the owner has no `sources.json` in the `addons` folder yet, LocalAI registers one library with the ID `official`. It is a YAML index of the -first-party add-ons (Tables, Voice, Face, Fine-tuning, Quantization, Studio, Talk, Office, +first-party add-ons (Tables, Voice, Face, Fine-tuning, Quantization, Studio, Talk, Simple Office, Sandbox and Web). The index has one entry for each enabled platform of each add-on, so an install on Linux (amd64 or arm64) and on macOS (Apple silicon or Intel) finds the add-ons for its own platform. The Sandbox add-on is Linux only. No add-on is built for Windows yet, so the @@ -426,7 +426,7 @@ is pushed and attached to the release when it is published (see ### Add-ons that are not built in -Studio, Talk, Voice, Face, Fine-tuning, Quantization, Office and Web are not part of the LocalAI +Studio, Talk, Voice, Face, Fine-tuning, Quantization, Simple Office and Web are not part of the LocalAI binary or image. A fresh install has none of them. The old links to the pages they replaced (see [Old links](#old-links)) open the add-on page, which says the add-on is not available until it is installed. @@ -4120,15 +4120,15 @@ An add-on that holds `sandbox=egress.` in the host operation setting (the defaul - `egress.check` (`act`, feature `chats`) takes `{host, port, kind, tls}`, with `kind` `connect` or `http`, and answers `{action, reason, allowPrivate, self}`, with `action` `allow` or `block`. An allow carries `self`: the addresses of the server as CIDR strings (its interfaces, loopback, and the addresses of the host of the external base URL), the same list `web_fetch` refuses. The add-on refuses them on every dial, besides the interfaces of its own machine, so a command cannot reach the LocalAI API through the server's public name or address. When a card asks the person (see the cards under [Network access](#network-access)), the call waits for the answer, at most 60 seconds, before it answers. The chat is the resource of the live envelope and the user is its actor: arguments named `chat`, `user`, `preset` or `allow`, or any other unknown name, are refused. The host writes the audit line of the verdict itself. The operation may wait up to 60 seconds for a card and has a 75-second timeout. - `egress.audit` (`act`, feature `chats`) takes `{events: [...]}`, at most 500 lines of `{host, port, kind, verdict, reason, addr, bytesUp, bytesDown, durationMs, at}`. `verdict` is `done` (an allowed connection ended; the bytes it moved) or `block` (the add-on refused it itself: an address that is private, the server's own or a metadata address, a TLS name that is not the host, a limit, or a connection while no call ran). -## Office +## Simple Office -The Office add-on reads and edits documents in the user's files, from a chat. It is a package like the others (`office` in the add-on library) and uses the `workspace.*` host operations to take a file in and give the result back. +Simple Office is a lightweight option for basic office-file work, without requiring Sandbox. It reads and edits documents in the user's files, from a chat. It is a package like the others (`office` in the add-on library) and uses the `workspace.*` host operations to take a file in and give the result back. | Tool | Kind | What it does | |---|---|---| | `read-docx`, `read-xlsx`, `read-pptx`, `read-pdf` | read | Return the text of a file: paragraphs with their index, cells row by row (a formula that has no stored result shows as the formula, such as `=SUM(A1:A2)`), slides with their shapes, pages. The answer is cut to about 48 KiB and says so. | | `edit-docx`, `edit-xlsx`, `edit-pptx` | action | Apply a list of changes (at most 200) to a file. All of them are saved, or none. | -| `create-docx`, `create-xlsx`, `create-pptx`, `create-pdf` | action | Make a new file. `create-pdf` takes headings, paragraphs, bullets and tables, and writes Latin-1 text with the built-in font; text in other scripts is refused with a reason. It needs no sandbox. | +| `create-docx`, `create-xlsx`, `create-pptx`, `create-pdf` | action | Make a new file. `create-docx` and `create-pdf` take a `markdown` string; `create-xlsx` takes a single-sheet `csv` string. All three take `path` and optional `title` strings; XLSX also accepts `sheet`. The former `blocks` and `sheets` arguments are no longer supported. PDF writes Latin-1 text with the built-in font. See [Simple Office](../simple-office) for the contract and limits. | The action tools run at once, as the user of the chat, with no confirmation, and only in a chat. Every tool, a read tool too, works with the chat's own access: in a chat whose agent works in one folder, `/` is that folder (see [Workspace operations](#workspace-operations)). Without accounts, the files are the same ones the chat and the Files page use. With accounts and `local-owner` mode, the `workspace.*` operations are refused, because the local owner is no account and has no files; use `named-user` mode for the Office and Sandbox add-ons. A file that someone else changed after the tool read it is never overwritten: the tool's version is saved as a second file next to it, and the answer says so. A file that is not of the type its name says, or a path outside the user's files, is refused with a reason the model can read. diff --git a/docs/content/features/simple-office.md b/docs/content/features/simple-office.md new file mode 100644 index 000000000..7c86d420a --- /dev/null +++ b/docs/content/features/simple-office.md @@ -0,0 +1,70 @@ +--- +title: "Simple Office" +weight: 35 +--- + +Simple Office is a lightweight add-on for basic Word, Excel, PowerPoint and +PDF file work in chats, without requiring Sandbox. Install `office` from the +add-on library. Its internal ID, tool names, settings and resource references +remain `office`; the visible name is **Simple Office**. Sandbox workflows remain +independent. There is no automatic fallback, routing or provider preference change. + +## Create documents with Markdown + +`create-docx` and `create-pdf` require string fields `path` and `markdown`. +Optional `title` is string metadata only; it does not add a visible heading. + +```json +{"path":"/reports/summary.docx","markdown":"# Summary\n\nA **bold** paragraph.\n\n- First\n- Second","title":"Report"} +``` + +Use a `.pdf` path with `create-pdf`. Both formats support headings, paragraphs, +bullet and numbered lists, bold/italic emphasis, and simple rectangular tables. +Inline and fenced code are literal text, never executed. Links show visible +labels without fetching their targets. Raw HTML, images and unsupported +constructs are rejected with an error. No network fetching, file inclusion, +macros, shell commands or external converters are used. + +Limits are 2000 document blocks, 100 items per list, 20 table columns and +500 body rows per table. The full JSON argument budget is 1 MiB; parsing is +bounded internally. PDF retains its built-in-font Latin-1 restrictions; +unsupported characters are refused before saving. No fonts or Unicode support +are added by this change. + +## Create a single-sheet workbook with CSV + +`create-xlsx` requires string fields `path` and `csv`; optional string `sheet` +defaults to `Sheet1`, and optional string `title` is workbook metadata. + +```json +{"path":"/reports/status.xlsx","csv":"Worker,Status\n00123,Healthy\n","sheet":"Status"} +``` + +CSV uses commas and standard quoting, including escaped quotes and multiline +quoted cells, with LF or CRLF line endings. There is no delimiter guessing. +Exactly one sheet is created. The first row is ordinary data, with no implicit +header styling. Every nonempty field is stored as a string: leading zeros and +values beginning with `=`, `+`, `-` or `@` are preserved, not inferred as numbers +or formulas. Empty cells stay blank at their original coordinates. Use +`edit-xlsx` afterwards for explicit numeric cells, formulas or additional sheets; +its existing behavior has not changed. + +Malformed quoting, ragged rows, only-empty content and invalid sheet names are +refused. Limits are 5000 rows, 200 columns and 100000 grid cells, including empty +fields, within the full 1 MiB JSON argument budget. + +## Compatibility change + +The three creation tools no longer accept the old `blocks` (DOCX/PDF) or +`sheets` (XLSX) structures. Update callers to `markdown` or `csv` respectively. +All advertised arguments of these three tools are strings; there is no alternate +structured-array creation contract. Read/edit tools and `create-pptx` are unchanged. +Multiple-sheet creation is not supported by `create-xlsx`. + +All paths are inside the caller's authorized workspace, not arbitrary host paths +or content URLs. Chat folder and read-only access rules still apply. Failed +parsing or rendering saves no new file. Existing files are never overwritten by +creation: a conflict saves the new result to a sibling path and reports it. +Cancellation, authorization, extension checks and atomic workspace storage remain +in force. These simplified catalog schemas are not evidence of live model tool +selection or of general native grammar-parser correctness. diff --git a/docs/content/features/tables.md b/docs/content/features/tables.md index 487aed6e2..ee4864a42 100644 --- a/docs/content/features/tables.md +++ b/docs/content/features/tables.md @@ -77,7 +77,9 @@ a grant. Administrators who install the addon get no access to databases. ## Tools -The manifest declares these tools. LocalAI rejects any other tool name. +The manifest declares the executable tools below. The one host-owned exception +is `addon__tables__databases-list`, described in [Database discovery](#database-discovery). +That name is reserved: a Tables manifest cannot redefine it. | Tool | Kind | Effect | |---|---|---| @@ -101,10 +103,38 @@ The manifest declares these tools. LocalAI rejects any other tool name. Tool names use hyphens because LocalAI manifests accept only `[a-z][a-z0-9-]*` identifiers. -There is no tool that lists every database. The addon only knows the IDs that -LocalAI sends it. LocalAI lists the databases a person may see from its own -resource records, so a listing can never show a database the person has no -grant for. +### Database discovery + +`addon__tables__databases-list` takes exactly `{}` and reads the host's resource +registry, not the Tables executable. It is available only through existing +authorized actor-bound consumers when Tables is visible, enabled and running, +and the backend supports host resource listing. Existing chat opt-ins, hidden +add-ons and agent allowlists still apply. Shared MCP sessions and consumers +without a principal do not gain access. + +The normal bounded data result contains only database IDs, optional display +names and the caller's role: + +```json +{"databases":[{"resourceId":"opaque-id","name":"Budget","role":"viewer"}],"truncated":false} +``` + +Only active databases owned by or shared with the caller are listed; installing +Tables grants no data access. Credentials and grants are rechecked on each call. +Permission, registry and owner/transport failures remain errors, not empty lists. +A successful empty result has `databases: []`. + +Results are ordered by resource ID and limited to 100 databases. There is no +pagination, cursor, search or total. `truncated: true` means the list is +incomplete; an omitted database is not evidence that it does not exist. Names +may be absent or duplicated: use opaque IDs, inspect the chosen database with +`addon__tables__databases-inspect`, then query its actual tables and columns. +Ask for clarification when the choice is ambiguous; never create a database +or change the chat's selection as a discovery fallback. Inspect and query +independently reauthorize the ID. + +This projected host read does not expose resource management, sharing, +selection changes or mutations as discovery tools. In the chats (`/api/chats`), the agents and the triggers, a change made with these tools is applied at once: turning Tables on for the chat or the agent is @@ -114,15 +144,24 @@ and a repeated operation applies once. Because nothing waits for a confirmation, undo is the safety net: Tables keeps a change set for every row and schema change a chat makes, and the chat shows an Undo button on it (see [Undo of a chat change](#undo-of-a-chat-change)). On every other surface (the OpenAI-compatible -API, MCP, an API key), a change made from chat is a proposal: the model asks, -LocalAI previews the change, and a person confirms it in the host confirmation -card. A rename uses -the standard proposal path. Creating a database from chat is a proposal too -(`databases-create` with a `name`, no resource ID). LocalAI reserves a pending -resource for the person who asked, shows the requested name in the card, and -creates the database only when that person confirms it. Cancelling or letting -the proposal expire removes the reservation. Sharing and deleting a database -are not tools. +API, MCP, an API key), changes normally return proposals for human confirmation. +Authorized server-side chats can also apply database creation and schema changes +with undo. Check the actual result kind: `proposal` is not completion; `applied` +confirms a change. The model cannot manufacture approval or identity arguments. +Sharing and whole-database deletion are not model tools. + +## Bundled discovery skill + +Tables packages the `tables-readonly` skill for structured-data questions and +table searches. Load it to learn the workflow: list existing databases when +needed, disambiguate names, inspect tables and columns, then query relevant rows +or aggregate totals. Database listing is bounded to 100 and has no pagination; +row queries have their own cursor. + +The skill needs no command runner. Existing installation, running-state, chat +switch and agent-grant gates control its availability. Its read-only guidance +does not revoke other tools granted to the chat. It never creates a database or +changes conversation selection as a discovery fallback. ## Model tools and approval cards diff --git a/gallery/addons/index.yaml b/gallery/addons/index.yaml index 9f6ced864..a963f82c2 100644 --- a/gallery/addons/index.yaml +++ b/gallery/addons/index.yaml @@ -195,8 +195,8 @@ - "fine-tune-stop" - "fine-tune-export" - id: "office" - name: Office - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + name: Simple Office + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." version: "0.0.0-development" channel: "development" artifact: "oci://quay.io/go-skynet/local-ai-addons:office-development" @@ -208,8 +208,8 @@ issuer: "https://token.actions.githubusercontent.com" identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" ui: - title: "Office" - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + title: "Simple Office" + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." mainPage: "office" icon: "ui/icon.png" toolCount: 11 @@ -231,8 +231,8 @@ - "pptx" - "pdf" - id: "office" - name: Office - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + name: Simple Office + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." version: "0.0.0-development" channel: "development" artifact: "oci://quay.io/go-skynet/local-ai-addons:office-development" @@ -244,8 +244,8 @@ issuer: "https://token.actions.githubusercontent.com" identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" ui: - title: "Office" - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + title: "Simple Office" + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." mainPage: "office" icon: "ui/icon.png" toolCount: 11 @@ -267,8 +267,8 @@ - "pptx" - "pdf" - id: "office" - name: Office - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + name: Simple Office + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." version: "0.0.0-development" channel: "development" artifact: "oci://quay.io/go-skynet/local-ai-addons:office-development" @@ -280,8 +280,8 @@ issuer: "https://token.actions.githubusercontent.com" identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" ui: - title: "Office" - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + title: "Simple Office" + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." mainPage: "office" icon: "ui/icon.png" toolCount: 11 @@ -303,8 +303,8 @@ - "pptx" - "pdf" - id: "office" - name: Office - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + name: Simple Office + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." version: "0.0.0-development" channel: "development" artifact: "oci://quay.io/go-skynet/local-ai-addons:office-development" @@ -316,8 +316,8 @@ issuer: "https://token.actions.githubusercontent.com" identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" ui: - title: "Office" - description: "Read and edit Word, Excel, PowerPoint and PDF files from your chats." + title: "Simple Office" + description: "Lightweight basic Word, Excel, PowerPoint and PDF file work from your chats, without requiring Sandbox." mainPage: "office" icon: "ui/icon.png" toolCount: 11 diff --git a/gallery/addons/office.yaml.tmpl b/gallery/addons/office.yaml.tmpl index 475ae6ff8..e16654994 100644 --- a/gallery/addons/office.yaml.tmpl +++ b/gallery/addons/office.yaml.tmpl @@ -1,5 +1,5 @@ id: "{{addon}}" -name: Office +name: Simple Office description: "{{description}}" version: "{{version}}" channel: "{{channel}}" diff --git a/pkg/addons/packaging/packaging_test.go b/pkg/addons/packaging/packaging_test.go index 442c63da2..6d189764d 100644 --- a/pkg/addons/packaging/packaging_test.go +++ b/pkg/addons/packaging/packaging_test.go @@ -50,7 +50,7 @@ var _ = Describe("package layout", func() { roles = append(roles, l.Role) Expect(l.MediaType).To(Equal("application/vnd.oci.image.layer.v1.tar+gzip")) } - Expect(roles).To(Equal([]string{"executable", "ui", "manifest"})) + Expect(roles).To(Equal([]string{"executable", "ui", "skills", "manifest"})) Expect(ra.Publishable).To(BeTrue()) Expect(p.verify(a, amd, "1.2.3")).To(Succeed()) other := p.mustBuild(amd, "1.2.3", "other bytes", "--revision", rev) @@ -176,7 +176,7 @@ var _ = Describe("package rules", func() { extra := append(append([]layerSpec{}, s...), layerSpec{role: "extra", files: []tfile{{name: "extra.bin", data: []byte("x"), mode: 0o644}}}) rewrite(dir, extra, nil) expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYERS") - s = readSpec(dir)[:3] + s = readSpec(dir)[:4] s[0], s[1] = s[1], s[0] rewrite(dir, s, nil) expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYERS") @@ -930,6 +930,9 @@ var _ = Describe("catalog template", func() { Expect(entries).To(HaveLen(2), id) for _, e := range entries { Expect(e.ID).To(Equal(id)) + if id == "office" { + Expect(e.Name).To(Equal("Simple Office")) + } Expect(e.Artifact).To(Equal("oci://" + repoName + ":" + id + "-1.2.3")) Expect(e.Policy.RequireSignature).To(BeTrue()) } diff --git a/pkg/addons/packaging/tables_readonly_skill_test.go b/pkg/addons/packaging/tables_readonly_skill_test.go new file mode 100644 index 000000000..757ec4f8d --- /dev/null +++ b/pkg/addons/packaging/tables_readonly_skill_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +package packaging + +import ( + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/core/services/addons/packages" + "github.com/mudler/LocalAI/core/services/addons/skilltree" + "github.com/mudler/LocalAI/core/services/studio/skills" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tables read-only skill", func() { + It("declares and packages a loadable, receipt-verified skill without shadows", func() { + matrix, err := loadPlatforms("../../../addons/tables/package/platforms.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(matrix.Skills).To(Equal("addons/tables/skills")) + var platforms []string + for _, p := range matrix.Platforms { + Expect(p.Enabled).To(BeTrue()) + platforms = append(platforms, p.Platform) + } + Expect(platforms).To(ConsistOf("linux/amd64", "linux/arm64", "darwin/amd64", "darwin/arm64")) + + p := newProject(map[string][]string{"linux/amd64": nil}) + dir := p.mustBuild("linux/amd64", "1.2.3", "fixture tables binary") + Expect(p.verify(dir, "linux/amd64", "1.2.3")).To(Succeed()) + leg, _, err := loadLeg(dir) + Expect(err).NotTo(HaveOccurred()) + files, err := leg.openLayers() + Expect(err).NotTo(HaveOccurred()) + installed := GinkgoT().TempDir() + var manifest contract.Manifest + var skillFiles []string + for _, f := range files { + if f.role == roleManifest { + manifest, err = contract.ParseManifest(f.data) + Expect(err).NotTo(HaveOccurred()) + } + if f.role != roleSkills { + continue + } + skillFiles = append(skillFiles, f.name) + dest := filepath.Join(installed, filepath.FromSlash(f.name)) + Expect(os.MkdirAll(filepath.Dir(dest), 0o755)).To(Succeed()) + Expect(os.WriteFile(dest, f.data, 0o644)).To(Succeed()) + } + Expect(skillFiles).To(Equal([]string{"skills/tables-readonly/SKILL.md"})) + Expect(manifest.Capabilities.SkillDefinitions).To(Equal([]contract.SkillDefinition{{Name: "tables-readonly"}})) + verified, err := skilltree.Verify(os.DirFS(filepath.Join(installed, "skills")), manifest) + Expect(err).NotTo(HaveOccurred()) + Expect(verified).To(HaveLen(1)) + receipt := packages.PackageReceipt{Directory: installed, Skills: verified} + loaded, err := packages.SkillFS(receipt) + Expect(err).NotTo(HaveOccurred()) + data, err := skilltree.ReadBounded(loaded, "tables-readonly/SKILL.md", skills.MaxFileSize) + Expect(err).NotTo(HaveOccurred()) + name, description, err := skills.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(name).To(Equal("tables-readonly")) + Expect(description).To(ContainSubstring("structured data")) + Expect(skills.Requires(data)).To(BeEmpty()) + Expect(skills.Index([]skills.Skill{{Name: name, Description: description}})).To(ContainSubstring("tables-readonly")) + Expect(os.WriteFile(filepath.Join(installed, "skills/tables-readonly/SKILL.md"), append(data, '\n'), 0o644)).To(Succeed()) + _, err = packages.SkillFS(receipt) + Expect(err).To(HaveOccurred(), "modified installed skill must not load") + }) + + It("rejects a missing skill, mismatched frontmatter, and scripts", func() { + p := newProject(map[string][]string{"linux/amd64": nil}) + file := filepath.Join(p.root, "addons/tables/skills/tables-readonly/SKILL.md") + data, err := os.ReadFile(file) + Expect(err).NotTo(HaveOccurred()) + Expect(os.Remove(file)).To(Succeed()) + _, err = p.build("linux/amd64", "1.2.3", exe("tables binary")) + Expect(err).To(HaveOccurred()) + Expect(os.WriteFile(file, []byte(strings.Replace(string(data), "name: tables-readonly", "name: wrong-name", 1)), 0o644)).To(Succeed()) + _, err = p.build("linux/amd64", "1.2.3", exe("tables binary")) + Expect(err).To(MatchError(ContainSubstring("names the skill wrong-name"))) + Expect(os.WriteFile(file, data, 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(filepath.Dir(file), "run.py"), []byte("print(1)\n"), 0o644)).To(Succeed()) + _, err = p.build("linux/amd64", "1.2.3", exe("tables binary")) + Expect(err).To(MatchError(ContainSubstring("no scripts"))) + }) +}) diff --git a/scripts/addons/recovery.sh b/scripts/addons/recovery.sh index 742532e0f..01c846f43 100755 --- a/scripts/addons/recovery.sh +++ b/scripts/addons/recovery.sh @@ -76,6 +76,7 @@ rm -rf "$fixture_dir" mkdir -p "$fixture_dir/tables" cp "$root/addons/tables/manifest.json" "$fixture_dir/tables/manifest.json" cp -R "$pkg/dist/ui" "$fixture_dir/tables/ui" +cp -R "$root/addons/tables/skills" "$fixture_dir/tables/skills" free_port() { python3 -I -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'; } named_port=$(free_port) diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json index 591f24767..26a42e8c7 100644 --- a/scripts/addons/suites.json +++ b/scripts/addons/suites.json @@ -4017,7 +4017,22 @@ "go: is unavailable when the owner cannot act, and never falls back to a read or a proposal", "go: keeps the argument rules: an identity argument is refused before anything runs", "go: are listed in any state, with the names they replace and whether they run", - "go: keeps its namespaced name and carries the built-in it shadows, only for a replaceable name" + "go: keeps its namespaced name and carries the built-in it shadows, only for a replaceable name", + "go: names the replacement string for legacy arguments at the chat boundary without sending them", + "go: synthesizes exactly one strict host read, without enumerating resources at discovery", + "go: projects only opaque IDs, optional names and caller roles through a normal data envelope", + "go: returns an array even for empty success and truthfully bounds sorted 100/101 results", + "go: rejects every input property and non-object rather than resolving selection", + "go: fails closed without the optional backend interface and never synthesizes for other add-ons", + "go: rejects a reserved Tables manifest collision in discovery and execution even if not advertised", + "go: rechecks missing, hidden, disabled, stopped and uninitialized Tables at execution", + "go: preserves typed credential, feature, owner, forwarding and registry failures after discovery", + "go: rejects missing principals and malformed projections instead of successful empty data", + "go: keeps synthesized listing behind consumer allowlists and hidden add-ons", + "go: does not let a listing-capable provider invent a named-user principal", + "go: revalidates listing failures through an already-bound consumer without namespace fallback", + "go: preserves the model read byte bound", + "go: offers the host read even when Tables has no executable definitions" ] }, "tables-admin": { @@ -4073,7 +4088,21 @@ "binds an output handle to its operation, person and database", "fails a truncated, grown or vanished stream and never serves it", "fails the transfer when the store cannot write, and when the source is revoked before completion", - "expires the file only, enforces the quota per person and leaves the database as it was" + "expires the file only, enforces the quota per person and leaves the database as it was", + "projects only active owned/shared resources and the caller's own role, never UI management metadata", + "projects only active owned/shared resources and the caller's own role, never UI management metadata", + "excludes policy reservations and resources whose deletion has begun", + "excludes policy reservations and resources whose deletion has begun", + "rechecks grants after discovery and independently refuses inspect on a revoked database", + "rechecks grants after discovery and independently refuses inspect on a revoked database", + "revalidates real credential and feature changes after discovery, not as successful empty lists", + "revalidates real credential and feature changes after discovery, not as successful empty lists", + "preserves typed missing resource registry and app registry failures", + "preserves typed missing resource registry and app registry failures", + "uses the service's actual 100/101 bound in ID order with truthful truncation and absent names", + "uses the service's actual 100/101 bound in ID order with truthful truncation and absent names", + "rechecks credentials at the carrier owner even if the frontend still admits the old principal", + "reports an unavailable carrier owner after discovery instead of fabricating empty success" ] }, "tables-ui": { @@ -4346,7 +4375,6 @@ "go: fails closed when no platform is enabled, the file is absent or empty", "go: rejects enabled platforms without a native runner or check, duplicates and unknown fields", "go: prints the enabled platforms with their native runners", - "go: ships the checked-in matrix with every platform disabled until its native check passes", "go: renders digest-pinned entries with an explicit add-on signer identity", "go: renders an entry for every first-party add-on from its own template", "go: refuses missing inputs, backend repositories, bad channels and unknown placeholders", @@ -4387,11 +4415,9 @@ "go: rejects a plan that does not fail closed on missing configuration", "go: rejects a plan that does not exit 1 for an add-on without an enabled platform on a publishing event", "go: rejects a plan that treats any matrix error as no legs", - "go: rejects a repository rule that does not keep the configured repository for tables", "go: rejects a publish or promote matrix that stops the other add-ons on a failure", "go: rejects a publish job that downloads the layouts of other add-ons", "go: rejects a promote job that does not check the digest format", - "go: derives a repository that the add-on tools accept for every add-on from the documented variable", "go: fails on a publishing event when an add-on has no enabled platform", "go: publishes when every add-on has an enabled platform", "go: gives an add-on without an enabled platform no legs on an event that publishes nothing", @@ -4439,7 +4465,79 @@ "workflow policy rejects a publish job that does not wait for every leg", "workflow policy rejects a validate job that cannot stop the release", "scripts never push, tag, sign or log in", - "packaging files never reference backend registries or workflows" + "packaging files never reference backend registries or workflows", + "go: adds the tool fields to an existing index from the repository manifests, and changes nothing else", + "go: caps the tool names at MaxEntryTools and keeps the full count", + "go: declares and packages a loadable, receipt-verified skill without shadows", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: enables a platform exactly when its native check is recorded", + "go: gives promote an add-on matrix", + "go: gives publish an add-on matrix", + "go: hands the guest image digest of the sandbox-image job to the package leg", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has a catalog template", + "go: has consistent package files for face", + "go: has consistent package files for fine-tuning", + "go: has consistent package files for office", + "go: has consistent package files for quantization", + "go: has consistent package files for sandbox", + "go: has consistent package files for slack", + "go: has consistent package files for studio", + "go: has consistent package files for tables", + "go: has consistent package files for talk", + "go: has consistent package files for telegram", + "go: has consistent package files for voice", + "go: has consistent package files for web", + "go: has no hard-coded add-on path", + "go: lays the skills of the add-on in their own layer and verifies them with the install rules", + "go: lets only the release-index job write the contents of the repository", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists every release platform with its runner", + "go: lists the add-ons in the validate matrix", + "go: lists the built-in skills an add-on replaces", + "go: lists the built-in tools an add-on replaces after its tools", + "go: loops the plan over the same list", + "go: publishes every add-on to the one documented repository, with its id as the tag prefix", + "go: refuses --from-manifest for a pinned channel or together with a merged layout", + "go: refuses to build when the skills folder and the manifest disagree, or a skill breaks a rule", + "go: rejects a missing skill, mismatched frontmatter, and scripts", + "go: rejects a repository rule that derives a repository for each add-on", + "go: renders the development channel as mutable entries: the moving tag, no digest", + "go: renders the development entries of every enabled platform from the manifest, without a package", + "go: reports the chat tools of the manifest on each entry and not the admin tools", + "go: ships a checked-in Tables matrix with at least one enabled platform", + "go: takes the package legs from the plan", + "package.sh passes SANDBOX_GUEST_IMAGE to the linker", + "package.sh refuses a foreign platform without an executable: office", + "package.sh refuses a foreign platform without an executable: sandbox", + "package.sh refuses a foreign platform without an executable: web", + "package.sh refuses a guest image that is not by digest", + "package.sh refuses a sandbox release build without SANDBOX_GUEST_IMAGE", + "package.sh stamps the sandbox executable with a guest image in a local check" ] }, "updates": { diff --git a/scripts/addons/tables_csv.sh b/scripts/addons/tables_csv.sh index bcc506f49..34cdc841a 100755 --- a/scripts/addons/tables_csv.sh +++ b/scripts/addons/tables_csv.sh @@ -72,6 +72,7 @@ rm -rf "$fixture_dir" mkdir -p "$fixture_dir/tables" cp "$root/addons/tables/manifest.json" "$fixture_dir/tables/manifest.json" cp -R "$pkg/dist/ui" "$fixture_dir/tables/ui" +cp -R "$root/addons/tables/skills" "$fixture_dir/tables/skills" free_port() { python3 -I -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'; } named_port=$(free_port) diff --git a/scripts/addons/tables_ui.sh b/scripts/addons/tables_ui.sh index 35dbbafd5..5f8a2ae97 100644 --- a/scripts/addons/tables_ui.sh +++ b/scripts/addons/tables_ui.sh @@ -61,6 +61,7 @@ rm -rf "$fixture_dir" mkdir -p "$fixture_dir/tables" "$fixture_dir/tables-next" cp "$root/addons/tables/manifest.json" "$fixture_dir/tables/manifest.json" cp -R "$pkg/dist/ui" "$fixture_dir/tables/ui" +cp -R "$root/addons/tables/skills" "$fixture_dir/tables/skills" # A second release, for the stale-tab checks: the same package, one patch up. python3 -I - "$root/addons/tables/manifest.json" "$fixture_dir/tables-next/manifest.json" <<'PY' import json, sys @@ -69,6 +70,7 @@ m['version'] = '0.1.1' json.dump(m, open(sys.argv[2], 'w')) PY cp -R "$pkg/dist/ui" "$fixture_dir/tables-next/ui" +cp -R "$root/addons/tables/skills" "$fixture_dir/tables-next/skills" free_port() { python3 -I -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'; } named_port=$(free_port) @@ -94,7 +96,7 @@ wait_for() { i=0 until [ -s "$file" ] && python3 -I -c "import sys,urllib.request;urllib.request.urlopen('http://127.0.0.1:$port/readyz',timeout=1)" 2>/dev/null; do i=$((i + 1)) - kill -0 "$pid" 2>/dev/null || { echo 'UI test server exited' >&2; tail -20 "$cache/logs/tables-ui-server.log" "$cache/logs/tables-ui-local-server.log" >&2; exit 1; } + kill -0 "$pid" 2>/dev/null || { echo 'UI test server exited' >&2; tail -n 20 "$cache/logs/tables-ui-server.log" "$cache/logs/tables-ui-local-server.log" >&2; exit 1; } [ "$i" -lt 180 ] || { echo 'UI test server readiness timed out' >&2; exit 1; } sleep 1 done diff --git a/scripts/addons/updates.sh b/scripts/addons/updates.sh index 3169381d0..d2c1091cb 100755 --- a/scripts/addons/updates.sh +++ b/scripts/addons/updates.sh @@ -88,6 +88,7 @@ m['version'] = '0.1.0-dev' json.dump(m, open(sys.argv[2], 'w')) PY cp -R "$pkg/dist/ui" "$d/ui" + cp -R "$root/addons/tables/skills" "$d/skills" printf 'build %s\n' "$n" >"$d/build.txt" printf 'development\n' >"$d/.fixture-channel" printf '%s\n' "$n" >"$d/.fixture-generation" diff --git a/tests/addons/actione2e/actions_e2e_test.go b/tests/addons/actione2e/actions_e2e_test.go index 4b9f1b78a..781748537 100644 --- a/tests/addons/actione2e/actions_e2e_test.go +++ b/tests/addons/actione2e/actions_e2e_test.go @@ -352,10 +352,7 @@ var _ = Describe("Add-on actions end to end", func() { It("lets the Office add-on create, read and edit a document in the user's files", func() { w := startAddon("office") w.put("alice", "/seed.txt", "x") - created := w.act("create-docx", "alice", "convaaaa", map[string]any{"path": "/report.docx", "blocks": []map[string]any{ - {"kind": "heading", "level": 1, "text": "Summary"}, - {"kind": "paragraph", "text": "All good."}, - }}) + created := w.act("create-docx", "alice", "convaaaa", map[string]any{"path": "/report.docx", "markdown": "# Summary\n\nAll good."}) Expect(created["ok"]).To(Equal(true), "create-docx = %v", created) Expect(created["path"]).To(Equal("/report.docx"), "create-docx = %v", created) read := w.read("read-docx", "alice", map[string]any{"path": "/report.docx"}) @@ -388,7 +385,7 @@ var _ = Describe("Add-on calls of a chat whose agent has a folder", func() { // Two documents made outside any chat: one in the agent's folder, one // outside it. for path, text := range map[string]string{"/Acme/in.docx": "Inside the folder.", "/Other/out.docx": marker} { - made := w.act("create-docx", "alice", "convplain", map[string]any{"path": path, "blocks": []map[string]any{{"kind": "paragraph", "text": text}}}) + made := w.act("create-docx", "alice", "convplain", map[string]any{"path": path, "markdown": text}) Expect(made["ok"]).To(Equal(true), "create-docx %s = %v", path, made) } w.scopedChat("convscope", workspace.ModeWrite) @@ -405,14 +402,14 @@ var _ = Describe("Add-on calls of a chat whose agent has a folder", func() { Expect(string(mustJSON(got))).NotTo(ContainSubstring(marker), "read-docx %q", p) } // A document it makes lands in the folder, never at the home root. - made := w.actIn("create-docx", "alice", "convscope", map[string]any{"path": "/new.docx", "blocks": []map[string]any{{"kind": "paragraph", "text": "New."}}}) + made := w.actIn("create-docx", "alice", "convscope", map[string]any{"path": "/new.docx", "markdown": "New."}) Expect(made["ok"]).To(Equal(true), "create-docx = %v", made) Expect(made["path"]).To(Equal("/new.docx"), "the path in the add-on's terms") _, ok := w.text("alice", "/Acme/new.docx") Expect(ok).To(BeTrue(), "the new document is in the folder") _, ok = w.text("alice", "/new.docx") Expect(ok).To(BeFalse(), "the new document reached the home root") - escaped := w.actIn("create-docx", "alice", "convscope", map[string]any{"path": "../escape.docx", "blocks": []map[string]any{{"kind": "paragraph", "text": "x"}}}) + escaped := w.actIn("create-docx", "alice", "convscope", map[string]any{"path": "../escape.docx", "markdown": "x"}) Expect(escaped["ok"]).To(Equal(false), "create-docx outside = %v", escaped) edited := w.actIn("edit-docx", "alice", "convscope", map[string]any{"path": "/Other/out.docx", "edits": []map[string]any{{"op": "replace", "find": marker, "text": "changed"}}}) Expect(edited["ok"]).To(Equal(false), "edit-docx outside = %v", edited) @@ -452,12 +449,12 @@ var _ = Describe("Add-on calls of a chat whose agent has a folder", func() { w := startAddon("office") w.put("alice", "/Acme/seed.txt", "x") w.scopedChat("convread", workspace.ModeRead) - made := w.actIn("create-docx", "alice", "convread", map[string]any{"path": "/new.docx", "blocks": []map[string]any{{"kind": "paragraph", "text": "New."}}}) + made := w.actIn("create-docx", "alice", "convread", map[string]any{"path": "/new.docx", "markdown": "New."}) Expect(made["ok"]).To(Equal(false), "create-docx in a read-only folder = %v", made) _, ok := w.text("alice", "/Acme/new.docx") Expect(ok).To(BeFalse()) // Its chat folder stays writable. - made = w.actIn("create-docx", "alice", "convread", map[string]any{"path": "/workspace/chat/new.docx", "blocks": []map[string]any{{"kind": "paragraph", "text": "New."}}}) + made = w.actIn("create-docx", "alice", "convread", map[string]any{"path": "/workspace/chat/new.docx", "markdown": "New."}) Expect(made["ok"]).To(Equal(true), "create-docx in the chat folder = %v", made) _, ok = w.text("alice", "/Chats/convread/new.docx") Expect(ok).To(BeTrue()) diff --git a/tests/addons/packaging/packaging_test.go b/tests/addons/packaging/packaging_test.go index 70087fb96..e1213c677 100644 --- a/tests/addons/packaging/packaging_test.go +++ b/tests/addons/packaging/packaging_test.go @@ -72,7 +72,7 @@ var _ = BeforeSuite(func() { func matrix() string { GinkgoHelper() p := filepath.Join(GinkgoT().TempDir(), "platforms.yaml") - body := "schema: 1\naddon: tables\nexecutable: tables\nmanifest: addons/tables/manifest.json\nui: addons/tables/ui/dist\nplatforms:\n" + + body := "schema: 1\naddon: tables\nexecutable: tables\nmanifest: addons/tables/manifest.json\nui: addons/tables/ui/dist\nskills: addons/tables/skills\nplatforms:\n" + " - platform: " + hostPlat + "\n enabled: true\n runner: native\n native_check: executable-start\n" Expect(os.WriteFile(p, []byte(body), 0o644)).To(Succeed()) return p @@ -226,6 +226,11 @@ var _ = Describe("first-party package", func() { Expect(info.Mode().Perm() & 0o100).NotTo(BeZero()) Expect(filepath.Join(receipt.Directory, "ui", "tables.js")).To(BeAnExistingFile()) Expect(filepath.Join(receipt.Directory, "ui", "tables.css")).To(BeAnExistingFile()) + skill, err := os.ReadFile(filepath.Join(receipt.Directory, "skills", "tables-readonly", "SKILL.md")) + Expect(err).NotTo(HaveOccurred()) + source, err := os.ReadFile(filepath.Join(repoRoot, "addons/tables/skills/tables-readonly/SKILL.md")) + Expect(err).NotTo(HaveOccurred()) + Expect(skill).To(Equal(source)) limits := contract.DefaultLimits() sup, err := addonruntime.NewSupervisor(addonruntime.Config{StateRoot: filepath.Join(GinkgoT().TempDir(), "runtime"), DataRoot: filepath.Join(GinkgoT().TempDir(), "data"), Limits: limits, ReadyTimeout: 20 * time.Second, StableHealth: limits.BackoffMax}) diff --git a/tests/addons/recovery/host_test.go b/tests/addons/recovery/host_test.go index c597f0335..1f7156003 100644 --- a/tests/addons/recovery/host_test.go +++ b/tests/addons/recovery/host_test.go @@ -284,13 +284,15 @@ func (h *host) library() *cluster.Library { // These specs run the tools, not the page: the package carries no UI // files, so the declaration of them is left out. delete(m.Capabilities, "ui") + skill, e := os.ReadFile("../../../addons/tables/skills/tables-readonly/SKILL.md") + Expect(e).NotTo(HaveOccurred()) exe, e := os.ReadFile(binary) Expect(e).NotTo(HaveOccurred()) for _, v := range []string{"0.1.0", "0.1.1"} { - Expect(lib.Add(cluster.Package{ID: "tables", Version: v, Capabilities: m.Capabilities, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed()) + Expect(lib.Add(cluster.Package{ID: "tables", Version: v, Capabilities: m.Capabilities, Files: map[string]string{"skills/tables-readonly/SKILL.md": string(skill)}, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed()) } // A release whose process fails at start: its upgrade must be undone. - Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.2.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: []byte("#!/bin/sh\nexit 9\n")}, GinkgoT().TempDir())).To(Succeed()) + Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.2.0", Capabilities: m.Capabilities, Files: map[string]string{"skills/tables-readonly/SKILL.md": string(skill)}, Executable: "bin/tables", Binary: []byte("#!/bin/sh\nexit 9\n")}, GinkgoT().TempDir())).To(Succeed()) return lib } diff --git a/tests/addons/shipped/shipped_test.go b/tests/addons/shipped/shipped_test.go index e64743fe9..59cd634e7 100644 --- a/tests/addons/shipped/shipped_test.go +++ b/tests/addons/shipped/shipped_test.go @@ -407,20 +407,20 @@ var _ = Describe("shipped add-ons on the real server", Ordered, ContinueOnFailur It("creates, reads and edits Word, Excel and PowerPoint files and keeps one undoable history", func() { chat := newLetterChat(admin) doc := toolResult(admin.toolCallIn(chat, "office__create-docx", map[string]any{"path": "/Reports/plan.docx", "title": "Plan", - "blocks": []map[string]any{{"kind": "heading", "level": 1, "text": "Q1 plan"}, {"kind": "paragraph", "text": "Ship the thing."}}})) + "markdown": "# Q1 plan\n\nShip the thing."})) Expect(fmt.Sprint(doc["result"])).To(ContainSubstring("ok:true")) edit := toolResult(admin.toolCallIn(newLetterChat(admin), "office__edit-docx", map[string]any{"path": "/Reports/plan.docx", "edits": []map[string]any{{"op": "replace", "find": "Ship", "text": "Launch"}}})) Expect(fmt.Sprint(edit["result"])).To(ContainSubstring("replaced:1")) read := toolResult(admin.toolCall("office__read-docx", map[string]any{"path": "/Reports/plan.docx"})) Expect(fmt.Sprint(read["result"])).To(ContainSubstring("Launch the thing.")) - x := toolResult(admin.toolCallIn(newLetterChat(admin), "office__create-xlsx", map[string]any{"path": "/Reports/b.xlsx", "sheets": []map[string]any{{"name": "S1", "rows": [][]string{{"a", "b"}, {"1", "2"}}}}})) + x := toolResult(admin.toolCallIn(newLetterChat(admin), "office__create-xlsx", map[string]any{"path": "/Reports/b.xlsx", "sheet": "S1", "csv": "a,b\n1,2\n"})) Expect(fmt.Sprint(x["result"])).To(ContainSubstring("ok:true")) p := toolResult(admin.toolCallIn(newLetterChat(admin), "office__create-pptx", map[string]any{"path": "/Reports/d.pptx", "slides": []map[string]any{{"title": "Hello", "bullets": []string{"x"}}}})) Expect(fmt.Sprint(p["result"])).To(ContainSubstring("ok:true")) }) It("runs an action tool in every chat, whatever the chat id looks like", func() { chat := newDigitChat(admin) - msg := admin.toolCallIn(chat, "office__create-docx", map[string]any{"path": "/Reports/digit.docx", "blocks": []map[string]any{{"kind": "paragraph", "text": "a"}}}) + msg := admin.toolCallIn(chat, "office__create-docx", map[string]any{"path": "/Reports/digit.docx", "markdown": "a"}) Expect(fmt.Sprint(toolResult(msg)["result"])).To(ContainSubstring("ok:true"), msg) }) }) diff --git a/tests/addons/tables/application_test.go b/tests/addons/tables/application_test.go index ebbf8a439..cf84560a5 100644 --- a/tests/addons/tables/application_test.go +++ b/tests/addons/tables/application_test.go @@ -279,9 +279,11 @@ func (h *host) library() *cluster.Library { // files, so the declaration of them is left out. The UI suite installs // the whole package. delete(m.Capabilities, "ui") + skill, e := os.ReadFile("../../../addons/tables/skills/tables-readonly/SKILL.md") + Expect(e).NotTo(HaveOccurred()) exe, e := os.ReadFile(binary) Expect(e).NotTo(HaveOccurred()) - Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed()) + Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Files: map[string]string{"skills/tables-readonly/SKILL.md": string(skill)}, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed()) return lib } diff --git a/tests/addons/tables/tools_test.go b/tests/addons/tables/tools_test.go index 182eb2912..6a82dc570 100644 --- a/tests/addons/tables/tools_test.go +++ b/tests/addons/tables/tools_test.go @@ -47,13 +47,15 @@ var _ = Describe("Tables tools under the real owner", Label("application"), func defs, e := adapter.Discover(ctx, h.actor("alice")) Expect(e).NotTo(HaveOccurred()) - Expect(defs).To(HaveLen(15)) + Expect(defs).To(HaveLen(16)) + Expect(defs).To(ContainElement(HaveField("Name", "addon__tables__databases-list"))) for _, d := range defs { Expect(d.Name).To(HavePrefix("addon__tables__")) } defs, e = adapter.Discover(ctx, h.actor("bob")) Expect(e).NotTo(HaveOccurred()) - Expect(defs).To(HaveLen(15)) + Expect(defs).To(HaveLen(16)) + Expect(defs).To(ContainElement(HaveField("Name", "addon__tables__databases-list"))) // Another user's identity cannot be claimed by changing a field of // the actor: the owner checks the stored credential. diff --git a/tests/addons/tablesadmin/host_test.go b/tests/addons/tablesadmin/host_test.go index 218cb1a9c..b33fc1c95 100644 --- a/tests/addons/tablesadmin/host_test.go +++ b/tests/addons/tablesadmin/host_test.go @@ -285,7 +285,11 @@ func (h *host) library() *cluster.Library { delete(m.Capabilities, "ui") exe, e := os.ReadFile(binary) Expect(e).NotTo(HaveOccurred()) - Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed()) + 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 } diff --git a/tests/addons/tablesadmin/model_listing_test.go b/tests/addons/tablesadmin/model_listing_test.go new file mode 100644 index 000000000..269d13b77 --- /dev/null +++ b/tests/addons/tablesadmin/model_listing_test.go @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT +package tablesadmin_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/core/services/addons/policy" + addontools "github.com/mudler/LocalAI/core/services/addons/tools" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const modelListName = "addon__tables__databases-list" + +// Neither backend fakes a listing: both enter Gateway, and the carrier reaches +// the bootstrapped owner's real resource service, policy and account database. +func modelListingBackend(h *host, forwarded bool) *gateway.Gateway { + GinkgoHelper() + if !forwarded { + return h.app.AddonGateway() + } + carrier, err := gateway.NewCarrier(*h.front) + Expect(err).NotTo(HaveOccurred()) + return &gateway.Gateway{Forward: carrier, Revalidate: auth.AddonRevalidate(h.app.AuthDB(), h.mode())} +} + +func discoverModelList(a *addontools.ToolAdapter, actor contract.Actor) { + GinkgoHelper() + defs, err := a.Discover(context.Background(), actor) + Expect(err).NotTo(HaveOccurred()) + names := []string{} + for _, d := range defs { + names = append(names, d.Name) + } + Expect(names).To(ContainElement(modelListName)) +} + +func executeModelList(a *addontools.ToolAdapter, actor contract.Actor) map[string]any { + GinkgoHelper() + out, err := a.Execute(context.Background(), actor, modelListName, json.RawMessage(`{}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(out.Kind).To(Equal(addontools.ResultData)) + var payload map[string]any + Expect(json.Unmarshal(out.Result, &payload)).To(Succeed()) + Expect(payload).To(HaveLen(2)) + Expect(payload).To(HaveKey("truncated")) + Expect(payload["databases"]).To(BeAssignableToTypeOf([]any{})) + return payload +} + +func modelListDenied(a *addontools.ToolAdapter, actor contract.Actor, want error) { + GinkgoHelper() + out, err := a.Execute(context.Background(), actor, modelListName, json.RawMessage(`{}`)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, want)).To(BeTrue(), "%v", err) + Expect(out.Result).To(BeEmpty()) +} + +var _ = Describe("model database listing through the real owner", func() { + for _, forwarded := range []bool{false, true} { + label := "local" + if forwarded { + label = "carrier" + } + Context(label, func() { + It("projects only active owned/shared resources and the caller's own role, never UI management metadata", func() { + h := ready(false) + id := h.database("bob", "model-matrix", "Matrix") + private := h.database("bob", "model-private", "Private") + Expect(h.grant("bob", id, "carol", "viewer").code).To(Equal(200)) + Expect(h.grant("bob", id, "dave", "editor").code).To(Equal(200)) + a := addontools.NewToolAdapter(modelListingBackend(h, forwarded)) + for user, role := range map[string]string{"bob": "owner", "carol": "viewer", "dave": "editor", "alice": "", "erin": ""} { + if user == "erin" { + modelListDenied(a, h.actor(user), gateway.ErrForbidden) + continue + } + discoverModelList(a, h.actor(user)) + payload := executeModelList(a, h.actor(user)) + Expect(payload["truncated"]).To(BeFalse()) + rows := payload["databases"].([]any) + if role == "" { + Expect(rows).To(BeEmpty()) + continue + } + expected := []any{map[string]any{"resourceId": id, "name": "Matrix", "role": role}} + if user == "bob" { + expected = append(expected, map[string]any{"resourceId": private, "name": "Private", "role": "owner"}) + } + Expect(rows).To(ConsistOf(expected)) + } + // An unrelated feature-enabled user has no grant, just like the + // installation administrator (alice), who never inherits data ownership. + Expect(h.revoke("bob", id, "dave").code).To(Equal(200)) + Expect(executeModelList(a, h.actor("dave"))["databases"]).To(BeEmpty()) + }) + + It("excludes policy reservations and resources whose deletion has begun", func() { + h := ready(false) + p := h.app.AddonPolicy() + active := h.database("bob", "model-active", "Active") + deleting := h.database("bob", "model-deleting", "Deleting") + Expect(p.BeginDelete(context.Background(), h.actor("bob"), h.key(deleting), "delete-model")).To(Succeed()) + // An indeterminate creation leaves a genuine pending policy record. + pending := "rfffffffffffffffffffffffffffffff" + _, err := p.CreateWithID(context.Background(), h.actor("bob"), "tables", pending, "pending-model", func(context.Context, policy.ResourceKey) error { return errors.New("fixture creation interrupted") }) + Expect(err).To(HaveOccurred()) + a := addontools.NewToolAdapter(modelListingBackend(h, forwarded)) + discoverModelList(a, h.actor("bob")) + Expect(executeModelList(a, h.actor("bob"))["databases"]).To(Equal([]any{map[string]any{"resourceId": active, "name": "Active", "role": "owner"}})) + }) + + It("rechecks grants after discovery and independently refuses inspect on a revoked database", func() { + h := ready(false) + id := h.database("bob", "model-revoke", "Revoked") + Expect(h.grant("bob", id, "carol", "viewer").code).To(Equal(200)) + a := addontools.NewToolAdapter(modelListingBackend(h, forwarded)) + actor := h.actor("carol") + discoverModelList(a, actor) + Expect(executeModelList(a, actor)["databases"]).To(HaveLen(1)) + Expect(h.revoke("bob", id, "carol").code).To(Equal(200)) + Expect(executeModelList(a, actor)["databases"]).To(BeEmpty()) + out, err := a.Execute(context.Background(), actor, "addon__tables__databases-inspect", json.RawMessage(fmt.Sprintf(`{"resourceId":%q}`, id))) + Expect(err).NotTo(HaveOccurred()) + Expect(out.Kind).To(Equal(addontools.ResultError)) + // Resource ACL denials deliberately conceal resource existence. + Expect(out.Code).To(Equal(addontools.CodeResourceUnreachable)) + Expect(out.Result).To(BeEmpty()) + }) + + It("revalidates real credential and feature changes after discovery, not as successful empty lists", func() { + h := ready(false) + a := addontools.NewToolAdapter(modelListingBackend(h, forwarded)) + actor := h.actor("bob") + discoverModelList(a, actor) + db := h.app.AuthDB() + Expect(db.Model(&auth.UserAPIKey{}).Where("id = ?", h.keys["bob"]).Update("disabled", true).Error).To(Succeed()) + modelListDenied(a, actor, gateway.ErrForbidden) + Expect(db.Model(&auth.UserAPIKey{}).Where("id = ?", h.keys["bob"]).Update("disabled", false).Error).To(Succeed()) + discoverModelList(a, actor) + Expect(db.Model(&auth.UserPermission{}).Where("user_id = ?", "bob").Update("permissions", auth.PermissionMap{auth.FeatureAddons: false}).Error).To(Succeed()) + modelListDenied(a, actor, gateway.ErrForbidden) + Expect(db.Model(&auth.UserPermission{}).Where("user_id = ?", "bob").Update("permissions", auth.PermissionMap{auth.FeatureAddons: true}).Error).To(Succeed()) + discoverModelList(a, actor) + expired := time.Now().Add(-time.Hour) + Expect(db.Model(&auth.UserAPIKey{}).Where("id = ?", h.keys["bob"]).Update("expires_at", expired).Error).To(Succeed()) + modelListDenied(a, actor, gateway.ErrForbidden) + }) + + It("preserves typed missing resource registry and app registry failures", func() { + h := ready(false) + a := addontools.NewToolAdapter(modelListingBackend(h, forwarded)) + actor := h.actor("bob") + discoverModelList(a, actor) + owner := h.app.AddonGateway() + registry := owner.Resources + owner.Resources = nil + defer func() { owner.Resources = registry }() + modelListDenied(a, actor, gateway.ErrUnavailable) + owner.Resources = registry + manager := owner.Manager + owner.Manager = nil + defer func() { owner.Manager = manager }() + modelListDenied(a, actor, gateway.ErrUnavailable) + }) + + It("uses the service's actual 100/101 bound in ID order with truthful truncation and absent names", func() { + h := ready(false) + p := h.app.AddonPolicy() + // Policy-only registrations deliberately have no backing database names. + // The real service still lists each authorized ID, without inventing one. + for i := 99; i >= 0; i-- { + id := fmt.Sprintf("r%031x", i) + _, err := p.CreateWithID(context.Background(), h.actor("bob"), "tables", id, fmt.Sprintf("bound-%d", i), func(context.Context, policy.ResourceKey) error { return nil }) + Expect(err).NotTo(HaveOccurred()) + } + backend := modelListingBackend(h, forwarded) + a := addontools.NewToolAdapter(backend) + check := func(truncated bool) { + GinkgoHelper() + // Check the upstream real service bound too: adapter-side sorting and + // clipping must not hide an upstream ordering/truncation regression. + upstream, err := backend.ListResources(context.Background(), h.actor("bob"), "tables") + Expect(err).NotTo(HaveOccurred()) + Expect(upstream.Resources).To(HaveLen(100)) + Expect(upstream.Truncated).To(Equal(truncated)) + payload := executeModelList(a, h.actor("bob")) + Expect(payload["truncated"]).To(Equal(truncated)) + rows := payload["databases"].([]any) + Expect(rows).To(HaveLen(100)) + for i, row := range rows { + id := fmt.Sprintf("r%031x", i) + Expect(upstream.Resources[i].ID).To(Equal(id)) + Expect(row).To(Equal(map[string]any{"resourceId": id, "role": "owner"})) + } + } + check(false) + _, err := p.CreateWithID(context.Background(), h.actor("bob"), "tables", fmt.Sprintf("r%031x", 100), "bound-100", func(context.Context, policy.ResourceKey) error { return nil }) + Expect(err).NotTo(HaveOccurred()) + check(true) + }) + }) + } + + It("rechecks credentials at the carrier owner even if the frontend still admits the old principal", func() { + h := ready(false) + backend := modelListingBackend(h, true) + // Model a frontend with stale identity state; only the owner DB changes. + backend.Revalidate = func(context.Context, contract.Actor) error { return nil } + a := addontools.NewToolAdapter(backend) + actor := h.actor("bob") + discoverModelList(a, actor) + Expect(h.app.AuthDB().Delete(&auth.UserAPIKey{}, "id = ?", h.keys["bob"]).Error).To(Succeed()) + modelListDenied(a, actor, gateway.ErrForbidden) + }) + + It("reports an unavailable carrier owner after discovery instead of fabricating empty success", func() { + h := ready(false) + a := addontools.NewToolAdapter(modelListingBackend(h, true)) + actor := h.actor("bob") + discoverModelList(a, actor) + h.stop() + modelListDenied(a, actor, gateway.ErrUnavailable) + }) +}) diff --git a/tests/addons/tablesexport/export_test.go b/tests/addons/tablesexport/export_test.go index e641e3de4..9a8a8456d 100644 --- a/tests/addons/tablesexport/export_test.go +++ b/tests/addons/tablesexport/export_test.go @@ -43,7 +43,9 @@ var _ = Describe("a CSV export downloaded through the owner and both frontends", } Expect(json.Unmarshal(raw, &m)).To(Succeed()) delete(m.Capabilities, "ui") - Expect(c.Library.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: tables}, c.Effects)).To(Succeed()) + skill, err := os.ReadFile("../../../addons/tables/skills/tables-readonly/SKILL.md") + Expect(err).NotTo(HaveOccurred()) + Expect(c.Library.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Files: map[string]string{"skills/tables-readonly/SKILL.md": string(skill)}, Executable: "bin/tables", Binary: tables}, c.Effects)).To(Succeed()) Expect(c.Up()).To(Succeed()) admin := c.Token(cluster.FrontA, "admin") life := func(cl cluster.Client, action string, gen uint64, extra map[string]any) { diff --git a/tests/e2e-ui/addons_fixture.go b/tests/e2e-ui/addons_fixture.go index edf440c92..ae560284a 100644 --- a/tests/e2e-ui/addons_fixture.go +++ b/tests/e2e-ui/addons_fixture.go @@ -120,7 +120,11 @@ func newAddonsFixture(dir, executable, dataDir string, localOwner bool) (*addons var buf bytes.Buffer tw := tar.NewWriter(&buf) add := func(name string, data []byte) error { - if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))}); err != nil { + mode := int64(0o644) + if name == "bin/fixture" || name == manifest["executable"] { + mode = 0o755 + } + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: int64(len(data))}); err != nil { return err } _, err := tw.Write(data)