mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 20:43:27 -05:00
Chats and agents can read a Home Assistant instance and, if the person allows it, switch things in it. Read tools: server summary, areas, entity search, state, history, logbook and the catalogue of callable services. Results are bounded, marked as untrusted data, and stripped of pictures, access tokens, secrets and locations. Write tools: call-service for quiet services (lights, switches, input booleans, media transport) follows the write mode of the connection; call-physical-service always needs a confirmation. A deny-by-default allowlist decides which services a model may call. A hard exclusion list that no setting can lift covers locks, alarms, sirens, valves, cameras, garage-type covers, restarts, reloads and shell, system and add-on management. Calls name entities one by one, up to a cap. Entities can be excluded by domain, area, pattern or label. One connection is one instance: the token is the credential and the address is its one allowed resource, so the host refuses any other address before the add-on runs. The add-on speaks only to that address, refuses redirects, verifies TLS, and accepts a self-signed certificate only through a pinned SHA-256 fingerprint. The policy lives in the add-on data folder and is edited on the add-on page by an administrator. For that, the host no longer asks a credential for a uiOnly tool of a credentialed add-on, since the page has no chat to name a connection. Assisted-by: Claude:claude-sonnet-5-5 go node
74 lines
3.8 KiB
JavaScript
74 lines
3.8 KiB
JavaScript
// Studio and Talk are packaged add-ons. A spec that draws one of them installs
|
|
// the real built bundle here: the app list names it, and its module and style
|
|
// sheet are served from the build output the way the add-on owner serves them.
|
|
// Specs open canonical declared pages, never historical standalone redirects.
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { app, mockAddons, stubAddonRoutes } from './addons-fixtures.js'
|
|
|
|
const addons = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../addons')
|
|
const DIGEST = 'e'.repeat(64)
|
|
const TYPES = { js: 'text/javascript', css: 'text/css', png: 'image/png' }
|
|
const built = new Map()
|
|
|
|
// The build empties and rewrites addons/<id>/ui/dist, and every worker serves
|
|
// its files, so two builds at once leave a running spec without its style sheet
|
|
// or module. The run builds once, before any worker starts (global-setup.js),
|
|
// and says so in this variable.
|
|
const BUILT = 'E2E_ADDON_BUNDLES_BUILT'
|
|
|
|
// Builds the bundles for the whole run. Called by the Playwright global setup.
|
|
export async function buildAddonBundles(ids) {
|
|
for (const id of ids) await import(path.join(addons, id, 'ui/build.mjs')).then(m => m.build())
|
|
}
|
|
|
|
// The add-ons the specs serve from their build output: Studio, Talk and Voice
|
|
// through installStudioAddons, Face and Fine-tuning through their own routes.
|
|
export async function buildStudioAddons(ids = ['studio', 'talk', 'voice', 'face', 'fine-tuning', 'telegram', 'slack', 'github', 'discord', 'home-assistant']) {
|
|
await buildAddonBundles(ids)
|
|
process.env[BUILT] = '1'
|
|
}
|
|
|
|
// The bundle of one add-on. A run that did not go through the global setup
|
|
// builds it here, once per worker, so a spec never runs against a stale one.
|
|
function build(id) {
|
|
if (process.env[BUILT]) return Promise.resolve(path.join(addons, id, 'ui/dist'))
|
|
if (!built.has(id)) built.set(id, import(path.join(addons, id, 'ui/build.mjs')).then(m => m.build()))
|
|
return built.get(id)
|
|
}
|
|
|
|
export function packagedApp(id, over = {}) {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(addons, id, 'manifest.json'), 'utf8'))
|
|
const ui = manifest.capabilities.ui
|
|
const base = `/api/addons/${id}/assets/${DIGEST}`
|
|
return app({
|
|
id, name: id, title: ui.title, description: ui.description, icon: undefined, generation: 1, version: manifest.version,
|
|
mainPage: `/app/addons/${id}/${ui.mainPage}`, tools: [], toolCount: 0,
|
|
ui: { entry: `${base}/${ui.entry}`, css: ui.css.map(c => `${base}/${c}`), sdk: ui.sdk, pages: ui.pages, navigation: ui.navigation, modelActions: ui.modelActions, homeCommands: ui.homeCommands?.map(c => ({ ...c, page: c.page || ui.mainPage, label: c.label || ui.title, icon: c.icon || 'puzzle' })) },
|
|
...over,
|
|
})
|
|
}
|
|
|
|
// Installs the add-ons and the routes that serve them. `ids` picks which.
|
|
// Returns the state of mockAddons.
|
|
export async function installStudioAddons(page, { ids = ['studio', 'talk'], ...options } = {}) {
|
|
const dirs = await Promise.all(ids.map(build))
|
|
await stubAddonRoutes(page)
|
|
await page.route(/\/api\/addons\/(studio|talk|voice|telegram|slack|github|discord|home-assistant)\/assets\//, route => {
|
|
const parts = new URL(route.request().url()).pathname.split('/')
|
|
const id = parts[3]
|
|
const file = parts.slice(6).join('/')
|
|
const dir = dirs[ids.indexOf(id)]
|
|
const ext = file.split('.').pop()
|
|
if (!dir || !TYPES[ext] || !fs.existsSync(path.join(dir, file))) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: TYPES[ext],
|
|
headers: { 'cache-control': 'private, max-age=31536000, immutable', 'x-content-type-options': 'nosniff' },
|
|
body: fs.readFileSync(path.join(dir, file)),
|
|
})
|
|
})
|
|
return mockAddons(page, { apps: ids.map(id => packagedApp(id)), ...options })
|
|
}
|