mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
fix(addons): stage no file while the UI host grants are locked
Sign-in off, or a policy file that is the only source, locks the UI host grants. An unsigned file must not be staged or installed then: the owner refuses file-begin with locked_by_deployment and the install dialog shows the lock and disables the install. Assisted-by: Claude:claude-sonnet-5-5 go eslint
This commit is contained in:
7 files changed
+45
-4
No files matched your search
@@ -38,7 +38,7 @@ const PREVIEW = {
|
||||
],
|
||||
}
|
||||
|
||||
async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody } = {}) {
|
||||
async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody, locked } = {}) {
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
|
||||
const state = await mockAddons(page, { apps: [], installed })
|
||||
const seen = { uploads: [], described: [], removed: [], preview: [], install: [], dispatch: state.log.dispatch }
|
||||
@@ -60,7 +60,7 @@ async function open(page, { file = fileView(), installed = [], uploadStatus = 20
|
||||
const body = req.postData() ? JSON.parse(req.postData()) : null
|
||||
const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
|
||||
if (path === '/') return json({ revision: 4, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} }, addons: [] })
|
||||
if (path === '/preview') { seen.preview.push(body); return json({ ...PREVIEW, installed: file.installed }) }
|
||||
if (path === '/preview') { seen.preview.push(body); return json({ ...PREVIEW, installed: file.installed, ...(locked ? { locked: true, lockedReason: locked } : {}) }) }
|
||||
if (path === '/install') {
|
||||
seen.install.push({ body, confirm: req.headers()['x-localai-confirm'] })
|
||||
if (!file.installed) {
|
||||
@@ -141,6 +141,15 @@ test.describe('Install from a file', () => {
|
||||
expect(seen.install[0].body.action).toBe('upgrade')
|
||||
})
|
||||
|
||||
test('a deployment that locks UI host grants cannot install a file', async ({ page }) => {
|
||||
await open(page, { locked: 'no_auth' })
|
||||
await page.goto('/app/addons')
|
||||
await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
|
||||
const dialog = page.getByTestId('install-dialog')
|
||||
await expect(dialog.getByTestId('install-file-locked')).toContainText('Sign-in is off')
|
||||
await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
|
||||
})
|
||||
|
||||
test('the same version cannot be installed again', async ({ page }) => {
|
||||
await open(page, { file: fileView({ installed: true, installedVersion: '0.2.0', relation: 'same' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })] })
|
||||
await page.goto('/app/addons')
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
if (!entry) return null
|
||||
const showHost = host.status === 'ready' && needsStep(host.preview)
|
||||
const block = showHost ? installBlock(host.preview) : null
|
||||
const gate = local ? fileGate(local, { downgradeTicked: downgrade }) : { allowed: true }
|
||||
const gate = local ? fileGate(local, { downgradeTicked: downgrade, locked: Boolean(host.preview && host.preview.locked) }) : { allowed: true }
|
||||
const start = () => {
|
||||
if (local) {
|
||||
// A file installs only with the approval of this dialog, with or without
|
||||
@@ -117,6 +117,9 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
{t('file.unsignedNotice')}
|
||||
</Callout>
|
||||
)}
|
||||
{local && host.preview && host.preview.locked && (
|
||||
<Callout tone="error" role="alert" testId="install-file-locked">{t(host.preview.lockedReason === 'no_auth' ? 'hostAccess.lockedNoAuth' : 'hostAccess.locked')}</Callout>
|
||||
)}
|
||||
{local && local.relation === 'upgrade' && (
|
||||
<p className="ad-quiet" data-testid="install-file-upgrade">{t('file.relation.upgrade', { installed: local.installedVersion, version: local.version })}</p>
|
||||
)}
|
||||
|
||||
+2
-1
@@ -55,7 +55,8 @@ export function fileEntry(file) {
|
||||
|
||||
// Whether the file may be installed at all, and what the person must confirm
|
||||
// first. The same version is never installed again; an older one needs a tick.
|
||||
export function fileGate(file, { downgradeTicked = false } = {}) {
|
||||
export function fileGate(file, { downgradeTicked = false, locked = false } = {}) {
|
||||
if (locked) return { allowed: false, reason: 'locked' }
|
||||
if (!file || file.relation === 'same') return { allowed: false, reason: 'same' }
|
||||
if (file.relation === 'downgrade' && !downgradeTicked) return { allowed: false, reason: 'downgrade' }
|
||||
return { allowed: true, reason: '' }
|
||||
|
||||
@@ -42,6 +42,7 @@ test('addonfile: the same version is never installed, an older one needs the tic
|
||||
assert.equal(fileGate(f('upgrade')).allowed, true)
|
||||
assert.equal(fileGate(f('new')).allowed, true)
|
||||
assert.equal(fileGate(null).allowed, false)
|
||||
assert.deepEqual(fileGate(f('new'), { locked: true }), { allowed: false, reason: 'locked' })
|
||||
})
|
||||
|
||||
test('addonfile: an installed add-on is upgraded and a new one installed', () => {
|
||||
|
||||
@@ -87,6 +87,12 @@ func (o *HostGrantsOwner) fileCall(ctx context.Context, a contract.Actor, call H
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
bad := func() (any, error) { return nil, o.invalid() }
|
||||
// An unsigned file installs only where UI host grants work: when the
|
||||
// deployment locks them (sign-in off, or a policy file that is the only
|
||||
// source), nothing can be staged either, and the answer says why.
|
||||
if call.Op == HostGrantsFileBegin && o.cfg.Locked {
|
||||
return nil, hgErr(403, HostGrantsLocked, o.lockedText())
|
||||
}
|
||||
switch call.Op {
|
||||
case HostGrantsFileBegin:
|
||||
var r fileBeginRequest
|
||||
|
||||
@@ -183,6 +183,18 @@ var _ = Describe("add-on zip files reach the owner", func() {
|
||||
Expect(typeOf(err)).To(Equal("not_human_surface"))
|
||||
})
|
||||
|
||||
It("stages nothing while the deployment locks the UI host grants", func() {
|
||||
sim := &fileSim{store: &uploads.Store{Root: filepath.Join(GinkgoT().TempDir(), "uploads")}, max: 1 << 20}
|
||||
fx := newHGFixture(func(c *HostGrantsConfig) { c.Locked = true; c.LockedReason = LockedNoAuth })
|
||||
fx.svc.WithFiles(sim)
|
||||
g := &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok}
|
||||
_, err := g.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("x"))
|
||||
Expect(typeOf(err)).To(Equal("locked_by_deployment"))
|
||||
Expect(err.Error()).To(Equal(LockedNoAuthText))
|
||||
ents, _ := os.ReadDir(sim.store.Root)
|
||||
Expect(ents).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("answers unavailable when the owner has no file service", func() {
|
||||
fx := newHGFixture()
|
||||
g := &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok}
|
||||
|
||||
@@ -1108,6 +1108,15 @@ and the confirmation of an administrator in their own browser session, like
|
||||
every change of what an add-on may do on the host. `--target` and `--api-key`
|
||||
come from `LOCALAI_TARGET` and `LOCALAI_API_KEY` when you leave them out.
|
||||
|
||||
### When sign-in is off or the grants are locked
|
||||
|
||||
An unsigned file installs only where UI host grants work. While sign-in is off,
|
||||
and when a deployment locks the UI grants, the owner refuses to stage a file
|
||||
(`403 locked_by_deployment`) and the dialog says so. Turn the UI grants on
|
||||
knowingly with `--addon-host-ui-grants-without-auth`, or turn sign-in on. A
|
||||
package from a file replaces a package from another library or signer only with
|
||||
the approval of the dialog, like any package from another source.
|
||||
|
||||
### What the server does with the file
|
||||
|
||||
The routes are administrator-only. `POST /api/addons/files` takes the zip as a
|
||||
|
||||
Reference in new issue
Block a user