feat(ui): install an add-on from a file

Add an Install from file button to the Add-ons page. The file goes to the
owner, then the install dialog opens with the host access step, the file
sha256 and the notice that nothing signs a local file. An upgrade does
not turn the add-on on, an older version needs a tick, and the same
version is refused. The link that local-ai addons install prints opens
the dialog for the staged file. The list, the details and the host access
page show that the add-on came from an unsigned local file.

Assisted-by: Claude:claude-sonnet-5-5 eslint playwright
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 22:52:50 +00:00
1 parent 274d7d983e
commit cbe84cf062
23 files changed
+892 -21

No files matched your search

@@ -0,0 +1,195 @@
import { test, expect } from '@playwright/test'
import { ADMIN, app, installedAddon, sessionFor, mockAddons } from './addons-fixtures.js'
// Install from a file: the button on the Add-ons page, the dialog with the
// unsigned notice and the host access step, the upgrade and the downgrade
// rules, and the link that `local-ai addons install` prints. The owner decides
// every answer, so these specs drive the page against a stand-in for the file
// routes and the host-grants routes and record what the page sent.
const shots = process.env.UI_SHOTS_DIR
async function shoot(page, name) {
if (!shots) return
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.waitForTimeout(400)
await page.screenshot({ path: `${shots}/${name}.png` })
}
const ID = 'c'.repeat(32)
const SHA = 'd'.repeat(64)
const op = (name, title, text, risk, over = {}) => ({ name, title, text, risk, status: 'not_allowed', ...over })
function fileView(over = {}) {
return {
fileId: ID, name: 'memory-0.2.0-linux-amd64.zip', size: 5364318, sha256: SHA, addonId: 'memory', title: 'Short memory',
version: '0.2.0', platform: 'linux/amd64', api: { min: 2, max: 2 }, required: ['workspace.materialize'], optional: ['egress.check'],
installed: false, installedVersion: '', relation: 'new', source: 'local-file', origin: 'local file, unsigned', ...over,
}
}
const PREVIEW = {
addonId: 'memory', title: 'Short memory', digest: 'sha256:' + SHA, version: '0.2.0', installed: false, official: false, revision: 4, nonce: 'nonce-f',
requested: { required: ['workspace.materialize'], optional: ['egress.check'] }, canInstall: true, blockedRequired: [], added: [], blanket: { applies: false },
ops: [
op('workspace.materialize', 'Copy files in', 'Copy files of a chat into the working area of the add-on.', 'changes data'),
op('egress.check', 'Ask about the network', 'Ask the host whether a network connection is allowed.', 'network'),
],
}
async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody } = {}) {
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 }
await page.route(/\/api\/addons\/files(\/[0-9a-f]+)?$/, route => {
const req = route.request()
const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
if (req.method() === 'POST') {
seen.uploads.push({ contentType: req.headers()['content-type'], body: req.postDataBuffer()?.toString('latin1') || '' })
return json(uploadBody || file, uploadStatus)
}
const id = req.url().split('/').pop()
if (req.method() === 'DELETE') { seen.removed.push(id); return route.fulfill({ status: 204 }) }
seen.described.push(id)
return id === ID ? json(file) : json({ error: { type: 'not_found', message: 'No such file' } }, 404)
})
await page.route(/\/api\/addons\/host-grants(\/.*)?(\?.*)?$/, route => {
const req = route.request()
const path = new URL(req.url()).pathname.replace('/api/addons/host-grants', '') || '/'
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 === '/install') {
seen.install.push({ body, confirm: req.headers()['x-localai-confirm'] })
if (!file.installed) {
state.installed.push(installedAddon({ id: 'memory', title: 'Short memory', version: file.version, sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped', generation: 1 }))
state.apps.push(app({ id: 'memory', name: 'memory', title: 'Short memory', version: file.version, generation: 1, enabled: false, state: 'stopped', mainPage: undefined, hasUI: false }))
}
return json({ payload: { OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 1 } })
}
return json({ error: { type: 'addon_error', message: 'unknown' } }, 404)
})
return { state, seen }
}
test.describe('Install from a file', () => {
test('uploads the zip, shows the unsigned notice, the digest and the host operations, and sends the approval', async ({ page }) => {
const { seen } = await open(page)
await page.goto('/app/addons')
await page.getByTestId('install-file-input').setInputFiles({ name: 'memory-0.2.0-linux-amd64.zip', mimeType: 'application/zip', buffer: Buffer.from('PK-fake-zip') })
const dialog = page.getByTestId('install-dialog')
await expect(dialog).toBeVisible()
expect(seen.uploads).toHaveLength(1)
expect(seen.uploads[0].contentType).toContain('multipart/form-data')
expect(seen.uploads[0].body).toContain('name="file"; filename="memory-0.2.0-linux-amd64.zip"')
await expect(dialog.getByTestId('install-unsigned')).toContainText('Local file, unsigned')
await expect(dialog.getByTestId('install-unsigned')).toContainText('Nothing signs this file')
const facts = dialog.getByTestId('install-file-facts')
await expect(facts.getByTestId('install-file-sha')).toHaveText(SHA)
await expect(facts).toContainText('memory 0.2.0')
await expect(facts).toContainText('linux/amd64')
// The same step as for a library entry; a file is never official.
const step = dialog.getByTestId('install-host-access')
await expect(step).toBeVisible()
await expect(step.locator('[data-op="workspace.materialize"]').getByRole('switch')).toBeDisabled()
await expect(step.getByTestId('hostaccess-not-official')).toHaveCount(0)
expect(seen.preview).toEqual([{ sourceId: 'local-file', addonId: 'memory', version: ID }])
await step.locator('[data-op="egress.check"]').getByRole('switch').click()
await shoot(page, 'install-from-file-dialog')
await dialog.getByTestId('install-confirm').click()
await expect(dialog.getByTestId('flow-steps')).toBeVisible()
await expect.poll(() => seen.install.length).toBe(1)
const { body, confirm } = seen.install[0]
expect(confirm).toBe('memory@sha256:' + SHA)
expect(body).toMatchObject({ action: 'install', sourceId: 'local-file', addonId: 'memory', version: ID })
expect(body.downgrade).toBeUndefined()
expect(body.approve).toMatchObject({ digest: 'sha256:' + SHA, revision: 4, nonce: 'nonce-f' })
expect(body.approve.ops.sort()).toEqual(['egress.check', 'workspace.materialize'])
// A fresh install is turned on, like a library install.
await expect.poll(() => seen.dispatch.some(d => d.action === 'enable')).toBe(true)
})
test('an upgrade says what it replaces, installs through the upgrade action and does not turn the add-on on', async ({ page }) => {
const { seen } = await open(page, { file: fileView({ installed: true, installedVersion: '0.1.0', relation: 'upgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] })
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-upgrade')).toContainText('replaces version 0.1.0 with version 0.2.0')
await dialog.getByTestId('install-confirm').click()
await expect.poll(() => seen.install.length).toBe(1)
expect(seen.install[0].body.action).toBe('upgrade')
expect(seen.install[0].body.downgrade).toBeUndefined()
await expect(dialog).toContainText('now runs version 0.2.0')
expect(seen.dispatch.filter(d => d.action === 'enable')).toHaveLength(0)
})
test('an older version needs a tick and sends the downgrade confirmation', async ({ page }) => {
const { seen } = await open(page, { file: fileView({ version: '0.0.9', installed: true, installedVersion: '0.1.0', relation: 'downgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] })
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-downgrade')).toContainText('older than the installed version 0.1.0')
await shoot(page, 'install-from-file-downgrade')
await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
await dialog.getByTestId('install-file-downgrade-confirm').check()
await expect(dialog.getByTestId('install-confirm')).toBeEnabled()
await dialog.getByTestId('install-confirm').click()
await expect.poll(() => seen.install.length).toBe(1)
expect(seen.install[0].body.downgrade).toBe(true)
expect(seen.install[0].body.action).toBe('upgrade')
})
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')
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-same')).toContainText('installed already')
await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
})
test('shows the reason the owner gives when it refuses the file', async ({ page }) => {
await open(page, { uploadStatus: 400, uploadBody: { error: { type: 'wrong_platform', code: 400, message: 'This file is for linux/arm64; this host is linux/amd64' } } })
await page.goto('/app/addons')
await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
const err = page.getByTestId('install-file-error')
await expect(err).toContainText('The file was not accepted.')
await expect(err).toContainText('This file is for linux/arm64; this host is linux/amd64')
await expect(page.getByTestId('install-dialog')).toHaveCount(0)
})
test('the link of the command line opens the dialog for the staged file, and cancel removes the file', async ({ page }) => {
const { seen } = await open(page)
await page.goto(`/app/addons?file=${ID}`)
const dialog = page.getByTestId('install-dialog')
await expect(dialog).toBeVisible()
expect(seen.described).toEqual([ID])
expect(seen.uploads).toHaveLength(0)
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toHaveCount(0)
await expect.poll(() => seen.removed).toEqual([ID])
await expect(page).not.toHaveURL(/file=/)
})
test('a link to a file that is gone says so', async ({ page }) => {
await open(page)
await page.goto(`/app/addons?file=${'e'.repeat(32)}`)
await expect(page.getByTestId('install-file-error')).toContainText('The file is gone')
})
test('the list and the host access page name an add-on from a file as unsigned', async ({ page }) => {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
const installed = installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })
await mockAddons(page, { apps: [{ id: 'memory', name: 'memory', title: 'Short memory', description: 'Notes.', version: '0.2.0', generation: 1, enabled: true, state: 'running', hasUI: false, tools: [], toolCount: 0, launchMode: 'always' }], installed: [installed] })
await page.route(/\/api\/addons\/host-grants$/, route => route.fulfill({ json: {
revision: 1, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} },
addons: [{ id: 'memory', title: 'Short memory', version: '0.2.0', state: 'running', sourceId: 'local-file', requested: { required: [], optional: [] }, ops: [], noLongerRequested: [], newOptional: [], identityMismatch: false, stopped: '' }],
} }))
await page.goto('/app/addons')
await expect(page.locator('[data-source="local-file"]').first()).toContainText('Local file, unsigned')
await page.goto('/app/addons?tab=access')
await expect(page.getByTestId('host-local-file')).toContainText('Local file, unsigned')
})
})
@@ -897,5 +897,38 @@
"reviewNamed": "Den Host-Zugriff von {{name}} prüfen und freigeben",
"title": "{{name}} {{version}} wartet auf deine Freigabe"
}
},
"file": {
"button": "Aus Datei installieren",
"checking": "Die Datei wird geprüft …",
"pick": "Eine ZIP-Datei eines Add-ons auswählen",
"rejected": "Die Datei wurde nicht akzeptiert.",
"gone": "Die Datei ist nicht mehr da. Eine Datei bleibt nach dem Hochladen eine Stunde lang erhalten. Lade sie erneut hoch.",
"installTitle": "{{name}} aus einer Datei installieren?",
"upgradeTitle": "{{name}} aus einer Datei aktualisieren?",
"confirmBody": "Version {{version}} aus der Datei {{name}} wird installiert und dann eingeschaltet.",
"confirmBodyUpgrade": "Version {{version}} aus der Datei {{name}} wird installiert. Das Add-on bleibt ein- oder ausgeschaltet, wie es jetzt ist.",
"upgradeAction": "Auf {{version}} aktualisieren",
"upgraded": "{{name}} läuft jetzt in Version {{version}}.",
"unsignedTitle": "Lokale Datei, nicht signiert",
"unsignedNotice": "Niemand signiert diese Datei, deshalb kann LocalAI nicht erkennen, wer sie erstellt hat. Installiere sie nur, wenn du ihrer Herkunft vertraust.",
"unsignedShort": "Lokale Datei, nicht signiert",
"badge": "Lokale Datei, nicht signiert",
"sourceValue": "Lokale Datei, nicht signiert",
"hostNotice": "Dieses Add-on stammt aus einer lokalen Datei, die niemand signiert. Gib ihm nur den Hostzugriff, den es nachweislich braucht.",
"facts": {
"file": "Datei",
"sha256": "SHA-256 der Datei",
"addon": "Add-on",
"platform": "Plattform",
"api": "Host-API"
},
"relation": {
"upgrade": "Dies ersetzt Version {{installed}} durch Version {{version}}. Die Daten des Add-ons bleiben erhalten.",
"same": "Version {{version}} ist bereits installiert. Wähle eine Datei mit einer anderen Version.",
"downgradeTitle": "Ältere Version",
"downgrade": "Dies installiert Version {{version}}, die älter ist als die installierte Version {{installed}}. Eine ältere Version kann Daten, die eine neuere geschrieben hat, eventuell nicht lesen.",
"downgradeConfirm": "Ich möchte die ältere Version installieren"
}
}
}
@@ -897,5 +897,38 @@
"reviewNamed": "Review and approve the host access of {{name}}",
"title": "{{name}} {{version}} waits for your approval"
}
},
"file": {
"button": "Install from file",
"checking": "Checking the file…",
"pick": "Choose an add-on zip file",
"rejected": "The file was not accepted.",
"gone": "The file is gone. A file waits for one hour after the upload. Upload it again.",
"installTitle": "Install {{name}} from a file?",
"upgradeTitle": "Update {{name}} from a file?",
"confirmBody": "Version {{version}} from the file {{name}} is installed, then turned on.",
"confirmBodyUpgrade": "Version {{version}} from the file {{name}} is installed. The add-on stays on or off as it is now.",
"upgradeAction": "Update to {{version}}",
"upgraded": "{{name}} now runs version {{version}}.",
"unsignedTitle": "Local file, unsigned",
"unsignedNotice": "Nothing signs this file, so LocalAI cannot tell who made it. Install it only if you trust where it came from.",
"unsignedShort": "Local file, unsigned",
"badge": "Local file, unsigned",
"sourceValue": "Local file, unsigned",
"hostNotice": "This add-on came from a local file that nothing signs. Give it only the host access you are sure it needs.",
"facts": {
"file": "File",
"sha256": "File sha256",
"addon": "Add-on",
"platform": "Platform",
"api": "Host API"
},
"relation": {
"upgrade": "This replaces version {{installed}} with version {{version}}. The data of the add-on is kept.",
"same": "Version {{version}} is installed already. Choose a file with another version.",
"downgradeTitle": "Older version",
"downgrade": "This installs version {{version}}, which is older than the installed version {{installed}}. An older version may not read data that a newer one wrote.",
"downgradeConfirm": "I want to install the older version"
}
}
}
@@ -897,5 +897,38 @@
"reviewNamed": "Revisar y aprobar el acceso al host de {{name}}",
"title": "{{name}} {{version}} espera tu aprobación"
}
},
"file": {
"button": "Instalar desde un archivo",
"checking": "Comprobando el archivo…",
"pick": "Elegir un archivo zip de un complemento",
"rejected": "No se aceptó el archivo.",
"gone": "El archivo ya no está. Un archivo se conserva una hora después de subirlo. Súbelo de nuevo.",
"installTitle": "¿Instalar {{name}} desde un archivo?",
"upgradeTitle": "¿Actualizar {{name}} desde un archivo?",
"confirmBody": "La versión {{version}} del archivo {{name}} se instala y después se activa.",
"confirmBodyUpgrade": "La versión {{version}} del archivo {{name}} se instala. El complemento sigue activado o desactivado como está ahora.",
"upgradeAction": "Actualizar a {{version}}",
"upgraded": "{{name}} ahora ejecuta la versión {{version}}.",
"unsignedTitle": "Archivo local, sin firma",
"unsignedNotice": "Nadie firma este archivo, así que LocalAI no puede saber quién lo creó. Instálalo solo si confías en su origen.",
"unsignedShort": "Archivo local, sin firma",
"badge": "Archivo local, sin firma",
"sourceValue": "Archivo local, sin firma",
"hostNotice": "Este complemento viene de un archivo local que nadie firma. Dale solo el acceso al host que estés seguro de que necesita.",
"facts": {
"file": "Archivo",
"sha256": "SHA-256 del archivo",
"addon": "Complemento",
"platform": "Plataforma",
"api": "API del host"
},
"relation": {
"upgrade": "Esto reemplaza la versión {{installed}} por la versión {{version}}. Los datos del complemento se conservan.",
"same": "La versión {{version}} ya está instalada. Elige un archivo con otra versión.",
"downgradeTitle": "Versión anterior",
"downgrade": "Esto instala la versión {{version}}, que es anterior a la versión instalada {{installed}}. Una versión anterior puede no leer los datos que escribió una más nueva.",
"downgradeConfirm": "Quiero instalar la versión anterior"
}
}
}
@@ -901,5 +901,38 @@
"reviewNamed": "Examiner et approuver l'accès à l'hôte de {{name}}",
"title": "{{name}} {{version}} attend votre approbation"
}
},
"file": {
"button": "Installer depuis un fichier",
"checking": "Vérification du fichier…",
"pick": "Choisir un fichier zip de module",
"rejected": "Le fichier n’a pas été accepté.",
"gone": "Le fichier n’est plus là. Un fichier est conservé une heure après l’envoi. Envoyez-le à nouveau.",
"installTitle": "Installer {{name}} depuis un fichier ?",
"upgradeTitle": "Mettre à jour {{name}} depuis un fichier ?",
"confirmBody": "La version {{version}} du fichier {{name}} est installée, puis activée.",
"confirmBodyUpgrade": "La version {{version}} du fichier {{name}} est installée. Le module reste activé ou désactivé comme il l’est maintenant.",
"upgradeAction": "Passer à {{version}}",
"upgraded": "{{name}} fonctionne maintenant en version {{version}}.",
"unsignedTitle": "Fichier local, non signé",
"unsignedNotice": "Personne ne signe ce fichier, donc LocalAI ne peut pas savoir qui l’a créé. Ne l’installez que si vous faites confiance à son origine.",
"unsignedShort": "Fichier local, non signé",
"badge": "Fichier local, non signé",
"sourceValue": "Fichier local, non signé",
"hostNotice": "Ce module vient d’un fichier local que personne ne signe. Ne lui donnez que l’accès à l’hôte dont vous êtes sûr qu’il a besoin.",
"facts": {
"file": "Fichier",
"sha256": "SHA-256 du fichier",
"addon": "Module",
"platform": "Plateforme",
"api": "API de l’hôte"
},
"relation": {
"upgrade": "Cela remplace la version {{installed}} par la version {{version}}. Les données du module sont conservées.",
"same": "La version {{version}} est déjà installée. Choisissez un fichier d’une autre version.",
"downgradeTitle": "Version plus ancienne",
"downgrade": "Cela installe la version {{version}}, plus ancienne que la version installée {{installed}}. Une version plus ancienne peut ne pas lire les données écrites par une version plus récente.",
"downgradeConfirm": "Je veux installer la version plus ancienne"
}
}
}
@@ -897,5 +897,38 @@
"reviewNamed": "Tinjau dan setujui akses host {{name}}",
"title": "{{name}} {{version}} menunggu persetujuan Anda"
}
},
"file": {
"button": "Pasang dari berkas",
"checking": "Memeriksa berkas…",
"pick": "Pilih berkas zip add-on",
"rejected": "Berkas tidak diterima.",
"gone": "Berkas sudah tidak ada. Berkas disimpan satu jam setelah diunggah. Unggah lagi.",
"installTitle": "Pasang {{name}} dari berkas?",
"upgradeTitle": "Perbarui {{name}} dari berkas?",
"confirmBody": "Versi {{version}} dari berkas {{name}} dipasang, lalu dinyalakan.",
"confirmBodyUpgrade": "Versi {{version}} dari berkas {{name}} dipasang. Add-on tetap menyala atau mati seperti sekarang.",
"upgradeAction": "Perbarui ke {{version}}",
"upgraded": "{{name}} sekarang berjalan di versi {{version}}.",
"unsignedTitle": "Berkas lokal, tanpa tanda tangan",
"unsignedNotice": "Tidak ada yang menandatangani berkas ini, jadi LocalAI tidak bisa tahu siapa yang membuatnya. Pasang hanya jika Anda memercayai asalnya.",
"unsignedShort": "Berkas lokal, tanpa tanda tangan",
"badge": "Berkas lokal, tanpa tanda tangan",
"sourceValue": "Berkas lokal, tanpa tanda tangan",
"hostNotice": "Add-on ini berasal dari berkas lokal yang tidak ditandatangani. Beri hanya akses host yang Anda yakini dibutuhkannya.",
"facts": {
"file": "Berkas",
"sha256": "SHA-256 berkas",
"addon": "Add-on",
"platform": "Platform",
"api": "API host"
},
"relation": {
"upgrade": "Ini mengganti versi {{installed}} dengan versi {{version}}. Data add-on tetap disimpan.",
"same": "Versi {{version}} sudah terpasang. Pilih berkas dengan versi lain.",
"downgradeTitle": "Versi lebih lama",
"downgrade": "Ini memasang versi {{version}}, yang lebih lama daripada versi terpasang {{installed}}. Versi yang lebih lama mungkin tidak bisa membaca data yang ditulis versi yang lebih baru.",
"downgradeConfirm": "Saya ingin memasang versi yang lebih lama"
}
}
}
@@ -897,5 +897,38 @@
"reviewNamed": "Controlla e approva l'accesso all'host di {{name}}",
"title": "{{name}} {{version}} aspetta la tua approvazione"
}
},
"file": {
"button": "Installa da file",
"checking": "Controllo del file…",
"pick": "Scegli un file zip di un componente",
"rejected": "Il file non è stato accettato.",
"gone": "Il file non c’è più. Un file resta per un’ora dopo il caricamento. Caricalo di nuovo.",
"installTitle": "Installare {{name}} da un file?",
"upgradeTitle": "Aggiornare {{name}} da un file?",
"confirmBody": "La versione {{version}} del file {{name}} viene installata e poi attivata.",
"confirmBodyUpgrade": "La versione {{version}} del file {{name}} viene installata. Il componente resta attivo o disattivo come è ora.",
"upgradeAction": "Aggiorna a {{version}}",
"upgraded": "{{name}} ora esegue la versione {{version}}.",
"unsignedTitle": "File locale, non firmato",
"unsignedNotice": "Nessuno firma questo file, quindi LocalAI non può sapere chi l’ha creato. Installalo solo se ti fidi della sua origine.",
"unsignedShort": "File locale, non firmato",
"badge": "File locale, non firmato",
"sourceValue": "File locale, non firmato",
"hostNotice": "Questo componente arriva da un file locale che nessuno firma. Dagli solo l’accesso all’host che sei sicuro gli serva.",
"facts": {
"file": "File",
"sha256": "SHA-256 del file",
"addon": "Componente",
"platform": "Piattaforma",
"api": "API dell’host"
},
"relation": {
"upgrade": "Questo sostituisce la versione {{installed}} con la versione {{version}}. I dati del componente vengono conservati.",
"same": "La versione {{version}} è già installata. Scegli un file con un’altra versione.",
"downgradeTitle": "Versione precedente",
"downgrade": "Questo installa la versione {{version}}, più vecchia della versione installata {{installed}}. Una versione più vecchia può non leggere i dati scritti da una più nuova.",
"downgradeConfirm": "Voglio installare la versione precedente"
}
}
}
@@ -897,5 +897,38 @@
"reviewNamed": "{{name}}의 호스트 접근 검토 후 승인",
"title": "{{name}} {{version}}이(가) 승인을 기다립니다"
}
},
"file": {
"button": "파일에서 설치",
"checking": "파일을 확인하는 중…",
"pick": "애드온 zip 파일 선택",
"rejected": "파일이 받아들여지지 않았습니다.",
"gone": "파일이 없습니다. 파일은 업로드 후 한 시간 동안 보관됩니다. 다시 업로드하세요.",
"installTitle": "파일에서 {{name}}을(를) 설치할까요?",
"upgradeTitle": "파일에서 {{name}}을(를) 업데이트할까요?",
"confirmBody": "파일 {{name}}의 {{version}} 버전을 설치한 다음 켭니다.",
"confirmBodyUpgrade": "파일 {{name}}의 {{version}} 버전을 설치합니다. 애드온은 지금처럼 켜짐 또는 꺼짐 상태를 유지합니다.",
"upgradeAction": "{{version}}(으)로 업데이트",
"upgraded": "{{name}}이(가) 이제 {{version}} 버전으로 실행됩니다.",
"unsignedTitle": "로컬 파일, 서명 없음",
"unsignedNotice": "이 파일에는 서명한 사람이 없어서 LocalAI는 누가 만들었는지 알 수 없습니다. 출처를 신뢰할 때만 설치하세요.",
"unsignedShort": "로컬 파일, 서명 없음",
"badge": "로컬 파일, 서명 없음",
"sourceValue": "로컬 파일, 서명 없음",
"hostNotice": "이 애드온은 아무도 서명하지 않은 로컬 파일에서 왔습니다. 꼭 필요하다고 확신하는 호스트 접근 권한만 주세요.",
"facts": {
"file": "파일",
"sha256": "파일 SHA-256",
"addon": "애드온",
"platform": "플랫폼",
"api": "호스트 API"
},
"relation": {
"upgrade": "설치된 {{installed}} 버전을 {{version}} 버전으로 바꿉니다. 애드온의 데이터는 유지됩니다.",
"same": "{{version}} 버전은 이미 설치되어 있습니다. 다른 버전의 파일을 선택하세요.",
"downgradeTitle": "이전 버전",
"downgrade": "설치된 {{installed}} 버전보다 오래된 {{version}} 버전을 설치합니다. 이전 버전은 새 버전이 쓴 데이터를 읽지 못할 수 있습니다.",
"downgradeConfirm": "이전 버전을 설치하겠습니다"
}
}
}
@@ -897,5 +897,38 @@
"reviewNamed": "Revisar e aprovar o acesso ao host de {{name}}",
"title": "{{name}} {{version}} aguarda sua aprovação"
}
},
"file": {
"button": "Instalar de um arquivo",
"checking": "Verificando o arquivo…",
"pick": "Escolher um arquivo zip de complemento",
"rejected": "O arquivo não foi aceito.",
"gone": "O arquivo não está mais lá. Um arquivo fica por uma hora depois do envio. Envie de novo.",
"installTitle": "Instalar {{name}} de um arquivo?",
"upgradeTitle": "Atualizar {{name}} de um arquivo?",
"confirmBody": "A versão {{version}} do arquivo {{name}} é instalada e depois ligada.",
"confirmBodyUpgrade": "A versão {{version}} do arquivo {{name}} é instalada. O complemento continua ligado ou desligado como está agora.",
"upgradeAction": "Atualizar para {{version}}",
"upgraded": "{{name}} agora roda na versão {{version}}.",
"unsignedTitle": "Arquivo local, sem assinatura",
"unsignedNotice": "Ninguém assina este arquivo, então o LocalAI não sabe quem o criou. Instale só se confiar na origem.",
"unsignedShort": "Arquivo local, sem assinatura",
"badge": "Arquivo local, sem assinatura",
"sourceValue": "Arquivo local, sem assinatura",
"hostNotice": "Este complemento veio de um arquivo local que ninguém assina. Dê a ele só o acesso ao host de que você tem certeza que ele precisa.",
"facts": {
"file": "Arquivo",
"sha256": "SHA-256 do arquivo",
"addon": "Complemento",
"platform": "Plataforma",
"api": "API do host"
},
"relation": {
"upgrade": "Isto troca a versão {{installed}} pela versão {{version}}. Os dados do complemento são mantidos.",
"same": "A versão {{version}} já está instalada. Escolha um arquivo com outra versão.",
"downgradeTitle": "Versão mais antiga",
"downgrade": "Isto instala a versão {{version}}, que é mais antiga que a versão instalada {{installed}}. Uma versão mais antiga pode não ler dados escritos por uma mais nova.",
"downgradeConfirm": "Quero instalar a versão mais antiga"
}
}
}
@@ -897,5 +897,38 @@
"reviewNamed": "查看并批准 {{name}} 的主机访问",
"title": "{{name}} {{version}} 等待你的批准"
}
},
"file": {
"button": "从文件安装",
"checking": "正在检查文件…",
"pick": "选择附加组件的 zip 文件",
"rejected": "文件未被接受。",
"gone": "文件已不存在。文件在上传后保留一小时。请重新上传。",
"installTitle": "从文件安装 {{name}}?",
"upgradeTitle": "从文件更新 {{name}}?",
"confirmBody": "将安装文件 {{name}} 中的 {{version}} 版本,然后启用。",
"confirmBodyUpgrade": "将安装文件 {{name}} 中的 {{version}} 版本。附加组件保持现在的开启或关闭状态。",
"upgradeAction": "更新到 {{version}}",
"upgraded": "{{name}} 现在运行 {{version}} 版本。",
"unsignedTitle": "本地文件,未签名",
"unsignedNotice": "没有人为此文件签名,因此 LocalAI 无法确定它由谁制作。只有在你信任其来源时才安装。",
"unsignedShort": "本地文件,未签名",
"badge": "本地文件,未签名",
"sourceValue": "本地文件,未签名",
"hostNotice": "此附加组件来自没有人签名的本地文件。只授予你确信它需要的主机访问权限。",
"facts": {
"file": "文件",
"sha256": "文件 SHA-256",
"addon": "附加组件",
"platform": "平台",
"api": "主机 API"
},
"relation": {
"upgrade": "这会把 {{installed}} 版本替换为 {{version}} 版本。附加组件的数据会保留。",
"same": "{{version}} 版本已安装。请选择另一个版本的文件。",
"downgradeTitle": "较旧的版本",
"downgrade": "这会安装 {{version}} 版本,它比已安装的 {{installed}} 版本更旧。较旧的版本可能无法读取较新版本写入的数据。",
"downgradeConfirm": "我要安装较旧的版本"
}
}
}
@@ -5,6 +5,7 @@ import Button from '../ui/Button'
import { apiUrl } from '../../utils/basePath'
import { AppIcon, ApprovalChip, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits'
import AddonMenu from './AddonMenu'
import { isLocalFile } from './fileInstall'
import { addedByAddon, stateChip } from './addonState'
// One installed add-on as a row: identity tile, name and state, one line of
@@ -49,7 +50,7 @@ export default function AddonRow({ tile, pinned, selected = false, attention = '
)}
{!app && <span className="ad-meta__item">{t('meta.builtinLong')}</span>}
{source && source.sourceId && (
<span className="ad-meta__item"><Icon name="shield" aria-hidden="true" /> {t(signed ? 'meta.signed' : 'meta.checked', { library: source.sourceId })}</span>
<span className="ad-meta__item" data-source={source.sourceId}><Icon name="shield" aria-hidden="true" /> {isLocalFile(source.sourceId) ? t('file.badge') : t(signed ? 'meta.signed' : 'meta.checked', { library: source.sourceId })}</span>
)}
{app && <span className="ad-meta__item"><VersionText version={app.version} /></span>}
</span>
@@ -10,6 +10,7 @@ import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog'
import BlanketPanel from './BlanketPanel'
import DenySection from './DenySection'
import { opNote } from './HostAccessList'
import { isLocalFile } from './fileInstall'
import { stateChip } from './addonState'
import { describeError } from './errors'
import { blanketHolders, editedOps, groupOps, opEditable, pageOps, pendingApprovals } from './hostGrantsState'
@@ -98,6 +99,7 @@ export default function HostAccessView({ grants, api = hostGrantsApi }) {
)}
</div>
</header>
{isLocalFile(addon.sourceId) && <Callout tone="warn" testId="host-local-file" className="ad-callout" title={t('file.unsignedTitle')}>{t('file.hostNotice')}</Callout>}
{stoppedText && <Callout tone="warn" testId="host-stopped" className="ad-callout">{stoppedText}</Callout>}
{addon.identityMismatch && <Callout tone="warn" testId="host-mismatch" className="ad-callout" actions={!locked && <Button size="sm" disabled={grants.busy} onClick={() => revokeAll(addon)}>{t('hostAccess.removeStale')}</Button>}>{t('hostAccess.identityMismatch')}</Callout>}
{addon.newOptional.length > 0 && <Callout tone="info" testId="host-new-optional" className="ad-callout">{t('hostAccess.newOptional', { ops: addon.newOptional.join(', ') })}</Callout>}
@@ -1,5 +1,5 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
@@ -13,6 +13,7 @@ import Callout from '../ui/Callout'
import HostAccessList from './HostAccessList'
import { useHostPreview } from './useHostPreview'
import { approvalBody, hostErrorKey, installBlock, needsStep } from './hostGrantsState'
import { fileAction, fileGate } from './fileInstall'
// The built-in chat tools that the entry replaces and that an installed add-on
// already replaces: only one of them can serve the tool, so the dialog says so
@@ -42,6 +43,10 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
// really stages.
const host = useHostPreview(entry, { enabled: Boolean(entry) && step === 'ask' })
const approval = useRef(null)
// An install from a file: a package nothing signs. The person sees the file's
// own facts and, for an older version, ticks a box first.
const local = entry ? entry.local : undefined
const [downgrade, setDowngrade] = useState(false)
useEffect(() => {
if (!entry) return undefined
@@ -74,13 +79,20 @@ 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 start = () => {
if (local) {
// A file installs only with the approval of this dialog, with or without
// host operations: the owner refuses a file install that has none.
approval.current = approvalBody(host.preview, host.ticked, { action: fileAction(local), sourceId: entry.sourceId, addonId: entry.id, version: entry.version, downgrade: local.relation === 'downgrade' && downgrade })
return actions.installAndTurnOn(entry, { approval: approval.current, turnOn: !local.installed })
}
approval.current = showHost
? approvalBody(host.preview, host.ticked, { action: 'install', sourceId: entry.sourceId, addonId: entry.id, version: entry.version })
: null
return actions.installAndTurnOn(entry, approval.current ? { approval: approval.current } : {})
}
const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on', approval: approval.current })
const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on', approval: approval.current, turnOn: !(local && local.installed) })
const stepState = (id) => {
if (step === 'ask') return 'pending'
if (step === 'done') return 'done'
@@ -92,13 +104,35 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
<div className="confirm-dialog-backdrop" onClick={running ? undefined : onClose}>
<div ref={ref} className="confirm-dialog ad-dialog" role="alertdialog" aria-modal="true" aria-labelledby="install-dialog-title" data-testid="install-dialog" data-running={running ? 'true' : 'false'} data-step={step} onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<span id="install-dialog-title" className="confirm-dialog-title">{t('flow.confirmTitle', { name: entry.title })}</span>
<span id="install-dialog-title" className="confirm-dialog-title">{local ? t(local.installed ? 'file.upgradeTitle' : 'file.installTitle', { name: entry.title }) : t('flow.confirmTitle', { name: entry.title })}</span>
</div>
<div className="confirm-dialog-body ad-confirm">
{step === 'ask' && (
<>
<p>{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}</p>
<EntryAdds entry={entry} />
{local
? <p>{t(local.installed ? 'file.confirmBodyUpgrade' : 'file.confirmBody', { version: local.version, name: local.name })}</p>
: <p>{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}</p>}
{local && (
<Callout tone="warn" testId="install-unsigned" title={t('file.unsignedTitle')}>
{t('file.unsignedNotice')}
</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>
)}
{local && local.relation === 'same' && (
<Callout tone="error" role="alert" testId="install-file-same">{t('file.relation.same', { version: local.version })}</Callout>
)}
{local && local.relation === 'downgrade' && (
<Callout tone="warn" testId="install-file-downgrade" title={t('file.relation.downgradeTitle')}>
<p>{t('file.relation.downgrade', { installed: local.installedVersion, version: local.version })}</p>
<label>
<input type="checkbox" checked={downgrade} onChange={(e) => setDowngrade(e.target.checked)} data-testid="install-file-downgrade-confirm" />
{' '}{t('file.relation.downgradeConfirm')}
</label>
</Callout>
)}
{!local && <EntryAdds entry={entry} />}
{shadowConflicts(entry, installed).map(c => (
<Callout key={c.tool} tone="warn" testId="install-shadow-warning" data-tool={c.tool}>
{t('flow.shadowWarning', { tool: c.tool, name: c.name })}
@@ -119,7 +153,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
<section className="ad-hostaccess" aria-labelledby="install-host-title" data-testid="install-host-access">
<h3 id="install-host-title" className="ad-hostaccess__title">{t('hostAccess.stepTitle')}</h3>
<p>{t('hostAccess.stepIntro', { name: entry.title })}</p>
{host.preview.official === false && (
{!local && host.preview.official === false && (
<p className="ad-quiet" data-testid="hostaccess-not-official">{t('hostAccess.notOfficial', { library: entry.sourceId })}</p>
)}
<HostAccessList ops={host.preview.ops} ticked={host.ticked} onTick={host.setTick} locked={host.preview.locked} />
@@ -132,10 +166,21 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
<p className="ad-quiet">{t('hostAccess.stepFooter')}</p>
</section>
)}
<dl className="dk-kv">
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(entry.digest)}</dd>
<dt>{t('trust.verification')}</dt><dd>{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
</dl>
{local ? (
<dl className="dk-kv" data-testid="install-file-facts">
<dt>{t('file.facts.file')}</dt><dd className="ad-wrap">{local.name}</dd>
<dt>{t('file.facts.sha256')}</dt><dd className="dk-mono ad-wrap" data-testid="install-file-sha">{local.sha256}</dd>
<dt>{t('file.facts.addon')}</dt><dd>{local.addonId} {local.version}</dd>
<dt>{t('file.facts.platform')}</dt><dd>{local.platform}</dd>
<dt>{t('file.facts.api')}</dt><dd>{local.api.min === local.api.max ? local.api.min : `${local.api.min} - ${local.api.max}`}</dd>
<dt>{t('trust.verification')}</dt><dd>{t('file.unsignedShort')}</dd>
</dl>
) : (
<dl className="dk-kv">
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(entry.digest)}</dd>
<dt>{t('trust.verification')}</dt><dd>{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
</dl>
)}
</>
)}
{step !== 'ask' && (
@@ -154,7 +199,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
)}
{step === 'done' && (
<p className="ad-entry__note" data-tone="ok" role="status">
{t('browse.installedOn', { name: entry.title })}
{local && local.installed ? t('file.upgraded', { name: entry.title, version: local.version }) : t('browse.installedOn', { name: entry.title })}
{entry.ui && entry.ui.mainPage && <> <Link to="/app/addons">{t('flow.seeInList')}</Link></>}
</p>
)}
@@ -168,7 +213,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
</div>
<div className="confirm-dialog-actions">
{step === 'ask' && <Button onClick={onClose}>{t('catalog.cancel')}</Button>}
{step === 'ask' && <Button variant="primary" onClick={start} disabled={host.status === 'loading' || Boolean(block)} data-testid="install-confirm">{t('flow.installAndTurnOn')}</Button>}
{step === 'ask' && <Button variant="primary" onClick={start} disabled={host.status === 'loading' || (Boolean(local) && host.status !== 'ready') || Boolean(block) || !gate.allowed} data-testid="install-confirm">{local && local.installed ? t('file.upgradeAction', { version: local.version }) : t('flow.installAndTurnOn')}</Button>}
{running && <Button variant="primary" icon="download" busy disabled>{step === 'install' ? t('flow.installing', { name: entry.title }) : t('flow.turningOn', { name: entry.title })}</Button>}
{step === 'failed' && <Button onClick={onClose}>{t('catalog.close')}</Button>}
{step === 'failed' && !actions.unknown[entry.id] && <Button variant="primary" icon="refresh" onClick={retry}>{t('catalog.retry')}</Button>}
@@ -0,0 +1,85 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button, Callout } from '../ui'
import InstallDialog from './InstallDialog'
import { useAddonActions } from './useAddonActions'
import { describeError } from './errors'
import { addonsApi, addonsCatalog, addonFilesApi } from '../../utils/addonsClient'
import { fileEntry, fileIdFromParams, normalizeFile } from './fileInstall'
// Install from a file. The button takes the add-on zip that `make addons-zip`
// writes; the owner checks it and the person sees the same dialog as for a
// library entry, with the host access step and the notice that nothing signs a
// local file. A link from `local-ai addons install` carries ?file=<id> of a
// file the command line staged, and opens the dialog straight away.
export default function InstallFromFile({ onInstalled, filesApi = addonFilesApi }) {
const { t } = useTranslation('addons')
const [params, setParams] = useSearchParams()
const input = useRef(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [entry, setEntry] = useState(null)
const actions = useAddonActions(addonsApi, { onSettled: () => onInstalled && onInstalled(), adapter: addonsCatalog })
const linked = fileIdFromParams(params)
const asked = useRef('')
const show = useCallback((raw) => {
const file = normalizeFile(raw)
if (!file.fileId || !file.addonId) throw new Error('unexpected answer')
setEntry(fileEntry(file))
}, [])
const fail = useCallback((e) => {
setError(e && e.type === 'not_found' ? t('file.gone') : describeError(e, t))
}, [t])
const pick = async (e) => {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file) return
setError(null)
setBusy(true)
try { show(await filesApi.upload(file)) } catch (err) { fail(err) } finally { setBusy(false) }
}
// A staged file the link names. Read once per id.
useEffect(() => {
if (!linked || asked.current === linked) return undefined
asked.current = linked
const ctl = new AbortController()
setBusy(true)
filesApi.describe(linked, ctl.signal)
.then(show)
.catch(err => { if (!err || err.name !== 'AbortError') fail(err) })
.finally(() => setBusy(false))
return () => ctl.abort()
}, [linked, filesApi, show, fail])
const close = () => {
const done = entry
setEntry(null)
if (linked) setParams(prev => { const out = new URLSearchParams(prev); out.delete('file'); return out }, { replace: true })
// A file that was not installed is not kept for the next visit.
const flow = done && actions.flows[`flow:${done.key}`]
if (done && !(flow && flow.step === 'done')) filesApi.remove(done.local.fileId).catch(() => {})
}
return (
<>
<input ref={input} type="file" accept=".zip,application/zip" hidden onChange={pick} aria-label={t('file.pick')} data-testid="install-file-input" />
<Button icon="upload" busy={busy} disabled={busy} onClick={() => input.current && input.current.click()} data-testid="addons-install-file">
{busy ? t('file.checking') : t('file.button')}
</Button>
{error && (
<div className="ad-file-error" role="alert" data-testid="install-file-error">
<Callout tone="error" actions={<Button size="sm" onClick={() => setError(null)}>{t('catalog.close')}</Button>}>
{t('file.rejected')} {error}
</Callout>
</div>
)}
<InstallDialog entry={entry} installed={{}} flow={entry ? actions.flows[`flow:${entry.key}`] : undefined} actions={actions} onClose={close} />
</>
)
}
@@ -7,6 +7,7 @@ import { describeError } from './errors'
import { updateView, formatWhen, upgradeAvailability } from './updateState'
import { upgradeFailureWords } from './updateReview'
import RecoveryPanel from './RecoveryPanel'
import { isLocalFile } from './fileInstall'
import { addonUpgrades } from '../../utils/addonUpgrades'
import { reasonText } from './UpgradeControls'
import { UpgradeReason } from '../upgrade/UpgradeBits'
@@ -25,7 +26,7 @@ export function OperatorFacts({ addon, api, language, actions, check, upgraded,
<h3 id="manage-trust" className="ad-section__title">{t('manage.trust')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.verification')}</dt><dd>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</dd>
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}</dd>
<dt>{t('trust.library')}</dt><dd data-testid="trust-library">{isLocalFile(addon.sourceId) ? t('file.sourceValue') : (addon.sourceId || '—')}</dd>
{addon.channel && (<><dt>{t('trust.channel')}</dt><dd>{addon.channel}</dd></>)}
{addon.selection && (<><dt>{t('trust.selection')}</dt><dd>{addon.selection}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</dd>
+73
View File
@@ -0,0 +1,73 @@
// Pure helpers of the install from a file: what the owner found in an
// uploaded zip, shaped for the install dialog that library entries use.
// The source of a package that came from a file. Nothing signs it.
export const LOCAL_FILE = 'local-file'
export function isLocalFile(sourceId) {
return sourceId === LOCAL_FILE
}
const RELATIONS = new Set(['new', 'upgrade', 'same', 'downgrade'])
const list = (v) => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : [])
const text = (v, max = 200) => (typeof v === 'string' ? v.slice(0, max) : '')
// The answer of the upload, checked field by field. Anything the owner did
// not send in the expected shape is dropped, never guessed.
export function normalizeFile(raw) {
const v = raw && typeof raw === 'object' ? raw : {}
const api = v.api && typeof v.api === 'object' ? v.api : {}
return {
fileId: text(v.fileId, 64),
name: text(v.name),
size: Number.isFinite(v.size) ? v.size : 0,
sha256: text(v.sha256, 64).toLowerCase(),
addonId: text(v.addonId, 64),
title: text(v.title) || text(v.addonId, 64),
version: text(v.version, 64),
platform: text(v.platform, 32),
api: { min: Number.isFinite(api.min) ? api.min : 0, max: Number.isFinite(api.max) ? api.max : 0 },
required: list(v.required),
optional: list(v.optional),
installed: v.installed === true,
installedVersion: text(v.installedVersion, 64),
relation: RELATIONS.has(v.relation) ? v.relation : 'new',
}
}
// The entry the install dialog works on. The dialog asks the owner what the
// file needs from the host with the same preview as for a library entry: the
// source is local-file and the version is the id of the staged file.
export function fileEntry(file) {
return {
key: `${LOCAL_FILE}/${file.addonId}/${file.fileId}`,
id: file.addonId,
title: file.title,
sourceId: LOCAL_FILE,
version: file.fileId,
digest: `sha256:${file.sha256}`,
policy: { requireSignature: false },
shadows: [],
local: 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 } = {}) {
if (!file || file.relation === 'same') return { allowed: false, reason: 'same' }
if (file.relation === 'downgrade' && !downgradeTicked) return { allowed: false, reason: 'downgrade' }
return { allowed: true, reason: '' }
}
// The lifecycle action the file needs: an installed add-on is upgraded.
export function fileAction(file) {
return file && file.installed ? 'upgrade' : 'install'
}
// The file id a link carries in ?file=, or '' when it has not the shape of one.
export function fileIdFromParams(params) {
const id = params && typeof params.get === 'function' ? params.get('file') : ''
return /^[0-9a-f]{32}$/.test(id || '') ? id : ''
}
@@ -125,10 +125,11 @@ export function approvedOps(preview, ticked) {
}
// The body of the install or upgrade that carries the approval.
export function approvalBody(preview, ticked, { operationId, action = 'install', sourceId, addonId, version, blanketAck } = {}) {
export function approvalBody(preview, ticked, { operationId, action = 'install', sourceId, addonId, version, blanketAck, downgrade } = {}) {
const approve = { digest: preview.digest, ops: approvedOps(preview, ticked), revision: preview.revision, nonce: preview.nonce }
if (blanketAck) approve.blanketAck = true
return { operationId, action, sourceId, addonId, version, approve }
// Only an install from a file can be a downgrade, and only after a tick.
return { operationId, action, sourceId, addonId, version, ...(downgrade ? { downgrade: true } : {}), approve }
}
// The operations grouped by family (the part before the first dot), in the
@@ -11,7 +11,7 @@ import { normalizeOperation } from '../../utils/addonsApi.js'
// route, which grants only what the verified package declares; without it the
// plain install runs, and the owner refuses it when the add-on needs a
// host operation that is not held.
export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark }, entry, { skipInstall = false, approval = null } = {}) {
export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark }, entry, { skipInstall = false, approval = null, turnOn = true } = {}) {
if (!skipInstall) {
mark({ step: 'install' })
const installed = await run(`install:${entry.key}`, {
@@ -27,6 +27,8 @@ export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark
if (!installed) return null
if (installed.outcome !== 'completed') { mark({ step: 'failed', failedStep: 'install', outcome: installed.outcome, error: installed.error }); return installed }
}
// An upgrade from a file keeps the add-on as it was: it does not turn it on.
if (!turnOn) { mark({ step: 'done' }); return { outcome: 'completed' } }
mark({ step: 'turn-on' })
// Generation 0 sets no precondition: the add-on was just installed.
const on = await run(`enable:${entry.id}:0`, {
+10 -2
View File
@@ -9,6 +9,7 @@ import AppsView from '../components/addons/AppsView'
import BrowseView from '../components/addons/BrowseView'
import LibrariesView from '../components/addons/LibrariesView'
import HostAccessView from '../components/addons/HostAccessView'
import InstallFromFile from '../components/addons/InstallFromFile'
import { useHostGrants } from '../components/addons/useHostGrants'
import { pendingApprovals } from '../components/addons/hostGrantsState'
import { resolveManage, resolveTab, visibleTabs } from '../components/addons/addonState'
@@ -24,6 +25,8 @@ export default function Addons() {
// The installed count for the tab and whether the list is a first run (the
// empty state then carries the way to Find add-ons, so the header does not).
const [summary, setSummary] = useState(null)
// A file install changes the list; the list is read again.
const [listKey, setListKey] = useState(0)
const [params, setParams] = useSearchParams()
const manage = resolveManage(params, isAdmin)
const tab = manage ? 'apps' : resolveTab(params.get('tab'), isAdmin)
@@ -68,7 +71,12 @@ export default function Addons() {
<PageHeader
title={t('title')}
supporting={<>{t('supporting')}{pagesNote && <> {pagesNote}</>}</>}
actions={showFind ? <Button variant="primary" icon="store" to="/app/addons?tab=browse" data-testid="addons-add">{t('drawer.add')}</Button> : null}
actions={isAdmin && tab === 'apps' ? (
<>
{showFind && <Button variant="primary" icon="store" to="/app/addons?tab=browse" data-testid="addons-add">{t('drawer.add')}</Button>}
<InstallFromFile onInstalled={() => setListKey(n => n + 1)} />
</>
) : null}
/>
{tabs.length > 1 && (
@@ -95,7 +103,7 @@ export default function Addons() {
)}
<div role="tabpanel" id={`ad-panel-${tab}`} aria-labelledby={tabs.length > 1 ? `ad-tab-${tab}` : undefined} aria-label={tabs.length > 1 ? undefined : t('tabs.apps')} className="ad-panel">
{tab === 'apps' && <AppsView api={addonsApi} adapter={addonsCatalog} onSummary={setSummary} grants={grants} />}
{tab === 'apps' && <AppsView key={listKey} api={addonsApi} adapter={addonsCatalog} onSummary={setSummary} grants={grants} />}
{tab === 'browse' && <BrowseView api={addonsApi} adapter={addonsCatalog} />}
{tab === 'libraries' && <LibrariesView adapter={addonsCatalog} />}
{tab === 'access' && <HostAccessView grants={grants} />}
+37
View File
@@ -0,0 +1,37 @@
// Client of the add-on file routes: an add-on zip is staged on the owner first,
// then installed through the host access install. Staging runs nothing; the
// owner keeps the file for an hour, for the person who uploaded it.
import { classifyHostGrants } from './hostGrantsApi.js'
// The id of a staged file: 32 lower-case hex characters.
export const FILE_ID = /^[0-9a-f]{32}$/
// The rules a file can break, and the answers about a staged file. Each is a
// final answer with words of its own from the owner.
const TYPES = new Set([
'bad_zip', 'unsafe_path', 'unsupported_entry', 'too_large', 'too_many_files', 'compression_ratio',
'bad_manifest', 'digest_mismatch', 'wrong_platform', 'bad_mode', 'not_found', 'busy', 'invalid_request',
])
export function classifyFiles(status, body) {
const err = body && typeof body === 'object' ? body.error : null
const type = err && typeof err === 'object' && typeof err.type === 'string' ? err.type : ''
if (TYPES.has(type)) {
return { kind: 'invalid', type, status, message: typeof err.message === 'string' ? err.message : '', details: err }
}
return classifyHostGrants(status, body)
}
export function createAddonFilesApi(addonsApi) {
const call = (path, opts = {}) => addonsApi.call(`/api/addons/files${path}`, { classify: classifyFiles, ...opts })
return {
// Sends the file and answers with what the owner found in it.
upload(file, signal) {
const form = new FormData()
form.append('file', file, file.name)
return call('', { method: 'POST', body: form, signal })
},
describe(id, signal) { return call(`/${encodeURIComponent(id)}`, { signal }) },
async remove(id, signal) { await call(`/${encodeURIComponent(id)}`, { method: 'DELETE', signal }) },
}
}
+4 -2
View File
@@ -59,11 +59,13 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) {
async function call(path, { method = 'GET', body, signal, headers: extra, classify = classifyResponse } = {}) {
let response
try {
// A form goes as it is: the browser writes the multipart boundary.
const form = typeof FormData !== 'undefined' && body instanceof FormData
response = await doFetch(base(path), {
method,
signal,
headers: { ...(body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }), ...(extra || {}) },
body: body === undefined ? undefined : JSON.stringify(body),
headers: { ...(body === undefined || form ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }), ...(extra || {}) },
body: body === undefined ? undefined : form ? body : JSON.stringify(body),
credentials: 'same-origin',
})
} catch (e) {
+2
View File
@@ -2,8 +2,10 @@
import { apiUrl } from './basePath'
import { createAddonsApi, createAddonsCatalogAdapter, normalizeOperation } from './addonsApi'
import { createHostGrantsApi } from './hostGrantsApi'
import { createAddonFilesApi } from './addonFilesApi'
export const addonsApi = createAddonsApi({ base: apiUrl })
export const addonsCatalog = createAddonsCatalogAdapter(addonsApi)
export const addonFilesApi = createAddonFilesApi(addonsApi)
export const hostGrantsApi = createHostGrantsApi(addonsApi)
export { normalizeOperation }
@@ -0,0 +1,120 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { LOCAL_FILE, isLocalFile, normalizeFile, fileEntry, fileGate, fileAction, fileIdFromParams } from '../../src/components/addons/fileInstall.js'
import { approvalBody } from '../../src/components/addons/hostGrantsState.js'
import { createAddonFilesApi } from '../../src/utils/addonFilesApi.js'
import { createAddonsApi, AddonsError } from '../../src/utils/addonsApi.js'
import { runInstallAndTurnOn } from '../../src/components/addons/installAndTurnOn.js'
const ID = 'a'.repeat(32)
const RAW = {
fileId: ID, name: 'memory-0.2.0-linux-amd64.zip', size: 1234, sha256: 'AB'.repeat(32), addonId: 'memory', title: 'Short memory',
version: '0.2.0', platform: 'linux/amd64', api: { min: 2, max: 2 }, required: ['workspace.materialize'], optional: ['egress.check'],
installed: true, installedVersion: '0.1.0', relation: 'upgrade', origin: 'local file, unsigned',
}
test('addonfile: the answer of the upload is checked field by field', () => {
const f = normalizeFile(RAW)
assert.equal(f.sha256, 'ab'.repeat(32))
assert.deepEqual(f.api, { min: 2, max: 2 })
assert.equal(f.relation, 'upgrade')
assert.deepEqual(normalizeFile({ ...RAW, relation: 'weird', required: 'x', size: 'big' }).required, [])
assert.equal(normalizeFile({ ...RAW, relation: 'weird' }).relation, 'new')
assert.equal(normalizeFile(null).fileId, '')
})
test('addonfile: the dialog entry names the local file source and the staged file id as the version', () => {
const e = fileEntry(normalizeFile(RAW))
assert.equal(e.sourceId, LOCAL_FILE)
assert.equal(e.version, ID)
assert.equal(e.id, 'memory')
assert.equal(e.digest, 'sha256:' + 'ab'.repeat(32))
assert.equal(e.policy.requireSignature, false)
assert.equal(isLocalFile(e.sourceId), true)
assert.equal(isLocalFile('official'), false)
})
test('addonfile: the same version is never installed, an older one needs the tick', () => {
const f = (relation) => normalizeFile({ ...RAW, relation })
assert.deepEqual(fileGate(f('same'), { downgradeTicked: true }), { allowed: false, reason: 'same' })
assert.deepEqual(fileGate(f('downgrade')), { allowed: false, reason: 'downgrade' })
assert.equal(fileGate(f('downgrade'), { downgradeTicked: true }).allowed, true)
assert.equal(fileGate(f('upgrade')).allowed, true)
assert.equal(fileGate(f('new')).allowed, true)
assert.equal(fileGate(null).allowed, false)
})
test('addonfile: an installed add-on is upgraded and a new one installed', () => {
assert.equal(fileAction(normalizeFile(RAW)), 'upgrade')
assert.equal(fileAction(normalizeFile({ ...RAW, installed: false, relation: 'new' })), 'install')
})
test('addonfile: only a well formed file id in the link opens the dialog', () => {
assert.equal(fileIdFromParams(new URLSearchParams(`file=${ID}`)), ID)
assert.equal(fileIdFromParams(new URLSearchParams('file=../x')), '')
assert.equal(fileIdFromParams(new URLSearchParams('')), '')
assert.equal(fileIdFromParams(null), '')
})
test('addonfile: the approval carries the downgrade confirmation only when it was given', () => {
const preview = { digest: 'sha256:aa', ops: [], revision: 3, nonce: 'n', blockedRequired: [] }
const base = { action: 'upgrade', sourceId: LOCAL_FILE, addonId: 'memory', version: ID }
assert.equal('downgrade' in approvalBody(preview, [], base), false)
assert.equal(approvalBody(preview, [], { ...base, downgrade: true }).downgrade, true)
assert.equal(approvalBody(preview, [], base).version, ID)
})
test('addonfile: the upload is a multipart form with the file part, and the file routes are the documented ones', async () => {
const seen = []
const fetch = async (url, init) => {
seen.push({ url, init })
return { ok: true, status: 200, json: async () => RAW }
}
const api = createAddonFilesApi(createAddonsApi({ fetch }))
const file = new File([new Uint8Array([80, 75])], 'x.zip')
const out = await api.upload(file)
assert.equal(out.fileId, ID)
assert.equal(seen[0].url, '/api/addons/files')
assert.equal(seen[0].init.method, 'POST')
assert.ok(seen[0].init.body instanceof FormData)
assert.equal(seen[0].init.body.get('file').name, 'x.zip')
assert.equal(seen[0].init.headers['Content-Type'], undefined, 'the browser writes the boundary')
await api.describe(ID)
assert.equal(seen[1].url, `/api/addons/files/${ID}`)
await api.remove(ID)
assert.equal(seen[2].init.method, 'DELETE')
})
test('addonfile: a refusal of the file keeps the reason the owner gave', async () => {
const fetch = async () => ({ ok: false, status: 400, json: async () => ({ error: { type: 'wrong_platform', message: 'This file is for linux/arm64; this host is linux/amd64' } }) })
const api = createAddonFilesApi(createAddonsApi({ fetch }))
await assert.rejects(api.upload(new File([new Uint8Array([1])], 'x.zip')), (e) => {
assert.ok(e instanceof AddonsError)
assert.match(e.message, /linux\/arm64/)
return true
})
})
test('addonfile: an upgrade from a file does not turn the add-on on, a new install does', async () => {
const marks = []
const ran = []
const run = async (key, op) => { ran.push(key); await op.submit('op1', undefined); return { outcome: 'completed' } }
const hostGrants = { install: async (body) => ({ payload: { OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 2 } }) }
const adapter = { submitLifecycle: async () => { throw new Error('plain install must not run') } }
const api = { installed: async () => [{ id: 'memory', enabled: true }] }
const entry = { key: 'k', id: 'memory', sourceId: LOCAL_FILE, version: ID }
const approval = { action: 'upgrade', sourceId: LOCAL_FILE, addonId: 'memory', version: ID, approve: { digest: 'sha256:aa', ops: [], revision: 0, nonce: 'n' } }
await runInstallAndTurnOn({ run, api, adapter, hostGrants, mark: v => marks.push(v.step) }, entry, { approval, turnOn: false })
assert.deepEqual(ran, ['install:k'])
assert.deepEqual(marks, ['install', 'done'])
ran.length = 0; marks.length = 0
adapter.submitLifecycle = async () => ({ payload: {} })
await runInstallAndTurnOn({ run, api, adapter, hostGrants, mark: v => marks.push(v.step) }, entry, { approval })
assert.deepEqual(ran, ['install:k', 'enable:memory:0'])
})
test('addonfile: a gone file is a typed answer, not an outage', async () => {
const fetch = async () => ({ ok: false, status: 404, json: async () => ({ error: { type: 'not_found', message: 'No such file' } }) })
const api = createAddonFilesApi(createAddonsApi({ fetch }))
await assert.rejects(api.describe(ID), (e) => e.kind === 'invalid' && e.type === 'not_found' && e.status === 404)
})