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:
Ettore Di Giacinto committed 2026-11-03 22:53:34 +00:00
1 parent 398c1d56cb
commit 653ba1afb8
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
View File
@@ -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}
+9
View File
@@ -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