Files
LocalAI/core/http/react-ui/e2e/library-sources.spec.js
T
mudler-agentandEttore Di Giacinto a9e19746be fix(addons): make owner settings authoritative (#13033)
* feat(addons): add a durable host policy store

Keep bounded host policy in one owner-managed durable store. Use revision
checks and retained receipts to reconcile updates without stale writes.

Require a sole migration source and explicit owner selection for clusters.
Restrict provider pins to tools and skills that existing consumers replace.
Document the internal scope; UI and carrier integration remain separate.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): reject malformed host policy JSON

Reject duplicate keys, noncanonical fields, and incomplete durable
structures before JSON decoding can discard evidence of corruption.
Validate provider keys before deletion so invalid removals cannot commit.

Keep empty provider sets canonical when writing migrated policies.
Regressions cover malformed documents, unchanged storage on rejection,
and replay with case-sensitive operation IDs.

Assisted-by: OpenAI:ChatGPT
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): decouple host policy validation

Move shared egress parsers into a dependency-light package so the gateway
can consume host settings without importing itself through core/config.
Keep config wrappers and a host type alias to preserve existing callers.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): carry host policy actions to owner

Keep host policy reads and updates on the authenticated owner. The owner
checks current administrator authority before updates and preserves CAS
conflicts and durable operation receipts across forwarding.

Provide read-only snapshots for trusted hosts over pinned mTLS. Missing
owner services never fall back to frontend policy. Application startup,
public settings routes and runtime consumers remain separate work.

Verify typed actions with local and real mTLS forwarding tests, including
revoked actors, strict payloads, receipt replay and unavailable stores.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep policy provenance private

Return a bounded policy projection from gateway reads, updates and replay.
Trusted host consumers use the same projection without migration metadata.
The private owner store retains provenance for migration records.

Cover local and pinned-carrier output, durable provenance and typed errors.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): initialize owner host settings

Open durable host settings only after the owner acquires its storage guard.
Keep frontends forwarding without importing their local configuration.

Preserve automatic migration for the sole combined host configuration.
Require explicit owner-local selection for non-default dedicated policy.
Close the store once on cancellation, shutdown or startup failure.

Cover migration, restrictive defaults, restart revisions, corruption,
duplicate owners and frontend isolation in the native bootstrap test.
Document the owner-only migration option and storage constraints.

Assisted-by: OpenAI:ChatGPT
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): close settings before the owner guard

Startup failures released the owner guard before closing host settings.
Use one deferred cleanup to preserve the close order on every failure.

Test real runtime and gateway failures, shutdown, and cancellation.
Check once-only closure and reacquire both locks after cleanup.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): enforce owner egress policy in Studio

Read owner policy for Studio defaults, views, cards and enforcement.
Frontend configuration must not override policy or mask an owner outage.
Propagate context and policy errors instead of reusing a cached allow.
Preserve local settings behavior when add-ons are disabled.

Cover existing consumers through independent pinned mTLS carriers.
Verify live tightening, pending-card outages and restart persistence.
Document the read semantics and the proxy verdict-cache boundary.

Assisted-by: OpenAI:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): read provider pins from owner policy

Studio reads current owner pins for tools and skills instead of frontend
configuration when add-ons are enabled. Policy failures stop selection
rather than silently choosing built-ins. Disabled add-ons retain local
configuration behavior.

Cover owner updates and outages, and document the read contract.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): abort turns on mandatory policy errors

Sessions must not treat owner policy failures as optional tool outages.
Stop priming before harness startup and abort turns before model sends.
Preserve wrapped policy causes and existing optional connector behavior.

Cover sent turns, priming failures, and optional connector failures.
Document the failure contract without changing fallback or cache rules.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): use owner policy for auto-upgrades

Read fresh owner policy before each automatic upgrade pass. Skip the
pass when that policy is unavailable instead of trusting local settings.

Wire a real owner policy store into the transportless manager fixture.
Keep assertions for stale frontend settings and owner policy changes.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): guard legacy settings writes

Reject migrated owner policy fields before applying any local settings.
Omit those fields from legacy reads while add-ons are enabled, and keep
unrelated local settings available without an owner request.

Cover presence, case folding, duplicate keys and atomic rejection.
Document the legacy contract and unchanged disabled behavior.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): use the existing Ginkgo suites

Register settings authority regressions with the existing suites to
comply with the Ginkgo/Gomega test policy. Preserve the conflict,
compatibility, owner outage, and upgrade assertions without adding
another RunSpecs bootstrap.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(egress): bound wildcard host policy grants

Support descendant and public-any patterns through the shared parser.
Keep wildcard grants inside the administrator ceiling and public guard.
Preserve the legacy exact-host exception under the registries ceiling.

Keep policy strings lossless for existing receipts and stored exact hosts.
Test canonical matching, private exclusions, ports, and policy replay.
Proxy connection authorization and cache changes remain separate.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(egress): authorize each new connection

Remove cached allows so policy tightening and owner outages block new
connections immediately. Keep existing DNS, private-address and SNI guards.
Socket tests cover HTTP keep-alive, CONNECT and consumed grants.
Established streams may finish under their original authorization.

Expect the canonical runsc path in the gVisor test, matching production.
This preserves the path contract when the test temp directory is a symlink.
A short alias avoids Unix socket path limits in deep worktrees.

Verify with go test -race -count=1 -timeout=180s ./addons/sandbox/...
using an offline environment and a short alias to the worktree temp folder.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): save network policy through the owner

Use owner revisions for host controls instead of frontend runtime settings.
Keep drafts across conflicts and outages, and replay uncertain writes with
the same operation identity. Read authoritative policy after each save.

Accept bounded host wildcards while keeping extra registries exact.
Keep typed private-access confirmation out of owner policy patches.
Add rendered form and client tests, and document reconciliation behavior.

Assisted-by: OpenAI: GPT-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): fail closed on owner policy errors

Reject partial, mistyped, unknown and oversized owner snapshots before
publishing them to the form. Match the complete Go policy JSON shape.

Keep owner availability errors and uncertain operation identities when
Discard resets the local draft. Only reconciliation can reopen saving.

Tests reproduce both review failures and cover immutable retries,
malformed reloads and the rendered discard-then-edit regression.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): finish owner settings controls

Route skill switches, provider choices and upgrade controls through owner
policy. Preserve per-key changes when another frontend updates the policy.
Keep local tool execution controls on their existing settings path.

Extend the existing state checklist and add-on acceptance guidance so new
controls declare their authority and test both deployment topologies.

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

* fix(studio): check policy errors in Tables skill tests

Adapt the Tables test added on master to the error-aware skillTurn API.
Assert that normal skill selection succeeds instead of discarding errors.

Verified with make test-distributed-fakebus and scoped golangci-lint.

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

* fix(addons): document guarded settings IO boundaries

Explain the narrow gosec exceptions for the authenticated administrator
settings response and fixed owner-root files. Keep symlink, mode and type
checks intact; no request-supplied paths reach this store.

Explicitly ignore best-effort temporary-file and directory cleanup errors;
file and directory sync failures still determine durability.

Verified owner-store and handler race tests and scoped golangci-lint.

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

* test(addons): run carrier policy cases in Ginkgo

Register projection, forwarding, validation and CAS cases with the existing
gateway suite. Check cleanup errors in test fixtures and explicitly ignore
response-body cleanup after decoding in the internal read client.

Verified gateway/application authority race tests and scoped lint.

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

* test(addons): inventory owner policy gateway cases

Require all four owner-policy cases in both the per-report and aggregate
gateway and artifact acceptance inventories. Keep exact matching intact.

Verified real test reports with the existing strict Ginkgo report validator:
215 gateway cases and 252 artifact cases passed, without skipped cases.
The full browser readiness probe remains a separate CI gate.

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

* test(ui): scope library settings apply action

Select the exact Apply button in the runtime settings pending bar. The
owner upgrade control has a separate Apply upgrade setting button, so an
unscoped substring locator is ambiguous.

Verified the failing Playwright case against the real UI test server and
Google Chrome, with isolated server and browser temporary paths.

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

* test(egress): update route and invalid-host fixtures

Use the context-aware egress callback in route tests. Keep the last-valid
list regression meaningful by using a partial-label wildcard, since a
whole-label descendant pattern is now valid input.

Verified focused application and route race tests and scoped lint.

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

* test(settings): reject partial-label wildcard fixture

Keep the invalid-input regression while accepting the newly supported
whole-label descendant wildcard. Use api.*.example.com as invalid input.

Reproduced the full endpoint suite failure, then verified all 616 endpoint
specs pass and scoped lint is clean.

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

---------

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

201 lines
11 KiB
JavaScript

import { test, expect } from './coverage-fixtures.js'
// Library sources of all three kinds: add-ons on the Add-ons Libraries tab,
// models on the Models page and backends on the Backends page, each in the
// panel its "Manage libraries" button opens, against a real
// server that starts like a normal install (playwright.library.config.js): the
// default sources of the add-on, backend and model kinds are in the store, and
// the routes change the store the `local-ai library` command edits. The specs
// share one server and change its sources, so they run in order.
//
// The conflict message needs two sources whose fetched indexes list one entry,
// which a test server without a network cannot give; that spec answers the list
// call itself and checks what the page shows of the message.
test.describe.configure({ mode: 'serial' })
const ROW = (page, id) => page.locator(`[data-source="${id}"]`)
// The defaults this build enables. The model gallery index `localai` is a default
// too, but a build reads the model library or the gallery, not both, so it is
// listed turned off.
const OFFICIAL = { addon: 'official', backend: 'localai-backends', model: 'official-models' }
const PAGES = { addon: '/app/addons?tab=libraries', model: '/app/models', backend: '/app/backends' }
async function openLibraries(page, kind = 'addon') {
await page.goto(PAGES[kind])
// The Backends page keeps its rare actions in the More menu.
if (kind === 'backend') {
await page.getByRole('button', { name: 'More backend actions' }).click()
await page.getByRole('menuitem', { name: 'Manage libraries…' }).click()
} else if (kind !== 'addon') await page.getByRole('button', { name: 'Manage libraries' }).click()
await expect(page.getByRole('list', { name: 'Libraries' })).toBeVisible()
}
async function sources(page) {
const res = await page.request.get('/api/library/sources')
expect(res.ok()).toBe(true)
return (await res.json())
}
test.describe('Library sources: all three kinds', () => {
test('each page lists only its own kind, with a Default badge and no kind filter', async ({ page }) => {
const expected = { addon: ['official'], backend: ['localai-backends'], model: ['official-models', 'localai'] }
for (const [kind, ids] of Object.entries(expected)) {
await openLibraries(page, kind)
await expect(page.locator('[data-source]')).toHaveCount(ids.length)
for (const id of ids) {
const row = ROW(page, id)
await expect(row).toBeVisible()
await expect(row).toHaveAttribute('data-kind', kind)
await expect(row.getByTestId('source-default')).toHaveText('Default')
}
await expect(ROW(page, OFFICIAL[kind]).getByTestId('source-state')).toHaveText('On')
await expect(page.getByRole('group', { name: 'Filter by kind' })).toHaveCount(0)
}
await openLibraries(page, 'model')
await expect(ROW(page, 'localai').getByTestId('source-state')).toHaveText('Off')
})
test('adds a model source of your own, which has no Default badge and stays after a reload', async ({ page }) => {
await openLibraries(page, 'model')
await page.getByRole('button', { name: 'Add a library' }).click()
const sheet = page.getByTestId('source-editor')
await expect(sheet.getByLabel('Kind')).toHaveCount(0)
await sheet.getByLabel('Index format').selectOption('gallery')
await sheet.getByLabel('Name').fill('Team models')
await sheet.getByLabel('Catalog URL').fill('https://models.example.org/index.yaml')
await sheet.getByRole('button', { name: 'Save' }).click()
const row = ROW(page, 'team-models')
await expect(row).toBeVisible()
await expect(row).toHaveAttribute('data-kind', 'model')
await expect(row.getByTestId('source-default')).toHaveCount(0)
await expect(row.getByTestId('source-state')).toHaveText('On')
const stored = (await sources(page)).sources.find(s => s.id === 'team-models')
expect(stored).toMatchObject({ kind: 'model', format: 'gallery', default: false, enabled: true, url: 'https://models.example.org/index.yaml' })
await page.reload()
await page.getByRole('button', { name: 'Manage libraries' }).click()
await expect(ROW(page, 'team-models')).toBeVisible()
// The same address twice is refused with the reason.
await page.getByRole('button', { name: 'Add a library' }).click()
const again = page.getByTestId('source-editor')
await again.getByLabel('Name').fill('Same address')
await again.getByLabel('Catalog URL').fill('https://models.example.org/index.yaml')
await again.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('alert').filter({ hasText: 'already a model source' })).toBeVisible()
})
test('turns a default off and removes it: it stays listed as removed, and restoring brings it back', async ({ page }) => {
await openLibraries(page, 'model')
const row = ROW(page, 'official-models')
await row.locator('[data-action="disable"]').click()
await expect(row.getByTestId('source-state')).toHaveText('Off')
await expect(row).toHaveAttribute('data-state', 'disabled')
expect((await sources(page)).sources.find(s => s.id === 'official-models')).toMatchObject({ enabled: false, removed: false, default: true })
await row.locator('[data-action="remove"]').click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('stays removed at the next start')
await dialog.getByRole('button', { name: 'Remove' }).click()
await expect(row).toHaveAttribute('data-state', 'removed')
await expect(row.getByTestId('source-state')).toHaveText('Removed')
await expect(row.getByTestId('source-default')).toHaveText('Default')
// The tombstone is in the store the CLI reads, not only on the page.
expect((await sources(page)).sources.find(s => s.id === 'official-models')).toMatchObject({ removed: true, enabled: false, default: true })
await page.reload()
await page.getByRole('button', { name: 'Manage libraries' }).click()
await expect(ROW(page, 'official-models')).toHaveAttribute('data-state', 'removed')
await ROW(page, 'official-models').locator('[data-action="restore"]').click()
await expect(ROW(page, 'official-models')).toHaveAttribute('data-state', 'enabled')
expect((await sources(page)).sources.find(s => s.id === 'official-models')).toMatchObject({ removed: false, enabled: true })
// A source of your own is deleted, not kept as a tombstone.
await ROW(page, 'team-models').locator('[data-action="remove"]').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove' }).click()
await expect(ROW(page, 'team-models')).toHaveCount(0)
expect((await sources(page)).sources.find(s => s.id === 'team-models')).toBeUndefined()
})
test('shows the conflict message the store gives next to the source that clashes', async ({ page }) => {
const message = 'the addon "tables" is listed by two of your library sources, "first" and "second"; disable or remove one of them (local-ai library disable second)'
await page.route('**/api/library/sources', route => route.request().method() === 'GET'
? route.fulfill({
json: {
officialLibraries: true,
sources: [
{ kind: 'addon', id: 'first', name: 'First', url: 'https://one.example/index.yaml', enabled: true, default: false, removed: false, suspended: false, conflict: message },
{ kind: 'addon', id: 'second', name: 'Second', url: 'https://two.example/index.yaml', enabled: true, default: false, removed: false, suspended: false, conflict: message },
{ kind: 'addon', id: 'official', name: 'Official', url: 'https://off.example/index.yaml', enabled: true, default: true, removed: false, suspended: false, conflict: 'the addon "tables" is provided by your source "first", so this default source is ignored for it' },
],
},
})
: route.fallback())
await openLibraries(page)
await expect(ROW(page, 'first').getByTestId('source-conflict')).toContainText('two of your library sources')
await expect(ROW(page, 'second').getByTestId('source-conflict')).toContainText('disable or remove one of them')
await expect(ROW(page, 'official').getByTestId('source-conflict')).toContainText('this default source is ignored for it')
})
})
test.describe('Library sources: Use official libraries', () => {
const settingRow = page => page.locator('[data-field="official_libraries"]')
async function setOfficial(page, on) {
await page.goto('/app/settings')
await page.getByRole('button', { name: /^Backends and galleries/ }).click()
const toggle = settingRow(page).getByRole('switch')
await expect(settingRow(page)).toContainText('Use official libraries')
await expect(toggle).toHaveAttribute('aria-checked', on ? 'false' : 'true')
await toggle.click()
await page.getByTestId('settings-pending').getByRole('button', { name: 'Apply', exact: true }).click()
await expect(page.getByText('Settings saved successfully')).toBeVisible()
}
const settings = async page => (await (await page.request.get('/api/settings')).json())
test('turning it off hides the default galleries and shows the defaults as off, and on brings them back', async ({ page }) => {
// On: the default model and backend galleries are read.
const on = await settings(page)
expect(on.official_libraries).toBe(true)
expect(on.galleries.length).toBeGreaterThan(0)
expect(on.backend_galleries.length).toBeGreaterThan(0)
const defaultUrls = [...on.galleries, ...on.backend_galleries].map(g => g.url)
// A source of your own, so there is something that must stay.
const added = await page.request.post('/api/library/sources', { data: { kind: 'model', format: 'gallery', id: 'mine', name: 'Mine', url: 'https://mine.example.org/index.yaml' } })
expect(added.status()).toBe(201)
await setOfficial(page, false)
const off = await settings(page)
expect(off.official_libraries).toBe(false)
expect(off.galleries.map(g => g.url)).toEqual(['https://mine.example.org/index.yaml'])
expect(off.backend_galleries).toEqual([])
// The Libraries tab lists the defaults, shown as off; the source of your own is on.
for (const [kind, id] of Object.entries(OFFICIAL)) {
await openLibraries(page, kind)
await expect(page.getByTestId('official-off')).toBeVisible()
await expect(ROW(page, id)).toHaveAttribute('data-state', 'suspended')
await expect(ROW(page, id).getByTestId('source-default')).toBeVisible()
await expect(ROW(page, id).getByTestId('source-state')).toContainText('Off')
}
await openLibraries(page, 'model')
await expect(ROW(page, 'mine')).toHaveAttribute('data-state', 'enabled')
await setOfficial(page, true)
const back = await settings(page)
expect(back.official_libraries).toBe(true)
expect(back.galleries.map(g => g.url)).toEqual(expect.arrayContaining([...on.galleries.map(g => g.url), 'https://mine.example.org/index.yaml']))
expect(back.backend_galleries.map(g => g.url)).toEqual(on.backend_galleries.map(g => g.url))
for (const [kind, id] of Object.entries(OFFICIAL)) {
await openLibraries(page, kind)
await expect(page.getByTestId('official-off')).toHaveCount(0)
await expect(ROW(page, id)).toHaveAttribute('data-state', 'enabled')
}
await page.request.delete('/api/library/sources/mine')
})
})