)}
diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx
index 147a5d6c6..db902748d 100644
--- a/core/http/react-ui/src/pages/Models.jsx
+++ b/core/http/react-ui/src/pages/Models.jsx
@@ -1,3 +1,4 @@
+import ModelLaunch from '../addons/ModelLaunch'
// eslint-disable-next-line no-unused-vars
import { useState, useCallback, useEffect, useLayoutEffect, useRef, Suspense } from 'react'
// eslint-disable-next-line no-unused-vars
@@ -1702,7 +1703,7 @@ function DiscoverDetail({
const ledgerFit = fitFor(vramBytes, budget, ramAvailable)
const contextLabel = CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]
const headroom = totalGpuMemory > 0 && vramBytes ? totalGpuMemory * 0.95 - vramBytes : null
- const openUseCase = modelUseCases(installedProfile).find(useCase => useCase.route)
+ const openUseCases = modelUseCases(installedProfile)
const leaves = leavesFree(disk, estimate?.sizeBytes)
const canInstall = !installing && !model.installed
@@ -1736,12 +1737,7 @@ function DiscoverDetail({
) : model.installed ? (
<>
- {openUseCase && (
-
- )}
+ {openUseCases.map(useCase => )}
diff --git a/core/http/react-ui/src/router.jsx b/core/http/react-ui/src/router.jsx
index ba5f3cb5d..aac445621 100644
--- a/core/http/react-ui/src/router.jsx
+++ b/core/http/react-ui/src/router.jsx
@@ -169,7 +169,7 @@ import HubLayout from './components/hub/HubLayout'
import { AddonRegistryProvider } from './addons/registry'
import { useAuth } from './context/AuthContext'
import { NavigationProvider, useNavigation } from './context/NavigationContext'
-import { addonPageAddress, bareChatsRedirect, chatsRedirect, studioAddress, studioRedirect, talkAddress } from './utils/legacyRoutes'
+import { bareChatsRedirect, chatsRedirect } from './utils/legacyRoutes'
import { legacyAddonsHub, operateHub } from './components/hub/hubConfig'
import { useHubAuth } from './components/hub/useHubAuth'
@@ -199,63 +199,16 @@ function AgentRedirect() {
return
}
-// An old generator link opens the same generator inside the Studio add-on. The
-// model, the query string and the hash are kept.
-function StudioTabRedirect({ legacy }) {
- const { model } = useParams()
- const { search, hash } = useLocation()
- return
-}
-
-// /app/studio[/tab[/model]] is a page of the Studio add-on now.
-function StudioRedirect() {
- const { tab, model } = useParams()
- const { search, hash } = useLocation()
- return
-}
-
-// /app/talk is a page of the Talk add-on now.
-function TalkRedirect() {
- const { search, hash } = useLocation()
- return
-}
-
function BrowseRedirect() {
const { '*': splat } = useParams()
return
}
-// The pages of Face, Voice, Fine-tuning and Quantization live only in their
-// add-ons. The old address opens the add-on page; when the add-on is not
-// installed or not ready, the add-on shell says so. withModel carries the
-// :model segment of the old route as ?model=: an add-on path segment
-// refuses "/", ":" and "%" and long ids (resolveSubpath), so a model id cannot
-// travel in the path.
-function AddonRedirect({ addon, page, withModel }) {
- const { search, hash } = useLocation()
- const { model } = useParams()
- return
-}
-
function Admin({ children }) {
return {children}
}
-// The layout of the add-on routes: the Build hub bar under the legacy switch,
-// the bare page otherwise.
-// With add-ons off (features.addons === false) the add-on addresses and the
-// old fine-tuning and quantization addresses lead back to /app. An absent key
-// counts as on.
-// Home then says that add-ons are off and how to turn them on (state.addonsOff
-// names the add-on the address was for).
-function AddonsOffRedirect({ addon, children }) {
- const { features, featuresLoaded } = useHubAuth()
- if (!featuresLoaded) return null
- if (features.addons === false) return
- return children
-}
-
function AddonsShell() {
const { features, featuresLoaded } = useHubAuth()
const outletContext = useOutletContext()
@@ -323,27 +276,6 @@ const appChildren = [
{ path: 'triggers/:id/edit', element: },
{ path: 'triggers/:id', element: },
{ path: 'agent-tasks', element: },
- { path: 'image', element: },
- { path: 'image/:model', element: },
- { path: 'video', element: },
- { path: 'video/:model', element: },
- { path: '3d', element: },
- { path: '3d/:model', element: },
- { path: 'tts', element: },
- { path: 'tts/:model', element: },
- { path: 'sound', element: },
- { path: 'sound/:model', element: },
- { path: 'diarization', element: },
- { path: 'diarization/:model', element: },
- { path: 'transform', element: },
- { path: 'transform/:model', element: },
- // Studio and Talk are add-ons; the old addresses open them. When the add-on
- // is not installed the add-on page says so.
- { path: 'studio', element: },
- { path: 'studio/:tab', element: },
- // A generator opened on one model: /app/studio/images/.
- { path: 'studio/:tab/:model', element: },
- { path: 'talk', element: },
{ path: 'account', element: },
// The Add-ons drawer. A viewer without the addons feature still reaches it
// when a built-in tile is theirs to open (the built-in list is empty today).
@@ -364,13 +296,6 @@ const appChildren = [
{ path: 'build', element: },
{ path: 'collections', element: },
{ path: 'collections/:name', element: },
- { path: 'fine-tune', element: },
- { path: 'quantize', element: },
- { path: 'face', element: },
- { path: 'face/:model', element: },
- { path: 'voice', element: },
- { path: 'voice/:model', element: },
- { path: 'voice-library', element: },
{ path: 'import-model', element: },
// The user's MCP connectors have a page of their own; the old address was a
// tab of the messaging connectors.
@@ -420,7 +345,6 @@ const appChildren = [
children: [{ path: ':id', element: }],
},
{ path: 'manage', element: },
- { path: 'voice-library/new', element: },
{ path: 'model-editor', element: },
{ path: 'model-editor/:name', element: },
{ path: 'ui-catalog', element: },
diff --git a/core/http/react-ui/src/utils/addonUpgrades.js b/core/http/react-ui/src/utils/addonUpgrades.js
index 139d643fa..e02222bb1 100644
--- a/core/http/react-ui/src/utils/addonUpgrades.js
+++ b/core/http/react-ui/src/utils/addonUpgrades.js
@@ -70,6 +70,16 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
},
})
+ const expireAttempt = (current, { jobID, addon, key, operationId }) => {
+ if (current()) return false
+ // A later session may already hold the same add-on and generation key.
+ if (book.pending(addon.id)?.operationId === operationId) book.resolve(addon.id, key)
+ if (snapshot.live.some(op => op.jobID === jobID)) {
+ emit({ ...snapshot, live: snapshot.live.filter(op => op.jobID !== jobID) })
+ }
+ return true
+ }
+
const store = {
bind(api, principal, persistence) {
if (scope === principal && storage === persistence) return
@@ -116,7 +126,8 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
// Upgrades one installed add-on (the record of `addons-installed`). Never
// throws: resolves { ok, message, outcome } where message is the key of the
// words of a failure (see updateErrorKey) and is empty on success.
- async start(api, addon) {
+ async start(api, addon, { current = () => true } = {}) {
+ if (!current()) return null
const view = updateView(addon && addon.update)
if (!view.canUpgrade || !addon.digest) return { ok: false, outcome: 'invalid', message: 'incompatible' }
if (snapshot.live.some(op => op.addonId === addon.id && op.state !== 'failed')) return { ok: false, outcome: 'busy', message: 'operation_conflict' }
@@ -150,15 +161,21 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
...snapshot,
live: [...withoutOld, { ...base, jobID, operationId, state: 'running', phase: 'submitting', progress: 0, cancellable: false, error: '', errorKey: '' }],
})
- return store.follow(api, { jobID, addon, operationId, key, controller, submit: true })
+ return store.follow(api, { jobID, addon, operationId, key, controller, submit: true, current })
},
// Runs the submit (or, with submit false, only reads the earlier operation)
// and turns its end into the entry of a list.
- async follow(api, { jobID, addon, operationId, key, controller, submit }) {
+ async follow(api, { jobID, addon, operationId, key, controller, submit, current = () => true }) {
const followingEpoch = epoch
const signal = controller.signal
- const tracked = trackPhases(api, jobID)
+ const tracked = trackPhases({ ...api, operationStatus: async (...args) => {
+ if (!current()) throw new DOMException('Aborted', 'AbortError')
+ const status = await api.operationStatus(...args)
+ if (!current()) throw new DOMException('Aborted', 'AbortError')
+ return status
+ } }, jobID)
+ const expired = () => expireAttempt(current, { jobID, addon, key, operationId })
try {
let outcome
// While the add-on service does not answer, the entry says that it
@@ -180,6 +197,7 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
} else {
outcome = (await settleOperation(tracked, { operationId, addonId: addon.id, signal, waits: pollWaits, sleep: sleepOf(jobID), onWait })).outcome
}
+ if (expired()) return null
let verified
if (outcome === 'completed') {
try {
@@ -191,10 +209,12 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
}
}
if (followingEpoch !== epoch) return { ok: false, outcome: "detached", message: "" }
+ if (expired()) return null
return store.conclude(jobID, addon, key, effectOutcome(outcome, verified), operationId)
} catch (e) {
+ if (expired()) return null
if (followingEpoch !== epoch) return { ok: false, outcome: "detached", message: "" }
- if (e && e.name === 'AbortError' && cancelled.has(jobID)) return store.afterCancel(api, { jobID, addon, operationId, key })
+ if (e && e.name === 'AbortError' && cancelled.has(jobID)) return store.afterCancel(api, { jobID, addon, operationId, key, current })
const kind = e && e.kind ? e.kind : 'unavailable'
if (kind === 'denied' || kind === 'invalid' || kind === 'auth') book.resolve(addon.id, key)
const errorKey = updateErrorKey(e && e.kind ? e : { kind })
@@ -224,12 +244,15 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
},
// After the request was aborted by a cancel: what did the owner end up with?
- async afterCancel(api, { jobID, addon, operationId, key }) {
+ async afterCancel(api, { jobID, addon, operationId, key, current = () => true }) {
cancelled.delete(jobID)
+ const expired = () => expireAttempt(current, { jobID, addon, key, operationId })
+ if (expired()) return null
let outcome = 'unknown'
try {
outcome = (await settleOperation(api, { operationId, addonId: addon.id, waits: [] })).outcome
} catch { /* an unreadable status stays unknown */ }
+ if (expired()) return null
const op = snapshot.live.find(o => o.jobID === jobID)
if (outcome === 'completed') {
book.resolve(addon.id, key)
@@ -267,7 +290,8 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
// Try a failed upgrade again. An unknown outcome is only read, never
// submitted a second time; anything else starts from the installed record
// as it is now, so a changed generation or candidate is honoured.
- async retry(api, jobID) {
+ async retry(api, jobID, { current = () => true } = {}) {
+ if (!current()) return null
const op = snapshot.live.find(o => o.jobID === jobID)
if (!op || op.state !== 'failed') return { ok: false, outcome: 'invalid', message: 'operation_conflict' }
const held = book.pending(op.addonId)
@@ -276,7 +300,7 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
controllers.set(jobID, controller)
patch(jobID, { state: 'running', cancellable: false, error: '', errorKey: '' })
const addon = op.addonRecord || { id: op.addonId, generation: Number(held.key.split(':')[2]) || 0 }
- return store.follow(api, { jobID, addon, operationId: held.operationId, key: held.key, controller, submit: false })
+ return store.follow(api, { jobID, addon, operationId: held.operationId, key: held.key, controller, submit: false, current })
}
let fresh
try {
@@ -284,8 +308,9 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create
} catch (e) {
return { ok: false, outcome: 'unavailable', message: updateErrorKey(e) }
}
+ if (!current()) return null
if (!fresh) return { ok: false, outcome: 'invalid', message: 'stale_installed' }
- return store.start(api, fresh)
+ return store.start(api, fresh, { current })
},
dismiss(jobID) {
diff --git a/core/http/react-ui/src/utils/chatCommands.js b/core/http/react-ui/src/utils/chatCommands.js
index c02686c27..602e0ba12 100644
--- a/core/http/react-ui/src/utils/chatCommands.js
+++ b/core/http/react-ui/src/utils/chatCommands.js
@@ -1,23 +1,5 @@
-// Whether a chat can run commands and code, from the "commands" field of the
-// chat's add-on list (GET /api/chats/:id/addons). The server computes it from
-// the tools the model gets at the next turn:
-// available a tool runs commands
-// off the Sandbox add-on is installed but off for this chat
-// unavailable the Sandbox add-on is installed but cannot run commands here
-// none no add-on that runs commands is installed for the user
-// When commands_provider is builtin, bash runs directly on this machine (no
-// Sandbox serves it); commandsState turns that into one of three states of its
-// own, so the notice says that commands run here, not isolated:
-// host no Sandbox
-// host-off the Sandbox is off for this chat
-// host-unavailable the Sandbox cannot run (commands_fallback_reason)
-// An older server sends no field: then nothing is shown.
-
-export const SANDBOX_ADDON_ID = 'sandbox'
-
-// Where an administrator installs the Sandbox. The first-start wizard step
-// "Run code safely" links here too.
-export const SANDBOX_INSTALL_LINK = `/app/addons?tab=browse&q=${SANDBOX_ADDON_ID}`
+// Command availability and isolation come from the selected provider DTO, not
+// an add-on's private discovery response or its name.
// commandsState reads the commands fields of an add-on list as one state and
// its reason.
@@ -30,6 +12,15 @@ export function commandsState(res) {
if (res.commands_fallback) return { state: 'host-unavailable', reason: why }
return { state: 'host', reason: '' }
}
+ const observation = res?.execution_status
+ if (state === 'available') {
+ const isolation = observation?.isolation || res?.commands_isolation
+ if (observation && observation.readiness !== 'available') return { state: 'unknown', reason: plainExplanation(observation.explanation) }
+ if (isolation === 'host') return { state: 'provider-host', reason: plainExplanation(observation?.explanation) }
+ if (isolation !== 'isolated') return { state: 'unknown', reason: plainExplanation(observation?.explanation) }
+ }
+ if (state === 'none' && observation) return { state: 'unknown', reason: plainExplanation(observation.explanation) }
+ if (state === 'unavailable' && (observation || res?.commands_provider)) return { state: 'provider-unavailable', reason: plainExplanation(observation?.explanation || reason) }
return { state, reason }
}
@@ -38,14 +29,21 @@ export function commandsState(res) {
// person who is not one is told to ask one, unless the fix is theirs.
export function commandsNotice(commands, { isAdmin = false, reason = '' } = {}) {
switch (commands) {
+ case 'provider-host':
+ case 'unknown':
+ case 'loading':
+ case 'provider-unavailable':
+ case 'switched_off':
+ return { state: commands, titleKey: commands === 'provider-host' ? 'hostTitle' : 'title', textKey: commands === 'switched_off' ? 'disabled' : commands === 'provider-host' ? 'providerHost' : commands === 'provider-unavailable' ? 'providerUnavailable' : 'unknown', reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null }
+
case 'off':
- return { state: 'off', textKey: 'off', link: null, askKey: null, reason: '' }
+ return { state: 'off', textKey: 'disabled', link: null, askKey: null, reason: '' }
case 'unavailable':
return {
state: 'unavailable',
- textKey: reason ? 'unavailableWhy' : 'unavailable',
+ textKey: 'providerUnavailable',
reason: typeof reason === 'string' ? reason.slice(0, 200) : '',
- link: isAdmin ? { to: `/app/addons?detail=${SANDBOX_ADDON_ID}`, labelKey: 'openSandbox' } : null,
+ link: null,
askKey: isAdmin ? null : 'askFix',
}
case 'none':
@@ -53,25 +51,14 @@ export function commandsNotice(commands, { isAdmin = false, reason = '' } = {})
state: 'none',
textKey: 'none',
reason: '',
- link: isAdmin ? { to: SANDBOX_INSTALL_LINK, labelKey: 'install' } : null,
- askKey: isAdmin ? null : 'askInstall',
+ link: null,
+ askKey: null,
}
- // Commands run, but on this machine: a quiet notice that stays.
+ // Actual host execution never implies that one particular add-on is the fix.
case 'host':
- return {
- state: 'host', titleKey: 'hostTitle', textKey: 'host', reason: '', tone: 'warn',
- link: isAdmin ? { to: SANDBOX_INSTALL_LINK, labelKey: 'install' } : null,
- askKey: isAdmin ? null : 'askInstall',
- }
case 'host-off':
- return { state: 'host-off', titleKey: 'hostTitle', textKey: 'hostOff', reason: '', tone: 'warn', link: null, askKey: null }
case 'host-unavailable':
- return {
- state: 'host-unavailable', titleKey: 'hostTitle', textKey: reason ? 'hostUnavailableWhy' : 'hostUnavailable',
- reason: typeof reason === 'string' ? reason.slice(0, 200) : '', tone: 'warn',
- link: isAdmin ? { to: `/app/addons?detail=${SANDBOX_ADDON_ID}`, labelKey: 'openSandbox' } : null,
- askKey: isAdmin ? null : 'askFix',
- }
+ return { state: commands, titleKey: 'hostTitle', textKey: 'providerHost', reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null }
default:
return null
}
@@ -106,7 +93,7 @@ export function writeHidden(storage, state) {
export function providerTag(card) {
if (!card || typeof card !== 'object') return null
if (card.kind === 'tool_provider' && card.id) {
- return card.id === SANDBOX_ADDON_ID ? { key: 'sandbox' } : { key: 'addon', values: { name: String(card.id) } }
+ return { key: 'addon', values: { name: String(card.id) } }
}
if (card.kind === 'host_exec') {
switch (card.id) {
@@ -126,3 +113,13 @@ export function providerTag(card) {
export function consentExpired(e) {
return e?.status === 410 || e?.body?.error?.type === 'consent_expired'
}
+
+// Plain text only. React text children escape markup; never pass this to HTML/Markdown.
+export function plainExplanation(value) {
+ return typeof value === 'string' ? [...value.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')].slice(0, 200).join('') : ''
+}
+
+export function noticeIdentity(res, chat, actor) {
+ const v = res?.execution_status || {}
+ return JSON.stringify([actor, chat, res?.commands_provider, v.generation, v.incarnation, res?.commands, v.readiness, v.isolation || res?.commands_isolation])
+}
diff --git a/core/http/react-ui/src/utils/chatConnectors.js b/core/http/react-ui/src/utils/chatConnectors.js
index 3d1185c21..88f16ea8c 100644
--- a/core/http/react-ui/src/utils/chatConnectors.js
+++ b/core/http/react-ui/src/utils/chatConnectors.js
@@ -1,5 +1,5 @@
// Helpers of the connectors page. No React, so a node test drives them.
-import { addonPageAddress } from './legacyRoutes.js'
+import { addonPageAddress } from '../addons/addresses.js'
// Groups the bindings of the user by the connector add-on that serves them. A
// kind that a running add-on serves gets a group with the address of the
diff --git a/core/http/react-ui/src/utils/chatEgress.js b/core/http/react-ui/src/utils/chatEgress.js
index 2d1716f46..59e228cc1 100644
--- a/core/http/react-ui/src/utils/chatEgress.js
+++ b/core/http/react-ui/src/utils/chatEgress.js
@@ -37,7 +37,7 @@ export function presetChoices(egress) {
export function networkMode(egress) {
if (!egress || !egress.loaded) return 'hidden'
if (egress.applies) return egress.addon ? 'row' : 'hidden'
- return egress.applies_reason === 'host' ? 'host' : 'hidden'
+ return egress.applies_reason === 'host' ? 'host' : 'unverified'
}
export const hostPort = (c) => `${c.host}:${c.port}`
diff --git a/core/http/react-ui/src/utils/legacyRoutes.js b/core/http/react-ui/src/utils/legacyRoutes.js
index 9e4208567..8c27a2812 100644
--- a/core/http/react-ui/src/utils/legacyRoutes.js
+++ b/core/http/react-ui/src/utils/legacyRoutes.js
@@ -1,52 +1,3 @@
-// The old routes of the migration table and where they go now. Redirects stay
-// in the app until a separate decision removes them.
-
-// Old generator route segment -> Studio tab key (the tabs of the Studio add-on).
-export const STUDIO_TABS = Object.freeze({
- image: 'images',
- video: 'video',
- '3d': 'threed',
- tts: 'tts',
- sound: 'sound',
- diarization: 'diarization',
- transform: 'transform',
-})
-
-// Studio and Talk are add-ons. Their pages live under /app/addons//;
-// the tab is one path segment and the model travels as ?model=, because
-// an add-on path segment cannot hold "/" or ":" (see resolveSubpath in the SDK).
-export const STUDIO_PAGE = '/app/addons/studio/studio'
-export const TALK_PAGE = '/app/addons/talk/talk'
-
-function addonAddress(base, { tab, model, search = '', hash = '' } = {}) {
- const params = new URLSearchParams(search)
- if (model) params.set('model', model)
- const query = params.toString()
- return `${base}${tab ? `/${encodeURIComponent(tab)}` : ''}${query ? `?${query}` : ''}${hash}`
-}
-
-// Where an old address of a page that moved to an add-on opens now: the page
-// path is kept as is (it may hold "/"), the model travels as ?model=.
-export function addonPageAddress({ addon, page, model, search = '', hash = '' }) {
- return addonAddress(`/app/addons/${addon}/${page}`, { model, search, hash })
-}
-
-// Where an old /app/studio[/tab[/model]] link opens now. The model, the query
-// string and the hash are kept.
-export function studioAddress(args) {
- return addonAddress(STUDIO_PAGE, args)
-}
-
-// Where an old /app/talk link opens now.
-export function talkAddress(args) {
- return addonAddress(TALK_PAGE, args)
-}
-
-// The target of an old generator route.
-export function studioRedirect(legacy, model, search = '', hash = '') {
- return studioAddress({ tab: STUDIO_TABS[legacy], model, search, hash })
-}
-
// The old chat page became Home, the front door where a chat starts, and the
// old agent-jobs pages became the triggers. The pages of the chats that moved
// back to the agents, skills and triggers addresses redirect too. This is
@@ -83,21 +34,7 @@ export const MIGRATION_ROUTES = Object.freeze([
{ from: '/app/skills', to: '/app/skills', kind: 'same' },
{ from: '/app/models', to: '/app/models', kind: 'same' },
{ from: '/app/settings', to: '/app/settings', kind: 'same' },
- { from: '/app/studio', to: '/app/addons/studio/studio', kind: 'addon-page' },
- { from: '/app/talk', to: '/app/addons/talk/talk', kind: 'addon-page' },
- { from: '/app/image', to: '/app/addons/studio/studio/images', kind: 'studio-tab' },
- { from: '/app/video', to: '/app/addons/studio/studio/video', kind: 'studio-tab' },
- { from: '/app/3d', to: '/app/addons/studio/studio/threed', kind: 'studio-tab' },
- { from: '/app/tts', to: '/app/addons/studio/studio/tts', kind: 'studio-tab' },
- { from: '/app/sound', to: '/app/addons/studio/studio/sound', kind: 'studio-tab' },
- { from: '/app/diarization', to: '/app/addons/studio/studio/diarization', kind: 'studio-tab' },
- { from: '/app/transform', to: '/app/addons/studio/studio/transform', kind: 'studio-tab' },
- { from: '/app/face', to: '/app/addons/face/recognition', kind: 'addon-page' },
- { from: '/app/voice', to: '/app/addons/voice/voice/recognition', kind: 'addon-page' },
- { from: '/app/voice-library', to: '/app/addons/voice/voice/library', kind: 'addon-page' },
- { from: '/app/voice-library/new', to: '/app/addons/voice/voice/library/new', kind: 'addon-page' },
- { from: '/app/fine-tune', to: '/app/addons/fine-tuning/jobs', kind: 'addon-page' },
- { from: '/app/quantize', to: '/app/addons/quantization/jobs', kind: 'addon-page' },
+
])
// /app/chats without an id. Opened from inside the app with navigation state
diff --git a/core/http/react-ui/src/utils/sandboxStatus.js b/core/http/react-ui/src/utils/sandboxStatus.js
deleted file mode 100644
index f75364164..000000000
--- a/core/http/react-ui/src/utils/sandboxStatus.js
+++ /dev/null
@@ -1,26 +0,0 @@
-// The state of the Sandbox add-on for the Tools picker of a chat. The add-on's
-// status read (open to every user) says whether it can run commands, with
-// which backend, and whether it runs them on the host with no isolation. The
-// picker says it in plain words next to the add-on's switch.
-
-export const SANDBOX_ADDON = 'sandbox'
-
-// { state: 'isolated' | 'host' | 'cannot' | 'starting' | 'unknown', backend, reason } from
-// the payload of the status read; anything unreadable is 'unknown'.
-export function sandboxState(status) {
- if (!status || typeof status !== 'object' || status.ok !== true) return { state: 'unknown', backend: '', reason: '' }
- const text = (v) => (typeof v === 'string' ? v.slice(0, 200) : '')
- const backend = text(status.active)
- if (status.canRun !== true) {
- const why = Array.isArray(status.backends) ? status.backends.find(b => b && b.usable !== true && text(b.reason)) : null
- return { state: 'cannot', backend: '', reason: text(status.reason) || (why ? text(why.reason) : '') }
- }
- if (status.hostNotice === true || backend === 'host') return { state: 'host', backend, reason: '' }
- return { state: 'isolated', backend, reason: '' }
-}
-
-// startingState is the state while the add-on service does not answer yet:
-// the page reads again by itself in retryMs.
-export function startingState(retryMs) {
- return { state: 'starting', backend: '', reason: '', retryMs: Math.max(0, Math.round(retryMs || 0)) }
-}
diff --git a/core/http/react-ui/src/utils/slashCommands.js b/core/http/react-ui/src/utils/slashCommands.js
index 70fadd1db..4e0b30284 100644
--- a/core/http/react-ui/src/utils/slashCommands.js
+++ b/core/http/react-ui/src/utils/slashCommands.js
@@ -1,3 +1,4 @@
+import { projectHomeCommands } from '../addons/homeCommands.js'
// The slash commands of the composer, on Home and in a chat: one registry.
// Pure functions, tested in tests/unit/slashcommands.test.mjs.
//
@@ -138,7 +139,7 @@ const RESERVED = reservedNames()
// The skills a chat can run now: switched on and not replaced.
export function usableSkills(skills) {
- return (skills || []).filter((s) => s && s.name && s.enabled !== false && !s.shadowed && !s.invalid)
+ return (skills || []).filter((s) => s && typeof s.name === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(s.name) && s.enabled !== false && !s.shadowed && !s.invalid)
}
// The items of /skill: every usable skill. One whose name is a command is
@@ -198,12 +199,14 @@ export function availableCommands(surface, ctx = {}) {
})
}
const shared = SHARED_COMMANDS.filter((a) => (!a.where || a.where.includes(surface)) && (!a.needs || a.needs(ctx)))
- return [...own, ...shared, ...skillCommands(ctx.skills)]
+ const addons = surface === 'home' && ctx.addonRegistry
+ ? projectHomeCommands(ctx.addonRegistry, { nativeNames: RESERVED, skillNames: usableSkills(ctx.skills).map(s => s.name), language: ctx.addonRegistry.language }).commands : []
+ return [...own, ...shared, ...addons, ...skillCommands(ctx.skills)]
}
// The groups of a surface, in menu order.
export function commandGroups(surface) {
- return [...(surface === 'home' ? SLASH_GROUPS : CHAT_SLASH_GROUPS), ...COMMAND_GROUPS]
+ return [...(surface === 'home' ? [...SLASH_GROUPS, 'addons'] : CHAT_SLASH_GROUPS), ...COMMAND_GROUPS]
}
// What the box asks of the menu.
diff --git a/core/http/react-ui/tests/unit/addon-session.test.mjs b/core/http/react-ui/tests/unit/addon-session.test.mjs
new file mode 100644
index 000000000..359485e73
--- /dev/null
+++ b/core/http/react-ui/tests/unit/addon-session.test.mjs
@@ -0,0 +1,244 @@
+// SPDX-License-Identifier: MIT
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { hookHarness } from './chatcommands-lifecycle.mjs'
+import { createActionBook, effectOutcome } from '../../src/components/addons/actionBook.js'
+import { runLifecycle, settleOperation } from '../../src/components/addons/lifecycleFlow.js'
+import { newOperationId } from '../../src/components/catalog/catalogState.js'
+const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } }
+
+for (const verb of ['start', 'stop']) test(`pending ${verb} cannot verify or publish after actual auth epoch changes before cleanup`, async () => {
+ let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, captureSessionAuthority: () => () => true }
+ const pending = deferred()
+ let submitted = 0, verified = 0, settled = 0
+ const h = hookHarness('../../src/components/addons/useAddonActions.js', 'useAddonActions', {
+ useAuth: () => auth, createActionBook, effectOutcome, runLifecycle, settleOperation, runInstallAndTurnOn() {},
+ })
+ const render = () => h.render({}, { onSettled: () => settled++ })
+ const old = render(); h.flush()
+ const args = { addonId: 'notes', submit: () => { submitted++; return pending.promise }, verify: async () => { verified++; return true } }
+ const work = old.run(`${verb}:notes:1`, args)
+ auth = { ...auth, sessionEpoch: 2 }
+ const next = render() // deliberately do not flush cleanup
+ assert.deepEqual(next.state, {})
+ assert.equal(await old.run('late', args), null)
+ pending.resolve({ phase: 'completed' }); await work
+ assert.equal(submitted, 1)
+ assert.equal(verified, 0)
+ assert.equal(settled, 0)
+ assert.deepEqual(render().state, {})
+})
+
+test('pending update check and upgrade cannot publish to the next actor before cleanup', async () => {
+ let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, captureSessionAuthority: () => () => true }
+ const pending = deferred(), updating = deferred()
+ let checked = 0, changed = 0, started = 0
+ const api = { updates: { check: () => { checked++; return pending.promise } } }
+ const h = hookHarness('../../src/components/addons/useAddonUpgrade.js', 'useAddonUpgrade', {
+ useAuth: () => auth, newOperationId, useAddonUpgrades: () => ({ live: [], records: [] }),
+ addonUpgrades: { start: () => { started++; return updating.promise } },
+ })
+ const render = () => h.render(api, { onChanged: () => changed++ })
+ const old = render(); h.flush()
+ const work = old.check({ id: 'notes' }), upgrade = old.upgrade({ id: 'notes' })
+ auth = { ...auth, user: { id: 'other' }, isAdmin: true, sessionEpoch: 2 }
+ assert.deepEqual(render().checks, {})
+ await old.check({ id: 'notes' }); await old.upgrade({ id: 'notes' })
+ pending.resolve({ state: 'update-available' }); updating.resolve({ ok: true })
+ await Promise.all([work, upgrade])
+ assert.equal(checked, 1); assert.equal(started, 1); assert.equal(changed, 0)
+ assert.deepEqual(render().checks, {})
+})
+
+test('upgrade store drops an expired completion without installed verification or cached record', async () => {
+ const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js')
+ const pending = deferred()
+ let live = true, verified = 0, submitted = 0
+ const store = createUpgradeStore()
+ const api = { updates: { upgrade: () => { submitted++; return pending.promise } }, installed: async () => { verified++; return [] } }
+ const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } }
+ const work = store.start(api, addon, { current: () => live })
+ assert.equal(submitted, 1)
+ live = false
+ assert.equal(await store.start(api, addon, { current: () => live }), null)
+ pending.resolve({ phase: 'completed' }); await work
+ assert.equal(verified, 0)
+ assert.deepEqual(store.getSnapshot(), { live: [], records: [] })
+})
+
+for (const switchUser of [false, true]) test(`installed records reject late admin read on ${switchUser ? 'account change' : 'actual auth refresh'} before cleanup`, async () => {
+ const { readFileSync } = await import('node:fs')
+ const ts = (await import('typescript')).default
+ let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, hasFeature: () => true }
+ const pending = deferred()
+ let reads = 0
+ const api = { apps: { list: async () => [{ id: 'notes', generation: 1 }] }, installed: () => { reads++; return pending.promise } }
+ const text = readFileSync(new URL('../../src/components/addons/AppsView.jsx', import.meta.url), 'utf8')
+ // Full Apps body/effects; JSX is compiled, leaf hooks are external boundaries.
+ const names = [...text.matchAll(/^import \{ ([^}]+) \} from/gm)].flatMap(m => m[1].split(',').map(n => n.trim()))
+ const defaults = [...text.matchAll(/^import (\w+) from/gm)].map(m => m[1])
+ const h = hookHarness('../../src/components/addons/AppsView.jsx', 'AppsView', {
+ ...Object.fromEntries([...names, ...defaults].filter(n => !n.startsWith('use')).map(n => [n, () => null])),
+ useAuth: () => auth, useContext: () => null, useTranslation: () => ({ t: k => k, i18n: {} }),
+ useAddonActions: () => ({}), useAddonUpgrade: () => ({ checks: {}, records: [] }), useBulkUpgrade: () => ({}),
+ useOutletContext: () => ({}), useNavigation: () => ({ pins: [] }), useHubAuth: () => ({ features: {} }),
+ useSearchParams: () => [new URLSearchParams(), () => {}],
+ buildTiles: () => [], resolvePins: () => [], filterTiles: () => [], filterByState: () => [], sortByAttention: () => [],
+ updateView: () => ({ state: '' }),
+ FEATURE_ADDONS: 'addons', RegistryContext: {}, setTimeout: () => 1, clearTimeout() {},
+ }, source => ts.transpileModule(source.replace('export default function', 'function').split(" if (state.status === 'loading')")[0] + 'return { installedInfo }\n}', { compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022 } }).outputText)
+ h.render({ api }); h.flush()
+ await new Promise(r => setImmediate(r))
+ h.render({ api }); h.flush()
+ assert.equal(reads, 1)
+ auth = { ...auth, user: { id: switchUser ? 'other' : 'admin' }, sessionEpoch: 2 }
+ assert.deepEqual(h.render({ api }).installedInfo, {})
+ pending.resolve([{ id: 'notes', privateValue: 'old-admin-record' }])
+ await new Promise(r => setImmediate(r))
+ assert.deepEqual(h.render({ api }).installedInfo, {})
+})
+
+for (const phase of ['completed', 'failed', 'intent', 'unreadable']) test(`observer drops ${phase} after auth epoch changes during status read`, async () => {
+ const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js')
+ const status = deferred(), reading = deferred()
+ let epoch = 1, verified = 0, reads = 0
+ const store = createUpgradeStore({ waits: [] })
+ const api = {
+ updates: { upgrade: async () => ({ phase: 'accepted' }) },
+ operationStatus: () => { reads++; reading.resolve(); return status.promise.then(value => { if (phase === 'unreadable') throw new Error('offline'); return value }) },
+ installed: async () => { verified++; return [] },
+ }
+ const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } }
+ const work = store.start(api, addon, { current: () => epoch === 1 })
+ await reading.promise
+ assert.equal(store.cancel(store.forAddon('notes').jobID), false, 'accepted owner work cannot be cancelled')
+ epoch = 2
+ const publications = []
+ store.subscribe(() => publications.push(store.getSnapshot()))
+ status.resolve({ phase })
+ assert.equal(await work, null)
+ assert.equal(reads, 1)
+ assert.equal(verified, 0)
+ assert.deepEqual(store.getSnapshot(), { live: [], records: [] })
+ assert.ok(publications.every(s => s.records.length === 0 && s.live.length === 0))
+})
+
+test('observer does not read status when auth expires before acceptance returns', async () => {
+ const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js')
+ const store = createUpgradeStore({ waits: [] }), accepted = deferred()
+ let epoch = 1, reads = 0
+ const api = {
+ updates: { upgrade: () => accepted.promise },
+ operationStatus: async () => { reads++; return { phase: 'completed' } },
+ }
+ const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } }
+ const work = store.start(api, addon, { current: () => epoch === 1 })
+ assert.equal(store.cancel(store.forAddon('notes').jobID), false, 'disconnect cannot prove preacceptance')
+ epoch = 2
+ accepted.resolve({ phase: 'accepted' })
+ assert.equal(await work, null)
+ assert.equal(reads, 0)
+ assert.deepEqual(store.getSnapshot(), { live: [], records: [] })
+})
+
+test('expired observer cleanup preserves a newer attempt with the same action key', async () => {
+ const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js')
+ let id = 0, epoch = 1
+ const book = createActionBook(() => `attempt-${++id}`)
+ const store = createUpgradeStore({ book, waits: [] })
+ const status = deferred(), reading = deferred(), nextSubmit = deferred()
+ const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } }
+ const api = {
+ updates: { upgrade: async () => ({ phase: 'accepted' }) },
+ operationStatus: () => { reading.resolve(); return status.promise },
+ }
+ const oldWork = store.start(api, addon, { current: () => epoch === 1 })
+ const old = store.forAddon(addon.id)
+ await reading.promise
+ // Another follower concludes the old entry, allowing a new authorized action.
+ store.conclude(old.jobID, addon, 'upgrade:notes:1', 'failed', old.operationId)
+ epoch = 2
+ const nextWork = store.start({
+ updates: { upgrade: () => nextSubmit.promise },
+ installed: async () => [{ ...addon, generation: 2 }],
+ }, addon, { current: () => epoch === 2 })
+ const next = store.forAddon(addon.id), held = book.pending(addon.id)
+ assert.notEqual(next.operationId, old.operationId)
+ status.resolve({ phase: 'completed' })
+ assert.equal(await oldWork, null)
+ assert.deepEqual(store.forAddon(addon.id), next)
+ assert.deepEqual(book.pending(addon.id), held)
+ assert.deepEqual(store.getSnapshot().records, [])
+ nextSubmit.resolve({ phase: 'completed' })
+ assert.equal((await nextWork).ok, true)
+ assert.equal(store.getSnapshot().records[0].operationId, next.operationId)
+})
+
+// Execute the actual AuthProvider state transitions, not a test-owned epoch getter.
+// Only its JSX boundary is replaced; fetch is the controlled network boundary.
+async function providerAuthority() {
+ const ts = (await import('typescript')).default
+ let status = { authEnabled: true, user: { id: 'admin', role: 'admin' } }
+ const logoutReply = deferred()
+ const h = hookHarness('../../src/context/AuthContext.jsx', 'AuthProvider', {
+ apiUrl: x => x, addonUpgrades: (await import('../../src/utils/addonUpgrades.js')).createUpgradeStore(),
+ fetch: url => url.endsWith('/logout') ? logoutReply.promise : Promise.resolve({ json: async () => status }),
+ document: { cookie: '' }, window: { location: {} },
+ }, source => ts.transpileModule(source.replace(/return \(\s* setImmediate(r))
+ auth = h.render({ children: null })
+ return {
+ get auth() { return auth },
+ async change(kind) {
+ if (kind === 'unmount') { h.unmount(); return }
+ if (kind === 'logout') { void auth.logout(); return } // network remains pending
+ if (kind === 'account') status = { ...status, user: { id: 'other', role: 'admin' } }
+ if (kind === 'feature') status = { ...status, user: { id: 'admin', role: 'user', permissions: {} } }
+ await auth.refresh()
+ // Do not render the provider or any consumer: expiry must be synchronous.
+ },
+ }
+}
+
+for (const path of ['observer', 'completion', 'verification']) {
+ for (const change of ['same-auth', 'refresh', 'account', 'feature', 'logout', 'unmount']) {
+ test(`background ${path} after origin unmount uses actual provider authority on ${change}`, async () => {
+ const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js')
+ const provider = await providerAuthority()
+ const store = createUpgradeStore({ waits: [] })
+ const reply = deferred(), reading = deferred(), verification = deferred(), verifying = deferred()
+ let verified = 0, changed = 0
+ const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } }
+ const api = {
+ updates: { upgrade: () => path === 'observer' ? Promise.resolve({ phase: 'accepted' }) : reply.promise },
+ operationStatus: () => { reading.resolve(); return reply.promise },
+ installed: () => { verified++; verifying.resolve(); return verification.promise },
+ }
+ const view = hookHarness('../../src/components/addons/useAddonUpgrade.js', 'useAddonUpgrade', {
+ useAuth: () => provider.auth, newOperationId, addonUpgrades: store,
+ useAddonUpgrades: () => store.getSnapshot(),
+ })
+ const origin = view.render(api, { onChanged: () => changed++ }); view.flush()
+ const work = origin.upgrade(addon)
+ view.unmount() // navigation must not revoke a valid background upgrade
+ if (path === 'observer') {
+ await reading.promise
+ assert.equal(store.cancel(store.forAddon(addon.id).jobID), false)
+ }
+ if (path === 'verification') {
+ reply.resolve({ phase: 'completed' }); await verifying.promise
+ }
+ if (change !== 'same-auth') await provider.change(change)
+ reply.resolve({ phase: 'completed' })
+ verification.resolve([{ ...addon, generation: 2 }])
+ await work
+ assert.equal(changed, 0, 'unmounted view must not receive callbacks')
+ assert.equal(verified, path === 'verification' || change === 'same-auth' ? 1 : 0)
+ assert.equal(store.getSnapshot().live.length, 0)
+ assert.equal(store.getSnapshot().records.length, change === 'same-auth' ? 1 : 0)
+ })
+ }
+}
diff --git a/core/http/react-ui/tests/unit/addonhomecommands.test.mjs b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs
new file mode 100644
index 000000000..6ecd2c5a2
--- /dev/null
+++ b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs
@@ -0,0 +1,277 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { projectHomeCommands, resolveHomeCommand } from '../../src/addons/homeCommands.js'
+import { shouldPoll } from '../../src/addons/registryState.js'
+const entry = '/api/addons/paint/assets/' + 'a'.repeat(64) + '/ui.js'
+const app = (id = 'paint', commands = [{ id: 'open', alias: 'paint', page: 'canvas', label: 'Paint', icon: 'palette' }]) => ({ id, generation: 3, enabled: true, state: 'running', ui: { entry: entry.replace('/paint/', `/${id}/`), sdk: { min: 2, max: 2 }, pages: ['canvas', 'history'], homeCommands: commands } })
+const snapshot = (apps = [app()], extra = {}) => ({ apps, allowed: true, status: 'ready', ...extra })
+test('Home addons: renamed addon and second creative command need no core branch', () => {
+ const p = projectHomeCommands(snapshot([app('creative', [{ id: 'make', page: 'canvas', label: 'Make', icon: 'palette' }, { id: 'past', page: 'history', label: 'Past', icon: 'clock' }])]))
+ assert.deepEqual(p.commands.map(c => c.cmd), ['/addon:creative:make', '/addon:creative:past'])
+ assert.equal(resolveHomeCommand('/addon:creative:make', snapshot([app('creative', [{ id: 'make', page: 'canvas', label: 'Make', icon: 'palette' }])]))?.page, 'canvas')
+})
+test('Home addons: aliases reserve native and skills and suppress all colliders deterministically', () => {
+ const apps = [app('a'), app('b')]
+ const opts = { nativeNames: new Set(['paint']) }
+ assert.deepEqual(projectHomeCommands(snapshot(apps), opts).diagnostics.map(d => d.reason), ['native-name', 'native-name'])
+ assert.equal(projectHomeCommands(snapshot([app()]), { skillNames: ['paint'] }).commands.length, 1)
+ const p = projectHomeCommands(snapshot(apps))
+ assert.equal(p.commands.length, 2)
+ assert.deepEqual(p, projectHomeCommands(snapshot([...apps].reverse())))
+ assert.equal(p.diagnostics[0].reason, 'addon-alias-collision')
+})
+test('Home addons: metadata unknown nested duplicate and unsafe destinations fail closed', () => {
+ for (const patch of [{ target: 'https://evil' }, { page: '../canvas' }, { label: {} }, { labels: { en: {} } }, { icon: 'javascript:bad' }, { page: 'missing' }]) {
+ const a = app(); Object.assign(a.ui.homeCommands[0], patch)
+ assert.equal(projectHomeCommands(snapshot([a])).commands.length, 0)
+ }
+ const a = app(); a.ui.homeCommands.push({ ...a.ui.homeCommands[0] })
+ assert.equal(projectHomeCommands(snapshot([a])).commands.length, 0)
+})
+test('Home addons: installed lifecycle error and absence remain distinct and polling recovers', () => {
+ for (const [patch, state] of [[{enabled:false}, 'installed-disabled'], [{state:'failed'}, 'installed-failed'], [{state:'starting'}, 'loading'], [{ui:null}, 'no-ui']]) {
+ assert.equal(projectHomeCommands(snapshot([{...app(), ...patch}])).states[0].status, state)
+ assert.equal(projectHomeCommands(snapshot([{...app(), ...patch}])).commands.length, 0)
+ }
+ assert.equal(projectHomeCommands(snapshot([], {status:'error'})).status, 'list-error')
+ assert.equal(projectHomeCommands(snapshot([])).status, 'absent')
+ for (const status of ['idle','ready','error']) assert.equal(shouldPoll({allowed:true, consumers:1, apps:[],status}), true)
+ assert.equal(projectHomeCommands(snapshot()).status, 'ready')
+})
+test('Home addons: exact typed and captured clicks reject stale generation permissions and arguments', () => {
+ const c = projectHomeCommands(snapshot()).commands[0]
+ assert.ok(resolveHomeCommand(c, snapshot()))
+ assert.equal(resolveHomeCommand(c, snapshot([{...app(),generation:4}])), null)
+ assert.equal(resolveHomeCommand(c, snapshot([], {allowed:false})), null)
+ assert.equal(resolveHomeCommand(c, snapshot([], {status:'error'})), null)
+ assert.equal(resolveHomeCommand('/paint args', snapshot()), null)
+ assert.equal(resolveHomeCommand('/pai', snapshot()), null)
+})
+
+import { readFileSync } from 'node:fs'
+import { createRegistryRequests } from '../../src/addons/registryRequests.js'
+import { dispatchHomeCommand } from '../../src/addons/homeCommands.js'
+import { availableCommands, parseInvocation, reservedNames } from '../../src/utils/slashCommands.js'
+const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } }
+
+test('Home addons: outstanding logout poll aborts and cannot overwrite the next actor', async () => {
+ const requests = createRegistryRequests(), old = deferred(), published = []
+ requests.setScope('alice:allowed')
+ let signal
+ const pending = requests.read(s => { signal = s; return old.promise }, s => published.push(s))
+ requests.setScope('bob:forbidden')
+ assert.ok(signal.aborted)
+ old.resolve([app()])
+ assert.equal(await pending, null)
+ assert.deepEqual(published, [])
+ requests.setScope('bob:allowed')
+ await requests.read(async () => [app('bob')], s => published.push(s))
+ assert.equal(published[0].apps[0].id, 'bob')
+})
+test('Home addons: cancellation newer reads and registry errors withdraw stale authority', async () => {
+ const requests = createRegistryRequests(), old = deferred(), published = [], ctl = new AbortController()
+ const pending = requests.read(() => old.promise, s => published.push(s), ctl.signal)
+ ctl.abort(); old.resolve([app()]); assert.equal(await pending, null)
+ const late = deferred()
+ const first = requests.read(() => late.promise, s => published.push(s))
+ await requests.read(async () => { throw new Error('offline') }, s => published.push(s))
+ late.resolve([app()]); assert.equal(await first, null)
+ assert.equal(published.length, 1)
+ assert.equal(projectHomeCommands({ ...published[0], allowed:true }).status, 'list-error')
+ assert.deepEqual(published[0].apps, [])
+})
+test('Home addons: live authenticated dispatch navigates only the current installed generation', async () => {
+ const command = projectHomeCommands(snapshot()).commands[0], paths = []
+ let now = { actor:'alice', allowed:true, list:snapshot() }
+ const requests = createRegistryRequests()
+ requests.setScope(now.actor)
+ const invoke = load => dispatchHomeCommand(command, {
+ current: () => now, navigate: path => paths.push(path),
+ refresh: () => requests.read(load, list => { now.list = list }),
+ })
+ assert.equal(await invoke(async () => [app()]), true)
+ assert.deepEqual(paths, ['/app/addons/paint/canvas'])
+ for (const patch of [{enabled:false}, {state:'failed'}, {generation:4}, {ui:{...app().ui, pages:['history']}}]) {
+ assert.equal(await invoke(async () => [{...app(), ...patch}]), false)
+ }
+ const late = deferred(), clicking = invoke(() => late.promise)
+ now = { actor:'bob', allowed:false, list:snapshot([]) }; requests.setScope(now.actor)
+ late.resolve([app()]); assert.equal(await clicking, false)
+ assert.equal(paths.length, 1)
+})
+test('Home addons: disabled failed ready transitions remain pollable and restart after permission returns', async () => {
+ const requests = createRegistryRequests()
+ let state = snapshot([])
+ for (const patch of [{enabled:false}, {state:'failed'}, {}]) {
+ assert.ok(shouldPoll({...state, consumers:1}))
+ await requests.read(async () => [{...app(), ...patch}], next => { state = {...next,allowed:true} })
+ }
+ assert.ok(projectHomeCommands(state).commands.length > 0)
+ assert.equal(shouldPoll({...state, allowed:false, consumers:1}), false)
+ assert.equal(shouldPoll({...state, allowed:true, consumers:1}), true)
+})
+test('Home addons: Studio manifest is consumed generically and live Home has no identity branches', () => {
+ const manifest = JSON.parse(readFileSync(new URL('../../../../../addons/studio/manifest.json', import.meta.url)))
+ const ui = manifest.capabilities.ui
+ const commands = ui.homeCommands.map(c => ({...c, page:c.page || ui.mainPage, label:c.label || ui.title, icon:c.icon || 'puzzle'}))
+ const installed = app(manifest.id, commands); installed.ui.pages = ui.pages
+ const menu = availableCommands('home', {addonRegistry:snapshot([installed])})
+ assert.ok(menu.some(c => c.cmd === '/studio'))
+ assert.ok(menu.some(c => c.cmd === '/addon:studio:open'))
+ assert.ok(!availableCommands('chat', {addonRegistry:snapshot([installed])}).some(c => c.addonId))
+ assert.ok(!availableCommands('home', {addonRegistry:snapshot([])}).some(c => c.addonId))
+ for (const path of ['../../src/pages/Home.jsx','../../src/components/home/homeActions.js']) {
+ assert.doesNotMatch(readFileSync(new URL(path, import.meta.url), 'utf8'), /studio/i)
+ }
+ const composer = readFileSync(new URL('../../src/components/home/HomeComposer.jsx', import.meta.url), 'utf8')
+ assert.match(composer, /onRunAction\(entry.id, entry\)/)
+ assert.match(composer, /onRunAction\(exact.id, exact\)/)
+ const registry = readFileSync(new URL('../../src/addons/registry.jsx', import.meta.url), 'utf8')
+ assert.match(registry, /dispatchHomeCommand\(command/)
+ assert.match(registry, /requests.read\(s => api.apps.list\(s\)/)
+})
+test('Home addons: native hidden names and usable skills are reserved on the actual menu', () => {
+ const apps = [app('creative', [{id:'open',alias:'settings',page:'canvas',label:'Create',icon:'palette'}])]
+ assert.ok(!availableCommands('home', {addonRegistry:snapshot(apps)}).some(c => c.addonId && c.cmd === '/settings'))
+ assert.ok(reservedNames().has('settings'))
+ const menu = availableCommands('home', {addonRegistry:snapshot(),skills:[{name:'paint',enabled:true}]})
+ assert.equal(menu.filter(c => c.cmd === '/paint').length, 1)
+ assert.ok(!menu.find(c => c.cmd === '/paint').addonId)
+ assert.equal(parseInvocation('/addon:paint:open', {skills:[{name:'addon:paint:open',enabled:true}]}), null)
+})
+
+test('Home addons: resolved inherited titles accept 61 through 80 Unicode characters and localized titles', () => {
+ for (let length = 61; length <= 80; length++) {
+ const label = 'é'.repeat(length), localized = '界'.repeat(length)
+ const a = app('paint', [{id:'open',page:'canvas',label,labels:{it:localized},icon:'palette'}])
+ const projected = projectHomeCommands(snapshot([a]), {language:'it'})
+ assert.equal(projected.states[0].status, 'ready', `title length ${length}`)
+ assert.equal(projected.commands[0]?.label, localized)
+ assert.equal(projectHomeCommands(snapshot([a])).commands[0]?.label, label)
+ }
+})
+test('Home addons: resolved secondary page fallback accepts the exact maximum and rejects overflow', () => {
+ const page = 'p'.repeat(64), label = 'é'.repeat(80) + ' — ' + page
+ const localized = '界'.repeat(80) + ' — ' + page
+ assert.equal([...label].length, 147)
+ const a = app('paint', [{id:'open',page,label,labels:{it:localized},icon:'palette'}])
+ a.ui.pages.push(page)
+ assert.equal(projectHomeCommands(snapshot([a])).commands[0]?.label, label)
+ assert.equal(projectHomeCommands(snapshot([a]), {language:'it'}).commands[0]?.label, localized)
+ for (const patch of [{label:label + 'x'}, {labels:{it:localized + 'x'}}, {label:'safe\u0085unsafe'}, {labels:{it:'safe\u202eunsafe'}}, {label:''}]) {
+ const bad = structuredClone(a); Object.assign(bad.ui.homeCommands[0], patch)
+ const projected = projectHomeCommands(snapshot([bad]))
+ assert.equal(projected.commands.length, 0)
+ assert.equal(projected.states[0].status, 'invalid-metadata')
+ }
+})
+test('Home addons: immutable package icons accept dotted segments and all contract image extensions', () => {
+ for (const path of ['ui.v1/icon.png','ui/icon.dark.png','ui.v1/icon.dark.JPEG','ui/icon.webp','ui/icon.jpg', 'a'.repeat(252) + '.png']) {
+ const a = app(); a.ui.homeCommands[0].icon = entry.slice(0, -5) + path
+ const projected = projectHomeCommands(snapshot([a]))
+ assert.equal(projected.states[0].status, 'ready', path)
+ assert.equal(projected.commands[0]?.iconUrl, a.ui.homeCommands[0].icon, path)
+ }
+})
+test('Home addons: package icons reject traversal encodings foreign prefixes and non-image extensions', () => {
+ const base = entry.slice(0, -5)
+ const paths = ['../icon.png','./icon.png','ui/../icon.png','ui/./icon.png','ui//icon.png','/icon.png','ui/icon.png/', 'ui\\icon.png', '%2e%2e/icon.png','ui%2ficon.png','ui%5cicon.png','ui/icon%2epng','ui/icon.png?x=1','ui/icon.png#x','ui/icon.svg','ui/icon.gif','ui/icon.js','ui/icon.png\u0000', 'a'.repeat(253) + '.png']
+ const urls = paths.map(p => base + p).concat(['https://evil/icon.png','//evil/icon.png','javascript:bad', base.replace('/paint/', '/other/') + 'icon.png', base.replace('a'.repeat(64), 'b'.repeat(64)) + 'icon.png'])
+ for (const icon of urls) {
+ const a = app(); a.ui.homeCommands[0].icon = icon
+ const projected = projectHomeCommands(snapshot([a]))
+ assert.equal(projected.commands.length, 0, icon)
+ assert.equal(projected.states[0].status, 'invalid-metadata', icon)
+ }
+})
+
+// Run the production provider, including its request admission and Home dispatch.
+// Effects stay explicitly controlled so refresh races cannot rely on cleanup.
+async function registrySessionHarness() {
+ const { hookHarness } = await import('./chatcommands-lifecycle.mjs')
+ const { transformWithOxc } = await import('vite')
+ const source = readFileSync(new URL('../../src/addons/registry.jsx', import.meta.url), 'utf8')
+ const provider = source.slice(0, source.indexOf(' const boundaryLabels')) + ' return value\n}'
+ const { code } = await transformWithOxc(provider, 'registry.jsx', { jsx: { runtime:'classic' } })
+ let auth = { user:{id:'alice'}, isAdmin:true, hasFeature:() => true, sessionEpoch:1 }
+ const reads = [], paths = [], revalidated = []
+ const api = { apps:{list(signal) { const response = deferred(); reads.push({signal, ...response}); return response.promise }} }
+ const manager = { snapshot:() => [], revalidate:apps => revalidated.push(apps), disposeAll() {} }
+ const h = hookHarness('../../src/addons/registry.jsx', 'AddonRegistryProvider', {
+ useAuth:() => auth, useNavigate:() => path => paths.push(path), useLocation:() => ({pathname:'/app/home',search:''}),
+ useTranslation:() => ({t:key => key,i18n:{language:'en'}}), FEATURE_ADDONS:'addons', addonsApi:api,
+ apiUrl:path => path, fetch:async () => ({json:async () => ({addons:true})}),
+ createRegistryRequests, dispatchHomeCommand, createRuntimeManager:() => manager,
+ resolveManage:() => false, resolveTab:() => 'apps', shouldPoll, pollDelay:() => 1000,
+ buildNavigation:() => [], availableChatActions:() => [],
+ document:{hidden:false}, setTimeout:() => 1, clearTimeout:() => {},
+ }, () => code.replace(/^import .*\n/gm, '').replace('export function', 'function'))
+ const render = () => h.render({api})
+ render(); h.flush()
+ await new Promise(resolve => setImmediate(resolve))
+ render(); h.flush(); render()
+ return { h, render, reads, paths, revalidated, refreshSession(user = auth.user) { auth = {...auth,user,sessionEpoch:auth.sessionEpoch + 1}; return render() } }
+}
+
+test('Home addons: actual registry rejects same-user refresh late poll before effect cleanup', async () => {
+ const r = await registrySessionHarness()
+ const value = r.render()
+ value.acquire(); r.render(); r.h.flush() // Start the actual production polling effect.
+ assert.equal(r.reads.length, 1)
+ const poll = r.reads[0]
+ r.refreshSession() // Same ID, feature and admin; deliberately do not flush effects.
+ poll.resolve([app()])
+ await new Promise(resolve => setImmediate(resolve))
+ assert.deepEqual(r.revalidated, [])
+ assert.ok(poll.signal.aborted)
+ assert.deepEqual(r.render().apps, [])
+ // Now cleanup may run; permission and polling recovery still work.
+ r.h.flush()
+ assert.equal(r.reads.length, 2)
+ r.reads[1].resolve([app()])
+ await new Promise(resolve => setImmediate(resolve))
+ assert.equal(r.render().apps[0].id, 'paint')
+})
+
+test('Home addons: actual registry pending NAV cannot cross same-user session refresh', async () => {
+ const r = await registrySessionHarness()
+ const command = projectHomeCommands(snapshot()).commands[0]
+ const pending = r.render().navigateHomeCommand(command)
+ assert.equal(r.reads.length, 1) // Fresh authenticated refetch already began.
+ r.refreshSession() // No metadata, ID or permissions change, and no effect cleanup.
+ r.reads[0].resolve([app()])
+ assert.equal(await pending, false)
+ assert.deepEqual(r.paths, [])
+ assert.deepEqual(r.revalidated, [])
+ const fresh = r.render().navigateHomeCommand(command)
+ r.reads[1].resolve([app()])
+ assert.equal(await fresh, true)
+ assert.deepEqual(r.paths, ['/app/addons/paint/canvas'])
+})
+
+for (const accountSwitch of [false, true]) test(`Apps registry: late list cannot cross ${accountSwitch ? 'account switch' : 'same-user refresh'} before cleanup`, async () => {
+ const { hookHarness } = await import('./chatcommands-lifecycle.mjs')
+ const r = await registrySessionHarness()
+ let registry = r.render()
+ let auth = { user:{id:'alice'}, sessionEpoch:1, isAdmin:true, hasFeature:() => true }
+ const reads = []
+ const api = { apps:{ list:() => { const d = deferred(); reads.push(d); return d.promise } } }
+ const h = hookHarness('../../src/components/addons/AppsView.jsx', 'AppsView', {
+ useAuth:() => auth, useContext:() => registry, RegistryContext:{}, useTranslation:() => ({t:x=>x,i18n:{}}),
+ FEATURE_ADDONS:'addons', pollDelay:() => 1000, setTimeout:()=>1, clearTimeout:()=>{},
+ }, s => s.replace('export default function', 'function').split(' // The installed record')[0] + 'return state\n}')
+ const render = () => h.render({api})
+ render(); h.flush()
+ const staleIngest = registry.ingest
+ auth = {...auth, user:{id:accountSwitch ? 'bob' : 'alice'},sessionEpoch:2}
+ registry = r.refreshSession(auth.user)
+ render() // Deliberately withhold Apps effect cleanup.
+ reads[0].resolve([app()]); await new Promise(resolve=>setImmediate(resolve))
+ assert.deepEqual(render().apps, [])
+ assert.deepEqual(r.render().apps, [])
+ staleIngest([app()]) // Registry independently rejects the originating authority.
+ assert.deepEqual(r.render().apps, [])
+ h.flush(); reads[1].resolve([app()]); await new Promise(resolve=>setImmediate(resolve))
+ assert.equal(render().apps[0].id, 'paint')
+ assert.equal(r.render().apps[0].id, 'paint')
+})
diff --git a/core/http/react-ui/tests/unit/addonmodelactions.test.mjs b/core/http/react-ui/tests/unit/addonmodelactions.test.mjs
new file mode 100644
index 000000000..f3f1bc972
--- /dev/null
+++ b/core/http/react-ui/tests/unit/addonmodelactions.test.mjs
@@ -0,0 +1,49 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { projectModelActions, dispatchModelAction } from '../../src/addons/modelActions.js'
+const app = (id = 'paint') => ({ id, title: 'Creative', enabled: true, state: 'running', generation: 3, ui: { sdk: { min: 2, max: 2 }, entry: `/api/addons/${id}/assets/${'a'.repeat(64)}/ui.js`, pages: ['canvas'], modelActions: [{ capability: 'FLAG_IMAGE', page: 'canvas', path: 'images' }] } })
+const snapshot = (apps = [app()], extra = {}) => ({ allowed: true, known: true, status: 'ready', apps, actor: 'owner:1', ...extra })
+test('Model actions: arbitrary providers deterministic collisions and selected model encoding', () => {
+ const a = projectModelActions(snapshot([app('zebra'), app('arbitrary')]), 'FLAG_IMAGE', 'org/a:b雪?&')
+ assert.deepEqual(a.choices.map(c => c.addonId), ['arbitrary', 'zebra'])
+ assert.deepEqual(a, projectModelActions(snapshot([app('arbitrary'), app('zebra')]), 'FLAG_IMAGE', 'org/a:b雪?&'))
+ assert.equal(new URL(a.choices[0].url, 'http://host').searchParams.get('model'), 'org/a:b雪?&')
+ assert.equal(projectModelActions(snapshot(), 'FLAG_CHAT', 'm').choices.length, 0)
+})
+test('Model actions: absence forbidden loading errors and installed lifecycle are distinct', () => {
+ for (const [extra, state] of [[{allowed:false}, 'forbidden'], [{allowed:false,known:false},'loading'], [{status:'idle'},'loading'], [{status:'error'},'list-error']]) assert.equal(projectModelActions(snapshot([], extra), 'FLAG_IMAGE', 'm').status, state)
+ assert.equal(projectModelActions(snapshot([]), 'FLAG_IMAGE', 'm').status, 'absent')
+ for (const [patch, state] of [[{enabled:false},'installed-disabled'], [{state:'starting'},'starting'], [{state:'failed'},'installed-failed'], [{ui:{...app().ui,sdk:{min:99,max:99}}},'incompatible']]) {
+ const p = projectModelActions(snapshot([{...app(),...patch}]), 'FLAG_IMAGE', 'm')
+ assert.equal(p.choices.length, 0); assert.equal(p.states[0].status,state)
+ }
+})
+test('Model actions: strict SDK bounded declaration validation fails closed', () => {
+ for (const patch of [{page:'missing'}, {path:'../x'}, {path:'/x'}, {path:'x?model=evil'}, {path:'x#hash'}, {path:''}, {path:'x/'.repeat(9)}, {path:'a'.repeat(65)}, {capability:'FLAG_CHAT'}, {url:'https://evil'}]) {
+ const a = app(); Object.assign(a.ui.modelActions[0],patch)
+ assert.equal(projectModelActions(snapshot([a]), 'FLAG_IMAGE','m').choices.length,0)
+ }
+ const a = app(); a.ui.modelActions.push({...a.ui.modelActions[0]})
+ assert.equal(projectModelActions(snapshot([a]), 'FLAG_IMAGE','m').choices.length,0)
+ assert.equal(projectModelActions(snapshot([app(),app()]), 'FLAG_IMAGE','m').choices.length,0)
+})
+test('Model actions: fresh dispatch refuses changed actor generation immutable entry and destination', async () => {
+ const s = snapshot(); const choice = projectModelActions(s,'FLAG_IMAGE','selected').choices[0]
+ for (const mutate of [a=>({...a,generation:4}),a=>({...a,ui:{...a.ui,entry:a.ui.entry.replace('a'.repeat(64),'b'.repeat(64))}}),a=>({...a,ui:{...a.ui,modelActions:[{capability:'FLAG_IMAGE',page:'canvas',path:'other'}]}}), a=>({...a,enabled:false})]) {
+ let now = {...s,list:s}; let visited = false
+ assert.equal(await dispatchModelAction(choice,{current:()=>now,refresh:async()=>{const apps=[mutate(app())]; now={...now,list:snapshot(apps)};return apps},navigate:()=>{visited=true}}),false)
+ assert.equal(visited,false)
+ }
+ let now = {...s,list:s}; let visited
+ assert.equal(await dispatchModelAction(choice,{current:()=>now,refresh:async()=>{const apps=[app()];now={...now,list:snapshot(apps)};return apps},navigate:p=>{visited=p}}),true)
+ assert.equal(new URL(visited,'http://host').searchParams.get('model'),'selected')
+ assert.equal(await dispatchModelAction(choice,{current:()=>now,refresh:async()=>{now={...now,actor:'owner:2'};return now.list.apps},navigate:()=>assert.fail()}),false)
+ assert.equal(await dispatchModelAction({...choice,actor:'previous'}, {current:()=>now,refresh:async()=>now.list.apps,navigate:()=>assert.fail()}),false)
+})
+
+test('Model actions: revoked session authority refuses a pending refresh before consumer render', async () => {
+ const s = snapshot(), choice = projectModelActions(s,'FLAG_IMAGE','m').choices[0]
+ let valid = true
+ const now = {...s,list:s}
+ assert.equal(await dispatchModelAction(choice, {current:()=>now,sessionCurrent:()=>valid,refresh:async()=>{valid=false;return s.apps},navigate:()=>assert.fail('revoked session navigated')}),false)
+})
diff --git a/core/http/react-ui/tests/unit/chatcommands-lifecycle.mjs b/core/http/react-ui/tests/unit/chatcommands-lifecycle.mjs
new file mode 100644
index 000000000..507072ace
--- /dev/null
+++ b/core/http/react-ui/tests/unit/chatcommands-lifecycle.mjs
@@ -0,0 +1,44 @@
+// Executes source hooks with persistent slots and explicitly controlled effect flushes.
+// Keeping effects separate lets tests inspect the render before cleanup runs.
+import { readFileSync } from 'node:fs'
+
+export function hookHarness(file, name, imports, transform = source => source) {
+ const slots = []
+ let cursor = 0
+ let pending = new Map()
+ const same = (a, b) => a && b && a.length === b.length && a.every((v, i) => Object.is(v, b[i]))
+ const hooks = {
+ useState(initial) {
+ const i = cursor++
+ if (!(i in slots)) slots[i] = typeof initial === 'function' ? initial() : initial
+ return [slots[i], value => { slots[i] = typeof value === 'function' ? value(slots[i]) : value }]
+ },
+ useRef(initial) {
+ const i = cursor++
+ return slots[i] ||= { current: initial }
+ },
+ useEffect(fn, deps) {
+ const i = cursor++
+ if (!same(slots[i]?.deps, deps)) pending.set(i, () => {
+ slots[i]?.cleanup?.()
+ slots[i] = { deps, cleanup: fn() }
+ })
+ },
+ useMemo(fn, deps) {
+ const i = cursor++
+ if (!same(slots[i]?.deps, deps)) slots[i] = { deps, value: fn() }
+ return slots[i].value
+ },
+ useSyncExternalStore(subscribe, snapshot) { cursor++; return snapshot() },
+ useCallback(fn) { cursor++; return fn },
+ }
+ const source = transform(readFileSync(new URL(file, import.meta.url), 'utf8')
+ .replace(/^import .*\n/gm, '').replace(/^export \{.*\}\n/gm, '').replace(/export function/g, 'function'))
+ const scope = { ...hooks, ...imports }
+ const hook = new Function(...Object.keys(scope), `${source}; return ${name}`)(...Object.values(scope))
+ return {
+ render(...args) { cursor = 0; return hook(...args) },
+ unmount() { pending.clear(); slots.forEach(slot => slot?.cleanup?.()) },
+ flush() { const effects = pending; pending = new Map(); effects.forEach(fn => fn()) },
+ }
+}
diff --git a/core/http/react-ui/tests/unit/chatcommands.test.mjs b/core/http/react-ui/tests/unit/chatcommands.test.mjs
index 2c51bcf2e..8c7704d50 100644
--- a/core/http/react-ui/tests/unit/chatcommands.test.mjs
+++ b/core/http/react-ui/tests/unit/chatcommands.test.mjs
@@ -9,29 +9,29 @@ test('chat commands: no notice when commands run or the server does not say', ()
assert.equal(commandsNotice('something-new', { isAdmin: true }), null)
})
-test('chat commands: with no Sandbox an administrator gets the install link and others are told to ask', () => {
+test('chat commands: with no provider there is no specific installation recommendation', () => {
assert.deepEqual(commandsNotice('none', { isAdmin: true }), {
- state: 'none', textKey: 'none', reason: '', link: { to: '/app/addons?tab=browse&q=sandbox', labelKey: 'install' }, askKey: null,
+ state: 'none', textKey: 'none', reason: '', link: null, askKey: null,
})
assert.deepEqual(commandsNotice('none', { isAdmin: false }), {
- state: 'none', textKey: 'none', reason: '', link: null, askKey: 'askInstall',
+ state: 'none', textKey: 'none', reason: '', link: null, askKey: null,
})
})
-test('chat commands: a Sandbox that cannot run gives its reason and the add-on page to an administrator', () => {
+test('chat commands: an unavailable provider gives its reason without a specific add-on recommendation', () => {
const admin = commandsNotice('unavailable', { isAdmin: true, reason: 'no sandbox backend works on this host' })
- assert.equal(admin.textKey, 'unavailableWhy')
+ assert.equal(admin.textKey, 'providerUnavailable')
assert.equal(admin.reason, 'no sandbox backend works on this host')
- assert.deepEqual(admin.link, { to: '/app/addons?detail=sandbox', labelKey: 'openSandbox' })
+ assert.equal(admin.link, null)
const user = commandsNotice('unavailable', { isAdmin: false })
- assert.equal(user.textKey, 'unavailable')
+ assert.equal(user.textKey, 'providerUnavailable')
assert.equal(user.link, null)
assert.equal(user.askKey, 'askFix')
assert.equal(commandsNotice('unavailable', { reason: 'x'.repeat(500) }).reason.length, 200)
})
test('chat commands: a Sandbox switched off in the chat is the user\'s own fix, with no link', () => {
- assert.deepEqual(commandsNotice('off', { isAdmin: false }), { state: 'off', textKey: 'off', link: null, askKey: null, reason: '' })
+ assert.deepEqual(commandsNotice('off', { isAdmin: false }), { state: 'off', textKey: 'disabled', link: null, askKey: null, reason: '' })
})
test('chat commands: hiding remembers the state, and storage that throws is ignored', () => {
@@ -59,23 +59,20 @@ test('chat commands: the built-in bash on this machine is a state of its own', a
assert.deepEqual(commandsState(undefined), { state: '', reason: '' })
})
-test('chat commands: a chat that runs commands here keeps a warning notice with the install link', async () => {
- const { SANDBOX_INSTALL_LINK } = await import('../../src/utils/chatCommands.js')
- const admin = commandsNotice('host', { isAdmin: true })
- assert.equal(admin.titleKey, 'hostTitle')
- assert.equal(admin.textKey, 'host')
- assert.equal(admin.tone, 'warn')
- assert.deepEqual(admin.link, { to: SANDBOX_INSTALL_LINK, labelKey: 'install' })
- assert.equal(commandsNotice('host', { isAdmin: false }).askKey, 'askInstall')
- assert.equal(commandsNotice('host-off', { isAdmin: true }).link, null, 'the person switches the Sandbox on in the menu')
- const why = commandsNotice('host-unavailable', { isAdmin: true, reason: 'no backend works' })
- assert.equal(why.textKey, 'hostUnavailableWhy')
- assert.deepEqual(why.link, { to: '/app/addons?detail=sandbox', labelKey: 'openSandbox' })
+test('chat commands: actual host execution keeps a generic isolation warning', () => {
+ for (const state of ['host', 'host-off', 'host-unavailable']) {
+ const info = commandsNotice(state, { isAdmin: true, reason: 'no backend works' })
+ assert.equal(info.titleKey, 'hostTitle')
+ assert.equal(info.textKey, 'providerHost')
+ assert.equal(info.tone, 'warn')
+ assert.equal(info.link, null)
+ assert.equal(info.askKey, null)
+ }
})
test('chat commands: the tool line says who served a call', async () => {
const { providerTag } = await import('../../src/utils/chatCommands.js')
- assert.deepEqual(providerTag({ kind: 'tool_provider', id: 'sandbox' }), { key: 'sandbox' })
+ assert.deepEqual(providerTag({ kind: 'tool_provider', id: 'sandbox' }), { key: 'addon', values: { name: 'sandbox' } })
assert.deepEqual(providerTag({ kind: 'tool_provider', id: 'other' }), { key: 'addon', values: { name: 'other' } })
assert.deepEqual(providerTag({ kind: 'host_exec', id: 'ran' }), { key: 'host' })
assert.deepEqual(providerTag({ kind: 'host_exec', id: 'once' }), { key: 'hostOnce' })
@@ -91,3 +88,218 @@ test('chat commands: a refused answer reads as an expired card only for 410 cons
assert.equal(consentExpired(new Error('network')), false)
assert.equal(consentExpired(undefined), false)
})
+
+test('typed safety: any actual addon host warns and unknown never appears isolated', async () => {
+ const { commandsState } = await import('../../src/utils/chatCommands.js')
+ assert.equal(commandsState({ commands: 'available', commands_provider: 'renamed', commands_isolation: 'host' }).state, 'provider-host')
+ assert.equal(commandsState({ commands: 'available', commands_provider: 'renamed' }).state, 'unknown')
+ assert.equal(commandsNotice('unknown').tone, 'warn')
+ assert.equal(commandsNotice('switched_off').textKey, 'disabled')
+})
+
+test('typed safety: dismissal tracks binding, status, chat and actor epoch', async () => {
+ const { noticeIdentity } = await import('../../src/utils/chatCommands.js')
+ const base = { commands: 'available', commands_provider: 'renamed', execution_status: { readiness: 'available', isolation: 'host', generation: '1', incarnation: 'a' } }
+ const id = noticeIdentity(base, 'chat', 'actor-session')
+ for (const field of ['generation', 'incarnation', 'isolation', 'readiness']) {
+ assert.notEqual(noticeIdentity({ ...base, execution_status: { ...base.execution_status, [field]: 'changed' } }, 'chat', 'actor-session'), id)
+ }
+ assert.notEqual(noticeIdentity(base, 'other', 'actor-session'), id)
+ assert.notEqual(noticeIdentity(base, 'chat', 'new-session'), id)
+})
+
+test('typed safety: explanation is bounded plain data with no install advice', async () => {
+ const { commandsState } = await import('../../src/utils/chatCommands.js')
+ const state = commandsState({ commands: 'unavailable', commands_provider: 'renamed', execution_status: { readiness: 'unknown', isolation: 'unknown', explanation: ''.repeat(30) } })
+ const notice = commandsNotice(state.state, { reason: state.reason })
+ assert.equal(notice.link, null)
+ assert.equal(notice.askKey, null)
+ assert.ok([...notice.reason].length <= 200)
+ assert.equal(typeof notice.reason, 'string')
+})
+
+test('typed safety: actual notice hook resets dismissal through loading, identity changes and logout', async () => {
+ const { readFileSync } = await import('node:fs')
+ const source = readFileSync(new URL('../../src/hooks/useCommandsNotice.js', import.meta.url), 'utf8').replace(/^import .*\n/gm, '').replace('export function', 'function')
+ let hidden = '', previous, effect
+ const hook = new Function('useState', 'useEffect', 'useCallback', 'commandsNotice', `${source}; return useCommandsNotice`)(
+ () => [hidden, v => { hidden = v }],
+ (fn, deps) => { if (previous !== deps[0]) { effect = fn; previous = deps[0] } },
+ fn => fn, commandsNotice)
+ const render = (state, identity = 'binding-1', actor = 'session-1') => {
+ const value = hook({ state, reason: '', identity }, false, [actor, 'chat'])
+ if (effect) { effect(); effect = null }
+ return value
+ }
+ render('provider-host').hide()
+ assert.equal(render('provider-host').hidden, true)
+ assert.equal(render('unknown', '').hidden, false)
+ assert.equal(render('provider-host').hidden, false, 'loading invalidates even the same binding')
+ render('provider-host').hide()
+ assert.equal(render('provider-host', 'binding-2').hidden, false)
+ assert.equal(render('provider-host', 'binding-1').hidden, false, 'replacement ABA does not restore dismissal')
+ render('provider-host').hide()
+ assert.equal(render('provider-host', 'binding-1', 'logged-out').hidden, false)
+})
+
+test('typed safety: actual explanation JSX escapes provider markup as React text', async () => {
+ const { readFileSync } = await import('node:fs')
+ const React = await import('react')
+ const { renderToStaticMarkup } = await import('react-dom/server')
+ const { transformWithOxc } = await import('vite')
+ const source = readFileSync(new URL('../../src/components/chat/CommandsNotice.jsx', import.meta.url), 'utf8')
+ assert.ok(!source.includes('dangerouslySetInnerHTML'))
+ const jsx = source.slice(source.indexOf('
{info.reason}
'), source.indexOf('
{info.reason}
') + '
{info.reason}
'.length)
+ const { code } = await transformWithOxc(`return (${jsx})`, 'notice.jsx', { jsx: { runtime: 'classic' } })
+ const element = new Function('React', 'info', code)(React, { reason: '' })
+ const html = renderToStaticMarkup(element)
+ assert.match(html, /<img/)
+ assert.ok(!html.includes(' {
+ const { commandsState } = await import('../../src/utils/chatCommands.js')
+ for (const commands of ['none', 'unavailable', 'available']) {
+ const projected = commandsState({ commands, execution_status: { readiness: 'unknown', isolation: 'isolated' } })
+ const notice = commandsNotice(projected.state, { isAdmin: true })
+ assert.ok(notice)
+ assert.equal(notice.link, null)
+ assert.equal(notice.askKey, null)
+ }
+})
+
+test('typed safety: builtin and all fallback notices have no Sandbox advice for any role', async () => {
+ const { commandsState } = await import('../../src/utils/chatCommands.js')
+ const { readFileSync } = await import('node:fs')
+ const translations = JSON.parse(readFileSync(new URL('../../public/locales/en/chats.json', import.meta.url))).tools.commands
+ for (const isAdmin of [true, false]) {
+ for (const fallback of [undefined, 'off', 'unavailable']) {
+ const projected = commandsState({ commands: 'available', commands_provider: 'builtin', commands_fallback: fallback && 'renamed', commands_fallback_reason: fallback })
+ const notice = commandsNotice(projected.state, { isAdmin, reason: projected.reason })
+ assert.equal(notice.tone, 'warn')
+ assert.equal(notice.link, null)
+ assert.equal(notice.askKey, null)
+ assert.match(translations[notice.textKey], /without isolation/)
+ assert.doesNotMatch(translations[notice.textKey], /sandbox|install|repair|switch.*on/i)
+ }
+ }
+})
+
+test('typed safety: actual switches reject late actor replies and hide old status before effects', async () => {
+ const { hookHarness } = await import('./chatcommands-lifecycle.mjs')
+ const names = ['chatMCP', 'chatSwitchRows', 'chatSkills', 'chatCommands', 'chatDraftSwitches']
+ const imports = Object.assign({}, ...await Promise.all(names.map(name => import(`../../src/utils/${name}.js`))))
+ const requests = []
+ const list = () => new Promise(resolve => requests.push(resolve))
+ const h = hookHarness('../../src/hooks/useChatSwitches.js', 'useChatSwitches', {
+ ...imports, chatAddonsApi: { list }, chatToolsApi: { list: async () => ({}) }, chatSkillsApi: { list: async () => ({}) },
+ })
+ const render = actor => h.render('same-chat', false, null, actor)
+ const isolated = { commands: 'available', commands_provider: 'renamed', commands_isolation: 'isolated' }
+ render('A:1'); h.flush()
+ assert.equal(requests.length, 1)
+ assert.equal(render('B:1').commands.state, 'unknown', 'render invalidates A before effect cleanup')
+ requests[0](isolated); await new Promise(resolve => setImmediate(resolve))
+ assert.equal(render('B:1').commands.state, 'unknown', 'late A cannot publish in B even before effects')
+ h.flush()
+ assert.equal(requests.length, 2, 'actor change starts a new request')
+ requests[1](isolated); await new Promise(resolve => setImmediate(resolve))
+ assert.equal(render('B:1').commands.state, 'available')
+ assert.equal(render('logged-out:2').commands.state, 'unknown')
+ h.flush()
+ assert.equal(render('B:3').commands.state, 'unknown', 'same user login does not reuse the old session')
+ h.flush()
+ requests[2](isolated); await new Promise(resolve => setImmediate(resolve))
+ assert.equal(render('B:3').commands.state, 'unknown', 'logout reply cannot populate new login')
+ requests[3](isolated); await new Promise(resolve => setImmediate(resolve))
+ assert.equal(render('B:3').commands.state, 'available')
+})
+
+test('typed safety: actual auth epoch reaches composer and changes on same-user logout/login', async () => {
+ const { hookHarness } = await import('./chatcommands-lifecycle.mjs')
+ const originalFetch = globalThis.fetch
+ const originalDocument = globalThis.document
+ const originalWindow = globalThis.window
+ try {
+ globalThis.fetch = async () => ({ json: async () => ({ authEnabled: true, user: { id: 'A' } }) })
+ globalThis.document = { cookie: '' }
+ globalThis.window = { location: {} }
+ const auth = hookHarness('../../src/context/AuthContext.jsx', 'AuthProvider', { apiUrl: path => path, addonUpgrades: (await import('../../src/utils/addonUpgrades.js')).createUpgradeStore() }, source => source.replace(/return \(\s* setImmediate(resolve))
+ let currentAuth = auth.render({})
+ const seen = []
+ const composer = hookHarness('../../src/hooks/useChatComposer.js', 'useChatComposer', {
+ useAuth: () => currentAuth,
+ useChatDrafts: () => ({ setFiles() {} }),
+ useModels: () => ({ models: [] }), CAP_CHAT: 'chat',
+ useChatSwitches: (...args) => { seen.push(args); return {} },
+ })
+ composer.render({ chatId: 'same-chat', userId: 'A' })
+ const initial = seen.at(-1)[3]
+ assert.ok(initial)
+ const beforeLogout = currentAuth.sessionEpoch
+ const logout = currentAuth.logout()
+ currentAuth = auth.render({})
+ assert.equal(currentAuth.user, null, 'logout invalidates before network completion')
+ assert.ok(currentAuth.sessionEpoch > beforeLogout)
+ composer.render({ chatId: 'same-chat', userId: 'A' })
+ assert.notEqual(seen.at(-1)[3], initial)
+ await logout
+ await currentAuth.refresh()
+ currentAuth = auth.render({})
+ assert.equal(currentAuth.user.id, 'A')
+ composer.render({ chatId: 'same-chat', userId: 'A' })
+ assert.notEqual(seen.at(-1)[3], initial, 'same actor has a new epoch after login')
+ } finally {
+ globalThis.fetch = originalFetch
+ globalThis.document = originalDocument
+ globalThis.window = originalWindow
+ }
+})
+
+for (const provider of ['copper-runner', 'violet-executor']) test(`typed safety: production Composer uses current ${provider} DTO, never discovery isolation`, async () => {
+ const { hookHarness } = await import('./chatcommands-lifecycle.mjs')
+ const names = ['chatMCP', 'chatSwitchRows', 'chatSkills', 'chatCommands', 'chatDraftSwitches']
+ const imports = Object.assign({}, ...await Promise.all(names.map(name => import(`../../src/utils/${name}.js`))))
+ const requests = []
+ const switchesHook = hookHarness('../../src/hooks/useChatSwitches.js', 'useChatSwitches', {
+ ...imports, chatAddonsApi:{list:() => new Promise(resolve => requests.push(resolve))},
+ chatToolsApi:{list:async () => ({})}, chatSkillsApi:{list:async () => ({})},
+ })
+ const composer = hookHarness('../../src/components/chat/ChatComposer.jsx', 'ChatComposer', {
+ useTranslation:() => ({t:key => key}), useAuth:() => ({isAdmin:true,user:{id:'alice'}}),
+ useModelFit:() => ({}), useCommandsNotice:() => ({info:null}),
+ // Only the original code needs these; keeping them permits a genuine RED run.
+ SANDBOX_ADDON:'sandbox', useSandboxStatus:() => null,
+ }, source => source.replace('export default function', 'function').split(' // What a row of the model list')[0] + 'return shellNote\n}')
+ const group = {group:'shell',providers:{bash:provider},provider,usable:true}
+ const render = (actor = 'alice:1', override) => {
+ const switches = switchesHook.render('same-chat', false, null, actor)
+ if (override) switches.commands = override
+ const shellNote = composer.render({composer:{drafts:{},switches,models:[]}})
+ return shellNote(group)
+ }
+ const unknown = note => {
+ assert.equal(note.note, 'tools.commands.unknown')
+ assert.notEqual(note.note, 'tools.shell.isolated')
+ }
+ unknown(render()); switchesHook.flush()
+ const reply = isolation => ({commands:'available',commands_provider:provider,execution_status:{readiness:'available',isolation,generation:2,incarnation:'run-2'}})
+ requests[0](reply('isolated')); await new Promise(resolve => setImmediate(resolve))
+ assert.equal(render().note, 'tools.shell.isolated')
+ unknown(render('alice:2')) // Same user refresh before cleanup invalidates positive evidence.
+ switchesHook.flush()
+ unknown(render('bob:3')) // Withhold cleanup of the second request.
+ requests[1](reply('isolated')); await new Promise(resolve => setImmediate(resolve))
+ unknown(render('bob:3')) // Late authenticated generation cannot publish.
+ switchesHook.flush()
+ requests[2](reply('host')); await new Promise(resolve => setImmediate(resolve))
+ assert.equal(render('bob:3').note, 'tools.commands.providerHost')
+ assert.equal(render('bob:3').warn, true)
+ for (const state of ['unknown', 'loading', '', null]) unknown(render('bob:3', {state,provider}))
+ unknown(render('bob:3', {state:'available',provider:'different-current-provider'}))
+ // Built-in bash remains a primitive choice, not an add-on identity.
+ group.providers.bash = 'builtin'
+ assert.equal(render('bob:3', {state:'host',provider:'builtin'}).warn, true)
+})
diff --git a/core/http/react-ui/tests/unit/chategress.test.mjs b/core/http/react-ui/tests/unit/chategress.test.mjs
index 69c0a8c59..d1479327e 100644
--- a/core/http/react-ui/tests/unit/chategress.test.mjs
+++ b/core/http/react-ui/tests/unit/chategress.test.mjs
@@ -22,7 +22,7 @@ test('chat egress: the Tools menu shows the row only where the Sandbox serves, a
// The policy applies, but no add-on draws the network row for it.
assert.equal(networkMode({ loaded: true, applies: true }), 'hidden')
assert.equal(networkMode({ loaded: true, applies: false, applies_reason: 'host' }), 'host')
- assert.equal(networkMode({ loaded: true, applies: false, applies_reason: 'none' }), 'hidden')
+ assert.equal(networkMode({ loaded: true, applies: false, applies_reason: 'none' }), 'unverified')
})
test('chat egress: cards sort pending first, newest first, and group by tool line', () => {
@@ -80,3 +80,7 @@ test('chat egress: the highlighted choice is what applies, never a choice above
assert.equal(askedAboveLimit({ preset: 'all', ceiling: 'off' }), 'all')
assert.equal(askedAboveLimit({ preset: 'registries', ceiling: 'all' }), '')
})
+
+test('chat egress: isolated reports cannot override backend false applicability', () => {
+ assert.equal(networkMode({ loaded: true, applies: false, addon: 'renamed', applies_reason: 'none', commands_isolation: 'isolated', preset: 'all' }), 'unverified')
+})
diff --git a/core/http/react-ui/tests/unit/chatfeatures.test.mjs b/core/http/react-ui/tests/unit/chatfeatures.test.mjs
index e7d46eaa4..216df6097 100644
--- a/core/http/react-ui/tests/unit/chatfeatures.test.mjs
+++ b/core/http/react-ui/tests/unit/chatfeatures.test.mjs
@@ -142,21 +142,19 @@ test('tool refusals: the typed codes of the add-on tools are read, an untyped er
assert.equal(toolRefusal('read_file: /a\nplain text'), null)
})
-test('sandbox state: the Tools picker reads isolated, host, cannot-run or unknown from the status read', async () => {
- const { sandboxState } = await import('../../src/utils/sandboxStatus.js')
- assert.deepEqual(sandboxState({ ok: true, canRun: true, active: 'gvisor' }), { state: 'isolated', backend: 'gvisor', reason: '' })
- assert.equal(sandboxState({ ok: true, canRun: true, active: 'host', hostNotice: true }).state, 'host')
- assert.deepEqual(sandboxState({ ok: true, canRun: false, backends: [{ name: 'kvm', usable: false, reason: 'no /dev/kvm' }] }), { state: 'cannot', backend: '', reason: 'no /dev/kvm' })
- assert.equal(sandboxState(null).state, 'unknown')
- assert.equal(sandboxState({ ok: false }).state, 'unknown')
- const { startingState } = await import('../../src/utils/sandboxStatus.js')
- assert.deepEqual(startingState(4000.4), { state: 'starting', backend: '', reason: '', retryMs: 4000 })
+test('command state: private discovery fields cannot establish selected provider isolation', async () => {
+ const { commandsState } = await import('../../src/utils/chatCommands.js')
+ for (const provider of ['copper-runner', 'violet-executor']) {
+ const res = { commands: 'available', commands_provider: provider, ok: true, canRun: true, active: 'gvisor' }
+ assert.equal(commandsState(res).state, 'unknown')
+ assert.equal(commandsState({ ...res, execution_status: { readiness: 'available', isolation: 'isolated' } }).state, 'available')
+ assert.equal(commandsState({ ...res, execution_status: { readiness: 'unknown', isolation: 'isolated' } }).state, 'unknown')
+ }
})
-test('home actions: an administrator without Studio gets Install Studio under the same command', async () => {
+test('home actions: only generic Apps browsing is native, never an install command', async () => {
const { availableActions } = await import('../../src/components/home/homeActions.js')
- const ids = (ctx) => availableActions(ctx).filter(a => a.cmd === '/studio').map(a => a.id)
- assert.deepEqual(ids({ isAdmin: true, studioInstalled: false }), ['studioInstall'])
- assert.deepEqual(ids({ isAdmin: true, studioInstalled: true }), ['studio'])
- assert.deepEqual(ids({ isAdmin: false, studioInstalled: false }), [])
+ assert.ok(availableActions({ hasAddons: true }).some(a => a.cmd === '/apps'))
+ assert.ok(!availableActions({ hasAddons: false }).some(a => a.cmd === '/apps'))
+ assert.ok(!availableActions({ isAdmin: true }).some(a => a.cmd === '/studio'))
})
diff --git a/core/http/react-ui/tests/unit/composer-render.test.mjs b/core/http/react-ui/tests/unit/composer-render.test.mjs
new file mode 100644
index 000000000..c6559dabc
--- /dev/null
+++ b/core/http/react-ui/tests/unit/composer-render.test.mjs
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: MIT
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import ts from 'typescript'
+import React, { useCallback, useMemo, useState } from 'react'
+import { renderToString } from 'react-dom/server'
+import * as slash from '../../src/utils/slashCommands.js'
+
+// Compile the entire production component, not a selected callback or source slice.
+// Leaf components and service hooks are boundaries; React executes all composer hooks.
+const source = readFileSync(new URL('../../src/components/chat/ChatComposer.jsx', import.meta.url), 'utf8')
+const names = [...source.matchAll(/^import (\w+) from/gm)].map(m => m[1])
+const leaf = () => null
+let captured
+const scope = {
+ ...Object.fromEntries(names.map(n => [n, leaf])), ...slash,
+ React, useCallback, useMemo, useState,
+ useTranslation: () => ({ t: k => k }), useAuth: () => ({ isAdmin: true, user: { id: 'a' } }),
+ useModelFit: () => ({ resources: null }), useCommandsNotice: () => ({}),
+ hostMemory: () => null, agentFolderLine: () => '', CAP_CHAT: 'chat',
+ CHAT_SLASH_ACTIONS: [], SLASH_ACTIONS: [],
+ HomeComposer: props => { captured = props; return React.createElement('textarea', { defaultValue: props.message }) },
+ CommandsChip: leaf, CommandsNotice: leaf, ToolFallbacks: leaf, ChatToolsControls: leaf,
+}
+const compiled = ts.transpileModule(source.replace(/^import .*\n/gm, '').replace('export default function', 'function'), {
+ compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, fileName: 'ChatComposer.jsx',
+}).outputText
+const Composer = new Function(...Object.keys(scope), `${compiled}; return ChatComposer`)(...Object.values(scope))
+
+test('full ChatComposer renders and about uses only selected command status', () => {
+ for (const [state, expected] of [['available', 'isolated'], ['host', 'host'], ['provider-host', 'host'], ['unknown', 'unknown'], ['loading', 'unknown'], ['', 'unknown'], ['none', 'none']]) {
+ const composer = {
+ drafts: { text: '', files: [], setText() {}, setFiles() {} }, models: [], agents: [],
+ switches: { commands: { state }, builtins: [], addons: [], servers: [], connectors: [], skills: [], fallbacks: [] },
+ }
+ let result
+ assert.match(renderToString(React.createElement(Composer, { composer, onRunAction: (_, value) => { result = value } })), /textarea/)
+ captured.onRunAction('about')
+ assert.equal(result.about.commands, expected, state)
+ }
+})
diff --git a/core/http/react-ui/tests/unit/esm.test.mjs b/core/http/react-ui/tests/unit/esm.test.mjs
index 251634535..60792154c 100644
--- a/core/http/react-ui/tests/unit/esm.test.mjs
+++ b/core/http/react-ui/tests/unit/esm.test.mjs
@@ -675,8 +675,8 @@ test('registry: the list is read off the Add-ons page only for a viewer with the
assert.equal(contributesUI([{ ...nav[0], state: 'failed' }]), false)
assert.equal(contributesUI(null), false)
const base = { allowed: true, onAddonsPage: false, apps: plain, status: 'ready', consumers: 1 }
- // Nothing contributes and the list is known: no read at all.
- assert.equal(shouldPoll(base), false)
+ // A mounted consumer keeps even an empty/disabled list current.
+ assert.equal(shouldPoll(base), true)
// A contribution keeps it current, with or without a consumer.
assert.equal(shouldPoll({ ...base, apps: nav, consumers: 0 }), true)
// The Add-ons page always reads it.
diff --git a/core/http/react-ui/tests/unit/homeassistant.test.mjs b/core/http/react-ui/tests/unit/homeassistant.test.mjs
index 3263141b5..30d7dea7b 100644
--- a/core/http/react-ui/tests/unit/homeassistant.test.mjs
+++ b/core/http/react-ui/tests/unit/homeassistant.test.mjs
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { readdirSync, readFileSync } from 'node:fs'
import { availableActions, filterActions } from '../../src/components/home/homeActions.js'
-const base = { isAdmin: true, hasModels: true, loadedCount: 0, studioInstalled: false, assistantAvailable: true }
+const base = { isAdmin: true, hasModels: true, loadedCount: 0, assistantAvailable: true }
const ids = (ctx) => availableActions(ctx).map(a => a.id)
test('home assistant: the /assistant action is offered to an admin when the server has the assistant', () => {
diff --git a/core/http/react-ui/tests/unit/legacyroutes.test.mjs b/core/http/react-ui/tests/unit/legacyroutes.test.mjs
index caa124de7..3b5525c58 100644
--- a/core/http/react-ui/tests/unit/legacyroutes.test.mjs
+++ b/core/http/react-ui/tests/unit/legacyroutes.test.mjs
@@ -3,125 +3,24 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { addonPageAddress, bareChatsRedirect, chatsRedirect, STUDIO_TABS, studioAddress, studioRedirect, talkAddress, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js'
+import { bareChatsRedirect, chatsRedirect, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js'
const here = dirname(fileURLToPath(import.meta.url))
const router = readFileSync(join(here, '../../src/router.jsx'), 'utf8')
-test('studioRedirect opens the Studio add-on and keeps the model, the query and the hash', () => {
- // A model id may hold "/" or ":", which an add-on path segment cannot, so it travels as ?model=.
- assert.equal(studioRedirect('image', 'stable/diffusion', '?x=1', '#top'), '/app/addons/studio/studio/images?x=1&model=stable%2Fdiffusion#top')
- assert.equal(studioRedirect('3d', undefined, '', ''), '/app/addons/studio/studio/threed')
- assert.equal(studioRedirect('tts', 'piper', '?voice=a', ''), '/app/addons/studio/studio/tts?voice=a&model=piper')
-})
+import { addonPageAddress } from '../../src/addons/addresses.js'
-test('studioAddress and talkAddress build the add-on page addresses', () => {
- assert.equal(studioAddress(), '/app/addons/studio/studio')
- assert.equal(studioAddress({ tab: 'images', model: 'a b', search: '?work=1' }), '/app/addons/studio/studio/images?work=1&model=a+b')
- assert.equal(studioAddress({ search: '?work=w%201' }), '/app/addons/studio/studio?work=w+1')
- assert.equal(talkAddress(), '/app/addons/talk/talk')
- assert.equal(talkAddress({ search: '?model=x', hash: '#h' }), '/app/addons/talk/talk?model=x#h')
+test('removed addon routes have no registrations and native routes remain', () => {
+ for (const path of ['studio','studio/:tab','studio/:tab/:model','talk','image','video','3d','tts','sound','diarization','transform','face','voice','voice-library','voice-library/new','fine-tune','quantize']) {
+ assert.ok(!router.includes(`path: '${path}'`), path)
+ assert.ok(!router.includes(`path: '${path}/:model'`), path + '/:model')
+ }
+ assert.ok(router.includes("path: '*'"))
+ for (const name of ['StudioTabRedirect','StudioRedirect','TalkRedirect','AddonRedirect','AddonsOffRedirect']) assert.ok(!router.includes(name), name)
})
-
-test('every standalone generator route maps to a Studio tab', () => {
- assert.deepEqual(Object.keys(STUDIO_TABS).sort(), ['3d', 'diarization', 'image', 'sound', 'transform', 'tts', 'video'])
+test('generic addon addresses retain nested pages selected model query and hash', () => {
+ assert.equal(addonPageAddress({addon:'arbitrary',page:'voice/library/new',model:'org/name:雪?&',search:'?x=1',hash:'#h'}), '/app/addons/arbitrary/voice/library/new?x=1&model=org%2Fname%3A%E9%9B%AA%3F%26#h')
})
-
-test('every moved row targets what studioRedirect builds for its own generator', () => {
- const moved = MIGRATION_ROUTES.filter(r => r.kind === 'studio-tab')
- assert.equal(moved.length, 7)
- for (const r of moved) {
- const key = r.from.replace('/app/', '')
- assert.equal(r.to, studioRedirect(key), `${r.from} targets ${r.to}`)
- }
-})
-
-test('each moved route has its own redirect entry, plain and with a model, bound to its own generator', () => {
- const lines = router.split('\n')
- for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'studio-tab')) {
- const key = r.from.replace('/app/', '')
- for (const path of [key, `${key}/:model`]) {
- const entry = lines.find(l => l.includes(`{ path: '${path}', element:`))
- assert.ok(entry, `router.jsx has no entry for path ${path}`)
- assert.ok(entry.includes(``), `entry for ${path} is not bound to legacy="${key}": ${entry.trim()}`)
- }
- }
-})
-
-test('the Studio and Talk routes redirect to their add-ons, with or without a tab and a model', () => {
- for (const path of ["path: 'studio'", "path: 'studio/:tab'", "path: 'studio/:tab/:model'"]) {
- assert.ok(router.includes(`{ ${path}, element: }`), `no StudioRedirect for ${path}`)
- }
- assert.ok(router.includes("{ path: 'talk', element: }"))
- assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/studio' && r.to === '/app/addons/studio/studio' && r.kind === 'addon-page'))
- assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/talk' && r.to === '/app/addons/talk/talk' && r.kind === 'addon-page'))
-})
-
-test('the router imports no Studio or Talk page: both are add-ons', () => {
- assert.ok(!/pages\/(Studio|Talk|ImageGen|VideoGen|ThreeDGen|TTS|Sound|Diarization|AudioTransform)'/.test(router))
-})
-
-test('the same-path routes need no redirect', () => {
- for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'same')) assert.equal(r.from, r.to)
-})
-
-test('moved routes redirect to their add-on page, with no built-in page behind them', () => {
- // Studio and Talk have their own redirects (see above).
- for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'addon-page' && !['/app/studio', '/app/talk'].includes(x.from))) {
- const [, , , addon, ...rest] = r.to.split('/')
- const page = rest.join('/')
- assert.ok(router.includes(``) || router.includes(``), `router.jsx does not redirect ${r.from}`)
- }
- assert.ok(!router.includes('MovedPage'))
- assert.ok(!/pages\/(FaceRecognition|VoiceRecognition|VoiceLibrary|VoiceProfileCreate|FineTune|Quantize)'/.test(router))
-})
-
-test('addonPageAddress keeps the page path, the model, the query and the hash', () => {
- assert.equal(addonPageAddress({ addon: 'voice', page: 'voice/recognition' }), '/app/addons/voice/voice/recognition')
- assert.equal(addonPageAddress({ addon: 'face', page: 'recognition', model: 'org/name', search: '?x=1', hash: '#h' }), '/app/addons/face/recognition?x=1&model=org%2Fname#h')
-})
-
-test('a moved route with a :model segment carries the model as ?model=, not in the path', () => {
- assert.ok(router.includes('withModel'), 'the model routes must use withModel')
- assert.ok(!/\/app\/addons\/\$\{addon\}\/\$\{page\}\/\$\{model\}/.test(router), 'the model must not be appended to the path')
-})
-
-test('the face route and the face model route both open the Face add-on, the model as ?model=', () => {
- assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/face' && r.to === '/app/addons/face/recognition' && r.kind === 'addon-page'))
- const lines = router.split('\n')
- const plain = lines.find(l => l.includes("{ path: 'face', element:"))
- const withModel = lines.find(l => l.includes("{ path: 'face/:model', element:"))
- assert.ok(plain?.includes(''), 'face does not redirect')
- assert.ok(withModel?.includes(''), 'face/:model does not redirect with withModel')
-})
-
-test('the fine-tune and quantize routes redirect to their add-on pages', () => {
- const lines = router.split('\n')
- const rows = [
- { from: '/app/fine-tune', to: '/app/addons/fine-tuning/jobs', addon: 'fine-tuning', tool: 'fineTune', feature: 'fine_tuning', path: 'fine-tune' },
- { from: '/app/quantize', to: '/app/addons/quantization/jobs', addon: 'quantization', tool: 'quantize', feature: 'quantization', path: 'quantize' },
- ]
- for (const r of rows) {
- assert.ok(MIGRATION_ROUTES.some(m => m.from === r.from && m.to === r.to && m.kind === 'addon-page'), `no row for ${r.from}`)
- const entry = lines.find(l => l.includes(`{ path: '${r.path}', element:`))
- assert.ok(entry?.includes(``), `${r.path} does not redirect`)
- }
-})
-
-test('the Build hub is gone and its address opens the models', async () => {
- const { hubs } = await import('../../src/components/hub/hubConfig.js')
- assert.ok(!hubs.some(h => h.id === 'build'))
- const entry = router.split('\n').find(l => l.includes("{ path: 'build', element:"))
- assert.ok(entry?.includes(''))
-})
-
-test('the voice routes open the tabs of the one Voice page', () => {
- const to = (from) => MIGRATION_ROUTES.find(r => r.from === from)?.to
- assert.equal(to('/app/voice'), '/app/addons/voice/voice/recognition')
- assert.equal(to('/app/voice-library'), '/app/addons/voice/voice/library')
- assert.equal(to('/app/voice-library/new'), '/app/addons/voice/voice/library/new')
-})
-
test('chatsRedirect opens the matching page and keeps the query and the hash; the old chat page opens Home', () => {
assert.equal(chatsRedirect('/app/chat'), '/app')
assert.equal(chatsRedirect('/app/chat/'), '/app')
diff --git a/core/http/react-ui/tests/unit/slots.test.mjs b/core/http/react-ui/tests/unit/slots.test.mjs
index 1c7dfd518..71d3a5586 100644
--- a/core/http/react-ui/tests/unit/slots.test.mjs
+++ b/core/http/react-ui/tests/unit/slots.test.mjs
@@ -291,8 +291,8 @@ test('slots: the SDK can present an approval of its own addon only through the h
test('slots: a page that only needs the list reads it once and never retries a failed read', () => {
const base = { allowed: true, onAddonsPage: false, apps: [], consumers: 3 }
assert.equal(shouldPoll({ ...base, status: 'idle' }), true)
- assert.equal(shouldPoll({ ...base, status: 'ready' }), false)
- assert.equal(shouldPoll({ ...base, status: 'error' }), false, 'no loop of failing reads for a page that only draws slots')
+ assert.equal(shouldPoll({ ...base, status: 'ready' }), true)
+ assert.equal(shouldPoll({ ...base, status: 'error' }), true, 'mounted consumers recover after failing reads')
assert.equal(shouldPoll({ ...base, status: 'idle', allowed: false }), false)
})
diff --git a/core/http/routes/addon_navigation_test.go b/core/http/routes/addon_navigation_test.go
index 413a2f077..55c366b69 100644
--- a/core/http/routes/addon_navigation_test.go
+++ b/core/http/routes/addon_navigation_test.go
@@ -99,9 +99,8 @@ var _ = Describe("Add-on navigation routes with real authentication", func() {
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
var got []string
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
- // The member existed when the routes registered, so the one-time upgrade
- // seed pinned Studio and Talk for it.
- Expect(got).To(Equal([]string{"studio", "talk"}))
+ // Navigation entries do not implicitly opt a member into pins.
+ Expect(got).To(BeEmpty())
put := func(sameOrigin bool) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPut, "/api/addons/pins", strings.NewReader(`["talk","studio"]`))
diff --git a/core/http/routes/studio_egress_test.go b/core/http/routes/studio_egress_test.go
index 0eb49a4e4..00d426fe7 100644
--- a/core/http/routes/studio_egress_test.go
+++ b/core/http/routes/studio_egress_test.go
@@ -11,6 +11,7 @@ import (
"github.com/mudler/LocalAI/core/services/studio/egress"
"github.com/mudler/LocalAI/core/services/studio/egress/audit"
"github.com/mudler/LocalAI/core/services/studio/store"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -27,7 +28,7 @@ var _ = Describe("Chats Sandbox network routes", func() {
s = newStudioServerWith(newTestFiles(), func(d *studio.Deps) {
chats = d.Store
d.IsAdmin = func(_ context.Context, user string) bool { return user == "root" }
- d.AddonTools = routeAddons{}
+ d.AddonTools = networkPolicyAddons{}
d.EgressAudit = lines
d.EgressSettings = func(context.Context) (egress.Settings, error) {
return egress.Settings{Ceiling: egress.PresetRegistries, ExtraRegistries: []string{"mirror.example.org"}}, nil
@@ -54,7 +55,9 @@ var _ = Describe("Chats Sandbox network routes", func() {
v := decodeJSON(rec)
Expect(v).To(HaveKeyWithValue("preset", "off"))
Expect(v).To(HaveKeyWithValue("ceiling", "registries"))
- Expect(v).To(HaveKeyWithValue("applies", true))
+ // Declared controls draw the row; neither isolation nor the control proves enforcement.
+ Expect(v).To(HaveKeyWithValue("applies", false))
+ Expect(v).To(HaveKeyWithValue("addon", "sandbox"))
Expect(v["registries"]).To(ContainElement(HaveKeyWithValue("id", "extra")))
rec = s.do("alice", http.MethodPut, "/api/chats/"+id+"/egress", map[string]any{"preset": "all"})
@@ -140,3 +143,13 @@ var _ = Describe("Chats Sandbox network routes", func() {
}
})
})
+
+// Only this fixture declares enforcement of the host network policy.
+type networkPolicyAddons struct{ routeAddons }
+
+func (networkPolicyAddons) Controls(_ context.Context, _, addon string) []string {
+ if addon == "sandbox" {
+ return []string{contract.ControlNetworkPolicy}
+ }
+ return nil
+}
diff --git a/core/schema/chats.go b/core/schema/chats.go
index e56404ea5..568943385 100644
--- a/core/schema/chats.go
+++ b/core/schema/chats.go
@@ -802,6 +802,8 @@ type ChatsAddon struct {
// ChatsAddonList lists the add-ons a chat can use.
type ChatsAddonList struct {
+ ExecutionStatus *ChatsExecutionStatus `json:"execution_status,omitempty"`
+
Addons []ChatsAddon `json:"addons"`
// Commands says whether the chat can run commands and code at its next
// turn: available, off (the add-on that runs them is switched off for this
@@ -1689,3 +1691,12 @@ type ChatsEgressEvent struct {
type ChatsEgressEventList struct {
Events []ChatsEgressEvent `json:"events"`
}
+
+// ChatsExecutionStatus is presentation-only; it grants no execution authority.
+type ChatsExecutionStatus struct {
+ Readiness string `json:"readiness"`
+ Isolation string `json:"isolation"`
+ Explanation string `json:"explanation,omitempty"`
+ Generation string `json:"generation,omitempty"`
+ Incarnation string `json:"incarnation,omitempty"`
+}
diff --git a/core/services/addons/admission.go b/core/services/addons/admission.go
new file mode 100644
index 000000000..536fe2952
--- /dev/null
+++ b/core/services/addons/admission.go
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: MIT
+package addons
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
+ "github.com/mudler/LocalAI/core/services/addons/packages"
+)
+
+// HostRequirementsRefusal is returned only by owner-admin lifecycle operations.
+// It contains verified request names, never policy paths or grants of others.
+type HostRequirementsRefusal struct{ Summary hostops.RequestSummary }
+
+func (e *HostRequirementsRefusal) Error() string {
+ return fmt.Sprintf("required host operations unavailable: %s; configure host policy and restart", strings.Join(e.Summary.MissingRequired, ", "))
+}
+func (m *Manager) requests(r packages.PackageReceipt) hostops.RequestSummary {
+ return hostops.Requests(r.Manifest.Capabilities.HostOperations, hostops.SupportedOperations(), m.hostOps[r.Manifest.ID])
+}
+func (m *Manager) admit(r packages.PackageReceipt) error {
+ s := m.requests(r)
+ if len(s.MissingRequired) != 0 {
+ return &HostRequirementsRefusal{Summary: s}
+ }
+ return nil
+}
+func (m *Manager) admission(id string) error {
+ if r, ok := currentReceipt(m.journal.Addons[id]); ok {
+ return m.admit(r)
+ }
+ return nil
+}
+
+func summaryPointer(s hostops.RequestSummary) *hostops.RequestSummary {
+ if s.RequiredCount+s.OptionalCount == 0 {
+ return nil
+ }
+ return &s
+}
+
+func copyRequestSummary(s *hostops.RequestSummary) *hostops.RequestSummary {
+ if s == nil {
+ return nil
+ }
+ out := *s
+ out.Required = slices.Clone(s.Required)
+ out.Optional = slices.Clone(s.Optional)
+ out.Granted = slices.Clone(s.Granted)
+ out.MissingRequired = slices.Clone(s.MissingRequired)
+ out.MissingOptional = slices.Clone(s.MissingOptional)
+ return &out
+}
diff --git a/core/services/addons/apps/apps.go b/core/services/addons/apps/apps.go
index 06878624b..9eab146a9 100644
--- a/core/services/addons/apps/apps.go
+++ b/core/services/addons/apps/apps.go
@@ -5,6 +5,8 @@
package apps
import (
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
+ "maps"
"net/url"
"regexp"
"slices"
@@ -34,7 +36,9 @@ type Snapshot struct {
UpdatePending bool
// HostOps are the host operation prefixes the host setting grants this
// add-on. They decide which declared host controls are drawn.
- HostOps []string
+ HostOps []string
+ HostOperations *hostops.RequestSummary
+ Unavailable bool
}
// List is the response of the apps list.
@@ -151,12 +155,27 @@ type UIView struct {
Actions []ChatActionView `json:"actions,omitempty"`
Slots []SlotView `json:"slots,omitempty"`
Renderers []contract.Renderer `json:"renderers,omitempty"`
+ // HomeCommands contains only resolved navigation metadata for this app.
+ HomeCommands []HomeCommandView `json:"homeCommands,omitempty"`
+ // ModelActions contains only declared destinations for this installed UI.
+ ModelActions []contract.ModelAction `json:"modelActions,omitempty"`
// Controls are the host controls the host draws for the addon: the ones
// its manifest declares that its grants and tools entitle it to now. The
// host draws them with its own components; the addon supplies nothing.
Controls []string `json:"controls,omitempty" enums:"network-policy,tool-providers" example:"network-policy"`
}
+// HomeCommandView is a navigation declaration resolved against this installed
+// generation. Page is a declared page identifier, not a URL or tool invocation.
+type HomeCommandView struct {
+ ID string `json:"id"`
+ Alias string `json:"alias,omitempty"`
+ Page string `json:"page"`
+ Label string `json:"label"`
+ Labels map[string]string `json:"labels,omitempty"`
+ Icon string `json:"icon"`
+}
+
// ChatActionView is a declared chat action as the host draws it.
type ChatActionView struct {
// ID is the manifest identifier of the action.
@@ -191,8 +210,9 @@ type SlotView struct {
// refuses the matching requests of everyone else; this is a convenience for
// the UI, never the control.
type AdminView struct {
- CanStart bool `json:"canStart"`
- CanStop bool `json:"canStop"`
+ CanStart bool `json:"canStart"`
+ HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"`
+ CanStop bool `json:"canStop"`
// ManagementURL opens the management view of the addon.
ManagementURL string `json:"managementUrl" example:"/app/addons?manage=notes"`
}
@@ -242,10 +262,10 @@ func build(s Snapshot, admin bool) App {
// The receipt came from the owner's journal, but the route is
// re-checked here: an Open action for a page the addon does not
// declare is never produced.
- if s.Enabled && u.MainPage != "" && slices.Contains(u.Pages, u.MainPage) {
+ if s.Enabled && !s.Unavailable && u.MainPage != "" && slices.Contains(u.Pages, u.MainPage) {
a.MainPage = "/app/addons/" + url.PathEscape(id) + "/" + url.PathEscape(u.MainPage)
}
- if s.Enabled {
+ if s.Enabled && !s.Unavailable {
a.UI = uiView(id, s.Receipt.Digest, u)
if a.UI != nil {
a.UI.Controls = contract.EntitledControls(m, s.HostOps)
@@ -256,7 +276,7 @@ func build(s Snapshot, admin bool) App {
a.Update = "update-pending"
}
if admin {
- a.Admin = &AdminView{CanStart: !s.Enabled, CanStop: s.Enabled, ManagementURL: "/app/addons?manage=" + url.QueryEscape(id)}
+ a.Admin = &AdminView{CanStart: !s.Enabled && !s.Unavailable, HostOperations: s.HostOperations, CanStop: s.Enabled, ManagementURL: "/app/addons?manage=" + url.QueryEscape(id)}
}
return a
}
@@ -293,6 +313,42 @@ func uiView(id, digest string, u *contract.UI) *UIView {
}
v := &UIView{Entry: asset(u.Entry), SDK: u.SDK, Pages: slices.Clone(u.Pages), Navigation: slices.Clone(u.Navigation), ChatActions: u.ChatActionIDs()}
v.Actions, v.Slots, v.Renderers = contributions(u)
+ if contract.ValidateModelActions(u) == nil {
+ v.ModelActions = slices.Clone(u.ModelActions)
+ }
+ for _, command := range u.HomeCommands {
+ page := command.Page
+ if page == "" {
+ page = u.MainPage
+ }
+ if page == "" || !slices.Contains(u.Pages, page) {
+ continue
+ }
+ c := HomeCommandView{ID: command.ID, Alias: command.Alias, Page: page, Label: id, Icon: "puzzle"}
+ if u.Title != nil {
+ c.Label, c.Labels = u.Title.Default(), maps.Clone(u.Title.ByLanguage)
+ }
+ if page != u.MainPage {
+ c.Label += " — " + page
+ for language, label := range c.Labels {
+ c.Labels[language] = label + " — " + page
+ }
+ }
+ if command.Label != nil {
+ c.Label, c.Labels = command.Label.Default(), maps.Clone(command.Label.ByLanguage)
+ }
+ if u.Icon != "" {
+ c.Icon = asset(u.Icon)
+ }
+ if command.Icon != "" {
+ if command.Icon == u.Icon {
+ c.Icon = asset(u.Icon)
+ } else {
+ c.Icon = command.Icon
+ }
+ }
+ v.HomeCommands = append(v.HomeCommands, c)
+ }
for _, css := range u.CSS {
v.CSS = append(v.CSS, asset(css))
}
diff --git a/core/services/addons/apps/extensions_test.go b/core/services/addons/apps/extensions_test.go
new file mode 100644
index 000000000..63011aa22
--- /dev/null
+++ b/core/services/addons/apps/extensions_test.go
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: MIT
+package apps
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("typed home metadata", func() {
+ It("projects home commands with resolved declared pages display metadata immutable icons and generation", func() {
+ u := page("home")
+ u.HomeCommands = []contract.HomeCommand{{ID: "open", Alias: "notes"}, {ID: "history", Page: "other"}, {ID: "custom", Page: "other", Label: &contract.Localized{ByLanguage: map[string]string{"en": "History", "it": "Cronologia"}}, Icon: "clock"}, {ID: "packaged", Icon: u.Icon}}
+ a := Build([]Snapshot{homeSnapshot(u)}, false)[0]
+ Expect(a.Generation).To(Equal(uint64(3)))
+ b, e := json.Marshal(a.UI)
+ Expect(e).NotTo(HaveOccurred())
+ var wire struct {
+ HomeCommands []struct {
+ ID, Alias, Page, Label, Icon string
+ Labels map[string]string
+ } `json:"homeCommands"`
+ }
+ Expect(json.Unmarshal(b, &wire)).To(Succeed())
+ Expect(wire.HomeCommands).To(HaveLen(4))
+ commands := wire.HomeCommands
+ Expect(commands[0].ID).To(Equal("open"))
+ Expect(commands[0].Alias).To(Equal("notes"))
+ Expect(commands[0].Page).To(Equal("home"))
+ Expect(commands[0].Label).To(Equal("Notes"))
+ Expect(commands[0].Labels).To(Equal(u.Title.ByLanguage))
+ Expect(commands[0].Icon).To(Equal("/api/addons/notes/assets/" + strings.Repeat("a", 64) + "/ui/icon.png"))
+ Expect(commands[1].Page).To(Equal("other"))
+ Expect(commands[1].Label).To(Equal("Notes — other"))
+ Expect(commands[2].Label).To(Equal("History"))
+ Expect(commands[2].Labels["it"]).To(Equal("Cronologia"))
+ Expect(commands[2].Icon).To(Equal("clock"))
+ Expect(commands[3].Icon).To(Equal(commands[0].Icon))
+ Expect(string(b)).NotTo(ContainSubstring(secretDir))
+ })
+ It("keeps disabled home apps installed without UI and never invents a destination", func() {
+ u := page("home")
+ u.HomeCommands = []contract.HomeCommand{{ID: "open"}}
+ s := snapshot("notes", false, "running", u)
+ Expect(BuildInstalled([]Snapshot{s})).To(HaveLen(1))
+ a := Build([]Snapshot{s}, true)[0]
+ Expect(a.UI).To(BeNil())
+ Expect(a.Generation).To(Equal(uint64(3)))
+ s.Receipt.Digest = "sha256:" + strings.Repeat("a", 64)
+ s.Enabled = true
+ u.MainPage = "missing"
+ u.HomeCommands = append(u.HomeCommands, contract.HomeCommand{ID: "bad", Page: "missing"})
+ b, e := json.Marshal(Build([]Snapshot{s}, false)[0].UI)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(string(b)).NotTo(ContainSubstring("homeCommands"))
+ u.MainPage = "home"
+ u.Icon = ""
+ u.Title = nil
+ u.HomeCommands = []contract.HomeCommand{{ID: "open"}}
+ b, e = json.Marshal(Build([]Snapshot{s}, false)[0].UI)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(string(b)).To(ContainSubstring(`"label":"notes"`))
+ Expect(string(b)).To(ContainSubstring(`"icon":"puzzle"`))
+ })
+})
+
+func homeSnapshot(u *contract.UI) Snapshot {
+ s := snapshot("notes", true, "running", u)
+ s.Receipt.Digest = "sha256:" + strings.Repeat("a", 64)
+ return s
+}
+
+var _ = Describe("generic Home contributions", func() {
+ It("projects two non-Studio addon pages without identity dispatch", func() {
+ for _, id := range []string{"paint", "notebook"} {
+ u := page("canvas")
+ u.Pages = []string{"canvas", "other"}
+ u.HomeCommands = []contract.HomeCommand{{ID: "open"}, {ID: "past", Page: "other"}}
+ s := snapshot(id, true, "running", u)
+ s.Receipt.Digest = "sha256:" + strings.Repeat("a", 64)
+ a := Build([]Snapshot{s}, false)[0]
+ Expect(a.ID).To(Equal(id))
+ Expect(a.UI.HomeCommands).To(HaveLen(2))
+ Expect(a.UI.HomeCommands[0].Page).To(Equal("canvas"))
+ Expect(a.UI.HomeCommands[1].Page).To(Equal("other"))
+ }
+ })
+})
diff --git a/core/services/addons/apps/installed.go b/core/services/addons/apps/installed.go
index 6ce8d61e1..fdf2ea40e 100644
--- a/core/services/addons/apps/installed.go
+++ b/core/services/addons/apps/installed.go
@@ -2,6 +2,7 @@
package apps
import (
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
"sort"
"github.com/mudler/LocalAI/pkg/addons/contract"
@@ -12,9 +13,10 @@ import (
// process observation. It never carries a package location, a source address,
// a data path or a credential.
type Installed struct {
- ID string `json:"id" example:"notes"`
- Title string `json:"title" example:"Notes"`
- Description string `json:"description,omitempty"`
+ HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"`
+ ID string `json:"id" example:"notes"`
+ Title string `json:"title" example:"Notes"`
+ Description string `json:"description,omitempty"`
// Icon is the same-origin icon URL of the installed generation.
Icon string `json:"icon,omitempty"`
Version string `json:"version" example:"1.2.3"`
@@ -117,7 +119,7 @@ func BuildInstalled(snaps []Snapshot) []Installed {
a := build(s, true)
m, r := s.Receipt.Manifest, s.Receipt
out = append(out, Installed{
- ID: s.AddonID, Title: a.Title, Description: a.Description, Icon: a.Icon, Version: m.Version, InstalledAt: s.InstalledAt, Generation: s.Generation,
+ ID: s.AddonID, HostOperations: s.HostOperations, Title: a.Title, Description: a.Description, Icon: a.Icon, Version: m.Version, InstalledAt: s.InstalledAt, Generation: s.Generation,
Enabled: s.Enabled, State: a.State, SourceID: r.SourceID, Channel: r.Channel, Selection: r.Selection, Verification: r.Verification, Digest: r.Digest,
Platform: Platform{OS: m.OS, Arch: m.Arch},
Compatibility: Compatibility{Host: m.Host, API: m.API, Protocol: m.Protocol, DataFormat: m.DataFormat},
diff --git a/core/services/addons/apps/modelactions_test.go b/core/services/addons/apps/modelactions_test.go
new file mode 100644
index 000000000..b6af9812a
--- /dev/null
+++ b/core/services/addons/apps/modelactions_test.go
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: MIT
+package apps
+
+import (
+ "encoding/json"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("installed model actions", func() {
+ It("projects typed model actions without sharing manifest or view slices", func() {
+ u := page("home")
+ u.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}}
+ s := homeSnapshot(u)
+ a := Build([]Snapshot{s}, false)[0]
+ Expect(a.UI.ModelActions).To(Equal(u.ModelActions))
+ b, err := json.Marshal(a)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(b)).To(ContainSubstring(`"modelActions":[{"capability":"FLAG_IMAGE","page":"home","path":"images"}]`))
+ u.ModelActions[0].Path = "changed"
+ Expect(a.UI.ModelActions[0].Path).To(Equal("images"))
+ a.UI.ModelActions[0].Page = "other"
+ Expect(u.ModelActions[0].Page).To(Equal("home"))
+ })
+ It("keeps unavailable installed records and omits unavailable or absent model metadata", func() {
+ u := page("home")
+ u.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home"}}
+ s := homeSnapshot(u)
+ s.Enabled = false
+ Expect(BuildInstalled([]Snapshot{s})).To(HaveLen(1))
+ Expect(Build([]Snapshot{s}, true)[0].UI).To(BeNil())
+ s.Enabled = true
+ u.ModelActions = nil
+ b, err := json.Marshal(Build([]Snapshot{s}, false)[0])
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(b)).NotTo(ContainSubstring("modelActions"))
+ u.ModelActions = []contract.ModelAction{{Capability: "FLAG_CHAT", Page: "home"}}
+ Expect(Build([]Snapshot{s}, false)[0].UI.ModelActions).To(BeEmpty())
+ })
+})
diff --git a/core/services/addons/extensions_lifecycle_test.go b/core/services/addons/extensions_lifecycle_test.go
new file mode 100644
index 000000000..e238b7af5
--- /dev/null
+++ b/core/services/addons/extensions_lifecycle_test.go
@@ -0,0 +1,277 @@
+//go:build linux
+
+// SPDX-License-Identifier: MIT
+package addons
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/addons/catalog"
+ "github.com/mudler/LocalAI/core/services/addons/packages"
+ addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
+ "github.com/mudler/LocalAI/core/services/addons/updates"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func lifecycleCapabilities(label string) contract.Capabilities {
+ return contract.Capabilities{Service: true, Actions: true, Tools: []string{"run", "health"}, ToolDefinitions: []contract.ToolDefinition{
+ {Name: "run", Description: "Run", Kind: "action", Shadows: "bash", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)},
+ {Name: "health", Description: "Health", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)},
+ }, UI: &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open", Alias: label}}, ModelActions: []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: label}}}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, HostOperations: &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}}
+}
+
+var _ = Describe("typed staged Manager lifecycle", func() {
+ It("persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback", func() {
+ r := newUpdateRig(true)
+ c := r.m.catalog
+ installer := r.m.installer
+ installer.Host.SDK = 2
+ binary := filepath.Join(GinkgoT().TempDir(), "fixture")
+ out, e := exec.Command("go", "build", "-o", binary, "../../../tests/addons/fixture/cmd").CombinedOutput()
+ Expect(e).NotTo(HaveOccurred(), string(out))
+ executable, e := os.ReadFile(binary)
+ Expect(e).NotTo(HaveOccurred())
+ caps := lifecycleCapabilities("first")
+ mode := "normal"
+ unsupported := false
+ r.lib.transform = func(m map[string]any) map[string][]byte {
+ m["capabilities"] = caps
+ if unsupported {
+ m["futureExtension"] = true
+ }
+ config, _ := json.Marshal(map[string]string{"mode": mode})
+ return map[string][]byte{"bin/sample": executable, "ui.js": []byte("export {}"), "fixture.json": config}
+ }
+ attach := func() {
+ s, e := addonruntime.NewSupervisor(addonruntime.Config{StateRoot: filepath.Join(r.cfg.Root, "runtime"), Limits: contract.DefaultLimits(), ReadyTimeout: time.Second, StableHealth: time.Second})
+ Expect(e).NotTo(HaveOccurred())
+ Expect(r.m.AttachRuntime(ctx, s)).To(Succeed())
+ }
+ check := func(number uint64, digest string, want contract.Capabilities) addonruntime.Instance {
+ GinkgoHelper()
+ st, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(st.Installed).To(BeTrue())
+ Expect(st.DesiredEnabled).To(BeTrue())
+ Expect(st.Generation).To(Equal(number))
+ var chosen packages.PackageReceipt
+ for _, g := range st.Generations {
+ if g.Number == number {
+ chosen = g.Receipt
+ }
+ }
+ Expect(chosen.Digest).To(Equal(digest))
+ Expect(chosen.Manifest.Capabilities).To(Equal(want))
+ declared, e := r.m.DeclaredCapabilities(ctx, "sample", number)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(declared.ExecutionProvider).To(Equal(want.ExecutionProvider))
+ Expect(declared.HostOperations).To(Equal(want.HostOperations))
+ declared.ExecutionProvider.Tools[0] = "mutated"
+ declared.HostOperations.Required[0] = "mutated"
+ declared.HostOperations.Optional[0] = "mutated"
+ again, e := r.m.DeclaredCapabilities(ctx, "sample", number)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(again.ExecutionProvider).To(Equal(want.ExecutionProvider))
+ Expect(again.HostOperations).To(Equal(want.HostOperations))
+ raw, e := os.ReadFile(filepath.Join(chosen.Directory, "receipt.json"))
+ Expect(e).NotTo(HaveOccurred())
+ var disk packages.PackageReceipt
+ Expect(json.Unmarshal(raw, &disk)).To(Succeed())
+ Expect(disk).To(Equal(chosen))
+ instance, e := r.m.Runtime(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(instance.Generation).To(Equal(number))
+ regs, e := r.m.runtime.Registry.Snapshot(ctx)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(regs).To(HaveLen(1))
+ Expect(regs[0].Generation).To(Equal(number))
+ Expect(regs[0].Incarnation).To(Equal(instance.Incarnation))
+ Expect(regs[0].Capabilities).To(Equal(want))
+ installedApps, e := r.m.Apps(ctx, actor)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(installedApps).To(HaveLen(1))
+ Expect(installedApps[0].UI).NotTo(BeNil())
+ Expect(installedApps[0].UI.ModelActions).To(Equal(want.UI.ModelActions))
+ installedApps[0].UI.ModelActions[0].Path = "mutated"
+ freshApps, e := r.m.Apps(ctx, actor)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(freshApps[0].UI.ModelActions).To(Equal(want.UI.ModelActions))
+ return instance
+ }
+ reopen := func() {
+ Expect(r.m.Close()).To(Succeed())
+ r.m = open(r.cfg, c, installer)
+ r.m.SetHostOps(map[string][]string{"sample": {"workspace."}})
+ r.m.EnableUpdates(updates.Config{Now: func() time.Time { return *r.clk }, MinRefresh: time.Second, TTL: time.Hour})
+ Expect(r.m.Reconcile(ctx)).To(Succeed())
+ attach()
+ }
+ first := r.lib.publish("1.2.3-dev", "development", "typed-first", catalog.Requirements{})
+ r.lib.refresh()
+ // Admission must reject a verified package before publication.
+ _, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "missing-required", AddonID: "sample", Action: "install", SourceID: "test", Version: "development"})
+ Expect(e).To(HaveOccurred())
+ missing, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(missing.Installed).To(BeFalse())
+ r.m.SetHostOps(map[string][]string{"sample": {"workspace."}})
+ r.installChannel()
+ _, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "enable-typed", AddonID: "sample", Action: "enable"})
+ Expect(e).NotTo(HaveOccurred())
+ attach()
+ old := check(1, first.Digest, caps)
+ check(1, first.Digest, caps)
+ adminApps, e := r.m.Apps(ctx, actor)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(adminApps).To(HaveLen(1))
+ Expect(adminApps[0].Admin.HostOperations.MissingOptional).To(Equal([]string{"egress.check"}))
+ Expect(adminApps[0].State).To(Equal("running"))
+ // A later SetHostOps is not a hot reload or a self grant.
+ r.m.SetHostOps(map[string][]string{"sample": {"workspace.", "egress."}})
+ Expect(r.m.hostOps["sample"]).To(Equal([]string{"workspace."}))
+
+ reopen()
+ recovered := check(1, first.Digest, caps)
+ Expect(recovered.Incarnation).NotTo(Equal(old.Incarnation))
+ // A stricter candidate must not stop the active incarnation.
+ beforeRefusal, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ caps = lifecycleCapabilities("unmet")
+ caps.HostOperations.Required = append(caps.HostOperations.Required, "egress.audit")
+ unmet := r.lib.publish("1.2.3-dev", "development", "typed-unmet", catalog.Requirements{})
+ r.lib.refresh()
+ _, e = r.m.Submit(ctx, actor, upgrade("unmet-manual", first.Digest, unmet.Digest))
+ Expect(e).To(HaveOccurred())
+ r.tick(2 * time.Second)
+ denied := r.m.AutoUpgrade(ctx)
+ Expect(denied).To(HaveLen(1))
+ Expect(denied[0].Err).To(HaveOccurred())
+ r.tick(2 * time.Second)
+ skipped := r.m.AutoUpgrade(ctx)
+ Expect(skipped).To(HaveLen(1))
+ Expect(skipped[0].SkippedAsFailure).To(BeTrue())
+ afterRefusal, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(afterRefusal).To(Equal(beforeRefusal))
+ Expect(check(1, first.Digest, lifecycleCapabilities("first")).Incarnation).To(Equal(recovered.Incarnation))
+ caps = lifecycleCapabilities("manual")
+ manual := r.lib.publish("1.2.3-dev", "development", "typed-manual", catalog.Requirements{})
+ r.lib.refresh()
+ _, e = r.m.Submit(ctx, actor, upgrade("typed-manual", first.Digest, manual.Digest))
+ Expect(e).NotTo(HaveOccurred())
+ check(2, manual.Digest, caps)
+ caps = lifecycleCapabilities("automatic")
+ automatic := r.lib.publish("1.2.3-dev", "development", "typed-auto", catalog.Requirements{})
+ r.tick(2 * time.Second)
+ results := r.m.AutoUpgrade(ctx)
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Err).NotTo(HaveOccurred())
+ Expect(results[0].Upgraded).To(BeTrue())
+ prior := check(3, automatic.Digest, caps)
+ priorCaps := caps
+ // The verified candidate stages successfully, but its actual subprocess
+ // fails registration. Manager must start the prior receipt again.
+ caps = lifecycleCapabilities("broken")
+ mode = "exit-start"
+ broken := r.lib.publish("1.2.3-dev", "development", "typed-broken", catalog.Requirements{})
+ r.lib.refresh()
+ _, e = r.m.Submit(ctx, actor, upgrade("typed-rollback", automatic.Digest, broken.Digest))
+ Expect(e).To(HaveOccurred())
+ restored := check(3, automatic.Digest, priorCaps)
+ Expect(restored.Incarnation).NotTo(Equal(prior.Incarnation))
+ recoveryHost := &managerHost{m: r.m}
+ q, qe := recoveryHost.Quiesce(ctx, "sample")
+ Expect(qe).NotTo(HaveOccurred())
+ var receipt packages.PackageReceipt
+ for _, g := range r.m.journal.Addons["sample"].Generations {
+ if g.Number == 3 {
+ receipt = g.Receipt
+ }
+ }
+ Expect(recoveryHost.StartCandidate(ctx, "sample", receipt, 3)).To(Succeed())
+ Expect(recoveryHost.StopCandidate(ctx, "sample")).To(Succeed())
+ Expect(recoveryHost.StopCandidate(ctx, "sample")).To(Succeed())
+ Expect(q.Resume(ctx)).To(Succeed())
+ check(3, automatic.Digest, priorCaps)
+ st, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(st.Generations).To(HaveLen(3))
+ reopen()
+ restarted := check(3, automatic.Digest, priorCaps)
+ Expect(restarted.Incarnation).NotTo(Equal(restored.Incarnation))
+ // Strict unsupported-manifest refusal must leave the active installed
+ // state and process intact, not just preserve old immutable files.
+ mode = "normal"
+ unsupported = true
+ refused := r.lib.publish("1.2.3-dev", "development", "typed-unsupported", catalog.Requirements{})
+ r.lib.refresh()
+ before, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ _, e = r.m.Submit(ctx, actor, upgrade("typed-refusal", automatic.Digest, refused.Digest))
+ Expect(e).To(HaveOccurred())
+ after, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(after).To(Equal(before))
+ unchanged := check(3, automatic.Digest, priorCaps)
+ Expect(unchanged.Incarnation).To(Equal(restarted.Incarnation))
+ reopen()
+ check(3, automatic.Digest, priorCaps)
+ // Restart with removed grants retains identity and enabled intent,
+ // but may not spawn a subprocess or admit tools/resources.
+ Expect(r.m.Close()).To(Succeed())
+ r.m = open(r.cfg, c, installer)
+ r.m.SetHostOps(map[string][]string{})
+ Expect(r.m.Reconcile(ctx)).To(Succeed())
+ attach()
+ unavailable, e := r.m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(unavailable.Installed).To(BeTrue())
+ Expect(unavailable.DesiredEnabled).To(BeTrue())
+ Expect(unavailable.Generation).To(Equal(uint64(3)))
+ Expect(unavailable.StartEligible).To(BeFalse())
+ _, e = r.m.Runtime(ctx, "sample")
+ Expect(e).To(HaveOccurred())
+ regs, e := r.m.runtime.Registry.Snapshot(ctx)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(regs).To(BeEmpty())
+ adminApps, e = r.m.Apps(ctx, actor)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(adminApps).To(HaveLen(1))
+ Expect(adminApps[0].State).To(Equal("failed"))
+ Expect(adminApps[0].Enabled).To(BeTrue())
+ Expect(adminApps[0].UI).To(BeNil())
+ Expect(adminApps[0].MainPage).To(BeEmpty())
+ Expect(adminApps[0].Admin.HostOperations.MissingRequired).To(Equal([]string{"workspace.materialize"}))
+ installed, e := r.m.Installed(ctx, actor)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(installed).To(HaveLen(1))
+ Expect(installed[0].State).To(Equal("failed"))
+ Expect(installed[0].HostOperations.MissingRequired).To(Equal([]string{"workspace.materialize"}))
+ Expect(installed[0].Digest).To(Equal(automatic.Digest))
+ readerApps, e := r.m.Apps(ctx, contract.Actor{UserID: "reader"})
+ Expect(e).NotTo(HaveOccurred())
+ Expect(readerApps).To(HaveLen(1))
+ Expect(readerApps[0].Admin).To(BeNil())
+
+ _, e = r.m.DeclaredTools(ctx, "sample", 3)
+ Expect(e).To(HaveOccurred())
+ _, e = r.m.DeclaredCapabilities(ctx, "sample", 3)
+ Expect(e).To(HaveOccurred())
+ Expect(r.m.Close()).To(Succeed())
+ r.m = open(r.cfg, c, installer)
+ r.m.SetHostOps(map[string][]string{"sample": {"workspace.", "egress."}})
+ Expect(r.m.Reconcile(ctx)).To(Succeed())
+ attach()
+ check(3, automatic.Digest, priorCaps)
+ adminApps, e = r.m.Apps(ctx, actor)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(adminApps[0].Admin.HostOperations.MissingRequired).To(BeEmpty())
+ Expect(adminApps[0].Admin.HostOperations.MissingOptional).To(BeEmpty())
+ })
+})
diff --git a/core/services/addons/gateway/admission_test.go b/core/services/addons/gateway/admission_test.go
new file mode 100644
index 000000000..8af6bdd9d
--- /dev/null
+++ b/core/services/addons/gateway/admission_test.go
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: MIT
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/mudler/LocalAI/core/services/addons"
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Host requirements carrier", func() {
+ It("preserves bounded owner admission refusals over mTLS without frontend grants", func() {
+ actor := contract.Actor{UserID: "admin", CredentialID: "key:admin", Mode: "named-user"}
+ raw := `{"schemaVersion":1,"id":"sample","version":"1.2.3","executable":"bin/sample","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"capabilities":{"service":true,"hostOperations":{"required":["workspace.materialize"]}}}`
+ m := ownerManagerPackage(actor, false, raw, nil, 1, map[string][]string{})
+ owner := &Gateway{Manager: m, Revalidate: func(context.Context, contract.Actor) error { return nil }}
+ _, cs := carrierFixture(func(c *Carrier) http.Handler {
+ h, e := c.OwnerHandler(owner)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ front := &Gateway{Forward: cs["front-a"], Revalidate: func(context.Context, contract.Actor) error { return nil }}
+ _, e := front.Dispatch(context.Background(), actor, contract.Request{Action: "install", AddonID: "sample", OperationID: "required-missing", Arguments: json.RawMessage(`{"sourceId":"test","version":"1.2.3"}`)})
+ Expect(e).To(HaveOccurred())
+ var refusal *addons.HostRequirementsRefusal
+ Expect(errors.As(e, &refusal)).To(BeTrue())
+ Expect(refusal.Summary.MissingRequired).To(Equal([]string{"workspace.materialize"}))
+ st, e := m.Status(context.Background(), "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(st.Installed).To(BeFalse())
+ rec := httptest.NewRecorder()
+ typedError(rec, &addons.HostRequirementsRefusal{Summary: hostops.RequestSummary{MissingRequired: []string{"workspace.materialize"}}})
+ Expect(rec.Code).To(Equal(http.StatusForbidden))
+ Expect(rec.Body.String()).To(ContainSubstring("workspace.materialize"))
+ Expect(rec.Body.String()).NotTo(ContainSubstring("directory"))
+ })
+})
diff --git a/core/services/addons/gateway/binding.go b/core/services/addons/gateway/binding.go
new file mode 100644
index 000000000..0e9486260
--- /dev/null
+++ b/core/services/addons/gateway/binding.go
@@ -0,0 +1,148 @@
+// SPDX-License-Identifier: MIT
+package gateway
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "regexp"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+// ExecutionBindingClaim names context, not authority. The owner must load its
+// actor-owned conversation and agent; frontend tool lists and grants are absent.
+type ExecutionBindingClaim struct {
+ ConversationID string `json:"conversationId"`
+}
+
+const executionBindingAction = "execution-binding-v1"
+
+type executionBindingCall struct {
+ Version int `json:"version"`
+ Claim ExecutionBindingClaim `json:"claim"`
+}
+type executionBindingReply struct {
+ Version int `json:"version"`
+ Binding ExecutionStatusBinding `json:"binding"`
+}
+
+func (c *ExecutionBindingClaim) UnmarshalJSON(raw []byte) error {
+ if observationObject(raw, "conversationId") != nil {
+ return ErrForbidden
+ }
+ type plain ExecutionBindingClaim
+ var v plain
+ if decode(bytes.NewReader(raw), &v) != nil || !validExecutionConversation(v.ConversationID) {
+ return ErrForbidden
+ }
+ *c = ExecutionBindingClaim(v)
+ return nil
+}
+func (c *executionBindingCall) UnmarshalJSON(raw []byte) error {
+ if observationObject(raw, "version", "claim") != nil {
+ return ErrForbidden
+ }
+ type plain executionBindingCall
+ var v plain
+ if decode(bytes.NewReader(raw), &v) != nil || v.Version != 1 || !validExecutionConversation(v.Claim.ConversationID) {
+ return ErrForbidden
+ }
+ *c = executionBindingCall(v)
+ return nil
+}
+
+// BindingForActor never treats the claim as a conversation record. Missing
+// authoritative context (including remote owner context) must fail closed.
+func (g *Gateway) BindingForActor(ctx context.Context, a contract.Actor, c ExecutionBindingClaim) (ExecutionStatusBinding, error) {
+ if ctx.Err() != nil {
+ return ExecutionStatusBinding{}, ErrUnavailable
+ }
+ if !validExecutionConversation(c.ConversationID) {
+ return ExecutionStatusBinding{}, ErrForbidden
+ }
+ if err := g.revalidate(ctx, a); err != nil {
+ return ExecutionStatusBinding{}, err
+ }
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if g.Forward != nil {
+ return g.Forward.executionBinding(ctx, a, c)
+ }
+ if g.ResolveExecutionBinding == nil {
+ return ExecutionStatusBinding{}, ErrForbidden
+ }
+ b, err := g.ResolveExecutionBinding(ctx, a, c)
+ if err != nil {
+ return ExecutionStatusBinding{}, ClassifyCheck(err)
+ }
+ if b.ConversationID != c.ConversationID || !(ExecutionStatusRequest{Version: 1, Binding: b, ObservationID: "binding"}).valid(a) {
+ return ExecutionStatusBinding{}, ErrForbidden
+ }
+ if ctx.Err() != nil {
+ return ExecutionStatusBinding{}, ErrUnavailable
+ }
+ if err = g.revalidate(ctx, a); err != nil {
+ return ExecutionStatusBinding{}, err
+ }
+ return b, nil
+}
+
+// ValidateExecutionBinding compares every component, never just the revision.
+// An application owner uses this same resolver before and after observation.
+func (g *Gateway) ValidateExecutionBinding(ctx context.Context, a contract.Actor, b ExecutionStatusBinding) error {
+ if b.Actor != a {
+ return ErrForbidden
+ }
+ now, err := g.BindingForActor(ctx, a, ExecutionBindingClaim{ConversationID: b.ConversationID})
+ if err != nil {
+ return err
+ }
+ if now != b {
+ return ErrForbidden
+ }
+ return nil
+}
+func (c *Carrier) executionBinding(ctx context.Context, a contract.Actor, claim ExecutionBindingClaim) (ExecutionStatusBinding, error) {
+ caps, err := c.Capabilities(ctx)
+ if err != nil || !caps.Supports(executionBindingAction) {
+ return ExecutionStatusBinding{}, ErrUnavailable
+ }
+ var id [24]byte
+ if _, err = rand.Read(id[:]); err != nil {
+ return ExecutionStatusBinding{}, ErrUnavailable
+ }
+ r, err := c.send(ctx, a, wireRequest{Request: contract.Request{Action: executionBindingAction, OperationID: "binding-" + hex.EncodeToString(id[:])}, ExecutionBinding: &executionBindingCall{Version: 1, Claim: claim}})
+ if err != nil {
+ return ExecutionStatusBinding{}, err
+ }
+ // Read-only cleanup: close errors cannot change the decoded result.
+ defer func() { _ = r.Body.Close() }()
+ var reply executionBindingReply
+ if decode(io.LimitReader(r.Body, 32768), &reply) != nil || reply.Version != 1 || reply.Binding.ConversationID != claim.ConversationID || !(ExecutionStatusRequest{Version: 1, Binding: reply.Binding, ObservationID: "binding"}).valid(a) || ctx.Err() != nil {
+ return ExecutionStatusBinding{}, ErrUnavailable
+ }
+ return reply.Binding, nil
+}
+
+// Replies refuse aliases and duplicate keys, including nested identities.
+func (r *executionBindingReply) UnmarshalJSON(raw []byte) error {
+ if observationObject(raw, "version", "binding") != nil {
+ return ErrForbidden
+ }
+ type plain executionBindingReply
+ var v plain
+ if json.Unmarshal(raw, &v) != nil {
+ return ErrForbidden
+ }
+ *r = executionBindingReply(v)
+ return nil
+}
+
+var executionConversationPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
+
+func validExecutionConversation(id string) bool { return executionConversationPattern.MatchString(id) }
diff --git a/core/services/addons/gateway/binding_test.go b/core/services/addons/gateway/binding_test.go
new file mode 100644
index 000000000..cfa6397a1
--- /dev/null
+++ b/core/services/addons/gateway/binding_test.go
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: MIT
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "net/http"
+ "strings"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+var _ = Describe("owner binding validation", func() {
+ It("validates current selection and authenticated actor", func() {
+ ctx := context.Background()
+ claim := ExecutionBindingClaim{ConversationID: "chat"}
+ b := ExecutionStatusBinding{Actor: frontActor, ConversationID: "chat", AddonID: "renamed", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "owner:1:2:3:4"}
+ g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }}
+ _, err := g.BindingForActor(ctx, frontActor, claim)
+ Expect(err).To(HaveOccurred())
+ calls := 0
+ g.ResolveExecutionBinding = func(_ context.Context, actor contract.Actor, got ExecutionBindingClaim) (ExecutionStatusBinding, error) {
+ calls++
+ if actor != frontActor || got != claim {
+ return ExecutionStatusBinding{}, ErrForbidden
+ }
+ return b, nil
+ }
+ got, err := g.BindingForActor(ctx, frontActor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(Equal(b))
+ Expect(g.ValidateExecutionBinding(ctx, frontActor, b)).To(Succeed())
+ stale := b
+ stale.CurrentSelection = "old"
+ Expect(g.ValidateExecutionBinding(ctx, frontActor, stale)).NotTo(Succeed())
+ forged := b
+ forged.Actor.UserID = "other"
+ before := calls
+ Expect(g.ValidateExecutionBinding(ctx, frontActor, forged)).NotTo(Succeed())
+ Expect(calls).To(Equal(before))
+ b.CurrentSelection = "current-selection" // The resolver reports a different current selection.
+ Expect(g.ValidateExecutionBinding(ctx, frontActor, got)).NotTo(Succeed())
+ })
+})
+var _ = Describe("binding claim strictness", func() {
+ It("rejects aliases, duplicate fields and null claims", func() {
+ for _, raw := range []string{`{"ConversationID":"chat"}`, `{"conversationId":"chat","actor":{}}`, `{"conversationId":"chat","conversationId":"other"}`, `null`} {
+ var c ExecutionBindingClaim
+ Expect(json.Unmarshal([]byte(raw), &c)).NotTo(Succeed(), raw)
+ }
+ })
+})
+
+var _ = Describe("owner binding carrier", func() {
+ It("resolves only the authenticated claim over actual mTLS", func() {
+ b := ExecutionStatusBinding{Actor: frontActor, ConversationID: "chat", AddonID: "renamed", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "owner:1:2:3:4"}
+ calls := 0
+ g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }, ResolveExecutionBinding: func(_ context.Context, a contract.Actor, c ExecutionBindingClaim) (ExecutionStatusBinding, error) {
+ calls++
+ if a != frontActor || c.ConversationID != "chat" {
+ return ExecutionStatusBinding{}, ErrForbidden
+ }
+ return b, nil
+ }}
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ front := &Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate}
+ got, e := front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"})
+ Expect(e).NotTo(HaveOccurred())
+ Expect(got).To(Equal(b))
+ Expect(calls).To(Equal(1))
+ Expect(front.ValidateExecutionBinding(context.Background(), frontActor, b)).To(Succeed())
+ stale := b
+ stale.Incarnation = "old"
+ Expect(front.ValidateExecutionBinding(context.Background(), frontActor, stale)).NotTo(Succeed())
+ _, e = front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "foreign"})
+ Expect(e).To(HaveOccurred())
+ before := calls
+ for _, raw := range []string{
+ `{"version":1,"claim":{"conversationId":"chat","actor":{}}}`,
+ `{"version":1,"claim":{"ConversationId":"chat"}}`,
+ `{"version":1,"claim":{"conversationId":"chat","conversationId":"foreign"}}`,
+ `{"version":2,"claim":{"conversationId":"chat"}}`,
+ } {
+ body := `{"request":{"action":"execution-binding-v1","operationId":"bad"},"executionBinding":` + raw + `}`
+ var wire wireRequest
+ Expect(decode(strings.NewReader(body), &wire)).NotTo(Succeed())
+ }
+ // A mixed door is signed and authenticated, but still has no authority.
+ resp, e := cs["front-a"].send(context.Background(), frontActor, wireRequest{Request: contract.Request{Action: executionBindingAction, OperationID: "mixed", AddonID: "forged"}, ExecutionBinding: &executionBindingCall{Version: 1, Claim: ExecutionBindingClaim{ConversationID: "chat"}}})
+ if resp != nil {
+ Expect(resp.Body.Close()).To(Succeed())
+ }
+ Expect(e).To(HaveOccurred())
+ Expect(calls).To(Equal(before))
+ g.ResolveExecutionBinding = nil
+ _, e = front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"})
+ Expect(e).To(HaveOccurred())
+ })
+ It("fails closed on unsupported owners and mismatched binding replies", func() {
+ b := ExecutionStatusBinding{Actor: frontActor, ConversationID: "other-chat", AddonID: "renamed", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "rev"}
+ for _, supported := range []bool{false, true} {
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }}
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !supported && r.URL.Path == "/addons/capabilities" {
+ Expect(json.NewEncoder(w).Encode(Capabilities{Protocol: 2})).To(Succeed())
+ return
+ }
+ if r.URL.Path == "/addons/forward" {
+ Expect(json.NewEncoder(w).Encode(executionBindingReply{Version: 1, Binding: b})).To(Succeed())
+ return
+ }
+ h.ServeHTTP(w, r)
+ })
+ })
+ front := &Gateway{Forward: cs["front-a"], Revalidate: func(context.Context, contract.Actor) error { return nil }}
+ got, e := front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"})
+ Expect(e).To(HaveOccurred())
+ Expect(got).To(Equal(ExecutionStatusBinding{}))
+ }
+ })
+})
+
+var _ = Describe("binding DTO strictness", func() {
+ It("accepts canonical bindings and rejects aliases and duplicates", func() {
+ rawBinding := `{"actor":{"userId":"u","credentialId":"c","mode":"named-user"},"addonId":"renamed","generation":1,"incarnation":"inc","executionTool":"run","statusTool":"health","conversationId":"chat","currentSelection":"current"}`
+ var b ExecutionStatusBinding
+ Expect(json.Unmarshal([]byte(rawBinding), &b)).To(Succeed())
+ Expect(b.ConversationID).To(Equal("chat"))
+ for _, raw := range []string{
+ strings.Replace(rawBinding, `"addonId"`, `"AddonId"`, 1),
+ strings.Replace(rawBinding, `"userId"`, `"UserId"`, 1),
+ strings.Replace(rawBinding, `"addonId":"renamed"`, `"addonId":"renamed","addonId":"other"`, 1),
+ strings.Replace(rawBinding, `"actor":`, `"conversationId":"chat","ConversationID":"foreign","actor":`, 1),
+ } {
+ Expect(json.Unmarshal([]byte(raw), &b)).NotTo(Succeed(), raw)
+ }
+ })
+})
+
+var _ = Describe("generic execution provider identity", func() {
+ It("passes through owner-resolved bindings with distinct provider and status names", func() {
+ for _, pair := range [][2]string{{"runner-one", "inspect"}, {"runner-two", "readiness"}} {
+ want := ExecutionStatusBinding{Actor: frontActor, ConversationID: "chat", AddonID: pair[0], Generation: 2, Incarnation: "live", ExecutionTool: "execute", StatusTool: pair[1], CurrentSelection: "current-inputs"}
+ g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }, ResolveExecutionBinding: func(context.Context, contract.Actor, ExecutionBindingClaim) (ExecutionStatusBinding, error) {
+ return want, nil
+ }}
+ got, err := g.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(Equal(want))
+ }
+ })
+})
diff --git a/core/services/addons/gateway/carrier.go b/core/services/addons/gateway/carrier.go
index a7855545f..ce76a50ce 100644
--- a/core/services/addons/gateway/carrier.go
+++ b/core/services/addons/gateway/carrier.go
@@ -128,8 +128,10 @@ func (c *Carrier) peer(r *http.Request) (PeerIdentity, error) {
}
type wireRequest struct {
- Request contract.Request `json:"request"`
- Relay *RelayRequest `json:"relay,omitempty"`
+ ExecutionBinding *executionBindingCall `json:"executionBinding,omitempty"`
+ Observation *ExecutionStatusRequest `json:"observation,omitempty"`
+ Request contract.Request `json:"request"`
+ Relay *RelayRequest `json:"relay,omitempty"`
// Proposal marks a call for the owner's proposal broker. Such a call is
// never a typed dispatch.
Proposal *ProposalCall `json:"proposal,omitempty"`
@@ -270,6 +272,47 @@ func (c *Carrier) OwnerHandler(g *Gateway) (http.Handler, error) {
http.Error(w, "invalid binding", 400)
return
}
+ if wire.ExecutionBinding != nil {
+ // New binding messages use exact canonical keys, including the
+ // routing envelope. Older ordinary messages keep their decoder.
+ var fields map[string]json.RawMessage
+ _ = json.Unmarshal(m.Body, &fields)
+ if observationObject(m.Body, "request", "executionBinding") != nil || observationObject(fields["request"], "action", "operationId", "addonId", "resourceId", "generation", "arguments") != nil {
+ http.Error(w, "invalid execution binding keys", 400)
+ return
+ }
+ if wire.Observation != nil || wire.Library != nil || wire.Resource != nil || wire.Apps != nil || wire.Artifact != nil || wire.Proposal != nil || wire.Relay != nil || wire.Request.Action != executionBindingAction || wire.Request.AddonID != "" || wire.Request.ResourceID != "" || wire.Request.Generation != 0 || (len(wire.Request.Arguments) != 0 && string(wire.Request.Arguments) != "null") {
+ http.Error(w, "invalid execution binding", 400)
+ return
+ }
+ out, err := g.BindingForActor(r.Context(), actor, wire.ExecutionBinding.Claim)
+ if err != nil {
+ typedError(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(executionBindingReply{Version: 1, Binding: out})
+ return
+ }
+ // Reserved actions cannot fall through to ordinary tool dispatch.
+ if wire.Request.Action == executionBindingAction {
+ http.Error(w, "missing execution binding", 400)
+ return
+ }
+ if wire.Observation != nil {
+ if wire.Library != nil || wire.Resource != nil || wire.Apps != nil || wire.Artifact != nil || wire.Proposal != nil || wire.Relay != nil || wire.Request.Action != executionStatusAction || wire.Request.AddonID != "" || wire.Request.ResourceID != "" || wire.Request.Generation != 0 || (len(wire.Request.Arguments) != 0 && string(wire.Request.Arguments) != "null") {
+ http.Error(w, "invalid observation binding", 400)
+ return
+ }
+ out, err := g.ExecutionStatusRead(r.Context(), actor, *wire.Observation)
+ if err != nil {
+ typedError(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(out)
+ return
+ }
if wire.Library != nil {
call := *wire.Library
// The owner classifies by the call, not by the action the peer wrote.
@@ -476,6 +519,13 @@ func typedError(w http.ResponseWriter, e error) {
_ = json.NewEncoder(w).Encode(resourceWire{Type: wireInvalid})
return
}
+ var requirements *addons.HostRequirementsRefusal
+ if errors.As(e, &requirements) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusForbidden)
+ _ = json.NewEncoder(w).Encode(sdkWire{Type: "host_requirements", HostOperations: &requirements.Summary})
+ return
+ }
var refusal *contract.SDKRefusal
if errors.As(e, &refusal) {
// The administrator is told why the add-on cannot be installed. Only
@@ -545,10 +595,11 @@ func IsOperationCause(code string) bool {
const wireSDK = "sdk_incompatible"
type sdkWire struct {
- Type string `json:"type"`
- Min uint64 `json:"min"`
- Max uint64 `json:"max"`
- Host uint64 `json:"host"`
+ HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"`
+ Type string `json:"type"`
+ Min uint64 `json:"min"`
+ Max uint64 `json:"max"`
+ Host uint64 `json:"host"`
}
// SDKRefusalError is the error of a failed operation whose cause is an SDK
@@ -699,8 +750,13 @@ func (c *Carrier) send(ctx context.Context, a contract.Actor, wire wireRequest)
return nil, resourceReplyError(resp.Body, resp.StatusCode, wire.Request.OperationID)
}
var b sdkWire
- if decode(io.LimitReader(resp.Body, 4096), &b) == nil {
+ if decode(io.LimitReader(resp.Body, 32768), &b) == nil {
switch b.Type {
+ case "host_requirements":
+ if b.HostOperations != nil {
+ return nil, fmt.Errorf("%w: %w", addons.ErrOperationFailed, &addons.HostRequirementsRefusal{Summary: *b.HostOperations})
+ }
+ return nil, ErrForbidden
case wireSDK:
return nil, SDKRefusalError(&contract.SDKRefusal{Min: b.Min, Max: b.Max, Host: b.Host})
case wirePackage:
@@ -727,7 +783,7 @@ func (c *Carrier) send(ctx context.Context, a contract.Actor, wire wireRequest)
// The owner's typed refusal is a decision, not an outage: nothing
// was applied and the same call is refused again.
var b resourceWire
- if decode(io.LimitReader(resp.Body, 4096), &b) == nil && b.Type == wireInvalid {
+ if decode(io.LimitReader(resp.Body, 32768), &b) == nil && b.Type == wireInvalid {
return nil, ErrInvalidRequest
}
}
@@ -766,7 +822,7 @@ var resourceRead = map[string]bool{"resource-list": true, "resource-grants": tru
// mutation fails closed: only the known read actions are exempt, so an action
// added later is reported as an unknown outcome until it is classified.
func mutation(action string) bool {
- return action != ActionHostSettingsRead && action != "read" && action != "operation-status" && action != "proposal-get" && action != "proposal-reconcile" && action != "proposal-list" && action != "artifact-status" && action != appsListAction && !readManagement[action] && !resourceRead[action] && !libraryRead[action]
+ return action != ActionHostSettingsRead && action != executionBindingAction && action != executionStatusAction && action != "read" && action != "operation-status" && action != "proposal-get" && action != "proposal-reconcile" && action != "proposal-list" && action != "artifact-status" && action != appsListAction && !readManagement[action] && !resourceRead[action] && !libraryRead[action]
}
// Act forwards an action call to the owner and waits for its answer, up to the
diff --git a/core/services/addons/gateway/extensions_test.go b/core/services/addons/gateway/extensions_test.go
new file mode 100644
index 000000000..a74b440db
--- /dev/null
+++ b/core/services/addons/gateway/extensions_test.go
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: MIT
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/services/addons"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("typed installed Apps carrier", func() {
+ It("round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs", func() {
+ ctx := context.Background()
+ admin := contract.Actor{UserID: "admin", CredentialID: "key:admin", Mode: "named-user"}
+ reader := contract.Actor{UserID: "reader", CredentialID: "key:reader", Mode: "named-user"}
+ var manifest contract.Manifest
+ Expect(json.Unmarshal([]byte(uiManifest), &manifest)).To(Succeed())
+ caps := contract.Capabilities{Service: true, Actions: true, Tools: []string{"run", "health"}, ToolDefinitions: []contract.ToolDefinition{
+ {Name: "run", Description: "Run", Kind: "action", Shadows: "bash", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)},
+ {Name: "health", Description: "Health", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)},
+ }, UI: &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home", "history"}, MainPage: "home", Title: &contract.Localized{ByLanguage: map[string]string{"en": "Notes", "it": "Note"}}, Icon: "icon.png", HomeCommands: []contract.HomeCommand{
+ {ID: "open", Alias: "notes"},
+ {ID: "history", Page: "history", Label: &contract.Localized{ByLanguage: map[string]string{"en": "History", "it": "Cronologia"}}, Icon: "clock"},
+ }}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, HostOperations: &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}}
+ manifest.Capabilities = caps
+ raw, e := json.Marshal(manifest)
+ Expect(e).NotTo(HaveOccurred())
+ m := ownerManagerPackage(admin, true, string(raw), map[string]string{"icon.png": "\x89PNG\r\n\x1a\n" + strings.Repeat("x", 100)}, 2, map[string][]string{"sample": {"workspace."}})
+ status, e := m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(status.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps))
+ // Status is the actual manifest-bearing DTO; its JSON copy must retain
+ // all declarations, including nested command labels, without aliases.
+ statusJSON, e := json.Marshal(status)
+ Expect(e).NotTo(HaveOccurred())
+ var decoded addons.Status
+ Expect(json.Unmarshal(statusJSON, &decoded)).To(Succeed())
+ Expect(decoded.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps))
+ copied := &status.Generations[0].Receipt.Manifest.Capabilities
+ copied.UI.HomeCommands[0].Alias = "mutated"
+ copied.UI.HomeCommands[1].Label.ByLanguage["it"] = "mutated"
+ copied.ExecutionProvider.Tools[0] = "mutated"
+ copied.HostOperations.Required[0] = "mutated"
+ copied.HostOperations.Optional[0] = "mutated"
+ status, e = m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(status.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps))
+
+ allow := func(context.Context, contract.Actor) error { return nil }
+ owner := &Gateway{Manager: m, Revalidate: allow}
+ _, cs := carrierFixture(func(c *Carrier) http.Handler {
+ h, e := c.OwnerHandler(owner)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ _, e = m.Submit(ctx, admin, addons.LifecycleCommand{OperationID: "typed-enable", AddonID: "sample", Action: "enable"})
+ Expect(e).NotTo(HaveOccurred())
+ for _, a := range []contract.Actor{admin, reader} {
+ local, e := owner.Apps(ctx, a)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(local.Apps).To(HaveLen(1))
+ for _, id := range []string{"front-a", "front-b"} {
+ front := &Gateway{Forward: cs[id], Revalidate: allow}
+ remote, e := front.Apps(ctx, a)
+ Expect(e).NotTo(HaveOccurred())
+ localJSON, err := json.Marshal(local)
+ Expect(err).NotTo(HaveOccurred())
+ remoteJSON, err := json.Marshal(remote)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(remoteJSON).To(MatchJSON(localJSON))
+ app := remote.Apps[0]
+ Expect(app.Generation).To(Equal(uint64(1)))
+ Expect(app.UI).NotTo(BeNil())
+ commands := app.UI.HomeCommands
+ Expect(commands).To(HaveLen(2))
+ Expect(commands[0].ID).To(Equal("open"))
+ Expect(commands[0].Alias).To(Equal("notes"))
+ Expect(commands[0].Page).To(Equal("home"))
+ Expect(commands[0].Labels).To(Equal(caps.UI.Title.ByLanguage))
+ Expect(commands[0].Label).To(Equal("Notes"))
+ Expect(commands[0].Icon).To(HavePrefix("/api/addons/sample/assets/"))
+ Expect(commands[0].Icon).To(HaveSuffix("/icon.png"))
+ digestPath := strings.TrimSuffix(strings.TrimPrefix(commands[0].Icon, "/api/addons/sample/assets/"), "/icon.png")
+ Expect(digestPath).To(MatchRegexp(`^[a-f0-9]{64}$`))
+ Expect(commands[1].Page).To(Equal("history"))
+ Expect(commands[1].Labels).To(Equal(caps.UI.HomeCommands[1].Label.ByLanguage))
+ Expect(commands[1].Icon).To(Equal("clock"))
+ Expect(app.Admin != nil).To(Equal(a == admin))
+ commands[0].Alias = "mutated"
+ commands[0].Labels["it"] = "mutated"
+ commands[1].Labels["it"] = "mutated"
+ fresh, e := owner.Apps(ctx, a)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(fresh.Apps[0].UI.HomeCommands).To(Equal(local.Apps[0].UI.HomeCommands))
+ b, e := json.Marshal(remote)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(string(b)).NotTo(ContainSubstring("directory"))
+ Expect(string(b)).NotTo(ContainSubstring("executionProvider"))
+ if a != admin {
+ Expect(string(b)).NotTo(ContainSubstring("hostOperations"))
+ } else {
+ Expect(app.Admin.HostOperations.MissingOptional).To(Equal([]string{"egress.check"}))
+ }
+ }
+ }
+ _, e = m.Submit(ctx, admin, addons.LifecycleCommand{OperationID: "typed-disable", AddonID: "sample", Action: "disable"})
+ Expect(e).NotTo(HaveOccurred())
+ for _, a := range []contract.Actor{admin, reader} {
+ local, e := owner.Apps(ctx, a)
+ Expect(e).NotTo(HaveOccurred())
+ remote, e := (&Gateway{Forward: cs["front-a"], Revalidate: allow}).Apps(ctx, a)
+ Expect(e).NotTo(HaveOccurred())
+ localJSON, err := json.Marshal(local)
+ Expect(err).NotTo(HaveOccurred())
+ remoteJSON, err := json.Marshal(remote)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(remoteJSON).To(MatchJSON(localJSON))
+ if a == admin {
+ Expect(remote.Apps).To(HaveLen(1))
+ Expect(remote.Apps[0].UI).To(BeNil())
+ Expect(remote.Apps[0].Enabled).To(BeFalse())
+ Expect(remote.Apps[0].Generation).To(Equal(uint64(1)))
+ } else {
+ Expect(remote.Apps).To(BeEmpty())
+ }
+ }
+ // Disabled means installed, not absent. Apps intentionally carries UI
+ // projection, not execution or host-operation authority.
+ installed, e := m.Installed(ctx, admin)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(installed).To(HaveLen(1))
+ status, e = m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(status.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps))
+ })
+})
diff --git a/core/services/addons/gateway/gateway.go b/core/services/addons/gateway/gateway.go
index 2b27c509d..27a6499c4 100644
--- a/core/services/addons/gateway/gateway.go
+++ b/core/services/addons/gateway/gateway.go
@@ -31,12 +31,18 @@ type Gateway struct {
HostSettings HostSettingsService
// HostSettingsAdmin checks current host authority, never add-on supplied flags.
HostSettingsAdmin func(context.Context, contract.Actor) error
- Limits RelayLimits
- Manager *addons.Manager
- Runtime CurrentRuntime
- Forward *Carrier
- Admission Admission
- Resolver ResourceResolver
+ // ResolveExecutionBinding loads actor-owned context on the owner. Nil denies.
+ ResolveExecutionBinding func(context.Context, contract.Actor, ExecutionBindingClaim) (ExecutionStatusBinding, error)
+ // AuthorizeExecutionStatus must revalidate current provider current selection,
+ // execution feature permission, access and builtin replacement authority on the owner.
+ // Nil denies all observations. Status is never selection authorization.
+ AuthorizeExecutionStatus func(context.Context, contract.Actor, ExecutionStatusBinding) error
+ Limits RelayLimits
+ Manager *addons.Manager
+ Runtime CurrentRuntime
+ Forward *Carrier
+ Admission Admission
+ Resolver ResourceResolver
// Tools reports the tools each installed add-on declares. When nil the
// Manager is used; with neither, a request that names a tool is denied.
Tools ToolSource
diff --git a/core/services/addons/gateway/library.go b/core/services/addons/gateway/library.go
index 5c0cea02d..442f42cfd 100644
--- a/core/services/addons/gateway/library.go
+++ b/core/services/addons/gateway/library.go
@@ -180,6 +180,8 @@ func (c Capabilities) Supports(call string) bool {
func (g *Gateway) capabilities() Capabilities {
c := Capabilities{Protocol: 2, OS: g.OS, Arch: g.Arch}
+ // Transport support is not permission to select or observe a provider.
+ c.Calls = append(c.Calls, executionStatusAction, executionBindingAction)
if c.OS == "" {
c.OS = runtime.GOOS
}
diff --git a/core/services/addons/gateway/observation.go b/core/services/addons/gateway/observation.go
new file mode 100644
index 000000000..23a7bd79d
--- /dev/null
+++ b/core/services/addons/gateway/observation.go
@@ -0,0 +1,286 @@
+// SPDX-License-Identifier: MIT
+package gateway
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "slices"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+// ExecutionStatusBinding contains opaque authenticated identities, not credentials.
+// Every component is a precondition; readiness never authorizes execution.
+type ExecutionStatusBinding struct {
+ ConversationID string `json:"conversationId,omitempty"`
+ Actor contract.Actor `json:"actor"`
+ AddonID string `json:"addonId"`
+ Generation uint64 `json:"generation"`
+ Incarnation string `json:"incarnation"`
+ ExecutionTool string `json:"executionTool"`
+ StatusTool string `json:"statusTool"`
+ // CurrentSelection is a digest of current selection inputs, not mutation history.
+ // It conveys no authority and contains no raw store or configuration data.
+ CurrentSelection string `json:"currentSelection"`
+}
+
+// Version belongs to this host-to-host DTO, not the addon protocol (still v1).
+type ExecutionStatusRequest struct {
+ Version int `json:"version"`
+ Binding ExecutionStatusBinding `json:"binding"`
+ ObservationID string `json:"observationId"`
+}
+type ExecutionStatusResponse struct {
+ Version int `json:"version"`
+ Binding ExecutionStatusBinding `json:"binding"`
+ ObservationID string `json:"observationId"`
+ Status contract.ExecutionStatus `json:"status"`
+}
+
+const executionStatusAction = "execution-status-read-v1"
+
+type observationRuntime interface {
+ AdmitObservation(uint64, string) (func(), error)
+ ReadObservation(context.Context, contract.Envelope, string) (contract.ExecutionStatus, error)
+}
+
+func observationUnknown(r ExecutionStatusRequest) ExecutionStatusResponse {
+ return ExecutionStatusResponse{Version: 1, Binding: r.Binding, ObservationID: r.ObservationID, Status: contract.ExecutionStatus{Readiness: "unknown", Isolation: "unknown"}}
+}
+func (r ExecutionStatusRequest) valid(a contract.Actor) bool {
+ b := r.Binding
+ return (b.ConversationID == "" || validExecutionConversation(b.ConversationID)) && r.Version == 1 && b.Actor == a && a.UserID != "" && a.CredentialID != "" && len(a.UserID) <= 256 && len(a.CredentialID) <= 256 && (a.Mode == "named-user" || a.Mode == "local-owner") && contract.ValidName(b.AddonID) && b.Generation != 0 && b.Incarnation != "" && len(b.Incarnation) <= 256 && contract.ValidName(b.ExecutionTool) && contract.ValidName(b.StatusTool) && b.CurrentSelection != "" && len(b.CurrentSelection) <= 256 && r.ObservationID != "" && len(r.ObservationID) <= 256
+}
+
+// ExecutionStatusRead is a host-only observation door. It cannot name arguments,
+// resources, operation IDs or grants. Ordinary model dispatch remains unchanged.
+func (g *Gateway) ExecutionStatusRead(ctx context.Context, a contract.Actor, r ExecutionStatusRequest) (ExecutionStatusResponse, error) {
+ out := observationUnknown(r)
+ if ctx.Err() != nil {
+ return out, ErrUnavailable
+ }
+ if !r.valid(a) {
+ return out, ErrForbidden
+ }
+ if e := g.revalidate(ctx, a); e != nil {
+ return out, e
+ }
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if g.Forward != nil {
+ return g.Forward.executionStatus(ctx, a, r)
+ }
+ return g.executionStatusLocal(ctx, a, r)
+}
+func (g *Gateway) executionStatusLocal(ctx context.Context, a contract.Actor, r ExecutionStatusRequest) (ExecutionStatusResponse, error) {
+ out := observationUnknown(r)
+ if !r.valid(a) || g.AuthorizeExecutionStatus == nil {
+ return out, ErrForbidden
+ }
+ aa, ok := g.Admission.(AddonAdmission)
+ if !ok {
+ return out, ErrForbidden
+ }
+ b := r.Binding
+ check := func(ctx context.Context) error {
+ if e := g.revalidate(ctx, a); e != nil {
+ return e
+ }
+ // Host selection/feature/builtin authority is independent of reported facts.
+ if e := ClassifyCheck(g.AuthorizeExecutionStatus(ctx, a, b)); e != nil {
+ return e
+ }
+ src, ok := g.toolSource().(CapabilitySource)
+ if !ok {
+ return ErrForbidden
+ }
+ caps, e := src.DeclaredCapabilities(ctx, b.AddonID, b.Generation)
+ if e != nil {
+ return unreachableOrDenied(e)
+ }
+ p := caps.ExecutionProvider
+ if p == nil || p.StatusTool != b.StatusTool || !slices.Contains(p.Tools, b.ExecutionTool) || !slices.Contains(caps.Tools, b.ExecutionTool) || !slices.Contains(caps.Tools, b.StatusTool) || slices.Contains(caps.AdminTools, b.StatusTool) {
+ return ErrForbidden
+ }
+ action, status := false, false
+ for _, d := range caps.ToolDefinitions {
+ if d.Name == b.ExecutionTool {
+ action = contract.IsExecutionAction(caps, d)
+ }
+ if d.Name == b.StatusTool {
+ status = contract.IsExecutionStatusTool(caps, d) && contract.ExecutionStatusAcceptsEmptyArguments(d)
+ }
+ }
+ if !action || !status {
+ return ErrForbidden
+ }
+ return nil
+ }
+ err := aa.AdmitAddon(ctx, a, b.AddonID, func(ctx context.Context) error {
+ if e := check(ctx); e != nil {
+ return e
+ }
+ instance, e := g.current(ctx, b.AddonID)
+ if e != nil {
+ return e
+ }
+ if instance.Generation != b.Generation || instance.Incarnation != b.Incarnation {
+ return ErrForbidden
+ }
+ client, ok := instance.Client.(observationRuntime)
+ if !ok {
+ return ErrUnavailable
+ }
+ // Identity and accepting state are tested together under the actual client's
+ // withdrawal lock. Never look up another client to dispatch after this point.
+ release, e := client.AdmitObservation(b.Generation, b.Incarnation)
+ if e != nil {
+ return e
+ }
+ defer release()
+ var raw [24]byte
+ if _, e = rand.Read(raw[:]); e != nil {
+ return ErrUnavailable
+ }
+ args, _ := json.Marshal(map[string]string{"tool": b.StatusTool})
+ env := contract.Envelope{Request: contract.Request{AddonID: b.AddonID, Generation: b.Generation, Action: "read", OperationID: "obs-" + hex.EncodeToString(raw[:]), Arguments: args}, Authority: contract.Authority{Actor: a, Scope: "read", ExpiresAt: time.Now().Add(5 * time.Second)}}
+ value, e := client.ReadObservation(ctx, env, r.ObservationID)
+ if e != nil {
+ return e
+ }
+ if e = ctx.Err(); e != nil {
+ return e
+ }
+ if e = check(ctx); e != nil {
+ return e
+ }
+ now, e := g.current(ctx, b.AddonID)
+ if e != nil {
+ return e
+ }
+ if now.Generation != b.Generation || now.Incarnation != b.Incarnation || now.Client != instance.Client {
+ return ErrUnavailable
+ }
+ done, e := client.AdmitObservation(b.Generation, b.Incarnation)
+ if e != nil {
+ return e
+ }
+ done()
+ rawStatus, e := json.Marshal(value)
+ if e != nil {
+ return ErrUnavailable
+ }
+ value, e = contract.DecodeExecutionStatus(rawStatus)
+ if e != nil {
+ return ErrUnavailable
+ }
+ out.Status = value
+ return nil
+ })
+ if err != nil {
+ return observationUnknown(r), ClassifyCheck(err)
+ }
+ return out, nil
+}
+func (c *Carrier) executionStatus(ctx context.Context, a contract.Actor, r ExecutionStatusRequest) (ExecutionStatusResponse, error) {
+ out := observationUnknown(r)
+ caps, err := c.Capabilities(ctx)
+ if err != nil || !caps.Supports(executionStatusAction) {
+ return out, ErrUnavailable
+ }
+ var id [24]byte
+ if _, e := rand.Read(id[:]); e != nil {
+ return out, ErrUnavailable
+ }
+ req := contract.Request{OperationID: "status-" + hex.EncodeToString(id[:]), Action: executionStatusAction}
+ resp, e := c.send(ctx, a, wireRequest{Request: req, Observation: &r})
+ if e != nil {
+ return out, e
+ }
+ // Read-only cleanup: close errors cannot change the decoded result.
+ defer func() { _ = resp.Body.Close() }()
+ var got ExecutionStatusResponse
+ if decode(io.LimitReader(resp.Body, 32768), &got) != nil || got.Version != 1 || got.Binding != r.Binding || got.ObservationID != r.ObservationID || ctx.Err() != nil {
+ return out, ErrUnavailable
+ }
+ raw, e := json.Marshal(got.Status)
+ if e != nil {
+ return out, ErrUnavailable
+ }
+ got.Status, e = contract.DecodeExecutionStatus(raw)
+ if e != nil {
+ return out, ErrUnavailable
+ }
+ return got, nil
+}
+
+// Observation DTOs use exact keys even though older carrier messages permit
+// encoding/json's case-insensitive aliases. Reject ambiguous nested identities.
+func observationObject(raw []byte, keys ...string) error {
+ if checkObject(raw) != nil {
+ return ErrForbidden
+ }
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(raw, &fields) != nil {
+ return ErrForbidden
+ }
+ for k := range fields {
+ if !slices.Contains(keys, k) {
+ return ErrForbidden
+ }
+ }
+ return nil
+}
+func (b *ExecutionStatusBinding) UnmarshalJSON(raw []byte) error {
+ if observationObject(raw, "conversationId", "actor", "addonId", "generation", "incarnation", "executionTool", "statusTool", "currentSelection") != nil {
+ return ErrForbidden
+ }
+ var fields map[string]json.RawMessage
+ _ = json.Unmarshal(raw, &fields)
+ if observationObject(fields["actor"], "userId", "credentialId", "mode") != nil {
+ return ErrForbidden
+ }
+ type plain ExecutionStatusBinding
+ var value plain
+ if decode(bytes.NewReader(raw), &value) != nil {
+ return ErrForbidden
+ }
+ *b = ExecutionStatusBinding(value)
+ return nil
+}
+func (r *ExecutionStatusRequest) UnmarshalJSON(raw []byte) error {
+ if observationObject(raw, "version", "binding", "observationId") != nil {
+ return ErrForbidden
+ }
+ type plain ExecutionStatusRequest
+ var value plain
+ if decode(bytes.NewReader(raw), &value) != nil {
+ return ErrForbidden
+ }
+ *r = ExecutionStatusRequest(value)
+ return nil
+}
+func (r *ExecutionStatusResponse) UnmarshalJSON(raw []byte) error {
+ if observationObject(raw, "version", "binding", "observationId", "status") != nil {
+ return ErrForbidden
+ }
+ type plain ExecutionStatusResponse
+ var value plain
+ if decode(bytes.NewReader(raw), &value) != nil {
+ return ErrForbidden
+ }
+ var fields map[string]json.RawMessage
+ _ = json.Unmarshal(raw, &fields)
+ status, e := contract.DecodeExecutionStatus(fields["status"])
+ if e != nil {
+ return e
+ }
+ *r = ExecutionStatusResponse(value)
+ r.Status = status
+ return nil
+}
diff --git a/core/services/addons/gateway/observation_runtime_test.go b/core/services/addons/gateway/observation_runtime_test.go
new file mode 100644
index 000000000..e0697f86c
--- /dev/null
+++ b/core/services/addons/gateway/observation_runtime_test.go
@@ -0,0 +1,220 @@
+//go:build linux
+
+// SPDX-License-Identifier: MIT
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync/atomic"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/addons"
+ "github.com/mudler/LocalAI/core/services/addons/packages"
+ addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// Only lookup ordering is instrumented. Every returned client belongs to the
+// real supervisor; admission, withdrawal and protocol dispatch are not mocked.
+type observationLookup struct {
+ supervisor *addonruntime.Supervisor
+ calls atomic.Int32
+ captured chan addonruntime.Instance
+ resume chan struct{}
+ pauseAt int32
+}
+
+func (s *observationLookup) Runtime(ctx context.Context, id string) (addonruntime.Instance, error) {
+ n := s.calls.Add(1)
+ if n == s.pauseAt && n == 2 {
+ // The first real /read has completed; pause before owner consumption.
+ s.captured <- addonruntime.Instance{}
+ select {
+ case <-s.resume:
+ case <-ctx.Done():
+ return addonruntime.Instance{}, ctx.Err()
+ }
+ }
+ i, e := s.supervisor.Current(ctx, id, 1)
+ if n == s.pauseAt && n == 1 && e == nil {
+ s.captured <- i
+ select {
+ case <-s.resume:
+ case <-ctx.Done():
+ return addonruntime.Instance{}, ctx.Err()
+ }
+ }
+ return i, e
+}
+
+type observationRecord struct {
+ Incarnation string `json:"incarnation"`
+ Envelope contract.Envelope `json:"envelope"`
+}
+
+var _ = Describe("supervised owner observation over authenticated carrier", func() {
+ var binary string
+ BeforeEach(func() {
+ binary = filepath.Join(GinkgoT().TempDir(), "fixture")
+ cmd := exec.Command("go", "build", "-o", binary, "../../../../tests/addons/fixture/cmd")
+ out, e := cmd.CombinedOutput()
+ Expect(e).NotTo(HaveOccurred(), string(out))
+ })
+ setup := func(pauseAt int32) (*addonruntime.Supervisor, packages.PackageReceipt, *observationLookup, *Gateway, ExecutionStatusRequest, string) {
+ ctx := context.Background()
+ caps, e := (observationCaps{}).DeclaredCapabilities(ctx, "sample", 1)
+ Expect(e).NotTo(HaveOccurred())
+ caps.Service = true
+ caps.Actions = true
+ for i := range caps.ToolDefinitions {
+ caps.ToolDefinitions[i].Description = "Fixture tool"
+ if len(caps.ToolDefinitions[i].InputSchema) == 0 {
+ caps.ToolDefinitions[i].InputSchema = json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)
+ }
+ }
+ rawCaps, e := json.Marshal(caps)
+ Expect(e).NotTo(HaveOccurred())
+ var manifest map[string]any
+ Expect(json.Unmarshal([]byte(uiManifest), &manifest)).To(Succeed())
+ manifest["capabilities"] = json.RawMessage(rawCaps)
+ rawManifest, e := json.Marshal(manifest)
+ Expect(e).NotTo(HaveOccurred())
+ b, e := os.ReadFile(binary)
+ Expect(e).NotTo(HaveOccurred())
+ logPath := filepath.Join(GinkgoT().TempDir(), "observations.jsonl")
+ config, e := json.Marshal(map[string]string{"mode": "execution-status", "observationLog": logPath})
+ Expect(e).NotTo(HaveOccurred())
+ m := ownerManagerPackage(frontActor, true, string(rawManifest), map[string]string{"bin/sample": string(b), "fixture.json": string(config)}, 1)
+ s, e := addonruntime.NewSupervisor(addonruntime.Config{StateRoot: GinkgoT().TempDir(), Limits: contract.DefaultLimits(), ReadyTimeout: 2 * time.Second, StableHealth: time.Second})
+ Expect(e).NotTo(HaveOccurred())
+ DeferCleanup(func() { Expect(s.Close(ctx)).To(Succeed()) })
+ Expect(m.AttachRuntime(ctx, s)).To(Succeed())
+ _, e = m.Submit(ctx, frontActor, addons.LifecycleCommand{AddonID: "sample", OperationID: "enable", Action: "enable", Generation: 1})
+ Expect(e).NotTo(HaveOccurred())
+ first, e := s.Current(ctx, "sample", 1)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(first.Client).To(BeAssignableToTypeOf(&addonruntime.Client{}))
+ // The installed receipt, not a separately invented replacement, starts again.
+ st, e := m.Status(ctx, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(st.Generations).To(HaveLen(1))
+ p := st.Generations[0].Receipt
+ Expect(p.Directory).NotTo(BeEmpty())
+ lookup := &observationLookup{supervisor: s, captured: make(chan addonruntime.Instance, 1), resume: make(chan struct{}), pauseAt: pauseAt}
+ g := &Gateway{Manager: m, Runtime: lookup, Admission: &addonAdmitted{}, Revalidate: func(_ context.Context, a contract.Actor) error {
+ if a != frontActor {
+ return ErrForbidden
+ }
+ return nil
+ }, AuthorizeExecutionStatus: func(_ context.Context, a contract.Actor, b ExecutionStatusBinding) error {
+ if a != frontActor || b.CurrentSelection != "rev" {
+ return ErrForbidden
+ }
+ return nil
+ }}
+ _, cs := carrierFixture(func(c *Carrier) http.Handler { h, e := c.OwnerHandler(g); Expect(e).NotTo(HaveOccurred()); return h })
+ front := &Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate}
+ r := ExecutionStatusRequest{Version: 1, Binding: ExecutionStatusBinding{Actor: frontActor, AddonID: "sample", Generation: 1, Incarnation: first.Incarnation, ExecutionTool: "run", StatusTool: "health", CurrentSelection: "rev"}, ObservationID: "q"}
+ return s, p, lookup, front, r, logPath
+ }
+ records := func(path string) []observationRecord {
+ f, e := os.Open(path)
+ if os.IsNotExist(e) {
+ return nil
+ }
+ Expect(e).NotTo(HaveOccurred())
+ defer func() { Expect(f.Close()).To(Succeed()) }()
+ d := json.NewDecoder(f)
+ var out []observationRecord
+ for d.More() {
+ var r observationRecord
+ Expect(d.Decode(&r)).To(Succeed())
+ out = append(out, r)
+ }
+ return out
+ }
+ restart := func(s *addonruntime.Supervisor, p packages.PackageReceipt) addonruntime.Instance {
+ old, e := s.Current(context.Background(), "sample", 1)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(s.Stop(context.Background(), old)).To(Succeed())
+ next, e := s.Start(context.Background(), p, 1)
+ Expect(e).NotTo(HaveOccurred())
+ current, e := s.Current(context.Background(), "sample", 1)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(current.Client).To(BeIdenticalTo(next.Client))
+ Expect(current.Client).NotTo(BeIdenticalTo(old.Client))
+ Expect(current.Incarnation).NotTo(Equal(old.Incarnation))
+ return next
+ }
+ positive := func(front *Gateway, r ExecutionStatusRequest, path string) {
+ out, e := front.ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(out.Binding).To(Equal(r.Binding))
+ Expect(out.ObservationID).To(Equal(r.ObservationID))
+ Expect(out.Status).To(Equal(contract.ExecutionStatus{Readiness: "available", Isolation: "isolated"}))
+ rows := records(path)
+ Expect(rows).NotTo(BeEmpty())
+ row := rows[len(rows)-1]
+ Expect(row.Incarnation).To(Equal(r.Binding.Incarnation))
+ Expect(row.Envelope.Authority.Actor).To(Equal(frontActor))
+ Expect(string(row.Envelope.Request.Arguments)).To(Equal(`{"tool":"health"}`))
+ Expect(row.Envelope.Request.OperationID).To(HavePrefix("obs-"))
+ Expect(row.Envelope.Request.OperationID).NotTo(Equal(r.ObservationID))
+ Expect(row.Envelope.Request.ResourceID).To(BeEmpty())
+ }
+ It("reads typed status and preserves named tool and host operation IDs through a supervised process", func() {
+ _, _, _, front, r, path := setup(0)
+ positive(front, r, path)
+ Expect(records(path)).To(HaveLen(1))
+ })
+ for _, tc := range []struct {
+ name string
+ pause int32
+ reads int
+ }{
+ {"rejects a captured supervised client after same generation replacement before carrier dispatch", 1, 0},
+ {"rejects a late supervised carrier observation after replacement before owner consumption", 2, 1},
+ } {
+ tc := tc
+ It(tc.name, func() {
+ s, p, lookup, front, r, path := setup(tc.pause)
+ type result struct {
+ out ExecutionStatusResponse
+ err error
+ }
+ done := make(chan result, 1)
+ go func() {
+ out, e := front.ExecutionStatusRead(context.Background(), frontActor, r)
+ done <- result{out, e}
+ }()
+ var captured addonruntime.Instance
+ Eventually(lookup.captured, 3*time.Second).Should(Receive(&captured))
+ if tc.pause == 1 {
+ Expect(captured.Incarnation).To(Equal(r.Binding.Incarnation))
+ }
+ next := restart(s, p)
+ close(lookup.resume)
+ var got result
+ Eventually(done, 3*time.Second).Should(Receive(&got))
+ Expect(got.err).To(HaveOccurred())
+ Expect(got.out.Status.Readiness).To(Equal("unknown"))
+ Expect(got.out.Status.Isolation).To(Equal("unknown"))
+ rows := records(path)
+ Expect(rows).To(HaveLen(tc.reads))
+ for _, row := range rows {
+ Expect(row.Incarnation).To(Equal(r.Binding.Incarnation))
+ }
+ r.Binding.Incarnation = next.Incarnation
+ r.ObservationID = "fresh-q"
+ positive(front, r, path)
+ Expect(records(path)).To(HaveLen(tc.reads + 1))
+ })
+ }
+})
diff --git a/core/services/addons/gateway/observation_test.go b/core/services/addons/gateway/observation_test.go
new file mode 100644
index 000000000..d18b108b5
--- /dev/null
+++ b/core/services/addons/gateway/observation_test.go
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: MIT
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "sync/atomic"
+
+ addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type observationCaps struct{ toolSet }
+
+type rawObservationCaps struct {
+ toolSet
+ caps contract.Capabilities
+}
+
+func (s rawObservationCaps) DeclaredCapabilities(context.Context, string, uint64) (contract.Capabilities, error) {
+ return s.caps, nil
+}
+
+func (s observationCaps) DeclaredCapabilities(context.Context, string, uint64) (contract.Capabilities, error) {
+ return contract.Capabilities{Tools: []string{"run", "health"}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, ToolDefinitions: []contract.ToolDefinition{
+ {Name: "run", Kind: "action", Shadows: "bash"},
+ {Name: "health", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)},
+ }}, nil
+}
+
+type observationClient struct {
+ contract.Runtime
+ calls atomic.Int32
+ stale atomic.Bool
+ after func()
+ actor contract.Actor
+}
+
+func (c *observationClient) AdmitObservation(g uint64, i string) (func(), error) {
+ if c.stale.Load() || g != 1 || i != "inc" {
+ return nil, addonruntime.ErrNotSent
+ }
+ return func() {}, nil
+}
+func (c *observationClient) ReadObservation(_ context.Context, e contract.Envelope, q string) (contract.ExecutionStatus, error) {
+ c.calls.Add(1)
+ if c.stale.Load() {
+ return contract.ExecutionStatus{}, addonruntime.ErrNotSent
+ }
+ if e.Authority.Actor != c.actor || e.Request.OperationID == q || e.Request.OperationID == "" || e.Request.ResourceID != "" || string(e.Request.Arguments) != `{"tool":"health"}` {
+ return contract.ExecutionStatus{}, ErrForbidden
+ }
+ if c.after != nil {
+ c.after()
+ }
+ return contract.ExecutionStatus{Readiness: "available", Isolation: "isolated"}, nil
+}
+
+var _ = Describe("exact owner execution observation", func() {
+ var g *Gateway
+ var c *observationClient
+ var r ExecutionStatusRequest
+ BeforeEach(func() {
+ c = &observationClient{actor: frontActor}
+ g = &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }, Admission: &addonAdmitted{}, Runtime: currentObservation{c}, Tools: observationCaps{toolSet{gen: 1, tools: map[string][]string{"sample": {"run", "health"}}}}, AuthorizeExecutionStatus: func(_ context.Context, a contract.Actor, b ExecutionStatusBinding) error {
+ if a != frontActor || b.CurrentSelection != "rev" {
+ return ErrForbidden
+ }
+ return nil
+ }}
+ r = ExecutionStatusRequest{Version: 1, Binding: ExecutionStatusBinding{Actor: frontActor, AddonID: "sample", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "rev"}, ObservationID: "q"}
+ })
+
+ DescribeTable("rejects invalid raw execution declarations", func(field, value string) {
+ caps, err := g.Tools.(observationCaps).DeclaredCapabilities(context.Background(), "sample", 1)
+ Expect(err).NotTo(HaveOccurred())
+ switch field {
+ case "schema":
+ caps.ToolDefinitions[1].InputSchema = json.RawMessage(value)
+ case "resource":
+ caps.ToolDefinitions[1].InputSchema = json.RawMessage(`{"type":"object","properties":{"resourceId":{"type":"string"}},"required":["resourceId"],"additionalProperties":false}`)
+ case "admin":
+ caps.AdminTools = []string{"health"}
+ case "status-ui":
+ caps.ToolDefinitions[1].UIOnly = false
+ case "status-kind":
+ caps.ToolDefinitions[1].Kind = value
+ case "action-ui":
+ caps.ToolDefinitions[0].UIOnly = true
+ case "action-kind":
+ caps.ToolDefinitions[0].Kind = value
+ case "shadow":
+ caps.ToolDefinitions[0].Shadows = value
+ }
+ g.Tools = rawObservationCaps{caps: caps}
+ out, err := g.ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(err).To(MatchError(ErrForbidden))
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ Expect(c.calls.Load()).To(BeZero())
+ },
+ Entry("wrong object", "schema", `{"type":"string","additionalProperties":false}`),
+ Entry("additional properties true", "schema", `{"type":"object","additionalProperties":true}`),
+ Entry("additional properties absent", "schema", `{"type":"object"}`),
+ Entry("required", "schema", `{"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false}`),
+ Entry("enum", "schema", `{"type":"object","enum":["x"],"additionalProperties":false}`),
+ Entry("unknown schema keyword", "schema", `{"type":"object","minProperties":1,"additionalProperties":false}`),
+ Entry("malformed required", "schema", `{"type":"object","required":null,"additionalProperties":false}`),
+ Entry("resource", "resource", ""),
+ Entry("admin", "admin", ""),
+ Entry("status uiOnly", "status-ui", ""),
+ Entry("status kind", "status-kind", "action"),
+ Entry("action uiOnly", "action-ui", ""),
+ Entry("action kind", "action-kind", "read"),
+ Entry("shadow", "shadow", "other"),
+ )
+
+ It("reads the exact uiOnly status tool locally and over authenticated mTLS", func() {
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ for _, gw := range []*Gateway{g, {Forward: cs["front-a"], Revalidate: g.Revalidate}} {
+ out, e := gw.ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(e).NotTo(HaveOccurred(), "forward=%v calls=%d", gw.Forward != nil, c.calls.Load())
+ Expect(out.Binding).To(Equal(r.Binding))
+ Expect(out.ObservationID).To(Equal("q"))
+ Expect(out.Status.Readiness).To(Equal("available"))
+ }
+ Expect(c.calls.Load()).To(Equal(int32(2)))
+ })
+ It("refuses forged identities tools current selections and incarnations without dispatch", func() {
+ for _, change := range []func(*ExecutionStatusRequest){func(r *ExecutionStatusRequest) { r.Binding.Actor.UserID = "forged" }, func(r *ExecutionStatusRequest) { r.Binding.StatusTool = "settings" }, func(r *ExecutionStatusRequest) { r.Binding.CurrentSelection = "old" }, func(r *ExecutionStatusRequest) { r.Binding.Incarnation = "old" }, func(r *ExecutionStatusRequest) { r.Binding.Generation = 2 }, func(r *ExecutionStatusRequest) { r.Binding.ExecutionTool = "other" }, func(r *ExecutionStatusRequest) { r.Version = 2 }, func(r *ExecutionStatusRequest) { r.ObservationID = "" }} {
+ bad := r
+ change(&bad)
+ out, e := g.ExecutionStatusRead(context.Background(), frontActor, bad)
+ Expect(e).To(HaveOccurred())
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ }
+ Expect(c.calls.Load()).To(BeZero())
+ })
+ It("rejects stale carrier replies and frontend authority fields without dispatch", func() {
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if req.URL.Path != "/addons/forward" {
+ h.ServeHTTP(w, req)
+ return
+ }
+ out := observationUnknown(r)
+ out.Binding.Incarnation = "replacement"
+ out.Status = contract.ExecutionStatus{Readiness: "available", Isolation: "isolated"}
+ Expect(json.NewEncoder(w).Encode(out)).To(Succeed())
+ })
+ })
+ front := &Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate}
+ out, e := front.ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(e).To(HaveOccurred())
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ Expect(c.calls.Load()).To(BeZero())
+ for _, extra := range []string{`"admin":true`, `"grants":["workspace."]`, `"operationId":"chosen"`, `"arguments":{}`} {
+ raw, _ := json.Marshal(r)
+ raw = append(raw[:len(raw)-1], []byte(","+extra+"}")...)
+ var decoded ExecutionStatusRequest
+ Expect(decode(strings.NewReader(string(raw)), &decoded)).To(HaveOccurred())
+ }
+ })
+ It("refuses forged observations through the real carrier and ignores ordinary dispatch attempts", func() {
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ for _, bad := range []ExecutionStatusRequest{
+ {Version: 1, Binding: ExecutionStatusBinding{Actor: frontActor}, ObservationID: "q"},
+ {Version: 1, Binding: r.Binding, ObservationID: ""},
+ } {
+ _, e := cs["front-a"].executionStatus(context.Background(), frontActor, bad)
+ Expect(e).To(HaveOccurred())
+ }
+ bad := r
+ bad.Binding.Actor.UserID = "forged"
+ _, e := cs["front-a"].executionStatus(context.Background(), frontActor, bad)
+ Expect(e).To(HaveOccurred())
+ _, e = g.Dispatch(context.Background(), frontActor, contract.Request{Action: executionStatusAction})
+ Expect(e).To(HaveOccurred())
+ Expect(c.calls.Load()).To(BeZero())
+ })
+ It("does not relay a captured old binding to a same-generation replacement", func() {
+ replacement := &observationClient{actor: frontActor}
+ g.Runtime = restartObservation{old: c, replacement: replacement}
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ out, e := (&Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate}).ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(e).To(HaveOccurred())
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ Expect(c.calls.Load()).To(BeZero())
+ Expect(replacement.calls.Load()).To(BeZero())
+ })
+ It("settles parent cancellation and owner permission loss as unknown", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ c.after = func() {
+ g.AuthorizeExecutionStatus = func(context.Context, contract.Actor, ExecutionStatusBinding) error { return ErrForbidden }
+ }
+ out, e := g.ExecutionStatusRead(ctx, frontActor, r)
+ Expect(e).To(HaveOccurred())
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ out, e = g.ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(e).To(HaveOccurred())
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ })
+
+ It("does not grant owner selection authority through wire capability support", func() {
+ g.AuthorizeExecutionStatus = nil
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ caps, e := cs["front-a"].Capabilities(context.Background())
+ Expect(e).NotTo(HaveOccurred())
+ Expect(caps.Supports(executionStatusAction)).To(BeTrue())
+ out, e := (&Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate}).ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(e).To(HaveOccurred())
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ Expect(c.calls.Load()).To(BeZero())
+ })
+ It("rejects duplicate noncanonical and nested authority keys in observation DTOs", func() {
+ raw, _ := json.Marshal(r)
+ for _, bad := range []string{
+ strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1),
+ strings.Replace(string(raw), `"binding":`, `"Binding":`, 1),
+ strings.Replace(string(raw), `"actor":{`, `"actor":{"admin":true,`, 1),
+ strings.Replace(string(raw), `"userId":`, `"UserId":`, 1),
+ } {
+ var decoded ExecutionStatusRequest
+ Expect(json.Unmarshal([]byte(bad), &decoded)).To(HaveOccurred())
+ }
+ })
+
+ It("withdraws a positive late reply when exact-client admission becomes stale", func() {
+ _, cs := carrierFixture(func(car *Carrier) http.Handler {
+ h, e := car.OwnerHandler(g)
+ Expect(e).NotTo(HaveOccurred())
+ return h
+ })
+ for _, gw := range []*Gateway{g, {Forward: cs["front-a"], Revalidate: g.Revalidate}} {
+ c.stale.Store(false)
+ c.after = func() { c.stale.Store(true) }
+ out, e := gw.ExecutionStatusRead(context.Background(), frontActor, r)
+ Expect(e).To(HaveOccurred())
+ Expect(out.Status.Readiness).To(Equal("unknown"))
+ }
+ })
+})
+
+type currentObservation struct{ c *observationClient }
+
+func (s currentObservation) Runtime(context.Context, string) (addonruntime.Instance, error) {
+ return addonruntime.Instance{Generation: 1, Incarnation: "inc", Client: s.c}, nil
+}
+
+// Snapshot is captured first; withdrawal occurs before gateway admission.
+type restartObservation struct{ old, replacement *observationClient }
+
+func (s restartObservation) Runtime(context.Context, string) (addonruntime.Instance, error) {
+ s.old.stale.Store(true)
+ return addonruntime.Instance{Generation: 1, Incarnation: "inc", Client: s.old}, nil
+}
diff --git a/core/services/addons/gateway/ownerfixture_test.go b/core/services/addons/gateway/ownerfixture_test.go
index 4ba8ff40a..1fc1347a3 100644
--- a/core/services/addons/gateway/ownerfixture_test.go
+++ b/core/services/addons/gateway/ownerfixture_test.go
@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "sort"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
@@ -26,6 +27,10 @@ const uiManifest = `{"schemaVersion":1,"id":"sample","version":"1.2.3","executab
// ownerManager builds a real owner journal. With a UI package it also installs
// the addon "sample" through the real catalog and installer, operation "install".
func ownerManager(actor contract.Actor, install bool) *addons.Manager {
+ return ownerManagerPackage(actor, install, uiManifest, nil, 1)
+}
+
+func ownerManagerPackage(actor contract.Actor, install bool, manifest string, files map[string]string, sdk uint64, grants ...map[string][]string) *addons.Manager {
root := GinkgoT().TempDir()
ctx := context.Background()
admin := func(_ context.Context, a contract.Actor) error {
@@ -36,9 +41,20 @@ func ownerManager(actor contract.Actor, install bool) *addons.Manager {
}
var b bytes.Buffer
tw := tar.NewWriter(&b)
- for _, f := range []struct{ name, data string }{{"manifest.json", uiManifest}, {"bin/sample", "binary"}, {"ui.js", "export default {};"}, {"ui.css", "body{}"}, {"secret.txt", "private"}} {
- Expect(tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0755, Size: int64(len(f.data))})).To(Succeed())
- _, e := tw.Write([]byte(f.data))
+ contents := map[string]string{"manifest.json": manifest, "bin/sample": "binary", "ui.js": "export default {};", "ui.css": "body{}", "secret.txt": "private"}
+ for name, data := range files {
+ contents[name] = data
+ }
+ // Stable entry order keeps identical package contents at the same digest.
+ names := make([]string, 0, len(contents))
+ for name := range contents {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ data := contents[name]
+ Expect(tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))})).To(Succeed())
+ _, e := tw.Write([]byte(data))
Expect(e).NotTo(HaveOccurred())
}
Expect(tw.Close()).To(Succeed())
@@ -56,7 +72,7 @@ func ownerManager(actor contract.Actor, install bool) *addons.Manager {
Expect(e).NotTo(HaveOccurred())
c := catalog.Catalog{Sources: sources, TargetOS: "linux", TargetArch: "amd64"}
cfg := addons.OwnerConfig{InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "disk", Root: filepath.Join(root, "owner")}
- i := &packages.Installer{Root: filepath.Join(cfg.Root, "packages"), Catalog: c, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}}
+ i := &packages.Installer{Root: filepath.Join(cfg.Root, "packages"), Catalog: c, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: sdk, DataFormat: 1, OS: "linux", Arch: "amd64"}}
DeferCleanup(func() {
_ = filepath.Walk(root, func(p string, info os.FileInfo, e error) error {
if e == nil && info.IsDir() {
@@ -68,6 +84,9 @@ func ownerManager(actor contract.Actor, install bool) *addons.Manager {
m, e := addons.NewManager(ctx, cfg, c, i, admin)
Expect(e).NotTo(HaveOccurred())
DeferCleanup(func() { Expect(m.Close()).To(Succeed()) })
+ if len(grants) != 0 {
+ m.SetHostOps(grants[0])
+ }
if install {
_, e = m.Submit(ctx, actor, addons.LifecycleCommand{AddonID: "sample", OperationID: "install", Action: "install", SourceID: "test", Version: "1.2.3"})
Expect(e).NotTo(HaveOccurred())
diff --git a/core/services/addons/hostops/admission.go b/core/services/addons/hostops/admission.go
new file mode 100644
index 000000000..e9c42beeb
--- /dev/null
+++ b/core/services/addons/hostops/admission.go
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: MIT
+package hostops
+
+import (
+ "slices"
+ "strings"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+// RequestSummary describes requests, not authority. Names are bounded by the
+// verified manifest (32 total, 64 bytes each); no policy paths or other addon
+// grants are included. Granted counts only requested, supported operations.
+type RequestSummary struct {
+ Required []string `json:"required,omitempty"`
+ Optional []string `json:"optional,omitempty"`
+ Granted []string `json:"granted,omitempty"`
+ MissingRequired []string `json:"missingRequired,omitempty"`
+ MissingOptional []string `json:"missingOptional,omitempty"`
+ RequiredCount int `json:"requiredCount"`
+ OptionalCount int `json:"optionalCount"`
+ GrantedCount int `json:"grantedCount"`
+}
+
+// Requests is side-effect free. Registry membership is exact; coverage uses the
+// broker's literal nonempty prefix rule, not a regexp or a request-as-grant.
+func Requests(req *contract.HostOperations, registered, grants []string) RequestSummary {
+ s := RequestSummary{}
+ if req == nil {
+ return s
+ }
+ s.Required = slices.Clone(req.Required)
+ s.Optional = slices.Clone(req.Optional)
+ slices.Sort(s.Required)
+ slices.Sort(s.Optional)
+ for i, names := range [][]string{s.Required, s.Optional} {
+ for _, name := range names {
+ if slices.Contains(registered, name) && hasPrefix(grants, name) {
+ s.Granted = append(s.Granted, name)
+ } else if i == 0 {
+ s.MissingRequired = append(s.MissingRequired, name)
+ } else {
+ s.MissingOptional = append(s.MissingOptional, name)
+ }
+ }
+ }
+ slices.Sort(s.Granted)
+ s.RequiredCount, s.OptionalCount, s.GrantedCount = len(s.Required), len(s.Optional), len(s.Granted)
+ return s
+}
+
+// SupportedOperations is the host's startup registry vocabulary. Handlers are
+// wired after manager recovery as their services become available. Admission
+// must not mistake that startup ordering for unsupported operations. The broker
+// still requires a registered handler and revalidates every request's authority.
+func SupportedOperations() []string {
+ return strings.Fields(`finetune.start finetune.get finetune.list finetune.checkpoints finetune.dataset finetune.export finetune.progress finetune.stop quantize.start quantize.get quantize.list quantize.import quantize.progress quantize.stop workspace.materialize workspace.commit workspace.export workspace.import workspace.changes workspace.admin egress.check egress.audit connector.inbound`)
+}
diff --git a/core/services/addons/hostops/admission_test.go b/core/services/addons/hostops/admission_test.go
new file mode 100644
index 000000000..a03360ca3
--- /dev/null
+++ b/core/services/addons/hostops/admission_test.go
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: MIT
+package hostops
+
+import (
+ "context"
+ "slices"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/services/studio/session"
+ "github.com/mudler/LocalAI/core/services/studio/workspace"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// Only registration is exercised: no workspace method or handler may run.
+type admissionWorkspaceStore struct{ workspace.Store }
+type admissionInbox struct{}
+
+func (admissionInbox) Ingest(context.Context, string, contract.ConnectorInbound) error {
+ panic("admission parity must not invoke handlers")
+}
+
+func registeredBuiltinOperations() []string {
+ b := New(NewInFlight(), nil, contract.DefaultLimits())
+ Expect(RegisterEngineJobs(b, &fakeFT{}, &fakeQ{}, backends{})).To(Succeed())
+ Expect(RegisterEngineDatasets(b, &fakeDatasets{})).To(Succeed())
+ Expect(RegisterEngineModels(b, &fakeExporter{}, &fakeImporter{})).To(Succeed())
+ p := NewProgresser(newFakeSource())
+ Expect(RegisterEngineProgress(b, p, p)).To(Succeed())
+ Expect(RegisterWorkspace(b, &WorkspaceHost{
+ Store: &admissionWorkspaceStore{},
+ Principal: func(contract.Actor) string { panic("registration only") },
+ DataDir: func(string) string { panic("registration only") },
+ Access: func(context.Context, string, string) (session.Access, error) { panic("registration only") },
+ })).To(Succeed())
+ Expect(RegisterEgress(b, &fakeEgress{})).To(Succeed())
+ Expect(RegisterConnector(b, admissionInbox{})).To(Succeed())
+ // Each registration error is checked above, so a duplicate cannot be hidden
+ // by the broker's map. Ops includes dynamic progress/stop and inbound specs.
+ return b.Ops()
+}
+
+var _ = Describe("Host operation request admission", func() {
+ It("matches the supported vocabulary exactly to every real builtin registration", func() {
+ registered := registeredBuiltinOperations()
+ supported := SupportedOperations()
+ for _, names := range [][]string{registered, supported} {
+ Expect(names).NotTo(BeEmpty())
+ seen := map[string]bool{}
+ for _, name := range names {
+ Expect(strings.TrimSpace(name)).NotTo(BeEmpty())
+ Expect(seen[name]).To(BeFalse(), "duplicate operation %q", name)
+ seen[name] = true
+ }
+ }
+ Expect(supported).To(ConsistOf(registered), "startup admission must match the case-sensitive runtime registry")
+ })
+ It("detects missing extra duplicate and case-changed operations with the parity oracle", func() {
+ registered := registeredBuiltinOperations()
+ Expect(registered).NotTo(BeEmpty())
+ oracle := ConsistOf(registered)
+ for _, mutated := range [][]string{
+ slices.Clone(registered[1:]),
+ append(slices.Clone(registered), "parity.negative.control"),
+ append(slices.Clone(registered), registered[0]),
+ append([]string{strings.ToUpper(registered[0])}, registered[1:]...),
+ } {
+ matches, err := oracle.Match(mutated)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(matches).To(BeFalse(), "parity oracle accepted mutated vocabulary %v", mutated)
+ }
+ })
+ It("uses registered exact names and literal nonempty grant prefixes without self grants", func() {
+ req := &contract.HostOperations{Required: []string{"workspace.materialize", "future.op"}, Optional: []string{"egress.check"}}
+ s := Requests(req, []string{"workspace.materialize", "egress.check"}, []string{"workspace."})
+ Expect(s.MissingRequired).To(Equal([]string{"future.op"}))
+ Expect(s.MissingOptional).To(Equal([]string{"egress.check"}))
+ Expect(s.Granted).To(Equal([]string{"workspace.materialize"}))
+ Expect(Requests(req, []string{"workspace.materialize"}, []string{""}).MissingRequired).To(HaveLen(2))
+ Expect(Requests(&contract.HostOperations{Required: []string{"workspace.materialize"}}, []string{"workspace.materialize"}, []string{"workspace.materialize.narrow."}).MissingRequired).To(HaveLen(1))
+ Expect(Requests(nil, nil, nil).MissingRequired).To(BeEmpty())
+ })
+ It("permits missing optional requests and bounds diagnostics to declaration names", func() {
+ req := &contract.HostOperations{Optional: []string{"egress.check", "future.op"}}
+ s := Requests(req, SupportedOperations(), []string{"egress."})
+ Expect(s.MissingRequired).To(BeEmpty())
+ Expect(s.MissingOptional).To(Equal([]string{"future.op"}))
+ Expect(s.OptionalCount).To(Equal(2))
+ Expect(s.GrantedCount).To(Equal(1))
+ })
+})
diff --git a/core/services/addons/hostops/broker.go b/core/services/addons/hostops/broker.go
index 558684f04..39d9c1f86 100644
--- a/core/services/addons/hostops/broker.go
+++ b/core/services/addons/hostops/broker.go
@@ -158,6 +158,54 @@ func refuse(w http.ResponseWriter, status int, code, msg string) {
// secret that incarnation holds; allowed lists the operation prefixes the
// host setting gives the add-on. Whatever the check, the default is refuse.
func (b *Broker) Handler(addonID, credential string, allowed []string) http.Handler {
+ return b.HandlerForRuntime(addonID, credential, allowed, 0, "")
+}
+
+// RuntimeEndpoint owns callback admission and draining for one runtime. Stop
+// prevents new HTTP requests and handler admissions. Drain waits for admitted
+// requests, including those still decoding, without holding a lock across them.
+// Retired observation correlations must not be forgotten until Drain returns.
+type RuntimeEndpoint struct {
+ handler http.Handler
+ mu sync.Mutex
+ closed bool
+ active sync.WaitGroup
+}
+
+type endpointKey struct{}
+
+func (b *Broker) EndpointForRuntime(addonID, credential string, allowed []string, generation uint64, incarnation string) *RuntimeEndpoint {
+ return &RuntimeEndpoint{handler: b.HandlerForRuntime(addonID, credential, allowed, generation, incarnation)}
+}
+
+func (e *RuntimeEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ e.mu.Lock()
+ if e.closed {
+ e.mu.Unlock()
+ refuse(w, http.StatusForbidden, contract.HostNoLiveOperation, "runtime endpoint closed")
+ return
+ }
+ e.active.Add(1)
+ e.mu.Unlock()
+ defer e.active.Done()
+ e.handler.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), endpointKey{}, e)))
+}
+
+func (e *RuntimeEndpoint) Stop() { e.mu.Lock(); e.closed = true; e.mu.Unlock() }
+
+// Drain must be called after Stop, which serializes admission with Wait.
+func (e *RuntimeEndpoint) Drain() { e.active.Wait() }
+
+// admit linearizes handler admission against Stop. No mutex is held while
+// running a handler: cancellation and teardown may themselves need the broker.
+func (e *RuntimeEndpoint) admit() bool {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return !e.closed
+}
+
+// HandlerForRuntime authenticates callbacks for exactly one runtime.
+func (b *Broker) HandlerForRuntime(addonID, credential string, allowed []string, generation uint64, incarnation string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
refuse(w, http.StatusMethodNotAllowed, contract.HostBadRequest, "POST only")
@@ -182,6 +230,10 @@ func (b *Broker) Handler(addonID, credential string, allowed []string) http.Hand
refuse(w, http.StatusBadRequest, contract.HostBadRequest, "malformed request")
return
}
+ if b.inflight.callbackDenied(addonID, req.OperationID, generation, incarnation) {
+ refuse(w, http.StatusForbidden, contract.HostScope, "observation callbacks denied")
+ return
+ }
if !hasPrefix(allowed, req.Op) {
refuse(w, http.StatusForbidden, contract.HostUnknownOp, "operation not allowed")
return
@@ -235,6 +287,8 @@ func (b *Broker) Handler(addonID, credential string, allowed []string) http.Hand
if err != nil {
status, code, msg := http.StatusServiceUnavailable, contract.HostUnavailable, "operation unavailable"
switch {
+ case errors.Is(err, ErrForbidden):
+ status, code, msg = http.StatusForbidden, contract.HostNoLiveOperation, "runtime endpoint closed"
case errors.Is(err, ErrNotFound):
status, code, msg = http.StatusNotFound, contract.HostNotFound, "not found"
case errors.Is(err, ErrConflict):
@@ -326,6 +380,9 @@ func checkArguments(raw json.RawMessage) (json.RawMessage, error) {
// operation ID and name; a repeat gets the first result, and a call that
// arrives while the first still runs waits for it. A failure is not kept.
func (b *Broker) run(ctx context.Context, spec Spec, call Call) (json.RawMessage, error) {
+ if endpoint, ok := ctx.Value(endpointKey{}).(*RuntimeEndpoint); ok && !endpoint.admit() {
+ return nil, ErrForbidden
+ }
if !spec.Idempotent {
return spec.Handler(ctx, call)
}
diff --git a/core/services/addons/hostops/inflight.go b/core/services/addons/hostops/inflight.go
index 30e0540de..8dbdd168c 100644
--- a/core/services/addons/hostops/inflight.go
+++ b/core/services/addons/hostops/inflight.go
@@ -5,6 +5,7 @@
package hostops
import (
+ "errors"
"sync"
"time"
@@ -15,23 +16,28 @@ import (
// add-on right now. A host operation is allowed only while its operation ID
// is in here, so an add-on cannot act when no user asked it to.
type InFlight struct {
- mu sync.Mutex
- entries map[string]*live
+ mu sync.Mutex
+ entries map[string]*live
+ observations map[string]*observation
}
type live struct {
env contract.Envelope
// binding is the chat the request was made for; bound is false for a
// request no chat made.
- binding Binding
- bound bool
+ incarnation string
+ observation *observation
+ binding Binding
+ bound bool
// mixed is set when two requests share the operation ID with different
// bindings. Neither chat can be trusted, so the entry is not live.
mixed bool
n int
}
-func NewInFlight() *InFlight { return &InFlight{entries: map[string]*live{}} }
+func NewInFlight() *InFlight {
+ return &InFlight{entries: map[string]*live{}, observations: map[string]*observation{}}
+}
func key(addonID, operationID string) string { return addonID + "/" + operationID }
@@ -39,26 +45,25 @@ func key(addonID, operationID string) string { return addonID + "/" + operationI
// function removes it; it is safe to call more than once. Two requests with
// the same operation ID share one entry until both end.
func (f *InFlight) Begin(env contract.Envelope) (end func()) {
- return f.begin(env, Binding{}, false)
+ return f.begin(env, Binding{}, false, "")
}
// BeginBound records the envelope of a request made for the chat b. The
// binding stays on the host: the envelope the add-on receives is unchanged.
func (f *InFlight) BeginBound(env contract.Envelope, b Binding) (end func()) {
- return f.begin(env, b, true)
+ return f.begin(env, b, true, "")
}
-func (f *InFlight) begin(env contract.Envelope, b Binding, bound bool) (end func()) {
+func (f *InFlight) begin(env contract.Envelope, b Binding, bound bool, incarnation string) (end func()) {
k := key(env.Request.AddonID, env.Request.OperationID)
f.mu.Lock()
if e := f.entries[k]; e != nil {
e.n++
- e.env = env
- if e.bound != bound || e.binding != b {
+ if e.observation != nil || e.bound != bound || e.binding != b || e.incarnation != incarnation || e.env.Request.Generation != env.Request.Generation || e.env.Authority.Actor != env.Authority.Actor || e.env.Authority.Scope != env.Authority.Scope || e.env.Request.ResourceID != env.Request.ResourceID {
e.mixed = true
}
} else {
- f.entries[k] = &live{env: env, binding: b, bound: bound, n: 1}
+ f.entries[k] = &live{env: env, binding: b, bound: bound, incarnation: incarnation, n: 1}
}
f.mu.Unlock()
var once sync.Once
@@ -94,3 +99,70 @@ func (f *InFlight) LookupBound(addonID, operationID string) (contract.Envelope,
}
return e.env, e.binding, true
}
+
+// observation is host-private, never decoded from an addon message.
+type observation struct {
+ env contract.Envelope
+ incarnation, request string
+ denied bool
+}
+
+// BeginRuntime binds a live envelope to the authenticated runtime incarnation.
+func (f *InFlight) BeginRuntime(env contract.Envelope, b Binding, bound bool, incarnation string) func() {
+ return f.begin(env, b, bound, incarnation)
+}
+
+// BeginObservation reserves a unique operation for a host-owned observation.
+// End removes the live authority idempotently. A bounded retired correlation is
+// kept until the runtime endpoint closes, so late inbound-class callbacks cannot
+// regain ordinary authority. At capacity admission fails closed (no eviction).
+func (f *InFlight) BeginObservation(env contract.Envelope, b Binding, incarnation, request string) (func(), func() bool, error) {
+ if incarnation == "" || request == "" || len(request) > 256 || env.Request.OperationID == "" {
+ return nil, nil, errors.New("missing observation identity")
+ }
+ f.mu.Lock()
+ k := key(env.Request.AddonID, env.Request.OperationID)
+ if len(f.observations) >= 4096 || f.entries[k] != nil || f.observations[k] != nil {
+ f.mu.Unlock()
+ return nil, nil, errors.New("operation already used")
+ }
+ o := &observation{env: env, incarnation: incarnation, request: request}
+ f.observations[k] = o
+ f.entries[k] = &live{env: env, binding: b, bound: b.Valid(), incarnation: incarnation, observation: o, n: 1}
+ f.mu.Unlock()
+ var once sync.Once
+ end := func() { once.Do(func() { f.mu.Lock(); delete(f.entries, k); f.mu.Unlock() }) }
+ return end, func() bool { f.mu.Lock(); defer f.mu.Unlock(); return o.denied }, nil
+}
+
+func (f *InFlight) callbackDenied(addon, op string, generation uint64, incarnation string) bool {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if o := f.observations[key(addon, op)]; o != nil {
+ o.denied = true
+ return true
+ }
+ if e := f.entries[key(addon, op)]; e != nil && incarnation != "" && (e.incarnation != incarnation || e.env.Request.Generation != generation) {
+ return true
+ }
+ return false
+}
+
+// ForgetRuntime releases retired observation correlations only after the callback
+// endpoint has stopped admission and all admitted HTTP handlers have drained.
+// Server.Close alone is insufficient: it does not wait for active handlers.
+// Live entries are removed too; the stopped endpoint refuses subsequent calls.
+func (f *InFlight) ForgetRuntime(addon, incarnation string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ for k, o := range f.observations {
+ if o.env.Request.AddonID == addon && o.incarnation == incarnation {
+ delete(f.observations, k)
+ }
+ }
+ for k, e := range f.entries {
+ if e.env.Request.AddonID == addon && e.incarnation == incarnation {
+ delete(f.entries, k)
+ }
+ }
+}
diff --git a/core/services/addons/hostops/observation_test.go b/core/services/addons/hostops/observation_test.go
new file mode 100644
index 000000000..3c1c38f29
--- /dev/null
+++ b/core/services/addons/hostops/observation_test.go
@@ -0,0 +1,267 @@
+// SPDX-License-Identifier: MIT
+package hostops
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Observation callback boundary", func() {
+ It("denies every correlated class before handlers and retains late denial", func() {
+ f := &fixture{inflight: NewInFlight()}
+ f.b = New(f.inflight, always, contract.DefaultLimits())
+ calls := 0
+ file := filepath.Join(GinkgoT().TempDir(), "effect")
+ for op, scope := range map[string]string{"workspace.materialize": "read", "workspace.export": "read", "workspace.commit": "act", "finetune.start": "apply", "connector.inbound": ScopeInbound} {
+ feature := "chats"
+ if scope == ScopeInbound {
+ feature = ""
+ }
+ Expect(f.b.Register(Spec{Op: op, Scope: scope, Feature: feature, Handler: func(context.Context, Call) (json.RawMessage, error) {
+ calls++
+ _ = os.WriteFile(file, []byte("effect"), 0600)
+ return json.RawMessage(`{}`), nil
+ }})).To(Succeed())
+ }
+ f.h = f.b.HandlerForRuntime("fine-tuning", "secret", []string{"workspace.", "finetune.", "connector."}, 1, "inc")
+ e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute))
+ end, denied, err := f.inflight.BeginObservation(e, Binding{}, "inc", "request")
+ Expect(err).NotTo(HaveOccurred())
+ for _, op := range []string{"workspace.materialize", "workspace.export", "workspace.commit", "finetune.start", "connector.inbound"} {
+ code, _ := f.call("secret", "status", op, `{}`)
+ Expect(code).To(Equal(http.StatusForbidden), op)
+ }
+ Expect(denied()).To(BeTrue())
+ Expect(calls).To(BeZero())
+ _, statErr := os.Stat(file)
+ Expect(os.IsNotExist(statErr)).To(BeTrue())
+ ordinary := env("fine-tuning", "ordinary", "act", time.Now().Add(time.Minute))
+ DeferCleanup(f.inflight.BeginRuntime(ordinary, Binding{}, false, "inc"))
+ code, _ := f.call("secret", "ordinary", "workspace.export", `{}`)
+ Expect(code).To(Equal(http.StatusOK))
+ code, _ = f.call("secret", "ordinary", "workspace.commit", `{}`)
+ Expect(code).To(Equal(http.StatusOK))
+ Expect(calls).To(Equal(2))
+ contents, err := os.ReadFile(file)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(contents)).To(Equal("effect"))
+ end()
+ end()
+ _, live := f.inflight.Lookup("fine-tuning", "status")
+ Expect(live).To(BeFalse())
+ code, _ = f.call("secret", "status", "connector.inbound", `{}`)
+ Expect(code).To(Equal(http.StatusForbidden))
+
+ })
+ It("cannot overwrite an observation with a forged identity or duplicate operation", func() {
+ f := NewInFlight()
+ e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute))
+ end, _, err := f.BeginObservation(e, Binding{}, "inc", "request")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(end)
+ forged := e
+ forged.Request.Generation = 2
+ DeferCleanup(f.BeginRuntime(forged, Binding{}, false, "other"))
+ _, ok := f.Lookup("fine-tuning", "status")
+ Expect(ok).To(BeFalse())
+ _, _, err = f.BeginObservation(e, Binding{}, "inc", "request2")
+ Expect(err).To(HaveOccurred())
+ })
+})
+
+var _ = Describe("Observation identity checks", func() {
+ It("rejects mismatched authenticated incarnation and forged IDs without touching handlers", func() {
+ f := newFixture(always)
+ e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute))
+ end, denied, err := f.inflight.BeginObservation(e, Binding{}, "inc", "q")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(end)
+ f.h = f.b.HandlerForRuntime("fine-tuning", "secret", []string{"finetune."}, 2, "replacement")
+ code, _ := f.call("secret", "status", "finetune.get", `{}`)
+ Expect(code).To(Equal(403))
+ Expect(denied()).To(BeTrue())
+ code, _ = f.call("secret", "forged", "finetune.get", `{}`)
+ Expect(code).To(Equal(403))
+ code, _ = f.call("forged", "status", "finetune.get", `{}`)
+ Expect(code).To(Equal(401))
+ Expect(f.seen.AddonID).To(BeEmpty())
+ })
+ It("removes retired state on endpoint close", func() {
+ f := NewInFlight()
+ e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute))
+ end, _, err := f.BeginObservation(e, Binding{}, "inc", "q")
+ Expect(err).NotTo(HaveOccurred())
+ end()
+ Expect(f.observations).To(HaveLen(1))
+ f.ForgetRuntime("fine-tuning", "inc")
+ f.ForgetRuntime("fine-tuning", "inc")
+ Expect(f.observations).To(BeEmpty())
+ Expect(f.entries).To(BeEmpty())
+ })
+ It("fails closed at the retained correlation bound without restricting ordinary operations", func() {
+ f := NewInFlight()
+ e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute))
+ for n := 0; n < 4096; n++ {
+ e.Request.OperationID = fmt.Sprintf("status-%d", n)
+ end, _, err := f.BeginObservation(e, Binding{}, "inc", "q")
+ Expect(err).NotTo(HaveOccurred())
+ end()
+ }
+ e.Request.OperationID = "overflow"
+ _, _, err := f.BeginObservation(e, Binding{}, "inc", "q")
+ Expect(err).To(HaveOccurred())
+ e.Request.OperationID = "ordinary"
+ end := f.BeginRuntime(e, Binding{}, false, "inc")
+ defer end()
+ _, live := f.Lookup("fine-tuning", "ordinary")
+ Expect(live).To(BeTrue())
+ f.ForgetRuntime("fine-tuning", "inc")
+ Expect(f.observations).To(BeEmpty())
+ })
+
+})
+
+// A body barrier runs only after authentication and after all request bytes have
+// been read. Returning EOF resumes decoding and the correlation denial check.
+type callbackBodyBarrier struct {
+ *strings.Reader
+ read chan struct{}
+ resume chan struct{}
+}
+
+func (b *callbackBodyBarrier) Read(p []byte) (int, error) {
+ n, err := b.Reader.Read(p)
+ if err != nil {
+ close(b.read)
+ <-b.resume
+ }
+ return n, err
+}
+func (b *callbackBodyBarrier) Close() error { return nil }
+
+var _ = Describe("Callback endpoint retirement", func() {
+ It("keeps authenticated pending inbound callbacks denied while another endpoint works", func() {
+ f := NewInFlight()
+ b := New(f, always, contract.DefaultLimits())
+ var inbound, ordinary atomic.Int32
+ Expect(b.Register(Spec{Op: "connector.inbound", Scope: ScopeInbound, Handler: func(context.Context, Call) (json.RawMessage, error) {
+ inbound.Add(1)
+ return json.RawMessage(`{}`), nil
+ }})).To(Succeed())
+ Expect(b.Register(Spec{Op: "workspace.export", Scope: "read", Feature: "chats", Handler: func(context.Context, Call) (json.RawMessage, error) {
+ ordinary.Add(1)
+ return json.RawMessage(`{}`), nil
+ }})).To(Succeed())
+ end, _, err := f.BeginObservation(env("fine-tuning", "status", "read", time.Now().Add(time.Minute)), Binding{}, "inc", "q")
+ Expect(err).NotTo(HaveOccurred())
+ end()
+ h := b.EndpointForRuntime("fine-tuning", "secret", []string{"connector."}, 1, "inc")
+ body := &callbackBodyBarrier{Reader: strings.NewReader(`{"operationId":"status","op":"connector.inbound","arguments":{}}`), read: make(chan struct{}), resume: make(chan struct{})}
+ req := httptest.NewRequest(http.MethodPost, "/host", body)
+ req.Header.Set(contract.HostCredentialHeader, "secret")
+ rec := httptest.NewRecorder()
+ done := make(chan struct{})
+ go func() { h.ServeHTTP(rec, req); close(done) }()
+ <-body.read
+ h.Stop()
+ retired := make(chan struct{})
+ go func() { h.Drain(); f.ForgetRuntime("fine-tuning", "inc"); close(retired) }()
+ select {
+ case <-retired:
+ Fail("retired before callback drained")
+ default:
+ }
+ Expect(f.callbackDenied("fine-tuning", "status", 1, "inc")).To(BeTrue())
+ other := &fixture{b: b, inflight: f, h: b.HandlerForRuntime("other", "other-secret", []string{"workspace."}, 1, "other-inc")}
+ defer f.BeginRuntime(env("other", "ordinary", "read", time.Now().Add(time.Minute)), Binding{}, false, "other-inc")()
+ code, _ := other.call("other-secret", "ordinary", "workspace.export", `{}`)
+ Expect(code).To(Equal(http.StatusOK))
+ close(body.resume)
+ <-done
+ Expect(inbound.Load()).To(BeZero())
+ Expect(rec.Code).To(Equal(http.StatusForbidden))
+ Expect(ordinary.Load()).To(Equal(int32(1)))
+ <-retired
+ Expect(f.observations).To(BeEmpty())
+ Expect(f.entries).To(HaveLen(1))
+ closed := &fixture{h: h}
+ code, _ = closed.call("secret", "fresh", "connector.inbound", `{}`)
+ Expect(code).To(Equal(http.StatusForbidden))
+ Expect(inbound.Load()).To(BeZero())
+ })
+})
+
+var _ = Describe("Decoded callback dispatch retirement", func() {
+ It("refuses an authorized decoded callback paused across endpoint Stop", func() {
+ f := NewInFlight()
+ authorized, resume := make(chan struct{}), make(chan struct{})
+ b := New(f, func(string, string) bool { close(authorized); <-resume; return true }, contract.DefaultLimits())
+ var calls atomic.Int32
+ Expect(b.Register(Spec{Op: "workspace.export", Scope: "read", Feature: "chats", Handler: func(context.Context, Call) (json.RawMessage, error) {
+ calls.Add(1)
+ return json.RawMessage(`{}`), nil
+ }})).To(Succeed())
+ h := b.EndpointForRuntime("fine-tuning", "secret", []string{"workspace."}, 1, "inc")
+ defer f.BeginRuntime(env("fine-tuning", "ordinary", "read", time.Now().Add(time.Minute)), Binding{}, false, "inc")()
+ fixture := &fixture{h: h}
+ result := make(chan int, 1)
+ go func() { code, _ := fixture.call("secret", "ordinary", "workspace.export", `{}`); result <- code }()
+ <-authorized // Decoded and correlation checked; paused in the real feature check.
+ h.Stop()
+ drained := make(chan struct{})
+ go func() { h.Drain(); close(drained) }()
+ select {
+ case <-drained:
+ Fail("drained before authorized request returned")
+ default:
+ }
+ close(resume)
+ Expect(<-result).To(Equal(http.StatusForbidden))
+ <-drained
+ Expect(calls.Load()).To(BeZero())
+ })
+ It("stops without waiting for a handler that ignores cancellation", func() {
+ f := NewInFlight()
+ b := New(f, always, contract.DefaultLimits())
+ entered, release := make(chan struct{}), make(chan struct{})
+ var h *RuntimeEndpoint
+ Expect(b.Register(Spec{Op: "connector.inbound", Scope: ScopeInbound, Handler: func(context.Context, Call) (json.RawMessage, error) {
+ close(entered)
+ <-release // Deliberately ignores request cancellation.
+ h.Stop() // No lifecycle lock may be held across the handler or Drain.
+ return json.RawMessage(`{}`), nil
+ }})).To(Succeed())
+ h = b.EndpointForRuntime("fine-tuning", "secret", []string{"connector."}, 1, "inc")
+ fixture := &fixture{h: h}
+ done := make(chan struct{})
+ go func() { fixture.call("secret", "ordinary", "connector.inbound", `{}`); close(done) }()
+ <-entered
+ stopped := make(chan struct{})
+ go func() { h.Stop(); close(stopped) }()
+ Eventually(stopped).Should(BeClosed())
+ drained := make(chan struct{})
+ go func() { h.Drain(); close(drained) }()
+ select {
+ case <-drained:
+ Fail("drained while handler still running")
+ default:
+ }
+ code, _ := fixture.call("secret", "fresh", "connector.inbound", `{}`)
+ Expect(code).To(Equal(http.StatusForbidden))
+ close(release)
+ Eventually(done).Should(BeClosed())
+ Eventually(drained).Should(BeClosed())
+ })
+})
diff --git a/core/services/addons/management.go b/core/services/addons/management.go
index 1f0cc9746..78b99cd01 100644
--- a/core/services/addons/management.go
+++ b/core/services/addons/management.go
@@ -170,6 +170,11 @@ func (m *Manager) Installed(ctx context.Context, actor contract.Actor) ([]apps.I
if st.DesiredEnabled && m.runtime != nil {
snap.State = m.runtime.State(id, st.Generation)
}
+ snap.HostOperations = summaryPointer(m.requests(receipt))
+ if m.admit(receipt) != nil {
+ snap.Unavailable = true
+ snap.State = "failed"
+ }
if m.runtime != nil {
restarts[id] = m.runtime.Attempts(id)
}
diff --git a/core/services/addons/manager.go b/core/services/addons/manager.go
index 84afe0585..cffceb848 100644
--- a/core/services/addons/manager.go
+++ b/core/services/addons/manager.go
@@ -155,16 +155,25 @@ type Manager struct {
skillSync SkillSync
// hostOps is the host setting of the host operation prefixes of each
// add-on. The apps list reads it to decide which host controls it draws.
- hostOps map[string][]string
+ hostOps map[string][]string
+ hostOpsSet bool
}
// SetHostOps sets the host operation grants of the add-ons (the host setting,
// read at startup). The apps list draws a host control only for an add-on
-// whose grants entitle it.
+// whose grants entitle it. The first call selects an immutable startup snapshot;
+// subsequent calls do not reload policy. Call before attaching the runtime.
func (m *Manager) SetHostOps(ops map[string][]string) {
m.mu.Lock()
defer m.mu.Unlock()
- m.hostOps = ops
+ if m.hostOpsSet {
+ return
+ }
+ m.hostOpsSet = true
+ m.hostOps = make(map[string][]string, len(ops))
+ for id, prefixes := range ops {
+ m.hostOps[id] = slices.Clone(prefixes)
+ }
}
// SkillSync copies the skills of an add-on package into the chats' skill layer
@@ -318,6 +327,10 @@ func (m *Manager) Status(ctx context.Context, id string) (Status, error) {
s.AddonID = id
s.ObservedHealth = "stopped"
s.StartEligible = s.Installed && s.DesiredEnabled
+ if m.admission(id) != nil {
+ s.StartEligible = false
+ s.ObservedHealth = "unavailable"
+ }
for _, op := range m.journal.Operations {
if op.Command.AddonID == id && op.Status.Phase != string(lifecycleCompleted) && op.Status.Phase != string(lifecycleFailed) {
s.StartEligible = false
@@ -497,6 +510,10 @@ func (m *Manager) submit(ctx context.Context, actor contract.Actor, cmd Lifecycl
// ErrPackage is fixed, so it is safe to show.
return op.Status, fmt.Errorf("%w: %w", ErrOperationFailed, packages.ErrPackage)
}
+ var admission *HostRequirementsRefusal
+ if errors.As(c, &admission) {
+ return op.Status, fmt.Errorf("%w: %w", ErrOperationFailed, admission)
+ }
var typed *FailureCause
if errors.As(c, &typed) {
return op.Status, fmt.Errorf("%w: %w", ErrOperationFailed, typed)
@@ -545,6 +562,14 @@ func (m *Manager) submit(ctx context.Context, actor contract.Actor, cmd Lifecycl
if m.uncertain || m.runtimeRecoveryRequired {
return op.Status, ErrRecoveryRequired
}
+ if e := m.admit(staged.Receipt()); e != nil {
+ op.Status.HostOperations = summaryPointer(m.requests(staged.Receipt()))
+ if discardErr := m.installer.Discard(staged); discardErr != nil {
+ xlog.Warn("Could not discard refused addon stage", "addon", cmd.AddonID, "error", discardErr)
+ }
+ return fail(e)
+ }
+ op.Status.HostOperations = summaryPointer(m.requests(staged.Receipt()))
var e error
if release != nil {
// An explicit upgrade never changes the trusted identity,
@@ -613,6 +638,9 @@ func (m *Manager) submit(ctx context.Context, actor contract.Actor, cmd Lifecycl
}
}
case "enable":
+ if e := m.admission(cmd.AddonID); e != nil {
+ return fail(e)
+ }
// Data that needs recovery is not served: it is restored or purged first.
if m.dataBlocked(cmd.AddonID) {
return fail()
@@ -880,6 +908,7 @@ func (m *Manager) AttachRuntime(ctx context.Context, s *addonruntime.Supervisor)
if m.uncertain || m.runtime != nil || s == nil {
return ErrConflict
}
+ m.hostOpsSet = true // An omitted startup policy is an immutable empty grant set.
m.runtime = s
m.instances = map[string]addonruntime.Instance{}
m.startFailed = map[string]error{}
@@ -902,6 +931,9 @@ func (m *Manager) syncFor(ctx context.Context, id string) error {
// the add-on stopped on a generation that never ran. Without a running
// instance there is nothing to replace and nothing to prove here.
func (m *Manager) startCandidate(ctx context.Context, id string, prev Status, receipt packages.PackageReceipt, number uint64) error {
+ if e := m.admit(receipt); e != nil {
+ return e
+ }
if m.runtime == nil || !prev.Installed || !prev.DesiredEnabled {
return nil
}
@@ -992,6 +1024,10 @@ func (m *Manager) syncRuntime(ctx context.Context, target string) error {
if _, failed := m.startFailed[id]; failed && id != target {
continue
}
+ if e := m.admission(id); e != nil {
+ m.startFailed[id] = e
+ continue
+ }
if m.quiesced[id] || m.dataBlocked(id) {
continue
}
@@ -1048,6 +1084,9 @@ func (m *Manager) Runtime(ctx context.Context, id string) (addonruntime.Instance
return addonruntime.Instance{}, e
}
state := m.journal.Addons[id]
+ if m.admission(id) != nil {
+ return addonruntime.Instance{}, addonruntime.ErrRuntime
+ }
if m.uncertain || m.runtimeRecoveryRequired || m.runtime == nil || !state.Installed || !state.DesiredEnabled {
return addonruntime.Instance{}, addonruntime.ErrRuntime
}
@@ -1145,7 +1184,7 @@ func (m *Manager) OperationStatus(ctx context.Context, actor contract.Actor, id
if !ok || op.Actor.UserID == "" || op.Actor.CredentialID == "" || (op.Actor.Mode != "named-user" && op.Actor.Mode != "local-owner") || op.Actor != actor {
return OperationStatus{}, ErrOperationNotFound
}
- result := OperationStatus{OperationID: id, AddonID: op.Status.AddonID, Phase: op.Status.Phase, Generation: op.Status.Generation, RuntimeState: "not-observed"}
+ result := OperationStatus{OperationID: id, AddonID: op.Status.AddonID, Phase: op.Status.Phase, Generation: op.Status.Generation, RuntimeState: "not-observed", HostOperations: copyRequestSummary(op.Status.HostOperations)}
if m.uncertain || m.runtimeRecoveryRequired {
result.RuntimeState = string(lifecycleRecovery)
}
@@ -1183,6 +1222,12 @@ func (m *Manager) Apps(ctx context.Context, actor contract.Actor) ([]apps.App, e
if st.DesiredEnabled && m.runtime != nil {
snap.State = m.runtime.State(id, st.Generation)
}
+ summary := m.requests(receipt)
+ snap.HostOperations = summaryPointer(summary)
+ if len(summary.MissingRequired) != 0 {
+ snap.Unavailable = true
+ snap.State = "failed"
+ }
// The apps list answers from the check cache only: it never
// evaluates and never reaches a library.
if m.updates != nil {
@@ -1245,7 +1290,7 @@ func (m *Manager) AppAsset(ctx context.Context, actor contract.Actor, id, digest
return packages.PackageReceipt{}, 0, ErrForbidden
}
receipt, ok := currentReceipt(st)
- if !ok || receipt.Manifest.Capabilities.UI == nil || receipt.Digest != "sha256:"+digest {
+ if !ok || m.admit(receipt) != nil || receipt.Manifest.Capabilities.UI == nil || receipt.Digest != "sha256:"+digest {
return packages.PackageReceipt{}, 0, ErrForbidden
}
return receipt, st.Generation, nil
@@ -1303,6 +1348,9 @@ func (m *Manager) DeclaredTools(ctx context.Context, id string, generation uint6
if !ok || !st.Installed || generation == 0 || st.Generation != generation {
return nil, ErrForbidden
}
+ if m.admission(id) != nil {
+ return nil, ErrForbidden
+ }
if c, ok := m.toolCache[id]; ok && c.generation == generation {
return c.tools, nil
}
@@ -1340,10 +1388,24 @@ func (m *Manager) DeclaredCapabilities(ctx context.Context, id string, generatio
if !ok {
return contract.Capabilities{}, ErrForbidden
}
+ if m.admit(receipt) != nil {
+ return contract.Capabilities{}, ErrForbidden
+ }
c := receipt.Manifest.Capabilities
out := contract.Capabilities{Service: c.Service, Tools: append([]string(nil), c.Tools...), AdminTools: append([]string(nil), c.AdminTools...), Actions: c.Actions, Undo: c.Undo}
out.ToolDefinitions = append([]contract.ToolDefinition(nil), c.ToolDefinitions...)
out.SkillDefinitions = append([]contract.SkillDefinition(nil), c.SkillDefinitions...)
+ if c.ExecutionProvider != nil {
+ p := *c.ExecutionProvider
+ p.Tools = slices.Clone(p.Tools)
+ out.ExecutionProvider = &p
+ }
+ if c.HostOperations != nil {
+ h := *c.HostOperations
+ h.Required = slices.Clone(h.Required)
+ h.Optional = slices.Clone(h.Optional)
+ out.HostOperations = &h
+ }
if c.Resources != nil {
r := *c.Resources
out.Resources = &r
diff --git a/core/services/addons/manager_host_ops_test.go b/core/services/addons/manager_host_ops_test.go
new file mode 100644
index 000000000..58740efe5
--- /dev/null
+++ b/core/services/addons/manager_host_ops_test.go
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+package addons
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Manager host grants snapshot", func() {
+ It("does not retain the caller's map or prefix slices", func() {
+ m := &Manager{}
+ grants := map[string][]string{"a": {"workspace."}}
+ m.SetHostOps(grants)
+ grants["a"][0] = "egress."
+ grants["b"] = []string{"workspace."}
+ Expect(m.hostOps).To(Equal(map[string][]string{"a": {"workspace."}}))
+ m.SetHostOps(map[string][]string{})
+ Expect(m.hostOps).To(Equal(map[string][]string{"a": {"workspace."}}))
+ })
+ It("keeps an explicitly empty startup policy immutable", func() {
+ m := &Manager{}
+ m.SetHostOps(map[string][]string{})
+ m.SetHostOps(map[string][]string{"a": {"workspace."}})
+ Expect(m.hostOps).To(BeEmpty())
+ })
+})
diff --git a/core/services/addons/packages/discard_test.go b/core/services/addons/packages/discard_test.go
new file mode 100644
index 000000000..9efeb6883
--- /dev/null
+++ b/core/services/addons/packages/discard_test.go
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: MIT
+package packages
+
+import (
+ "context"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "os"
+)
+
+var _ = Describe("Refused package stages", func() {
+ It("discards only the creating installer's unpublished single use stage", func() {
+ manifest, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json")
+ Expect(e).NotTo(HaveOccurred())
+ i, r := fixture(archive(nil, manifest))
+ staged, e := i.Stage(context.Background(), r, nil)
+ Expect(e).NotTo(HaveOccurred())
+ dir := staged.state.dir
+ other := &Installer{}
+ Expect(other.Discard(staged)).NotTo(Succeed())
+ Expect(dir).To(BeADirectory())
+ Expect(i.Discard(staged)).To(Succeed())
+ Expect(dir).NotTo(BeADirectory())
+ _, e = i.Commit(context.Background(), staged)
+ Expect(e).To(HaveOccurred())
+ Expect(i.Discard(staged)).NotTo(Succeed())
+ })
+})
diff --git a/core/services/addons/packages/extensions_test.go b/core/services/addons/packages/extensions_test.go
new file mode 100644
index 000000000..2eeee02bd
--- /dev/null
+++ b/core/services/addons/packages/extensions_test.go
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: MIT
+package packages
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("typed package declarations", func() {
+ It("persists model actions and typed declarations together separately and absent in verified immutable manifests and receipts", func() {
+ for mask := 0; mask < 16; mask++ {
+ b, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json")
+ Expect(e).NotTo(HaveOccurred())
+ var m contract.Manifest
+ Expect(json.Unmarshal(b, &m)).To(Succeed())
+ if mask&1 != 0 {
+ m.Capabilities.UI = &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 1, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open"}}}
+ }
+ if mask&8 != 0 {
+ if m.Capabilities.UI == nil {
+ m.Capabilities.UI = &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 1, Max: 2}, Pages: []string{"home"}}
+ }
+ m.Capabilities.UI.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}}
+ }
+ if mask&2 != 0 {
+ m.Capabilities.Actions = true
+ m.Capabilities.Tools = []string{"run", "health"}
+ m.Capabilities.ToolDefinitions = []contract.ToolDefinition{{Name: "run", Description: "Run", Kind: "action", Shadows: "bash", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)}, {Name: "health", Description: "Status", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}}
+ m.Capabilities.ExecutionProvider = &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}
+ }
+ if mask&4 != 0 {
+ m.Capabilities.HostOperations = &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}
+ }
+ b, e = json.Marshal(m)
+ Expect(e).NotTo(HaveOccurred())
+ parsed, e := contract.ParseManifest(b)
+ Expect(e).NotTo(HaveOccurred())
+ data := archiveFiles(b, map[string][]byte{"ui.js": []byte("export {}")})
+ i, r := fixture(data)
+ st, e := i.Stage(context.Background(), r, nil)
+ Expect(e).NotTo(HaveOccurred())
+ receipt, e := i.Commit(context.Background(), st)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(receipt.Digest).To(Equal(r.Digest))
+ Expect(receipt.Manifest).To(Equal(parsed))
+ disk, e := os.ReadFile(filepath.Join(receipt.Directory, "receipt.json"))
+ Expect(e).NotTo(HaveOccurred())
+ var loaded PackageReceipt
+ Expect(json.Unmarshal(disk, &loaded)).To(Succeed())
+ Expect(loaded).To(Equal(receipt))
+ manifest, e := os.ReadFile(filepath.Join(receipt.Directory, "manifest.json"))
+ Expect(e).NotTo(HaveOccurred())
+ Expect(manifest).To(Equal(b))
+ if mask == 0 {
+ for _, key := range []string{"homeCommands", "executionProvider", "hostOperations", "modelActions"} {
+ Expect(string(disk)).NotTo(ContainSubstring(key))
+ }
+ }
+ }
+ })
+ It("refuses unsupported typed candidates without changing the previous package bytes digest or receipt", func() {
+ b, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json")
+ Expect(e).NotTo(HaveOccurred())
+ i, r := fixture(archive(nil, b))
+ st, e := i.Stage(context.Background(), r, nil)
+ Expect(e).NotTo(HaveOccurred())
+ prior, e := i.Commit(context.Background(), st)
+ Expect(e).NotTo(HaveOccurred())
+ before, e := os.ReadFile(filepath.Join(prior.Directory, "receipt.json"))
+ Expect(e).NotTo(HaveOccurred())
+ // Unknown extension keys are refused, rather than weakening strict v1.
+ bad := []byte(strings.Replace(string(b), `"service":true`, `"service":true,"executionProvider":{"tools":["run"],"statusTool":"health","future":true}`, 1))
+ candidate, release := fixture(archive(nil, bad))
+ candidate.Root = i.Root
+ _, e = candidate.Stage(context.Background(), release, nil)
+ Expect(e).To(HaveOccurred())
+ after, e := os.ReadFile(filepath.Join(prior.Directory, "receipt.json"))
+ Expect(e).NotTo(HaveOccurred())
+ Expect(after).To(Equal(before))
+ manifest, e := os.ReadFile(filepath.Join(prior.Directory, "manifest.json"))
+ Expect(e).NotTo(HaveOccurred())
+ Expect(manifest).To(Equal(b))
+ Expect(prior.Digest).To(Equal(r.Digest))
+ Expect(prior.Digest).NotTo(Equal(release.Digest))
+ })
+})
diff --git a/core/services/addons/packages/receipt.go b/core/services/addons/packages/receipt.go
index 33c6d59a7..8a159927f 100644
--- a/core/services/addons/packages/receipt.go
+++ b/core/services/addons/packages/receipt.go
@@ -111,3 +111,22 @@ func (i *Installer) Commit(ctx context.Context, s StagedPackage) (PackageReceipt
st.used = true
return receipt, nil
}
+
+// Discard consumes an unpublished stage without publishing an installed receipt.
+// Only its creating installer can remove it; installed packages are untouched.
+func (i *Installer) Discard(s StagedPackage) error {
+ if s.state == nil {
+ return ErrPackage
+ }
+ st := s.state
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ if st.owner != i || st.used {
+ return ErrPackage
+ }
+ if e := os.RemoveAll(st.dir); e != nil {
+ return e
+ }
+ st.used = true
+ return nil
+}
diff --git a/core/services/addons/pins/pins.go b/core/services/addons/pins/pins.go
index 3b263fa88..ec01e60d2 100644
--- a/core/services/addons/pins/pins.go
+++ b/core/services/addons/pins/pins.go
@@ -19,15 +19,6 @@ const HardLimit = 32
// shorter than the time an administrator needs to reinstall after a mistake.
const Retention = 30 * 24 * time.Hour
-// SeedIDs are pinned once, at upgrade, for every account that already exists,
-// so the layout does not change under users who had Studio and Talk in the
-// sidebar. An account created after the upgrade is never seeded.
-var SeedIDs = []string{"studio", "talk"}
-
-// UpgradeSeedName names the one seed run of this release. A later release that
-// seeds something else uses another name.
-const UpgradeSeedName = "studio-talk-v1"
-
// Pin is one row: one user, one add-on, one position. Every field is
// serialised: the file store writes whole Pin values, and a pin without its
// owner or its missing mark would restart the retention window at each reopen.
@@ -40,30 +31,12 @@ type Pin struct {
func (Pin) TableName() string { return "addon_pins" }
-// SeedRun records that a seed ran for the whole deployment.
-type SeedRun struct {
- Name string `gorm:"primaryKey;size:64"`
- At time.Time
-}
-
-func (SeedRun) TableName() string { return "addon_pin_seed_runs" }
-
// Store keeps the pins. Every method is safe for concurrent use.
type Store interface {
List(ctx context.Context, userID string) ([]Pin, error)
Replace(ctx context.Context, userID string, ids []string) error
- SeedExisting(ctx context.Context, userIDs []string, ids []string) (bool, error)
Distinct(ctx context.Context) ([]string, error)
MarkMissing(ctx context.Context, ids []string, now time.Time) error
ClearMissing(ctx context.Context, ids []string) error
DeleteMissingBefore(ctx context.Context, cutoff time.Time) (int64, error)
- // MarkInstalled records, once per ID, that a seeded add-on was present.
- // Installed lists the IDs recorded. A seeded pin does not expire before
- // its add-on was installed once (see Sweep).
- MarkInstalled(ctx context.Context, ids []string) error
- Installed(ctx context.Context) ([]string, error)
}
-
-// installedPrefix starts the name of the record that a seeded add-on was
-// installed. The records share the table of the seed runs.
-const installedPrefix = "installed:"
diff --git a/core/services/addons/pins/service.go b/core/services/addons/pins/service.go
index 1e9b3df2f..d389c0907 100644
--- a/core/services/addons/pins/service.go
+++ b/core/services/addons/pins/service.go
@@ -14,9 +14,7 @@ var ErrTooMany = errors.New("too many pins")
// built-in catalog and the Apps list, so a pin is filtered exactly like a tile.
type Service struct{ Store Store }
-// List returns the caller's visible pins in order. It never seeds: the upgrade
-// seed runs once at start (Store.SeedExisting), for the accounts that exist
-// then, so an account created later starts with no pins.
+// List returns visible pins in order without changing the saved selection.
func (s *Service) List(ctx context.Context, userID string, visible map[string]bool) ([]string, error) {
stored, err := s.Store.List(ctx, userID)
if err != nil {
diff --git a/core/services/addons/pins/service_gorm_test.go b/core/services/addons/pins/service_gorm_test.go
index e5cba9eeb..1821678cd 100644
--- a/core/services/addons/pins/service_gorm_test.go
+++ b/core/services/addons/pins/service_gorm_test.go
@@ -28,19 +28,18 @@ var _ = Describe("Two frontends over one database", func() {
Expect(got).To(Equal([]string{"x", "talk"}), "the second frontend sees a different list")
})
- // Two frontends that start together run the upgrade seed once between them.
- It("seed once when they start together", func() {
+ // Opening a second frontend does not alter a saved selection.
+ It("preserve explicit unpins when another frontend opens", func() {
db, err := auth.InitDB(":memory:")
- Expect(err).ToNot(HaveOccurred())
- a, _ := NewGormStore(db)
- b, _ := NewGormStore(db)
+ Expect(err).NotTo(HaveOccurred())
+ a, err := NewGormStore(db)
+ Expect(err).NotTo(HaveOccurred())
ctx := context.Background()
- ranA, errA := a.SeedExisting(ctx, []string{"u1"}, SeedIDs)
- ranB, errB := b.SeedExisting(ctx, []string{"u1"}, SeedIDs)
- Expect(errA).ToNot(HaveOccurred())
- Expect(errB).ToNot(HaveOccurred())
- Expect(ranA).ToNot(Equal(ranB), "exactly one frontend must seed")
- got, _ := a.List(ctx, "u1")
- Expect(idsOf(got)).To(Equal([]string{"studio", "talk"}), "seeded twice or not at all")
+ Expect(a.Replace(ctx, "u1", []string{"studio", "talk"})).To(Succeed())
+ Expect(a.Replace(ctx, "u1", []string{})).To(Succeed())
+ b, err := NewGormStore(db)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(b.List(ctx, "u1")).To(BeEmpty())
+ Expect(b.List(ctx, "u2")).To(BeEmpty())
})
})
diff --git a/core/services/addons/pins/service_test.go b/core/services/addons/pins/service_test.go
index 0b22112e3..e9438f2c4 100644
--- a/core/services/addons/pins/service_test.go
+++ b/core/services/addons/pins/service_test.go
@@ -37,10 +37,7 @@ var _ = Describe("Service", func() {
})
It("starts a new account with no pins", func() {
- // The upgrade ran earlier for the accounts that existed then.
- ran, err := svc.Store.SeedExisting(ctx, []string{"old"}, SeedIDs)
- Expect(err).ToNot(HaveOccurred())
- Expect(ran).To(BeTrue())
+ Expect(svc.Store.Replace(ctx, "old", []string{"studio", "talk"})).To(Succeed())
got, err := svc.List(ctx, "created-after-the-upgrade", vis("studio", "talk"))
Expect(err).ToNot(HaveOccurred())
Expect(got).To(BeEmpty(), "a new account must start with no pins")
@@ -50,19 +47,11 @@ var _ = Describe("Service", func() {
Expect(slices.Equal(got, []string{"talk"})).To(BeTrue(), "a read or a save must never add a pin of its own: %v", got)
})
- It("pins Studio and Talk once at the upgrade seed", func() {
- ran, err := svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs)
- Expect(err).ToNot(HaveOccurred())
- Expect(ran).To(BeTrue())
- got, err := svc.List(ctx, "u1", vis("studio", "talk"))
- Expect(err).ToNot(HaveOccurred())
- Expect(got).To(Equal([]string{"studio", "talk"}), "an account that existed at the upgrade keeps Studio and Talk")
- _, err = svc.Put(ctx, "u1", []string{"talk"}, vis("studio", "talk"))
- Expect(err).ToNot(HaveOccurred())
- ran, _ = svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs)
- Expect(ran).To(BeFalse(), "the seed ran a second time")
- got, _ = svc.List(ctx, "u1", vis("studio", "talk"))
- Expect(got).To(Equal([]string{"talk"}), "an unpin did not stick")
+ It("keeps explicit pins and unpins on repeated reads", func() {
+ Expect(svc.Store.Replace(ctx, "u1", []string{"studio", "talk"})).To(Succeed())
+ Expect(svc.List(ctx, "u1", vis("studio", "talk"))).To(Equal([]string{"studio", "talk"}))
+ Expect(svc.Put(ctx, "u1", []string{"talk"}, vis("studio", "talk"))).To(Equal([]string{"talk"}))
+ Expect(svc.List(ctx, "u1", vis("studio", "talk"))).To(Equal([]string{"talk"}))
})
It("keeps pins the caller cannot see on Put", func() {
diff --git a/core/services/addons/pins/store_contract_test.go b/core/services/addons/pins/store_contract_test.go
index fda111e42..7b0bfb161 100644
--- a/core/services/addons/pins/store_contract_test.go
+++ b/core/services/addons/pins/store_contract_test.go
@@ -46,35 +46,20 @@ func registerStoreContract(open func() Store) {
Expect(got).To(BeEmpty(), "leak: %v", idsOf(got))
})
- It("the upgrade seed pins existing accounts once and skips later ones", func() {
- ran, err := s.SeedExisting(ctx, []string{"u1", "u2"}, SeedIDs)
- Expect(err).ToNot(HaveOccurred())
- Expect(ran).To(BeTrue())
- for _, u := range []string{"u1", "u2"} {
- got, _ := s.List(ctx, u)
- Expect(idsOf(got)).To(Equal([]string{"studio", "talk"}), "account %s that existed at the upgrade must have both pins", u)
- }
- // An account created after the upgrade has no pins.
- got, _ := s.List(ctx, "u3")
- Expect(got).To(BeEmpty(), "a later account must start empty")
- _ = s.Replace(ctx, "u1", []string{"talk"}) // u1 unpins studio
- again, err := s.SeedExisting(ctx, []string{"u1", "u2", "u3"}, SeedIDs)
- Expect(err).ToNot(HaveOccurred())
- Expect(again).To(BeFalse(), "the seed ran twice")
- got, _ = s.List(ctx, "u1")
- Expect(idsOf(got)).To(Equal([]string{"talk"}), "an unpin must stick")
- got, _ = s.List(ctx, "u3")
- Expect(got).To(BeEmpty(), "a second run must not seed a later account")
+ It("starts all accounts empty until explicit replacement", func() {
+ Expect(s.List(ctx, "u1")).To(BeEmpty())
+ Expect(s.Replace(ctx, "u1", []string{"studio", "talk"})).To(Succeed())
+ Expect(s.List(ctx, "u2")).To(BeEmpty())
+ Expect(s.Replace(ctx, "u1", []string{"talk"})).To(Succeed())
+ got, err := s.List(ctx, "u1")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(idsOf(got)).To(Equal([]string{"talk"}))
})
- It("a deployment with no account still records the seed, so a later account starts empty", func() {
- ran, err := s.SeedExisting(ctx, nil, SeedIDs)
- Expect(err).ToNot(HaveOccurred())
- Expect(ran).To(BeTrue(), "an empty first run must count")
- again, _ := s.SeedExisting(ctx, []string{"late"}, SeedIDs)
- Expect(again).To(BeFalse(), "the seed must not run after the first call")
- got, _ := s.List(ctx, "late")
- Expect(got).To(BeEmpty(), "a new account must start with no pins")
+ It("keeps an explicit empty selection empty", func() {
+ Expect(s.Replace(ctx, "u1", []string{"studio"})).To(Succeed())
+ Expect(s.Replace(ctx, "u1", []string{})).To(Succeed())
+ Expect(s.List(ctx, "u1")).To(BeEmpty())
})
It("missing pins are marked, cleared and deleted after the window", func() {
@@ -118,13 +103,16 @@ func registerStoreContract(open func() Store) {
Expect(d).To(Equal([]string{"a", "b", "c"}))
})
- It("records once that a seeded add-on was installed, apart from the seed run", func() {
- Expect(s.Installed(ctx)).To(BeEmpty())
- Expect(s.MarkInstalled(ctx, []string{"talk", "studio"})).To(Succeed())
- Expect(s.MarkInstalled(ctx, []string{"studio"})).To(Succeed())
- Expect(s.Installed(ctx)).To(Equal([]string{"studio", "talk"}))
- ran, err := s.SeedExisting(ctx, []string{"u1"}, SeedIDs)
- Expect(err).ToNot(HaveOccurred())
- Expect(ran).To(BeTrue(), "an install record is not the seed run")
+ It("applies equal retention to never-installed IDs in every store", func() {
+ Expect(s.Replace(ctx, "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed())
+ now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ Expect(Sweep(ctx, s, []string{}, now)).To(BeZero())
+ got, err := s.List(ctx, "u1")
+ Expect(err).NotTo(HaveOccurred())
+ for _, pin := range got {
+ Expect(pin.MissingSince).To(Equal(&now))
+ }
+ Expect(Sweep(ctx, s, []string{}, now.Add(Retention))).To(BeZero())
+ Expect(Sweep(ctx, s, []string{}, now.Add(Retention+time.Second))).To(BeEquivalentTo(3))
})
}
diff --git a/core/services/addons/pins/store_file.go b/core/services/addons/pins/store_file.go
index 6e3693f66..6f7b9ef9f 100644
--- a/core/services/addons/pins/store_file.go
+++ b/core/services/addons/pins/store_file.go
@@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"slices"
- "strings"
"sync"
"time"
)
@@ -23,12 +22,11 @@ type FileStore struct {
}
type fileState struct {
- Pins map[string][]Pin `json:"pins"`
- SeedRuns map[string]time.Time `json:"seedRuns"`
+ Pins map[string][]Pin `json:"pins"`
}
func NewFileStore(path string) (*FileStore, error) {
- s := &FileStore{path: path, st: fileState{Pins: map[string][]Pin{}, SeedRuns: map[string]time.Time{}}}
+ s := &FileStore{path: path, st: fileState{Pins: map[string][]Pin{}}}
if path == "" {
return s, nil
}
@@ -45,9 +43,6 @@ func NewFileStore(path string) (*FileStore, error) {
if s.st.Pins == nil {
s.st.Pins = map[string][]Pin{}
}
- if s.st.SeedRuns == nil {
- s.st.SeedRuns = map[string]time.Time{}
- }
return s, nil
}
@@ -97,29 +92,6 @@ func rebuild(old []Pin, userID string, ids []string) []Pin {
return out
}
-func (s *FileStore) SeedExisting(_ context.Context, userIDs []string, ids []string) (bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if _, done := s.st.SeedRuns[UpgradeSeedName]; done {
- return false, nil
- }
- for _, userID := range userIDs {
- have := map[string]bool{}
- list := s.st.Pins[userID]
- for _, p := range list {
- have[p.AddonID] = true
- }
- for _, id := range ids {
- if !have[id] {
- list = append(list, Pin{UserID: userID, AddonID: id, Position: len(list)})
- }
- }
- s.st.Pins[userID] = list
- }
- s.st.SeedRuns[UpgradeSeedName] = time.Now().UTC()
- return true, s.save()
-}
-
func (s *FileStore) Distinct(_ context.Context) ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -185,32 +157,3 @@ func (s *FileStore) DeleteMissingBefore(_ context.Context, cutoff time.Time) (in
}
return n, s.save()
}
-
-func (s *FileStore) MarkInstalled(_ context.Context, ids []string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- changed := false
- for _, id := range ids {
- if _, done := s.st.SeedRuns[installedPrefix+id]; !done {
- s.st.SeedRuns[installedPrefix+id] = time.Now().UTC()
- changed = true
- }
- }
- if !changed {
- return nil
- }
- return s.save()
-}
-
-func (s *FileStore) Installed(_ context.Context) ([]string, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- var out []string
- for name := range s.st.SeedRuns {
- if id, ok := strings.CutPrefix(name, installedPrefix); ok {
- out = append(out, id)
- }
- }
- slices.Sort(out)
- return out, nil
-}
diff --git a/core/services/addons/pins/store_file_test.go b/core/services/addons/pins/store_file_test.go
index 67f2f2364..d41ebfa94 100644
--- a/core/services/addons/pins/store_file_test.go
+++ b/core/services/addons/pins/store_file_test.go
@@ -31,15 +31,16 @@ var _ = Describe("File store", func() {
ctx := context.Background()
path := filepath.Join(GinkgoT().TempDir(), "pins.json")
a, _ := NewFileStore(path)
- ran, _ := a.SeedExisting(ctx, []string{"local-owner"}, SeedIDs)
- Expect(ran).To(BeTrue(), "first seed must run")
+ Expect(a.Replace(ctx, "local-owner", []string{"studio", "talk"})).To(Succeed())
_ = a.Replace(ctx, "local-owner", []string{"talk"})
b, err := NewFileStore(path)
Expect(err).ToNot(HaveOccurred())
got, _ := b.List(ctx, "local-owner")
Expect(idsOf(got)).To(Equal([]string{"talk"}), "pins lost across a restart")
- again, _ := b.SeedExisting(ctx, []string{"local-owner"}, SeedIDs)
- Expect(again).To(BeFalse(), "the seed marker must survive a restart")
+ Expect(b.Replace(ctx, "local-owner", []string{})).To(Succeed())
+ c, err := NewFileStore(path)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(c.List(ctx, "local-owner")).To(BeEmpty())
})
// The retention window of a removed add-on must not restart at each reopen.
diff --git a/core/services/addons/pins/store_gorm.go b/core/services/addons/pins/store_gorm.go
index b9cebb030..8d5fa2f83 100644
--- a/core/services/addons/pins/store_gorm.go
+++ b/core/services/addons/pins/store_gorm.go
@@ -4,13 +4,10 @@ package pins
import (
"context"
"fmt"
- "slices"
- "strings"
"time"
"github.com/mudler/LocalAI/core/services/advisorylock"
"gorm.io/gorm"
- "gorm.io/gorm/clause"
)
// GormStore keeps the pins in the account database, which every frontend of a
@@ -21,7 +18,7 @@ func NewGormStore(db *gorm.DB) (*GormStore, error) {
// Several frontends migrate the shared database at startup: take the
// schema-migrate lock like every other shared table does.
if err := advisorylock.WithLockCtx(context.Background(), db, advisorylock.KeySchemaMigrate, func() error {
- return db.AutoMigrate(&Pin{}, &SeedRun{})
+ return db.AutoMigrate(&Pin{})
}); err != nil {
return nil, fmt.Errorf("migrating addon pin tables: %w", err)
}
@@ -51,44 +48,6 @@ func (s *GormStore) Replace(ctx context.Context, userID string, ids []string) er
})
}
-// SeedExisting inserts the marker row first. Only the call that inserts it
-// seeds, so several frontends that start together seed once between them.
-func (s *GormStore) SeedExisting(ctx context.Context, userIDs []string, ids []string) (bool, error) {
- ran := false
- err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
- res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&SeedRun{Name: UpgradeSeedName, At: time.Now().UTC()})
- if res.Error != nil {
- return res.Error
- }
- if res.RowsAffected == 0 {
- return nil
- }
- ran = true
- for _, userID := range userIDs {
- var have []Pin
- if err := tx.Where("user_id = ?", userID).Find(&have).Error; err != nil {
- return err
- }
- held := map[string]bool{}
- for _, p := range have {
- held[p.AddonID] = true
- }
- next := len(have)
- for _, id := range ids {
- if held[id] {
- continue
- }
- if err := tx.Create(&Pin{UserID: userID, AddonID: id, Position: next}).Error; err != nil {
- return err
- }
- next++
- }
- }
- return nil
- })
- return ran, err
-}
-
func (s *GormStore) Distinct(ctx context.Context) ([]string, error) {
var out []string
err := s.db.WithContext(ctx).Model(&Pin{}).Distinct().Pluck("addon_id", &out).Error
@@ -113,26 +72,3 @@ func (s *GormStore) DeleteMissingBefore(ctx context.Context, cutoff time.Time) (
res := s.db.WithContext(ctx).Where("missing_since IS NOT NULL AND missing_since < ?", cutoff).Delete(&Pin{})
return res.RowsAffected, res.Error
}
-
-func (s *GormStore) MarkInstalled(ctx context.Context, ids []string) error {
- for _, id := range ids {
- err := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&SeedRun{Name: installedPrefix + id, At: time.Now().UTC()}).Error
- if err != nil {
- return err
- }
- }
- return nil
-}
-
-func (s *GormStore) Installed(ctx context.Context) ([]string, error) {
- var names []string
- if err := s.db.WithContext(ctx).Model(&SeedRun{}).Where("name LIKE ?", installedPrefix+"%").Pluck("name", &names).Error; err != nil {
- return nil, err
- }
- out := make([]string, 0, len(names))
- for _, n := range names {
- out = append(out, strings.TrimPrefix(n, installedPrefix))
- }
- slices.Sort(out)
- return out, nil
-}
diff --git a/core/services/addons/pins/sweep.go b/core/services/addons/pins/sweep.go
index c68207e54..6e4897123 100644
--- a/core/services/addons/pins/sweep.go
+++ b/core/services/addons/pins/sweep.go
@@ -11,28 +11,10 @@ import (
// right now: the built-in IDs and every installed add-on, enabled or not. A
// nil present means the owner did not answer, and then nothing changes: an
// outage must never expire a pin.
-//
-// A seeded pin (SeedIDs) is kept while its add-on was never installed: the
-// upgrade pins Studio and Talk before an administrator installs them, and
-// that may take longer than Retention. Once the add-on was present, its pin
-// follows the rule like any other.
func Sweep(ctx context.Context, store Store, present []string, now time.Time) (int64, error) {
if present == nil {
return 0, nil
}
- var seen []string
- for _, id := range SeedIDs {
- if slices.Contains(present, id) {
- seen = append(seen, id)
- }
- }
- if err := store.MarkInstalled(ctx, seen); err != nil {
- return 0, err
- }
- installed, err := store.Installed(ctx)
- if err != nil {
- return 0, err
- }
pinned, err := store.Distinct(ctx)
if err != nil {
return 0, err
@@ -42,10 +24,6 @@ func Sweep(ctx context.Context, store Store, present []string, now time.Time) (i
switch {
case slices.Contains(present, id):
back = append(back, id)
- case slices.Contains(SeedIDs, id) && !slices.Contains(installed, id):
- // Not installed yet: no clock runs, and a mark an older sweep
- // set is cleared.
- back = append(back, id)
default:
gone = append(gone, id)
}
diff --git a/core/services/addons/pins/sweep_test.go b/core/services/addons/pins/sweep_test.go
index 6fece9159..3f3589fda 100644
--- a/core/services/addons/pins/sweep_test.go
+++ b/core/services/addons/pins/sweep_test.go
@@ -20,6 +20,44 @@ var _ = Describe("Sweep", func() {
s, _ = NewFileStore("")
})
+ It("keeps present IDs regardless of enablement and treats nil as a complete no-op", func() {
+ Expect(s.Replace(ctx, "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed())
+ now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ Expect(s.MarkMissing(ctx, []string{"studio", "talk", "arbitrary"}, now)).To(Succeed())
+ Expect(Sweep(ctx, s, nil, now.Add(2*Retention))).To(BeZero())
+ got, err := s.List(ctx, "u1")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(HaveLen(3))
+ for _, pin := range got {
+ Expect(pin.MissingSince).To(Equal(&now))
+ }
+ // Presence includes installed but disabled add-ons; enablement is not an input.
+ Expect(Sweep(ctx, s, []string{"studio", "talk", "arbitrary"}, now.Add(2*Retention))).To(BeZero())
+ got, err = s.List(ctx, "u1")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(HaveLen(3))
+ for _, pin := range got {
+ Expect(pin.MissingSince).To(BeNil())
+ }
+ })
+
+ It("applies equal retention to never-installed Studio Talk and arbitrary IDs", func() {
+ Expect(s.Replace(ctx, "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed())
+ now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ n, err := Sweep(ctx, s, []string{}, now)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(n).To(BeZero())
+ got, err := s.List(ctx, "u1")
+ Expect(err).NotTo(HaveOccurred())
+ for _, pin := range got {
+ Expect(pin.MissingSince).To(Equal(&now), pin.AddonID)
+ }
+ n, err = Sweep(ctx, s, []string{}, now.Add(Retention+time.Second))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(n).To(BeEquivalentTo(3))
+ Expect(s.List(ctx, "u1")).To(BeEmpty())
+ })
+
It("keeps a removed pin for thirty days and restores it on reinstall", func() {
_ = s.Replace(ctx, "u1", []string{"studio", "notes"})
t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
@@ -54,46 +92,17 @@ var _ = Describe("Sweep", func() {
})
})
-var _ = Describe("Sweep of the seeded pins", func() {
- ctx := context.Background()
- t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
- day := 24 * time.Hour
-
- It("keeps a seeded pin while its add-on was never installed, then applies the rule", func() {
- s, _ := NewFileStore("")
- _, err := s.SeedExisting(ctx, []string{"u1"}, SeedIDs)
- Expect(err).ToNot(HaveOccurred())
- // Ninety days with Studio and Talk not installed: nothing expires.
- for d := 0; d <= 90; d += 10 {
- n, err := Sweep(ctx, s, []string{"notes"}, t0.Add(time.Duration(d)*day))
- Expect(err).ToNot(HaveOccurred())
- Expect(n).To(BeZero(), "day %d", d)
- }
- got, _ := s.List(ctx, "u1")
- Expect(idsOf(got)).To(Equal([]string{"studio", "talk"}))
- for _, p := range got {
- Expect(p.MissingSince).To(BeNil(), p.AddonID)
- }
-
- // Studio is installed, then removed: from then on its pin expires
- // after Retention. Talk, never installed, stays.
- _, _ = Sweep(ctx, s, []string{"studio"}, t0.Add(100*day))
- Expect(s.Installed(ctx)).To(Equal([]string{"studio"}))
- _, _ = Sweep(ctx, s, []string{"notes"}, t0.Add(101*day))
- n, _ := Sweep(ctx, s, []string{"notes"}, t0.Add(101*day).Add(Retention).Add(time.Hour))
- Expect(n).To(BeEquivalentTo(1))
- got, _ = s.List(ctx, "u1")
- Expect(idsOf(got)).To(Equal([]string{"talk"}))
- })
-
- It("clears the mark an older sweep set on a seeded pin that was never installed", func() {
- s, _ := NewFileStore("")
- _ = s.Replace(ctx, "u1", []string{"talk"})
- Expect(s.MarkMissing(ctx, []string{"talk"}, t0)).To(Succeed())
- _, err := Sweep(ctx, s, []string{"notes"}, t0.Add(day))
- Expect(err).ToNot(HaveOccurred())
- got, _ := s.List(ctx, "u1")
- Expect(got).To(HaveLen(1))
- Expect(got[0].MissingSince).To(BeNil())
+var _ = Describe("Sweep retention marks", func() {
+ It("preserves a never-installed pin's existing missing mark", func() {
+ ctx := context.Background()
+ s, err := NewFileStore("")
+ Expect(err).NotTo(HaveOccurred())
+ now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ Expect(s.Replace(ctx, "u1", []string{"talk"})).To(Succeed())
+ Expect(s.MarkMissing(ctx, []string{"talk"}, now)).To(Succeed())
+ Expect(Sweep(ctx, s, []string{}, now.Add(time.Hour))).To(BeZero())
+ got, err := s.List(ctx, "u1")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got[0].MissingSince).To(Equal(&now))
})
})
diff --git a/core/services/addons/recovery.go b/core/services/addons/recovery.go
index 305fb03f5..3a81a0846 100644
--- a/core/services/addons/recovery.go
+++ b/core/services/addons/recovery.go
@@ -133,6 +133,9 @@ func (h *managerHost) Commit(ctx context.Context, candidate recovery.Candidate)
}
defer h.lock()()
m := h.m
+ if e := m.admit(staged.Receipt()); e != nil {
+ return packages.PackageReceipt{}, e
+ }
receipt, e := m.installer.Commit(ctx, staged)
if e != nil {
return packages.PackageReceipt{}, e
@@ -152,6 +155,9 @@ func (h *managerHost) Commit(ctx context.Context, candidate recovery.Candidate)
func (h *managerHost) StartCandidate(ctx context.Context, id string, receipt packages.PackageReceipt, number uint64) error {
defer h.lock()()
m := h.m
+ if e := m.admit(receipt); e != nil {
+ return e
+ }
if m.runtime == nil {
return nil
}
diff --git a/core/services/addons/runtime/OBSERVATIONS.md b/core/services/addons/runtime/OBSERVATIONS.md
new file mode 100644
index 000000000..01d80ccb0
--- /dev/null
+++ b/core/services/addons/runtime/OBSERVATIONS.md
@@ -0,0 +1,105 @@
+# Host observation boundary (T8–T9)
+
+`(*Client).ReadObservation(ctx, envelope, requestID)` is the dedicated host-only
+status read API. It returns only a validated `contract.ExecutionStatus`; errors
+return unknown/unknown and never return raw results or output handles. The read
+is bounded by the parent deadline, five seconds and the runtime read timeout.
+
+## Authenticated owner read (T9)
+
+`Gateway.ExecutionStatusRead(ctx, actor, request)` is the host-only owner API.
+The versioned request carries the observation ID and every component of binding B:
+authenticated actor, addon, generation, incarnation, execution tool, status tool,
+conversation and current provider-selection identity. The response echoes that identity with validated status.
+No request field carries arguments, grants, administrator rights, or an operation ID.
+The owner generates the runtime operation ID independently of the observation ID.
+The wire envelope uses `{"tool":"declared-status-tool"}` for actual status input `{}`.
+
+The owner requires `AuthorizeExecutionStatus` on every admission and before publishing a reply.
+This host callback must check current selection, execution feature permission,
+addon access, and authority to replace the builtin provider. An absent callback denies reads.
+The addon admission checks resource-independent account access. The verified manifest
+must bind the execution action to this exact resourceless, `uiOnly` status read.
+`Manager.Runtime` also checks installed/enabled lifecycle and required host operations.
+The status path does not call `ToolAdapter.Execute` or change its `uiOnly` exclusion.
+
+The owner captures one actual client. `Client.AdmitObservation(generation, incarnation)`
+checks identity and accepting state under the same mutex that serializes withdrawal.
+Its idempotent lease pins that client through dispatch and consumption without holding
+manager or supervisor locks across network I/O. Observation leases do not consume the
+ordinary request concurrency allowance. Withdrawal rejects later admissions and cancels
+pending observations. A replacement never receives a request bound to the old client.
+The owner checks current lifecycle, client identity, selection, and permission again
+before returning a positive value. Errors return unknown/unknown.
+
+The existing authenticated mTLS carrier adds `execution-status-read-v1` as a typed call.
+Its request and response DTOs have their own version 1; addon protocol v1 remains unchanged.
+The carrier rejects mixed call variants, identity mismatches, unknown fields, duplicate
+keys, and noncanonical keys. Transport capability support cannot grant selection authority.
+Older owners without this call return unavailable. Reads have no retries or positive cache.
+
+## T10 integration responsibilities
+
+Generate the observation ID in the host request shell, never from addon or browser input.
+Share one observation within that enclosing request. T9 does not implement coalescing
+or the reducer shell. Cancel the parent context when the request closes or authority changes.
+The owner and runtime independently cap each read at five seconds.
+Recheck the current binding when consuming a returned observation, including after carrier delivery.
+A reply can become stale after the owner's final check; it is never execution authorization.
+Application integration must configure the owner callback from authoritative host selection
+and permission state. Until then, the default path denies observations.
+
+The client records observation ownership in the authoritative in-flight entry.
+The incarnation-authenticated broker checks it before dispatching any operation
+class (including inbound), without modifying grants. A denied callback invalidates
+the status reply. Outputs and unknown payload fields, including artifact handles,
+are rejected without opening, adopting or publishing anything. Independently
+admitted concurrent ordinary reads/actions retain their normal authority.
+
+Live records and cancellation registrations are removed idempotently on completion,
+error, timeout, cancellation or withdrawal. Retired correlations are kept until
+the incarnation's callback endpoint stops admission and all admitted HTTP
+handlers drain. `http.Server.Close` alone does not wait for active handlers.
+The supervisor stops admission, closes the server to cancel network requests,
+drains admitted requests, then forgets correlations. Pending decoding requests
+retain their denial. Dispatch admission is serialized with endpoint Stop for
+all scopes, without holding a lifecycle mutex across a handler or drain.
+Pre-Stop admissions may finish, but retirement cannot complete underneath them.
+This prevents a late callback from recovering credential-only inbound authority.
+Retention is bounded to 4096 records per InFlight index; observation admission
+fails closed at capacity, rather than
+evicting a denial and restoring authority. Ordinary work is not capacity-limited
+by this observation index. A long-lived runtime reaching the limit needs restart
+before further observations; no cross-request positive result cache exists.
+
+Stop never waits for handlers. Drain runs in the endpoint cleanup goroutine,
+not in process termination or under a supervisor lock. A handler that ignores
+cancellation can delay its endpoint's drain and reclamation indefinitely, but
+cannot restore admission or block another endpoint. Retained correlations stay
+within the same 4096-record bound; no global closed-incarnation tombstones are
+created. The endpoint and its cleanup goroutine remain until its handlers exit.
+
+This is a host-mediated effect boundary, not a process sandbox or purity
+attestation. An addon may still mutate its own memory/files or perform its own I/O.
+Uncorrelated independently authorized inbound work remains ordinary inbound work;
+correlation is not evidence about arbitrary process behavior.
+
+## Owner binding API (bounded transport milestone)
+
+`Gateway.BindingForActor(ctx, actor, ExecutionBindingClaim{ConversationID: id})`
+accepts only a conversation claim. The optional `ResolveExecutionBinding` callback
+runs on the owner and must load actor-owned authoritative context. A missing
+resolver denies the request. `ValidateExecutionBinding` resolves the claim again
+and compares every binding component. The optional ToolAdapter backend forwards
+these host-only calls; it does not change discovery or ordinary execution.
+
+The authenticated carrier negotiates `execution-binding-v1`, uses version 1
+canonical-key DTOs, and rejects authority fields and mixed dispatch doors.
+`conversationId` is optional in the older observation binding DTO for compatibility,
+but is required for the new binding API. Binding lookup has a five-second budget.
+Transport support is not execution authority or permission to observe.
+
+This milestone tests an injected owner-trusted resolver, including real mTLS.
+It does not install an application resolver, instrument agent/conversation stores,
+or connect request/session lifecycle hooks. Product binding remains denied until
+an owner resolver can capture authoritative mutation revisions and eligibility.
diff --git a/core/services/addons/runtime/client.go b/core/services/addons/runtime/client.go
index 68277bda0..97bd69f8e 100644
--- a/core/services/addons/runtime/client.go
+++ b/core/services/addons/runtime/client.go
@@ -26,6 +26,8 @@ type Client struct {
mu sync.Mutex
accept bool
active int
+ observationLeases int
+ observations map[string]context.CancelFunc
endpoint, credential string
registration Registration
limits contract.Limits
@@ -45,16 +47,23 @@ func newClient(r Registration, credential string, l contract.Limits) (*Client, e
t := httpclient.HardenedTransport()
t.Proxy = nil
t.MaxResponseHeaderBytes = 16 << 10
- return &Client{endpoint: r.Endpoint, credential: credential, registration: r, limits: l, http: httpclient.New(httpclient.WithTransport(t))}, nil
+ return &Client{inflight: hostops.NewInFlight(), endpoint: r.Endpoint, credential: credential, registration: r, limits: l, http: httpclient.New(httpclient.WithTransport(t))}, nil
}
func (c *Client) admitting() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.accept }
-func (c *Client) withdraw() { c.mu.Lock(); c.accept = false; c.mu.Unlock() }
+func (c *Client) withdraw() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.accept = false
+ for _, cancel := range c.observations {
+ cancel()
+ }
+}
func (c *Client) drain(ctx context.Context) {
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for {
c.mu.Lock()
- n := c.active
+ n := c.active + c.observationLeases
c.mu.Unlock()
if n == 0 {
return
@@ -185,9 +194,9 @@ func (c *Client) begin(ctx context.Context, env *contract.Envelope) func() {
return func() {}
}
if b, ok := hostops.BindingOf(ctx); ok {
- return c.inflight.BeginBound(*env, b)
+ return c.inflight.BeginRuntime(*env, b, true, c.registration.Incarnation)
}
- return c.inflight.Begin(*env)
+ return c.inflight.BeginRuntime(*env, hostops.Binding{}, false, c.registration.Incarnation)
}
func (c *Client) call(ctx context.Context, path string, env *contract.Envelope, in, out any, timeout time.Duration) error {
done, e := c.admit()
diff --git a/core/services/addons/runtime/extensions_test.go b/core/services/addons/runtime/extensions_test.go
new file mode 100644
index 000000000..e2be183c3
--- /dev/null
+++ b/core/services/addons/runtime/extensions_test.go
@@ -0,0 +1,134 @@
+//go:build linux
+
+// SPDX-License-Identifier: MIT
+package runtime
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/addons/packages"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func typedCapabilities() contract.Capabilities {
+ return contract.Capabilities{Service: true, Actions: true, Tools: []string{"run", "health"}, ToolDefinitions: []contract.ToolDefinition{{Name: "run", Description: "Run", Kind: "action", Shadows: "bash", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)}, {Name: "health", Description: "Status", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}}, UI: &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open"}}, ModelActions: []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}}}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, HostOperations: &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}}
+}
+
+var _ = Describe("typed runtime declarations", func() {
+ It("requires exact typed capability equality on authenticated registration health", func() {
+ original := typedCapabilities()
+ for _, change := range []func(*contract.Capabilities){func(c *contract.Capabilities) { c.UI.ModelActions[0].Path = "changed" }, func(c *contract.Capabilities) { c.UI.ModelActions = nil }, func(c *contract.Capabilities) { c.UI.HomeCommands[0].ID = "changed" }, func(c *contract.Capabilities) { c.ExecutionProvider.StatusTool = "other" }, func(c *contract.Capabilities) { c.HostOperations.Required = []string{"other.op"} }, func(c *contract.Capabilities) { c.UI.HomeCommands = nil }, func(c *contract.Capabilities) { c.ExecutionProvider = nil }, func(c *contract.Capabilities) { c.HostOperations = nil }} {
+ var echo Registration
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer GinkgoRecover()
+ Expect(r.Header.Get("Authorization")).To(Equal("Bearer " + strings.Repeat("a", 64)))
+ Expect(r.Header.Get("X-LocalAI-Incarnation")).To(Equal("inc"))
+ Expect(json.NewEncoder(w).Encode(echo)).To(Succeed())
+ }))
+ expected := Registration{AddonID: "sample", Generation: 7, Incarnation: "inc", Protocol: 1, Endpoint: srv.URL, Capabilities: original}
+ client, e := newClient(expected, strings.Repeat("a", 64), contract.DefaultLimits())
+ Expect(e).NotTo(HaveOccurred())
+ echo = expected
+ Expect(client.health(context.Background(), expected)).To(Succeed())
+ b, e := json.Marshal(expected)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(json.Unmarshal(b, &echo)).To(Succeed())
+ change(&echo.Capabilities)
+ Expect(client.health(context.Background(), expected)).To(MatchError(ErrRuntime))
+ srv.Close()
+ }
+ })
+ It("keeps all typed declarations on automatic crash restart with a fresh incarnation", func() {
+ s := supervisor()
+ caps := typedCapabilities()
+ i, e := s.Start(context.Background(), fixture("normal", caps), 9)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(i.process.cmd.Process.Kill()).To(Succeed())
+ Eventually(func() string {
+ regs, e := s.Registry.Snapshot(context.Background())
+ if e != nil || len(regs) != 1 {
+ return ""
+ }
+ return regs[0].Incarnation
+ }, 3*time.Second).Should(And(Not(BeEmpty()), Not(Equal(i.Incarnation))))
+ regs, e := s.Registry.Snapshot(context.Background())
+ Expect(e).NotTo(HaveOccurred())
+ Expect(regs).To(HaveLen(1))
+ Expect(regs[0].Capabilities).To(Equal(caps))
+ regs[0].Capabilities.UI.ModelActions[0].Path = "mutated"
+ fresh, e := s.Registry.Snapshot(context.Background())
+ Expect(e).NotTo(HaveOccurred())
+ Expect(fresh[0].Capabilities).To(Equal(caps))
+ Expect(regs[0].Generation).To(Equal(uint64(9)))
+ current, e := s.Current(context.Background(), "sample", 9)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(current.Client.(*Client).health(context.Background(), fresh[0])).To(Succeed())
+ })
+
+ It("replays typed and legacy receipts across process restarts", func() {
+ s := supervisor()
+ caps := typedCapabilities()
+ prior := fixture("normal", caps)
+ for _, p := range []packages.PackageReceipt{prior, fixture("normal", service), prior} {
+ b, e := os.ReadFile(filepath.Join(p.Directory, "receipt.json"))
+ Expect(e).NotTo(HaveOccurred())
+ var loaded packages.PackageReceipt
+ Expect(json.Unmarshal(b, &loaded)).To(Succeed())
+ Expect(loaded).To(Equal(p))
+ i, e := s.Start(context.Background(), loaded, 7)
+ Expect(e).NotTo(HaveOccurred())
+ first := i.Incarnation
+ regs, e := s.Registry.Snapshot(context.Background())
+ Expect(e).NotTo(HaveOccurred())
+ Expect(regs).To(HaveLen(1))
+ Expect(regs[0].Capabilities).To(Equal(p.Manifest.Capabilities))
+ Expect(regs[0].Generation).To(Equal(uint64(7)))
+ Expect(s.Stop(context.Background(), i)).To(Succeed())
+ i, e = s.Start(context.Background(), loaded, 7)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(i.Incarnation).NotTo(Equal(first))
+ regs, e = s.Registry.Snapshot(context.Background())
+ Expect(e).NotTo(HaveOccurred())
+ Expect(regs[0].Capabilities).To(Equal(p.Manifest.Capabilities))
+ Expect(s.Stop(context.Background(), i)).To(Succeed())
+ }
+ })
+})
+
+var _ = Describe("supervisor observation capture ordering", func() {
+ It("cannot observe a restarted same-generation process through the captured client", func() {
+ ctx := context.Background()
+ s := supervisor()
+ p := fixture("normal", typedCapabilities())
+ old, e := s.Start(ctx, p, 9)
+ Expect(e).NotTo(HaveOccurred())
+ captured, e := s.Current(ctx, "sample", 9)
+ Expect(e).NotTo(HaveOccurred())
+ client := captured.Client.(*Client)
+ release, e := client.AdmitObservation(captured.Generation, captured.Incarnation)
+ Expect(e).NotTo(HaveOccurred())
+ release()
+ Expect(s.Stop(ctx, old)).To(Succeed())
+ replacement, e := s.Start(ctx, p, 9)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(replacement.Incarnation).NotTo(Equal(captured.Incarnation))
+ Expect(replacement.Client).NotTo(BeIdenticalTo(captured.Client))
+ _, e = client.AdmitObservation(9, captured.Incarnation)
+ Expect(e).To(HaveOccurred())
+ env := contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 9, OperationID: "old-observation", Action: "read", Arguments: json.RawMessage(`{"tool":"health"}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "k", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Second)}}
+ value, e := client.ReadObservation(ctx, env, "q")
+ Expect(e).To(MatchError(ErrNotSent))
+ Expect(value.Readiness).To(Equal("unknown"))
+ _, e = replacement.Client.(*Client).AdmitObservation(9, captured.Incarnation)
+ Expect(e).To(HaveOccurred())
+ })
+})
diff --git a/core/services/addons/runtime/observation.go b/core/services/addons/runtime/observation.go
new file mode 100644
index 000000000..35693dfc2
--- /dev/null
+++ b/core/services/addons/runtime/observation.go
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: MIT
+package runtime
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "sync"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+// ReadObservation is the host-only status read boundary. The owner must first
+// authorize the actor/tool and capture this exact Client, generation and
+// incarnation. It does not grant access to uiOnly tools or admit a provider.
+func (c *Client) ReadObservation(ctx context.Context, env contract.Envelope, requestID string) (contract.ExecutionStatus, error) {
+ unknown := contract.ExecutionStatus{Readiness: "unknown", Isolation: "unknown"}
+ // This narrow host path accepts only the named-tool envelope for actual {} input.
+ var args map[string]string
+ if json.Unmarshal(env.Request.Arguments, &args) != nil || len(args) != 1 || !contract.ValidName(args["tool"]) {
+ return unknown, ErrNotSent
+ }
+ canonical, _ := json.Marshal(args)
+ if !bytes.Equal(bytes.TrimSpace(env.Request.Arguments), canonical) {
+ return unknown, ErrNotSent
+ }
+ if requestID == "" || len(requestID) > 256 || c.inflight == nil || c.valid(env, "read") != nil {
+ return unknown, ErrNotSent
+ }
+ done, err := c.admit()
+ if err != nil {
+ return unknown, err
+ }
+ defer done()
+ binding, _ := hostops.BindingOf(ctx)
+ end, denied, err := c.inflight.BeginObservation(env, binding, c.registration.Incarnation, requestID)
+ if err != nil {
+ return unknown, ErrNotSent
+ }
+ defer end()
+ ctx, cancel := context.WithTimeout(ctx, min(5*time.Second, c.limits.ReadTimeout))
+ defer cancel()
+ c.mu.Lock()
+ if !c.accept {
+ c.mu.Unlock()
+ return unknown, ErrNotSent
+ }
+ if c.observations == nil {
+ c.observations = map[string]context.CancelFunc{}
+ }
+ c.observations[env.Request.OperationID] = cancel
+ c.mu.Unlock()
+ defer func() { c.mu.Lock(); delete(c.observations, env.Request.OperationID); c.mu.Unlock() }()
+ resp, err := c.send(ctx, "/read", env)
+ if err != nil {
+ return unknown, err
+ }
+ // Read-only cleanup: close errors cannot change the decoded result.
+ defer func() { _ = resp.Body.Close() }()
+ var result contract.Result
+ if err = decode(resp.Body, c.limits.EnvelopeBytes, &result); err != nil {
+ return unknown, err
+ }
+ if denied() || len(result.Outputs) != 0 || result.Validate(c.limits) != nil || !c.admitting() {
+ return unknown, ErrRuntime
+ }
+ value, err := contract.DecodeExecutionStatus(result.Payload)
+ if err != nil {
+ return unknown, err
+ }
+ return value, nil
+}
+
+// AdmitObservation checks the immutable identity and withdrawal state atomically.
+// The returned lease only pins this client; it never re-resolves a replacement.
+// Withdrawal cancels pending reads and prevents subsequent dispatch admission.
+func (c *Client) AdmitObservation(generation uint64, incarnation string) (func(), error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if !c.accept || c.registration.Generation != generation || c.registration.Incarnation != incarnation {
+ return nil, ErrNotSent
+ }
+ c.observationLeases++
+ var once sync.Once
+ return func() { once.Do(func() { c.mu.Lock(); c.observationLeases--; c.mu.Unlock() }) }, nil
+}
diff --git a/core/services/addons/runtime/observation_admission_test.go b/core/services/addons/runtime/observation_admission_test.go
new file mode 100644
index 000000000..e5b4e492e
--- /dev/null
+++ b/core/services/addons/runtime/observation_admission_test.go
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: MIT
+package runtime
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("exact observation client admission", func() {
+ It("refuses missing wire tool discriminators before any read", func() {
+ var calls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`)
+ Expect(err).NotTo(HaveOccurred())
+ }))
+ defer srv.Close()
+ c, e := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "old", Endpoint: srv.URL}, strings.Repeat("a", 64), contract.DefaultLimits())
+ Expect(e).NotTo(HaveOccurred())
+ c.accept = true
+ env := contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 1, Action: "read", OperationID: "op", Arguments: json.RawMessage(`{}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "k", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Second)}}
+ _, e = c.ReadObservation(context.Background(), env, "q")
+ Expect(e).To(HaveOccurred())
+ Expect(calls.Load()).To(BeZero())
+ })
+ It("serializes exact identity admission with withdrawal and never dispatches the replacement", func() {
+ var calls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`)
+ Expect(err).NotTo(HaveOccurred())
+ }))
+ defer srv.Close()
+ old, e := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "old", Endpoint: srv.URL}, strings.Repeat("a", 64), contract.DefaultLimits())
+ Expect(e).NotTo(HaveOccurred())
+ old.accept = true
+ replacement, e := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "new", Endpoint: srv.URL}, strings.Repeat("b", 64), contract.DefaultLimits())
+ Expect(e).NotTo(HaveOccurred())
+ replacement.accept = true
+ _, e = old.AdmitObservation(2, "old")
+ Expect(e).To(HaveOccurred())
+ _, e = replacement.AdmitObservation(1, "old")
+ Expect(e).To(HaveOccurred())
+ release, e := old.AdmitObservation(1, "old")
+ Expect(e).NotTo(HaveOccurred())
+ old.withdraw() // deterministic capture-to-dispatch restart boundary
+ env := contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 1, Action: "read", OperationID: "op", Arguments: json.RawMessage(`{"tool":"health"}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "k", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Second)}}
+ _, e = old.ReadObservation(context.Background(), env, "q")
+ Expect(e).To(HaveOccurred())
+ release()
+ release()
+ Expect(old.active).To(BeZero())
+ Expect(calls.Load()).To(BeZero())
+ _, e = old.AdmitObservation(1, "old")
+ Expect(e).To(HaveOccurred())
+ })
+})
diff --git a/core/services/addons/runtime/observation_test.go b/core/services/addons/runtime/observation_test.go
new file mode 100644
index 000000000..7f4559a01
--- /dev/null
+++ b/core/services/addons/runtime/observation_test.go
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: MIT
+package runtime
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Host-only observation read", func() {
+ makeClient := func(handler http.HandlerFunc) (*Client, *hostops.Broker) {
+ srv := httptest.NewServer(handler)
+ DeferCleanup(srv.Close)
+ c, err := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "inc", Endpoint: srv.URL}, strings.Repeat("a", 64), contract.DefaultLimits())
+ Expect(err).NotTo(HaveOccurred())
+ c.accept = true
+ c.inflight = hostops.NewInFlight()
+ b := hostops.New(c.inflight, func(string, string) bool { return true }, contract.DefaultLimits())
+ return c, b
+ }
+ env := func() contract.Envelope {
+ return contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 1, OperationID: "status", Action: "read", Arguments: json.RawMessage(`{"tool":"status"}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "alice", CredentialID: "key", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Minute)}}
+ }
+ It("rejects output handles before they can reach storage or output-open", func() {
+ var opens atomic.Int32
+ c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/output" {
+ opens.Add(1)
+ }
+ _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"},"outputs":[{"handle":"evil","name":"evil.txt","mediaType":"text/plain","size":1}]}`)
+ Expect(err).NotTo(HaveOccurred())
+ })
+ value, err := c.ReadObservation(context.Background(), env(), "q")
+ Expect(err).To(HaveOccurred())
+ Expect(value.Readiness).NotTo(Equal("available"))
+ Expect(opens.Load()).To(BeZero())
+ ordinary := env()
+ ordinary.Request.OperationID = "ordinary"
+ r, err := c.Read(context.Background(), ordinary)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(r.Outputs).To(HaveLen(1))
+ })
+ It("settles callback denial as failure even if the addon returns a valid positive payload", func() {
+ var callback http.Handler
+ var code atomic.Int32
+ c, b := makeClient(func(w http.ResponseWriter, r *http.Request) {
+ req := httptest.NewRequest("POST", "/host", strings.NewReader(`{"operationId":"status","op":"connector.inbound","arguments":{}}`))
+ req.Header.Set(contract.HostCredentialHeader, "secret")
+ rec := httptest.NewRecorder()
+ callback.ServeHTTP(rec, req)
+ code.Store(int32(rec.Code))
+ _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`)
+ Expect(err).NotTo(HaveOccurred())
+ })
+ var calls atomic.Int32
+ Expect(b.Register(hostops.Spec{Op: "connector.inbound", Scope: hostops.ScopeInbound, Handler: func(context.Context, hostops.Call) (json.RawMessage, error) {
+ calls.Add(1)
+ return json.RawMessage(`{}`), nil
+ }})).To(Succeed())
+ callback = b.HandlerForRuntime("sample", "secret", []string{"connector."}, 1, "inc")
+ _, err := c.ReadObservation(context.Background(), env(), "q")
+ Expect(err).To(HaveOccurred())
+ Expect(code.Load()).To(Equal(int32(403)))
+ Expect(calls.Load()).To(BeZero())
+ _, live := c.inflight.Lookup("sample", "status")
+ Expect(live).To(BeFalse())
+ })
+ It("cleans up after cancellation and rejects repeat correlation", func() {
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ var once sync.Once
+ c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) { once.Do(func() { close(entered) }); <-release })
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { _, err := c.ReadObservation(ctx, env(), "q"); done <- err }()
+ <-entered
+ cancel()
+ Expect(<-done).To(HaveOccurred())
+ close(release)
+ _, live := c.inflight.Lookup("sample", "status")
+ Expect(live).To(BeFalse())
+ _, err := c.ReadObservation(context.Background(), env(), "q2")
+ Expect(err).To(HaveOccurred())
+ })
+ It("keeps an ordinary concurrent read authorized while an observation is pending", func() {
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ var statusCode atomic.Int32
+ var callback http.Handler
+ c, b := makeClient(func(w http.ResponseWriter, r *http.Request) {
+ var e contract.Envelope
+ _ = json.NewDecoder(r.Body).Decode(&e)
+ if e.Request.OperationID == "status" {
+ close(entered)
+ <-release
+ } else {
+ req := httptest.NewRequest("POST", "/host", strings.NewReader(`{"operationId":"ordinary","op":"workspace.export","arguments":{}}`))
+ req.Header.Set(contract.HostCredentialHeader, "secret")
+ rec := httptest.NewRecorder()
+ callback.ServeHTTP(rec, req)
+ statusCode.Store(int32(rec.Code))
+ }
+ _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`)
+ Expect(err).NotTo(HaveOccurred())
+ })
+ Expect(b.Register(hostops.Spec{Op: "workspace.export", Scope: "read", Feature: "chats", Handler: func(context.Context, hostops.Call) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }})).To(Succeed())
+ callback = b.HandlerForRuntime("sample", "secret", []string{"workspace."}, 1, "inc")
+ done := make(chan error, 1)
+ go func() { _, err := c.ReadObservation(context.Background(), env(), "q"); done <- err }()
+ <-entered
+ ordinary := env()
+ ordinary.Request.OperationID = "ordinary"
+ _, err := c.Read(context.Background(), ordinary)
+ close(release)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(<-done).NotTo(HaveOccurred())
+ Expect(statusCode.Load()).To(Equal(int32(200)))
+ })
+ DescribeTable("cleans up every settled path", func(mode string) {
+ c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) {
+ switch mode {
+ case "error":
+ http.Error(w, "failed", 503)
+ case "timeout":
+ time.Sleep(50 * time.Millisecond)
+ case "malformed":
+ _, err := io.WriteString(w, `{"payload":{"artifact":"evil"}}`)
+ Expect(err).NotTo(HaveOccurred())
+ default:
+ _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`)
+ Expect(err).NotTo(HaveOccurred())
+ }
+ })
+ ctx := context.Background()
+ if mode == "timeout" {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, 20*time.Millisecond)
+ defer cancel()
+ }
+ _, err := c.ReadObservation(ctx, env(), "q")
+ if mode == "success" {
+ Expect(err).NotTo(HaveOccurred())
+ } else {
+ Expect(err).To(HaveOccurred())
+ }
+ _, live := c.inflight.Lookup("sample", "status")
+ Expect(live).To(BeFalse())
+ }, Entry("success", "success"), Entry("error", "error"), Entry("timeout", "timeout"), Entry("malformed/artifact payload", "malformed"))
+
+ It("cancels an admitted observation when the runtime withdraws", func() {
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) { close(entered); <-release })
+ done := make(chan error, 1)
+ go func() { _, err := c.ReadObservation(context.Background(), env(), "q"); done <- err }()
+ <-entered
+ c.withdraw()
+ select {
+ case err := <-done:
+ Expect(err).To(HaveOccurred())
+ case <-time.After(100 * time.Millisecond):
+ close(release)
+ Fail("withdrawal did not cancel observation")
+ return
+ }
+ close(release)
+ _, live := c.inflight.Lookup("sample", "status")
+ Expect(live).To(BeFalse())
+ })
+
+})
diff --git a/core/services/addons/runtime/supervisor.go b/core/services/addons/runtime/supervisor.go
index 701c71946..5659131ce 100644
--- a/core/services/addons/runtime/supervisor.go
+++ b/core/services/addons/runtime/supervisor.go
@@ -492,12 +492,19 @@ func (s *Supervisor) launch(ctx context.Context, pkg packages.PackageReceipt, ge
}
// serveHostOps serves the host operation channel of one incarnation and
-// closes it when the process ends. terminate waits for the process, so the
-// endpoint never outlives it.
+// stops admission when the process ends. Cleanup drains active requests before
+// releasing correlations; a handler ignoring cancellation cannot block terminate.
func (s *Supervisor) serveHostOps(p *process, l net.Listener, id, credential string, prefixes []string) {
- srv := &http.Server{Handler: s.config.HostOps.Broker.Handler(id, credential, prefixes), ReadHeaderTimeout: 5 * time.Second, MaxHeaderBytes: 16 << 10}
+ endpoint := s.config.HostOps.Broker.EndpointForRuntime(id, credential, prefixes, p.registration.Generation, p.registration.Incarnation)
+ srv := &http.Server{Handler: endpoint, ReadHeaderTimeout: 5 * time.Second, MaxHeaderBytes: 16 << 10}
go func() { _ = srv.Serve(l) }()
- go func() { <-p.done; _ = srv.Close() }()
+ go func() {
+ <-p.done
+ endpoint.Stop()
+ _ = srv.Close()
+ endpoint.Drain()
+ s.config.HostOps.InFlight.ForgetRuntime(id, p.registration.Incarnation)
+ }()
}
func (s *Supervisor) terminate(p *process) {
diff --git a/core/services/addons/runtime/supervisor_test.go b/core/services/addons/runtime/supervisor_test.go
index 5280a6a17..90de5ef4d 100644
--- a/core/services/addons/runtime/supervisor_test.go
+++ b/core/services/addons/runtime/supervisor_test.go
@@ -729,7 +729,7 @@ var _ = Describe("Addon data directory", func() {
}
Expect(call("wrong")).To(Equal(http.StatusUnauthorized))
Expect(call(credential)).To(Equal(http.StatusForbidden))
- end := in.Begin(contract.Envelope{Request: contract.Request{AddonID: "sample", OperationID: "op1", Generation: 1, Action: "a", Arguments: json.RawMessage(`{}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "c", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Minute)}})
+ end := in.BeginRuntime(contract.Envelope{Request: contract.Request{AddonID: "sample", OperationID: "op1", Generation: 1, Action: "a", Arguments: json.RawMessage(`{}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "c", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Minute)}}, hostops.Binding{}, false, inst.Incarnation)
Expect(call(credential)).To(Equal(http.StatusOK))
end()
Expect(call(credential)).To(Equal(http.StatusForbidden))
diff --git a/core/services/addons/state.go b/core/services/addons/state.go
index b4587e2d1..022dede46 100644
--- a/core/services/addons/state.go
+++ b/core/services/addons/state.go
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: MIT
package addons
-import "github.com/mudler/LocalAI/core/services/addons/packages"
+import (
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
+ "github.com/mudler/LocalAI/core/services/addons/packages"
+)
// lifecyclePhase is shared by the owner journal and upgrade reducer. The
// operation DTO retains string fields for compatibility with existing callers.
@@ -54,7 +57,8 @@ type OperationStatus struct {
OperationID, AddonID, Phase string
Generation uint64
// RuntimeState is an observation, never a durable execution receipt.
- Stage string `json:"stage,omitempty"`
- FailureCode string `json:"failureCode,omitempty"`
- RuntimeState string `json:"runtimeState,omitempty"`
+ Stage string `json:"stage,omitempty"`
+ FailureCode string `json:"failureCode,omitempty"`
+ RuntimeState string `json:"runtimeState,omitempty"`
+ HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"`
}
diff --git a/core/services/addons/tools/adapter.go b/core/services/addons/tools/adapter.go
index 2e586591c..628aa873b 100644
--- a/core/services/addons/tools/adapter.go
+++ b/core/services/addons/tools/adapter.go
@@ -25,7 +25,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "regexp"
"slices"
"strings"
"time"
@@ -317,18 +316,31 @@ func randomOperationID() (string, error) {
return "t" + hex.EncodeToString(raw[:]), nil
}
-var toolName = regexp.MustCompile(`^addon__([a-z][a-z0-9-]{0,63})__([a-z][a-z0-9-]{0,63})$`)
-
-// ToolName is the function name of an add-on tool.
+// ToolName formats a qualified identity. Callers validate components and, for
+// model-callable tools, the total length with ParseToolName.
func ToolName(addonID, tool string) string { return Prefix + addonID + "__" + tool }
-// ParseToolName splits a namespaced name. It accepts nothing else.
-func ParseToolName(name string) (addonID, tool string, ok bool) {
- m := toolName.FindStringSubmatch(name)
- if m == nil || len(name) > MaxNameLength {
+// ParseQualifiedToolName splits an add-on tool identity with valid contract
+// components. It does not apply the model's total name limit: Studio stand-ins
+// identify unavailable add-ons, including long IDs, rather than dispatch tools.
+func ParseQualifiedToolName(name string) (addonID, tool string, ok bool) {
+ qualified, ok := strings.CutPrefix(name, Prefix)
+ if !ok {
return "", "", false
}
- return m[1], m[2], true
+ addonID, tool, ok = strings.Cut(qualified, "__")
+ if !ok || !contract.ValidName(addonID) || !contract.ValidName(tool) {
+ return "", "", false
+ }
+ return addonID, tool, true
+}
+
+// ParseToolName splits a model-callable name, enforcing its total length limit.
+func ParseToolName(name string) (addonID, tool string, ok bool) {
+ if len(name) > MaxNameLength {
+ return "", "", false
+ }
+ return ParseQualifiedToolName(name)
}
// IsToolName reports whether name is in the namespace reserved for add-on
diff --git a/core/services/addons/tools/binding.go b/core/services/addons/tools/binding.go
new file mode 100644
index 000000000..ba3d48147
--- /dev/null
+++ b/core/services/addons/tools/binding.go
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: MIT
+package tools
+
+import (
+ "context"
+
+ "github.com/mudler/LocalAI/core/services/addons/gateway"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+// ExecutionBindingBackend is an optional host-only door. Ordinary Backend
+// implementations and model tool execution do not gain binding authority.
+type ExecutionBindingBackend interface {
+ BindingForActor(context.Context, contract.Actor, gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error)
+ ValidateExecutionBinding(context.Context, contract.Actor, gateway.ExecutionStatusBinding) error
+}
+
+// BindingForActor forwards only an authenticated actor and conversation claim.
+// Discovery results, model arguments and frontend permissions are not inputs.
+func (t *ToolAdapter) BindingForActor(ctx context.Context, a contract.Actor, c gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error) {
+ b, ok := t.backend.(ExecutionBindingBackend)
+ if !ok {
+ return gateway.ExecutionStatusBinding{}, gateway.ErrForbidden
+ }
+ return b.BindingForActor(ctx, a, c)
+}
+
+func (t *ToolAdapter) ValidateExecutionBinding(ctx context.Context, a contract.Actor, b gateway.ExecutionStatusBinding) error {
+ owner, ok := t.backend.(ExecutionBindingBackend)
+ if !ok {
+ return gateway.ErrForbidden
+ }
+ return owner.ValidateExecutionBinding(ctx, a, b)
+}
+
+// ExecutionStatusRead is optional and never falls back to ordinary tool dispatch.
+func (t *ToolAdapter) ExecutionStatusRead(ctx context.Context, a contract.Actor, r gateway.ExecutionStatusRequest) (gateway.ExecutionStatusResponse, error) {
+ owner, ok := t.backend.(interface {
+ ExecutionStatusRead(context.Context, contract.Actor, gateway.ExecutionStatusRequest) (gateway.ExecutionStatusResponse, error)
+ })
+ if !ok {
+ return gateway.ExecutionStatusResponse{}, gateway.ErrForbidden
+ }
+ return owner.ExecutionStatusRead(ctx, a, r)
+}
diff --git a/core/services/addons/tools/binding_test.go b/core/services/addons/tools/binding_test.go
new file mode 100644
index 000000000..6659fdab1
--- /dev/null
+++ b/core/services/addons/tools/binding_test.go
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: MIT
+package tools_test
+
+import (
+ "context"
+ "github.com/mudler/LocalAI/core/services/addons/gateway"
+ "github.com/mudler/LocalAI/core/services/addons/tools"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type bindingBackend struct {
+ fake
+ actor contract.Actor
+ claim gateway.ExecutionBindingClaim
+ binding gateway.ExecutionStatusBinding
+}
+
+func (b *bindingBackend) BindingForActor(_ context.Context, a contract.Actor, c gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error) {
+ b.actor = a
+ b.claim = c
+ return b.binding, nil
+}
+func (b *bindingBackend) ValidateExecutionBinding(_ context.Context, a contract.Actor, v gateway.ExecutionStatusBinding) error {
+ b.actor = a
+ if v != b.binding {
+ return gateway.ErrForbidden
+ }
+ return nil
+}
+
+var _ = Describe("optional execution binding backend", func() {
+ It("forwards bindings only through an explicit binding backend", func() {
+ ctx := context.Background()
+ actor := contract.Actor{UserID: "u", CredentialID: "c", Mode: "named-user"}
+ claim := gateway.ExecutionBindingClaim{ConversationID: "chat"}
+ adapter := tools.NewToolAdapter(&fake{})
+ _, err := adapter.BindingForActor(ctx, actor, claim)
+ Expect(err).To(HaveOccurred())
+ Expect(adapter.ValidateExecutionBinding(ctx, actor, gateway.ExecutionStatusBinding{})).NotTo(Succeed())
+ backend := &bindingBackend{binding: gateway.ExecutionStatusBinding{Actor: actor, ConversationID: "chat", CurrentSelection: "owner-epoch"}}
+ adapter = tools.NewToolAdapter(backend)
+ got, err := adapter.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(Equal(backend.binding))
+ Expect(backend.actor).To(Equal(actor))
+ Expect(backend.claim).To(Equal(claim))
+ Expect(adapter.ValidateExecutionBinding(ctx, actor, got)).To(Succeed())
+ got.CurrentSelection = "forged"
+ Expect(adapter.ValidateExecutionBinding(ctx, actor, got)).To(MatchError(gateway.ErrForbidden))
+ })
+})
diff --git a/core/services/addons/tools/consumer_matrix_test.go b/core/services/addons/tools/consumer_matrix_test.go
index 5c70205ea..d803fd2f5 100644
--- a/core/services/addons/tools/consumer_matrix_test.go
+++ b/core/services/addons/tools/consumer_matrix_test.go
@@ -166,6 +166,9 @@ var _ = Describe("Tool consumer matrix", func() {
})
Expect(importers).To(ConsistOf(
"core/application/addon_gateway.go", // builds the provider from the owner gateway
+ "core/application/execution_binding.go", // validates the selected qualified identity
+ "core/services/studio/addontools.go", // formats unavailable stand-in identities
+ "core/services/studio/capabilities.go", // parses stand-in identities without dispatch
"core/http/endpoints/mcp/addon_tools.go", // the one executor of the namespace
"core/http/endpoints/mcp/tools.go", // refuses the namespace in MCP sessions
))
diff --git a/core/services/addons/tools/definitions_test.go b/core/services/addons/tools/definitions_test.go
index f351655b7..2fc121f42 100644
--- a/core/services/addons/tools/definitions_test.go
+++ b/core/services/addons/tools/definitions_test.go
@@ -210,3 +210,18 @@ var _ = Describe("The add-ons that replace a built-in chat tool", func() {
}))
})
})
+
+var _ = Describe("typed execution status model exclusion", func() {
+ It("keeps declared uiOnly execution status tools out of generic adapters", func() {
+ app := apps.App{ID: "renamed", Generation: 1, Enabled: true, State: "running", Tools: []string{"health"}, ToolDefinitions: []contract.ToolDefinition{{Name: "health", Kind: "read", UIOnly: true, Description: "Observe execution.", InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}}}
+ f := &fake{list: apps.List{Apps: []apps.App{app}}}
+ for _, a := range []*tools.ToolAdapter{tools.NewToolAdapter(f), tools.NewChatToolAdapter(f)} {
+ defs, e := a.Discover(context.Background(), member)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(defs).To(BeEmpty())
+ _, e = a.Execute(context.Background(), member, tools.ToolName("renamed", "health"), json.RawMessage(`{}`))
+ Expect(e).To(MatchError(tools.ErrUnknownTool))
+ }
+ Expect(f.calls()).To(BeZero())
+ })
+})
diff --git a/core/services/addons/tools/toolnames_test.go b/core/services/addons/tools/toolnames_test.go
new file mode 100644
index 000000000..d70434e57
--- /dev/null
+++ b/core/services/addons/tools/toolnames_test.go
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: MIT
+package tools_test
+
+import (
+ "strings"
+
+ "github.com/mudler/LocalAI/core/services/addons/tools"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("qualified add-on tool names", func() {
+ It("round trips valid components and enforces the callable length boundary", func() {
+ for _, id := range []string{"a", "runner-9", "r" + strings.Repeat("x", 63)} {
+ for _, tool := range []string{"t", "read-doc", "t" + strings.Repeat("z", 63)} {
+ name := tools.ToolName(id, tool)
+ gotID, gotTool, ok := tools.ParseQualifiedToolName(name)
+ Expect(ok).To(BeTrue())
+ Expect(gotID).To(Equal(id))
+ Expect(gotTool).To(Equal(tool))
+ _, _, callable := tools.ParseToolName(name)
+ Expect(callable).To(Equal(len(name) <= tools.MaxNameLength))
+ }
+ }
+ for _, length := range []int{64, 65} {
+ name := tools.ToolName("a", "t"+strings.Repeat("x", length-11))
+ Expect(len(name)).To(Equal(length))
+ _, _, ok := tools.ParseToolName(name)
+ Expect(ok).To(Equal(length == 64))
+ }
+ })
+ It("rejects malformed qualified identities while reserving their namespace", func() {
+ for _, name := range []string{"addon__bad_id__tool", "addon__a__bad_tool", "addon__9a__tool", "addon__a__tool__extra", "addon____tool", "addon__a__", "addon__"} {
+ _, _, ok := tools.ParseQualifiedToolName(name)
+ Expect(ok).To(BeFalse(), name)
+ Expect(tools.IsToolName(name)).To(BeTrue(), name)
+ }
+ for _, name := range []string{"bash", "a__tool", "mcp__a__tool"} {
+ _, _, ok := tools.ParseToolName(name)
+ Expect(ok).To(BeFalse())
+ Expect(tools.IsToolName(name)).To(BeFalse())
+ }
+ })
+})
diff --git a/core/services/addons/tools_test.go b/core/services/addons/tools_test.go
index d12f7186c..cc56243eb 100644
--- a/core/services/addons/tools_test.go
+++ b/core/services/addons/tools_test.go
@@ -117,6 +117,9 @@ var _ = Describe("declared capabilities of the installed generation", Label("too
m := open(cfg, c, i)
var caps contract.Capabilities
fillNonZero(reflect.ValueOf(&caps).Elem())
+ // Admission requires valid, supported requests even in the reflection fixture.
+ caps.HostOperations = &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}
+ m.SetHostOps(map[string][]string{"notes": {"workspace."}})
v := contract.VersionRange{Min: 1, Max: 1}
m.journal.Addons["notes"] = Status{AddonID: "notes", Installed: true, DesiredEnabled: true, Generation: 1, Generations: []Generation{{Number: 1, Receipt: packages.PackageReceipt{
Manifest: contract.Manifest{SchemaVersion: 1, ID: "notes", Version: "1.0.0", Executable: "bin/x", OS: "linux", Arch: "amd64", Host: v, API: v, Protocol: v, DataFormat: v, Capabilities: caps}}}}}
diff --git a/core/services/addons/updates_test.go b/core/services/addons/updates_test.go
index 1c1986f27..0b2a7fce6 100644
--- a/core/services/addons/updates_test.go
+++ b/core/services/addons/updates_test.go
@@ -8,10 +8,12 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "sort"
"strings"
"sync"
"time"
@@ -40,6 +42,8 @@ type updateLibrary struct {
archives map[string][]byte
releases []catalog.Release
sources *catalog.Sources
+ // transform lets lifecycle specs publish real typed executable packages.
+ transform func(map[string]any) map[string][]byte
}
func (l *updateLibrary) publish(version, channel, content string, requires catalog.Requirements) catalog.Release {
@@ -49,15 +53,26 @@ func (l *updateLibrary) publish(version, channel, content string, requires catal
var m map[string]any
Expect(json.Unmarshal(manifest, &m)).To(Succeed())
m["version"] = version
+ files := map[string][]byte{"bin/sample": []byte(content)}
+ if l.transform != nil {
+ for name, data := range l.transform(m) {
+ files[name] = data
+ }
+ }
manifest, _ = json.Marshal(m)
+ files["manifest.json"] = manifest
var b bytes.Buffer
tw := tar.NewWriter(&b)
- for _, f := range []struct {
- name string
- data []byte
- }{{"manifest.json", manifest}, {"bin/sample", []byte(content)}} {
- Expect(tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0755, Size: int64(len(f.data))})).To(Succeed())
- _, e = tw.Write(f.data)
+ // Stable entry order keeps identical package contents at the same digest.
+ names := make([]string, 0, len(files))
+ for name := range files {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ data := files[name]
+ Expect(tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))})).To(Succeed())
+ _, e = tw.Write(data)
Expect(e).NotTo(HaveOccurred())
}
Expect(tw.Close()).To(Succeed())
@@ -167,6 +182,44 @@ func (r *updateRig) journalOps() int {
return len(r.m.journal.Operations)
}
+var _ = Describe("update library archive fixtures", func() {
+ It("publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms", func() {
+ for _, transformed := range []bool{false, true} {
+ lib := &updateLibrary{archives: map[string][]byte{}, server: &httptest.Server{URL: "http://fixture.invalid"}}
+ wantNames := []string{"bin/sample", "manifest.json"}
+ if transformed {
+ lib.transform = func(m map[string]any) map[string][]byte {
+ m["description"] = "unchanged transform"
+ return map[string][]byte{"z-last.txt": []byte("last"), "a-first.txt": []byte("first"), "ui/main.js": []byte("export default {};")}
+ }
+ wantNames = []string{"a-first.txt", "bin/sample", "manifest.json", "ui/main.js", "z-last.txt"}
+ }
+ var archives [][]byte
+ digests := map[string]bool{}
+ for attempt := 0; attempt < 100; attempt++ {
+ release := lib.publish("1.2.3", "", "unchanged content", catalog.Requirements{})
+ digests[release.Digest] = true
+ archives = append(archives, lib.archives[strings.TrimPrefix(release.Artifact, lib.server.URL)])
+ }
+ Expect(digests).To(HaveLen(1), "unchanged publishes must not change the digest; transformed=%t", transformed)
+ for _, archive := range archives {
+ Expect(archive).To(Equal(archives[0]))
+ tr := tar.NewReader(bytes.NewReader(archive))
+ var names []string
+ for {
+ header, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ Expect(err).NotTo(HaveOccurred())
+ names = append(names, header.Name)
+ }
+ Expect(names).To(Equal(wantNames), "archive entries must use lexical order")
+ }
+ }
+ })
+})
+
var _ = Describe("explicit upgrade from an authoritative update check", func() {
It("reports a digest-only change of a moving channel, applies nothing and offers the pinned upgrade", func() {
r := newUpdateRig(true)
diff --git a/core/services/studio/addontools.go b/core/services/studio/addontools.go
index ab86ddc8e..1edfa140e 100644
--- a/core/services/studio/addontools.go
+++ b/core/services/studio/addontools.go
@@ -9,6 +9,7 @@ import (
"slices"
"sort"
+ addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/workspace"
)
@@ -57,7 +58,7 @@ type AddonGate interface {
// unavailableToolName is the name of the stand-in tool of an add-on that
// cannot work. It has the shape of the broker's tool names.
-func unavailableToolName(addonID string) string { return "addon__" + addonID + "__unavailable" }
+func unavailableToolName(addonID string) string { return addontools.ToolName(addonID, "unavailable") }
// ErrBadAddonID means a switch names something that is not an add-on ID, or
// there are too many switches.
@@ -193,12 +194,11 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session
continue
}
}
- if off[d.AddonID] || reservedToolNames[d.Name] || (sc != nil && !sc.addons[d.AddonID]) {
- continue
+ var grants []string
+ if sc != nil {
+ grants = sc.addonNames
}
- // An unattended run with no agent never gets a tool that acts at once:
- // the owner's explicit grant, in an agent, is what allows it.
- if d.Action && rec.Origin != "" && rec.AgentID == "" {
+ if !AddonToolEligible(d, rec, grants) {
continue
}
name := d.Name
diff --git a/core/services/studio/agents/json.go b/core/services/studio/agents/json.go
index 3c38ecdec..eae3b1494 100644
--- a/core/services/studio/agents/json.go
+++ b/core/services/studio/agents/json.go
@@ -14,7 +14,8 @@ import (
// jsonStore keeps the agents in memory and, with a directory, in the file
// agents.json (rewritten atomically after every change). One process owns the
-// directory: the lock is in-process only, so a cluster uses the GORM store.
+// directory.
+// A cluster uses GORM.
type jsonStore struct {
mu sync.Mutex
path string // empty: memory only
diff --git a/core/services/studio/capabilities.go b/core/services/studio/capabilities.go
index 2d30f0b21..27bfd24a5 100644
--- a/core/services/studio/capabilities.go
+++ b/core/services/studio/capabilities.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/core/services/studio/sandbox"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/skills"
@@ -60,6 +61,8 @@ const (
// Commands is what a chat knows about running commands at a turn.
type Commands struct {
+ ExecutionStatus *ExecutionStatusView
+
State CommandState
// Tool is the name of the tool that runs commands, when State is available.
Tool string
@@ -136,11 +139,8 @@ func standInRunner(tools []session.Tool) string {
if !slices.Contains(t.StandInFor, commandTool) {
continue
}
- id, ok := strings.CutPrefix(t.Name, "addon__")
- if !ok {
- continue
- }
- if id, ok = strings.CutSuffix(id, "__unavailable"); ok {
+ id, tool, ok := addontools.ParseQualifiedToolName(t.Name)
+ if ok && tool == "unavailable" {
return id
}
}
@@ -205,6 +205,8 @@ func (s *Service) commandsOf(ctx context.Context, user string, tools []session.T
// Tools menu shows it. It uses the same tools the model would get. The chat of
// another user is session.ErrNotFound.
func (s *Service) CommandRunner(ctx context.Context, user, conv string) (Commands, error) {
+ ctx, closeScope := s.executionScope(ctx, principal(user), conv)
+ defer closeScope()
p := principal(user)
rec, err := s.d.Store.GetChat(ctx, p, conv)
if err != nil {
@@ -233,6 +235,9 @@ func (s *Service) commandsFor(ctx context.Context, p string, rec session.ChatRec
}
}
c := s.commandsOf(ctx, p, tools, rec.AddonsOff, sc)
+ if _, ok := s.d.AddonTools.(ExecutionObserver); ok {
+ c.ExecutionStatus = executionStatusView(ctx, p, c.Addon)
+ }
tools = s.applyBuiltins(tools, s.builtinsFor(ctx, a, rec, false))
return shellSwitched(s.withHostBash(c, tools), s.hiddenFor(ctx, rec)), nil
}
diff --git a/core/services/studio/capabilities_test.go b/core/services/studio/capabilities_test.go
index 28a8be882..c4b090d8a 100644
--- a/core/services/studio/capabilities_test.go
+++ b/core/services/studio/capabilities_test.go
@@ -2,7 +2,10 @@ package studio_test
import (
"context"
+ "github.com/mudler/LocalAI/core/services/studio/executionrequest"
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
"strings"
+ "time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -233,3 +236,45 @@ var _ = Describe("capabilities", func() {
Expect(childrenOf(svc, alice, id)).To(HaveLen(2))
})
})
+
+// presentationAddons supplies an admitted request just as the product broker does.
+type presentationAddons struct {
+ *fakeAddons
+ bound bool
+}
+
+func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) {
+ if !p.bound {
+ return nil, "", nil
+ }
+ b := model.Binding{Actor: user, Addon: "sandbox", Generation: "27", Incarnation: "runtime-two", ExecutionTool: "bash", StatusTool: "status"}
+ r := executionrequest.New(ctx, b, "q", model.Eligible, executionrequest.IO{
+ Validate: func(context.Context, model.Binding) error { return nil },
+ Read: func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Host}), nil
+ },
+ }, nil)
+ Expect(r.Binding()).To(Equal(b))
+ return r, b.Addon, func() string { return "plain explanation" }
+}
+
+var _ = Describe("execution presentation DTO", func() {
+ It("returns admitted binding identity through CommandRunner and no identity without admission", func() {
+ for _, bound := range []bool{true, false} {
+ broker := presentationAddons{fakeAddons: &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}, bound: bound}
+ svc, _, _ := newCapsChat(broker, nil)
+ id := create(svc, alice)
+ c, err := svc.CommandRunner(context.Background(), alice, id)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(c.ExecutionStatus).NotTo(BeNil())
+ if bound {
+ Expect(c.ExecutionStatus.Generation).To(Equal("27"))
+ Expect(c.ExecutionStatus.Incarnation).To(Equal("runtime-two"))
+ Expect(c.ExecutionStatus.Readiness).To(Equal("available"))
+ } else {
+ Expect(c.ExecutionStatus.Generation).To(BeEmpty())
+ Expect(c.ExecutionStatus.Readiness).To(Equal("unknown"))
+ }
+ }
+ })
+})
diff --git a/core/services/studio/egress_test.go b/core/services/studio/egress_test.go
index 5ea9a9e75..9693fd8cb 100644
--- a/core/services/studio/egress_test.go
+++ b/core/services/studio/egress_test.go
@@ -238,11 +238,11 @@ var _ = Describe("the add-on that draws the network row of a chat", func() {
return svc
}
- It("is the add-on that runs the commands and carries the network policy", func() {
+ It("names the runner control without claiming host network enforcement", func() {
svc := service("sandbox", "isolated", map[string][]string{"sandbox": {"network-policy", "tool-providers"}})
v, err := svc.Egress(context.Background(), alice, create(svc, alice))
Expect(err).NotTo(HaveOccurred())
- Expect(v.Applies).To(BeTrue())
+ Expect(v.Applies).To(BeFalse())
Expect(v.JSON().Addon).To(Equal("sandbox"))
})
@@ -252,10 +252,10 @@ var _ = Describe("the add-on that draws the network row of a chat", func() {
Expect(v.JSON().Addon).To(Equal("devbox"))
})
- It("is none when the runner has no network policy, and the policy still applies", func() {
+ It("is none when the runner has no network policy and enforcement is unknown", func() {
svc := service("sandbox", "isolated", map[string][]string{"sandbox": {"tool-providers"}})
v, _ := svc.Egress(context.Background(), alice, create(svc, alice))
- Expect(v.Applies).To(BeTrue())
+ Expect(v.Applies).To(BeFalse())
Expect(v.JSON().Addon).To(BeEmpty())
})
diff --git a/core/services/studio/egressapi.go b/core/services/studio/egressapi.go
index b1a9ea880..92d87c48c 100644
--- a/core/services/studio/egressapi.go
+++ b/core/services/studio/egressapi.go
@@ -72,6 +72,8 @@ func registriesView(s egress.Settings) []egress.Registry {
// Egress returns the network of a chat for its owner. The chat of another
// user is session.ErrNotFound.
func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, error) {
+ ctx, closeScope := s.executionScope(ctx, principal(user), conv)
+ defer closeScope()
p := principal(user)
rec, err := s.d.Store.GetChat(ctx, p, conv)
if err != nil {
@@ -116,7 +118,8 @@ func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, er
case cmds.State == CommandsAvailable && (cmds.Provider == BuiltinProvider || cmds.Isolation == "host"):
v.AppliesReason = EgressAppliesHost
case cmds.State == CommandsAvailable:
- v.Applies = true
+ // Provider isolation does not establish host network-policy enforcement.
+ v.AppliesReason = EgressAppliesNone
if c, ok := s.d.AddonTools.(AddonControls); ok && slices.Contains(c.Controls(ctx, p, cmds.Provider), contract.ControlNetworkPolicy) {
v.Addon = cmds.Provider
}
diff --git a/core/services/studio/executionobservation.go b/core/services/studio/executionobservation.go
new file mode 100644
index 000000000..4cab34084
--- /dev/null
+++ b/core/services/studio/executionobservation.go
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: MIT
+package studio
+
+import (
+ "context"
+ "sync"
+
+ "github.com/mudler/LocalAI/core/services/studio/executionrequest"
+ "github.com/mudler/LocalAI/core/services/studio/session"
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+)
+
+// ExecutionObserver admits the owner-selected provider, not a discovery claim.
+// The request and explanation reader belong exclusively to this outer scope.
+type ExecutionObserver interface {
+ NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() string)
+}
+
+type executionObservationKey struct{}
+type executionObservation struct {
+ ctx context.Context
+ user, conversation string
+ once sync.Once
+ request *executionrequest.Request
+ addon string
+ explanation func() string
+ provider ExecutionObserver
+}
+
+// executionScope borrows existing outer state. Nested consumers must not close
+// it: only the real turn, priming operation or standalone API request owns Close.
+func (s *Service) executionScope(ctx context.Context, user, conversation string) (context.Context, func()) {
+ if existing, ok := ctx.Value(executionObservationKey{}).(*executionObservation); ok && existing.user == user && existing.conversation == conversation {
+ return ctx, func() {}
+ }
+ provider, _ := s.d.AddonTools.(ExecutionObserver)
+ ownerCtx, cancel := context.WithCancel(ctx)
+ scope := &executionObservation{ctx: ownerCtx, user: user, conversation: conversation, provider: provider}
+ return context.WithValue(ctx, executionObservationKey{}, scope), func() {
+ cancel()
+ scope.once.Do(func() {})
+ if scope.request != nil {
+ scope.request.Close()
+ }
+ }
+}
+func (s *Service) productScope(ctx context.Context, x session.Access) (context.Context, func()) {
+ a := x.Actor()
+ return s.executionScope(ctx, a.Principal, a.ChatID)
+}
+
+// ExecutionObservation never falls back to an arbitrary installed provider.
+// An out-of-scope, wrong-user or non-selected add-on has unknown status.
+func ExecutionObservation(ctx context.Context, user, addon string) (model.Value, string) {
+ scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation)
+ if !ok || user != scope.user {
+ return model.Unknown(), ""
+ }
+ scope.once.Do(func() {
+ if scope.provider != nil {
+ scope.request, scope.addon, scope.explanation = scope.provider.NewExecutionObservation(scope.ctx, scope.user, scope.conversation)
+ }
+ })
+ if scope.request == nil || addon != scope.addon {
+ return model.Unknown(), ""
+ }
+ value := scope.request.Observe(ctx)
+ explanation := ""
+ if value.Readiness != model.UnknownReadiness && scope.explanation != nil {
+ explanation = scope.explanation()
+ }
+ return value, explanation
+}
+
+// ExecutionStatusView is observation data, never execution authorization.
+type ExecutionStatusView struct {
+ Readiness string `json:"readiness"`
+ Isolation string `json:"isolation"`
+ Explanation string `json:"explanation,omitempty"`
+ Generation string `json:"generation,omitempty"`
+ Incarnation string `json:"incarnation,omitempty"`
+}
+
+func executionStatusView(ctx context.Context, user, addon string) *ExecutionStatusView {
+ v, explanation := model.Unknown(), ""
+ if addon != "" {
+ v, explanation = ExecutionObservation(ctx, user, addon)
+ }
+ out := &ExecutionStatusView{Readiness: string(v.Readiness), Isolation: string(v.Isolation), Explanation: explanation}
+ scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation)
+ if ok && scope.user == user && scope.addon == addon && scope.request != nil {
+ b := scope.request.Binding()
+ out.Generation, out.Incarnation = b.Generation, b.Incarnation
+ }
+ return out
+}
diff --git a/core/services/studio/executionobservation_test.go b/core/services/studio/executionobservation_test.go
new file mode 100644
index 000000000..bb51e7085
--- /dev/null
+++ b/core/services/studio/executionobservation_test.go
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: MIT
+package studio
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "sync/atomic"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/studio/executionrequest"
+ "github.com/mudler/LocalAI/core/services/studio/session"
+ "github.com/mudler/LocalAI/core/services/studio/session/sessiontest"
+ "github.com/mudler/LocalAI/core/services/studio/workspace"
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+)
+
+type observationProvider struct {
+ AddonTools
+ reads atomic.Int32
+ requests chan *executionrequest.Request
+ started chan struct{}
+ release chan struct{}
+ fail bool
+}
+
+func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) {
+ b := model.Binding{Actor: user, Addon: "renamed-provider", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"}
+ r := executionrequest.New(ctx, b, "host-q", model.Eligible, executionrequest.IO{
+ Validate: func(context.Context, model.Binding) error { return nil },
+ Read: func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) {
+ p.reads.Add(1)
+ if p.started != nil {
+ close(p.started)
+ }
+ if p.release != nil {
+ select {
+ case <-p.release:
+ case <-ctx.Done():
+ return model.InvalidPayload(), ctx.Err()
+ }
+ }
+ if p.fail {
+ return model.InvalidPayload(), errors.New("failed")
+ }
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
+ }}, nil)
+ if p.requests != nil {
+ p.requests <- r
+ }
+ return r, b.Addon, func() string { return "" }
+}
+
+var _ = Describe("execution status product scope", func() {
+ It("shares one status observation across the actual session hook", func() {
+ p := &observationProvider{requests: make(chan *executionrequest.Request, 3)}
+ s := &Service{d: Deps{AddonTools: p}}
+ h := &sessiontest.FakeHarness{Turns: [][]sessiontest.Step{{{Tool: "status", Args: "{}"}, {Tool: "egress", Args: "{}"}}}}
+ failures := make(chan string, 8)
+ observe := func(ctx context.Context) {
+ v, _ := ExecutionObservation(ctx, "alice", "renamed-provider")
+ if v.Isolation != model.Isolated {
+ failures <- "not isolated"
+ }
+ }
+ m, err := session.New(session.Options{Store: sessiontest.NewMemoryStore(), StateDir: GinkgoT().TempDir(), Harness: h, ProductScope: s.productScope,
+ ChatTools: func(ctx context.Context, _ session.Access) (session.TurnTools, error) {
+ observe(ctx)
+ tools := []session.Tool{}
+ for _, name := range []string{"status", "egress"} {
+ tools = append(tools, session.Tool{Name: name, Description: name, Schema: json.RawMessage(`{"type":"object"}`), Run: func(ctx context.Context, _ json.RawMessage) (string, error) { observe(ctx); return "ok", nil }})
+ }
+ return session.TurnTools{Tools: tools}, nil
+ }})
+ Expect(err).NotTo(HaveOccurred())
+ defer func() { Expect(m.Close(context.Background())).To(Succeed()) }()
+ x, err := session.NewAccess(workspace.Actor{Principal: "alice", ChatID: "c1"}, workspace.View{})
+ Expect(err).NotTo(HaveOccurred())
+ chat, err := m.Open(context.Background(), x, "c1")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed())
+ timer := time.NewTimer(5 * time.Second)
+ defer timer.Stop()
+ for {
+ select {
+ case e := <-chat.Events():
+ if e.Kind == session.KindTurnEnd {
+ goto finished
+ }
+ case <-timer.C:
+ Fail("turn timed out")
+ }
+ }
+ finished:
+ Expect(failures).To(BeEmpty())
+ Expect(p.reads.Load()).To(Equal(int32(2)), "priming + one turn")
+ for range 2 {
+ r := <-p.requests
+ Expect(r.Observe(context.Background()) != model.Unknown()).To(BeFalse(), "scope not closed")
+ }
+ })
+})
+var _ = Describe("execution status product scope", func() {
+ It("closes status observations on cancellation and failure", func() {
+ for _, fail := range []bool{false, true} {
+ p := &observationProvider{started: make(chan struct{}), release: make(chan struct{}), fail: fail}
+ s := &Service{d: Deps{AddonTools: p}}
+ ctx, closeScope := s.executionScope(context.Background(), "alice", "c1")
+ consumer, cancel := context.WithCancel(ctx)
+ done := make(chan model.Value, 1)
+ go func() { v, _ := ExecutionObservation(consumer, "alice", "renamed-provider"); done <- v }()
+ select {
+ case <-p.started:
+ case <-time.After(5 * time.Second):
+ Fail("no read")
+ }
+ cancel()
+ Expect(<-done).To(Equal(model.Unknown()))
+ if fail {
+ close(p.release)
+ } else {
+ closeScope()
+ }
+ v, _ := ExecutionObservation(ctx, "alice", "renamed-provider")
+ Expect(v != model.Unknown()).To(BeFalse(), v)
+ closeScope()
+ Expect(p.reads.Load() != 1).To(BeFalse(), "retried read")
+ legacy, _ := ExecutionObservation(ctx, "alice", "legacy-provider")
+ Expect(legacy).To(Equal(model.Unknown()))
+ }
+ })
+})
+
+var _ = Describe("execution status product scope", func() {
+ It("retains the admitted status presentation binding per request", func() {
+ p := &observationProvider{}
+ s := &Service{d: Deps{AddonTools: p}}
+ ctx, closeScope := s.executionScope(context.Background(), "alice", "chat-one")
+ defer closeScope()
+ v := executionStatusView(ctx, "alice", "renamed-provider")
+ Expect(v.Readiness != "available" || v.Isolation != "isolated" || v.Generation != "1" || v.Incarnation != "one").To(BeFalse(), "missing or unredacted admitted presentation binding", v)
+ Expect(*executionStatusView(ctx, "alice", "renamed-provider")).To(Equal(*v))
+ Expect(executionStatusView(ctx, "bob", "renamed-provider").Readiness).To(Equal("unknown"))
+ next, closeNext := s.executionScope(context.Background(), "alice", "chat-two")
+ defer closeNext()
+ Expect(executionStatusView(next, "alice", "renamed-provider").Readiness != "available").To(BeFalse(), "fresh request lost status")
+ Expect(p.reads.Load() != 2).To(BeFalse(), "DTO re-probed within request")
+ })
+})
+
+var _ = Describe("execution status product scope", func() {
+ It("leaves unbound status presentation unknown", func() {
+ s := &Service{}
+ ctx, done := s.executionScope(context.Background(), "alice", "chat")
+ defer done()
+ v := executionStatusView(ctx, "alice", "")
+ Expect(v.Readiness != "unknown" || v.Isolation != "unknown" || v.Generation != "" || v.Incarnation != "").To(BeFalse(), "unbound status manufactured identity", v)
+ })
+})
diff --git a/core/services/studio/executionrequest/README.md b/core/services/studio/executionrequest/README.md
new file mode 100644
index 000000000..95b2bc7b9
--- /dev/null
+++ b/core/services/studio/executionrequest/README.md
@@ -0,0 +1,43 @@
+# Request-local execution observation shell
+
+`New(parent, binding, q, eligibility, IO, Clock)` starts a fresh **Unbound**
+model and submits its authoritative `Bind`. The caller must generate Q and
+obtain the binding from the owner; this package does not admit providers.
+Call `defer request.Close()` at the enclosing request or turn boundary.
+Nested prompt, status and egress consumers call `Observe` on that same object.
+No context attachment or production entry-point integration exists yet.
+
+`IO.Read` handles the reducer's sole `Read` effect. It must preserve B/Q through
+the carrier, decode the strict typed contract, and honor cancellation.
+`IO.Validate` checks current owner authority, current selection and exact runtime
+identity. It runs after the read and before each settled observation consumption.
+Nil callbacks cannot produce a positive observation. Status never grants tool
+access or proves network-policy enforcement.
+
+The shell serializes actual `executionstatus.Reduce` events. `Publish` wakes
+consumers, `Cancel` cancels the read, and `Read` starts its I/O goroutine. A
+five-second maximum deadline starts at construction and respects an earlier
+parent deadline. A clock callback supplies `Tick`; parent cancellation supplies
+terminal `Close`. Explicit lifecycle/permission/provider changes call
+`Invalidate`. There is deliberately no rebind method: a new admission lifetime
+requires a new request object. Settled failures cannot retry because the reducer
+ignores subsequent probes (R7), not because of a shell retry flag.
+
+A consumer's own canceled wait returns unknown without closing other consumers.
+Close/Invalidate stop waiting consumers without waiting for an uncooperative I/O
+callback. Late callbacks still enter the reducer and cannot restore withdrawn or
+closed values. I/O implementations remain responsible for stopping their work.
+Validation and consumption cannot be atomic with subsequent execution: execution
+must retain its independent owner admission and freshness checks.
+
+`Trace` provides detached reducer transitions for diagnostics and tests. Binding
+identities must be opaque tokens, never credentials. Model values carry readiness
+and isolation only; typed wire explanations and backend/API projections belong
+to the subsequent integration task.
+
+Focused verification (with the repository's prepared offline environment):
+
+```sh
+go test -race -count=1 ./core/services/studio/executionrequest ./pkg/addons/model/executionstatus
+go run ./pkg/addons/model/executionstatus/cmd/viewer -check
+```
diff --git a/core/services/studio/executionrequest/request.go b/core/services/studio/executionrequest/request.go
new file mode 100644
index 000000000..317e5c4cf
--- /dev/null
+++ b/core/services/studio/executionrequest/request.go
@@ -0,0 +1,217 @@
+// SPDX-License-Identifier: MIT
+
+// Package executionrequest runs the effects of one executionstatus model.
+// It owns no authority, provider selection, cross-request cache or retry policy.
+package executionrequest
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+)
+
+// IO must preserve the admitted binding and observation ID through the owner
+// carrier. Read supplies a payload only after strict contract decoding. Validate
+// checks current owner authority, selection and runtime identity, never reported
+// readiness. Implementations must honor context cancellation.
+type IO struct {
+ Read func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error)
+ Validate func(context.Context, model.Binding) error
+}
+
+type Timer interface{ Stop() bool }
+
+// Clock preserves monotonic time; fake clocks let tests deliver the deadline
+// without sleeps. AfterFunc must schedule callbacks, not invoke them inline.
+type Clock interface {
+ Now() time.Time
+ AfterFunc(time.Duration, func()) Timer
+}
+type realClock struct{}
+
+func (realClock) Now() time.Time { return time.Now() }
+func (realClock) AfterFunc(d time.Duration, fn func()) Timer { return time.AfterFunc(d, fn) }
+
+// Request is an explicitly owned observation lifetime. Call Close at the real
+// enclosing request/turn boundary. Do not retain it in a service or chat cache.
+// Events are serialized under mu; only Reduce decides transitions and effects.
+type Request struct {
+ mu sync.Mutex
+ ctx context.Context
+ b model.Binding
+ q model.ObservationID
+ io IO
+ clock Clock
+ origin, deadline time.Time
+ state model.State
+ trace []model.Transition
+ changed chan struct{}
+ cancel context.CancelFunc
+ timer Timer
+ stopParent func() bool
+}
+
+// New consumes an already authoritative admission, not a frontend selection
+// claim. Q is generated by the host caller once per admitted request. A nil
+// clock uses real monotonic time. Missing IO fails closed without retry.
+func New(ctx context.Context, b model.Binding, q model.ObservationID, eligibility model.Eligibility, io IO, clock Clock) *Request {
+ if clock == nil {
+ clock = realClock{}
+ }
+ now := clock.Now()
+ deadline := now.Add(5 * time.Second)
+ if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
+ deadline = d
+ }
+ r := &Request{ctx: ctx, b: b, q: q, io: io, clock: clock, origin: now, deadline: deadline, state: model.Unbound{}, changed: make(chan struct{})}
+ r.mu.Lock()
+ r.step(model.Bind{B: b, Q: q, Eligibility: eligibility})
+ r.mu.Unlock()
+ stop := context.AfterFunc(ctx, r.Close)
+ r.mu.Lock()
+ r.stopParent = stop
+ if _, closed := r.state.(model.Closed); closed {
+ stop()
+ }
+ r.mu.Unlock()
+ return r
+}
+func (r *Request) now() int64 { return r.clock.Now().Sub(r.origin).Nanoseconds() }
+
+// step runs reducer effects while holding mu. External reads run outside mu;
+// their completion feeds an event, including after cancellation or withdrawal.
+func (r *Request) step(e model.Event) {
+ tr := model.Reduce(r.state, e)
+ r.state = tr.State
+ // Copy slices so Trace callers cannot alter the shell's history.
+ tr.Effects = append([]model.Effect(nil), tr.Effects...)
+ r.trace = append(r.trace, tr)
+ for _, effect := range tr.Effects {
+ switch effect.(type) {
+ case model.Read:
+ ctx, cancel := context.WithCancel(r.ctx)
+ r.cancel = cancel
+ r.timer = r.clock.AfterFunc(r.deadline.Sub(r.clock.Now()), func() { r.mu.Lock(); defer r.mu.Unlock(); r.step(model.Tick{Now: r.now()}) })
+ go r.read(ctx, cancel)
+ case model.Cancel:
+ if r.cancel != nil {
+ r.cancel()
+ }
+ case model.Publish:
+ close(r.changed)
+ r.changed = make(chan struct{})
+ }
+ }
+ if _, pending := r.state.(model.Pending); !pending && r.timer != nil {
+ r.timer.Stop()
+ r.timer = nil
+ }
+}
+func (r *Request) read(ctx context.Context, cancel context.CancelFunc) {
+ defer cancel()
+ payload := model.InvalidPayload()
+ var err error
+ if r.io.Read != nil {
+ payload, err = r.io.Read(ctx, r.b, r.q, r.deadline)
+ }
+ // The carrier's return is not freshness proof. Check before any positive
+ // publication; the consuming caller checks again before using it.
+ fresh := r.io.Validate != nil && r.io.Validate(ctx, r.b) == nil
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.ctx.Err() != nil {
+ r.step(model.Close{})
+ }
+ if !fresh {
+ r.step(model.Invalidate{Cause: "stale-binding"})
+ }
+ if err != nil {
+ r.step(model.Failure{B: r.b, Q: r.q, Cause: "read-failed", Now: r.now()})
+ return
+ }
+ r.step(model.Reply{B: r.b, Q: r.q, Payload: payload, Now: r.now()})
+}
+
+// Observe coalesces consumers of the same request. A consumer's cancellation
+// stops only its wait; parent cancellation closes the entire observation.
+// A settled error never causes another read: the reducer enforces R7.
+func (r *Request) Observe(ctx context.Context) model.Value {
+ r.mu.Lock()
+ if r.ctx.Err() != nil {
+ r.step(model.Close{})
+ }
+ r.step(model.Probe{B: r.b, Q: r.q, Now: r.now(), Deadline: r.deadline.Sub(r.origin).Nanoseconds()})
+ for {
+ if ctx.Err() != nil {
+ r.mu.Unlock()
+ return model.Unknown()
+ }
+ if _, pending := r.state.(model.Pending); !pending {
+ if _, observed := r.state.(model.Observed); !observed {
+ v := model.Project(r.state)
+ r.mu.Unlock()
+ return v
+ }
+ r.mu.Unlock()
+ fresh := r.io.Validate != nil && r.io.Validate(ctx, r.b) == nil
+ r.mu.Lock()
+ if r.ctx.Err() != nil {
+ r.step(model.Close{})
+ }
+ // A canceled consumer cannot establish freshness failure for peers.
+ // Parent cancellation above still closes the shared observation.
+ if ctx.Err() != nil {
+ r.mu.Unlock()
+ return model.Unknown()
+ }
+ if !fresh {
+ r.step(model.Invalidate{Cause: "stale-binding"})
+ }
+ v := model.Project(r.state)
+ if ctx.Err() != nil {
+ v = model.Unknown()
+ }
+ r.mu.Unlock()
+ return v
+ }
+ changed := r.changed
+ r.mu.Unlock()
+ select {
+ case <-changed:
+ case <-ctx.Done():
+ return model.Unknown()
+ }
+ r.mu.Lock()
+ }
+}
+
+func (r *Request) Invalidate(cause model.Cause) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.step(model.Invalidate{Cause: cause})
+}
+func (r *Request) Close() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.step(model.Close{})
+ if r.stopParent != nil {
+ r.stopParent()
+ }
+}
+
+// Trace returns detached reducer transitions for model/runtime correspondence
+// checks. It includes ignored events, never credentials or wire payloads.
+func (r *Request) Trace() []model.Transition {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ out := append([]model.Transition(nil), r.trace...)
+ for i := range out {
+ out[i].Effects = append([]model.Effect(nil), out[i].Effects...)
+ }
+ return out
+}
+
+// Binding returns the immutable admitted identity for presentation only.
+func (r *Request) Binding() model.Binding { return r.b }
diff --git a/core/services/studio/executionrequest/request_test.go b/core/services/studio/executionrequest/request_test.go
new file mode 100644
index 000000000..bc2c46b1a
--- /dev/null
+++ b/core/services/studio/executionrequest/request_test.go
@@ -0,0 +1,376 @@
+// SPDX-License-Identifier: MIT
+package executionrequest_test
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ shell "github.com/mudler/LocalAI/core/services/studio/executionrequest"
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestRequest(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Request-local execution observation")
+}
+
+type timer struct {
+ fn func()
+ stopped bool
+}
+
+func (t *timer) Stop() bool { old := t.stopped; t.stopped = true; return !old }
+
+type clock struct {
+ mu sync.Mutex
+ now time.Time
+ timers []*timer
+}
+
+func (c *clock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now }
+func (c *clock) AfterFunc(_ time.Duration, fn func()) shell.Timer {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ t := &timer{fn: fn}
+ c.timers = append(c.timers, t)
+ return t
+}
+func (c *clock) advance(d time.Duration) {
+ c.mu.Lock()
+ c.now = c.now.Add(d)
+ ts := append([]*timer(nil), c.timers...)
+ c.mu.Unlock()
+ for _, t := range ts {
+ t.fn()
+ }
+}
+
+// deadlineConsumer delivers an expiry at the validation boundary without a wall timer.
+type deadlineConsumer struct {
+ context.Context
+ deadline time.Time
+ done chan struct{}
+}
+
+func (c *deadlineConsumer) Deadline() (time.Time, bool) { return c.deadline, true }
+func (c *deadlineConsumer) Done() <-chan struct{} { return c.done }
+func (c *deadlineConsumer) Err() error {
+ select {
+ case <-c.done:
+ return context.DeadlineExceeded
+ default:
+ return nil
+ }
+}
+
+var _ = Describe("Reducer shell", func() {
+ var b model.Binding
+ var c *clock
+ var io shell.IO
+ BeforeEach(func() {
+ b = model.Binding{Actor: "actor/credential", Addon: "renamed", Generation: "1", Incarnation: "first", ExecutionTool: "run", StatusTool: "health"}
+ c = &clock{now: time.Now()}
+ io.Validate = func(context.Context, model.Binding) error { return nil }
+ io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
+ }
+ })
+ It("coalesces concurrent prompt/status/egress consumers into one exact read", func() {
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ calls := 0
+ var mu sync.Mutex
+ io.Read = func(ctx context.Context, got model.Binding, q model.ObservationID, deadline time.Time) (model.Payload, error) {
+ mu.Lock()
+ calls++
+ mu.Unlock()
+ if got != b || q != "q" || !deadline.Equal(c.Now().Add(5*time.Second)) {
+ return model.InvalidPayload(), errors.New("wrong binding")
+ }
+ close(entered)
+ <-release
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ results := make(chan model.Value, 3)
+ for range 3 {
+ go func() { results <- r.Observe(context.Background()) }()
+ }
+ <-entered
+ close(release)
+ for range 3 {
+ Expect(<-results).To(Equal(model.Value{Readiness: model.Available, Isolation: model.Isolated}))
+ }
+ mu.Lock()
+ Expect(calls).To(Equal(1))
+ mu.Unlock()
+ Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated))
+ reads := 0
+ for _, tr := range r.Trace() {
+ for _, e := range tr.Effects {
+ if _, ok := e.(model.Read); ok {
+ reads++
+ }
+ }
+ }
+ Expect(reads).To(Equal(1))
+ })
+ for _, kind := range []string{"failure", "malformed"} {
+ It("never retries settled "+kind, func() {
+ calls := 0
+ io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
+ calls++
+ if kind == "failure" {
+ return model.InvalidPayload(), errors.New("offline")
+ }
+ return model.InvalidPayload(), nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ Expect(calls).To(Equal(1))
+ Expect(r.Trace()[2].Rule).To(Equal(model.R4))
+ })
+ }
+ It("settles missing contracts without dispatch", func() {
+ io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
+ Fail("unexpected dispatch")
+ return model.InvalidPayload(), nil
+ }
+ r := shell.New(context.Background(), b, "q", "no-contract", io, c)
+ defer r.Close()
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ })
+ It("times out at five seconds, cancels the read and ignores late success", func() {
+ entered := make(chan struct{})
+ canceled := make(chan struct{})
+ release := make(chan struct{})
+ returned := make(chan struct{})
+ io.Read = func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) {
+ close(entered)
+ <-ctx.Done()
+ close(canceled)
+ <-release
+ defer close(returned)
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ out := make(chan model.Value, 1)
+ go func() { out <- r.Observe(context.Background()) }()
+ <-entered
+ c.advance(5 * time.Second)
+ Expect(<-out).To(Equal(model.Unknown()))
+ <-canceled
+ close(release)
+ <-returned
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ })
+ It("uses the earlier parent deadline", func() {
+ deadline := c.Now().Add(time.Second)
+ ctx, cancel := context.WithDeadline(context.Background(), deadline)
+ defer cancel()
+ io.Read = func(_ context.Context, _ model.Binding, _ model.ObservationID, d time.Time) (model.Payload, error) {
+ if !d.Equal(deadline) {
+ return model.InvalidPayload(), errors.New("wrong deadline")
+ }
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Host}), nil
+ }
+ r := shell.New(ctx, b, "q", model.Eligible, io, c)
+ defer r.Close()
+ Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Host))
+ })
+ for _, reason := range []string{"close", "parent-cancel", "disable", "same-generation-incarnation", "current-selection"} {
+ It("withdraws pending reads on "+reason, func() {
+ entered := make(chan struct{})
+ canceled := make(chan struct{})
+ release := make(chan struct{})
+ io.Read = func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) {
+ close(entered)
+ <-ctx.Done()
+ close(canceled)
+ <-release
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ r := shell.New(ctx, b, "q", model.Eligible, io, c)
+ defer r.Close()
+ out := make(chan model.Value, 1)
+ go func() { out <- r.Observe(context.Background()) }()
+ <-entered
+ switch reason {
+ case "close":
+ r.Close()
+ case "parent-cancel":
+ cancel()
+ default:
+ r.Invalidate(model.Cause(reason))
+ }
+ Expect(<-out).To(Equal(model.Unknown()))
+ <-canceled
+ close(release)
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ })
+ }
+ It("checks freshness after the carrier returns and on every later consumption", func() {
+ var mu sync.Mutex
+ valid := true
+ io.Validate = func(context.Context, model.Binding) error {
+ mu.Lock()
+ defer mu.Unlock()
+ if !valid {
+ return errors.New("replaced")
+ }
+ return nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated))
+ mu.Lock()
+ valid = false
+ mu.Unlock()
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ })
+ It("rejects a stale successful carrier reply before publishing", func() {
+ io.Validate = func(context.Context, model.Binding) error { return errors.New("incarnation replaced") }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ for _, tr := range r.Trace() {
+ Expect(tr.Rule).NotTo(Equal(model.R3))
+ }
+ })
+ It("keeps independent actors and requests separate", func() {
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ other := b
+ other.Actor = "other"
+ s := shell.New(context.Background(), other, "other-q", model.Eligible, io, c)
+ defer s.Close()
+ r.Invalidate("permission")
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ Expect(s.Observe(context.Background()).Isolation).To(Equal(model.Isolated))
+ })
+ It("does not return a positive result to a consumer canceled during freshness validation", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ validations := 0
+ io.Validate = func(context.Context, model.Binding) error {
+ validations++
+ if validations == 2 {
+ cancel()
+ }
+ return nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ Expect(r.Observe(ctx)).To(Equal(model.Unknown()))
+ })
+
+ for _, kind := range []string{"canceled", "deadline-exceeded"} {
+ It("keeps consumer validation "+kind+" local to the consumer", func() {
+ var consumer context.Context
+ var expire func()
+ wantErr := context.Canceled
+ if kind == "canceled" {
+ consumer, expire = context.WithCancel(context.Background())
+ defer expire()
+ } else {
+ ctx := &deadlineConsumer{Context: context.Background(), deadline: c.Now().Add(time.Second), done: make(chan struct{})}
+ consumer = ctx
+ expire = func() { close(ctx.done) }
+ wantErr = context.DeadlineExceeded
+ }
+ reads := 0
+ io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
+ reads++
+ return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
+ }
+ io.Validate = func(ctx context.Context, _ model.Binding) error {
+ if ctx == consumer {
+ expire()
+ return ctx.Err()
+ }
+ return nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated))
+ Expect(r.Observe(consumer)).To(Equal(model.Unknown()))
+ Expect(consumer.Err()).To(Equal(wantErr))
+ Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated))
+ Expect(reads).To(Equal(1))
+ rules := []model.Rule{}
+ readEffects := 0
+ for _, tr := range r.Trace() {
+ rules = append(rules, tr.Rule)
+ for _, effect := range tr.Effects {
+ if _, ok := effect.(model.Read); ok {
+ readEffects++
+ }
+ }
+ }
+ Expect(rules).To(Equal([]model.Rule{model.R1, model.R2, model.R3, model.R7, model.R7}))
+ Expect(readEffects).To(Equal(1))
+ })
+ }
+
+ It("matches the normative success/duplicate-probe/close rule and effect trace", func() {
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated))
+ Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated))
+ r.Close()
+ trace := r.Trace()
+ rules := []model.Rule{}
+ for _, tr := range trace {
+ rules = append(rules, tr.Rule)
+ }
+ Expect(rules).To(Equal([]model.Rule{model.R1, model.R2, model.R3, model.R7, model.R10}))
+ Expect(trace[0].Effects).To(Equal([]model.Effect{model.Publish{Value: model.Unknown()}}))
+ Expect(trace[1].Effects).To(Equal([]model.Effect{model.Read{B: b, Q: "q", Deadline: int64(5 * time.Second)}, model.Publish{Value: model.Unknown()}}))
+ Expect(trace[2].Effects).To(Equal([]model.Effect{model.Publish{Value: model.Value{Readiness: model.Available, Isolation: model.Isolated}}}))
+ Expect(trace[3].Effects).To(BeEmpty())
+ Expect(trace[4].Effects).To(Equal([]model.Effect{model.Publish{Value: model.Unknown()}}))
+ trace[1].Effects[0] = model.Cancel{Q: "tampered"}
+ Expect(r.Trace()[1].Effects[0]).To(BeAssignableToTypeOf(model.Read{}))
+ })
+ It("expires before the first probe without dispatch", func() {
+ calls := 0
+ io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
+ calls++
+ return model.InvalidPayload(), nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ c.advance(5 * time.Second)
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ Expect(calls).To(BeZero())
+ Expect(r.Trace()[1].Rule).To(Equal(model.R9))
+ })
+ It("fails closed when the freshness callback is absent", func() {
+ io.Validate = nil
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ defer r.Close()
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ })
+
+ It("normalizes unavailable readiness and keeps Closed absorbing", func() {
+ io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
+ return model.ValidPayload(model.Value{Readiness: model.Unavailable, Isolation: model.Isolated}), nil
+ }
+ r := shell.New(context.Background(), b, "q", model.Eligible, io, c)
+ Expect(r.Observe(context.Background())).To(Equal(model.Value{Readiness: model.Unavailable, Isolation: model.UnknownIsolation}))
+ r.Close()
+ r.Invalidate("late")
+ Expect(r.Observe(context.Background())).To(Equal(model.Unknown()))
+ Expect(r.Trace()[len(r.Trace())-1].State).To(Equal(model.Closed{}))
+ })
+})
diff --git a/core/services/studio/executionselection.go b/core/services/studio/executionselection.go
new file mode 100644
index 000000000..199f9ed88
--- /dev/null
+++ b/core/services/studio/executionselection.go
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: MIT
+package studio
+
+import (
+ "slices"
+
+ "github.com/mudler/LocalAI/core/services/studio/session"
+)
+
+// AddonToolEligible is the common chat/owner eligibility policy. Agent grants
+// only narrow access. Unattended chats without an agent never get action tools.
+func AddonToolEligible(d AddonTool, rec session.ChatRecord, grants []string) bool {
+ return !slices.Contains(rec.AddonsOff, d.AddonID) && !reservedToolNames[d.Name] &&
+ (rec.AgentID == "" || slices.Contains(grants, d.AddonID)) &&
+ !(d.Action && rec.Origin != "" && rec.AgentID == "")
+}
+
+// SelectExecutionProvider uses the same install ordering and pinned-idle rule
+// as chat shadow resolution. Installed but ineligible pins never fall through.
+func SelectExecutionProvider(defs []AddonTool, installed []AddonShadower, rec session.ChatRecord, grants []string, pin string) (AddonTool, bool) {
+ var ids []string
+ when := map[string]string{}
+ sh := shadowers{byName: map[string][]string{}}
+ for _, d := range installed {
+ if slices.Contains(d.Tools, "bash") {
+ sh.byName["bash"] = append(sh.byName["bash"], d.AddonID)
+ }
+ }
+ for _, d := range defs {
+ if d.Shadows == "bash" && AddonToolEligible(d, rec, grants) {
+ if !slices.Contains(ids, d.AddonID) {
+ ids = append(ids, d.AddonID)
+ }
+ when[d.AddonID] = d.InstalledAt
+ }
+ }
+ if sh.pinnedButIdle("bash", pin, ids) {
+ return AddonTool{}, false
+ }
+ chosen := ChooseProvider(ids, when, pin)
+ for _, d := range defs {
+ if chosen != "" && d.AddonID == chosen && d.Shadows == "bash" && AddonToolEligible(d, rec, grants) {
+ return d, true
+ }
+ }
+ return AddonTool{}, false
+}
diff --git a/core/services/studio/executionselection_test.go b/core/services/studio/executionselection_test.go
new file mode 100644
index 000000000..782302b61
--- /dev/null
+++ b/core/services/studio/executionselection_test.go
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: MIT
+package studio
+
+import (
+ "github.com/mudler/LocalAI/core/services/studio/session"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("execution binding selection policy", func() {
+ It("uses oldest install ordering and never substitutes another installed pinned-idle provider", func() {
+ defs := []AddonTool{{Name: "addon__new__run", AddonID: "new", Shadows: "bash", Action: true, InstalledAt: "2025-02-01T00:00:00Z"}, {Name: "addon__old__run", AddonID: "old", Shadows: "bash", Action: true, InstalledAt: "2025-01-01T00:00:00Z"}}
+ installed := []AddonShadower{{AddonID: "new", Tools: []string{"bash"}}, {AddonID: "old", Tools: []string{"bash"}}}
+ rec := session.ChatRecord{}
+ selected, ok := SelectExecutionProvider(defs, installed, rec, nil, "")
+ Expect(ok).To(BeTrue())
+ Expect(selected.AddonID).To(Equal("old"))
+ rec.AddonsOff = []string{"old"}
+ _, ok = SelectExecutionProvider(defs, installed, rec, nil, "old")
+ Expect(ok).To(BeFalse())
+ selected, ok = SelectExecutionProvider(defs, installed, rec, nil, "not-installed")
+ Expect(ok).To(BeTrue())
+ Expect(selected.AddonID).To(Equal("new"))
+ _, ok = SelectExecutionProvider(defs[:1], installed, session.ChatRecord{}, nil, "old")
+ Expect(ok).To(BeFalse())
+ })
+})
diff --git a/core/services/studio/service.go b/core/services/studio/service.go
index 66851a65b..64b7d262c 100644
--- a/core/services/studio/service.go
+++ b/core/services/studio/service.go
@@ -424,7 +424,7 @@ func New(d Deps) (*Service, error) {
s.shared = sharedfolders.New(sharedfolders.Options{Files: d.Files, Consents: d.PageConsents, Audit: d.SharedFolderAudit, Users: s.folderUsers()})
// A chat mounts the shared folders of its owner at /workspace/shared-.
mgr, err := session.New(session.Options{Store: d.Store, Workspace: d.Files.Store(), Harness: h, StateDir: d.StateDir, Inputs: s.inputs, ChatTools: s.chatTools,
- Shared: s.shared.Roots, Resolve: s.resolveAccess, Logger: log})
+ Shared: s.shared.Roots, Resolve: s.resolveAccess, ProductScope: s.productScope, Logger: log})
if err != nil {
return nil, err
}
diff --git a/core/services/studio/session/chat.go b/core/services/studio/session/chat.go
index 067e8035b..1726bf4e0 100644
--- a/core/services/studio/session/chat.go
+++ b/core/services/studio/session/chat.go
@@ -854,6 +854,18 @@ func (c *Chat) runTurn(text string, refresh bool, media []Media) int {
c.mu.Unlock()
}()
+ if f := c.m.o.ProductScope; f != nil {
+ c.accMu.RLock()
+ x := c.acc
+ c.accMu.RUnlock()
+ var closeScope func()
+ ctx, closeScope = f(ctx, x)
+ defer closeScope()
+ c.mu.Lock()
+ c.turnCtx = ctx
+ c.mu.Unlock()
+ }
+
c.endMu.Lock()
c.emit(Event{Kind: KindTurnStart, Turn: n})
c.endMu.Unlock()
diff --git a/core/services/studio/session/chattools.go b/core/services/studio/session/chattools.go
index f2f45fcef..99886b75a 100644
--- a/core/services/studio/session/chattools.go
+++ b/core/services/studio/session/chattools.go
@@ -563,6 +563,11 @@ func (c *Chat) primeChatTools(ctx context.Context) error {
c.accMu.RLock()
x := c.acc
c.accMu.RUnlock()
+ if scope := c.m.o.ProductScope; scope != nil {
+ var closeScope func()
+ ctx, closeScope = scope(ctx, x)
+ defer closeScope()
+ }
tt, err := callChatTools(ctx, f, x, c.m.log)
if mandatoryChatToolsError(err) {
return err
diff --git a/core/services/studio/session/manager.go b/core/services/studio/session/manager.go
index dd54b3e9f..2b11af354 100644
--- a/core/services/studio/session/manager.go
+++ b/core/services/studio/session/manager.go
@@ -58,6 +58,14 @@ type Options struct {
// is dropped. An error from the hook does not fail the turn: it runs with
// the sandbox tools only. Nil: only the sandbox tools.
ChatTools func(ctx context.Context, x Access) (TurnTools, error)
+ // ProductScope optionally attaches request-local product state before any
+ // turn tool construction. Its context is used through the actual turn end;
+ // close releases the state after checkpoint and persistence, not when
+ // ChatTools returns. Priming gets a separate scope closed before Open returns.
+ // The hook must return a non-nil context derived from ctx and a non-nil
+ // close function. It must not retain state on the chat or manager. Nil
+ // leaves the context unchanged. Consumers must preserve cancellation.
+ ProductScope func(ctx context.Context, x Access) (context.Context, func())
// Resolve gives the access of a chat again, for a chat whose sandbox
// could not mount its view (a shared folder went away). a is the chat's
// actor. Nil: the view is built again for the actor, as UserAccess does.
diff --git a/core/services/studio/session/productscope_test.go b/core/services/studio/session/productscope_test.go
new file mode 100644
index 000000000..116b87842
--- /dev/null
+++ b/core/services/studio/session/productscope_test.go
@@ -0,0 +1,192 @@
+// SPDX-License-Identifier: MIT
+package session_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sync/atomic"
+ "time"
+
+ shell "github.com/mudler/LocalAI/core/services/studio/executionrequest"
+ "github.com/mudler/LocalAI/core/services/studio/session"
+ "github.com/mudler/LocalAI/core/services/studio/session/sessiontest"
+ "github.com/mudler/LocalAI/core/services/studio/workspace"
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type productScopeKey struct{}
+type productScope struct {
+ request *shell.Request
+ q model.ObservationID
+ closed atomic.Bool
+}
+
+var _ = Describe("product request scopes", func() {
+ newChat := func(o session.Options) *session.Chat {
+ o.Store, o.StateDir = sessiontest.NewMemoryStore(), GinkgoT().TempDir()
+ m, err := session.New(o)
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { Expect(m.Close(context.Background())).To(Succeed()) })
+ chat, err := openAs(m, context.Background(), workspace.Actor{Principal: "alice", ChatID: "c1"}, "c1")
+ Expect(err).NotTo(HaveOccurred())
+ return chat
+ }
+ waitTurn := func(chat *session.Chat) {
+ timer := time.NewTimer(5 * time.Second)
+ defer timer.Stop()
+ for {
+ select {
+ case e, open := <-chat.Events():
+ Expect(open).To(BeTrue(), "event stream closed before turn end")
+ if e.Kind == session.KindTurnEnd {
+ return
+ }
+ case <-timer.C:
+ Fail("turn did not end")
+ }
+ }
+ }
+
+ It("keeps one scope through prompt/status/egress and isolates priming and subsequent turns", func() {
+ errors := make(chan error, 16)
+ report := func(err error) error { errors <- err; return err }
+ var serial, reads atomic.Int32
+ scopes := make(chan *productScope, 3)
+ consumed := make(chan model.ObservationID, 7)
+ b := model.Binding{Actor: "alice", Addon: "renamed-provider", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "health"}
+ expected := model.Value{Readiness: model.Available, Isolation: model.Isolated}
+ o := session.Options{Harness: &sessiontest.FakeHarness{Turns: [][]sessiontest.Step{
+ {{Tool: "status", Args: "{}"}, {Tool: "egress", Args: "{}"}},
+ {{Tool: "status", Args: "{}"}, {Tool: "egress", Args: "{}"}},
+ }}}
+ o.ProductScope = func(ctx context.Context, x session.Access) (context.Context, func()) {
+ if x.Actor().ChatID != "c1" {
+ _ = report(fmt.Errorf("wrong chat ID")) // report queues the failure for the spec goroutine.
+ }
+ s := &productScope{q: model.ObservationID(fmt.Sprint(serial.Add(1)))}
+ s.request = shell.New(ctx, b, s.q, model.Eligible, shell.IO{
+ Read: func(ctx context.Context, got model.Binding, q model.ObservationID, deadline time.Time) (model.Payload, error) {
+ reads.Add(1)
+ if got != b || q != s.q || time.Until(deadline) > 5*time.Second {
+ return model.InvalidPayload(), fmt.Errorf("wrong admission")
+ }
+ return model.ValidPayload(expected), nil
+ },
+ Validate: func(context.Context, model.Binding) error { return nil },
+ }, nil)
+ scopes <- s
+ return context.WithValue(ctx, productScopeKey{}, s), func() { s.request.Close(); s.closed.Store(true) }
+ }
+ o.ChatTools = func(ctx context.Context, _ session.Access) (session.TurnTools, error) {
+ s, ok := ctx.Value(productScopeKey{}).(*productScope)
+ if !ok {
+ return session.TurnTools{}, report(fmt.Errorf("missing scope"))
+ }
+ if s.closed.Load() || s.request.Observe(ctx) != expected {
+ return session.TurnTools{}, report(fmt.Errorf("prompt scope not live"))
+ }
+ consumed <- s.q
+ var tools []session.Tool
+ for _, name := range []string{"status", "egress"} {
+ tools = append(tools, session.Tool{Name: name, Description: name, Schema: json.RawMessage(`{"type":"object"}`), Run: func(ctx context.Context, _ json.RawMessage) (string, error) {
+ current, ok := ctx.Value(productScopeKey{}).(*productScope)
+ if !ok || current != s || s.closed.Load() || s.request.Observe(ctx) != expected {
+ return "", report(fmt.Errorf("scope not live or shared"))
+ }
+ consumed <- s.q
+ return "ok", nil
+ }})
+ }
+ return session.TurnTools{Tools: tools, Prompt: "stable prompt"}, nil
+ }
+ chat := newChat(o)
+ var prime *productScope
+ Eventually(scopes, 5*time.Second).Should(Receive(&prime))
+ Expect(prime.closed.Load()).To(BeTrue())
+ for range 2 {
+ Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed())
+ waitTurn(chat)
+ var s *productScope
+ Eventually(scopes, 5*time.Second).Should(Receive(&s))
+ Expect(s.closed.Load()).To(BeTrue())
+ }
+ Expect(errors).To(BeEmpty())
+ Expect(serial.Load()).To(Equal(int32(3)))
+ Expect(reads.Load()).To(Equal(int32(3)))
+ var got []model.ObservationID
+ for len(consumed) > 0 {
+ got = append(got, <-consumed)
+ }
+ Expect(got).To(Equal([]model.ObservationID{"1", "2", "2", "2", "3", "3", "3"}))
+ })
+
+ for _, stop := range []bool{false, true} {
+ It(fmt.Sprintf("closes pending observations at the real turn boundary (stop=%t)", stop), func() {
+ errors := make(chan error, 8)
+ var serial atomic.Int32
+ scopes := make(chan *productScope, 2)
+ entered, canceled := make(chan struct{}, 2), make(chan struct{}, 2)
+ results := make(chan model.Value, 2)
+ block := make(chan struct{})
+ h := &sessiontest.FakeHarness{}
+ if stop {
+ h.Turns = [][]sessiontest.Step{{{Block: block}}}
+ }
+ o := session.Options{Harness: h}
+ o.ProductScope = func(ctx context.Context, _ session.Access) (context.Context, func()) {
+ s := &productScope{q: model.ObservationID(fmt.Sprint(serial.Add(1)))}
+ s.request = shell.New(ctx, model.Binding{Actor: "alice", Addon: "renamed", ExecutionTool: "run", StatusTool: "health"}, s.q, model.Eligible, shell.IO{
+ Read: func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) {
+ entered <- struct{}{}
+ <-ctx.Done()
+ canceled <- struct{}{}
+ return model.InvalidPayload(), ctx.Err()
+ }, Validate: func(context.Context, model.Binding) error { return nil },
+ }, nil)
+ scopes <- s
+ return context.WithValue(ctx, productScopeKey{}, s), func() { s.request.Close(); s.closed.Store(true) }
+ }
+ o.ChatTools = func(ctx context.Context, _ session.Access) (session.TurnTools, error) {
+ s, ok := ctx.Value(productScopeKey{}).(*productScope)
+ if !ok {
+ err := fmt.Errorf("missing scope")
+ errors <- err
+ return session.TurnTools{}, err
+ }
+ go func() { results <- s.request.Observe(context.Background()) }()
+ select {
+ case <-entered:
+ case <-time.After(2 * time.Second):
+ err := fmt.Errorf("observation did not start")
+ errors <- err
+ return session.TurnTools{}, err
+ }
+ return session.TurnTools{}, nil
+ }
+ chat := newChat(o)
+ var prime *productScope
+ Eventually(scopes, 5*time.Second).Should(Receive(&prime))
+ Expect(prime.closed.Load()).To(BeTrue())
+ Eventually(canceled).Should(Receive())
+ Eventually(results).Should(Receive(Equal(model.Unknown())))
+ Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed())
+ var turn *productScope
+ Eventually(scopes).Should(Receive(&turn))
+ if stop {
+ Eventually(h.Sent).Should(HaveLen(1))
+ Expect(turn.closed.Load()).To(BeFalse(), "scope must outlive ChatTools")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ Expect(chat.Stop(ctx)).To(Succeed())
+ }
+ waitTurn(chat)
+ Expect(turn.closed.Load()).To(BeTrue())
+ Eventually(canceled).Should(Receive())
+ Eventually(results).Should(Receive(Equal(model.Unknown())))
+ Expect(errors).To(BeEmpty())
+ })
+ }
+})
diff --git a/core/services/studio/store/json.go b/core/services/studio/store/json.go
index e5b7fbe1f..f27ed3e91 100644
--- a/core/services/studio/store/json.go
+++ b/core/services/studio/store/json.go
@@ -24,8 +24,7 @@ import (
// jsonStore keeps chats in memory and, when it has a directory, mirrors them to
// files: one small record file per chat (rewritten atomically), one
// append-only transcript file per chat and one harness state file per chat
-// (written only when the state changes). One process owns the directory: the
-// locks below are in-process only, so two LocalAI processes must not share it.
+// (written only when the state changes). One process owns the directory.
// A cluster uses the GORM store.
//
// mu guards the map only. Each chat has its own lock, held for the whole
@@ -72,6 +71,10 @@ func NewJSON(dir string) (Store, error) {
if dir == "" {
return s, nil
}
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return nil, err
+ }
+
if err := os.MkdirAll(s.chatDir(), 0o700); err != nil {
return nil, err
}
@@ -421,7 +424,7 @@ func (s *jsonStore) CreateChat(_ context.Context, rec session.ChatRecord) error
if _, ok := s.chats[rec.ID]; ok {
return session.ErrExists
}
- c := chatState{Rec: rec}
+ c := chatState{Rec: cloneRec(rec)}
c.Rec.HarnessState = nil
c.Rec.Egress = rec.Egress.Clone()
c.Rec.Goal = rec.Goal.Clone()
@@ -460,9 +463,30 @@ func (s *jsonStore) GetChat(_ context.Context, principal, id string) (session.Ch
func cloneRec(r session.ChatRecord) session.ChatRecord {
r.SkillsOff = append([]string(nil), r.SkillsOff...)
r.AddonsOff = append([]string(nil), r.AddonsOff...)
+ r.McpOff = append([]string(nil), r.McpOff...)
r.BuiltinsOff = append([]string(nil), r.BuiltinsOff...)
r.BuiltinsOn = append([]string(nil), r.BuiltinsOn...)
- r.McpOff = append([]string(nil), r.McpOff...)
+ if r.HostConsent != nil {
+ cp := *r.HostConsent
+ r.HostConsent = &cp
+ }
+ if r.Snapshot != nil {
+ cp := *r.Snapshot
+ r.Snapshot = &cp
+ }
+ if r.Settings.Temperature != nil {
+ cp := *r.Settings.Temperature
+ r.Settings.Temperature = &cp
+ }
+ if r.Settings.TopP != nil {
+ cp := *r.Settings.TopP
+ r.Settings.TopP = &cp
+ }
+ if r.Settings.TopK != nil {
+ cp := *r.Settings.TopK
+ r.Settings.TopK = &cp
+ }
+
r.HarnessState = append([]byte(nil), r.HarnessState...)
r.Egress = r.Egress.Clone()
r.Goal = r.Goal.Clone()
@@ -499,6 +523,7 @@ func (s *jsonStore) edit(principal, id string, change func(next *chatState) erro
}
defer unlock()
next := c.chatState
+ next.Rec = cloneRec(c.Rec)
if err := change(&next); err != nil {
return err
}
@@ -513,6 +538,7 @@ func (s *jsonStore) edit(principal, id string, change func(next *chatState) erro
xlog.Warn("studio store: could not remove an old harness state file", "chat", id, "error", err)
}
}
+ next.Rec = cloneRec(next.Rec)
c.chatState = next
return nil
}
diff --git a/core/services/studio/toolnames_internal_test.go b/core/services/studio/toolnames_internal_test.go
new file mode 100644
index 000000000..75b9dba47
--- /dev/null
+++ b/core/services/studio/toolnames_internal_test.go
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: MIT
+package studio
+
+import (
+ "strings"
+
+ "github.com/mudler/LocalAI/core/services/studio/session"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("add-on stand-in identities", func() {
+ It("recognizes valid long stand-ins but rejects malformed identities", func() {
+ for _, id := range []string{"runner", "r" + strings.Repeat("x", 63)} {
+ Expect(standInRunner([]session.Tool{{Name: unavailableToolName(id), StandInFor: []string{"bash"}}})).To(Equal(id))
+ }
+ for _, name := range []string{"addon____unavailable", "addon__bad_id__unavailable", "addon__9runner__unavailable", "addon__runner__bash", "addon__runner__unavailable__extra", "runner__unavailable"} {
+ Expect(standInRunner([]session.Tool{{Name: name, StandInFor: []string{"bash"}}})).To(BeEmpty(), name)
+ }
+ Expect(standInRunner([]session.Tool{{Name: unavailableToolName("runner")}})).To(BeEmpty())
+ })
+})
diff --git a/core/services/studio/triggersrestart_test.go b/core/services/studio/triggersrestart_test.go
index 8d2a96fb3..7a6e23280 100644
--- a/core/services/studio/triggersrestart_test.go
+++ b/core/services/studio/triggersrestart_test.go
@@ -31,6 +31,12 @@ func openFrontend(dir string, sh *shared) *shared {
return &shared{chats: st, files: sh.files, agents: ag, notices: ns, trg: ts, clock: sh.clock, addons: sh.addons}
}
+// Stop every producer before reopening the files in the next frontend.
+func closeRestartFrontend(r *trigRig) {
+ r.host.Close()
+ Expect(r.svc.Close(context.Background())).To(Succeed())
+}
+
var _ = Describe("triggersrestart", func() {
It("agents triggers and notifications survive a restart with no database", func() {
ctx, dir := context.Background(), GinkgoT().TempDir()
@@ -41,7 +47,7 @@ var _ = Describe("triggersrestart", func() {
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Reviewer", Addons: []string{"office"}})
Expect(err).NotTo(HaveOccurred())
tr := r.schedule(studio.TriggerInput{AgentID: ag.ID, Kind: triggers.KindSchedule, Schedule: "once 2026-10-27 08:00", TZ: "UTC"})
- r.host.Close()
+ closeRestartFrontend(r)
// The server was down across the due time. The new process finds everything.
base.clock.Advance(2 * time.Hour)
@@ -60,7 +66,7 @@ var _ = Describe("triggersrestart", func() {
r2.pass()
runs, _ := r2.host.TriggerRuns(ctx, alice, tr.ID, 0)
Expect(runs).To(HaveLen(1), "a missed once-trigger ran %d times", len(runs))
- r2.host.Close()
+ closeRestartFrontend(r2)
// A third start still has the notification and does not run the trigger again.
third := openFrontend(dir, base)
@@ -78,7 +84,7 @@ var _ = Describe("triggersrestart", func() {
base := newShared()
r := openFrontend(dir, base).rig(saySummary, studio.TriggerOptions{})
tr := r.schedule(studio.TriggerInput{})
- r.host.Close()
+ closeRestartFrontend(r)
base.clock.Advance(3*24*time.Hour + time.Hour)
r2 := openFrontend(dir, base).rig(saySummary, studio.TriggerOptions{})
diff --git a/deploy/addons/host-policy.json b/deploy/addons/host-policy.json
new file mode 100644
index 000000000..152459252
--- /dev/null
+++ b/deploy/addons/host-policy.json
@@ -0,0 +1,11 @@
+{
+ "schemaVersion": 1,
+ "grants": {
+ "fine-tuning": ["finetune."],
+ "quantization": ["quantize."],
+ "sandbox": ["workspace.", "egress."],
+ "office": ["workspace."],
+ "web": ["workspace."],
+ "telegram": ["connector."]
+ }
+}
diff --git a/docs/content/development/addon-testing.md b/docs/content/development/addon-testing.md
index 9533977d8..6a25a8422 100644
--- a/docs/content/development/addon-testing.md
+++ b/docs/content/development/addon-testing.md
@@ -6,8 +6,53 @@ description = "Prepare and verify the toolchain for focused addon tests."
The addon verification scripts separate explicit dependency setup from offline checks.
Run all commands from the repository root. `scripts/addons/suites.json` lists the available suites and their exact case inventories.
+For typed extensions, run `contracts`, `apps`, `runtime`, `gateway`, and `host-slots`.
+The `contracts` suite includes all official package manifests and generic architecture guards. It runs `./pkg/addons/contract`,
+`./pkg/addons/packaging`, and the root architecture suite, each with an exact
+Ginkgo report inventory (including twelve official manifest audits, two policy
+packaging checks, and two architecture guards). `apps` inventories the generic
+UI fixtures; `gateway` inventories the real application owner binding fixture.
+The latter installs two differently named packages through the actual catalog
+and Manager, uses JSON chat/agent stores, selects the oldest provider, then pins
+the second and checks each declared execution/status tool and runtime identity.
+The gateway callback-only test separately checks honest binding pass-through.
+
+The shared `scripts/addons/ginkgo_report.py` comparator checks actual report
+artifacts, not just JSON syntax. Every required case must pass exactly once.
+A report may declare an explicit `focus` matching its command's Ginkgo focus;
+only excluded specs may be skipped, never required cases. Harness self-tests
+exercise missing artifacts, missing required cases, and skipped reports.
Unknown suites, empty selections, skipped required tests, and failed commands return a nonzero status.
+## Direct verification evidence and limits
+
+The bounded review verification used offline caches, `GOMAXPROCS=2`, and
+`GOFLAGS='-mod=readonly -p=2 "-ldflags=-s -w"'`. Direct commands used
+`go test -timeout=5m -count=1 -tags auth PACKAGE -ginkgo.json-report=REPORT`
+for `./pkg/addons/contract`, `./pkg/addons/packaging`, `.`,
+`./core/services/addons/apps`,
+`./core/services/addons/gateway`, and `./core/application`.
+Actual reports compared with the harness comparator contain 196 contract,
+174 packaging, 2 architecture, 10 apps, and 115 gateway cases, all passed.
+The focused `execution binding owner resolver` run passed all 8 required cases;
+its excluded specs are not required cases. Full application testing exited zero
+with 256 passed and 3 PostgreSQL-dependent Files cases skipped because
+`LOCALAI_TEST_POSTGRES_URL` was unset; it is not an all-cases acceptance pass.
+
+`sh scripts/addons/prerequisites_test.sh` passed its exact 38-case inventory,
+including missing/skipped report rejection. Model race testing and viewer tests
+passed, and `go run ./pkg/addons/model/executionstatus/cmd/viewer -check`
+verified trace coverage and deterministic data. Focused real owner artifacts
+with a removed or skipped required case were also rejected by the comparator.
+Existing contract coverage passed without an invented failing baseline.
+
+These are direct checks, **not a full harness pass**. The commands and report
+inventories are wired, but `sh scripts/addons/prepare.sh --check` remains blocked
+by missing `protoc` 26.1. No downloads or integration preparation were performed.
+Browser acceptance and named-user SQL authority integration remain unverified;
+the JSON owner fixture does not establish either. Local evidence reports and
+logs are under `.cache/addons/logs/review2-*` and are not product artifacts.
+
## Required tools
Provide these tools before running setup:
@@ -480,3 +525,58 @@ The recovery suite above remains required for crash boundaries during backup,
staging, migration and publication, including format-changing recovery-required
outcomes. The focused submission tests do not replace the real executable,
real-auth HTTP or rendered-browser acceptance gates.
+
+### Current-read execution bindings
+
+`ApplicationConfig.ChatSelectionSnapshot` returns synchronized, detached current
+provider pins and host execution mode. Store reads preserve actor scope and return
+detached records. JSON stores use their existing mutexes and atomic writes; SQL
+stores use ordinary current reads. No revision ledger or directory lock is needed.
+
+The gateway compares current selection inputs and runtime identity before dispatch
+and after the reply. The reducer binds explicit actor, credential, conversation,
+provider, tools and runtime identity. These checks are advisory, not a transaction
+or a guarantee to detect revoke-and-restore changes between reads.
+
+### Model action contract milestone
+
+Run from the repository root (these packages share the root Go module):
+
+```sh
+go test -count=1 -v -tags auth ./pkg/addons/contract ./core/services/addons/apps ./core/services/addons/packages ./core/services/addons/runtime ./addons/internal/protocol ./core/config
+```
+
+`modelactions_test.go` covers strict keys, capability exclusion, traversal and
+SDK path/count bounds, full manifest round trips and owning manifests. Apps
+specs exercise actual installed projection and slice isolation. The package and
+protocol declaration matrices include model actions independently and together
+with all other extensions. Runtime specs verify exact registration equality,
+crash recovery, receipt replay and immutable registry snapshots. Core/config
+owns vocabulary parity; the contract package does not import core/config.
+Exact contract, Apps, package and protocol spec names are in
+`scripts/addons/suites.json`. These unit checks do not certify browser dispatch,
+PostgreSQL services, or full-system harness prerequisites.
+
+
+### Model launch and canonical route regressions
+
+`core/http/react-ui/e2e/addon-model-actions.spec.js` exercises the production
+React InstalledModels inspector, gallery Open and ModelPage Overview renderer.
+Each surface covers arbitrary single providers, deterministic named collisions,
+encoded selected model values, absence without install promotion and stale
+installation refusal. The removed-route case asserts NotFound without redirect.
+The tests mock HTTP responses; they do not verify backend authorization or
+multi-node integration. Node tests in `addonmodelactions.test.mjs` cover bounded
+metadata and dispatch rejection after actor, session, generation, entry or
+page/path changes. `legacyroutes.test.mjs` retains native chat handoff checks.
+
+The canonical-address fixture audit includes audio-transform, chats-switches-real,
+chrome-audit, diarization-profiles, media-history, navigation, no-edge-rails,
+page-render-smoke, sound-expansion, every studio spec, talk, taste-polish,
+threed-animation, threed-gen and addon-navigation. Existing cases remain;
+standalone addon addresses become declared fixture pages, and selected models
+travel as query values. Addon-local navigation adapter coverage stays in place.
+
+The execution-status viewer defaults to `viewer.html`. Use `-o /path/to/viewer.html`
+to choose an output location explicitly; it does not read environment variables.
+`-check` alone verifies the model without writing a file.
diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md
index 5f738fa69..d389ef742 100644
--- a/docs/content/features/addons.md
+++ b/docs/content/features/addons.md
@@ -136,6 +136,212 @@ receipt is pruned conflicts on its stale revision rather than applying again.
Acknowledgement follows durable persistence; uncertain durability requires reopening
and reconciling the operation before further use.
+## Optional typed manifest extensions
+
+These declarations extend the strict manifest contract. They keep
+`schemaVersion: 1`, host/API/protocol/data-format level 1 and UI SDK 2.
+The Go contract parser is authoritative; there is no separate JSON Schema.
+The fragments below merge into an otherwise valid manifest, not a complete
+installable package. Existing executable, identity, compatibility and UI
+entry/SDK fields remain required.
+
+### Home page commands
+
+Inside `capabilities.ui`:
+
+```json
+{
+ "pages": ["studio"],
+ "navigation": ["studio"],
+ "mainPage": "studio",
+ "title": {"en": "Studio", "it": "Studio"},
+ "icon": "ui/icon.png",
+ "homeCommands": [{"id": "open", "alias": "studio"}]
+}
+```
+
+`homeCommands` is opt-in and has at most 8 records. Each has a unique `id`
+accepted by `contract.ValidName`. Optional `alias` uses the same lower-case,
+slash-free identifier syntax; it requests convenience naming, not ownership.
+Optional `page` must belong to `pages`, even if not in `navigation`. Omission
+uses a nonempty, declared `mainPage`, never the first page or an external URL.
+
+Optional `label` is a `Localized` string or language map (not a `default`
+wrapper), with at most 60 Unicode characters per label and the existing
+plain-text and language rules. Omission selects the installed localized title
+and, for a non-main destination, the page identifier. Optional `icon` is an
+allowlisted semantic host icon or exactly the packaged `ui.icon`; omission
+selects the installed app icon, then `puzzle`. Omit unused overrides rather
+than sending empty strings or null. No tool, URL, command text, install action
+or component can be a command destination.
+
+### Home commands from installed apps
+
+Home reads only the authenticated installed Apps registry, never the catalog.
+A contribution is actionable only while its app is enabled, running, compatible
+with the host SDK, and has validated immutable assets and a declared destination.
+Loading, list errors, forbidden access, absence, disabled installations and failed
+installations remain distinct; a list error does not mean “not installed”.
+Mounted consumers keep bounded polling alive after disable or failure so recovery
+appears without reloading. Drawing commands does not load addon JavaScript.
+
+Every eligible contribution has `/addon::`. Optional aliases
+cannot take any native command name, even a hidden native command, or any usable
+skill name. If multiple contributions request the same alias, all lose that alias;
+the canonical commands remain. Order is addon ID then contribution ID in the Apps
+group. Home shows deterministic, actor-visible alias diagnostics to administrators
+in its expandable command diagnostics section. Labels do not identify commands.
+
+Clicking a command or submitting its exact text uses the same navigation guard.
+Commands accept no arguments and do not execute prefixes. The guard re-reads the
+authenticated installed registry and checks actor, permissions, generation,
+immutable entry and declared page before navigating. Disabled, failed, upgraded,
+removed or inaccessible apps cannot be opened by a previously captured menu entry.
+Logout aborts pending reads; even a response that ignores cancellation cannot
+publish into another actor's registry. Installed commands are Home-only; `/apps`
+remains generic native navigation to installed apps. Uninstalled apps never get
+install commands or promotional examples. Studio owns its `/studio` alias in its
+manifest rather than in Home code. Historical URL redirects and pin migration
+remain unchanged.
+
+### Execution provider and status payload
+
+Inside `capabilities`:
+
+```json
+{
+ "executionProvider": {
+ "tools": ["bash"],
+ "statusTool": "execution-status"
+ }
+}
+```
+
+`tools` contains 1–8 unique declared action tools with executable definitions.
+The current chat binding requires each bound tool to shadow `bash` through
+`shadows`; existing shadow validation still applies, including its uniqueness
+rule. This declaration neither grants shadow authority nor changes provider
+arbitration or execution authorization.
+
+`statusTool` is a separately declared `kind: "read"`, `uiOnly: true` tool,
+not an execution binding or admin tool. Its input schema is an object accepting
+`{}`, with no required inputs, no resource, and `additionalProperties: false`.
+It must answer without mutation. An owner-side typed read must invoke it;
+the model-facing tool adapter intentionally excludes `uiOnly` tools.
+
+The existing result envelope carries this JSON in `payload`:
+
+```json
+{
+ "readiness": "available",
+ "isolation": "isolated",
+ "explanation": "Commands use the configured isolated backend."
+}
+```
+
+`contract.DecodeExecutionStatus` accepts at most 2048 UTF-8 bytes. Both enums
+are required: readiness is `available`, `unavailable` or `unknown`; isolation
+is `isolated`, `host` or `unknown`. Optional explanation is nonempty plain text,
+at most 200 Unicode characters, without markup, controls or direction overrides.
+Unknown or duplicate fields, trailing JSON, `outputs`, private `ok` fields,
+backend names and warning booleans are not accepted. Invalid payloads return
+an error and unknown/unknown, never partial provider data. Non-available
+readiness normalizes isolation to unknown. Status describes only the bound
+tools of that addon incarnation; it is not a host isolation guarantee.
+
+Studio consumes this declaration through the owner-selected conversation binding,
+not an addon ID or a private settings payload. Prompt construction, command
+status and nested egress reads share one observation within a turn. Priming and
+standalone command-status or egress requests have separate, bounded scopes.
+Consumer cancellation stops that wait; turn completion cancels the observation.
+Failed reads settle as unknown and do not retry within the same scope. Each
+consumption revalidates the complete owner binding, including conversation,
+current selection identity and runtime incarnation.
+
+The application resolver reads the current principal-scoped chat and agent from
+JSON or SQL/GORM stores and revalidates current account credentials in named-user
+mode. Local-owner mode maps to the anonymous chat principal. Reads are advisory,
+not transactional snapshots: revoke-and-restore changes between reads are not
+remembered. Ordinary execution authority and consent remain unchanged. An undeclared
+legacy provider does not acquire an isolation claim. Sandbox retains its existing
+private Settings status response and separately declares the typed page-only
+`execution-status` read. Unknown backends report unknown, never inferred isolation.
+
+Provider-reported isolation and a declared network control do not establish
+host network-policy enforcement. The egress API therefore does not claim policy
+applicability for addon command runners until the host can prove enforcement.
+Built-in or explicitly host execution continues to report the host-execution
+reason. Controls can still identify the addon that draws its settings row.
+
+The Apps tab binds each list request and registry ingestion to the authenticated
+session epoch. A same-user refresh or account switch immediately hides the old
+list and rejects its pending responses, even before effect cleanup. A new list
+request can populate the refreshed session. These in-memory scopes contain no
+credentials.
+
+### Requested host operations are not grants
+
+Inside `capabilities`:
+
+```json
+{
+ "hostOperations": {
+ "required": ["workspace.materialize", "workspace.commit"],
+ "optional": ["egress.check", "egress.audit"]
+ }
+}
+```
+
+Each name must pass `contract.ValidHostOp` exactly: no wildcard or prefix grant.
+There are at most 32 entries total, unique across both lists. An empty object
+is invalid. Omission preserves legacy/unspecified requests, not an empty grant
+and not a request for every operation. Required operations declare service
+prerequisites; optional denials must support degraded operation. Unknown but
+syntactically valid names are unsupported, never grants by inference. Requests
+do not create host endpoints or change administrator-controlled grants.
+
+### Official operation audit
+
+The official manifests request the operations their production hostclient calls use.
+Deployment prefixes in `deploy/addons/host-policy.json` remain separate administrator grants.
+The generic binary selects no policy automatically.
+
+| Addon | Required operations | Optional operations |
+| --- | --- | --- |
+| Fine-tuning | `finetune.checkpoints`, `finetune.dataset`, `finetune.export`, `finetune.get`, `finetune.list`, `finetune.start`, `finetune.stop` | None |
+| Quantization | `quantize.get`, `quantize.import`, `quantize.list`, `quantize.start`, `quantize.stop` | None |
+| Office | `workspace.export`, `workspace.import` | None |
+| Sandbox | `workspace.changes`, `workspace.commit`, `workspace.materialize` | `egress.audit`, `egress.check`, `workspace.admin` |
+| Web | None | `workspace.admin` |
+| Face, Slack, Studio, Tables, Talk, Telegram, Voice | None; omit `hostOperations` | None |
+
+Fine-tuning and Quantization include operation names passed through mutation and undo wrappers.
+They do not request the host's unused progress operations.
+Telegram's historical `connector.` deployment grant does not imply a hostclient call or manufacture a manifest request.
+
+Sandbox's proxy uses the optional egress operations when configured connections need policy decisions.
+Missing grants do not authorize connections; the proxy cannot obtain an allow verdict.
+Sandbox's settings and Web's provider settings use `workspace.admin` to check administrators.
+Without that optional operation, these settings refuse changes; ordinary tools can remain available.
+The shipped deployment policy covers every official required operation.
+The package tests compare all twelve manifests with this source audit and the host's registered vocabulary.
+Their package-builder fixtures use temporary executable and UI bytes, not official release binaries or frontend build artifacts.
+
+### Compatibility and release packaging
+
+New hosts accept old manifests without these fields. Old hosts reject unknown
+extension fields during staging. Extension-bearing packages therefore require
+the host release introducing these contracts; document that minimum LocalAI
+release beside each addon release. An older host must retain its installed
+package on refusal. Do not add automatic install retries or promise new error
+wording from old binaries.
+
+Catalog version ranges do not negotiate individual fields and cannot guarantee
+eligibility before staging. SDK 2 does not signal support for these manifest
+extensions. If older hosts need support, release maintainers must decide whether
+and how long to publish separate legacy packages. These declarations alone do
+not install packages, grant operations or activate runtime/UI integrations.
+
## Singleton owner and its limits
Addon state lives in one place. Exactly one LocalAI instance is the **owner** of an
@@ -228,7 +434,7 @@ available on this instance:
While the key is `false`:
- The UI shows no Add-ons entry, no pinned apps, and no fine-tuning or
- quantization entry. `/app/addons`, `/app/fine-tune` and `/app/quantize` lead back
+ quantization entry. `/app/addons` and canonical add-on page addresses lead back
to `/app`. The UI does not call `/api/addons/pins` or `/api/addons/builtin`. A
server that sends no `addons` key counts as `true` in the UI.
- Every path under `/api/addons`, `/api/fine-tuning` and `/api/quantization`
@@ -1666,6 +1872,50 @@ person to confirm.
## Host operations
+### Required and optional operation admission
+
+A verified manifest can request exact operation names with
+`capabilities.hostOperations.required` and `.optional`. These are requests,
+**not grants**. The owner compares them with the host operation vocabulary
+and the immutable host policy selected at startup. Legacy manifests without
+requests retain their configured behavior.
+
+Coverage uses the broker's literal, nonempty prefix rule. For example,
+`workspace.` covers the requested name `workspace.materialize`;
+`workspace.materialize.narrow.` does not. Registry membership is an exact-name
+check: a grant cannot make `workspace.future` a supported operation. Requests
+are exact names, not prefixes, patterns, or authority for selecting providers.
+
+The owner verifies and stages a new install or upgrade before admission.
+Missing required requests refuse publication, before installed records,
+migrations, runtime withdrawal, or process creation. A refused upgrade retains
+the old receipt, digest, generation, and running incarnation; it needs no
+rollback. Automatic upgrades suppress further attempts for the same failed
+candidate until a different candidate appears or the owner restarts. They never
+acquire grants or prompt for approval.
+
+After an operator removes required grants and restarts, the package remains
+installed with its enabled intent. Its effective runtime is unavailable
+(`failed` in Apps), and the owner admits no launch, tool, resource, connector,
+or page access. It does not offer installation as a repair. Restore the host
+policy and restart the owner to make it available again. Policy is restart-only:
+subsequent manager `SetHostOps` calls do not replace the startup decision.
+
+Missing optional requests permit a degraded start. Existing admin Apps details
+and lifecycle operation results include bounded required, optional, granted,
+and missing name lists with counts. Here “granted” counts only supported
+**requested** names covered by host policy, not every grant the addon holds.
+Ordinary Apps readers receive the generic failed observation without policy
+paths, grant details, or missing operation names. Split deployments carry the
+owner's decision; a frontend cannot supply grants.
+
+Admission does not authorize an individual call. The broker still requires a
+registered handler and rechecks the live incarnation, operation scope, actor
+features/admin status, workspace/chat access, and connector binding. Handlers
+are wired as host services start; admission uses the host's startup operation
+vocabulary rather than treating not-yet-wired handlers as unknown operations.
+
+
A host operation is how an add-on process asks the host to do something the
add-on cannot do itself, such as starting a job of the engine. It is contract
surface that third-party add-ons cannot use unless the host setting names them.
@@ -1677,8 +1927,11 @@ The rules:
default is refuse.
- The host setting `--addon-host-ops` (`LOCALAI_ADDON_HOST_OPS`) names the
add-ons that get the channel and the operation prefixes each may call, as a
- comma list of `addon-id=op-prefix`. The default is
- `fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector.`. An empty value names nobody.
+ comma list of `addon-id=op-prefix`. The generic binary grants nothing when
+ unconfigured. An explicitly empty value names nobody, including in Docker.
+ An administrator can instead select a file with `--addon-host-policy`; see
+ [Administrator host operation policy](#administrator-host-operation-policy)
+ for precedence and explicit-empty behavior.
This is a host setting, not a manifest field and not a model argument.
- An add-on that the setting names finds two optional fields in its startup
object: `hostEndpoint`, a loopback address of the form
@@ -1731,7 +1984,7 @@ scope (today `connector.inbound`):
owner is refused with `bad_request` before the operation runs.
- The add-on ID is the one the host built the listener for, never a value of the
call. The host setting must still name the operation prefix for the add-on
- (`telegram=connector.` by default).
+ (`telegram=connector.` in the stock Docker policy).
Every other operation keeps all its checks. An operation cannot become inbound by
a field of the request: the scope is part of its registration.
@@ -1953,6 +2206,15 @@ definition replaces one with `"shadows": ""`:
...: the built-in bash serves", or that the chat has no such tool when the
built-in is off too), from `fallbacks` of `GET /api/chats/{id}/tools`. A
tool without `shadows` keeps its add-on name.
+- **Qualified identities.** The shared tool adapter formats and parses
+ `addon____`. Both components must match
+ `[a-z][a-z0-9-]{0,63}`. Model-callable names cannot exceed 64 characters.
+ Execution-status binding rejects plain names, malformed names, and names
+ whose add-on ID differs from the selected provider. The real adapter already
+ supplies canonical names; this check also guards the binding boundary.
+ Studio's unavailable stand-ins use the same component grammar but do not
+ apply the model-callable total length limit. A long stand-in identifies an
+ unavailable add-on; it does not make that name callable through the adapter.
- **Who served it.** A call the add-on served carries the card `tool_provider`
with the add-on's ID, so the chat shows the provider on the tool line.
- **Several add-ons.** Only one add-on serves a name. An administrator picks it
@@ -2960,13 +3222,12 @@ the caller and is sent with `Cache-Control: private, no-store`.
days the pins are deleted. A background sweep runs every six hours; with several
frontends, one of them runs it at a time. A disabled add-on is not removed, so its pins
never expire. A sweep that cannot read the add-on owner changes nothing.
-- **Upgrade seed.** A read or a save never pins anything. Once, at the upgrade that adds
- this feature, the accounts that exist then get Studio and Talk pinned. Both are
- add-ons, so a pin is drawn once the add-on is installed. A seeded pin does not
- expire while its add-on was never installed, however long that takes; once the
- add-on was installed, its pin follows the retention rule like any other (the sweep
- records the first install in the table of seed runs, `addon_pin_seed_runs`, or in
- the pins file). An account created later starts with no pins. An unpin stays after a restart.
+- **Explicit selection.** New and existing accounts, including the local owner, start
+ without implicit pins. Startup, reads, and saves never add Studio, Talk, or any other
+ ID on their own. Existing saved user pins remain unchanged. Explicit pins and unpins
+ survive restarts and are shared across frontends with an account database.
+ Every absent ID follows the same 30-day retention rule, even if it was never installed.
+ The store no longer creates or uses seed metadata; it does not drop existing database tables.
- **Startup.** If the pins file cannot be read (it is corrupt), the server does not start
and logs the error. It does not fall back to memory, because that would lose every unpin
at the next restart.
@@ -3002,46 +3263,34 @@ former Installed tab showed is the **More details** section of an add-on in My a
`?tab=installed` opens My add-ons. The management link of an app
(`/app/addons?manage=ID`) opens My add-ons with the details of that add-on open.
-### Old links
+### Add-on page addresses and model actions
-The generators that had their own page now open inside the Studio add-on, and the
-Studio and Talk pages open their add-ons. The old links still work and keep the model, the
-query string and the hash:
+Open add-on pages through My add-ons or their declared navigation entries.
+The canonical address is `/app/addons//`, with an optional relative
+suffix beneath the declared page. Historical standalone generator and add-on
+addresses are not registered and show Not Found. There is no compatibility
+support window for these unreleased routes.
-| Old link | Opens |
-|---|---|
-| `/app/studio` | `/app/addons/studio/studio` |
-| `/app/studio/` | `/app/addons/studio/studio/` |
-| `/app/talk` | `/app/addons/talk/talk` |
-| `/app/image` | `/app/addons/studio/studio/images` |
-| `/app/video` | `/app/addons/studio/studio/video` |
-| `/app/3d` | `/app/addons/studio/studio/threed` |
-| `/app/tts` | `/app/addons/studio/studio/tts` |
-| `/app/sound` | `/app/addons/studio/studio/sound` |
-| `/app/diarization` | `/app/addons/studio/studio/diarization` |
-| `/app/transform` | `/app/addons/studio/studio/transform` |
-| `/app/face` | `/app/addons/face/recognition` |
-| `/app/face/` | `/app/addons/face/recognition?model=` |
-| `/app/voice` | `/app/addons/voice/voice/recognition` |
-| `/app/voice/` | `/app/addons/voice/voice/recognition?model=` |
-| `/app/voice-library` | `/app/addons/voice/voice/library` |
-| `/app/voice-library/new` | `/app/addons/voice/voice/library/new` |
+Installed-model inspectors, gallery Open actions and model details resolve
+`capabilities.ui.modelActions` from the authenticated installed Apps list.
+A declaration names a model capability, a declared page and an optional safe
+relative path. Core does not map provider IDs to private page paths.
-None of these links has a core page behind it: the face, voice, Studio and Talk pages exist
-only in their add-ons. Without the add-on, a link opens the add-on page, which says the
-add-on is not available.
-A model id can hold `/` or `:`, which an add-on path segment refuses, so the model travels
-as `?model=` (for example `/app/voice/org%2Fname` opens `/app/addons/voice/voice/recognition?model=org%2Fname`; the face links and the
-Studio links do the same).
+One usable provider opens with one click. Multiple providers appear as named
+choices in the action menu, sorted by provider ID. When none is usable, the
+capability label remains without a dead link or an install promotion.
+Chat and completion keep their native destinations.
-A link with a model (`/app/image/`) opens `/app/addons/studio/studio/images?model=`. The
-`/app/studio?tab=` links redirect to `/app/addons/studio/studio/`. A viewer without the `3d`,
-`audio_diarization` or `audio_transform` feature still lands on the app home, as before.
+The host sends the selected model as a URLSearchParams value under `model`.
+Slashes, colons, Unicode and query delimiters remain part of the model name.
+Each click refreshes the actor-filtered installed list and checks the captured
+provider, page, path, generation, immutable entry and authentication authority.
+A changed installation or session refuses the stale action instead of choosing
+another provider. Projection does not load add-on code or query the catalog.
+Talk still lands on its existing page; this does not change its pipeline.
-The legacy switch (`LOCALAI_LEGACY_NAVIGATION`) restores the old sidebar and the Add-ons
-hub only. These redirects do not depend on it: with the switch on, the
-old generator links also open the Studio add-on. The legacy sidebar lists Home, Models, All chats, Agents, MCP connectors, Memory and
-the hubs; Studio and Talk are in My add-ons, as add-ons.
+The legacy navigation switch changes the sidebar and hubs only. It does not
+restore removed addon-specific route registrations.
### The Studio add-on
@@ -3146,8 +3395,7 @@ with **Install and turn on**. Once usable, they appear in My add-ons and can be
| `fine-tuning` | Fine-Tune | `/app/addons/fine-tuning/jobs` | users with the `fine_tuning` feature; the page shows an off notice for anyone else |
| `quantization` | Quantize | `/app/addons/quantization/jobs` | users with the `quantization` feature; the page shows an off notice for anyone else |
-The old links `/app/fine-tune` and `/app/quantize` open the add-on page. Without the add-on,
-they show the add-on page that says the add-on is not available. The pages call
+Canonical links open the add-on shell, which reports unavailable installations. The pages call
`/api/fine-tuning/*` and `/api/quantization/*` with the signed-in user's own session (see
"Core routes from add-ons"); the core keeps these routes and checks every call again.
Both add-ons offer chat tools that start a job. In the chats a tool call starts it at once; on other surfaces it is a proposal that waits for a person to confirm. They
@@ -3604,7 +3852,12 @@ An upgrade started from the list, the details, **Find add-ons** or **Upgrade all
running when you leave the page. It appears in the Activity page under the **Add-ons**
filter, with progress, **Cancel** and **Retry**. Cancel stops the request, and the page then
reads the operation to report what the owner did. The list of upgrades is kept by the
-browser that started them, so it is lost on a page reload.
+browser that started them, so it is lost on a page reload. Background tracking continues
+across navigation only within the same authentication session. An authentication
+refresh, account or permission change, logout initiation, or removal of the
+authentication provider expires pending observations. Late replies then cannot
+verify an upgrade or publish its result into the new session. This does not undo
+an operation already accepted by the server.
While the add-on service restarts (an upgrade restarts it), the page cannot read the
operation and the server answers 503. The page then waits longer before each new read, 2,
@@ -4484,3 +4737,215 @@ Logout clears the current user's observer records. Storage restrictions may prev
observer recovery, but never cancel the owner operation. An unreadable owner or
exhausted polling window is an unknown outcome: keep the ID and read it again,
rather than starting a replacement operation.
+
+## Administrator host operation policy
+
+`--addon-host-policy` (environment: `LOCALAI_ADDON_HOST_POLICY`) selects an
+administrator-controlled JSON file. Use an absolute path to a readable regular
+file, at most 64 KiB. LocalAI reads it once at startup; changes require a restart.
+There is no implicit search, remote download, or manifest-selected policy path.
+Keep the file outside addon-writable directories. The deployment administrator
+must protect its ownership and mount permissions. A symlink to a regular file is
+accepted; path validation does not prove that the file is administrator-owned.
+
+```json
+{
+ "schemaVersion": 1,
+ "grants": {
+ "sandbox": ["workspace.", "egress."],
+ "office": ["workspace."]
+ }
+}
+```
+
+```sh
+local-ai run --addon-host-policy=/etc/localai/addon-host-policy.json
+# Equivalent selection:
+LOCALAI_ADDON_HOST_POLICY=/etc/localai/addon-host-policy.json local-ai run
+# Explicitly grant nothing, without opening any selected policy:
+local-ai run --addon-host-ops=
+# Disable policy selection inherited from the environment:
+local-ai run --addon-host-policy=
+```
+
+The file requires exactly `schemaVersion` (integer `1`) and `grants` (an object).
+Keys are case-sensitive. Unknown fields, duplicate keys, duplicate prefixes,
+malformed JSON and trailing data are errors. An empty `grants` object is valid.
+The limits are 128 addon IDs and 32 prefixes per addon. IDs use lowercase ASCII
+letters, digits and hyphens and start with a letter or digit. Prefixes use the
+same grammar as `--addon-host-ops`: lowercase ASCII letter segments, each ending
+in a dot, such as `workspace.` or `workspace.files.`. They are not regular
+expressions or exact operation names. The broker must still register an
+operation before any prefix can authorize it.
+
+Selection uses presence, not string length:
+
+1. An explicit `--addon-host-ops` wins, including an empty value.
+2. Otherwise, an explicitly set `LOCALAI_ADDON_HOST_OPS` wins, including empty.
+3. Otherwise, the selected policy supplies the complete grant map.
+4. The generic configuration resolver grants nothing when unconfigured.
+
+Overrides replace grants; they never merge with the file. LocalAI does not open
+an ignored policy and logs that it was ignored. An invalid selected policy stops
+startup. An explicitly empty `--addon-host-policy` disables an environment policy
+and supplies no grants. Startup logs report the source and counts, not file
+contents. Manifests, addon requests and libraries cannot grant host operations.
+In split deployments, the owner enforces authority; frontend settings cannot add
+grants. Programmatic `WithAddonHostOps`, including an empty map, is an exact
+startup decision and takes a copy of the supplied map and prefix slices.
+
+### Breaking upgrade change: standalone and custom deployments
+
+**The generic binary no longer grants host operations by default.** Upgrading
+an unconfigured standalone or custom deployment removes the former implicit
+grants, even when official addons are already installed. Those addons can lose
+host-backed functionality. Startup warns about unconfigured grants even before
+any addon is installed. LocalAI does not infer consent from installed addons,
+write migration grants into addon storage, or automatically restore old grants.
+
+Existing explicit flag or environment settings keep their effective grants,
+including explicitly empty settings. An override replaces the complete policy;
+it never unions with it. A missing or invalid selected file fails startup.
+
+To retain the former grants, review `deploy/addons/host-policy.json` from your
+trusted LocalAI release, then install and explicitly select it:
+
+```sh
+sudo install -d -m 0755 /etc/localai/addons
+sudo install -o root -g root -m 0644 deploy/addons/host-policy.json /etc/localai/addons/host-policy.json
+local-ai run --addon-host-policy=/etc/localai/addons/host-policy.json
+# Or configure this environment variable in your service, then restart:
+# LOCALAI_ADDON_HOST_POLICY=/etc/localai/addons/host-policy.json
+```
+
+The exact former explicit setting remains valid:
+
+```sh
+local-ai run --addon-host-ops='fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector.'
+```
+
+The reviewed deployment file contains exactly:
+
+| Addon ID | Prefix grants |
+| --- | --- |
+| `fine-tuning` | `finetune.` |
+| `quantization` | `quantize.` |
+| `sandbox` | `workspace.`, `egress.` |
+| `office` | `workspace.` |
+| `web` | `workspace.` |
+| `telegram` | `connector.` |
+
+This file is host-owned deployment policy, not authority supplied by a manifest
+or catalog. Grants trust addon identity, not a verified signature or provenance.
+Installing different code under a granted ID gives that code the selected grants.
+Protect the file against writes by addons and untrusted users. Review and reduce
+the grants if you do not intend to trust every listed ID.
+
+### Maintained launcher selection
+
+The stock Docker runtime copies the reviewed file to
+`/etc/localai/addons/host-policy.json` and explicitly selects that path through
+`LOCALAI_ADDON_HOST_POLICY`. No executable-relative or working-directory search
+is involved. `--addon-host-ops=` overrides this selection with zero grants.
+`--addon-host-policy=` disables the image's policy selection and gives zero
+grants unless an explicit host-operations override is present.
+
+| Launcher or deployment | Host policy decision |
+| --- | --- |
+| Stock runtime `Dockerfile` | Ships and explicitly selects the reviewed file. |
+| `docker-compose.yaml`, `docker-compose.distributed.yaml` | Inherit the stock runtime image selection; custom images must select their own policy. Only the addon owner enforces grants in split mode. |
+| `website/static/install/kubernetes.yaml` | Inherits the stock image selection. A custom image must ship/select policy or mount an administrator file and select its absolute path. |
+| External Helm chart referenced by the Kubernetes guide | Not shipped here. Check the selected image and chart environment; custom images are unconfigured unless the operator selects policy. |
+| `.devcontainer/docker-compose-devcontainer.yml` | Uses the development target, not the stock runtime stage. A manually launched binary is unconfigured. |
+| Release binaries, `website/static/install.sh`, AppImage/desktop launchers (`contrib/appimage`), macOS launcher packaging | Unconfigured standalone behavior. Operators must explicitly select policy or host operations; packaging does not migrate consent. |
+| Custom systemd services | No maintained unit ships in this tree. Set `LOCALAI_ADDON_HOST_POLICY` or explicit host operations in the unit, protect the file, and restart. |
+| `docs/docker-compose.yaml` | Documentation-site server only; it does not launch LocalAI. |
+
+These non-stock launchers do not promise the former implicit grants. Automatic
+policy installation or selection for them is deferred; use the explicit upgrade
+procedure above. Merely copying a file without selecting it does not grant
+anything. Do not fetch host policy from an addon library.
+
+### Owner-resolved execution observations
+
+The owner resolves execution-status bindings from an authenticated actor and a
+conversation ID. Callers cannot choose the provider, tools, runtime incarnation,
+agent grants, or consent. The resolver uses the chat's current add-on switches,
+agent grants, provider pins, and installed-provider ordering. An installed but
+idle pinned provider does not fall through to another add-on.
+Execution observations read the same current owner policy as normal execution,
+not frontend-local provider pins. Owner policy outages fail closed; a CAS pin
+change invalidates a binding whose current selection no longer matches.
+
+The binding path uses existing principal-scoped chat and agent reads on the
+owner, supporting JSON and SQL/GORM stores. Named actors use their account ID;
+the local owner uses the anonymous chat principal. Current account status,
+feature permissions and credentials are checked through normal authorization.
+The owner must have these stores; frontend tool lists are never authority.
+
+The status gateway re-resolves current authorization and selection before
+dispatch and before publishing positive facts, and compares the exact generation,
+runtime incarnation and tools. A changed current selection withdraws old facts;
+a fresh request can succeed after access is restored. This is current-read
+consistency, not a historical revoke-and-restore (ABA) guarantee or a database
+transaction spanning the read. The host compares
+current selection identity only; it is not a durable revision or owner nonce.
+A binding does not authorize command execution, grant access or replace consent.
+Request/session integration shares observations only within a bounded request.
+
+### Execution safety notices
+
+The chat Tools menu and command chip describe the actual selected execution
+provider, not an installed app's name. Any provider that reports host execution
+gets a no-isolation warning. Unknown readiness or isolation gets an explicit
+warning, not an isolated or safe badge. A status read does not grant execution
+permission or replace the built-in host command consent flow.
+
+Provider explanations are bounded plain text. Dismissing a notice applies only
+to that mounted chat context and exact provider binding, including generation,
+incarnation, readiness and isolation. Loading, switching providers,
+changing isolation, changing actors or logging out resets the dismissal.
+Actor or authentication-session changes also invalidate pending status reads
+before the next effect runs; logging in as the same user starts a new epoch.
+Built-in command execution, including fallback from an unavailable provider,
+shows the generic host warning without Sandbox installation or repair advice.
+This warning does not change command consent or administrator settings.
+
+The network panel retains the configured policy when the backend cannot verify
+its enforcement. It explicitly says that the policy does not apply to commands.
+Provider-reported isolation alone does not establish network enforcement.
+Missing current authorization, selection or runtime context yields unknown;
+installing an app does not confer authority or prove network enforcement.
+
+The chat `/about` card reports isolation from the selected command provider.
+When the provider status is missing or unknown, the card does not claim isolation.
+After an authentication refresh or account change, pending Apps reads and action
+results cannot populate the new session. The server still authorizes every action.
+
+### Model destinations (contract and backend metadata)
+
+An installed addon can declare optional `capabilities.ui.modelActions`:
+
+```json
+{"modelActions":[{"capability":"FLAG_IMAGE","page":"studio","path":"images"}]}
+```
+
+Each entry requires an existing non-native model capability and a declared UI
+page. The optional relative suffix must stay beneath that page. One entry per
+capability is allowed within an addon, with at most 27 entries. Native chat and
+completion cannot be shadowed; aggregate `FLAG_LLM` and `FLAG_ANY` are excluded.
+The remaining concrete host model flags form the vocabulary, guarded by parity
+tests. No labels, priorities, permissions, URLs or query templates are accepted.
+
+The combined page/suffix follows SDK routing bounds: at most 256 ASCII characters
+and eight segments, each 1–64 characters from `[A-Za-z0-9._~-]`; `.` and `..`
+segments are forbidden. Absolute paths, empty suffixes, percent escapes, queries,
+fragments, unknown keys, duplicate keys and noncanonical keys are rejected.
+Installed Apps metadata preserves these typed declarations and existing
+unavailable-UI omission; it does not discover or promote catalog entries.
+
+Studio declares its existing image, video, speech and sound tabs. Talk declares
+its transcription landing page, Face face recognition and Voice speaker
+recognition. Talk currently has no selected-model query semantics; its landing
+behavior is unchanged. This backend milestone does not yet alter model Open
+dispatch or remove historical frontend routes.
diff --git a/docs/content/features/chats-agents.md b/docs/content/features/chats-agents.md
index 8d7a09771..9c11136f8 100644
--- a/docs/content/features/chats-agents.md
+++ b/docs/content/features/chats-agents.md
@@ -635,3 +635,11 @@ behaves with several frontends:
| Live notifications | Shared, live only | Sent over the message bus to every frontend, with the "read or deleted" signal. A client that missed one lists the inbox, which is the record. |
| The supervisor of a running run | Per instance | The frontend that started the run watches it. The leader also ends any run that nobody watches whose chat is over, so a run is not lost when its frontend dies. |
| Where the turn of a run runs | Stateless | On the frontend that started it, or on a chat worker when the frontends send unattended turns there. The turn claim, its heartbeat and the waiting messages are in the database, so either one can run it and only one does. |
+
+### JSON storage
+
+Without an auth database, persistent chats and agent definitions use per-instance
+JSON storage. These stores use in-process mutexes and atomic writes. Run one
+process per JSON directory on a local filesystem; clusters use the SQL stores.
+There is no cross-process directory lock or historical selection ledger.
+Status observations use current principal-scoped reads in either backend.
diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md
index d30ff67d7..870d5f810 100644
--- a/docs/content/features/chats.md
+++ b/docs/content/features/chats.md
@@ -1019,7 +1019,7 @@ setting like the others.
### What the web interface is told
`GET /api/chats/{id}/addons` says the same for the web interface in
-`commands`: `available`, `off` (the Sandbox is installed but off for this chat,
+`commands`: `available`, `off` (the selected provider is off for this chat,
or its agent does not name it, and the built-in `bash` is not allowed),
`unavailable` (installed, but it cannot run commands on this server;
`commands_reason` says why) or `none` (no add-on that runs commands is
@@ -1027,9 +1027,12 @@ installed for you). When commands run, `commands_provider` says who serves
`bash` (`builtin`, or the add-on's ID) and `commands_isolation` says `isolated`
or `host`. `commands_consent` is `ask` when each command waits for the card,
and `commands_fallback` with `commands_fallback_reason` name the add-on that
-would serve but does not (`off`, or its reason). The **Tools** menu and the
-chip next to it say "Commands run on this machine" with the reason, and link an
-administrator to the Sandbox.
+would serve but does not (`off`, or its reason). The **Tools** menu uses this selected-provider response, not private add-on
+status data. It labels commands as isolated only when the current provider
+explicitly reports available, isolated execution. Missing, loading and unknown
+observations remain unknown. Host execution shows a warning without recommending
+a specific add-on. The first-run guide and native tool settings do not recommend
+an add-on by ID.
```bash
curl -s localhost:8080/api/chats/$ID/addons
diff --git a/docs/content/features/face-recognition.md b/docs/content/features/face-recognition.md
index 9bcc358c5..a2beb98eb 100644
--- a/docs/content/features/face-recognition.md
+++ b/docs/content/features/face-recognition.md
@@ -398,7 +398,7 @@ default only fires when the field is omitted.
## The WebUI page
-The page is the **Recognition** page of the Face add-on (**Add-ons → Face**, `/app/addons/face/recognition`). Install the add-on from **Browse add-ons** and start it; the old `/app/face` link opens the same page in the core web UI until then. The page calls the `/v1/face/*` routes described on this page with your own session. It follows the same layout as the Voice add-on's Recognition page. **Who is this** looks up a photo against the people you enrolled (`POST /v1/face/identify`, cut-off 0.35 by default, adjustable on the scale). **Same person?** compares two photos (`POST /v1/face/verify`), optionally with the liveness check, and draws the face the model found in each photo. **Enrol a person** takes a photo, a name, optional labels and a permission tick; a copy of the photo stays in the browser only if you tick it.
+The page is the **Recognition** page of the Face add-on (**Add-ons → Face**, `/app/addons/face/recognition`). Install the add-on from **Browse add-ons** and start it. Canonical links show the add-on shell when the installation is unavailable. The page calls the `/v1/face/*` routes described on this page with your own session. It follows the same layout as the Voice add-on's Recognition page. **Who is this** looks up a photo against the people you enrolled (`POST /v1/face/identify`, cut-off 0.35 by default, adjustable on the scale). **Same person?** compares two photos (`POST /v1/face/verify`), optionally with the liveness check, and draws the face the model found in each photo. **Enrol a person** takes a photo, a name, optional labels and a permission tick; a copy of the photo stays in the browser only if you tick it.
The people list is kept in the browser because the server has no list call. After a search, a saved person the server did not return is marked "not on the server", and a server restart empties the server's index. Removing a person waits ten seconds behind an Undo toast before `POST /v1/face/forget` is sent. The server matches one face per photo. Detecting faces, attribute guesses (off by default, often wrong) and the raw embedding are under **More tools**. With no face model installed the page says what is missing and offers gallery models to install.
diff --git a/docs/content/features/fine-tuning.md b/docs/content/features/fine-tuning.md
index 83c869481..a39eb4a2f 100644
--- a/docs/content/features/fine-tuning.md
+++ b/docs/content/features/fine-tuning.md
@@ -225,11 +225,11 @@ A user needs the fine-tuning permission. Without it, the page says the account c
The Fine-Tune page is the `fine-tuning` add-on. It does not ship with LocalAI. An administrator installs it from **Find add-ons** with **Install and turn on**. Once it is on, it opens from My add-ons at `/app/addons/fine-tuning/jobs` and can be shown in the sidebar.
-The old link `/app/fine-tune` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise.
+Open the canonical add-on page from My add-ons. Historical standalone links are not registered.
The API does not change. The page calls `/api/fine-tuning/*` with the signed-in user's own session, and the server checks the fine-tuning permission on every call, so the add-on never widens what a user may do.
-With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/fine-tune` leads back to `/app`, and every path under `/api/fine-tuning` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate).
+With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/addons/fine-tuning/jobs` leads back to `/app`, and every path under `/api/fine-tuning` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate).
The add-on also offers tools to the chat: list jobs, propose a job, store a small dataset, read the status and the checkpoints of a job, stop a job and export a checkpoint as a model. A tool can only control a job that a tool started. A job started in the page is listed, but a tool cannot stop or export it. In the chats, and in an agent or a trigger whose agent names the add-on, a tool call that starts a job runs at once, because you turned the add-on on; a trigger with no agent gets no such tool; the result says what an undo does (see below). On the OpenAI-compatible API and other surfaces, a tool only proposes the action and a person confirms it in an approval card. A model or a dataset given as a local path is refused. The tools call the engine through the host operations (see [Host operations](../addons/#host-operations)), which the host setting `LOCALAI_ADDON_HOST_OPS` grants to this add-on by the `finetune.` prefix, and they run with the permissions of the person in the chat.
diff --git a/docs/content/features/quantization.md b/docs/content/features/quantization.md
index 2c67880e5..e8dd10786 100644
--- a/docs/content/features/quantization.md
+++ b/docs/content/features/quantization.md
@@ -164,11 +164,11 @@ A user needs the quantization permission. Without it, the page says the account
The Quantize page is the `quantization` add-on. It does not ship with LocalAI. An administrator installs it from **Find add-ons** with **Install and turn on**. Once it is on, it opens from My add-ons at `/app/addons/quantization/jobs` and can be shown in the sidebar.
-The old link `/app/quantize` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise.
+Open the canonical add-on page from My add-ons. Historical standalone links are not registered.
The API does not change. The page calls `/api/quantization/*` with the signed-in user's own session, and the server checks the quantization permission on every call.
-With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/quantize` leads back to `/app`, and every path under `/api/quantization` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate).
+With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/addons/quantization/jobs` leads back to `/app`, and every path under `/api/quantization` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate).
The add-on also offers tools to the chat: list jobs, propose a job, read the status of a job, stop a job and import the result as a model. A tool can only control a job that a tool started. A job started in the page is listed, but a tool cannot stop or import it. In the chats, an agent and a trigger, a tool call that starts a job runs at once, because you turned the add-on on for that chat; the result says what an undo does (see below). On the OpenAI-compatible API and other surfaces, a tool only proposes the action and a person confirms it in an approval card. A model given as a local path is refused. The tools call the engine through the host operations (see [Host operations](../addons/#host-operations)), which the host setting `LOCALAI_ADDON_HOST_OPS` grants to this add-on by the `quantize.` prefix, and they run with the permissions of the person in the chat.
diff --git a/docs/content/features/studio.md b/docs/content/features/studio.md
index f196d160c..788e0666b 100644
--- a/docs/content/features/studio.md
+++ b/docs/content/features/studio.md
@@ -6,7 +6,7 @@ url = "/features/studio/"
+++
{{% notice note %}}
-Studio is an add-on. It is not part of the web bundle: install it from **Find add-ons** in the [Add-ons](../addons/#the-studio-add-on) page with **Install and turn on**. Once it is usable, it is in My add-ons and can be shown in the sidebar, and it opens at `/app/addons/studio/studio`. The old links (`/app/studio`, `/app/image` and the other generator links) open it there.
+Studio is an add-on. It is not part of the web bundle: install it from **Find add-ons** in the [Add-ons](../addons/#the-studio-add-on) page with **Install and turn on**. Once it is usable, it is in My add-ons and can be shown in the sidebar, and it opens at `/app/addons/studio/studio`. Use its canonical add-on page address.
{{% /notice %}}
Studio is the part of the web UI where you make images, video, 3D objects, speech, sound and transformed audio, and find what you made before. It opens on a front page with a prompt box and your own work. Each type still has its own workspace page (Images, Video, 3D, TTS, Sound, Transform, Diarization) where a run happens.
@@ -75,4 +75,4 @@ Diarization lists who spoke when as one lane per speaker, the talk time of each
## What a workspace accepts from the front page
-The workspaces read these query parameters, so a link of your own works too: `prompt`, `model`, `size`, `n` (count), `from` (the id of the result it starts from) and `edge` (how). For example `/app/studio/video?prompt=Slow%20push-in&from=&edge=animate`.
+The workspaces read these query parameters, so a link of your own works too: `prompt`, `model`, `size`, `n` (count), `from` (the id of the result it starts from) and `edge` (how). For example `/app/addons/studio/studio/video?prompt=Slow%20push-in&from=&edge=animate`.
diff --git a/docs/content/features/voice-recognition.md b/docs/content/features/voice-recognition.md
index 4a935cfa3..3266322a3 100644
--- a/docs/content/features/voice-recognition.md
+++ b/docs/content/features/voice-recognition.md
@@ -559,7 +559,7 @@ default only applies when omitted.
## The WebUI page
-The page is the **Recognition** tab of the Voice add-on (**Add-ons → Voice**, `/app/addons/voice/voice/recognition`). Install the add-on from **Browse add-ons** and start it; the old `/app/voice` link opens the same page in the core web UI until then. The page calls the `/v1/voice/*` routes described on this page with your own session. The Voice page has four tabs. **Recognition** is this feature. **Library** is the [Voice Library](/features/text-to-audio/#voice-library) for text-to-speech, a different store with its own recordings and transcripts. **Text to speech** speaks text with a voice model. **From a recording** links to the diarization workspace, where a speaker can be named and remembered.
+The page is the **Recognition** tab of the Voice add-on (**Add-ons → Voice**, `/app/addons/voice/voice/recognition`). Install the add-on from **Browse add-ons** and start it. Canonical links show the add-on shell when the installation is unavailable. The page calls the `/v1/voice/*` routes described on this page with your own session. The Voice page has four tabs. **Recognition** is this feature. **Library** is the [Voice Library](/features/text-to-audio/#voice-library) for text-to-speech, a different store with its own recordings and transcripts. **Text to speech** speaks text with a voice model. **From a recording** links to the diarization workspace, where a speaker can be named and remembered.
On the Recognition tab:
diff --git a/docs/superpowers/plans/2026-10-16-addons-navigation.md b/docs/superpowers/plans/2026-10-16-addons-navigation.md
index f832ce786..8bdaa92d6 100644
--- a/docs/superpowers/plans/2026-10-16-addons-navigation.md
+++ b/docs/superpowers/plans/2026-10-16-addons-navigation.md
@@ -1,5 +1,17 @@
# Add-on navigation Implementation Plan
+
+> Routing update: historical addon-specific redirect requirements below are
+> superseded by the approved model-actions amendment (2026-11-02). Use declared
+> canonical add-on pages; native chat handoff and addon-local adapters remain.
+
+> Pin behavior update: the unreleased seed proposal and seed code examples below are
+> superseded. Startup adds no implicit pins and does not scan accounts. Existing saved
+> pins remain intact. Explicit selections persist across reopen and frontends. Studio,
+> Talk, and arbitrary never-installed IDs use equal retention. See the current
+> [pin behavior](../../content/features/addons.md) and [design](../specs/2026-10-16-addons-navigation-design.md#explicit-pins).
+
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the long sidebar with four top-level items (Chat, Models, Add-ons, Settings) and a per-user Pinned section, and move Studio, Talk, fine-tuning, quantization, the Voice add-on and the Face add-on behind the Add-ons drawer without breaking any old URL or weakening any permission. Voice, Face, fine-tuning and quantization are add-on packages that do not ship with LocalAI: users find them in Browse add-ons and, once installed, in the drawer.
diff --git a/docs/superpowers/specs/2026-10-16-addons-navigation-design.md b/docs/superpowers/specs/2026-10-16-addons-navigation-design.md
index ace4a65da..dcd4d3fb3 100644
--- a/docs/superpowers/specs/2026-10-16-addons-navigation-design.md
+++ b/docs/superpowers/specs/2026-10-16-addons-navigation-design.md
@@ -1,5 +1,10 @@
# Add-ons navigation design
+
+> Routing update: historical addon-specific redirect requirements below are
+> superseded by the approved model-actions amendment (2026-11-02). Use declared
+> canonical add-on pages; native chat handoff and addon-local adapters remain.
+
## Status
- Status: implemented, with the amendments of 2026-10-31 below, which win over the text that follows. One question stays open (see "Open questions and decisions"). Questions (a) and (b) and the rulings (d) to (j) were decided on 2026-10-16. The changelog at the end lists every change of that day.
@@ -15,7 +20,7 @@ What shipped differs from this spec in these points. The owner decided each one;
- **Studio and Talk are packaged add-ons, not built in** (replaces decisions 4 and 10 and "Built-in add-ons and packaged add-ons"). They are add-on packages like Voice and Face, listed in the official library and installed by an administrator. The built-in catalog is empty. Nothing is preinstalled, also not at upgrade.
- **The core pages are removed** (replaces the redirect rule "Otherwise the old route opens the core page"). An old route always opens the add-on page; while the add-on is not installed, that page says it is not available.
- **Users need the `addons` feature** (replaces "no user loses access" and the Studio and Talk rows of the gate table). Every add-on page is behind `addons`, which is off by default for a user and is not granted at upgrade. An administrator grants it in Settings → Users and keys. The upgrade notes say so.
-- **Sidebar.** Studio and Talk appear only once installed; the upgrade seed pins them for existing accounts, and a seeded pin does not expire before its add-on was installed once.
+- **Sidebar.** Studio and Talk appear only once installed and explicitly pinned, like any other add-on. All absent IDs follow the same retention rule.
- **Browse add-ons is for administrators only**; a user with `addons` sees the installed add-ons.
### Approved decisions
@@ -171,11 +176,13 @@ Browser storage (`localStorage`) is not enough. The sidebar already uses it for
The pin state must work with several frontends. The table lives in the shared database, so every frontend reads the same list. This follows the distributed-state guide (`.agents/distributed-state.md`). No per-process cache may hold the only copy.
-### The one-time seed
+### Explicit pins
-In plain words. When LocalAI is upgraded to the version that has pins, every account that already exists gets Studio and Talk pinned, once. They keep the sidebar entries they had before the upgrade. After that nothing is pinned for them again, so if one of them unpins Studio, it stays unpinned. An account created after the upgrade starts with no pins at all; it finds Studio and Talk in the drawer and pins what it uses.
-
-How it works. The seed runs once, when LocalAI starts the first version that has pins, not when a user reads the pin list. A marker in the shared database records that it ran, so a restart, a second frontend and a later release do not run it again; with several frontends that start together, exactly one runs it. It pins only for the accounts that exist at that moment. The pin routes never add a pin of their own. With authentication off there is one local owner, and that owner counts as an existing account: it gets the seed on the first start of that version, and the marker is kept in the pins file next to the pins.
+New and existing accounts start without implicit pins. Startup never scans accounts to add pins.
+Only an explicit user selection changes the saved list. Existing saved pins are preserved.
+Pins and unpins survive a reopen and a second frontend. With authentication off, the local owner
+uses the same rule. Every absent ID follows the same retention window, including never-installed IDs.
+The store no longer creates or reads seed metadata and does not drop existing tables.
### API
@@ -378,7 +385,7 @@ Rules for redirects:
- The Create group is removed. Chat moves to the top level. Studio and Talk leave the sidebar unless the user pins them. Home and the Build and Operate hub entries stay where they are today.
- The Build and Operate hubs keep their tabs, except for the tabs that moved to add-ons. The fine-tuning, quantization, Face, Voices and Add-ons tabs leave the Build hub. Voice and Face get no hub entry of any kind (question (d)): the add-ons are not shipped with LocalAI, so a user finds them in Browse add-ons and, once installed, in the drawer. The Build overview page keeps its cards for fine-tuning and quantization; they link to the old routes, which open the add-on while it is usable and the core page otherwise. The hub's `addonNavigation` second row (`hubConfig.js:74`) goes away: its job moves to the drawer and to the pinned group.
-- Users who had Studio or Talk in the sidebar see them in the drawer. At upgrade, the server pins Studio and Talk once for the accounts that exist then, so the layout does not change under them. The seed runs once for the deployment and never again, so a later unpin sticks, and an account created after the upgrade starts with no pins (see "The one-time seed").
+- Studio and Talk are available in the drawer once installed. Users explicitly choose their pins; existing saved pins remain unchanged.
### Documentation
@@ -412,9 +419,9 @@ Decided on 2026-10-16. They are add-on packages and do not ship with LocalAI. Se
Decided on 2026-10-16. They move in this change, as two add-ons. The API and the job managers stay in core; the add-ons get the UI and a tool surface; the host operations they need are new contract surface, each with its own tests. See "Host operations for the engine".
-### (g) The pin seed
+### (g) Explicit pin selection
-Decided on 2026-10-16. Existing accounts get Studio and Talk pinned once, at upgrade; new accounts start with no pins. See "The one-time seed".
+The unreleased implementation uses explicit pin selection for every account. See "Explicit pins".
### (h) SDK additions
@@ -446,7 +453,7 @@ Pin storage:
- With authentication off, the local owner's pins persist across a reload.
- Two frontends on one database return the same list (use the existing fake-bus pattern from the distributed-state guide).
- The cap of eight is enforced in the UI. The server rejects a list over a hard limit.
-- The upgrade seed pins Studio and Talk once, for the accounts that exist at the upgrade, and never again. An account created after the upgrade has no pins. A read or a save never adds a pin. With authentication off, the local owner is seeded once and an unpin sticks across a restart. Two frontends that start together seed once between them.
+- New and existing accounts have no implicit pins. Explicit pins and unpins survive reopen and a second frontend. Every absent ID has equal retention; reads and startup add nothing.
- The start of the 30-day window of a removed add-on survives a restart in both stores.
Permission filtering:
@@ -511,7 +518,7 @@ Quality gates:
| A user who has not installed Voice, Face, fine-tuning or quantization no longer has a hub tab for it | The user does not know the tool exists | The old URLs keep opening the core page; the Build overview keeps its cards for fine-tuning and quantization; each package is listed in Browse add-ons. |
| A platform is enabled before its native check passes | A broken package ships for that platform | A test fails when a platform is enabled without a recorded native check; the release workflow builds only enabled platforms. |
| An add-on process calls a host operation | The add-on reaches the job engine | A call needs a live request of that add-on, runs as that request's actor, passes the feature check, and is allowed only for add-ons the host setting names. |
-| The pin seed pins for the wrong accounts | An account created after the upgrade gets pins it did not ask for | The seed runs once at upgrade, for the accounts that exist then, and the pin routes never seed. Tested for both cases. |
+| Startup adds unwanted pins | Accounts get pins they did not ask for | Startup never changes saved selections. Tested for existing accounts and the local owner. |
### Rollback
@@ -528,7 +535,7 @@ Changes of 2026-10-16, after the pre-flight review of the plan. One line each, w
- New section "Host operations for the engine": the four host operations, their rules, marked as new contract surface (scan C22, maintainer ruling).
- New decisions 10 to 12 and the section "Distribution": Voice, Face, fine-tuning and quantization are not shipped; platform list `linux/amd64`, `linux/arm64`, `darwin/arm64`, `darwin/amd64`, each with its runner; a platform is enabled only after its native check (scan C19, maintainer ruling).
- Question (d) and the Migration section: no hub tab for Voice or Face; discovery through Browse and the drawer; old routes open the core page until the add-on is installed (scan C18, maintainer ruling).
-- New subsection "The one-time seed" and the Sidebar bullet: seed once at upgrade for existing accounts, new accounts start with no pins (scan C23, maintainer ruling).
+- The earlier one-time seed proposal is superseded by explicit pin selection in this unreleased refactor.
- New subsection "Additions accepted on 2026-10-16" and the `HomeUndoToast` row: the viewer, the WAV helper, 26 icon names and the undo bar are accepted (scan C20, maintainer ruling).
- Section "API": the three routes are registered by `RegisterAddonNavigationRoutes`, so the existing route tests pass without edits (scan C1).
- Section "Disabled or removed while pinned": the start of the retention window is stored with the pin and survives a restart (scan C2).
@@ -536,7 +543,7 @@ Changes of 2026-10-16, after the pre-flight review of the plan. One line each, w
- Section "Redirects": the model of a moved route travels as `?model=`, and a moved page opens in its add-on only while the add-on is usable (scan C17).
- Section "Shared UI in the SDK": add-ons that use SDK 2 declare `min: 2, max: 2`; the tests that pin SDK 1 are updated with the SDK change (scan C14).
- Section "Rollback": the legacy switch restores the old sidebar and hub tab; the generator redirects do not depend on it (scan C13).
-- Test plan and gate table: the tile of a packaged add-on shows for anyone with the `addons` feature and its page shows the off notice; tests added for the seed, the host operations, the platforms and the inventory (scan C19, C22, C15).
+- Test plan and gate table: the tile of a packaged add-on shows for anyone with the `addons` feature and its page shows the off notice; tests cover explicit pins, the host operations, the platforms and the inventory (scan C19, C22, C15).
- Plan only: pin store keeps the owner and the missing mark in the file (scan C2); the two-frontends test moves to the service task (scan C3); `ClipInput` is built in the Voice library task (scan C8); the suite inventory is updated by Tasks 1, 4 and 9 (scan C15).
- Section "Extension points": the `app.nav` slot is drawn on the Add-ons drawer page below the tile grid, and in the Build hub row only under the legacy switch (Task 6 review, maintainer ruling).
- Section "Redirects" (Task 7 review, maintainer ruling): the redirect into Studio keeps the model as a path segment (`/app/studio//`), not `?model=`, because Studio is a core route. Revisit if Studio moves under the add-on host; cost if wrong: a redirect with a model needs a rewrite then.
diff --git a/gallery/addons/index.yaml b/gallery/addons/index.yaml
index 4d23936ca..ce66d526e 100644
--- a/gallery/addons/index.yaml
+++ b/gallery/addons/index.yaml
@@ -460,9 +460,10 @@
description: "Let your chats run commands and code in an isolated sandbox, with your files mounted."
mainPage: "sandbox"
icon: "ui/icon.png"
- toolCount: 4
+ toolCount: 5
tools:
- "bash"
+ - "execution-status"
- "status"
- "set-backend"
- "confirm-host"
@@ -491,9 +492,10 @@
description: "Let your chats run commands and code in an isolated sandbox, with your files mounted."
mainPage: "sandbox"
icon: "ui/icon.png"
- toolCount: 4
+ toolCount: 5
tools:
- "bash"
+ - "execution-status"
- "status"
- "set-backend"
- "confirm-host"
diff --git a/pkg/addons/contract/README.md b/pkg/addons/contract/README.md
new file mode 100644
index 000000000..4b422ae4c
--- /dev/null
+++ b/pkg/addons/contract/README.md
@@ -0,0 +1,62 @@
+# Typed addon contract
+
+`ParseManifest` is the strict v1 parser used by staging and package verification.
+Unknown fields, duplicate keys, invalid declarations, and unsupported compatibility ranges fail validation.
+The typed declarations remain additive strict v1 fields, not a new protocol version.
+
+- `capabilities.ui.homeCommands` names installed pages, never tools or install actions.
+- `capabilities.executionProvider` names execution tools and one declared UI-only read status tool.
+- `capabilities.hostOperations.required` and `.optional` request exact operation names, never grants.
+
+`ValidHostOp` validates syntax only. The host's registered vocabulary determines support.
+Administrator policy and request admission remain authoritative, including for optional operations.
+Status is advisory; an isolation report does not prove network enforcement or authorize execution.
+
+See [the addon guide](../../../docs/content/features/addons.md#optional-typed-manifest-extensions)
+for JSON examples, bounds, collision rules, lifecycle behavior, policy migration, and old-host staging refusal.
+Old hosts must retain the old package when an extension-bearing upgrade is refused.
+Release maintainers select the minimum supporting release and the duration of any legacy package support.
+
+Typed owner observations currently support local-owner JSON selections only.
+Named-user and SQL selections remain unavailable and fail closed; they are not completed scope.
+
+## Optional model actions
+
+`capabilities.ui.modelActions` maps a model capability to a declared page:
+
+```json
+{"modelActions":[{"capability":"FLAG_IMAGE","page":"studio","path":"images"}]}
+```
+
+`capability` and `page` are required. `path` is an optional nonempty relative
+suffix. There is one mapping per capability per addon, at most 27 mappings.
+The SDK route bounds apply to `page/path`: 256 ASCII characters, eight segments,
+and 1–64 characters per segment from `[A-Za-z0-9._~-]`, excluding `.` and `..`.
+Absolute paths, URLs, queries, fragments, percent encoding and backslashes fail.
+Unknown, duplicate and noncanonical keys fail strict parsing.
+
+The exact vocabulary is `ModelActionCapabilities()`: all concrete flags in
+`config.GetAllModelConfigUsecases()` except native `FLAG_CHAT` and
+`FLAG_COMPLETION` and aggregate `FLAG_LLM`. `FLAG_ANY` is not a concrete flag.
+A core/config parity test guards this list without importing core into contract.
+Declarations carry no priorities, labels, permissions or model query templates.
+Apps exposes a cloned typed list only through its existing installed UI view;
+unavailable UI omission is unchanged. Protocol registration uses the same
+`contract.Capabilities`, and verifies exact equality including these actions.
+
+Studio owns image/video/TTS/sound destinations; Talk owns transcription at its
+existing landing page; Face and Voice own face/speaker recognition respectively.
+Talk does not currently consume a selected-model query value. Its pipeline and
+landing semantics remain unchanged. Frontend dispatch and route cleanup are a
+separate milestone; this contract alone does not change model Open behavior.
+
+The allowed capability strings are:
+
+```text
+FLAG_EDIT FLAG_EMBEDDINGS FLAG_RERANK FLAG_IMAGE FLAG_TRANSCRIPT FLAG_TTS
+FLAG_SOUND_GENERATION FLAG_TOKENIZE FLAG_VAD FLAG_VIDEO FLAG_DETECTION
+FLAG_VISION FLAG_FACE_RECOGNITION FLAG_SPEAKER_RECOGNITION FLAG_AUDIO_TRANSFORM
+FLAG_DIARIZATION FLAG_SOUND_CLASSIFICATION FLAG_REALTIME_AUDIO FLAG_SCORE
+FLAG_DEPTH FLAG_TOKEN_CLASSIFY FLAG_3D FLAG_3D_ANIMATION FLAG_DECISIONS
+FLAG_UPSCALE FLAG_MOTION FLAG_FORECAST
+```
diff --git a/pkg/addons/contract/exactkeys.go b/pkg/addons/contract/exactkeys.go
new file mode 100644
index 000000000..8b6362110
--- /dev/null
+++ b/pkg/addons/contract/exactkeys.go
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+ "strings"
+)
+
+// exactObjectFields checks keys before encoding/json can match struct fields
+// case-insensitively. strictJSON retains duplicate, trailing and size/depth bounds.
+// Only the immediate object is checked: localized maps keep their locale keys.
+func exactObjectFields(b []byte, max int64, keys ...string) (map[string]json.RawMessage, error) {
+ var fields map[string]json.RawMessage
+ if err := strictJSON(b, &fields, max); err != nil {
+ return nil, err
+ }
+ for key := range fields {
+ if !slices.Contains(keys, key) {
+ return nil, fmt.Errorf("noncanonical or unknown JSON key %q", key)
+ }
+ }
+ return fields, nil
+}
+
+// rejectExtensionAliases narrows exact spelling to the new declaration entry
+// keys, without changing historical spelling behavior for unrelated fields.
+func rejectExtensionAliases(b []byte, keys ...string) error {
+ var fields map[string]json.RawMessage
+ if err := strictJSON(b, &fields, DefaultLimits().ManifestBytes); err != nil {
+ return err
+ }
+ for key := range fields {
+ for _, canonical := range keys {
+ if key != canonical && strings.EqualFold(key, canonical) {
+ return fmt.Errorf("noncanonical extension key %q", key)
+ }
+ }
+ }
+ return nil
+}
+
+func (c *Capabilities) UnmarshalJSON(b []byte) error {
+ if err := rejectExtensionAliases(b, "executionProvider", "hostOperations"); err != nil {
+ return err
+ }
+ type wire Capabilities
+ var v wire
+ if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil {
+ return err
+ }
+ *c = Capabilities(v)
+ return nil
+}
+
+func (u *UI) UnmarshalJSON(b []byte) error {
+ if err := rejectExtensionAliases(b, "homeCommands", "modelActions"); err != nil {
+ return err
+ }
+ type wire UI
+ var v wire
+ if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil {
+ return err
+ }
+ *u = UI(v)
+ return nil
+}
+
+func (p *ExecutionProvider) UnmarshalJSON(b []byte) error {
+ if _, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "tools", "statusTool"); err != nil {
+ return err
+ }
+ type wire ExecutionProvider
+ var v wire
+ if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil {
+ return err
+ }
+ *p = ExecutionProvider(v)
+ return nil
+}
+
+func (o *HostOperations) UnmarshalJSON(b []byte) error {
+ if _, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "required", "optional"); err != nil {
+ return err
+ }
+ type wire HostOperations
+ var v wire
+ if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil {
+ return err
+ }
+ *o = HostOperations(v)
+ return nil
+}
diff --git a/pkg/addons/contract/exactkeys_test.go b/pkg/addons/contract/exactkeys_test.go
new file mode 100644
index 000000000..1d2591052
--- /dev/null
+++ b/pkg/addons/contract/exactkeys_test.go
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "encoding/json"
+ "strings"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Extension canonical wire keys", func() {
+ DescribeTable("rejects status aliases without partial trust", func(raw string) {
+ v, err := DecodeExecutionStatus([]byte(raw))
+ Expect(err).To(HaveOccurred())
+ Expect(v).To(Equal(ExecutionStatus{Readiness: "unknown", Isolation: "unknown"}))
+ },
+ Entry("Explanation markup", `{"readiness":"available","isolation":"host","Explanation":"unsafe"}`),
+ Entry("Readiness override", `{"readiness":"unknown","Readiness":"available","isolation":"host"}`),
+ Entry("uppercase enums", `{"READINESS":"available","ISOLATION":"host"}`),
+ Entry("Explanation control", `{"readiness":"available","isolation":"host","Explanation":"a\nb"}`),
+ Entry("Explanation oversized", `{"readiness":"available","isolation":"host","Explanation":"`+strings.Repeat("x", 201)+`"}`),
+ Entry("benign explanation alias", `{"readiness":"available","isolation":"host","EXPLANATION":"Ready"}`),
+ Entry("isolation override", `{"readiness":"available","isolation":"unknown","Isolation":"host"}`),
+ )
+ DescribeTable("rejects declaration aliases in manifests and direct capability echoes", func(old, replacement string) {
+ raw := strings.Replace(string(extensionFixture()), old, replacement, 1)
+ Expect(raw).NotTo(Equal(string(extensionFixture())))
+ _, err := ParseManifest([]byte(raw))
+ Expect(err).To(HaveOccurred())
+ var manifest map[string]json.RawMessage
+ Expect(json.Unmarshal([]byte(raw), &manifest)).To(Succeed())
+ var c Capabilities
+ Expect(json.Unmarshal(manifest["capabilities"], &c)).NotTo(Succeed())
+ },
+ Entry("Alias empty", `"alias":"work"`, `"Alias":""`),
+ Entry("Label null", `"label":{"en":"Open work","it":"Apri"}`, `"Label":null`),
+ Entry("ID override", `"id":"open"`, `"id":"ignored","ID":"open"`),
+ Entry("Page variant", `"page":"history"`, `"Page":"history"`),
+ Entry("Icon variant", `"icon":"clock"`, `"Icon":"clock"`),
+ Entry("provider Tools", `"tools":["run"],"statusTool"`, `"Tools":["run"],"statusTool"`),
+ Entry("provider StatusTool", `"statusTool":"health"`, `"StatusTool":"health"`),
+ Entry("provider mixed duplicate", `"statusTool":"health"`, `"statusTool":"ignored","StatusTool":"health"`),
+ Entry("operation Required", `"required":["workspace.materialize"]`, `"Required":["workspace.materialize"]`),
+ Entry("operation Optional", `"optional":["egress.check"]`, `"OPTIONAL":["egress.check"]`),
+ Entry("operation mixed duplicate", `"optional":["egress.check"]`, `"optional":[],"Optional":["egress.check"]`),
+ Entry("provider entry variant", `"executionProvider":`, `"ExecutionProvider":`),
+ Entry("provider entry mixed duplicate", `"executionProvider":`, `"executionProvider":null,"ExecutionProvider":`),
+ Entry("operations entry variant", `"hostOperations":`, `"HOSTOPERATIONS":`),
+ Entry("operations entry mixed duplicate", `"hostOperations":`, `"hostOperations":null,"HostOperations":`),
+ Entry("commands entry variant", `"homeCommands":`, `"HomeCommands":`),
+ Entry("commands entry mixed duplicate", `"homeCommands":`, `"homeCommands":[],"HomeCommands":`),
+ )
+ It("preserves arbitrary valid locale keys at the localized map boundary", func() {
+ raw := strings.Replace(string(extensionFixture()), `"it":"Apri"`, `"zh-Hant":"開啟","pt-BR":"Abrir"`, 1)
+ _, err := ParseManifest([]byte(raw))
+ Expect(err).NotTo(HaveOccurred())
+ })
+})
diff --git a/pkg/addons/contract/executionstatus.go b/pkg/addons/contract/executionstatus.go
new file mode 100644
index 000000000..3301d8ede
--- /dev/null
+++ b/pkg/addons/contract/executionstatus.go
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "fmt"
+ "unicode/utf8"
+)
+
+const (
+ MaxExecutionStatusBytes = 2048
+ MaxExecutionExplanationChars = 200
+)
+
+// ExecutionStatus is provider-reported observation, not authority or a host
+// isolation guarantee. Only DecodeExecutionStatus validates a wire payload.
+type ExecutionStatus struct {
+ Readiness string `json:"readiness"`
+ Isolation string `json:"isolation"`
+ Explanation string `json:"explanation,omitempty"`
+}
+
+// DecodeExecutionStatus decodes Result.Payload, never the result envelope.
+// Unknown/duplicate/trailing fields (including outputs) are refused. On any
+// error it returns unknown/unknown, not partially trusted provider data.
+func DecodeExecutionStatus(b []byte) (ExecutionStatus, error) {
+ unknown := ExecutionStatus{Readiness: "unknown", Isolation: "unknown"}
+ var v ExecutionStatus
+ if !utf8.Valid(b) {
+ return unknown, fmt.Errorf("invalid execution status UTF-8")
+ }
+ fields, err := exactObjectFields(b, MaxExecutionStatusBytes, "readiness", "isolation", "explanation")
+ if err != nil {
+ return unknown, err
+ }
+ if err := strictJSON(b, &v, MaxExecutionStatusBytes); err != nil {
+ return unknown, err
+ }
+ if v.Readiness != "available" && v.Readiness != "unavailable" && v.Readiness != "unknown" {
+ return unknown, fmt.Errorf("invalid execution readiness")
+ }
+ if v.Isolation != "isolated" && v.Isolation != "host" && v.Isolation != "unknown" {
+ return unknown, fmt.Errorf("invalid execution isolation")
+ }
+ // Distinguish absent explanation from explicit null or empty text.
+ if _, present := fields["explanation"]; present && !ValidPlainText(v.Explanation, MaxExecutionExplanationChars) {
+ return unknown, fmt.Errorf("invalid execution explanation")
+ }
+ if v.Readiness != "available" {
+ v.Isolation = "unknown"
+ }
+ return v, nil
+}
diff --git a/pkg/addons/contract/executionstatus_test.go b/pkg/addons/contract/executionstatus_test.go
new file mode 100644
index 000000000..beef7afac
--- /dev/null
+++ b/pkg/addons/contract/executionstatus_test.go
@@ -0,0 +1,74 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "strings"
+
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Execution status payload", func() {
+ It("validates every enum pair and normalizes nonavailable isolation", func() {
+ for _, r := range []string{"available", "unavailable", "unknown"} {
+ for _, i := range []string{"isolated", "host", "unknown"} {
+ v, err := DecodeExecutionStatus([]byte(`{"readiness":"` + r + `","isolation":"` + i + `","explanation":"` + strings.Repeat("界", 200) + `"}`))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(v.Readiness).To(Equal(r))
+ if r != "available" {
+ Expect(v.Isolation).To(Equal("unknown"))
+ } else {
+ Expect(v.Isolation).To(Equal(i))
+ }
+ }
+ }
+ })
+ DescribeTable("rejects invalid payloads without partial success", func(b string) {
+ v, err := DecodeExecutionStatus([]byte(b))
+ Expect(err).To(HaveOccurred())
+ Expect(v.Readiness).To(Equal("unknown"))
+ Expect(v.Isolation).To(Equal("unknown"))
+ },
+ Entry("missing isolation", `{"readiness":"available"}`),
+ Entry("missing readiness", `{"isolation":"host"}`),
+ Entry("bad readiness", `{"readiness":"ok","isolation":"host"}`),
+ Entry("bad isolation", `{"readiness":"available","isolation":"container"}`),
+ Entry("duplicate", `{"readiness":"available","readiness":"unknown","isolation":"host"}`),
+ Entry("unknown field", `{"readiness":"available","isolation":"host","ok":true}`),
+ Entry("outputs", `{"readiness":"available","isolation":"host","outputs":[]}`),
+ Entry("trailing", `{"readiness":"available","isolation":"host"}{}`),
+ Entry("null", `null`),
+ Entry("null explanation", `{"readiness":"available","isolation":"host","explanation":null}`),
+ Entry("empty explanation", `{"readiness":"available","isolation":"host","explanation":""}`),
+ Entry("oversized explanation", `{"readiness":"available","isolation":"host","explanation":"`+strings.Repeat("界", 201)+`"}`),
+ Entry("markup", `{"readiness":"available","isolation":"host","explanation":"OK"}`),
+ Entry("control", `{"readiness":"available","isolation":"host","explanation":"a\nb"}`),
+ Entry("direction", `{"readiness":"available","isolation":"host","explanation":"a\u202eb"}`),
+ Entry("oversized payload", strings.Repeat(" ", 2049)+`{}`),
+ )
+ It("bridges validation into the existing pure model boundary", func() {
+ for _, tc := range []struct {
+ raw string
+ valid bool
+ value model.Value
+ }{
+ {`{"readiness":"available","isolation":"host"}`, true, model.Value{Readiness: model.Available, Isolation: model.Host}},
+ {`{"readiness":"unavailable","isolation":"isolated"}`, true, model.Value{Readiness: model.Unavailable, Isolation: model.UnknownIsolation}},
+ {`{"readiness":"available","isolation":"host","outputs":[]}`, false, model.Unknown()},
+ } {
+ v, err := DecodeExecutionStatus([]byte(tc.raw))
+ payload := model.InvalidPayload()
+ if err == nil {
+ payload = model.ValidPayload(model.Value{Readiness: model.Readiness(v.Readiness), Isolation: model.Isolation(v.Isolation)})
+ }
+ b := model.Binding{Addon: "renamed"}
+ tr := model.Reduce(model.Pending{B: b, Q: "q", Deadline: 10}, model.Reply{B: b, Q: "q", Payload: payload, Now: 1})
+ if tc.valid {
+ Expect(tr.Rule).To(Equal(model.R3))
+ } else {
+ Expect(tr.Rule).To(Equal(model.R4))
+ }
+ }
+ })
+})
diff --git a/pkg/addons/contract/extensions.go b/pkg/addons/contract/extensions.go
new file mode 100644
index 000000000..c938d8cf7
--- /dev/null
+++ b/pkg/addons/contract/extensions.go
@@ -0,0 +1,159 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+)
+
+const (
+ MaxHomeCommands = 8
+ MaxExecutionTools = 8
+ MaxRequestedHostOperations = 32
+)
+
+// HomeCommand contributes a declared page, never a tool or an external URL.
+// Empty Page resolves to UI.MainPage. Alias requests a name, not ownership.
+type HomeCommand struct {
+ ID string `json:"id"`
+ Alias string `json:"alias,omitempty"`
+ Page string `json:"page,omitempty"`
+ Label *Localized `json:"label,omitempty"`
+ Icon string `json:"icon,omitempty"`
+}
+
+// UnmarshalJSON keeps omission distinct from an explicit empty or null
+// override. Only omission selects the installed display/destination defaults.
+func (c *HomeCommand) UnmarshalJSON(b []byte) error {
+ type wire HomeCommand
+ var v wire
+ fields, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "id", "alias", "page", "label", "icon")
+ if err != nil {
+ return err
+ }
+ if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil {
+ return err
+ }
+ for key, value := range map[string]string{"alias": v.Alias, "page": v.Page, "icon": v.Icon} {
+ if _, present := fields[key]; present && value == "" {
+ return fmt.Errorf("home command %s must be nonempty when present", key)
+ }
+ }
+ if _, present := fields["label"]; present && v.Label == nil {
+ return fmt.Errorf("home command label must not be null")
+ }
+ *c = HomeCommand(v)
+ return nil
+}
+
+// ExecutionProvider binds observations to these action tools only. It grants
+// neither shadowing authority nor permission to execute commands.
+type ExecutionProvider struct {
+ Tools []string `json:"tools"`
+ StatusTool string `json:"statusTool"`
+}
+
+// HostOperations declares requests, never host grants. Omission preserves
+// legacy/unspecified requests; an explicitly empty declaration is invalid.
+type HostOperations struct {
+ Required []string `json:"required,omitempty"`
+ Optional []string `json:"optional,omitempty"`
+}
+
+// IsExecutionAction reports declaration facts only, never execution authority.
+func IsExecutionAction(c Capabilities, d ToolDefinition) bool {
+ return slices.Contains(c.Tools, d.Name) && d.Kind == ToolKindAction && !d.UIOnly && d.Shadows == "bash"
+}
+
+// IsExecutionStatusTool reports the status declaration shape, not read authority.
+// Schema acceptance is checked separately to preserve manifest diagnostics.
+func IsExecutionStatusTool(c Capabilities, d ToolDefinition) bool {
+ return slices.Contains(c.Tools, d.Name) && !slices.Contains(c.AdminTools, d.Name) && d.Kind == ToolKindRead && d.UIOnly && IsResourceless(d)
+}
+
+// ExecutionStatusAcceptsEmptyArguments applies the manifest schema rules even
+// when a capability source supplies raw definitions without parsing a manifest.
+func ExecutionStatusAcceptsEmptyArguments(d ToolDefinition) bool {
+ if ValidateToolSchema(d.InputSchema, ToolKindRead) != nil {
+ return false
+ }
+ var schema struct {
+ Required []string `json:"required"`
+ Enum json.RawMessage `json:"enum"`
+ }
+ // Allowed constraints otherwise apply to absent properties or non-object types.
+ return json.Unmarshal(d.InputSchema, &schema) == nil && len(schema.Required) == 0 && len(schema.Enum) == 0
+}
+
+func validateExtensions(c Capabilities) error {
+ if u := c.UI; u != nil {
+ if err := ValidateModelActions(u); err != nil {
+ return err
+ }
+ if len(u.HomeCommands) > MaxHomeCommands {
+ return fmt.Errorf("too many home commands")
+ }
+ seen := map[string]bool{}
+ for _, command := range u.HomeCommands {
+ if !ValidName(command.ID) || seen[command.ID] {
+ return fmt.Errorf("invalid or duplicate home command")
+ }
+ seen[command.ID] = true
+ if command.Alias != "" && !ValidName(command.Alias) {
+ return fmt.Errorf("invalid home command alias")
+ }
+ page := command.Page
+ if page == "" {
+ page = u.MainPage
+ }
+ if page == "" || !slices.Contains(u.Pages, page) {
+ return fmt.Errorf("home command requires a declared page or mainPage")
+ }
+ if command.Label != nil && !command.Label.valid(MaxLabelChars) {
+ return fmt.Errorf("invalid home command label")
+ }
+ if command.Icon != "" && !validIconName(command.Icon) && command.Icon != u.Icon {
+ return fmt.Errorf("home command icon must be a host icon name or the packaged icon")
+ }
+ }
+ }
+ if p := c.ExecutionProvider; p != nil {
+ if len(p.Tools) == 0 || len(p.Tools) > MaxExecutionTools || !uniqueIDs(p.Tools) || !ValidName(p.StatusTool) || slices.Contains(p.Tools, p.StatusTool) {
+ return fmt.Errorf("invalid execution provider bindings")
+ }
+ defs := map[string]ToolDefinition{}
+ for _, d := range c.ToolDefinitions {
+ defs[d.Name] = d
+ }
+ for _, name := range p.Tools {
+ d, ok := defs[name]
+ if !ok || !IsExecutionAction(c, d) {
+ return fmt.Errorf("execution provider must bind declared action tools shadowing bash")
+ }
+ }
+ d, ok := defs[p.StatusTool]
+ if !ok || !IsExecutionStatusTool(c, d) {
+ return fmt.Errorf("execution status must be a separate page-only resourceless read tool")
+ }
+ if !ExecutionStatusAcceptsEmptyArguments(d) {
+ return fmt.Errorf("execution status must accept empty arguments")
+ }
+ }
+ if ops := c.HostOperations; ops != nil {
+ n := len(ops.Required) + len(ops.Optional)
+ if n == 0 || n > MaxRequestedHostOperations {
+ return fmt.Errorf("host operations require 1 to %d requests", MaxRequestedHostOperations)
+ }
+ seen := map[string]bool{}
+ for _, list := range [][]string{ops.Required, ops.Optional} {
+ for _, name := range list {
+ if !ValidHostOp(name) || seen[name] {
+ return fmt.Errorf("invalid or duplicate host operation request")
+ }
+ seen[name] = true
+ }
+ }
+ }
+ return nil
+}
diff --git a/pkg/addons/contract/extensions_test.go b/pkg/addons/contract/extensions_test.go
new file mode 100644
index 000000000..68f8b00fa
--- /dev/null
+++ b/pkg/addons/contract/extensions_test.go
@@ -0,0 +1,183 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func extensionFixture() []byte {
+ return []byte(strings.Replace(string(fixture()), `"service":true`, `"service":true,"actions":true,"tools":["run","health"],"toolDefinitions":[{"name":"run","description":"Run commands","kind":"action","shadows":"bash","inputSchema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}},{"name":"health","description":"Report readiness","kind":"read","uiOnly":true,"inputSchema":{"type":"object","properties":{},"additionalProperties":false}}],"executionProvider":{"tools":["run"],"statusTool":"health"},"hostOperations":{"required":["workspace.materialize"],"optional":["egress.check"]},"ui":{"entry":"ui/main.js","sdk":{"min":2,"max":2},"pages":["work","history"],"mainPage":"work","icon":"ui/icon.png","homeCommands":[{"id":"open","alias":"work","label":{"en":"Open work","it":"Apri"}},{"id":"history","page":"history","icon":"clock"}]}`, 1))
+}
+
+var _ = Describe("Optional extension contracts", func() {
+ DescribeTable("keeps execution declaration predicates aligned with manifest validation", func(field, value, message string) {
+ m, err := ParseManifest(extensionFixture())
+ Expect(err).NotTo(HaveOccurred())
+ c := &m.Capabilities
+ switch field {
+ case "schema":
+ c.ToolDefinitions[1].InputSchema = json.RawMessage(value)
+ case "admin":
+ c.AdminTools = []string{"health"}
+ case "status-ui":
+ c.ToolDefinitions[1].UIOnly = false
+ case "status-kind":
+ c.ToolDefinitions[1].Kind = value
+ case "action-ui":
+ c.ToolDefinitions[0].UIOnly = true
+ case "action-kind":
+ c.ToolDefinitions[0].Kind = value
+ case "shadow":
+ c.ToolDefinitions[0].Shadows = value
+ }
+ action := IsExecutionAction(*c, c.ToolDefinitions[0])
+ status := IsExecutionStatusTool(*c, c.ToolDefinitions[1]) && ExecutionStatusAcceptsEmptyArguments(c.ToolDefinitions[1])
+ Expect(action && status).To(BeFalse())
+ Expect(validateExtensions(*c)).To(MatchError(message))
+ raw, err := json.Marshal(m)
+ Expect(err).NotTo(HaveOccurred())
+ _, err = ParseManifest(raw)
+ Expect(err).To(HaveOccurred())
+ },
+ Entry("wrong object", "schema", `{"type":"string","additionalProperties":false}`, "execution status must accept empty arguments"),
+ Entry("additional properties true", "schema", `{"type":"object","additionalProperties":true}`, "execution status must accept empty arguments"),
+ Entry("additional properties absent", "schema", `{"type":"object"}`, "execution status must accept empty arguments"),
+ Entry("required", "schema", `{"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false}`, "execution status must accept empty arguments"),
+ Entry("enum", "schema", `{"type":"object","enum":["x"],"additionalProperties":false}`, "execution status must accept empty arguments"),
+ Entry("unknown schema keyword", "schema", `{"type":"object","minProperties":1,"additionalProperties":false}`, "execution status must accept empty arguments"),
+ Entry("malformed required", "schema", `{"type":"object","required":null,"additionalProperties":false}`, "execution status must accept empty arguments"),
+ Entry("resource", "schema", `{"type":"object","properties":{"resourceId":{"type":"string"}},"required":["resourceId"],"additionalProperties":false}`, "execution status must be a separate page-only resourceless read tool"),
+ Entry("admin", "admin", "", "execution status must be a separate page-only resourceless read tool"),
+ Entry("status uiOnly", "status-ui", "", "execution status must be a separate page-only resourceless read tool"),
+ Entry("status kind", "status-kind", "action", "execution status must be a separate page-only resourceless read tool"),
+ Entry("action uiOnly", "action-ui", "", "execution provider must bind declared action tools shadowing bash"),
+ Entry("action kind", "action-kind", "read", "execution provider must bind declared action tools shadowing bash"),
+ Entry("shadow", "shadow", "other", "execution provider must bind declared action tools shadowing bash"),
+ )
+ It("accepts empty arguments with optional property constraints", func() {
+ m, err := ParseManifest(extensionFixture())
+ Expect(err).NotTo(HaveOccurred())
+ d := &m.Capabilities.ToolDefinitions[1]
+ d.InputSchema = json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","enum":["ok"]}},"required":[],"additionalProperties":false}`)
+ Expect(IsExecutionStatusTool(m.Capabilities, *d)).To(BeTrue())
+ Expect(ExecutionStatusAcceptsEmptyArguments(*d)).To(BeTrue())
+ raw, err := json.Marshal(m)
+ Expect(err).NotTo(HaveOccurred())
+ _, err = ParseManifest(raw)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("preserves all three declarations through manifest and capability echo", func() {
+ m, err := ParseManifest(extensionFixture())
+ Expect(err).NotTo(HaveOccurred())
+ b, err := json.Marshal(m)
+ Expect(err).NotTo(HaveOccurred())
+ again, err := ParseManifest(b)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(again).To(Equal(m))
+ b, err = json.Marshal(m.Capabilities)
+ Expect(err).NotTo(HaveOccurred())
+ var echo Capabilities
+ Expect(json.Unmarshal(b, &echo)).To(Succeed())
+ Expect(echo).To(Equal(m.Capabilities))
+ Expect(SchemaVersion).To(Equal(1))
+ Expect(HostSDKVersion).To(Equal(uint64(2)))
+ })
+ It("keeps old manifests free of extension keys", func() {
+ m, err := ParseManifest(fixture())
+ Expect(err).NotTo(HaveOccurred())
+ b, err := json.Marshal(m)
+ Expect(err).NotTo(HaveOccurred())
+ for _, key := range []string{"homeCommands", "executionProvider", "hostOperations"} {
+ Expect(string(b)).NotTo(ContainSubstring(key))
+ }
+ })
+ It("bounds commands and operation requests at their declared limits", func() {
+ m, err := ParseManifest(extensionFixture())
+ Expect(err).NotTo(HaveOccurred())
+ c := m.Capabilities
+ c.UI.HomeCommands = nil
+ for i := 0; i < MaxHomeCommands; i++ {
+ c.UI.HomeCommands = append(c.UI.HomeCommands, HomeCommand{ID: fmt.Sprintf("open-%d", i)})
+ }
+ Expect(validateExtensions(c)).To(Succeed())
+ c.UI.HomeCommands = append(c.UI.HomeCommands, HomeCommand{ID: "overflow"})
+ Expect(validateExtensions(c)).NotTo(Succeed())
+ c.UI.HomeCommands = nil
+ c.HostOperations = &HostOperations{}
+ for i := 0; i < MaxRequestedHostOperations; i++ {
+ c.HostOperations.Required = append(c.HostOperations.Required, "future.op"+string(rune('a'+i/26))+string(rune('a'+i%26)))
+ }
+ Expect(validateExtensions(c)).To(Succeed())
+ c.HostOperations.Optional = []string{"future.overflow"}
+ Expect(validateExtensions(c)).NotTo(Succeed())
+ c.HostOperations = nil
+ c.ExecutionProvider.Tools = make([]string, MaxExecutionTools+1)
+ Expect(validateExtensions(c)).NotTo(Succeed())
+ })
+ It("accepts localized string labels, packaged icons and explicit non-navigation pages", func() {
+ b := strings.Replace(string(extensionFixture()), `"label":{"en":"Open work","it":"Apri"}`, `"label":"`+strings.Repeat("界", MaxLabelChars)+`"`, 1)
+ b = strings.Replace(b, `"icon":"clock"`, `"icon":"ui/icon.png"`, 1)
+ _, err := ParseManifest([]byte(b))
+ Expect(err).NotTo(HaveOccurred())
+ })
+ DescribeTable("requires status schemas to accept the empty object", func(schema string, valid bool) {
+ b := strings.Replace(string(extensionFixture()), `"type":"object","properties":{},"additionalProperties":false`, schema, 1)
+ Expect(b).NotTo(Equal(string(extensionFixture())))
+ _, err := ParseManifest([]byte(b))
+ if valid {
+ Expect(err).NotTo(HaveOccurred())
+ } else {
+ Expect(err).To(HaveOccurred())
+ }
+ },
+ Entry("root enum excludes objects", `"type":"object","properties":{},"additionalProperties":false,"enum":["never-an-object"]`, false),
+ Entry("root required property excludes empty objects", `"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false`, false),
+ Entry("root type excludes objects", `"type":"string","properties":{},"additionalProperties":false`, false),
+ Entry("optional constrained properties accept empty objects", `"type":"object","properties":{"query":{"type":"string","enum":["ready"],"minLength":1},"options":{"type":"object","properties":{"detail":{"type":"boolean"}},"required":["detail"],"additionalProperties":false}},"additionalProperties":false`, true),
+ Entry("keywords for other types do not constrain objects", `"type":"object","properties":{},"additionalProperties":false,"minLength":1,"maxLength":0,"minItems":1,"maxItems":0,"minimum":1,"maximum":0`, true),
+ )
+ DescribeTable("rejects malformed extension declarations", func(old, replacement string) {
+ b := strings.Replace(string(extensionFixture()), old, replacement, 1)
+ Expect(b).NotTo(Equal(string(extensionFixture())))
+ _, err := ParseManifest([]byte(b))
+ Expect(err).To(HaveOccurred())
+ },
+ Entry("non bash execution tool", `"shadows":"bash",`, ``),
+ Entry("empty optional alias", `"alias":"work"`, `"alias":""`),
+ Entry("null optional alias", `"alias":"work"`, `"alias":null`),
+ Entry("empty optional page", `"page":"history"`, `"page":""`),
+ Entry("null optional label", `"label":{"en":"Open work","it":"Apri"}`, `"label":null`),
+ Entry("empty optional icon", `"icon":"clock"`, `"icon":""`),
+ Entry("unknown command key", `"id":"open"`, `"id":"open","target":"https://example.org"`),
+ Entry("duplicate command key", `"id":"open"`, `"id":"open","id":"again"`),
+ Entry("invalid alias", `"alias":"work"`, `"alias":"/Work"`),
+ Entry("missing id", `"id":"open",`, ``),
+ Entry("duplicate id", `"id":"history"`, `"id":"open"`),
+ Entry("undeclared page", `"page":"history"`, `"page":"outside"`),
+ Entry("no default destination", `"mainPage":"work",`, ``),
+ Entry("foreign icon", `"icon":"clock"`, `"icon":"ui/other.png"`),
+ Entry("long label", `"en":"Open work"`, `"en":"`+strings.Repeat("界", 61)+`"`),
+ Entry("bad locale", `"it":"Apri"`, `"bad_tag":"Apri"`),
+ Entry("markup label", `"en":"Open work"`, `"en":"Open"`),
+ Entry("duplicate locale", `"en":"Open work"`, `"en":"Open work","en":"Again"`),
+ Entry("status schema open", `"properties":{},"additionalProperties":false`, `"properties":{},"additionalProperties":true`),
+ Entry("status wrong kind", `"name":"health","description":"Report readiness","kind":"read"`, `"name":"health","description":"Report readiness","kind":"action"`),
+ Entry("empty provider", `"tools":["run"],"statusTool":"health"`, ``),
+ Entry("unknown execution tool", `"tools":["run"],"statusTool"`, `"tools":["missing"],"statusTool"`),
+ Entry("duplicate execution binding", `"tools":["run"],"statusTool"`, `"tools":["run","run"],"statusTool"`),
+ Entry("same status binding", `"statusTool":"health"`, `"statusTool":"run"`),
+ Entry("non UI status", `"uiOnly":true`, `"uiOnly":false`),
+ Entry("status required input", `"properties":{},"additionalProperties"`, `"properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties"`),
+ Entry("status resource", `"properties":{},"additionalProperties"`, `"properties":{"resourceId":{"type":"string"}},"additionalProperties"`),
+ Entry("empty requests", `"required":["workspace.materialize"],"optional":["egress.check"]`, ``),
+ Entry("duplicate requests", `"optional":["egress.check"]`, `"optional":["workspace.materialize"]`),
+ Entry("wildcard request", `"egress.check"`, `"egress.*"`),
+ Entry("unknown request key", `"optional":["egress.check"]`, `"optional":["egress.check"],"grants":true`),
+ )
+})
diff --git a/pkg/addons/contract/manifest.go b/pkg/addons/contract/manifest.go
index 7a8e6155f..81f7e731f 100644
--- a/pkg/addons/contract/manifest.go
+++ b/pkg/addons/contract/manifest.go
@@ -111,8 +111,10 @@ type Capabilities struct {
Connector *ConnectorCapability `json:"connector,omitempty"`
// SkillDefinitions are the skills the package ships under skills/ (see
// skills.go). The chat's skill index offers them while the add-on runs.
- SkillDefinitions []SkillDefinition `json:"skillDefinitions,omitempty"`
- UI *UI `json:"ui,omitempty"`
+ SkillDefinitions []SkillDefinition `json:"skillDefinitions,omitempty"`
+ UI *UI `json:"ui,omitempty"`
+ ExecutionProvider *ExecutionProvider `json:"executionProvider,omitempty"`
+ HostOperations *HostOperations `json:"hostOperations,omitempty"`
}
// ResourceTools maps the host's UI-only resource management operations to
@@ -203,11 +205,13 @@ func validateResourceTools(c Capabilities) error {
}
type UI struct {
- Entry string `json:"entry"`
- CSS []string `json:"css,omitempty"`
- SDK VersionRange `json:"sdk"`
- Pages []string `json:"pages,omitempty"`
- Navigation []string `json:"navigation,omitempty"`
+ HomeCommands []HomeCommand `json:"homeCommands,omitempty"`
+ ModelActions []ModelAction `json:"modelActions,omitempty"`
+ Entry string `json:"entry"`
+ CSS []string `json:"css,omitempty"`
+ SDK VersionRange `json:"sdk"`
+ Pages []string `json:"pages,omitempty"`
+ Navigation []string `json:"navigation,omitempty"`
// ChatActions are the chat actions: a bare identifier (inert) or a full
// declaration with label, icon, placement and target.
ChatActions []ChatAction `json:"chatActions,omitempty"`
@@ -358,6 +362,9 @@ func ValidateManifest(m Manifest, h HostCompatibility) error {
if e := validateToolDefinitions(c.ToolDefinitions, c.Tools); e != nil {
return e
}
+ if e := validateExtensions(c); e != nil {
+ return e
+ }
if e := validateResourceTools(c); e != nil {
return e
}
diff --git a/pkg/addons/contract/modelactions.go b/pkg/addons/contract/modelactions.go
new file mode 100644
index 000000000..448dd52fb
--- /dev/null
+++ b/pkg/addons/contract/modelactions.go
@@ -0,0 +1,86 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "fmt"
+ "regexp"
+ "slices"
+ "strings"
+)
+
+// ModelAction contributes navigation for a non-native model capability. Page
+// must be declared; Path is an optional suffix, never a URL or query template.
+type ModelAction struct {
+ Capability string `json:"capability"`
+ Page string `json:"page"`
+ Path string `json:"path,omitempty"`
+}
+
+// ModelActionCapabilities returns the host's concrete non-native model flags.
+// FLAG_ANY and aggregate FLAG_LLM are not destinations. Chat and completion
+// remain native and cannot be shadowed. Keep vocabulary parity in core/config.
+func ModelActionCapabilities() []string {
+ return []string{
+ "FLAG_EDIT", "FLAG_EMBEDDINGS", "FLAG_RERANK", "FLAG_IMAGE", "FLAG_TRANSCRIPT",
+ "FLAG_TTS", "FLAG_SOUND_GENERATION", "FLAG_TOKENIZE", "FLAG_VAD", "FLAG_VIDEO",
+ "FLAG_DETECTION", "FLAG_VISION", "FLAG_FACE_RECOGNITION", "FLAG_SPEAKER_RECOGNITION",
+ "FLAG_AUDIO_TRANSFORM", "FLAG_DIARIZATION", "FLAG_SOUND_CLASSIFICATION",
+ "FLAG_REALTIME_AUDIO", "FLAG_SCORE", "FLAG_DEPTH", "FLAG_TOKEN_CLASSIFY",
+ "FLAG_3D", "FLAG_3D_ANIMATION", "FLAG_DECISIONS", "FLAG_UPSCALE", "FLAG_MOTION", "FLAG_FORECAST",
+ }
+}
+
+// MaxModelActions bounds the declaration independently of the wire byte limit.
+const MaxModelActions = 27
+
+var modelRouteSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]{1,64}$`)
+
+func (a *ModelAction) UnmarshalJSON(b []byte) error {
+ fields, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "capability", "page", "path")
+ if err != nil {
+ return err
+ }
+ type wire ModelAction
+ var v wire
+ if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil {
+ return err
+ }
+ if _, present := fields["path"]; present && v.Path == "" {
+ return fmt.Errorf("model action path must be nonempty when present")
+ }
+ *a = ModelAction(v)
+ return nil
+}
+
+// ValidateModelActions uses the SDK resolveSubpath grammar: at most eight
+// segments and 256 ASCII characters including the page, 64 per segment.
+// Declarations intentionally disallow the SDK's optional leading slash/query.
+func ValidateModelActions(u *UI) error {
+ if len(u.ModelActions) > MaxModelActions {
+ return fmt.Errorf("too many model actions")
+ }
+ seen := map[string]bool{}
+ for _, a := range u.ModelActions {
+ if !slices.Contains(ModelActionCapabilities(), a.Capability) || seen[a.Capability] {
+ return fmt.Errorf("unsupported or duplicate model action capability")
+ }
+ seen[a.Capability] = true
+ if !ValidName(a.Page) || !slices.Contains(u.Pages, a.Page) {
+ return fmt.Errorf("model action requires a declared page")
+ }
+ route := a.Page
+ if a.Path != "" {
+ route += "/" + a.Path
+ }
+ segments := strings.Split(route, "/")
+ if len(route) > 256 || len(segments) > 8 {
+ return fmt.Errorf("model action route exceeds SDK bounds")
+ }
+ for _, s := range segments {
+ if !modelRouteSegment.MatchString(s) || s == "." || s == ".." {
+ return fmt.Errorf("invalid model action route segment")
+ }
+ }
+ }
+ return nil
+}
diff --git a/pkg/addons/contract/modelactions_test.go b/pkg/addons/contract/modelactions_test.go
new file mode 100644
index 000000000..87eaaa62c
--- /dev/null
+++ b/pkg/addons/contract/modelactions_test.go
@@ -0,0 +1,108 @@
+// SPDX-License-Identifier: MIT
+package contract
+
+import (
+ "encoding/json"
+ "fmt"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "os"
+ "strings"
+)
+
+func modelActionFixture(actions string) []byte {
+ return []byte(strings.Replace(string(extensionFixture()), `"homeCommands":`, `"modelActions":`+actions+`,"homeCommands":`, 1))
+}
+
+var _ = Describe("Optional model actions", func() {
+ It("bounds model actions to one mapping per supported capability", func() {
+ m, err := ParseManifest(extensionFixture())
+ Expect(err).NotTo(HaveOccurred())
+ for _, capability := range ModelActionCapabilities() {
+ m.Capabilities.UI.ModelActions = append(m.Capabilities.UI.ModelActions, ModelAction{Capability: capability, Page: "work"})
+ }
+ Expect(m.Capabilities.UI.ModelActions).To(HaveLen(MaxModelActions))
+ b, err := json.Marshal(m)
+ Expect(err).NotTo(HaveOccurred())
+ _, err = ParseManifest(b)
+ Expect(err).NotTo(HaveOccurred())
+ m.Capabilities.UI.ModelActions = append(m.Capabilities.UI.ModelActions, ModelAction{Capability: "FLAG_IMAGE", Page: "work"})
+ b, err = json.Marshal(m)
+ Expect(err).NotTo(HaveOccurred())
+ _, err = ParseManifest(b)
+ Expect(err).To(HaveOccurred())
+ })
+ It("declares only owning model actions for existing addon destinations", func() {
+ expected := map[string][]ModelAction{
+ "studio": {{Capability: "FLAG_IMAGE", Page: "studio", Path: "images"}, {Capability: "FLAG_VIDEO", Page: "studio", Path: "video"}, {Capability: "FLAG_TTS", Page: "studio", Path: "tts"}, {Capability: "FLAG_SOUND_GENERATION", Page: "studio", Path: "sound"}},
+ "talk": {{Capability: "FLAG_TRANSCRIPT", Page: "talk"}},
+ "face": {{Capability: "FLAG_FACE_RECOGNITION", Page: "recognition"}},
+ "voice": {{Capability: "FLAG_SPEAKER_RECOGNITION", Page: "recognition"}},
+ }
+ for id, actions := range expected {
+ b, err := os.ReadFile(fmt.Sprintf("../../../addons/%s/manifest.json", id))
+ Expect(err).NotTo(HaveOccurred())
+ m, err := ParseManifest(b)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(m.Capabilities.UI.ModelActions).To(Equal(actions))
+ }
+ })
+
+ It("round trips declared model destinations without changing protocol or SDK", func() {
+ m, err := ParseManifest(modelActionFixture(`[{"capability":"FLAG_IMAGE","page":"work","path":"images"},{"capability":"FLAG_TRANSCRIPT","page":"history"}]`))
+ Expect(err).NotTo(HaveOccurred())
+ b, err := json.Marshal(m)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(b)).To(ContainSubstring(`"modelActions":[{"capability":"FLAG_IMAGE","page":"work","path":"images"},{"capability":"FLAG_TRANSCRIPT","page":"history"}]`))
+ again, err := ParseManifest(b)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(again).To(Equal(m))
+ })
+ It("accepts SDK route segment and combined path boundaries", func() {
+ for _, path := range []string{"a_~.-9", strings.Repeat("a", 64), "a/b/c/d/e/f/g", strings.Repeat("a", 64) + "/" + strings.Repeat("b", 64) + "/" + strings.Repeat("c", 64) + "/" + strings.Repeat("d", 56)} {
+ _, err := ParseManifest(modelActionFixture(`[{"capability":"FLAG_IMAGE","page":"work","path":"` + path + `"}]`))
+ Expect(err).NotTo(HaveOccurred(), path)
+ }
+ })
+ DescribeTable("rejects unsafe or ambiguous model actions", func(actions string) {
+ _, err := ParseManifest(modelActionFixture(actions))
+ Expect(err).To(HaveOccurred())
+ },
+ Entry("unknown field", `[{"capability":"FLAG_IMAGE","page":"work","url":"https://example.org"}]`),
+ Entry("duplicate field", `[{"capability":"FLAG_IMAGE","page":"work","page":"history"}]`),
+ Entry("case field", `[{"Capability":"FLAG_IMAGE","page":"work"}]`),
+ Entry("case page", `[{"capability":"FLAG_IMAGE","Page":"work"}]`),
+ Entry("case path", `[{"capability":"FLAG_IMAGE","page":"work","Path":"images"}]`),
+ Entry("duplicate capability", `[{"capability":"FLAG_IMAGE","page":"work"},{"capability":"FLAG_IMAGE","page":"history"}]`),
+ Entry("missing page", `[{"capability":"FLAG_IMAGE"}]`),
+ Entry("undeclared page", `[{"capability":"FLAG_IMAGE","page":"outside"}]`),
+ Entry("unknown capability", `[{"capability":"FLAG_FUTURE","page":"work"}]`),
+ Entry("native chat", `[{"capability":"FLAG_CHAT","page":"work"}]`),
+ Entry("native completion", `[{"capability":"FLAG_COMPLETION","page":"work"}]`),
+ Entry("aggregate LLM", `[{"capability":"FLAG_LLM","page":"work"}]`),
+ Entry("any capability", `[{"capability":"FLAG_ANY","page":"work"}]`),
+ Entry("noncanonical capability", `[{"capability":"flag_image","page":"work"}]`),
+ Entry("absolute", `[{"capability":"FLAG_IMAGE","page":"work","path":"/images"}]`),
+ Entry("URL", `[{"capability":"FLAG_IMAGE","page":"work","path":"https://example.org"}]`),
+ Entry("traversal", `[{"capability":"FLAG_IMAGE","page":"work","path":"a/../b"}]`),
+ Entry("dot", `[{"capability":"FLAG_IMAGE","page":"work","path":"."}]`),
+ Entry("encoded traversal", `[{"capability":"FLAG_IMAGE","page":"work","path":"%2e%2e"}]`),
+ Entry("query", `[{"capability":"FLAG_IMAGE","page":"work","path":"images?model=x"}]`),
+ Entry("hash", `[{"capability":"FLAG_IMAGE","page":"work","path":"images#x"}]`),
+ Entry("backslash", `[{"capability":"FLAG_IMAGE","page":"work","path":"a\\b"}]`),
+ Entry("empty segment", `[{"capability":"FLAG_IMAGE","page":"work","path":"a//b"}]`),
+ Entry("trailing slash", `[{"capability":"FLAG_IMAGE","page":"work","path":"a/"}]`),
+ Entry("empty path", `[{"capability":"FLAG_IMAGE","page":"work","path":""}]`),
+ Entry("null path", `[{"capability":"FLAG_IMAGE","page":"work","path":null}]`),
+ Entry("long segment", `[{"capability":"FLAG_IMAGE","page":"work","path":"`+strings.Repeat("a", 65)+`"}]`),
+ Entry("too many segments", `[{"capability":"FLAG_IMAGE","page":"work","path":"a/b/c/d/e/f/g/h"}]`),
+ Entry("long combined route", `[{"capability":"FLAG_IMAGE","page":"work","path":"`+strings.Repeat("a", 64)+`/`+strings.Repeat("b", 64)+`/`+strings.Repeat("c", 64)+`/`+strings.Repeat("d", 57)+`"}]`),
+ )
+ It("rejects noncanonical modelActions entry keys and duplicate keys", func() {
+ for _, key := range []string{`"ModelActions":[]`, `"modelActions":[],"modelActions":[]`, `"modelActions":[],"ModelActions":[]`} {
+ b := strings.Replace(string(extensionFixture()), `"homeCommands":`, key+`,"homeCommands":`, 1)
+ _, err := ParseManifest([]byte(b))
+ Expect(err).To(HaveOccurred())
+ }
+ })
+})
diff --git a/pkg/addons/model/executionstatus/README.md b/pkg/addons/model/executionstatus/README.md
new file mode 100644
index 000000000..c4b3841ea
--- /dev/null
+++ b/pkg/addons/model/executionstatus/README.md
@@ -0,0 +1,65 @@
+# Execution status model and trace viewer
+
+This pure model describes one enclosing request's status observation, not
+execution authority. Bindings include actor, credential, conversation, provider, generation,
+incarnation and tool names. `Reduce` returns a state, ordered
+effects, rule and guard. It does not perform I/O or read a clock.
+
+`Traces()` supplies independent expected rules, states, ordered effects and final
+projections for all eleven normative trace families. `RunTrace` runs the actual
+reducer. `Explore(6)` checks bounded histories, safety, liveness and all 49
+state/event cells. R8 is retired; ignored transitions use I. A settled failure
+cannot retry without ending the binding lifetime through authoritative admission.
+
+## Generate and inspect
+
+From the repository root:
+
+```sh
+go test -count=1 ./pkg/addons/model/executionstatus/...
+go run ./pkg/addons/model/executionstatus/cmd/viewer -check
+go run ./pkg/addons/model/executionstatus/cmd/viewer -o /path/to/executionstatus-viewer.html
+```
+
+Open the resulting HTML directly in a browser. Select a trace and use Previous
+and Next to inspect each event, before/after state, ordered effects, rule and
+guard. The page is self-contained: no server, network, JavaScript reducer or
+frontend build is needed. JavaScript only presents transitions computed by Go.
+Reply descriptions retain the model payload's validation details; they are not
+wire-format payloads. Fixtures contain synthetic opaque identities, not secrets.
+
+Without `-o`, generation writes `executionstatus-viewer.html` under
+`SDD_LOCAL_ROOT` when that environment variable is present. Otherwise it writes
+portable `viewer.html` in the current directory.
+`go generate ./pkg/addons/model/executionstatus` uses the same default: the
+configured artifact root, or the model directory when the variable is absent.
+An explicit `-o` always overrides this default. Generated HTML is not committed.
+
+## Check contract
+
+`-check` writes nothing. It recomputes real traces, compares each step with the
+independent expected rule/state/ordered effects, checks final projections and
+nonempty guards, and requires all families, rules, states and effect kinds.
+It runs shared bounded invariant exploration, including complete matrix coverage.
+It then generates twice in memory, compares bytes for determinism and checks the
+embedded JSON against freshly computed data. Map keys are sorted by Go's JSON
+encoder; HTML-sensitive payload text is escaped.
+
+To verify an existing generated artifact, supply both flags:
+
+```sh
+go run ./pkg/addons/model/executionstatus/cmd/viewer -check -o /path/to/executionstatus-viewer.html
+```
+
+This additionally checks embedded data and exact generated HTML bytes, detecting
+stale data or presentation. A missing file, stale file, invariant counterexample,
+trace mismatch, incomplete coverage, nondeterminism or malformed template returns
+nonzero. Bare `-check` deliberately needs no existing artifact; it verifies the
+current model and template in memory. Regenerate and check after changing the
+reducer, fixtures, exploration domain or viewer template.
+
+The viewer's Ginkgo/Gomega suite tests data correspondence and error paths,
+including changed expected rules/states/effects, missing families, invariant
+failure, incomplete exploration, deterministic HTML, stale files and CLI errors.
+The model suite remains the independent source of reducer and constructor checks.
+These bounded checks do not certify runtime security or wire contracts.
diff --git a/pkg/addons/model/executionstatus/cmd/viewer/main.go b/pkg/addons/model/executionstatus/cmd/viewer/main.go
new file mode 100644
index 000000000..980e06cb2
--- /dev/null
+++ b/pkg/addons/model/executionstatus/cmd/viewer/main.go
@@ -0,0 +1,233 @@
+// SPDX-License-Identifier: MIT
+
+// Command viewer exports steps computed by the real executionstatus reducer.
+package main
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "reflect"
+ "strings"
+
+ es "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+)
+
+//go:embed viewer.tmpl.html
+var page string
+
+const dataStart = ``
+const placeholder = "/*__DATA__*/null"
+const explorationDepth = 6
+
+type stepJSON struct {
+ Number int `json:"step"`
+ Event string `json:"event"`
+ Before string `json:"before"`
+ After string `json:"after"`
+ Effects []string `json:"effects"`
+ Rule es.Rule `json:"rule"`
+ Guard string `json:"guard"`
+}
+type traceJSON struct {
+ Family int `json:"family"`
+ Name string `json:"name"`
+ Steps []stepJSON `json:"steps"`
+ Value es.Value `json:"value"`
+}
+type dataJSON struct {
+ Traces []traceJSON `json:"traces"`
+ Rules []es.Rule `json:"rules"`
+ Explore es.Exploration `json:"explore"`
+}
+
+// describe retains the concrete union tag, including empty constructors. Using
+// Go's structural formatting also exposes Reply's private validation payload;
+// JSON-marshalling the model interfaces directly would silently lose it.
+func describe(v any) string { return fmt.Sprintf("%T %+v", v, v) }
+
+// collect verifies the independent fixture oracle before exporting reducer data.
+// Function arguments let tests prove failures are propagated, not decorated as
+// successful traces. Production always supplies the shared model functions.
+func collect(traces []es.Trace, runTrace func(es.State, []es.Event) []es.Transition, explore func(int) (es.Exploration, error)) (dataJSON, error) {
+ d := dataJSON{Rules: es.Rules()}
+ families := map[int]bool{}
+ rules := map[es.Rule]bool{}
+ states := map[reflect.Type]bool{reflect.TypeOf(es.Unbound{}): true}
+ effects := map[reflect.Type]bool{}
+ for _, tr := range traces {
+ families[tr.Family] = true
+ steps := runTrace(es.Unbound{}, tr.Events)
+ if len(steps) == 0 || len(steps) != len(tr.Want) || len(steps) != len(tr.Events) {
+ return d, fmt.Errorf("trace %q: incomplete steps", tr.Name)
+ }
+ t := traceJSON{Family: tr.Family, Name: tr.Name, Value: tr.Value}
+ var before es.State = es.Unbound{}
+ for i, s := range steps {
+ want := tr.Want[i]
+ if s.Rule != want.Rule || !reflect.DeepEqual(s.State, want.State) || !reflect.DeepEqual(s.Effects, want.Effects) || s.Guard == "" {
+ return d, fmt.Errorf("trace %q step %d: rule/state/ordered effects/guard mismatch: got %+v, want %+v", tr.Name, i+1, s, want)
+ }
+ rules[s.Rule] = true
+ states[reflect.TypeOf(s.State)] = true
+ fx := []string{}
+ for _, e := range s.Effects {
+ effects[reflect.TypeOf(e)] = true
+ fx = append(fx, describe(e))
+ }
+ t.Steps = append(t.Steps, stepJSON{i + 1, describe(tr.Events[i]), describe(before), describe(s.State), fx, s.Rule, s.Guard})
+ before = s.State
+ }
+ if es.Project(before) != tr.Value {
+ return d, fmt.Errorf("trace %q: final projection mismatch", tr.Name)
+ }
+ d.Traces = append(d.Traces, t)
+ }
+ if len(families) != 11 {
+ return d, fmt.Errorf("incomplete trace families: %d", len(families))
+ }
+ for i := 1; i <= 11; i++ {
+ if !families[i] {
+ return d, fmt.Errorf("missing trace family %d", i)
+ }
+ }
+ if len(rules) != len(d.Rules) {
+ return d, fmt.Errorf("incomplete rule coverage")
+ }
+ for _, r := range d.Rules {
+ if !rules[r] {
+ return d, fmt.Errorf("missing rule %s", r)
+ }
+ }
+ if len(states) != len(es.AllStates()) {
+ return d, fmt.Errorf("incomplete state coverage")
+ }
+ for _, s := range es.AllStates() {
+ if !states[reflect.TypeOf(s)] {
+ return d, fmt.Errorf("missing state %T", s)
+ }
+ }
+ for _, e := range []es.Effect{es.Read{}, es.Cancel{}, es.Publish{}} {
+ if !effects[reflect.TypeOf(e)] {
+ return d, fmt.Errorf("missing effect %T", e)
+ }
+ }
+ rep, err := explore(explorationDepth)
+ if err != nil {
+ return d, fmt.Errorf("exploration: %w", err)
+ }
+ if rep.States == 0 || rep.Transitions == 0 || rep.LivenessChecks != rep.States || len(rep.Cells) != len(es.AllStates())*len(es.AllEvents()) {
+ return d, fmt.Errorf("incomplete invariant exploration")
+ }
+ for _, s := range es.AllStates() {
+ for _, e := range es.AllEvents() {
+ if !rep.Cells[fmt.Sprintf("%T/%T", s, e)] {
+ return d, fmt.Errorf("missing matrix cell %T/%T", s, e)
+ }
+ }
+ }
+ d.Explore = rep
+ return d, nil
+}
+func computedData() ([]byte, error) {
+ d, err := collect(es.Traces(), es.RunTrace, es.Explore)
+ if err != nil {
+ return nil, err
+ }
+ // encoding/json sorts map keys and escapes HTML, including script terminators.
+ return json.Marshal(d)
+}
+func generate() ([]byte, error) {
+ data, err := computedData()
+ if err != nil {
+ return nil, err
+ }
+ if strings.Count(page, placeholder) != 1 {
+ return nil, fmt.Errorf("template must contain exactly one data placeholder")
+ }
+ return []byte(strings.Replace(page, placeholder, string(data), 1)), nil
+}
+func validatePage(html []byte) error {
+ start := bytes.Index(html, []byte(dataStart))
+ if start < 0 {
+ return fmt.Errorf("missing embedded data")
+ }
+ start += len(dataStart)
+ end := bytes.Index(html[start:], []byte(dataEnd))
+ if end < 0 {
+ return fmt.Errorf("unterminated embedded data")
+ }
+ expected, err := computedData()
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(html[start:start+end], expected) {
+ return fmt.Errorf("embedded data differs from real reducer traces")
+ }
+ return nil
+}
+func run(args []string, out io.Writer) error {
+ flags := flag.NewFlagSet("viewer", flag.ContinueOnError)
+ flags.SetOutput(out)
+ defaultPath := "viewer.html"
+ path := flags.String("o", defaultPath, "output file (with -check, verify this existing file)")
+ check := flags.Bool("check", false, "verify traces, invariants and deterministic embedded data without writing")
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+ if flags.NArg() != 0 {
+ return fmt.Errorf("unexpected positional arguments")
+ }
+ explicit := false
+ flags.Visit(func(f *flag.Flag) {
+ if f.Name == "o" {
+ explicit = true
+ }
+ })
+ html, err := generate()
+ if err != nil {
+ return err
+ }
+ if *check {
+ again, err := generate()
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(html, again) {
+ return fmt.Errorf("nondeterministic generation")
+ }
+ if err := validatePage(html); err != nil {
+ return err
+ }
+ if explicit {
+ existing, err := os.ReadFile(*path)
+ if err != nil {
+ return err
+ }
+ if err := validatePage(existing); err != nil {
+ return err
+ }
+ if !bytes.Equal(existing, html) {
+ return fmt.Errorf("stale viewer: %s", *path)
+ }
+ }
+ _, err = fmt.Fprintln(out, "executionstatus: traces, rule/state/effect coverage, invariants and deterministic embedded data verified")
+ return err
+ }
+ if err := os.WriteFile(*path, html, 0600); err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(out, "wrote %s\n", *path)
+ return err
+}
+func main() {
+ if err := run(os.Args[1:], os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/pkg/addons/model/executionstatus/cmd/viewer/main_test.go b/pkg/addons/model/executionstatus/cmd/viewer/main_test.go
new file mode 100644
index 000000000..20b8c26eb
--- /dev/null
+++ b/pkg/addons/model/executionstatus/cmd/viewer/main_test.go
@@ -0,0 +1,148 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ es "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestViewer(t *testing.T) { RegisterFailHandler(Fail); RunSpecs(t, "Execution status viewer") }
+
+var _ = Describe("Real reducer trace export", func() {
+ It("propagates output failures for checks and writes", func() {
+ path := filepath.Join(GinkgoT().TempDir(), "viewer.html")
+ Expect(run([]string{"-check"}, failedOutput{})).To(MatchError("output failed"))
+ Expect(run([]string{"-o", path}, failedOutput{})).To(MatchError("output failed"))
+ })
+ It("exports every trace step with state, event, effects, rule and guard", func() {
+ d, err := collect(es.Traces(), es.RunTrace, es.Explore)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(d.Traces).To(HaveLen(len(es.Traces())))
+ for i, tr := range d.Traces {
+ Expect(tr.Steps).To(HaveLen(len(es.Traces()[i].Events)))
+ for j, s := range tr.Steps {
+ Expect(s.Number).To(Equal(j + 1))
+ Expect(s.Event).NotTo(BeEmpty())
+ Expect(s.Before).NotTo(BeEmpty())
+ Expect(s.After).NotTo(BeEmpty())
+ Expect(s.Guard).NotTo(BeEmpty())
+ Expect(s.Rule).To(Equal(es.Traces()[i].Want[j].Rule))
+ Expect(s.Effects).NotTo(BeNil())
+ }
+ }
+ Expect(d.Explore.Cells).To(HaveLen(49))
+ })
+ It("rejects wrong expected rules", func() {
+ tr := es.Traces()
+ tr[0].Want[0].Rule = es.I
+ _, err := collect(tr, es.RunTrace, es.Explore)
+ Expect(err).To(MatchError(ContainSubstring("step 1")))
+ })
+ It("rejects wrong expected states", func() {
+ tr := es.Traces()
+ tr[0].Want[0].State = es.Closed{}
+ _, err := collect(tr, es.RunTrace, es.Explore)
+ Expect(err).To(HaveOccurred())
+ })
+ It("rejects missing ordered effects", func() {
+ tr := es.Traces()
+ tr[0].Want[1].Effects = nil
+ _, err := collect(tr, es.RunTrace, es.Explore)
+ Expect(err).To(HaveOccurred())
+ })
+ It("rejects wrong final projection", func() {
+ tr := es.Traces()
+ tr[0].Value = es.Unknown()
+ _, err := collect(tr, es.RunTrace, es.Explore)
+ Expect(err).To(HaveOccurred())
+ })
+ It("rejects missing trace families", func() { _, err := collect(es.Traces()[:1], es.RunTrace, es.Explore); Expect(err).To(HaveOccurred()) })
+ It("propagates invariant counterexamples", func() {
+ _, err := collect(es.Traces(), es.RunTrace, func(int) (es.Exploration, error) { return es.Exploration{}, errors.New("counterexample") })
+ Expect(err).To(MatchError(ContainSubstring("counterexample")))
+ })
+ It("rejects incomplete exploration", func() {
+ _, err := collect(es.Traces(), es.RunTrace, func(int) (es.Exploration, error) { return es.Exploration{}, nil })
+ Expect(err).To(HaveOccurred())
+ })
+ It("generates deterministic self-contained HTML and detects embedded data drift", func() {
+ a, err := generate()
+ Expect(err).NotTo(HaveOccurred())
+ b, err := generate()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(a).To(Equal(b))
+ Expect(validatePage(a)).To(Succeed())
+ Expect(validatePage(bytes.Replace(a, []byte(`"R1"`), []byte(`"R8"`), 1))).NotTo(Succeed())
+ Expect(string(a)).To(ContainSubstring(`id="trace"`))
+ Expect(string(a)).To(ContainSubstring(`id="next"`))
+ })
+ It("writes explicit output outside the working directory", func() {
+ cwd, err := os.Getwd()
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(os.Chdir, cwd)
+ work := GinkgoT().TempDir()
+ root := GinkgoT().TempDir()
+ Expect(os.Chdir(work)).To(Succeed())
+ GinkgoT().Setenv("SDD_LOCAL_ROOT", root)
+ var out bytes.Buffer
+ Expect(run([]string{"-o", filepath.Join(root, "executionstatus-viewer.html")}, &out)).To(Succeed())
+ html, err := os.ReadFile(filepath.Join(root, "executionstatus-viewer.html"))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(validatePage(html)).To(Succeed())
+ entries, err := os.ReadDir(work)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(entries).To(BeEmpty())
+ })
+ It("defaults to portable viewer.html", func() {
+ cwd, err := os.Getwd()
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(os.Chdir, cwd)
+ Expect(os.Chdir(GinkgoT().TempDir())).To(Succeed())
+ GinkgoT().Setenv("SDD_LOCAL_ROOT", "")
+ Expect(os.Unsetenv("SDD_LOCAL_ROOT")).To(Succeed())
+ var out bytes.Buffer
+ Expect(run(nil, &out)).To(Succeed())
+ html, err := os.ReadFile("viewer.html")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(validatePage(html)).To(Succeed())
+ })
+ It("keeps bare checks write-free even with an unusable default root", func() {
+ root := GinkgoT().TempDir()
+ GinkgoT().Setenv("SDD_LOCAL_ROOT", filepath.Join(root, "missing"))
+ var out bytes.Buffer
+ Expect(run([]string{"-check"}, &out)).To(Succeed())
+ entries, err := os.ReadDir(root)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(entries).To(BeEmpty())
+ })
+ It("checks in memory without writing and supports explicit output plus stale-file checks", func() {
+ GinkgoT().Setenv("SDD_LOCAL_ROOT", filepath.Join(GinkgoT().TempDir(), "missing"))
+ dir := GinkgoT().TempDir()
+ path := filepath.Join(dir, "viewer.html")
+ var out bytes.Buffer
+ Expect(run([]string{"-check"}, &out)).To(Succeed())
+ Expect(run([]string{"-o", path}, &out)).To(Succeed())
+ Expect(run([]string{"-check", "-o", path}, &out)).To(Succeed())
+ Expect(os.WriteFile(path, []byte("stale"), 0600)).To(Succeed())
+ Expect(run([]string{"-check", "-o", path}, &out)).NotTo(Succeed())
+ })
+ It("reports missing check targets, write failures, bad flags and positional arguments", func() {
+ var out bytes.Buffer
+ path := filepath.Join(GinkgoT().TempDir(), "missing", "viewer.html")
+ Expect(run([]string{"-check", "-o", path}, &out)).NotTo(Succeed())
+ Expect(run([]string{"-o", path}, &out)).NotTo(Succeed())
+ Expect(run([]string{"-bad"}, &out)).NotTo(Succeed())
+ Expect(run([]string{"unexpected"}, &out)).NotTo(Succeed())
+ })
+})
+
+type failedOutput struct{}
+
+func (failedOutput) Write([]byte) (int, error) { return 0, errors.New("output failed") }
diff --git a/pkg/addons/model/executionstatus/cmd/viewer/viewer.tmpl.html b/pkg/addons/model/executionstatus/cmd/viewer/viewer.tmpl.html
new file mode 100644
index 000000000..55f151e95
--- /dev/null
+++ b/pkg/addons/model/executionstatus/cmd/viewer/viewer.tmpl.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+Execution status model traces
+
+
Execution status traces
+
Computed by the Go executionstatus reducer. This page presents recorded transitions; it does not simulate the reducer. Observations are not authority or egress enforcement.
+
+
+
+
+
+
+
diff --git a/pkg/addons/model/executionstatus/explore.go b/pkg/addons/model/executionstatus/explore.go
new file mode 100644
index 000000000..277a208af
--- /dev/null
+++ b/pkg/addons/model/executionstatus/explore.go
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: MIT
+package executionstatus
+
+import (
+ "fmt"
+ "reflect"
+)
+
+type Exploration struct {
+ States, Transitions, LivenessChecks int
+ Cells map[string]bool
+}
+type history struct {
+ state State
+ reads, inflight int
+ now int64
+ path []Event
+}
+
+// Explore performs bounded BFS over reachable histories, not just states.
+// The representative domain has two identities, two Qs, both admission outcomes,
+// payload validity, and times before/equal/after deadline. Rebinding resets the
+// lifetime counter only on R1. Deduplication includes counters and monotonic time;
+// it never merges a retry violation into an already-visited state.
+func Explore(depth int) (Exploration, error) {
+ result := Exploration{Cells: map[string]bool{}}
+ if depth < 1 {
+ return result, fmt.Errorf("depth must be positive")
+ }
+ events := explorationEvents()
+ queue := []history{{state: Unbound{}}}
+ seen := map[string]bool{}
+ for len(queue) > 0 {
+ h := queue[0]
+ queue = queue[1:]
+ key := fmt.Sprintf("%#v/%d/%d/%d", h.state, h.reads, h.inflight, h.now)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ result.States++
+ if err := live(h.state); err != nil {
+ return result, fmt.Errorf("%w; path=%#v", err, h.path)
+ }
+ result.LivenessChecks++
+ // Check frontier transitions too: depth bounds expansion, not invariants.
+ for _, e := range events {
+ now, hasTime := eventTime(e)
+ if hasTime && now < h.now {
+ continue
+ }
+ tr := Reduce(h.state, e)
+ result.Transitions++
+ result.Cells[fmt.Sprintf("%T/%T", h.state, e)] = true
+ next := history{tr.State, h.reads, h.inflight, h.now, append(append([]Event{}, h.path...), e)}
+ if hasTime {
+ next.now = now
+ }
+ if tr.Rule == R1 {
+ next.reads = 0
+ }
+ for _, fx := range tr.Effects {
+ switch fx := fx.(type) {
+ case Read:
+ next.reads++
+ next.inflight++
+ b, q, _ := identity(tr.State)
+ if fx.B != b || fx.Q != q || tr.Rule != R2 {
+ return result, counterexample("read identity", next)
+ }
+ case Cancel:
+ _, q, _ := identity(h.state)
+ if fx.Q != q || next.inflight != 1 {
+ return result, counterexample("cancel without exact pending read", next)
+ }
+ next.inflight--
+ case Publish:
+ if fx.Value != Project(tr.State) {
+ return result, counterexample("publication differs from projection", next)
+ }
+ }
+ }
+ if tr.Rule == R3 {
+ next.inflight--
+ } // reply completed the outstanding read.
+ if next.reads > 1 || next.inflight > 1 || next.inflight < 0 {
+ return result, counterexample("one read per lifetime/in flight", next)
+ }
+ _, pending := tr.State.(Pending)
+ if pending != (next.inflight == 1) {
+ return result, counterexample("pending/read correspondence", next)
+ }
+ if _, closed := h.state.(Closed); closed && (!reflect.DeepEqual(h.state, tr.State) || len(tr.Effects) != 0 || tr.Rule != I) {
+ return result, counterexample("Closed absorbing", next)
+ }
+ if _, withdrawn := h.state.(Withdrawn); withdrawn {
+ if _, _, bound := identity(tr.State); bound && tr.Rule != R1 {
+ return result, counterexample("rebind needs admission", next)
+ }
+ }
+ if v := Project(tr.State); v.Readiness != Available && v.Isolation != UnknownIsolation {
+ return result, counterexample("nonavailable isolation", next)
+ }
+ if o, ok := tr.State.(Observed); ok && !reflect.DeepEqual(h.state, tr.State) {
+ reply, exact := e.(Reply)
+ p, wasPending := h.state.(Pending)
+ if !exact || !wasPending || reply.B != o.B || reply.Q != o.Q || reply.Now >= p.Deadline || !reply.Payload.valid || tr.Rule != R3 {
+ return result, counterexample("positive without exact current reply", next)
+ }
+ }
+ // Binding data is immutable except for an explicit authoritative R1.
+ oldB, oldQ, oldBound := identity(h.state)
+ newB, newQ, newBound := identity(tr.State)
+ if oldBound && newBound && (oldB != newB || oldQ != newQ) {
+ return result, counterexample("binding mutation", next)
+ }
+ if len(h.path) < depth {
+ queue = append(queue, next)
+ }
+ }
+ }
+ return result, nil
+}
+func counterexample(message string, h history) error {
+ return fmt.Errorf("%s: state=%#v reads=%d inflight=%d path=%#v", message, h.state, h.reads, h.inflight, h.path)
+}
+
+// All non-Pending states are waiting-for-input or terminal. For Pending, BFS
+// must reach a settled state, and each timeout and cancellation exit is checked.
+func live(s State) error {
+ p, ok := s.(Pending)
+ if !ok {
+ return nil
+ }
+ exits := []Event{Tick{p.Deadline}, Invalidate{"disabled"}, Close{}}
+ queue := []State{s}
+ seen := map[State]bool{}
+ for len(queue) > 0 {
+ s := queue[0]
+ queue = queue[1:]
+ if seen[s] {
+ continue
+ }
+ seen[s] = true
+ if _, pending := s.(Pending); !pending {
+ return nil
+ }
+ for _, e := range exits {
+ t := Reduce(s, e)
+ if _, pending := t.State.(Pending); pending {
+ return fmt.Errorf("pending exit failed: %T", e)
+ }
+ if len(t.Effects) != 2 {
+ return fmt.Errorf("pending exit lacks cancel/publication: %T", e)
+ }
+ if _, ok := t.Effects[0].(Cancel); !ok {
+ return fmt.Errorf("pending exit does not cancel first: %T", e)
+ }
+ queue = append(queue, t.State)
+ }
+ }
+ return fmt.Errorf("no settled/waiting state reachable")
+}
+func eventTime(e Event) (int64, bool) {
+ switch e := e.(type) {
+ case Probe:
+ return e.Now, true
+ case Reply:
+ return e.Now, true
+ case Failure:
+ return e.Now, true
+ case Tick:
+ return e.Now, true
+ case Bind, Invalidate, Close:
+ return 0, false
+ }
+ panic("unhandled event constructor")
+}
+func explorationEvents() []Event {
+ a := sampleBinding()
+ b := a
+ b.Incarnation = "incarnation-b"
+ out := []Event{Invalidate{"disabled"}, Close{}}
+ for _, b := range []Binding{a, b} {
+ for _, q := range []ObservationID{"q", "q2"} {
+ for _, eligibility := range []Eligibility{Eligible, "denied", "no-contract"} {
+ out = append(out, Bind{b, q, eligibility})
+ }
+ for _, now := range []int64{0, 1, 2, 3} {
+ out = append(out, Probe{b, q, now, 2}, Failure{b, q, "failed", now})
+ for _, payload := range []Payload{InvalidPayload(), ValidPayload(Value{Available, Isolated}), ValidPayload(Value{Available, Host}), ValidPayload(Value{Unavailable, Isolated}), ValidPayload(Value{Available, UnknownIsolation})} {
+ out = append(out, Reply{b, q, payload, now})
+ }
+ }
+ }
+ }
+ for _, now := range []int64{0, 1, 2, 3} {
+ out = append(out, Tick{now})
+ }
+ return out
+}
diff --git a/pkg/addons/model/executionstatus/gen.go b/pkg/addons/model/executionstatus/gen.go
new file mode 100644
index 000000000..92c65fd60
--- /dev/null
+++ b/pkg/addons/model/executionstatus/gen.go
@@ -0,0 +1,7 @@
+// SPDX-License-Identifier: MIT
+package executionstatus
+
+// Generate a self-contained step-through viewer using the real reducer and
+// shared trace oracles. The default artifact is local, not committed.
+//
+//go:generate go run ./cmd/viewer
diff --git a/pkg/addons/model/executionstatus/model_test.go b/pkg/addons/model/executionstatus/model_test.go
new file mode 100644
index 000000000..1e34bc86b
--- /dev/null
+++ b/pkg/addons/model/executionstatus/model_test.go
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: MIT
+package executionstatus
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "reflect"
+ "testing"
+
+ ginkgo "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestExecutionStatus(t *testing.T) {
+ RegisterFailHandler(ginkgo.Fail)
+ ginkgo.RunSpecs(t, "Execution status model")
+}
+
+var _ = ginkgo.Describe("Executable status model", func() {
+ for _, trace := range Traces() {
+ ginkgo.It(fmt.Sprintf("trace family %d: %s", trace.Family, trace.Name), func() {
+ steps := RunTrace(Unbound{}, trace.Events)
+ Expect(steps).To(HaveLen(len(trace.Want)), trace.Name)
+ for i, step := range steps {
+ Expect(step.Rule).To(Equal(trace.Want[i].Rule), "%s step %d", trace.Name, i)
+ Expect(step.State).To(Equal(trace.Want[i].State), "%s step %d", trace.Name, i)
+ Expect(step.Effects).To(Equal(trace.Want[i].Effects), "%s step %d", trace.Name, i)
+ Expect(step.Guard).NotTo(BeEmpty())
+ }
+ Expect(Project(steps[len(steps)-1].State)).To(Equal(trace.Value), trace.Name)
+ })
+ }
+ ginkgo.It("includes all eleven normative trace families", func() {
+ families := map[int]bool{}
+ for _, trace := range Traces() {
+ families[trace.Family] = true
+ }
+ Expect(families).To(HaveLen(11))
+ })
+
+ ginkgo.It("enumerates every declared marker constructor (Go sums are not sealed)", func() {
+ f, err := parser.ParseFile(token.NewFileSet(), "types.go", nil, 0)
+ Expect(err).NotTo(HaveOccurred())
+ for marker, examples := range map[string][]any{"isState": stateExamples(), "isEvent": eventExamples(), "isEffect": {Read{}, Cancel{}, Publish{}}} {
+ declared := map[string]bool{}
+ for _, d := range f.Decls {
+ fn, ok := d.(*ast.FuncDecl)
+ if ok && fn.Name.Name == marker {
+ declared[fn.Recv.List[0].Type.(*ast.Ident).Name] = true
+ }
+ }
+ listed := map[string]bool{}
+ for _, x := range examples {
+ listed[reflect.TypeOf(x).Name()] = true
+ }
+ Expect(listed).To(Equal(declared), marker)
+ }
+ Expect(AllStates()).To(HaveLen(7))
+ Expect(AllEvents()).To(HaveLen(7))
+ Expect(Rules()).To(Equal([]Rule{R1, R2, R3, R4, R5, R6, R7, R9, R10, R11, I}))
+ })
+
+ ginkgo.It("checks all 49 matrix cells with an independent representative rule oracle", func() {
+ matrix := [][]Rule{
+ {R1, I, I, I, I, R6, R10}, {I, R2, I, I, I, R6, R10}, {I, R7, R3, R4, R5, R6, R10},
+ {I, R7, I, I, I, R6, R10}, {I, R7, I, I, I, R6, R10}, {R1, I, I, I, I, R6, R10}, {I, I, I, I, I, I, I},
+ }
+ states := AllStates()
+ events := AllEvents()
+ for i, s := range states {
+ for j, e := range events {
+ tr := Reduce(s, e)
+ Expect(tr.Rule).To(Equal(matrix[i][j]), "%T x %T", s, e)
+ Expect(tr.Guard).NotTo(BeEmpty())
+ }
+ }
+ })
+
+ ginkgo.It("explores bounded histories with safety, lifetime, matrix and liveness checks", func() {
+ report, err := Explore(5)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(report.Cells).To(HaveLen(49))
+ Expect(report.States).To(BeNumerically(">", 100))
+ Expect(report.Transitions).To(BeNumerically(">", 10000))
+ Expect(report.LivenessChecks).To(Equal(report.States))
+ _, err = fmt.Fprintf(ginkgo.GinkgoWriter, "BFS: states=%d transitions=%d cells=%d liveness=%d\n", report.States, report.Transitions, len(report.Cells), report.LivenessChecks)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ ginkgo.It("covers deadline guard splits for Reply, Failure, Tick and expired Probe", func() {
+ b := sampleBinding()
+ p := Pending{b, "q", 10}
+ for _, now := range []int64{9, 10, 11} {
+ for _, payload := range []Payload{InvalidPayload(), ValidPayload(Value{Available, Isolated})} {
+ tr := Reduce(p, Reply{b, "q", payload, now})
+ want := R5
+ if now < 10 {
+ want = R4
+ if payload.valid {
+ want = R3
+ }
+ }
+ Expect(tr.Rule).To(Equal(want))
+ if want == R5 {
+ Expect(tr.State).To(Equal(SettledUnknown{b, "q", Timeout}))
+ }
+ }
+ want := R5
+ if now < 10 {
+ want = R4
+ }
+ Expect(Reduce(p, Failure{b, "q", "failed", now}).Rule).To(Equal(want))
+ want = R5
+ if now < 10 {
+ want = I
+ }
+ Expect(Reduce(p, Tick{now}).Rule).To(Equal(want))
+ want = R9
+ if now < 10 {
+ want = R2
+ }
+ Expect(Reduce(Unobserved{b, "q"}, Probe{b, "q", now, 10}).Rule).To(Equal(want))
+ }
+ })
+ ginkgo.It("rejects invalid local payload enums and conservatively projects nonavailable values", func() {
+ b := sampleBinding()
+ for _, v := range []Value{{"invalid", Isolated}, {Available, "invalid"}, {"", ""}} {
+ Expect(Reduce(Pending{b, "q", 10}, Reply{b, "q", ValidPayload(v), 1}).Rule).To(Equal(R4))
+ }
+ for _, r := range []Readiness{Unavailable, UnknownReadiness} {
+ for _, i := range []Isolation{Isolated, Host, UnknownIsolation} {
+ tr := Reduce(Pending{b, "q", 10}, Reply{b, "q", ValidPayload(Value{r, i}), 1})
+ Expect(tr.Rule).To(Equal(R3))
+ Expect(Project(tr.State)).To(Equal(Value{r, UnknownIsolation}))
+ }
+ }
+ })
+
+ ginkgo.It("distinguishes every binding component and Q, even at the deadline", func() {
+ b := sampleBinding()
+ pending := Pending{b, "q", 10}
+ typ := reflect.TypeOf(b)
+ for i := 0; i < typ.NumField(); i++ {
+ other := b
+ v := reflect.ValueOf(&other).Elem().Field(i)
+ v.SetString(v.String() + "-other")
+ for _, e := range []Event{Probe{other, "q", 10, 20}, Reply{other, "q", ValidPayload(Value{Available, Isolated}), 10}, Failure{other, "q", "failed", 10}} {
+ Expect(Reduce(pending, e)).To(Equal(Transition{pending, nil, I, "ignored: pair or identity guard"}), fmt.Sprint(e))
+ }
+ }
+ for _, s := range []State{Unobserved{b, "q"}, pending, Observed{b, "q", Value{Available, Isolated}}, SettledUnknown{b, "q", "failed"}} {
+ Expect(Reduce(s, Bind{b, "other", Eligible}).Rule).To(Equal(I))
+ Expect(Reduce(s, Probe{b, "other", 1, 10}).Rule).To(Equal(I))
+ }
+ })
+})
+
+func stateExamples() []any {
+ var out []any
+ for _, s := range AllStates() {
+ out = append(out, s)
+ }
+ return out
+}
+func eventExamples() []any {
+ var out []any
+ for _, e := range AllEvents() {
+ out = append(out, e)
+ }
+ return out
+}
diff --git a/pkg/addons/model/executionstatus/reducer.go b/pkg/addons/model/executionstatus/reducer.go
new file mode 100644
index 000000000..d29130e33
--- /dev/null
+++ b/pkg/addons/model/executionstatus/reducer.go
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: MIT
+package executionstatus
+
+// Reduce consumes serialized inputs. All time is caller-provided monotonic
+// time in a caller-chosen unit. Cancellation effects precede publication.
+func Reduce(s State, event Event) Transition {
+ ignored := Transition{s, nil, I, "ignored: pair or identity guard"}
+ if _, ok := s.(Closed); ok {
+ return ignored
+ }
+ b, q, bound := identity(s)
+ emit := func(next State, r Rule, g string, effects ...Effect) Transition {
+ return Transition{next, effects, r, g}
+ }
+ withdraw := func(next State, r Rule, g string, c Cause) Transition {
+ effects := []Effect{}
+ if p, ok := s.(Pending); ok {
+ effects = append(effects, Cancel{p.Q})
+ }
+ effects = append(effects, Publish{Unknown(), c})
+ return emit(next, r, g, effects...)
+ }
+ settle := func(p Pending, r Rule, c Cause) Transition {
+ return emit(SettledUnknown{p.B, p.Q, c}, r, "exact pending completion; deadline takes precedence", Cancel{p.Q}, Publish{Unknown(), c})
+ }
+ switch e := event.(type) {
+ case Bind:
+ if bound {
+ if b != e.B {
+ return withdraw(Withdrawn{"binding-changed"}, R11, "bound identity changed", "binding-changed")
+ }
+ return ignored // Q never resets an admitted observation.
+ }
+ switch s.(type) {
+ case Unbound, Withdrawn:
+ if e.Eligibility == Eligible {
+ return emit(Unobserved{e.B, e.Q}, R1, "authoritative eligible bind", Publish{Unknown(), ""})
+ }
+ c := Cause(e.Eligibility)
+ return emit(SettledUnknown{e.B, e.Q, c}, R1, "authoritative ineligible bind", Publish{Unknown(), c})
+ }
+ case Probe:
+ if !bound || b != e.B || q != e.Q {
+ return ignored
+ }
+ switch s.(type) {
+ case Unobserved:
+ if e.Deadline <= e.Now {
+ return emit(SettledUnknown{b, q, Timeout}, R9, "probe already expired", Publish{Unknown(), Timeout})
+ }
+ return emit(Pending{b, q, e.Deadline}, R2, "exact never-probed and future deadline", Read{b, q, e.Deadline}, Publish{Unknown(), ""})
+ case Pending, Observed, SettledUnknown:
+ return emit(s, R7, "exact probe already attempted or settled")
+ }
+ case Reply:
+ if p, ok := s.(Pending); ok && p.B == e.B && p.Q == e.Q {
+ if e.Now >= p.Deadline {
+ return settle(p, R5, Timeout)
+ }
+ if !e.Payload.valid {
+ return settle(p, R4, Malformed)
+ }
+ v := normalize(e.Payload.value)
+ return emit(Observed{b, q, v}, R3, "exact valid reply before deadline", Publish{v, ""})
+ }
+ case Failure:
+ if p, ok := s.(Pending); ok && p.B == e.B && p.Q == e.Q {
+ if e.Now >= p.Deadline {
+ return settle(p, R5, Timeout)
+ }
+ return settle(p, R4, e.Cause)
+ }
+ case Tick:
+ if p, ok := s.(Pending); ok && e.Now >= p.Deadline {
+ return settle(p, R5, Timeout)
+ }
+ case Invalidate:
+ return withdraw(Withdrawn{e.Cause}, R6, "lifecycle invalidation", e.Cause)
+ case Close:
+ return withdraw(Closed{}, R10, "enclosing request closed", "")
+ }
+ return ignored
+}
diff --git a/pkg/addons/model/executionstatus/traces.go b/pkg/addons/model/executionstatus/traces.go
new file mode 100644
index 000000000..951bfca7b
--- /dev/null
+++ b/pkg/addons/model/executionstatus/traces.go
@@ -0,0 +1,258 @@
+// SPDX-License-Identifier: MIT
+package executionstatus
+
+// Trace contains independent expected transitions for consumers and the viewer.
+// Guard text is explanatory, not part of the trace oracle.
+type Trace struct {
+ Family int
+ Name string
+ Events []Event
+ Want []Transition
+ Value Value
+}
+
+func RunTrace(initial State, events []Event) []Transition {
+ out := make([]Transition, 0, len(events))
+ s := initial
+ for _, e := range events {
+ t := Reduce(s, e)
+ out = append(out, t)
+ s = t.State
+ }
+ return out
+}
+func sampleBinding() Binding {
+ // #nosec G101 -- Symbolic credential identity for reducer traces, not an authentication secret.
+ return Binding{Actor: "actor-a", Credential: "credential-a", Conversation: "conversation-a", Mode: "named-user", Addon: "provider-a", Generation: "generation-a", Incarnation: "incarnation-a", ExecutionTool: "execute-a", StatusTool: "status-a"}
+}
+func AllStates() []State {
+ b := sampleBinding()
+ return []State{Unbound{}, Unobserved{b, "q"}, Pending{b, "q", 10}, Observed{b, "q", Value{Available, Isolated}}, SettledUnknown{b, "q", "failed"}, Withdrawn{"disabled"}, Closed{}}
+}
+func AllEvents() []Event {
+ b := sampleBinding()
+ return []Event{Bind{b, "q", Eligible}, Probe{b, "q", 1, 10}, Reply{b, "q", ValidPayload(Value{Available, Isolated}), 2}, Failure{b, "q", "failed", 2}, Tick{10}, Invalidate{"disabled"}, Close{}}
+}
+
+// Traces are fixtures, not generated by Reduce. Each step specifies ordered
+// effects explicitly so a reducer regression cannot rewrite its own oracle.
+func Traces() []Trace {
+ a := sampleBinding()
+ b := a
+ b.Addon = "provider-b"
+ b.ExecutionTool = "execute-b"
+ b.StatusTool = "status-b"
+ b.Conversation = "conversation-b"
+ iso := Value{Available, Isolated}
+ host := Value{Available, Host}
+ unknown := Unknown()
+ var out []Trace
+ var t Trace
+ var current State
+ start := func(f int, name string) { t = Trace{Family: f, Name: name}; current = Unbound{} }
+ step := func(e Event, r Rule, s State, fx ...Effect) {
+ t.Events = append(t.Events, e)
+ t.Want = append(t.Want, Transition{State: s, Rule: r, Effects: fx})
+ current = s
+ }
+ ignore := func(e Event) { step(e, I, current) }
+ finish := func(v Value) { t.Value = v; out = append(out, t) }
+ bind := func(b Binding, q ObservationID) {
+ step(Bind{b, q, Eligible}, R1, Unobserved{b, q}, Publish{unknown, ""})
+ }
+ probe := func(b Binding, q ObservationID) {
+ step(Probe{b, q, 1, 10}, R2, Pending{b, q, 10}, Read{b, q, 10}, Publish{unknown, ""})
+ }
+ reply := func(b Binding, q ObservationID, v Value) {
+ vnorm := v
+ if v.Readiness != Available {
+ vnorm.Isolation = UnknownIsolation
+ }
+ step(Reply{b, q, ValidPayload(v), 2}, R3, Observed{b, q, vnorm}, Publish{vnorm, ""})
+ }
+ repeat := func(b Binding, q ObservationID) { step(Probe{b, q, 1, 10}, R7, current) }
+ invalidate := func(c Cause, pending bool, q ObservationID) {
+ fx := []Effect{}
+ if pending {
+ fx = append(fx, Cancel{q})
+ }
+ fx = append(fx, Publish{unknown, c})
+ step(Invalidate{c}, R6, Withdrawn{c}, fx...)
+ }
+ start(1, "happy path and duplicate")
+ bind(a, "q")
+ probe(a, "q")
+ reply(a, "q", iso)
+ ignore(Reply{a, "q", ValidPayload(host), 3})
+ finish(iso)
+ start(2, "two racing consumers")
+ bind(a, "q")
+ probe(a, "q")
+ repeat(a, "q")
+ reply(a, "q", host)
+ repeat(a, "q")
+ finish(host)
+ start(3, "timeout cannot retry or reset Q")
+ bind(a, "q")
+ probe(a, "q")
+ step(Tick{10}, R5, SettledUnknown{a, "q", Timeout}, Cancel{"q"}, Publish{unknown, Timeout})
+ ignore(Reply{a, "q", ValidPayload(iso), 11})
+ repeat(a, "q")
+ ignore(Probe{a, "q2", 11, 20})
+ ignore(Bind{a, "q", Eligible})
+ ignore(Bind{a, "q2", Eligible})
+ finish(unknown)
+ for _, failure := range []bool{false, true} {
+ name := "malformed"
+ var e Event = Reply{a, "q", InvalidPayload(), 2}
+ c := Malformed
+ if failure {
+ name = "failure"
+ e = Failure{a, "q", "failed", 2}
+ c = "failed"
+ }
+ start(4, name+" settles without retry")
+ bind(a, "q")
+ probe(a, "q")
+ step(e, R4, SettledUnknown{a, "q", c}, Cancel{"q"}, Publish{unknown, c})
+ repeat(a, "q")
+ ignore(Reply{a, "q", ValidPayload(iso), 3})
+ finish(unknown)
+ }
+ start(4, "fresh model succeeds")
+ bind(a, "q")
+ probe(a, "q")
+ reply(a, "q", iso)
+ finish(iso)
+ start(5, "disable and authoritative re-enable")
+ bind(a, "q")
+ probe(a, "q")
+ invalidate("disabled", true, "q")
+ ignore(Reply{a, "q", ValidPayload(iso), 2})
+ ignore(Probe{a, "q", 2, 10})
+ bind(a, "q2")
+ probe(a, "q2")
+ reply(a, "q2", iso)
+ finish(iso)
+ for _, replacement := range []Binding{b, func() Binding { x := a; x.Incarnation = "incarnation-new"; return x }()} {
+ start(6, "replacement "+replacement.Incarnation+replacement.Addon)
+ bind(a, "q")
+ probe(a, "q")
+ reply(a, "q", iso)
+ invalidate("replaced", false, "q")
+ bind(replacement, "q2")
+ probe(replacement, "q2")
+ ignore(Reply{a, "q", ValidPayload(iso), 2})
+ reply(replacement, "q2", host)
+ finish(host)
+ }
+ for _, prior := range []string{"pending", "observed", "settled"} {
+ start(7, "unexpected bind from "+prior)
+ bind(a, "q")
+ probe(a, "q")
+ if prior == "observed" {
+ reply(a, "q", iso)
+ }
+ if prior == "settled" {
+ step(Failure{a, "q", "failed", 2}, R4, SettledUnknown{a, "q", "failed"}, Cancel{"q"}, Publish{unknown, "failed"})
+ }
+ fx := []Effect{}
+ if prior == "pending" {
+ fx = append(fx, Cancel{"q"})
+ }
+ fx = append(fx, Publish{unknown, "binding-changed"})
+ step(Bind{b, "q2", Eligible}, R11, Withdrawn{"binding-changed"}, fx...)
+ ignore(Reply{a, "q", ValidPayload(iso), 2})
+ bind(b, "q2")
+ probe(b, "q2")
+ reply(b, "q2", host)
+ finish(host)
+ }
+ start(8, "mismatches and deadline equality")
+ ignore(Probe{a, "q", 1, 10})
+ ignore(Reply{a, "q", ValidPayload(iso), 1})
+ bind(a, "q")
+ ignore(Probe{a, "q2", 1, 10})
+ ignore(Probe{b, "q", 1, 10})
+ ignore(Reply{a, "q", ValidPayload(iso), 1})
+ probe(a, "q")
+ ignore(Tick{9})
+ ignore(Reply{b, "q", ValidPayload(iso), 10})
+ step(Reply{a, "q", InvalidPayload(), 10}, R5, SettledUnknown{a, "q", Timeout}, Cancel{"q"}, Publish{unknown, Timeout})
+ finish(unknown)
+ start(9, "close pending then all events")
+ bind(a, "q")
+ probe(a, "q")
+ step(Close{}, R10, Closed{}, Cancel{"q"}, Publish{unknown, ""})
+ for _, e := range AllEvents() {
+ ignore(e)
+ }
+ finish(unknown)
+ for i, s := range AllStates() {
+ start(9, "close constructor "+string(rune('0'+i)))
+ switch s.(type) {
+ case Unbound:
+ case Unobserved:
+ bind(a, "q")
+ case Pending:
+ bind(a, "q")
+ probe(a, "q")
+ case Observed:
+ bind(a, "q")
+ probe(a, "q")
+ reply(a, "q", iso)
+ case SettledUnknown:
+ step(Bind{a, "q", "denied"}, R1, SettledUnknown{a, "q", "denied"}, Publish{unknown, "denied"})
+ case Withdrawn:
+ invalidate("disabled", false, "q")
+ case Closed:
+ step(Close{}, R10, Closed{}, Publish{unknown, ""})
+ }
+ if _, ok := s.(Closed); ok {
+ ignore(Close{})
+ } else if _, ok := s.(Pending); ok {
+ step(Close{}, R10, Closed{}, Cancel{"q"}, Publish{unknown, ""})
+ } else {
+ step(Close{}, R10, Closed{}, Publish{unknown, ""})
+ }
+ for _, e := range AllEvents() {
+ ignore(e)
+ }
+ finish(unknown)
+ }
+ for _, reason := range []Eligibility{"no-contract", "denied"} {
+ start(10, string(reason))
+ step(Bind{a, "q", reason}, R1, SettledUnknown{a, "q", Cause(reason)}, Publish{unknown, Cause(reason)})
+ repeat(a, "q")
+ ignore(Reply{a, "q", ValidPayload(iso), 2})
+ ignore(Bind{a, "q", Eligible})
+ ignore(Bind{a, "q2", Eligible})
+ finish(unknown)
+ }
+ start(10, "expired probe")
+ bind(a, "q")
+ step(Probe{a, "q", 10, 10}, R9, SettledUnknown{a, "q", Timeout}, Publish{unknown, Timeout})
+ repeat(a, "q")
+ finish(unknown)
+ other := a
+ other.Actor = "actor-b"
+ for _, actor := range []Binding{a, other} {
+ start(11, "actor separation "+actor.Actor)
+ bind(actor, "q")
+ probe(actor, "q")
+ foreign := other
+ if actor == other {
+ foreign = a
+ }
+ ignore(Reply{foreign, "q", ValidPayload(iso), 2})
+ reply(actor, "q", Value{Unavailable, Isolated})
+ invalidate("permission-lost", false, "q")
+ finish(unknown)
+ }
+ start(11, "available unknown is not enforcement")
+ bind(a, "q")
+ probe(a, "q")
+ reply(a, "q", Value{Available, UnknownIsolation})
+ finish(Value{Available, UnknownIsolation})
+ return out
+}
diff --git a/pkg/addons/model/executionstatus/types.go b/pkg/addons/model/executionstatus/types.go
new file mode 100644
index 000000000..56e6456ae
--- /dev/null
+++ b/pkg/addons/model/executionstatus/types.go
@@ -0,0 +1,192 @@
+// SPDX-License-Identifier: MIT
+
+// Package executionstatus models one enclosing request's observational status.
+// Bind is authoritative host admission; effects are data, never operations.
+package executionstatus
+
+// Binding contains opaque identity tokens, never credentials. All fields take
+// part in equality, including incarnation within the same package generation.
+type Binding struct {
+ Actor, Credential, Conversation, Mode, Addon, Generation, Incarnation, ExecutionTool, StatusTool string
+}
+type ObservationID string
+type Cause string
+type Eligibility string
+
+const Eligible Eligibility = "eligible"
+const (
+ Timeout Cause = "timeout"
+ Malformed Cause = "malformed"
+)
+
+type Readiness string
+type Isolation string
+
+const (
+ Available Readiness = "available"
+ Unavailable Readiness = "unavailable"
+ UnknownReadiness Readiness = "unknown"
+ Isolated Isolation = "isolated"
+ Host Isolation = "host"
+ UnknownIsolation Isolation = "unknown"
+)
+
+type Value struct {
+ Readiness Readiness
+ Isolation Isolation
+}
+
+func Unknown() Value { return Value{UnknownReadiness, UnknownIsolation} }
+func normalize(v Value) Value {
+ if v.Readiness != Available {
+ v.Isolation = UnknownIsolation
+ }
+ return v
+}
+
+// Payload is a local validation boundary, not a wire decoder. A future strict
+// contract decoder supplies ValidPayload only after validating its full schema.
+// Zero Payload represents malformed input; no contract dependency is needed.
+type Payload struct {
+ value Value
+ valid bool
+}
+
+func ValidPayload(v Value) Payload {
+ valid := (v.Readiness == Available || v.Readiness == Unavailable || v.Readiness == UnknownReadiness) && (v.Isolation == Isolated || v.Isolation == Host || v.Isolation == UnknownIsolation)
+ return Payload{v, valid}
+}
+func InvalidPayload() Payload { return Payload{} }
+
+type State interface{ isState() }
+type Unbound struct{}
+type Unobserved struct {
+ B Binding
+ Q ObservationID
+}
+type Pending struct {
+ B Binding
+ Q ObservationID
+ Deadline int64
+}
+type Observed struct {
+ B Binding
+ Q ObservationID
+ Value Value
+}
+type SettledUnknown struct {
+ B Binding
+ Q ObservationID
+ Cause Cause
+}
+type Withdrawn struct{ Cause Cause }
+type Closed struct{}
+
+func (Unbound) isState() {}
+func (Unobserved) isState() {}
+func (Pending) isState() {}
+func (Observed) isState() {}
+func (SettledUnknown) isState() {}
+func (Withdrawn) isState() {}
+func (Closed) isState() {}
+
+type Event interface{ isEvent() }
+type Bind struct {
+ B Binding
+ Q ObservationID
+ Eligibility Eligibility
+}
+type Probe struct {
+ B Binding
+ Q ObservationID
+ Now, Deadline int64
+}
+type Reply struct {
+ B Binding
+ Q ObservationID
+ Payload Payload
+ Now int64
+}
+type Failure struct {
+ B Binding
+ Q ObservationID
+ Cause Cause
+ Now int64
+}
+type Tick struct{ Now int64 }
+type Invalidate struct{ Cause Cause }
+type Close struct{}
+
+func (Bind) isEvent() {}
+func (Probe) isEvent() {}
+func (Reply) isEvent() {}
+func (Failure) isEvent() {}
+func (Tick) isEvent() {}
+func (Invalidate) isEvent() {}
+func (Close) isEvent() {}
+
+type Effect interface{ isEffect() }
+type Read struct {
+ B Binding
+ Q ObservationID
+ Deadline int64
+}
+type Cancel struct{ Q ObservationID }
+type Publish struct {
+ Value Value
+ Cause Cause
+}
+
+func (Read) isEffect() {}
+func (Cancel) isEffect() {}
+func (Publish) isEffect() {}
+
+type Rule string
+
+const (
+ R1 Rule = "R1"
+ R2 Rule = "R2"
+ R3 Rule = "R3"
+ R4 Rule = "R4"
+ R5 Rule = "R5"
+ R6 Rule = "R6"
+ R7 Rule = "R7"
+ R9 Rule = "R9"
+ R10 Rule = "R10"
+ R11 Rule = "R11"
+ I Rule = "I"
+)
+
+type Transition struct {
+ State State
+ Effects []Effect
+ Rule Rule
+ Guard string
+}
+
+func Rules() []Rule { return []Rule{R1, R2, R3, R4, R5, R6, R7, R9, R10, R11, I} }
+
+func identity(s State) (Binding, ObservationID, bool) {
+ switch s := s.(type) {
+ case Unobserved:
+ return s.B, s.Q, true
+ case Pending:
+ return s.B, s.Q, true
+ case Observed:
+ return s.B, s.Q, true
+ case SettledUnknown:
+ return s.B, s.Q, true
+ case Unbound, Withdrawn, Closed:
+ return Binding{}, "", false
+ }
+ panic("unhandled state constructor")
+}
+func Project(s State) Value {
+ switch s := s.(type) {
+ case Observed:
+ return normalize(s.Value)
+ case Unbound, Unobserved, Pending, SettledUnknown, Withdrawn, Closed:
+ return Unknown()
+ }
+ panic("unhandled state constructor")
+}
diff --git a/pkg/addons/packaging/host_policy_test.go b/pkg/addons/packaging/host_policy_test.go
new file mode 100644
index 000000000..13994fcd1
--- /dev/null
+++ b/pkg/addons/packaging/host_policy_test.go
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: MIT
+package packaging_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/config"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("official host policy deployment", func() {
+ It("loads exactly the historical grants through the actual host loader", func() {
+ root, err := filepath.Abs(filepath.Join("..", "..", ".."))
+ Expect(err).NotTo(HaveOccurred())
+ grants, err := config.LoadAddonHostPolicy(filepath.Join(root, "deploy", "addons", "host-policy.json"))
+ Expect(err).NotTo(HaveOccurred())
+ // Deployment-owned exception: historical identity grants, not core defaults.
+ legacy := "fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector."
+ want, err := config.ParseAddonHostOps(legacy)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(grants).To(Equal(want))
+ })
+ It("explicitly selects the shipped policy in the final Docker runtime stage", func() {
+ data, err := os.ReadFile(filepath.Join("..", "..", "..", "Dockerfile"))
+ Expect(err).NotTo(HaveOccurred())
+ start := strings.LastIndex(string(data), "\nFROM ")
+ Expect(start).To(BeNumerically(">=", 0))
+ runtime := string(data[start:])
+ Expect(runtime).To(ContainSubstring("COPY deploy/addons/host-policy.json /etc/localai/addons/host-policy.json"))
+ Expect(runtime).To(ContainSubstring("ENV LOCALAI_ADDON_HOST_POLICY=/etc/localai/addons/host-policy.json"))
+ })
+})
diff --git a/pkg/addons/packaging/official_contract_test.go b/pkg/addons/packaging/official_contract_test.go
new file mode 100644
index 000000000..2cf5dfc90
--- /dev/null
+++ b/pkg/addons/packaging/official_contract_test.go
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: MIT
+package packaging
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/services/addons/hostops"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// This audit records calls, including the mutation/undo wrappers' operation
+// arguments. It is intentionally independent of deployment prefix grants.
+var officialRequests = map[string]contract.HostOperations{
+ "fine-tuning": {Required: strings.Fields("finetune.checkpoints finetune.dataset finetune.export finetune.get finetune.list finetune.start finetune.stop")},
+ "quantization": {Required: strings.Fields("quantize.get quantize.import quantize.list quantize.start quantize.stop")},
+ "office": {Required: strings.Fields("workspace.export workspace.import")},
+ "sandbox": {Required: strings.Fields("workspace.changes workspace.commit workspace.materialize"), Optional: strings.Fields("egress.audit egress.check workspace.admin")},
+ "web": {Optional: []string{"workspace.admin"}},
+}
+
+var _ = Describe("official typed package contracts", func() {
+ for _, id := range firstPartyAddons {
+ It("audits actual host calls and strict package contract for "+id, func() {
+ root := filepath.Join("../../..", "addons", id)
+ data, err := os.ReadFile(filepath.Join(root, "manifest.json"))
+ Expect(err).NotTo(HaveOccurred())
+ m, err := contract.ParseManifest(data)
+ Expect(err).NotTo(HaveOccurred())
+ want, requests := officialRequests[id]
+ if requests {
+ Expect(m.Capabilities.HostOperations).To(Equal(&want))
+ } else {
+ Expect(m.Capabilities.HostOperations).To(BeNil(), "no hostclient calls: do not manufacture requests from policy grants")
+ }
+ // Collect exact operation literals in production source, including fields
+ // passed to jobundo. An unknown operation-shaped literal is not evidence
+ // of support; compare independently with the real registered vocabulary.
+ var used []string
+ Expect(filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ f, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
+ if err != nil {
+ return err
+ }
+ ast.Inspect(f, func(n ast.Node) bool {
+ lit, ok := n.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return true
+ }
+ value, err := strconv.Unquote(lit.Value)
+ Expect(err).NotTo(HaveOccurred())
+ if slices.Contains(hostops.SupportedOperations(), value) {
+ used = append(used, value)
+ }
+ return true
+ })
+ return nil
+ })).To(Succeed())
+ declared := append(slices.Clone(want.Required), want.Optional...)
+ slices.Sort(used)
+ used = slices.Compact(used)
+ Expect(used).To(ConsistOf(declared), "update the audited call inventory when production calls change")
+ for _, name := range declared {
+ Expect(hostops.SupportedOperations()).To(ContainElement(name))
+ }
+ policyPath, err := filepath.Abs("../../../deploy/addons/host-policy.json")
+ Expect(err).NotTo(HaveOccurred())
+ grants, err := config.LoadAddonHostPolicy(policyPath)
+ Expect(err).NotTo(HaveOccurred())
+ summary := hostops.Requests(m.Capabilities.HostOperations, hostops.SupportedOperations(), grants[id])
+ Expect(summary.MissingRequired).To(BeEmpty())
+ denied := hostops.Requests(m.Capabilities.HostOperations, hostops.SupportedOperations(), nil)
+ Expect(denied.Granted).To(BeEmpty(), "requests never self-grant")
+ Expect(denied.MissingRequired).To(ConsistOf(want.Required))
+ p := newProjectFor(id, map[string][]string{amd: nil})
+ layout := p.mustBuild(amd, "1.2.3", "fixture executable")
+ Expect(p.verify(layout, amd, "1.2.3")).To(Succeed())
+ })
+ }
+})
diff --git a/pkg/addons/packaging/packaging_test.go b/pkg/addons/packaging/packaging_test.go
index 6d189764d..cc7564377 100644
--- a/pkg/addons/packaging/packaging_test.go
+++ b/pkg/addons/packaging/packaging_test.go
@@ -939,7 +939,7 @@ var _ = Describe("catalog template", func() {
}
})
It("reports the chat tools of the manifest on each entry and not the admin tools", func() {
- counts := map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "office": 11, "sandbox": 4, "web": 5}
+ counts := map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "office": 11, "sandbox": 5, "web": 5}
for id, want := range counts {
p := newProjectFor(id, both)
m := p.merged("1.2.3", rev, nil)
@@ -1013,7 +1013,7 @@ var _ = Describe("catalog template", func() {
for _, e := range entries {
got[e.ID] = e.ToolCount
}
- Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0, "telegram": 0, "slack": 0, "office": 11, "sandbox": 4, "web": 5}))
+ Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0, "telegram": 0, "slack": 0, "office": 11, "sandbox": 5, "web": 5}))
})
It("refuses missing inputs, backend repositories, bad channels and unknown placeholders", func() {
reg := newRegistry()
diff --git a/scripts/addons/ginkgo_report.py b/scripts/addons/ginkgo_report.py
new file mode 100644
index 000000000..8c57e5748
--- /dev/null
+++ b/scripts/addons/ginkgo_report.py
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: MIT
+"""Exact required-case comparison shared by the harness and direct evidence checks."""
+import json
+import re
+
+
+def ginkgo_report(path, expected, focus=None):
+ try:
+ reports = json.loads(path.read_text())
+ assert isinstance(reports, list) and reports
+ cases = []
+ for report in reports:
+ assert report['SuiteSucceeded'] is True
+ specs = report['SpecReports']
+ assert specs
+ for spec in specs:
+ # Suite hooks are execution evidence, not focused It cases. Ginkgo
+ # gives them a null hierarchy; still require every hook to pass.
+ if spec['LeafNodeType'] != 'It':
+ assert spec['State'] == 'passed'
+ continue
+ # Ginkgo reports filtered-out specs as skipped. Only an explicit
+ # report focus may exclude them; a required skipped case still fails.
+ text = ' '.join((spec.get('ContainerHierarchyTexts') or []) + [spec['LeafNodeText']])
+ if focus and not re.search(focus, text):
+ assert spec['State'] == 'skipped'
+ continue
+ assert spec['State'] == 'passed'
+ if spec['LeafNodeType'] == 'It':
+ cases.append(spec['LeafNodeText'])
+ assert cases and sorted(cases) == sorted(expected)
+ except (OSError, ValueError, KeyError, TypeError, AssertionError):
+ raise SystemExit('Ginkgo report missing, empty, skipped, failed or inventory mismatch: ' + str(path))
+ return cases
diff --git a/scripts/addons/ginkgo_report_test.py b/scripts/addons/ginkgo_report_test.py
new file mode 100644
index 000000000..27c73f0cf
--- /dev/null
+++ b/scripts/addons/ginkgo_report_test.py
@@ -0,0 +1,85 @@
+# SPDX-License-Identifier: MIT
+"""Regression checks for the exact Ginkgo evidence gate (no Go toolchain needed)."""
+import json
+import pathlib
+import tempfile
+import unittest
+
+from ginkgo_report import ginkgo_report
+
+
+def spec(text='required', state='passed', kind='It', containers=None):
+ return dict(LeafNodeText=text, State=state, LeafNodeType=kind,
+ ContainerHierarchyTexts=containers)
+
+
+class ReportTests(unittest.TestCase):
+ def setUp(self):
+ scratch_root = pathlib.Path(__file__).resolve().parents[2] / '.cache/addons/tmp'
+ scratch_root.mkdir(parents=True, exist_ok=True)
+ self.scratch = tempfile.TemporaryDirectory(dir=scratch_root)
+ self.addCleanup(self.scratch.cleanup)
+ self.path = pathlib.Path(self.scratch.name) / 'report.json'
+
+ def check(self, specs, expected=None, focus=None, succeeded=True):
+ self.path.write_text(json.dumps([dict(SuiteSucceeded=succeeded, SpecReports=specs)]))
+ return ginkgo_report(self.path, ['required'] if expected is None else expected, focus)
+
+ def test_suite_hooks_with_null_hierarchy(self):
+ self.assertEqual(self.check([spec('', kind='BeforeSuite'), spec(containers=['group']),
+ spec('', kind='DeferCleanup (Suite)')]), ['required'])
+
+ def test_focused_suite_hooks_are_not_filtered_specs(self):
+ self.assertEqual(self.check([spec('', kind='BeforeSuite'), spec(containers=['selected']),
+ spec('other', 'skipped', containers=['elsewhere']),
+ spec('', kind='DeferCleanup (Suite)')], focus='selected'), ['required'])
+
+ def test_top_level_it_with_null_hierarchy(self):
+ self.assertEqual(self.check([spec()]), ['required'])
+
+ def test_failed_hook_rejected_even_outside_focus(self):
+ with self.assertRaises(SystemExit):
+ self.check([spec('', 'failed', 'BeforeSuite'), spec(containers=['selected'])], focus='selected')
+
+ def test_skipped_hook_rejected(self):
+ with self.assertRaises(SystemExit):
+ self.check([spec('', 'skipped', 'BeforeSuite'), spec()])
+
+ def test_failed_suite_rejected(self):
+ with self.assertRaises(SystemExit):
+ self.check([spec()], succeeded=False)
+
+ def test_failed_skipped_pending_required_rejected(self):
+ for state in ['failed', 'skipped', 'pending', 'panicked', 'interrupted']:
+ with self.subTest(state=state), self.assertRaises(SystemExit):
+ self.check([spec(state=state, containers=[])])
+
+ def test_missing_extra_and_duplicate_cases_rejected(self):
+ for specs in [[spec('other')], [spec(), spec('extra')], [spec(), spec()]]:
+ with self.subTest(specs=specs), self.assertRaises(SystemExit):
+ self.check(specs)
+
+ def test_required_case_cannot_be_excluded_by_focus(self):
+ with self.assertRaises(SystemExit):
+ self.check([spec(containers=['selected']), spec('other', 'skipped', containers=[])],
+ expected=['required', 'other'], focus='selected')
+
+ def test_excluded_passed_spec_rejected(self):
+ with self.assertRaises(SystemExit):
+ self.check([spec(containers=['selected']), spec('other', containers=[])], focus='selected')
+
+ def test_hooks_only_are_not_tests(self):
+ with self.assertRaises(SystemExit):
+ self.check([spec('', kind='BeforeSuite')])
+
+ def test_missing_empty_and_malformed_artifacts_rejected(self):
+ with self.assertRaises(SystemExit):
+ ginkgo_report(self.path, ['required'])
+ for text in ['', '[]', '{}', 'null', '[', '[{"SuiteSucceeded":true,"SpecReports":[]}]']:
+ self.path.write_text(text)
+ with self.subTest(text=text), self.assertRaises(SystemExit):
+ ginkgo_report(self.path, ['required'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/scripts/addons/prerequisites_test.sh b/scripts/addons/prerequisites_test.sh
index 688d63de0..c46979e7d 100644
--- a/scripts/addons/prerequisites_test.sh
+++ b/scripts/addons/prerequisites_test.sh
@@ -8,7 +8,7 @@ mkdir -p "$TMPDIR"
fixture=$(mktemp -d)
trap 'rm -rf "$fixture"' EXIT HUP INT TERM
mkdir -p "$fixture/scripts/addons" "$fixture/bin"
-for file in prepare.sh verify.sh suites.json ui_cases.py; do
+for file in prepare.sh verify.sh suites.json ui_cases.py ginkgo_report.py; do
test -f "$root/scripts/addons/$file" || { echo "missing implementation: $file" >&2; exit 1; }
cp "$root/scripts/addons/$file" "$fixture/scripts/addons/$file"
done
@@ -29,6 +29,10 @@ echo "${FIXTURE_PROTOC:-libprotoc 26.1}"
TOOL
chmod +x "$fixture/bin/"*
count=0
+# Exercise successful real-format hooks as well as fail-closed report evidence.
+python3 "$root/scripts/addons/ginkgo_report_test.py"
+count=$((count + 1))
+printf '{"case":"ginkgo-reporter-regressions","status":"passed"}\n'
reject() {
name=$1; expected=$2; shift 2
if "$@" > "$fixture/output" 2>&1; then
@@ -101,6 +105,11 @@ TOOL
reject ginkgo-empty 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[]' sh "$verify" prerequisites
reject ginkgo-skipped 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"skipped","LeafNodeType":"It","LeafNodeText":"sqlite transaction"}]}]' sh "$verify" prerequisites
reject ginkgo-missing 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='{}' sh "$verify" prerequisites
+# Valid JSON is insufficient: the actual comparator must reject a missing case
+# and a missing report artifact, even when the child exits successfully.
+reject ginkgo-inventory-missing 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"passed","LeafNodeType":"It","LeafNodeText":"different case"}]}]' sh "$verify" prerequisites
+printf '#!/bin/sh\nexit 0\n' > "$fixture/bin/go"
+reject ginkgo-artifact-missing 'Ginkgo report' env PATH="$fixture/bin:$PATH" sh "$verify" prerequisites
# A failed Ginkgo run names its failed spec and the first line of its failure.
cat > "$fixture/bin/go" <<'TOOL'
#!/bin/sh
@@ -130,6 +139,13 @@ cat > "$fixture/scripts/addons/suites.json" <<'JSON'
JSON
reject gateway-missing-report 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"passed","LeafNodeType":"It","LeafNodeText":"sqlite transaction"}]}]' sh "$verify" gateway
reject gateway-skipped-report 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"skipped","LeafNodeType":"It","LeafNodeText":"sqlite transaction"}]}]' sh "$verify" gateway
+# Pin/browser setup fixtures never build or download: make their disk
+# prerequisite deterministic without bypassing the real preparation gate.
+cat > "$fixture/bin/df" <<'TOOL'
+#!/bin/sh
+printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\nfixture 41943040 0 41943040 0%% /\n'
+TOOL
+chmod +x "$fixture/bin/df"
# A commit at a tagged module resolves to the tag, with an exact module sum.
cat > "$fixture/bin/go" <<'TOOL'
#!/bin/sh
diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json
index 604381df4..47fbeee29 100644
--- a/scripts/addons/suites.json
+++ b/scripts/addons/suites.json
@@ -43,7 +43,10 @@
"ui-report-empty",
"ui-report-missing",
"leak-ignores-command-line",
- "leak-finds-working-directory"
+ "leak-finds-working-directory",
+ "ginkgo-inventory-missing",
+ "ginkgo-artifact-missing",
+ "ginkgo-reporter-regressions"
],
"readiness": {
"ginkgo": {
@@ -105,39 +108,57 @@
"auth",
"./pkg/addons/contract",
"-ginkgo.json-report=../../../.cache/addons/logs/contracts.json"
+ ],
+ [
+ "go",
+ "test",
+ "-count=1",
+ "-p",
+ "2",
+ "-tags",
+ "auth",
+ "./pkg/addons/packaging",
+ "-ginkgo.json-report=../../../.cache/addons/logs/contracts-packaging.json"
+ ],
+ [
+ "go",
+ "test",
+ "-count=1",
+ "-p",
+ "2",
+ "-tags",
+ "auth",
+ ".",
+ "-ginkgo.json-report=.cache/addons/logs/contracts-architecture.json"
]
],
"cases": [
- "accepts independent service tool page and chat capabilities",
- "rejects invalid namespace IDs and executable paths",
- "rejects add-on IDs that the host serves as a route segment",
- "rejects invalid versions ranges and capabilities",
- "rejects unknown duplicate trailing and oversized manifest JSON",
- "rejects incompatible host API protocol SDK data and platform fixtures",
- "validates UI only when declared and rejects inconsistent contributions",
"uses canonical version one wire fixtures",
"rejects incomplete expired or forged authority shapes",
"rejects unknown and oversized wire payloads",
"validates finite defaults overrides and cross field bounds",
"validates preview receipt states and output descriptions",
- "accepts the apps surface fields and keeps headless manifests valid",
- "rejects unknown and reserved launch modes",
- "rejects remote data absolute and traversing icon paths",
- "requires the main page to be one of the declared pages",
- "bounds localized titles and descriptions and rejects markup",
- "requires the existence tool of an addon whose resources are created or deleted",
- "reserves the segments of the navigation routes",
- "refuses an add-on that needs a newer SDK than the host provides, and says so",
- "accepts a range that contains the host SDK",
- "accepts a range that spans the host SDK",
- "refuses an add-on built for SDK 1 only on a version 2 host",
- "keeps the generic message for a range that is malformed",
- "accepts a read tool that names no resource",
- "keeps a create tool free of the resource argument",
- "refuses a read tool that offers the resource without requiring it",
- "refuses a write or schema tool without a resource",
- "still accepts a read tool that requires its resource",
- "still accepts a write or schema tool that requires its resource",
+ "validates every enum pair and normalizes nonavailable isolation",
+ "missing isolation",
+ "missing readiness",
+ "bad readiness",
+ "bad isolation",
+ "duplicate",
+ "unknown field",
+ "outputs",
+ "trailing",
+ "null",
+ "null explanation",
+ "empty explanation",
+ "oversized explanation",
+ "markup",
+ "control",
+ "direction",
+ "oversized payload",
+ "bridges validation into the existing pure model boundary",
+ "is valid when the add-on says it acts",
+ "is refused when the add-on does not say it acts",
+ "leaves the other kinds alone",
"accepts a pull kind with a declared page",
"accepts drafts and a preview interval within the bounds",
"lets an add-on declare nothing but the connector",
@@ -149,10 +170,6 @@
"a page the UI does not declare",
"a preview interval too short",
"a preview interval too long",
- "is valid when the add-on says it acts",
- "is refused when the add-on does not say it acts",
- "leaves the other kinds alone",
- "cuts a long name on a character boundary",
"a plain name",
"a path keeps its last element",
"a Windows path",
@@ -161,11 +178,17 @@
"empty",
"a hidden name loses its leading dot",
"a long name keeps its extension",
+ "accepts a read tool that names no resource",
+ "still accepts a read tool that requires its resource",
+ "refuses a read tool that offers the resource without requiring it",
+ "refuses a write or schema tool without a resource",
+ "still accepts a write or schema tool that requires its resource",
+ "keeps a create tool free of the resource argument",
+ "accepts an action tool that names no resource, and refuses one that offers it without requiring it",
+ "keeps the argument names of identity and approval out of an action tool",
"is a valid scope for an action request",
"is refused for any other request, and an action request refuses any other scope",
"has a long timeout of its own that is not part of the limits an add-on receives",
- "accepts an action tool that names no resource, and refuses one that offers it without requiring it",
- "keeps the argument names of identity and approval out of an action tool",
"round-trip",
"unknown field",
"duplicate key",
@@ -186,6 +209,69 @@
"hyphen",
"empty verb",
"too long",
+ "has the same IDs as the registry of the React UI",
+ "parses skillDefinitions from the manifest",
+ "accepts the add-on id and names that start with it",
+ "refuses a bad name, naming it",
+ "refuses a name declared twice",
+ "refuses more than 16 skills",
+ "refuses a plain name that does not start with the add-on id",
+ "accepts a skill that replaces a replaceable built-in skill by its name",
+ "refuses shadows that does not repeat the name, or names a skill that may not be replaced",
+ "keeps the replaceable skills and tools apart, so one pin map holds both",
+ "cuts a long name on a character boundary",
+ "Explanation markup",
+ "Readiness override",
+ "uppercase enums",
+ "Explanation control",
+ "Explanation oversized",
+ "benign explanation alias",
+ "isolation override",
+ "Alias empty",
+ "Label null",
+ "ID override",
+ "Page variant",
+ "Icon variant",
+ "provider Tools",
+ "provider StatusTool",
+ "provider mixed duplicate",
+ "operation Required",
+ "operation Optional",
+ "operation mixed duplicate",
+ "provider entry variant",
+ "provider entry mixed duplicate",
+ "operations entry variant",
+ "operations entry mixed duplicate",
+ "commands entry variant",
+ "commands entry mixed duplicate",
+ "preserves arbitrary valid locale keys at the localized map boundary",
+ "accepts known controls and refuses an unknown or repeated one",
+ "reads ui.controls from a manifest and refuses an unknown field in it",
+ "draws the network policy only for an add-on granted egress that runs bash",
+ "serves a second add-on with the same controls, by its grant and not by its name",
+ "draws the tool providers for an add-on that replaces any built-in tool",
+ "accepts a read tool without a resource",
+ "refuses one that works on a resource or is not a read: the page has no resource to name",
+ "is resourceless like any read without a resource",
+ "refuses an add-on that needs a newer SDK than the host provides, and says so",
+ "accepts a range that contains the host SDK",
+ "accepts a range that spans the host SDK",
+ "refuses an add-on built for SDK 1 only on a version 2 host",
+ "keeps the generic message for a range that is malformed",
+ "accepts independent service tool page and chat capabilities",
+ "requires the existence tool of an addon whose resources are created or deleted",
+ "rejects invalid namespace IDs and executable paths",
+ "rejects add-on IDs that the host serves as a route segment",
+ "rejects invalid versions ranges and capabilities",
+ "rejects unknown duplicate trailing and oversized manifest JSON",
+ "rejects incompatible host API protocol SDK data and platform fixtures",
+ "validates UI only when declared and rejects inconsistent contributions",
+ "accepts the apps surface fields and keeps headless manifests valid",
+ "rejects unknown and reserved launch modes",
+ "rejects remote data absolute and traversing icon paths",
+ "requires the main page to be one of the declared pages",
+ "bounds localized titles and descriptions and rejects markup",
+ "reserves the segments of the navigation routes",
"bash is replaceable",
"web_search and web_fetch are replaceable",
"a file tool is not",
@@ -201,29 +287,715 @@
"extra optional properties are allowed",
"lists only the shadows that pass the check",
"keeps the file tools off the replaceable list",
- "parses skillDefinitions from the manifest",
- "accepts the add-on id and names that start with it",
- "refuses a bad name, naming it",
- "refuses a name declared twice",
- "refuses more than 16 skills",
- "refuses a plain name that does not start with the add-on id",
+ "preserves all three declarations through manifest and capability echo",
+ "keeps old manifests free of extension keys",
+ "bounds commands and operation requests at their declared limits",
+ "accepts localized string labels, packaged icons and explicit non-navigation pages",
+ "root enum excludes objects",
+ "root required property excludes empty objects",
+ "root type excludes objects",
+ "optional constrained properties accept empty objects",
+ "keywords for other types do not constrain objects",
+ "non bash execution tool",
+ "empty optional alias",
+ "null optional alias",
+ "empty optional page",
+ "null optional label",
+ "empty optional icon",
+ "unknown command key",
+ "duplicate command key",
+ "invalid alias",
+ "missing id",
+ "duplicate id",
+ "undeclared page",
+ "no default destination",
+ "foreign icon",
+ "long label",
+ "bad locale",
+ "markup label",
+ "duplicate locale",
+ "status schema open",
+ "status wrong kind",
+ "empty provider",
+ "unknown execution tool",
+ "duplicate execution binding",
+ "same status binding",
+ "non UI status",
+ "status required input",
+ "status resource",
+ "empty requests",
+ "duplicate requests",
+ "wildcard request",
+ "unknown request key",
+ "audits actual host calls and strict package contract for face",
+ "audits actual host calls and strict package contract for fine-tuning",
+ "audits actual host calls and strict package contract for office",
+ "audits actual host calls and strict package contract for quantization",
+ "audits actual host calls and strict package contract for sandbox",
+ "audits actual host calls and strict package contract for slack",
+ "audits actual host calls and strict package contract for studio",
+ "audits actual host calls and strict package contract for tables",
+ "audits actual host calls and strict package contract for talk",
+ "audits actual host calls and strict package contract for telegram",
+ "audits actual host calls and strict package contract for voice",
+ "audits actual host calls and strict package contract for web",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "publishes every add-on to the one documented repository, with its id as the tag prefix",
+ "fails on a publishing event when an add-on has no enabled platform",
+ "publishes when every add-on has an enabled platform",
+ "gives an add-on without an enabled platform no legs on an event that publishes nothing",
+ "fails on a missing or malformed platform file on every event",
+ "joins every enabled platform leg into one verified index",
+ "never creates a partial index when an enabled platform has no leg",
+ "refuses duplicate legs, local-check legs, other versions and extra platforms",
+ "refuses a merged layout that lost a platform after the merge",
+ "renders digest-pinned entries with an explicit add-on signer identity",
+ "renders the development channel as mutable entries: the moving tag, no digest",
+ "renders the development entries of every enabled platform from the manifest, without a package",
+ "refuses --from-manifest for a pinned channel or together with a merged layout",
+ "renders an entry for every first-party add-on from its own template",
+ "reports the chat tools of the manifest on each entry and not the admin tools",
+ "caps the tool names at MaxEntryTools and keeps the full count",
+ "lists the built-in skills an add-on replaces",
+ "lists the built-in tools an add-on replaces after its tools",
+ "adds the tool fields to an existing index from the repository manifests, and changes nothing else",
+ "refuses missing inputs, backend repositories, bad channels and unknown placeholders",
+ "carries placeholders only: no fabricated URL, registry or namespace",
+ "reads a YAML list and refuses JSON, unknown fields and a second document",
+ "has no hard-coded add-on path",
+ "lists the add-ons in the validate matrix",
+ "gives promote an add-on matrix",
+ "gives publish an add-on matrix",
+ "takes the package legs from the plan",
+ "loops the plan over the same list",
+ "has consistent package files for face",
+ "has consistent package files for fine-tuning",
+ "has consistent package files for office",
+ "has consistent package files for quantization",
+ "has consistent package files for sandbox",
+ "has consistent package files for slack",
+ "has consistent package files for studio",
+ "has consistent package files for tables",
+ "has consistent package files for talk",
+ "has consistent package files for telegram",
+ "has consistent package files for voice",
+ "has consistent package files for web",
+ "refuses missing and backend repositories before any request",
+ "pushes platform digests then the index, and a retry is digest-identical",
+ "rejects overwriting an immutable version with different content",
+ "adopts published content only for the same version and commit",
+ "refuses reserved aliases and mismatched tags as immutable tags",
+ "publishes nothing from a partial or local-check layout",
+ "fails closed when no platform is enabled, the file is absent or empty",
+ "rejects enabled platforms without a native runner or check, duplicates and unknown fields",
+ "prints the enabled platforms with their native runners",
+ "ships a checked-in Tables matrix with at least one enabled platform",
+ "moves development forward and leaves it alone for an older commit",
+ "accepts a diverged branch head and refuses one commit with two contents",
+ "treats a force-pushed branch as the new truth for a push to the same ref",
+ "blocks the alias for anything but a push to the channel's own ref",
+ "refuses development when its commit is unreachable, even for a push",
+ "fails on any git error that is not a plain not-ancestor answer",
+ "tells an unreachable commit from a plain not-ancestor",
+ "moves latest only to a newer stable version",
+ "refuses reserved or foreign alias names and unpublished digests, leaving aliases unchanged",
+ "promotes a branch channel under its hashed name",
+ "accepts the checked-in workflow",
+ "rejects pull_request_target",
+ "rejects secrets in a pull request validation job",
+ "rejects id-token write outside the publishing job",
+ "lets only the release-index job write the contents of the repository",
+ "hands the guest image digest of the sandbox-image job to the package leg",
+ "rejects a registry login or publish in an untrusted job",
+ "rejects publish that does not wait for every platform leg",
+ "rejects publish that can run after a failed leg",
+ "rejects publish that does not need validate",
+ "rejects a package leg that does not need validate",
+ "rejects a validate job that cannot stop the release",
+ "rejects a package or promote job that runs after a failed validate",
+ "keeps validate unprivileged on every event",
+ "rejects promotion that does not wait for signing",
+ "rejects alias promotion before signing",
+ "rejects an alias command inside the publishing job",
+ "rejects a literal latest tag",
+ "rejects backend registries and backend workflows",
+ "rejects a signer identity that is a pattern or the backend workflow",
+ "rejects signing by tag or without referrers and the new bundle format",
+ "rejects missing serialization per channel",
+ "rejects publication that is shared between add-ons",
+ "rejects a workflow that names one add-on by path or skips an add-on in the matrix",
+ "rejects a plan that does not refuse an add-on without an enabled platform",
+ "rejects a plan that does not exit 1 for an add-on without an enabled platform on a publishing event",
+ "rejects a plan that treats any matrix error as no legs",
+ "rejects a repository rule that derives a repository for each add-on",
+ "rejects a publish or promote matrix that stops the other add-ons on a failure",
+ "rejects a publish job that downloads the layouts of other add-ons",
+ "rejects a promote job that does not check the digest format",
+ "rejects a plan that does not fail closed on missing configuration",
+ "rejects a checkout of the pull request head or a persisted token",
+ "rejects write permission at workflow level and a literal platform matrix",
+ "rejects a publishing job without an environment or a gate",
+ "does not trust the backend workflow identity in the documented verification",
+ "loads exactly the historical grants through the actual host loader",
+ "explicitly selects the shipped policy in the final Docker runtime stage",
+ "accepts the add-on namespace and refuses every other go-skynet repository",
+ "rejects a package without its entrypoint",
+ "rejects a declared UI whose layer or files are missing",
+ "rejects a package without a required runtime library",
+ "rejects a manifest whose platform differs from the image",
+ "rejects an image config or annotation of another platform",
+ "rejects a manifest version that differs from the release version",
+ "rejects another artifact type",
+ "rejects unexpected layers, roles and layer media types",
+ "rejects an unexpected file in the executable layer",
+ "rejects a blob whose bytes differ from its digest",
+ "rejects a receipt that does not match the image",
+ "rejects invalid extraction permissions",
+ "rejects a UI file that is executable or outside the declared directory",
+ "rejects traversal paths, links and duplicates",
+ "rejects a UI that bundles its own React runtime",
+ "rejects an icon that is not an image of its type",
+ "rejects an invalid platform layout marker or index",
+ "lays the skills of the add-on in their own layer and verifies them with the install rules",
+ "refuses to build when the skills folder and the manifest disagree, or a skill breaks a rule",
+ "builds a deterministic platform layout with executable, ui and manifest layers",
+ "restores the validated executable mode on extraction",
+ "refuses a platform that the matrix has not enabled unless it is a local check",
+ "refuses an invalid version, an existing layout and an empty executable",
+ "never publishes from a pull request, whatever the ref",
+ "maps master alone to development and publishes the commit tag",
+ "never publishes from a branch push, whatever the branch is named",
+ "keeps reserved names out of the branch namespace",
+ "separates refs that normalize to the same label",
+ "bounds empty, Unicode and very long branch names",
+ "lets a tag push prepare the immutable version and never move latest",
+ "promotes latest only for a published, stable, non-draft release",
+ "rejects unknown events and a malformed commit",
+ "prints the decision as key=value lines",
+ "keeps official addon identities out of generic CLI and config policy code",
+ "keeps provider and Home dispatch independent of official addon IDs",
+ "bounds model actions to one mapping per supported capability",
+ "declares only owning model actions for existing addon destinations",
+ "round trips declared model destinations without changing protocol or SDK",
+ "accepts SDK route segment and combined path boundaries",
+ "unknown field",
+ "duplicate field",
+ "case field",
+ "case page",
+ "case path",
+ "duplicate capability",
+ "missing page",
+ "undeclared page",
+ "unknown capability",
+ "native chat",
+ "native completion",
+ "aggregate LLM",
+ "any capability",
+ "noncanonical capability",
+ "absolute",
+ "URL",
+ "traversal",
+ "dot",
+ "encoded traversal",
+ "query",
+ "hash",
+ "backslash",
+ "empty segment",
+ "trailing slash",
+ "empty path",
+ "null path",
+ "long segment",
+ "too many segments",
+ "long combined route",
+ "rejects noncanonical modelActions entry keys and duplicate keys",
+ "wrong object",
+ "additional properties true",
+ "additional properties absent",
+ "required",
+ "enum",
+ "unknown schema keyword",
+ "malformed required",
+ "resource",
+ "admin",
+ "status uiOnly",
+ "status kind",
+ "action uiOnly",
+ "action kind",
+ "shadow",
+ "accepts empty arguments with optional property constraints",
"refuses protected or reserved plain names, whatever the add-on id",
- "accepts a skill that replaces a replaceable built-in skill by its name",
- "refuses shadows that does not repeat the name, or names a skill that may not be replaced",
"keeps embedded names protected or reserved without requiring a slot fallback",
- "keeps the replaceable skills and tools apart, so one pin map holds both",
- "accepts a read tool without a resource",
- "refuses one that works on a resource or is not a read: the page has no resource to name",
- "is resourceless like any read without a resource",
- "has the same IDs as the registry of the React UI",
- "accepts known controls and refuses an unknown or repeated one",
- "reads ui.controls from a manifest and refuses an unknown field in it",
- "draws the network policy only for an add-on granted egress that runs bash",
- "serves a second add-on with the same controls, by its grant and not by its name",
- "draws the tool providers for an add-on that replaces any built-in tool",
"does not promise a core fallback for document provider names"
],
- "report": ".cache/addons/logs/contracts.json"
+ "report": ".cache/addons/logs/contracts.json",
+ "reports": [
+ {
+ "path": ".cache/addons/logs/contracts.json",
+ "cases": [
+ "a plain name",
+ "a path keeps its last element",
+ "a Windows path",
+ "control characters and quotes",
+ "dots only",
+ "empty",
+ "a hidden name loses its leading dot",
+ "a long name keeps its extension",
+ "accepts a read tool that names no resource",
+ "still accepts a read tool that requires its resource",
+ "refuses a read tool that offers the resource without requiring it",
+ "refuses a write or schema tool without a resource",
+ "still accepts a write or schema tool that requires its resource",
+ "keeps a create tool free of the resource argument",
+ "accepts an action tool that names no resource, and refuses one that offers it without requiring it",
+ "keeps the argument names of identity and approval out of an action tool",
+ "is valid when the add-on says it acts",
+ "is refused when the add-on does not say it acts",
+ "leaves the other kinds alone",
+ "cuts a long name on a character boundary",
+ "refuses an add-on that needs a newer SDK than the host provides, and says so",
+ "accepts a range that contains the host SDK",
+ "accepts a range that spans the host SDK",
+ "refuses an add-on built for SDK 1 only on a version 2 host",
+ "keeps the generic message for a range that is malformed",
+ "accepts independent service tool page and chat capabilities",
+ "requires the existence tool of an addon whose resources are created or deleted",
+ "rejects invalid namespace IDs and executable paths",
+ "rejects add-on IDs that the host serves as a route segment",
+ "rejects invalid versions ranges and capabilities",
+ "rejects unknown duplicate trailing and oversized manifest JSON",
+ "rejects incompatible host API protocol SDK data and platform fixtures",
+ "validates UI only when declared and rejects inconsistent contributions",
+ "accepts the apps surface fields and keeps headless manifests valid",
+ "rejects unknown and reserved launch modes",
+ "rejects remote data absolute and traversing icon paths",
+ "requires the main page to be one of the declared pages",
+ "bounds localized titles and descriptions and rejects markup",
+ "reserves the segments of the navigation routes",
+ "bounds model actions to one mapping per supported capability",
+ "declares only owning model actions for existing addon destinations",
+ "round trips declared model destinations without changing protocol or SDK",
+ "accepts SDK route segment and combined path boundaries",
+ "unknown field",
+ "duplicate field",
+ "case field",
+ "case page",
+ "case path",
+ "duplicate capability",
+ "missing page",
+ "undeclared page",
+ "unknown capability",
+ "native chat",
+ "native completion",
+ "aggregate LLM",
+ "any capability",
+ "noncanonical capability",
+ "absolute",
+ "URL",
+ "traversal",
+ "dot",
+ "encoded traversal",
+ "query",
+ "hash",
+ "backslash",
+ "empty segment",
+ "trailing slash",
+ "empty path",
+ "null path",
+ "long segment",
+ "too many segments",
+ "long combined route",
+ "rejects noncanonical modelActions entry keys and duplicate keys",
+ "validates every enum pair and normalizes nonavailable isolation",
+ "missing isolation",
+ "missing readiness",
+ "bad readiness",
+ "bad isolation",
+ "duplicate",
+ "unknown field",
+ "outputs",
+ "trailing",
+ "null",
+ "null explanation",
+ "empty explanation",
+ "oversized explanation",
+ "markup",
+ "control",
+ "direction",
+ "oversized payload",
+ "bridges validation into the existing pure model boundary",
+ "is a valid scope for an action request",
+ "is refused for any other request, and an action request refuses any other scope",
+ "has a long timeout of its own that is not part of the limits an add-on receives",
+ "bash is replaceable",
+ "web_search and web_fetch are replaceable",
+ "a file tool is not",
+ "an unknown name is not",
+ "a page-only tool cannot shadow",
+ "two tools cannot shadow one name",
+ "a shadow of bash must be an action tool",
+ "a shadow of web_fetch must be a read tool",
+ "a shadow of bash must declare command",
+ "a shadow keeps the type of a required property",
+ "a shadow requires no property the built-in does not",
+ "a shadow names no resource",
+ "extra optional properties are allowed",
+ "lists only the shadows that pass the check",
+ "keeps the file tools off the replaceable list",
+ "accepts known controls and refuses an unknown or repeated one",
+ "reads ui.controls from a manifest and refuses an unknown field in it",
+ "draws the network policy only for an add-on granted egress that runs bash",
+ "serves a second add-on with the same controls, by its grant and not by its name",
+ "draws the tool providers for an add-on that replaces any built-in tool",
+ "Explanation markup",
+ "Readiness override",
+ "uppercase enums",
+ "Explanation control",
+ "Explanation oversized",
+ "benign explanation alias",
+ "isolation override",
+ "Alias empty",
+ "Label null",
+ "ID override",
+ "Page variant",
+ "Icon variant",
+ "provider Tools",
+ "provider StatusTool",
+ "provider mixed duplicate",
+ "operation Required",
+ "operation Optional",
+ "operation mixed duplicate",
+ "provider entry variant",
+ "provider entry mixed duplicate",
+ "operations entry variant",
+ "operations entry mixed duplicate",
+ "commands entry variant",
+ "commands entry mixed duplicate",
+ "preserves arbitrary valid locale keys at the localized map boundary",
+ "parses skillDefinitions from the manifest",
+ "accepts the add-on id and names that start with it",
+ "refuses a bad name, naming it",
+ "refuses a name declared twice",
+ "refuses more than 16 skills",
+ "refuses a plain name that does not start with the add-on id",
+ "refuses protected or reserved plain names, whatever the add-on id",
+ "accepts a skill that replaces a replaceable built-in skill by its name",
+ "refuses shadows that does not repeat the name, or names a skill that may not be replaced",
+ "keeps embedded names protected or reserved without requiring a slot fallback",
+ "keeps the replaceable skills and tools apart, so one pin map holds both",
+ "has the same IDs as the registry of the React UI",
+ "accepts a read tool without a resource",
+ "refuses one that works on a resource or is not a read: the page has no resource to name",
+ "is resourceless like any read without a resource",
+ "uses canonical version one wire fixtures",
+ "rejects incomplete expired or forged authority shapes",
+ "rejects unknown and oversized wire payloads",
+ "validates finite defaults overrides and cross field bounds",
+ "validates preview receipt states and output descriptions",
+ "round-trip",
+ "unknown field",
+ "duplicate key",
+ "trailing",
+ "not an object",
+ "empty",
+ "refuses an oversized body",
+ "finetune.get",
+ "quantize.start",
+ "finetune.a.b",
+ "empty",
+ "no dot",
+ "leading dot",
+ "uppercase",
+ "trailing space",
+ "slash",
+ "trailing newline",
+ "hyphen",
+ "empty verb",
+ "too long",
+ "does not promise a core fallback for document provider names",
+ "wrong object",
+ "additional properties true",
+ "additional properties absent",
+ "required",
+ "enum",
+ "unknown schema keyword",
+ "malformed required",
+ "resource",
+ "admin",
+ "status uiOnly",
+ "status kind",
+ "action uiOnly",
+ "action kind",
+ "shadow",
+ "accepts empty arguments with optional property constraints",
+ "preserves all three declarations through manifest and capability echo",
+ "keeps old manifests free of extension keys",
+ "bounds commands and operation requests at their declared limits",
+ "accepts localized string labels, packaged icons and explicit non-navigation pages",
+ "root enum excludes objects",
+ "root required property excludes empty objects",
+ "root type excludes objects",
+ "optional constrained properties accept empty objects",
+ "keywords for other types do not constrain objects",
+ "non bash execution tool",
+ "empty optional alias",
+ "null optional alias",
+ "empty optional page",
+ "null optional label",
+ "empty optional icon",
+ "unknown command key",
+ "duplicate command key",
+ "invalid alias",
+ "missing id",
+ "duplicate id",
+ "undeclared page",
+ "no default destination",
+ "foreign icon",
+ "long label",
+ "bad locale",
+ "markup label",
+ "duplicate locale",
+ "status schema open",
+ "status wrong kind",
+ "empty provider",
+ "unknown execution tool",
+ "duplicate execution binding",
+ "same status binding",
+ "non UI status",
+ "status required input",
+ "status resource",
+ "empty requests",
+ "duplicate requests",
+ "wildcard request",
+ "unknown request key",
+ "accepts a pull kind with a declared page",
+ "accepts drafts and a preview interval within the bounds",
+ "lets an add-on declare nothing but the connector",
+ "no kind",
+ "a bad kind id",
+ "a repeated kind",
+ "markup in the title",
+ "an unknown delivery",
+ "a page the UI does not declare",
+ "a preview interval too short",
+ "a preview interval too long"
+ ]
+ },
+ {
+ "path": ".cache/addons/logs/contracts-packaging.json",
+ "cases": [
+ "audits actual host calls and strict package contract for face",
+ "audits actual host calls and strict package contract for fine-tuning",
+ "audits actual host calls and strict package contract for office",
+ "audits actual host calls and strict package contract for quantization",
+ "audits actual host calls and strict package contract for sandbox",
+ "audits actual host calls and strict package contract for slack",
+ "audits actual host calls and strict package contract for studio",
+ "audits actual host calls and strict package contract for tables",
+ "audits actual host calls and strict package contract for talk",
+ "audits actual host calls and strict package contract for telegram",
+ "audits actual host calls and strict package contract for voice",
+ "audits actual host calls and strict package contract for web",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "lists every release platform with its runner",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "enables a platform exactly when its native check is recorded",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "has a catalog template",
+ "publishes every add-on to the one documented repository, with its id as the tag prefix",
+ "fails on a publishing event when an add-on has no enabled platform",
+ "publishes when every add-on has an enabled platform",
+ "gives an add-on without an enabled platform no legs on an event that publishes nothing",
+ "fails on a missing or malformed platform file on every event",
+ "joins every enabled platform leg into one verified index",
+ "never creates a partial index when an enabled platform has no leg",
+ "refuses duplicate legs, local-check legs, other versions and extra platforms",
+ "refuses a merged layout that lost a platform after the merge",
+ "renders digest-pinned entries with an explicit add-on signer identity",
+ "renders the development channel as mutable entries: the moving tag, no digest",
+ "renders the development entries of every enabled platform from the manifest, without a package",
+ "refuses --from-manifest for a pinned channel or together with a merged layout",
+ "renders an entry for every first-party add-on from its own template",
+ "reports the chat tools of the manifest on each entry and not the admin tools",
+ "caps the tool names at MaxEntryTools and keeps the full count",
+ "lists the built-in skills an add-on replaces",
+ "lists the built-in tools an add-on replaces after its tools",
+ "adds the tool fields to an existing index from the repository manifests, and changes nothing else",
+ "refuses missing inputs, backend repositories, bad channels and unknown placeholders",
+ "carries placeholders only: no fabricated URL, registry or namespace",
+ "reads a YAML list and refuses JSON, unknown fields and a second document",
+ "has no hard-coded add-on path",
+ "lists the add-ons in the validate matrix",
+ "gives promote an add-on matrix",
+ "gives publish an add-on matrix",
+ "takes the package legs from the plan",
+ "loops the plan over the same list",
+ "has consistent package files for face",
+ "has consistent package files for fine-tuning",
+ "has consistent package files for office",
+ "has consistent package files for quantization",
+ "has consistent package files for sandbox",
+ "has consistent package files for slack",
+ "has consistent package files for studio",
+ "has consistent package files for tables",
+ "has consistent package files for talk",
+ "has consistent package files for telegram",
+ "has consistent package files for voice",
+ "has consistent package files for web",
+ "refuses missing and backend repositories before any request",
+ "pushes platform digests then the index, and a retry is digest-identical",
+ "rejects overwriting an immutable version with different content",
+ "adopts published content only for the same version and commit",
+ "refuses reserved aliases and mismatched tags as immutable tags",
+ "publishes nothing from a partial or local-check layout",
+ "fails closed when no platform is enabled, the file is absent or empty",
+ "rejects enabled platforms without a native runner or check, duplicates and unknown fields",
+ "prints the enabled platforms with their native runners",
+ "ships a checked-in Tables matrix with at least one enabled platform",
+ "moves development forward and leaves it alone for an older commit",
+ "accepts a diverged branch head and refuses one commit with two contents",
+ "treats a force-pushed branch as the new truth for a push to the same ref",
+ "blocks the alias for anything but a push to the channel's own ref",
+ "refuses development when its commit is unreachable, even for a push",
+ "fails on any git error that is not a plain not-ancestor answer",
+ "tells an unreachable commit from a plain not-ancestor",
+ "moves latest only to a newer stable version",
+ "refuses reserved or foreign alias names and unpublished digests, leaving aliases unchanged",
+ "promotes a branch channel under its hashed name",
+ "accepts the checked-in workflow",
+ "rejects pull_request_target",
+ "rejects secrets in a pull request validation job",
+ "rejects id-token write outside the publishing job",
+ "lets only the release-index job write the contents of the repository",
+ "hands the guest image digest of the sandbox-image job to the package leg",
+ "rejects a registry login or publish in an untrusted job",
+ "rejects publish that does not wait for every platform leg",
+ "rejects publish that can run after a failed leg",
+ "rejects publish that does not need validate",
+ "rejects a package leg that does not need validate",
+ "rejects a validate job that cannot stop the release",
+ "rejects a package or promote job that runs after a failed validate",
+ "keeps validate unprivileged on every event",
+ "rejects promotion that does not wait for signing",
+ "rejects alias promotion before signing",
+ "rejects an alias command inside the publishing job",
+ "rejects a literal latest tag",
+ "rejects backend registries and backend workflows",
+ "rejects a signer identity that is a pattern or the backend workflow",
+ "rejects signing by tag or without referrers and the new bundle format",
+ "rejects missing serialization per channel",
+ "rejects publication that is shared between add-ons",
+ "rejects a workflow that names one add-on by path or skips an add-on in the matrix",
+ "rejects a plan that does not refuse an add-on without an enabled platform",
+ "rejects a plan that does not exit 1 for an add-on without an enabled platform on a publishing event",
+ "rejects a plan that treats any matrix error as no legs",
+ "rejects a repository rule that derives a repository for each add-on",
+ "rejects a publish or promote matrix that stops the other add-ons on a failure",
+ "rejects a publish job that downloads the layouts of other add-ons",
+ "rejects a promote job that does not check the digest format",
+ "rejects a plan that does not fail closed on missing configuration",
+ "rejects a checkout of the pull request head or a persisted token",
+ "rejects write permission at workflow level and a literal platform matrix",
+ "rejects a publishing job without an environment or a gate",
+ "does not trust the backend workflow identity in the documented verification",
+ "loads exactly the historical grants through the actual host loader",
+ "explicitly selects the shipped policy in the final Docker runtime stage",
+ "accepts the add-on namespace and refuses every other go-skynet repository",
+ "rejects a package without its entrypoint",
+ "rejects a declared UI whose layer or files are missing",
+ "rejects a package without a required runtime library",
+ "rejects a manifest whose platform differs from the image",
+ "rejects an image config or annotation of another platform",
+ "rejects a manifest version that differs from the release version",
+ "rejects another artifact type",
+ "rejects unexpected layers, roles and layer media types",
+ "rejects an unexpected file in the executable layer",
+ "rejects a blob whose bytes differ from its digest",
+ "rejects a receipt that does not match the image",
+ "rejects invalid extraction permissions",
+ "rejects a UI file that is executable or outside the declared directory",
+ "rejects traversal paths, links and duplicates",
+ "rejects a UI that bundles its own React runtime",
+ "rejects an icon that is not an image of its type",
+ "rejects an invalid platform layout marker or index",
+ "lays the skills of the add-on in their own layer and verifies them with the install rules",
+ "refuses to build when the skills folder and the manifest disagree, or a skill breaks a rule",
+ "builds a deterministic platform layout with executable, ui and manifest layers",
+ "restores the validated executable mode on extraction",
+ "refuses a platform that the matrix has not enabled unless it is a local check",
+ "refuses an invalid version, an existing layout and an empty executable",
+ "never publishes from a pull request, whatever the ref",
+ "maps master alone to development and publishes the commit tag",
+ "never publishes from a branch push, whatever the branch is named",
+ "keeps reserved names out of the branch namespace",
+ "separates refs that normalize to the same label",
+ "bounds empty, Unicode and very long branch names",
+ "lets a tag push prepare the immutable version and never move latest",
+ "promotes latest only for a published, stable, non-draft release",
+ "rejects unknown events and a malformed commit",
+ "prints the decision as key=value lines"
+ ]
+ },
+ {
+ "path": ".cache/addons/logs/contracts-architecture.json",
+ "cases": [
+ "keeps official addon identities out of generic CLI and config policy code",
+ "keeps provider and Home dispatch independent of official addon IDs"
+ ]
+ }
+ ]
},
"catalog": {
"kind": "ginkgo",
@@ -398,7 +1170,10 @@
"installs the linux/arm64 child of an index with four platforms",
"refuses an index that does not list the host platform",
"refuses a child whose digest differs from the one the index lists",
- "still installs a single manifest and refuses a different digest"
+ "still installs a single manifest and refuses a different digest",
+ "persists model actions and typed declarations together separately and absent in verified immutable manifests and receipts",
+ "refuses unsupported typed candidates without changing the previous package bytes digest or receipt",
+ "discards only the creating installer's unpublished single use stage"
],
"report": ".cache/addons/logs/packages.json"
},
@@ -418,35 +1193,15 @@
]
],
"cases": [
- "recovers every durable journal boundary without replaying publication",
- "removes abandoned staging without removing published packages on recovery",
- "keeps immutable history on successful upgrade and reinstallation",
- "excludes competing owners and rejects conflicting durable identities",
- "never opens replica roots or elects an absent owner",
- "binds duplicate operation IDs to actor and command and serializes conflicts",
- "refuses commands and asset reads for an add-on ID the host serves as a route",
- "retains generations identity data and grants across uninstall and restart",
- "preserves old packages and active generation on manager upgrade failure",
- "requires current authoritative administration even for idempotent retries",
- "rejects symlinked package roots without touching external staging",
- "executes enabled owner intent on startup and stops on disable and close without forwarder execution",
+ "persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback",
+ "fails the install and says the package could not be verified, without installing anything",
"retries runtime synchronization after canceled disable and generation upgrade",
- "binds lifecycle lookup to current admin and original actor without writes or repair",
- "cancels status lookup while a lifecycle writer holds the journal lock",
- "denies enable and disable for a stale generation without recording anything",
- "lists the identifiers of installed addons, disabled ones included",
- "refuses to list installed identifiers while recovery is required",
- "lists apps by visibility and serves an icon only for a visible app at its generation",
- "reports the supervisor state of an app through a crash and a restart",
- "reads a flat directory in batches and stops at the entry bound",
- "adds up nested files and reports a complete walk as not truncated",
- "never follows or counts a symbolic link",
- "keeps a result for a short time so polling does not walk again",
- "stops at the time bound and reports a truncated size",
- "does not keep a result cut short by the caller",
- "is what the installed view reports for the data directory",
"resolves a package for the current enabled generation and digest only",
+ "binds lifecycle lookup to current admin and original actor without writes or repair",
+ "publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms",
"answers from the current installed manifest only and follows a generation change",
+ "does not retain the caller's map or prefix slices",
+ "keeps an explicitly empty startup policy immutable",
"reports a digest-only change of a moving channel, applies nothing and offers the pinned upgrade",
"refuses a second operation ID for the same transition, in flight or done, and applies it once",
"runs concurrent requests with one operation ID once and gives each the same answer",
@@ -461,8 +1216,21 @@
"shows the pending update in the apps list from the cache only",
"does not accept pins without an update checker or on another action",
"keeps the unpinned typed upgrade of an earlier release working",
- "carries the SDK refusal text to the caller of a failed install and keeps the generic error",
- "installs an add-on whose SDK range contains the host SDK",
+ "traces completion, retries, detach, interruption and late delivery without replay",
+ "keeps terminals immutable for every event and gives each active state a terminal exit",
+ "denies enable and disable for a stale generation without recording anything",
+ "lists the identifiers of installed addons, disabled ones included",
+ "refuses to list installed identifiers while recovery is required",
+ "lists apps by visibility and serves an icon only for a visible app at its generation",
+ "distinguishes healthy empty connector discovery from loss of runtime ownership",
+ "executes enabled owner intent on startup and stops on disable and close without forwarder execution",
+ "writes the owner and storage only, with no address",
+ "loads a file written in the old format with an address",
+ "loads when only the address changed",
+ "refuses a changed owner ID",
+ "refuses a changed storage ID",
+ "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build",
+ "refuses the upgrade when the tag moved again after the check, and installs nothing",
"upgrades an installed add-on through the same path as a manual upgrade",
"does nothing when no newer version is listed",
"refuses a candidate whose package no longer matches its listed digest and replaces nothing",
@@ -471,34 +1239,45 @@
"does nothing without an update checker",
"keeps the manual upgrade closed to a caller who is not an administrator",
"copies them at install, upgrade and owner start, and removes them at uninstall",
+ "installs upgrades and uninstalls the actual Sandbox document skills through verified receipts",
"never fails the operation when the copy fails",
- "fails the install and says the package could not be verified, without installing anything",
+ "copies every capability field of the manifest, so a new field cannot be forgotten",
+ "reports the supervisor state of an app through a crash and a restart",
+ "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum",
+ "reads a flat directory in batches and stops at the entry bound",
+ "adds up nested files and reports a complete walk as not truncated",
+ "never follows or counts a symbolic link",
+ "keeps a result for a short time so polling does not walk again",
+ "stops at the time bound and reports a truncated size",
+ "does not keep a result cut short by the caller",
+ "is what the installed view reports for the data directory",
+ "finishes a real backup and commit after the submitting request is cancelled",
+ "polls and retries during a blocked backup, rejects conflicting IDs, and retains the receipt on restart",
+ "reconciles durable accepted work to interrupted without replay",
+ "Close cancels and joins an accepted owner worker before releasing storage",
+ "Close cancels a backup in flight and joins its cleanup before releasing ownership",
+ "serializes synchronous disable against an accepted upgrade without overwriting its journal",
+ "carries the SDK refusal text to the caller of a failed install and keeps the generic error",
+ "installs an add-on whose SDK range contains the host SDK",
+ "rejects symlinked package roots without touching external staging",
+ "recovers every durable journal boundary without replaying publication",
+ "removes abandoned staging without removing published packages on recovery",
+ "keeps immutable history on successful upgrade and reinstallation",
+ "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall",
+ "excludes competing owners and rejects conflicting durable identities",
+ "never opens replica roots or elects an absent owner",
+ "needs a state root for the owner only",
+ "refuses commands and asset reads for an add-on ID the host serves as a route",
+ "binds duplicate operation IDs to actor and command and serializes conflicts",
+ "retains generations identity data and grants across uninstall and restart",
+ "preserves old packages and active generation on manager upgrade failure",
+ "requires current authoritative administration even for idempotent retries",
"names a release that the library does not list",
"names two libraries that list the same add-on",
"names a library that cannot be read",
"reads the library on demand when the startup refresh has not filled the cache",
"reads the library again when the startup refresh failed",
- "writes the owner and storage only, with no address",
- "loads a file written in the old format with an address",
- "loads when only the address changed",
- "refuses a changed owner ID",
- "refuses a changed storage ID",
- "copies every capability field of the manifest, so a new field cannot be forgotten",
- "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build",
- "refuses the upgrade when the tag moved again after the check, and installs nothing",
- "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall",
- "needs a state root for the owner only",
- "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum",
- "distinguishes healthy empty connector discovery from loss of runtime ownership",
- "installs upgrades and uninstalls the actual Sandbox document skills through verified receipts",
- "finishes a real backup and commit after the submitting request is cancelled",
- "polls and retries during a blocked backup, rejects conflicting IDs, and retains the receipt on restart",
- "reconciles durable accepted work to interrupted without replay",
- "Close cancels and joins an accepted owner worker before releasing storage",
- "traces completion, retries, detach, interruption and late delivery without replay",
- "keeps terminals immutable for every event and gives each active state a terminal exit",
- "Close cancels a backup in flight and joins its cleanup before releasing ownership",
- "serializes synchronous disable against an accepted upgrade without overwriting its journal"
+ "cancels status lookup while a lifecycle writer holds the journal lock"
],
"report": ".cache/addons/logs/lifecycle.json"
},
@@ -515,6 +1294,17 @@
"auth",
"./core/services/addons/runtime",
"-ginkgo.json-report=../../../../.cache/addons/logs/runtime.json"
+ ],
+ [
+ "go",
+ "test",
+ "-count=1",
+ "-p",
+ "2",
+ "-tags",
+ "auth",
+ "./addons/internal/protocol",
+ "-ginkgo.json-report=../../../.cache/addons/logs/runtime-protocol.json"
]
],
"cases": [
@@ -566,9 +1356,162 @@
"kills an add-on that ignores the closing of its startup channel, within the grace period",
"goes to the log line by line with the add-on ID and a cap per line",
"records the chat with the envelope of a read and of an action while it runs",
- "sends the add-on an envelope without a new field"
+ "sends the add-on an envelope without a new field",
+ "requires exact typed capability equality on authenticated registration health",
+ "keeps all typed declarations on automatic crash restart with a fresh incarnation",
+ "replays typed and legacy receipts across process restarts",
+ "refuses missing wire tool discriminators before any read",
+ "serializes exact identity admission with withdrawal and never dispatches the replacement",
+ "cannot observe a restarted same-generation process through the captured client",
+ "rejects output handles before they can reach storage or output-open",
+ "settles callback denial as failure even if the addon returns a valid positive payload",
+ "cleans up after cancellation and rejects repeat correlation",
+ "keeps an ordinary concurrent read authorized while an observation is pending",
+ "error",
+ "malformed/artifact payload",
+ "success",
+ "timeout",
+ "cancels an admitted observation when the runtime withdraws",
+ "encodes a payload",
+ "reads the tool of a request",
+ "empty tool",
+ "no tool",
+ "not an object",
+ "no arguments",
+ "decodes arguments into a closed struct",
+ "unknown field",
+ "trailing value",
+ "wrong type",
+ "bad request",
+ "too large",
+ "not found",
+ "conflict",
+ "feature",
+ "scope",
+ "keeps any other failure as it is",
+ "ends the context when the owner closes the startup channel, so Run can return",
+ "serves /act for a runtime that implements ActRuntime, under the act scope only",
+ "has no /act for a runtime that does not act",
+ "serves /connector for a runtime that implements ConnectorRuntime, behind the incarnation credential",
+ "answers a refusal of the add-on as a bad request",
+ "has no /connector for a runtime that carries no connector",
+ "refuses without the protocol flag and builds no runtime",
+ "echoes model actions and typed declarations together separately and absent over authenticated health",
+ "sends the reason of a bad request on, so the owner can tell the person what to fix",
+ "keeps the plain answer for a bad request without a reason",
+ "closes the runtime with a bounded context before it exits, once",
+ "exits when the owner closes the startup channel",
+ "still exits when the runtime does not return in time",
+ "exits at once for a runtime with nothing to close"
],
- "report": ".cache/addons/logs/runtime.json"
+ "report": ".cache/addons/logs/runtime.json",
+ "reports": [
+ {
+ "path": ".cache/addons/logs/runtime.json",
+ "cases": [
+ "registers only ready generation bound service headless and UI capabilities",
+ "rejects forged registration nonloopback and stale incarnation handshakes",
+ "bounds readiness and handles exit during start and stop",
+ "withdraws immediately drains boundedly and rejects stale process identities",
+ "exhausts restart budget without retaining dead registrations",
+ "does not inherit secrets and exits when the private parent channel closes",
+ "rejects foreign supervisor handles and numeric PID substitutions",
+ "resets restart budget only after sustained authenticated health",
+ "authenticates every request and separates trusted authority from model arguments",
+ "bounds output streams cancellation deadlines and stop admission",
+ "tells a 409 conflict of the add-on from an outage",
+ "rejects nonloopback endpoints and bounds oversized streams and concurrency",
+ "never reports failed between a launch starting and the restarted process being published",
+ "reserves a draining slot against concurrent starts",
+ "waits for a restart launch worker before acknowledging stop",
+ "exposes a new incarnation client after restart without reviving the old client",
+ "kills the exact paused child of a killed owner before launching its replacement",
+ "fails closed on ambiguous launch identities and preserves unrelated reused PIDs",
+ "retains a canceled restart slot until cleanup completes and rejects duplicate supervisors",
+ "refuses a missing established ledger and fences failed cleanup publication",
+ "withdraws and initiates every live slot before a cleanup timeout",
+ "reports stopped, running, failed, starting and running again across a crash and a restart",
+ "keeps reporting failed once the restart budget is exhausted",
+ "creates one private data directory per addon and passes its path in the startup object",
+ "gives no data directory without a data root and refuses an unsafe root or addon ID",
+ "gives an add-on that the host setting names a host endpoint of its own",
+ "gives an add-on that the host setting does not name no host endpoint",
+ "closes the host endpoint when the incarnation ends",
+ "an HTTP 400 answer",
+ "an HTTP 422 answer",
+ "keeps a bounded, printable reason and nothing else of the body",
+ "leaves 503, 500 and 404 as outages",
+ "types a withdrawn client, a full slot table, a bad envelope and a missing schema as not sent",
+ "keeps a not-sent error an ErrRuntime for callers that do not tell them apart",
+ "types a refused connection as not sent",
+ "never types a possibly-sent request as not sent",
+ "types the add-on's own 'forbidden' answer as a denial of the person, not as an outage",
+ "keeps a 403 about the envelope itself an outage: it is a host fault, not a permission",
+ "reaches an add-on that declares actions, as the actor of the envelope, under the act scope",
+ "never sends a call that is not an act call or has another scope",
+ "never sends an act call to an add-on that did not declare actions",
+ "stops an act call when the caller gives up",
+ "posts the request with the incarnation credential and decodes the answer",
+ "is not sent to an add-on that declared no connector",
+ "gives it time to release what it owns before it kills the process",
+ "kills an add-on that ignores the closing of its startup channel, within the grace period",
+ "goes to the log line by line with the add-on ID and a cap per line",
+ "records the chat with the envelope of a read and of an action while it runs",
+ "sends the add-on an envelope without a new field",
+ "requires exact typed capability equality on authenticated registration health",
+ "keeps all typed declarations on automatic crash restart with a fresh incarnation",
+ "replays typed and legacy receipts across process restarts",
+ "refuses missing wire tool discriminators before any read",
+ "serializes exact identity admission with withdrawal and never dispatches the replacement",
+ "cannot observe a restarted same-generation process through the captured client",
+ "rejects output handles before they can reach storage or output-open",
+ "settles callback denial as failure even if the addon returns a valid positive payload",
+ "cleans up after cancellation and rejects repeat correlation",
+ "keeps an ordinary concurrent read authorized while an observation is pending",
+ "error",
+ "malformed/artifact payload",
+ "success",
+ "timeout",
+ "cancels an admitted observation when the runtime withdraws"
+ ]
+ },
+ {
+ "path": ".cache/addons/logs/runtime-protocol.json",
+ "cases": [
+ "encodes a payload",
+ "reads the tool of a request",
+ "empty tool",
+ "no tool",
+ "not an object",
+ "no arguments",
+ "decodes arguments into a closed struct",
+ "unknown field",
+ "trailing value",
+ "wrong type",
+ "bad request",
+ "too large",
+ "not found",
+ "conflict",
+ "feature",
+ "scope",
+ "keeps any other failure as it is",
+ "ends the context when the owner closes the startup channel, so Run can return",
+ "serves /act for a runtime that implements ActRuntime, under the act scope only",
+ "has no /act for a runtime that does not act",
+ "serves /connector for a runtime that implements ConnectorRuntime, behind the incarnation credential",
+ "answers a refusal of the add-on as a bad request",
+ "has no /connector for a runtime that carries no connector",
+ "refuses without the protocol flag and builds no runtime",
+ "echoes model actions and typed declarations together separately and absent over authenticated health",
+ "sends the reason of a bad request on, so the owner can tell the person what to fix",
+ "keeps the plain answer for a bad request without a reason",
+ "closes the runtime with a bounded context before it exits, once",
+ "exits when the owner closes the startup channel",
+ "still exits when the runtime does not return in time",
+ "exits at once for a runtime with nothing to close"
+ ]
+ }
+ ]
},
"gateway": {
"kind": "ginkgo",
@@ -594,35 +1537,32 @@
"auth",
"./core/http/endpoints/addons",
"-ginkgo.json-report=../../../../.cache/addons/logs/gateway-http.json"
+ ],
+ [
+ "go",
+ "test",
+ "-count=1",
+ "-p",
+ "2",
+ "-tags",
+ "auth",
+ "./core/application",
+ "-ginkgo.focus=execution binding owner resolver|workspace operation declarations",
+ "-ginkgo.json-report=../../.cache/addons/logs/gateway-owner-binding.json"
+ ],
+ [
+ "go",
+ "test",
+ "-count=1",
+ "-p",
+ "2",
+ "-tags",
+ "auth",
+ "./core/services/studio/session",
+ "-ginkgo.json-report=../../../../.cache/addons/logs/gateway-session-scope.json"
]
],
"cases": [
- "serves only declared installed generation assets and rejects traversal",
- "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
- "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
- "propagates interrupted progress cancellation through the receiving frontend",
- "validates finite relay budgets and prevents disabling polling bounds",
- "builds a carrier only from complete pinned identities",
- "types an unknown mutation outcome as unavailable without losing its operation",
- "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
- "alternates pinned frontends and rejects untrusted certificates without local fallback",
- "reports the original operation after lost mutation replies without retrying",
- "rejects a stream longer than its receiving frontend budget",
- "rejects a CA signed peer without a pin on the owner side",
- "refuses an owner whose CA signed certificate is not the pinned owner",
- "returns the artifact status through the owner from every frontend with one denial for the rest",
- "types the owner's refusal and outage of a lookup the same from every frontend",
- "writes an attachment header that cannot be broken out of",
- "rotates the enforced audience on restart and rejects captured requests",
- "never expires the response deadline after the relay returned",
- "returns the owner's forbidden status for an invalid kind and for missing policy",
- "still reports unavailable when the owner is unable to serve and not forbidden",
- "reaches the same decision through both frontends and delivers bytes when allowed",
- "keeps aborting when revocation happens after bytes were written",
- "bounds progress, asset and file relays by their own limit",
- "alternates real owner journal status and terminates coarse progress without submitting again",
- "reports an account store outage as unavailable and a refused account as forbidden",
- "rejects unknown lifecycle argument fields before touching the owner journal",
"reports a runtime that cannot serve as unavailable through both frontends",
"reports an output that ends before its first byte as unavailable",
"fails closed as unavailable for an unclassified owner error",
@@ -633,24 +1573,111 @@
"keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone",
"lets unavailability win when an error wraps both a denial and an unavailable cause",
"does not leak attachment headers into an error when the first read fails",
+ "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
+ "carries the chat binding signed and rejects a changed one",
+ "signs a request without a binding as before",
+ "gives the owner the chat the frontend bound the call to",
+ "says nothing was sent when the add-on is not running, instead of an unknown outcome",
+ "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call",
+ "is not reachable through the typed dispatch of the direct API",
+ "refuses what is not a declared action tool",
+ "refuses an add-on that did not declare actions",
+ "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user",
+ "reports an unknown outcome, not a refusal, when the runtime fails after the call began",
+ "returns the artifact status through the owner from every frontend with one denial for the rest",
+ "types the owner's refusal and outage of a lookup the same from every frontend",
+ "writes an attachment header that cannot be broken out of",
+ "rejects unknown lifecycle argument fields before touching the owner journal",
+ "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
+ "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
+ "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
+ "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
+ "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
+ "reports an account store outage as unavailable and a refused account as forbidden",
+ "propagates interrupted progress cancellation through the receiving frontend",
+ "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs",
+ "returns the owner's forbidden status for an invalid kind and for missing policy",
+ "still reports unavailable when the owner is unable to serve and not forbidden",
+ "reaches the same decision through both frontends and delivers bytes when allowed",
+ "keeps aborting when revocation happens after bytes were written",
+ "validates finite relay budgets and prevents disabling polling bounds",
"treats every action except the known reads as a mutation",
- "keeps resolved resources under admission for JSON and attachment bytes",
- "closes interrupted output and releases admission on disconnect",
- "rechecks revoked credentials during attachment copying",
- "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "reads typed status and preserves named tool and host operation IDs through a supervised process",
+ "rejects a captured supervised client after same generation replacement before carrier dispatch",
+ "rejects a late supervised carrier observation after replacement before owner consumption",
+ "serves only declared installed generation assets and rejects traversal",
+ "builds a carrier only from complete pinned identities",
+ "types an unknown mutation outcome as unavailable without losing its operation",
+ "passes through owner-resolved bindings with distinct provider and status names",
+ "rejects a CA signed peer without a pin on the owner side",
+ "refuses an owner whose CA signed certificate is not the pinned owner",
+ "crosses the owner's wire as the same typed error, whatever the frontend",
+ "does not trust a code the host does not know, and cleans the detail the add-on wrote",
+ "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
+ "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
+ "never expires the response deadline after the relay returned",
+ "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
+ "keeps the answer of a call that outlasts the write timeout of the owner server",
+ "is an unknown outcome when the answer is lost",
"refuses at ingest a file result larger than the relay file bound so nothing unservable is staged",
- "denies private relays without host resolution and admission",
- "serves declared assets of the current enabled generation by package digest only",
+ "preserves bounded owner admission refusals over mTLS without frontend grants",
+ "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
+ "writes the set with private modes and no CA key",
+ "loads both roles into carriers and serves a frontend call from the owner",
+ "gives the owner no signing key and the frontend its own",
+ "accepts two frontends made from the same set",
+ "refuses a frontend from another generation",
+ "reads only the files of the role",
+ "refuses a key file with group or other access",
+ "refuses an unknown role",
+ "refuses to overwrite a file and leaves it unchanged",
+ "needs an owner host",
"allows only an exact declared tool name and fails closed otherwise",
"never contacts the add-on for an undeclared tool and still serves a declared one",
"denies a repeated key at any depth however it is spelled",
"denies arguments that are not exactly one object",
"separates a denial from an unavailable declaration source",
"answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key",
+ "runs the backend-releases check for a pinned frontend without an actor",
+ "refuses a system call from a peer that is not pinned",
+ "never lets a system call change anything or ride the user-facing route",
+ "denies private relays without host resolution and admission",
+ "resolves only the authenticated claim over actual mTLS",
+ "fails closed on unsupported owners and mismatched binding replies",
+ "serves declared assets of the current enabled generation by package digest only",
+ "bounds progress, asset and file relays by their own limit",
+ "reads the exact uiOnly status tool locally and over authenticated mTLS",
+ "refuses forged identities tools current selections and incarnations without dispatch",
+ "rejects stale carrier replies and frontend authority fields without dispatch",
+ "refuses forged observations through the real carrier and ignores ordinary dispatch attempts",
+ "does not relay a captured old binding to a same-generation replacement",
+ "settles parent cancellation and owner permission loss as unknown",
+ "does not grant owner selection authority through wire capability support",
+ "rejects duplicate noncanonical and nested authority keys in observation DTOs",
+ "withdraws a positive late reply when exact-client admission becomes stale",
+ "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "rotates the enforced audience on restart and rejects captured requests",
+ "keeps resolved resources under admission for JSON and attachment bytes",
+ "closes interrupted output and releases admission on disconnect",
+ "rechecks revoked credentials during attachment copying",
+ "alternates real owner journal status and terminates coarse progress without submitting again",
+ "keeps its address stable across starts",
+ "does not share state between different roots",
+ "fails clearly when the port is in use",
+ "rejects an invalid port file",
+ "rejects a port file with a loose mode",
+ "rejects a port file that links outside the root",
"is the typed invalid request on the owner for a read, never an outage",
"is the typed invalid request on the owner for a proposal call",
"is the typed invalid request on the owner for a json relay",
"is the same typed answer from a forwarding frontend",
+ "runs a resource-less read tool for the calling user only",
+ "denies a tool that declares a resource when none is given, and never reaches the add-on",
+ "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
+ "alternates pinned frontends and rejects untrusted certificates without local fallback",
+ "reports the original operation after lost mutation replies without retrying",
+ "rejects a stream longer than its receiving frontend budget",
+ "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
"stores a proposal from an API key and lets only the proposer's browser session confirm it",
"cancels a proposal from the same chain and never lets typed dispatch or relay confirm",
"denies sharing and deletion proposals and an unauthenticated caller",
@@ -720,47 +1747,7 @@
"reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable",
"keeps the update actions out of the tools and names every field of the status in the Swagger spec",
"answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend",
- "crosses the owner's wire as the same typed error, whatever the frontend",
- "does not trust a code the host does not know, and cleans the detail the add-on wrote",
- "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
- "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
- "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
- "keeps the answer of a call that outlasts the write timeout of the owner server",
- "is an unknown outcome when the answer is lost",
- "runs the backend-releases check for a pinned frontend without an actor",
- "refuses a system call from a peer that is not pinned",
- "never lets a system call change anything or ride the user-facing route",
- "writes the set with private modes and no CA key",
- "loads both roles into carriers and serves a frontend call from the owner",
- "gives the owner no signing key and the frontend its own",
- "accepts two frontends made from the same set",
- "refuses a frontend from another generation",
- "reads only the files of the role",
- "refuses a key file with group or other access",
- "refuses an unknown role",
- "refuses to overwrite a file and leaves it unchanged",
- "needs an owner host",
- "says nothing was sent when the add-on is not running, instead of an unknown outcome",
- "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call",
- "is not reachable through the typed dispatch of the direct API",
- "refuses what is not a declared action tool",
- "refuses an add-on that did not declare actions",
- "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user",
- "reports an unknown outcome, not a refusal, when the runtime fails after the call began",
- "runs a resource-less read tool for the calling user only",
- "denies a tool that declares a resource when none is given, and never reaches the add-on",
- "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
- "keeps its address stable across starts",
- "does not share state between different roots",
- "fails clearly when the port is in use",
- "rejects an invalid port file",
- "rejects a port file with a loose mode",
- "rejects a port file that links outside the root",
- "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
- "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
- "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
- "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
- "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
+ "returns the same text through a forwarding frontend",
"Sec-Fetch-Site cross-site is refused even with a Referer on the same host",
"Sec-Fetch-Site same-origin passes",
"Sec-Fetch-Site same-site is refused",
@@ -803,15 +1790,165 @@
"returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator",
"logs the cause of a failed install and types it for the client",
"logs the reason behind a generic denial and leaves the response unchanged",
- "returns the same text through a forwarding frontend",
"names every typed cause of a failed install in the 403 of the dispatch route",
- "carries the chat binding signed and rejects a changed one",
- "signs a request without a binding as before",
- "gives the owner the chat the frontend bound the call to",
+ "keeps one scope through prompt/status/egress and isolates priming and subsequent turns",
+ "closes pending observations at the real turn boundary (stop=false)",
+ "closes pending observations at the real turn boundary (stop=true)",
+ "gives a person's own call the whole home and no chat",
+ "refuses a person's access for a fenced actor or an actor of a chat",
+ "builds a view only of folders inside the access, at no more than their mode",
+ "a sibling",
+ "the home",
+ "a name that starts like the folder",
+ "a parent spelled inside",
+ "another home",
+ "a relative path",
+ "refuses an empty request and a missing store",
+ "names the work folder in the prompt and makes it only when it is used",
+ "names the work folder in the prompt and makes it only when it is used",
+ "gives two chats with the same title two folders before either is made",
+ "gives two chats with the same title two folders before either is made",
+ "renames a folder name that has no folder yet",
+ "renames a folder name that has no folder yet",
+ "names no work folder when the product does not ask",
+ "marks the working files of the chat's file list",
+ "puts the kind of each file on the card of a call",
+ "closes an idle chat and opens it again",
+ "does not close a chat in use by the janitor",
+ "gives the same type for the common text files on every host",
+ "file tools time out",
+ "the output cap stops the process",
+ "backend missing",
+ "backend missing, first failure",
+ "cooling down",
+ "cooling down for minutes",
+ "load failed",
+ "model not found",
+ "provider error",
+ "upstream key",
+ "upstream path",
+ "upstream host",
+ "timeout",
+ "cancelled",
+ "refused",
+ "quota",
+ "context",
+ "unknown",
+ "does not tell the user to check the backend of a provider that failed",
+ "does not read a deadline as a context that is too small",
+ "ignores a model name that is not plain",
+ "removes addresses, paths, secrets and stacks",
+ "drops the internal marks",
+ "cuts a long text",
+ "keeps a well formed app",
+ "refuses a bad reference",
+ "drops an oversize input or result but keeps the reference",
+ "drops invalid JSON",
+ "CapText cuts on a rune boundary",
+ "Summarize picks the first useful argument",
+ "tells uploads, model and tool writes apart",
+ "follows a rename of the chat folder",
+ "are not found for another user",
+ "give an empty prompt without files and a capped one with many",
+ "gets the actor and the input source",
+ "offers no sandbox tools without a sandbox",
+ "keeps the turn events when full",
+ "drops the oldest text",
+ "does not drop answer text in a reasoning flood",
+ "opens a chat and finishes a turn",
+ "gives the model the user's text and the guest paths, as before",
+ "gives the model only the list when there is no text",
+ "puts the text read from the files between the words and the list",
+ "leaves a message without files alone",
+ "splits what ComposeMessage wrote back into the words, the note and the files",
+ "splits a message that has only files",
+ "splits a voice note without words",
+ "splits waiting messages joined into one, each with its files",
+ "keeps text that only looks like the list",
+ "shows the user's words of an old message that holds the list",
+ "leaves the other lines alone",
+ "gives the model the list of a message stored with its files apart",
+ "tells the user when a turn has no harness after a restart failure",
+ "refuses to open a chat as the workspace administrator",
+ "keeps the order of the conversation in the transcript summary",
+ "maps every home spelling to the primary mount",
+ "maps /workspace/chat to the chat folder",
+ "keeps the home spelling of the chat folder that attachment lists use",
+ "refuses parents, NUL and control characters wherever they point",
+ "takes encoded and look-alike separators as literal names",
+ "refuses a /workspace path that is no mount, case-sensitively",
+ "maps the skill layers read only",
+ "never resolves outside its mounts",
+ "is empty for a view without mounts",
+ "names a file of the chat folder where the commands see it",
+ "keeps the home spelling for a file outside the chat folder and in an unscoped chat",
+ "keeps the stored spelling, which SplitMessage reads back",
+ "needs a chat ID and keeps the owner without the fence",
+ "takes a new fence at the next open even when the view is the same",
+ "makes the folder of a chat that is not open, once, as the user",
+ "builds a scoped access with the view builder of every chat",
+ "gives a command runner the turn's view and the skill layers the actor can read",
+ "gives the same access to another chat of the user",
+ "gives a refused model request a reason the user can act on",
+ "context too small with numbers",
+ "context too small without numbers",
+ "the model calls a tool that is not offered",
+ "model keeps failing",
+ "keeps the fence on its actor and drops it for the owner",
+ "refuses an actor without a valid principal",
+ "reaches the client",
+ "names its files by the place and leaves out files outside its folders",
+ "mounts the folder at home and the chat folder at chat",
+ "keeps today's view for an unscoped chat",
+ "refuses a primary folder outside the fence as a missing home",
+ "mounts shared folders read only for a scoped view",
+ "renames a shared folder called chat",
+ "tells a scoped chat only its folders and no page hint",
+ "names the whole home for a scoped chat whose folder is the home",
+ "keeps today's prompt for an unscoped chat",
+ "validates current selection and authenticated actor",
+ "rejects aliases, duplicate fields and null claims",
+ "accepts canonical bindings and rejects aliases and duplicates",
"projects private policy safely",
"forwards authenticated owner decisions",
"validates strict request payloads",
"checks CAS and administrator authority",
+ "uses owner CAS pins for execution and typed status and fails closed on policy outage",
+ "resolves local-owner SQLite using current chat and agent reads",
+ "resolves named SQLite chats and rechecks current accounts and credentials",
+ "selects the oldest real provider then pins a second distinct manifest and runtime",
+ "binds the actual renamed running provider and rejects forged bindings and foreign actors",
+ "observes the actual renamed provider through the Studio typed adapter and withdraws stale facts",
+ "validates current pins and chat switches without historical ABA guarantees",
+ "denies foreign chat access and invalid named credentials",
+ "uses current agent grants and denies unattended actions",
+ "rejects stale runtime incarnations without owner callback nonces",
+ "uses the ordinary chat store interface",
+ "rejects noncanonical and mismatched selected tool identities",
+ "wrong object",
+ "additional properties true",
+ "additional properties absent",
+ "required",
+ "enum",
+ "unknown schema keyword",
+ "malformed required",
+ "resource",
+ "admin",
+ "status uiOnly",
+ "status kind",
+ "action uiOnly",
+ "action kind",
+ "shadow",
+ "nil",
+ "empty",
+ "materialize",
+ "read",
+ "prefix only",
+ "future suffix",
+ "mixed grants",
+ "unrelated operations",
+ "bare workspace",
+ "similar prefix",
"accepts before a slow backup, detaches the request and polls through either frontend"
],
"report": ".cache/addons/logs/gateway.json",
@@ -819,70 +1956,26 @@
{
"path": ".cache/addons/logs/gateway.json",
"cases": [
- "serves only declared installed generation assets and rejects traversal",
- "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
- "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
- "propagates interrupted progress cancellation through the receiving frontend",
- "validates finite relay budgets and prevents disabling polling bounds",
- "builds a carrier only from complete pinned identities",
- "types an unknown mutation outcome as unavailable without losing its operation",
- "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
- "alternates pinned frontends and rejects untrusted certificates without local fallback",
- "reports the original operation after lost mutation replies without retrying",
- "rejects a stream longer than its receiving frontend budget",
"rejects a CA signed peer without a pin on the owner side",
"refuses an owner whose CA signed certificate is not the pinned owner",
- "returns the artifact status through the owner from every frontend with one denial for the rest",
- "types the owner's refusal and outage of a lookup the same from every frontend",
- "writes an attachment header that cannot be broken out of",
- "rotates the enforced audience on restart and rejects captured requests",
- "never expires the response deadline after the relay returned",
+ "rejects unknown lifecycle argument fields before touching the owner journal",
+ "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "reports an account store outage as unavailable and a refused account as forbidden",
+ "alternates real owner journal status and terminates coarse progress without submitting again",
"returns the owner's forbidden status for an invalid kind and for missing policy",
"still reports unavailable when the owner is unable to serve and not forbidden",
"reaches the same decision through both frontends and delivers bytes when allowed",
"keeps aborting when revocation happens after bytes were written",
- "bounds progress, asset and file relays by their own limit",
- "alternates real owner journal status and terminates coarse progress without submitting again",
- "reports an account store outage as unavailable and a refused account as forbidden",
- "rejects unknown lifecycle argument fields before touching the owner journal",
- "reports a runtime that cannot serve as unavailable through both frontends",
- "reports an output that ends before its first byte as unavailable",
- "fails closed as unavailable for an unclassified owner error",
- "keeps authorization and policy denials forbidden",
- "keeps a mutation unknown when the runtime fails after the owner applied it",
- "keeps a mutation unknown when the owner reports recovery required for its operation",
- "reports a mutation as unknown when the owner cannot serve it at all",
- "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone",
- "lets unavailability win when an error wraps both a denial and an unavailable cause",
- "does not leak attachment headers into an error when the first read fails",
- "treats every action except the known reads as a mutation",
- "keeps resolved resources under admission for JSON and attachment bytes",
- "closes interrupted output and releases admission on disconnect",
- "rechecks revoked credentials during attachment copying",
- "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
"refuses at ingest a file result larger than the relay file bound so nothing unservable is staged",
- "denies private relays without host resolution and admission",
- "serves declared assets of the current enabled generation by package digest only",
- "allows only an exact declared tool name and fails closed otherwise",
- "never contacts the add-on for an undeclared tool and still serves a declared one",
- "denies a repeated key at any depth however it is spelled",
- "denies arguments that are not exactly one object",
- "separates a denial from an unavailable declaration source",
- "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key",
- "is the typed invalid request on the owner for a read, never an outage",
- "is the typed invalid request on the owner for a proposal call",
- "is the typed invalid request on the owner for a json relay",
- "is the same typed answer from a forwarding frontend",
- "crosses the owner's wire as the same typed error, whatever the frontend",
- "does not trust a code the host does not know, and cleans the detail the add-on wrote",
- "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
- "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
- "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
- "keeps the answer of a call that outlasts the write timeout of the owner server",
- "is an unknown outcome when the answer is lost",
- "runs the backend-releases check for a pinned frontend without an actor",
- "refuses a system call from a peer that is not pinned",
- "never lets a system call change anything or ride the user-facing route",
+ "projects private policy safely",
+ "forwards authenticated owner decisions",
+ "validates strict request payloads",
+ "checks CAS and administrator authority",
+ "validates current selection and authenticated actor",
+ "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs",
+ "rotates the enforced audience on restart and rejects captured requests",
+ "passes through owner-resolved bindings with distinct provider and status names",
"writes the set with private modes and no CA key",
"loads both roles into carriers and serves a frontend call from the owner",
"gives the owner no signing key and the frontend its own",
@@ -893,6 +1986,104 @@
"refuses an unknown role",
"refuses to overwrite a file and leaves it unchanged",
"needs an owner host",
+ "builds a carrier only from complete pinned identities",
+ "types an unknown mutation outcome as unavailable without losing its operation",
+ "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
+ "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
+ "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
+ "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
+ "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
+ "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
+ "preserves bounded owner admission refusals over mTLS without frontend grants",
+ "accepts canonical bindings and rejects aliases and duplicates",
+ "keeps resolved resources under admission for JSON and attachment bytes",
+ "closes interrupted output and releases admission on disconnect",
+ "rechecks revoked credentials during attachment copying",
+ "denies private relays without host resolution and admission",
+ "carries the chat binding signed and rejects a changed one",
+ "signs a request without a binding as before",
+ "gives the owner the chat the frontend bound the call to",
+ "bounds progress, asset and file relays by their own limit",
+ "returns the artifact status through the owner from every frontend with one denial for the rest",
+ "types the owner's refusal and outage of a lookup the same from every frontend",
+ "writes an attachment header that cannot be broken out of",
+ "rejects aliases, duplicate fields and null claims",
+ "runs the backend-releases check for a pinned frontend without an actor",
+ "refuses a system call from a peer that is not pinned",
+ "never lets a system call change anything or ride the user-facing route",
+ "wrong object",
+ "additional properties true",
+ "additional properties absent",
+ "required",
+ "enum",
+ "unknown schema keyword",
+ "malformed required",
+ "resource",
+ "admin",
+ "status uiOnly",
+ "status kind",
+ "action uiOnly",
+ "action kind",
+ "shadow",
+ "reads the exact uiOnly status tool locally and over authenticated mTLS",
+ "refuses forged identities tools current selections and incarnations without dispatch",
+ "rejects stale carrier replies and frontend authority fields without dispatch",
+ "refuses forged observations through the real carrier and ignores ordinary dispatch attempts",
+ "does not relay a captured old binding to a same-generation replacement",
+ "settles parent cancellation and owner permission loss as unknown",
+ "does not grant owner selection authority through wire capability support",
+ "rejects duplicate noncanonical and nested authority keys in observation DTOs",
+ "withdraws a positive late reply when exact-client admission becomes stale",
+ "serves only declared installed generation assets and rejects traversal",
+ "accepts before a slow backup, detaches the request and polls through either frontend",
+ "reads typed status and preserves named tool and host operation IDs through a supervised process",
+ "rejects a captured supervised client after same generation replacement before carrier dispatch",
+ "rejects a late supervised carrier observation after replacement before owner consumption",
+ "resolves only the authenticated claim over actual mTLS",
+ "fails closed on unsupported owners and mismatched binding replies",
+ "runs a resource-less read tool for the calling user only",
+ "denies a tool that declares a resource when none is given, and never reaches the add-on",
+ "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
+ "never expires the response deadline after the relay returned",
+ "propagates interrupted progress cancellation through the receiving frontend",
+ "reports a runtime that cannot serve as unavailable through both frontends",
+ "reports an output that ends before its first byte as unavailable",
+ "fails closed as unavailable for an unclassified owner error",
+ "keeps authorization and policy denials forbidden",
+ "keeps a mutation unknown when the runtime fails after the owner applied it",
+ "keeps a mutation unknown when the owner reports recovery required for its operation",
+ "reports a mutation as unknown when the owner cannot serve it at all",
+ "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone",
+ "lets unavailability win when an error wraps both a denial and an unavailable cause",
+ "does not leak attachment headers into an error when the first read fails",
+ "keeps its address stable across starts",
+ "does not share state between different roots",
+ "fails clearly when the port is in use",
+ "rejects an invalid port file",
+ "rejects a port file with a loose mode",
+ "rejects a port file that links outside the root",
+ "allows only an exact declared tool name and fails closed otherwise",
+ "never contacts the add-on for an undeclared tool and still serves a declared one",
+ "denies a repeated key at any depth however it is spelled",
+ "denies arguments that are not exactly one object",
+ "separates a denial from an unavailable declaration source",
+ "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key",
+ "crosses the owner's wire as the same typed error, whatever the frontend",
+ "does not trust a code the host does not know, and cleans the detail the add-on wrote",
+ "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
+ "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
+ "alternates pinned frontends and rejects untrusted certificates without local fallback",
+ "reports the original operation after lost mutation replies without retrying",
+ "rejects a stream longer than its receiving frontend budget",
+ "treats every action except the known reads as a mutation",
+ "is the typed invalid request on the owner for a read, never an outage",
+ "is the typed invalid request on the owner for a proposal call",
+ "is the typed invalid request on the owner for a json relay",
+ "is the same typed answer from a forwarding frontend",
+ "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
+ "keeps the answer of a call that outlasts the write timeout of the owner server",
+ "is an unknown outcome when the answer is lost",
+ "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
"says nothing was sent when the add-on is not running, instead of an unknown outcome",
"runs a declared action tool for the calling user, under the act scope, admitted as an add-on call",
"is not reachable through the typed dispatch of the direct API",
@@ -900,28 +2091,8 @@
"refuses an add-on that did not declare actions",
"is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user",
"reports an unknown outcome, not a refusal, when the runtime fails after the call began",
- "runs a resource-less read tool for the calling user only",
- "denies a tool that declares a resource when none is given, and never reaches the add-on",
- "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
- "keeps its address stable across starts",
- "does not share state between different roots",
- "fails clearly when the port is in use",
- "rejects an invalid port file",
- "rejects a port file with a loose mode",
- "rejects a port file that links outside the root",
- "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
- "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
- "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
- "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
- "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
- "carries the chat binding signed and rejects a changed one",
- "signs a request without a binding as before",
- "gives the owner the chat the frontend bound the call to",
- "projects private policy safely",
- "forwards authenticated owner decisions",
- "validates strict request payloads",
- "checks CAS and administrator authority",
- "accepts before a slow backup, detaches the request and polls through either frontend"
+ "validates finite relay budgets and prevents disabling polling bounds",
+ "serves declared assets of the current enabled generation by package digest only"
]
},
{
@@ -1041,6 +2212,154 @@
"logs the reason behind a generic denial and leaves the response unchanged",
"names every typed cause of a failed install in the 403 of the dispatch route"
]
+ },
+ {
+ "path": ".cache/addons/logs/gateway-owner-binding.json",
+ "cases": [
+ "uses owner CAS pins for execution and typed status and fails closed on policy outage",
+ "resolves local-owner SQLite using current chat and agent reads",
+ "resolves named SQLite chats and rechecks current accounts and credentials",
+ "selects the oldest real provider then pins a second distinct manifest and runtime",
+ "binds the actual renamed running provider and rejects forged bindings and foreign actors",
+ "observes the actual renamed provider through the Studio typed adapter and withdraws stale facts",
+ "validates current pins and chat switches without historical ABA guarantees",
+ "denies foreign chat access and invalid named credentials",
+ "uses current agent grants and denies unattended actions",
+ "rejects stale runtime incarnations without owner callback nonces",
+ "uses the ordinary chat store interface",
+ "rejects noncanonical and mismatched selected tool identities",
+ "nil",
+ "empty",
+ "materialize",
+ "read",
+ "prefix only",
+ "future suffix",
+ "mixed grants",
+ "unrelated operations",
+ "bare workspace",
+ "similar prefix"
+ ],
+ "focus": "execution binding owner resolver|workspace operation declarations"
+ },
+ {
+ "path": ".cache/addons/logs/gateway-session-scope.json",
+ "cases": [
+ "keeps one scope through prompt/status/egress and isolates priming and subsequent turns",
+ "closes pending observations at the real turn boundary (stop=false)",
+ "closes pending observations at the real turn boundary (stop=true)",
+ "gives a person's own call the whole home and no chat",
+ "refuses a person's access for a fenced actor or an actor of a chat",
+ "builds a view only of folders inside the access, at no more than their mode",
+ "a sibling",
+ "the home",
+ "a name that starts like the folder",
+ "a parent spelled inside",
+ "another home",
+ "a relative path",
+ "refuses an empty request and a missing store",
+ "names the work folder in the prompt and makes it only when it is used",
+ "names the work folder in the prompt and makes it only when it is used",
+ "gives two chats with the same title two folders before either is made",
+ "gives two chats with the same title two folders before either is made",
+ "renames a folder name that has no folder yet",
+ "renames a folder name that has no folder yet",
+ "names no work folder when the product does not ask",
+ "marks the working files of the chat's file list",
+ "puts the kind of each file on the card of a call",
+ "closes an idle chat and opens it again",
+ "does not close a chat in use by the janitor",
+ "gives the same type for the common text files on every host",
+ "file tools time out",
+ "the output cap stops the process",
+ "backend missing",
+ "backend missing, first failure",
+ "cooling down",
+ "cooling down for minutes",
+ "load failed",
+ "model not found",
+ "provider error",
+ "upstream key",
+ "upstream path",
+ "upstream host",
+ "timeout",
+ "cancelled",
+ "refused",
+ "quota",
+ "context",
+ "unknown",
+ "does not tell the user to check the backend of a provider that failed",
+ "does not read a deadline as a context that is too small",
+ "ignores a model name that is not plain",
+ "removes addresses, paths, secrets and stacks",
+ "drops the internal marks",
+ "cuts a long text",
+ "keeps a well formed app",
+ "refuses a bad reference",
+ "drops an oversize input or result but keeps the reference",
+ "drops invalid JSON",
+ "CapText cuts on a rune boundary",
+ "Summarize picks the first useful argument",
+ "tells uploads, model and tool writes apart",
+ "follows a rename of the chat folder",
+ "are not found for another user",
+ "give an empty prompt without files and a capped one with many",
+ "gets the actor and the input source",
+ "offers no sandbox tools without a sandbox",
+ "keeps the turn events when full",
+ "drops the oldest text",
+ "does not drop answer text in a reasoning flood",
+ "opens a chat and finishes a turn",
+ "gives the model the user's text and the guest paths, as before",
+ "gives the model only the list when there is no text",
+ "puts the text read from the files between the words and the list",
+ "leaves a message without files alone",
+ "splits what ComposeMessage wrote back into the words, the note and the files",
+ "splits a message that has only files",
+ "splits a voice note without words",
+ "splits waiting messages joined into one, each with its files",
+ "keeps text that only looks like the list",
+ "shows the user's words of an old message that holds the list",
+ "leaves the other lines alone",
+ "gives the model the list of a message stored with its files apart",
+ "tells the user when a turn has no harness after a restart failure",
+ "refuses to open a chat as the workspace administrator",
+ "keeps the order of the conversation in the transcript summary",
+ "maps every home spelling to the primary mount",
+ "maps /workspace/chat to the chat folder",
+ "keeps the home spelling of the chat folder that attachment lists use",
+ "refuses parents, NUL and control characters wherever they point",
+ "takes encoded and look-alike separators as literal names",
+ "refuses a /workspace path that is no mount, case-sensitively",
+ "maps the skill layers read only",
+ "never resolves outside its mounts",
+ "is empty for a view without mounts",
+ "names a file of the chat folder where the commands see it",
+ "keeps the home spelling for a file outside the chat folder and in an unscoped chat",
+ "keeps the stored spelling, which SplitMessage reads back",
+ "needs a chat ID and keeps the owner without the fence",
+ "takes a new fence at the next open even when the view is the same",
+ "makes the folder of a chat that is not open, once, as the user",
+ "builds a scoped access with the view builder of every chat",
+ "gives a command runner the turn's view and the skill layers the actor can read",
+ "gives the same access to another chat of the user",
+ "gives a refused model request a reason the user can act on",
+ "context too small with numbers",
+ "context too small without numbers",
+ "the model calls a tool that is not offered",
+ "model keeps failing",
+ "keeps the fence on its actor and drops it for the owner",
+ "refuses an actor without a valid principal",
+ "reaches the client",
+ "names its files by the place and leaves out files outside its folders",
+ "mounts the folder at home and the chat folder at chat",
+ "keeps today's view for an unscoped chat",
+ "refuses a primary folder outside the fence as a missing home",
+ "mounts shared folders read only for a scoped view",
+ "renames a shared folder called chat",
+ "tells a scoped chat only its folders and no page hint",
+ "names the whole home for a scoped chat whose folder is the home",
+ "keeps today's prompt for an unscoped chat"
+ ]
}
]
},
@@ -1345,6 +2664,29 @@
"migrates the ownership of a stopped store with a backup and an explicit mapping and refuses a populated downgrade",
"refuses a proposal whose preview the add-on refused, storing nothing",
"settles a confirmation the add-on refused as rejected, never as an unknown outcome",
+ "R34: returns an admission that was refused before the wire to pending, and a repeat applies",
+ "R35, R52: a hold that cancels the dispatch before the wire ends the proposal as cancelled, not unknown",
+ "R38: records a conflict of the add-on as rejected, not unknown",
+ "R39: still records an unknown outcome when the request may have been sent",
+ "reports a not-sent confirmation through the gateway as an outage that sent nothing, never as unknown",
+ "is a refusal that changed nothing, not an outage, and leaves the ID free",
+ "R4, S4: refuses an editor who claims the write action for a schema tool, and stores nothing",
+ "R4: refuses an editor who names the schema action for a schema tool",
+ "derives the action from the tool, so the owner needs the matching claim and gets the schema action",
+ "still lets an editor propose a data write for a write tool",
+ "R3: refuses a read tool, a create tool and a tool without a definition",
+ "runs the declaration check as well as the kind check",
+ "refuses a named tool when the source cannot report kinds",
+ "R3: a creation names a create tool, never a schema or data tool",
+ "retry-after-not-sent: R34 then R37",
+ "addon-conflict-is-rejected: R38",
+ "timeout-unknown-then-receipt-committed: R39 then R40",
+ "runs the work for a current account",
+ "refuses a stale account and does not run the work",
+ "refuses an identity that does not fit the mode",
+ "refuses a held add-on as unavailable, and serves it again after the release",
+ "refuses an empty add-on ID and a nil work function",
+ "is reached through the gateway provider",
"stores a proposal from an API key and lets only the proposer's browser session confirm it",
"cancels a proposal from the same chain and never lets typed dispatch or relay confirm",
"denies sharing and deletion proposals and an unauthenticated caller",
@@ -1414,29 +2756,7 @@
"reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable",
"keeps the update actions out of the tools and names every field of the status in the Swagger spec",
"answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend",
- "R34: returns an admission that was refused before the wire to pending, and a repeat applies",
- "R35, R52: a hold that cancels the dispatch before the wire ends the proposal as cancelled, not unknown",
- "R38: records a conflict of the add-on as rejected, not unknown",
- "R39: still records an unknown outcome when the request may have been sent",
- "reports a not-sent confirmation through the gateway as an outage that sent nothing, never as unknown",
- "is a refusal that changed nothing, not an outage, and leaves the ID free",
- "R4, S4: refuses an editor who claims the write action for a schema tool, and stores nothing",
- "R4: refuses an editor who names the schema action for a schema tool",
- "derives the action from the tool, so the owner needs the matching claim and gets the schema action",
- "still lets an editor propose a data write for a write tool",
- "R3: refuses a read tool, a create tool and a tool without a definition",
- "runs the declaration check as well as the kind check",
- "refuses a named tool when the source cannot report kinds",
- "R3: a creation names a create tool, never a schema or data tool",
- "retry-after-not-sent: R34 then R37",
- "addon-conflict-is-rejected: R38",
- "timeout-unknown-then-receipt-committed: R39 then R40",
- "runs the work for a current account",
- "refuses a stale account and does not run the work",
- "refuses an identity that does not fit the mode",
- "refuses a held add-on as unavailable, and serves it again after the release",
- "refuses an empty add-on ID and a nil work function",
- "is reached through the gateway provider",
+ "returns the same text through a forwarding frontend",
"Sec-Fetch-Site cross-site is refused even with a Referer on the same host",
"Sec-Fetch-Site same-origin passes",
"Sec-Fetch-Site same-site is refused",
@@ -1479,7 +2799,6 @@
"returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator",
"logs the cause of a failed install and types it for the client",
"logs the reason behind a generic denial and leaves the response unchanged",
- "returns the same text through a forwarding frontend",
"names every typed cause of a failed install in the 403 of the dispatch route"
],
"report": ".cache/addons/logs/proposals.json",
@@ -1823,32 +3142,6 @@
"reports an unavailable declaration source as unavailable and never opens the output",
"shortens the lifetime and keeps the bounds consistent, and zero keeps the defaults",
"fails the artifact with a fixed reason that says it was refused, not that the add-on is down",
- "serves only declared installed generation assets and rejects traversal",
- "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
- "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
- "propagates interrupted progress cancellation through the receiving frontend",
- "validates finite relay budgets and prevents disabling polling bounds",
- "builds a carrier only from complete pinned identities",
- "types an unknown mutation outcome as unavailable without losing its operation",
- "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
- "alternates pinned frontends and rejects untrusted certificates without local fallback",
- "reports the original operation after lost mutation replies without retrying",
- "rejects a stream longer than its receiving frontend budget",
- "rejects a CA signed peer without a pin on the owner side",
- "refuses an owner whose CA signed certificate is not the pinned owner",
- "returns the artifact status through the owner from every frontend with one denial for the rest",
- "types the owner's refusal and outage of a lookup the same from every frontend",
- "writes an attachment header that cannot be broken out of",
- "rotates the enforced audience on restart and rejects captured requests",
- "never expires the response deadline after the relay returned",
- "returns the owner's forbidden status for an invalid kind and for missing policy",
- "still reports unavailable when the owner is unable to serve and not forbidden",
- "reaches the same decision through both frontends and delivers bytes when allowed",
- "keeps aborting when revocation happens after bytes were written",
- "bounds progress, asset and file relays by their own limit",
- "alternates real owner journal status and terminates coarse progress without submitting again",
- "reports an account store outage as unavailable and a refused account as forbidden",
- "rejects unknown lifecycle argument fields before touching the owner journal",
"reports a runtime that cannot serve as unavailable through both frontends",
"reports an output that ends before its first byte as unavailable",
"fails closed as unavailable for an unclassified owner error",
@@ -1859,24 +3152,111 @@
"keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone",
"lets unavailability win when an error wraps both a denial and an unavailable cause",
"does not leak attachment headers into an error when the first read fails",
+ "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
+ "carries the chat binding signed and rejects a changed one",
+ "signs a request without a binding as before",
+ "gives the owner the chat the frontend bound the call to",
+ "says nothing was sent when the add-on is not running, instead of an unknown outcome",
+ "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call",
+ "is not reachable through the typed dispatch of the direct API",
+ "refuses what is not a declared action tool",
+ "refuses an add-on that did not declare actions",
+ "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user",
+ "reports an unknown outcome, not a refusal, when the runtime fails after the call began",
+ "returns the artifact status through the owner from every frontend with one denial for the rest",
+ "types the owner's refusal and outage of a lookup the same from every frontend",
+ "writes an attachment header that cannot be broken out of",
+ "rejects unknown lifecycle argument fields before touching the owner journal",
+ "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
+ "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
+ "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
+ "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
+ "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
+ "reports an account store outage as unavailable and a refused account as forbidden",
+ "propagates interrupted progress cancellation through the receiving frontend",
+ "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs",
+ "returns the owner's forbidden status for an invalid kind and for missing policy",
+ "still reports unavailable when the owner is unable to serve and not forbidden",
+ "reaches the same decision through both frontends and delivers bytes when allowed",
+ "keeps aborting when revocation happens after bytes were written",
+ "validates finite relay budgets and prevents disabling polling bounds",
"treats every action except the known reads as a mutation",
- "keeps resolved resources under admission for JSON and attachment bytes",
- "closes interrupted output and releases admission on disconnect",
- "rechecks revoked credentials during attachment copying",
- "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "reads typed status and preserves named tool and host operation IDs through a supervised process",
+ "rejects a captured supervised client after same generation replacement before carrier dispatch",
+ "rejects a late supervised carrier observation after replacement before owner consumption",
+ "serves only declared installed generation assets and rejects traversal",
+ "builds a carrier only from complete pinned identities",
+ "types an unknown mutation outcome as unavailable without losing its operation",
+ "passes through owner-resolved bindings with distinct provider and status names",
+ "rejects a CA signed peer without a pin on the owner side",
+ "refuses an owner whose CA signed certificate is not the pinned owner",
+ "crosses the owner's wire as the same typed error, whatever the frontend",
+ "does not trust a code the host does not know, and cleans the detail the add-on wrote",
+ "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
+ "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
+ "never expires the response deadline after the relay returned",
+ "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
+ "keeps the answer of a call that outlasts the write timeout of the owner server",
+ "is an unknown outcome when the answer is lost",
"refuses at ingest a file result larger than the relay file bound so nothing unservable is staged",
- "denies private relays without host resolution and admission",
- "serves declared assets of the current enabled generation by package digest only",
+ "preserves bounded owner admission refusals over mTLS without frontend grants",
+ "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
+ "writes the set with private modes and no CA key",
+ "loads both roles into carriers and serves a frontend call from the owner",
+ "gives the owner no signing key and the frontend its own",
+ "accepts two frontends made from the same set",
+ "refuses a frontend from another generation",
+ "reads only the files of the role",
+ "refuses a key file with group or other access",
+ "refuses an unknown role",
+ "refuses to overwrite a file and leaves it unchanged",
+ "needs an owner host",
"allows only an exact declared tool name and fails closed otherwise",
"never contacts the add-on for an undeclared tool and still serves a declared one",
"denies a repeated key at any depth however it is spelled",
"denies arguments that are not exactly one object",
"separates a denial from an unavailable declaration source",
"answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key",
+ "runs the backend-releases check for a pinned frontend without an actor",
+ "refuses a system call from a peer that is not pinned",
+ "never lets a system call change anything or ride the user-facing route",
+ "denies private relays without host resolution and admission",
+ "resolves only the authenticated claim over actual mTLS",
+ "fails closed on unsupported owners and mismatched binding replies",
+ "serves declared assets of the current enabled generation by package digest only",
+ "bounds progress, asset and file relays by their own limit",
+ "reads the exact uiOnly status tool locally and over authenticated mTLS",
+ "refuses forged identities tools current selections and incarnations without dispatch",
+ "rejects stale carrier replies and frontend authority fields without dispatch",
+ "refuses forged observations through the real carrier and ignores ordinary dispatch attempts",
+ "does not relay a captured old binding to a same-generation replacement",
+ "settles parent cancellation and owner permission loss as unknown",
+ "does not grant owner selection authority through wire capability support",
+ "rejects duplicate noncanonical and nested authority keys in observation DTOs",
+ "withdraws a positive late reply when exact-client admission becomes stale",
+ "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "rotates the enforced audience on restart and rejects captured requests",
+ "keeps resolved resources under admission for JSON and attachment bytes",
+ "closes interrupted output and releases admission on disconnect",
+ "rechecks revoked credentials during attachment copying",
+ "alternates real owner journal status and terminates coarse progress without submitting again",
+ "keeps its address stable across starts",
+ "does not share state between different roots",
+ "fails clearly when the port is in use",
+ "rejects an invalid port file",
+ "rejects a port file with a loose mode",
+ "rejects a port file that links outside the root",
"is the typed invalid request on the owner for a read, never an outage",
"is the typed invalid request on the owner for a proposal call",
"is the typed invalid request on the owner for a json relay",
"is the same typed answer from a forwarding frontend",
+ "runs a resource-less read tool for the calling user only",
+ "denies a tool that declares a resource when none is given, and never reaches the add-on",
+ "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
+ "alternates pinned frontends and rejects untrusted certificates without local fallback",
+ "reports the original operation after lost mutation replies without retrying",
+ "rejects a stream longer than its receiving frontend budget",
+ "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
"stores a proposal from an API key and lets only the proposer's browser session confirm it",
"cancels a proposal from the same chain and never lets typed dispatch or relay confirm",
"denies sharing and deletion proposals and an unauthenticated caller",
@@ -1946,47 +3326,7 @@
"reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable",
"keeps the update actions out of the tools and names every field of the status in the Swagger spec",
"answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend",
- "crosses the owner's wire as the same typed error, whatever the frontend",
- "does not trust a code the host does not know, and cleans the detail the add-on wrote",
- "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
- "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
- "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
- "keeps the answer of a call that outlasts the write timeout of the owner server",
- "is an unknown outcome when the answer is lost",
- "runs the backend-releases check for a pinned frontend without an actor",
- "refuses a system call from a peer that is not pinned",
- "never lets a system call change anything or ride the user-facing route",
- "writes the set with private modes and no CA key",
- "loads both roles into carriers and serves a frontend call from the owner",
- "gives the owner no signing key and the frontend its own",
- "accepts two frontends made from the same set",
- "refuses a frontend from another generation",
- "reads only the files of the role",
- "refuses a key file with group or other access",
- "refuses an unknown role",
- "refuses to overwrite a file and leaves it unchanged",
- "needs an owner host",
- "says nothing was sent when the add-on is not running, instead of an unknown outcome",
- "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call",
- "is not reachable through the typed dispatch of the direct API",
- "refuses what is not a declared action tool",
- "refuses an add-on that did not declare actions",
- "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user",
- "reports an unknown outcome, not a refusal, when the runtime fails after the call began",
- "runs a resource-less read tool for the calling user only",
- "denies a tool that declares a resource when none is given, and never reaches the add-on",
- "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
- "keeps its address stable across starts",
- "does not share state between different roots",
- "fails clearly when the port is in use",
- "rejects an invalid port file",
- "rejects a port file with a loose mode",
- "rejects a port file that links outside the root",
- "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
- "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
- "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
- "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
- "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
+ "returns the same text through a forwarding frontend",
"Sec-Fetch-Site cross-site is refused even with a Referer on the same host",
"Sec-Fetch-Site same-origin passes",
"Sec-Fetch-Site same-site is refused",
@@ -2029,15 +3369,28 @@
"returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator",
"logs the cause of a failed install and types it for the client",
"logs the reason behind a generic denial and leaves the response unchanged",
- "returns the same text through a forwarding frontend",
"names every typed cause of a failed install in the 403 of the dispatch route",
- "carries the chat binding signed and rejects a changed one",
- "signs a request without a binding as before",
- "gives the owner the chat the frontend bound the call to",
"projects private policy safely",
"forwards authenticated owner decisions",
"validates strict request payloads",
"checks CAS and administrator authority",
+ "validates current selection and authenticated actor",
+ "accepts canonical bindings and rejects aliases and duplicates",
+ "rejects aliases, duplicate fields and null claims",
+ "wrong object",
+ "additional properties true",
+ "additional properties absent",
+ "required",
+ "enum",
+ "unknown schema keyword",
+ "malformed required",
+ "resource",
+ "admin",
+ "status uiOnly",
+ "status kind",
+ "action uiOnly",
+ "action kind",
+ "shadow",
"accepts before a slow backup, detaches the request and polls through either frontend"
],
"report": ".cache/addons/logs/artifacts.json",
@@ -2087,70 +3440,26 @@
{
"path": ".cache/addons/logs/artifacts-gateway.json",
"cases": [
- "serves only declared installed generation assets and rejects traversal",
- "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
- "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
- "propagates interrupted progress cancellation through the receiving frontend",
- "validates finite relay budgets and prevents disabling polling bounds",
- "builds a carrier only from complete pinned identities",
- "types an unknown mutation outcome as unavailable without losing its operation",
- "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
- "alternates pinned frontends and rejects untrusted certificates without local fallback",
- "reports the original operation after lost mutation replies without retrying",
- "rejects a stream longer than its receiving frontend budget",
"rejects a CA signed peer without a pin on the owner side",
"refuses an owner whose CA signed certificate is not the pinned owner",
- "returns the artifact status through the owner from every frontend with one denial for the rest",
- "types the owner's refusal and outage of a lookup the same from every frontend",
- "writes an attachment header that cannot be broken out of",
- "rotates the enforced audience on restart and rejects captured requests",
- "never expires the response deadline after the relay returned",
+ "rejects unknown lifecycle argument fields before touching the owner journal",
+ "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "reports an account store outage as unavailable and a refused account as forbidden",
+ "alternates real owner journal status and terminates coarse progress without submitting again",
"returns the owner's forbidden status for an invalid kind and for missing policy",
"still reports unavailable when the owner is unable to serve and not forbidden",
"reaches the same decision through both frontends and delivers bytes when allowed",
"keeps aborting when revocation happens after bytes were written",
- "bounds progress, asset and file relays by their own limit",
- "alternates real owner journal status and terminates coarse progress without submitting again",
- "reports an account store outage as unavailable and a refused account as forbidden",
- "rejects unknown lifecycle argument fields before touching the owner journal",
- "reports a runtime that cannot serve as unavailable through both frontends",
- "reports an output that ends before its first byte as unavailable",
- "fails closed as unavailable for an unclassified owner error",
- "keeps authorization and policy denials forbidden",
- "keeps a mutation unknown when the runtime fails after the owner applied it",
- "keeps a mutation unknown when the owner reports recovery required for its operation",
- "reports a mutation as unknown when the owner cannot serve it at all",
- "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone",
- "lets unavailability win when an error wraps both a denial and an unavailable cause",
- "does not leak attachment headers into an error when the first read fails",
- "treats every action except the known reads as a mutation",
- "keeps resolved resources under admission for JSON and attachment bytes",
- "closes interrupted output and releases admission on disconnect",
- "rechecks revoked credentials during attachment copying",
- "adopts read outputs as private artifacts inside admission and replaces a forged resource identity",
+ "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome",
"refuses at ingest a file result larger than the relay file bound so nothing unservable is staged",
- "denies private relays without host resolution and admission",
- "serves declared assets of the current enabled generation by package digest only",
- "allows only an exact declared tool name and fails closed otherwise",
- "never contacts the add-on for an undeclared tool and still serves a declared one",
- "denies a repeated key at any depth however it is spelled",
- "denies arguments that are not exactly one object",
- "separates a denial from an unavailable declaration source",
- "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key",
- "is the typed invalid request on the owner for a read, never an outage",
- "is the typed invalid request on the owner for a proposal call",
- "is the typed invalid request on the owner for a json relay",
- "is the same typed answer from a forwarding frontend",
- "crosses the owner's wire as the same typed error, whatever the frontend",
- "does not trust a code the host does not know, and cleans the detail the add-on wrote",
- "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
- "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
- "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
- "keeps the answer of a call that outlasts the write timeout of the owner server",
- "is an unknown outcome when the answer is lost",
- "runs the backend-releases check for a pinned frontend without an actor",
- "refuses a system call from a peer that is not pinned",
- "never lets a system call change anything or ride the user-facing route",
+ "projects private policy safely",
+ "forwards authenticated owner decisions",
+ "validates strict request payloads",
+ "checks CAS and administrator authority",
+ "validates current selection and authenticated actor",
+ "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs",
+ "rotates the enforced audience on restart and rejects captured requests",
+ "passes through owner-resolved bindings with distinct provider and status names",
"writes the set with private modes and no CA key",
"loads both roles into carriers and serves a frontend call from the owner",
"gives the owner no signing key and the frontend its own",
@@ -2161,6 +3470,104 @@
"refuses an unknown role",
"refuses to overwrite a file and leaves it unchanged",
"needs an owner host",
+ "builds a carrier only from complete pinned identities",
+ "types an unknown mutation outcome as unavailable without losing its operation",
+ "rejects untrusted peers wrong audience body expiry replay and revoked credentials",
+ "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
+ "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
+ "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
+ "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
+ "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
+ "preserves bounded owner admission refusals over mTLS without frontend grants",
+ "accepts canonical bindings and rejects aliases and duplicates",
+ "keeps resolved resources under admission for JSON and attachment bytes",
+ "closes interrupted output and releases admission on disconnect",
+ "rechecks revoked credentials during attachment copying",
+ "denies private relays without host resolution and admission",
+ "carries the chat binding signed and rejects a changed one",
+ "signs a request without a binding as before",
+ "gives the owner the chat the frontend bound the call to",
+ "bounds progress, asset and file relays by their own limit",
+ "returns the artifact status through the owner from every frontend with one denial for the rest",
+ "types the owner's refusal and outage of a lookup the same from every frontend",
+ "writes an attachment header that cannot be broken out of",
+ "rejects aliases, duplicate fields and null claims",
+ "runs the backend-releases check for a pinned frontend without an actor",
+ "refuses a system call from a peer that is not pinned",
+ "never lets a system call change anything or ride the user-facing route",
+ "wrong object",
+ "additional properties true",
+ "additional properties absent",
+ "required",
+ "enum",
+ "unknown schema keyword",
+ "malformed required",
+ "resource",
+ "admin",
+ "status uiOnly",
+ "status kind",
+ "action uiOnly",
+ "action kind",
+ "shadow",
+ "reads the exact uiOnly status tool locally and over authenticated mTLS",
+ "refuses forged identities tools current selections and incarnations without dispatch",
+ "rejects stale carrier replies and frontend authority fields without dispatch",
+ "refuses forged observations through the real carrier and ignores ordinary dispatch attempts",
+ "does not relay a captured old binding to a same-generation replacement",
+ "settles parent cancellation and owner permission loss as unknown",
+ "does not grant owner selection authority through wire capability support",
+ "rejects duplicate noncanonical and nested authority keys in observation DTOs",
+ "withdraws a positive late reply when exact-client admission becomes stale",
+ "serves only declared installed generation assets and rejects traversal",
+ "accepts before a slow backup, detaches the request and polls through either frontend",
+ "reads typed status and preserves named tool and host operation IDs through a supervised process",
+ "rejects a captured supervised client after same generation replacement before carrier dispatch",
+ "rejects a late supervised carrier observation after replacement before owner consumption",
+ "resolves only the authenticated claim over actual mTLS",
+ "fails closed on unsupported owners and mismatched binding replies",
+ "runs a resource-less read tool for the calling user only",
+ "denies a tool that declares a resource when none is given, and never reaches the add-on",
+ "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
+ "never expires the response deadline after the relay returned",
+ "propagates interrupted progress cancellation through the receiving frontend",
+ "reports a runtime that cannot serve as unavailable through both frontends",
+ "reports an output that ends before its first byte as unavailable",
+ "fails closed as unavailable for an unclassified owner error",
+ "keeps authorization and policy denials forbidden",
+ "keeps a mutation unknown when the runtime fails after the owner applied it",
+ "keeps a mutation unknown when the owner reports recovery required for its operation",
+ "reports a mutation as unknown when the owner cannot serve it at all",
+ "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone",
+ "lets unavailability win when an error wraps both a denial and an unavailable cause",
+ "does not leak attachment headers into an error when the first read fails",
+ "keeps its address stable across starts",
+ "does not share state between different roots",
+ "fails clearly when the port is in use",
+ "rejects an invalid port file",
+ "rejects a port file with a loose mode",
+ "rejects a port file that links outside the root",
+ "allows only an exact declared tool name and fails closed otherwise",
+ "never contacts the add-on for an undeclared tool and still serves a declared one",
+ "denies a repeated key at any depth however it is spelled",
+ "denies arguments that are not exactly one object",
+ "separates a denial from an unavailable declaration source",
+ "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key",
+ "crosses the owner's wire as the same typed error, whatever the frontend",
+ "does not trust a code the host does not know, and cleans the detail the add-on wrote",
+ "is a stale view for a caller that only knows ErrStale, and a denial otherwise",
+ "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage",
+ "alternates pinned frontends and rejects untrusted certificates without local fallback",
+ "reports the original operation after lost mutation replies without retrying",
+ "rejects a stream longer than its receiving frontend budget",
+ "treats every action except the known reads as a mutation",
+ "is the typed invalid request on the owner for a read, never an outage",
+ "is the typed invalid request on the owner for a proposal call",
+ "is the typed invalid request on the owner for a json relay",
+ "is the same typed answer from a forwarding frontend",
+ "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it",
+ "keeps the answer of a call that outlasts the write timeout of the owner server",
+ "is an unknown outcome when the answer is lost",
+ "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden",
"says nothing was sent when the add-on is not running, instead of an unknown outcome",
"runs a declared action tool for the calling user, under the act scope, admitted as an add-on call",
"is not reachable through the typed dispatch of the direct API",
@@ -2168,28 +3575,8 @@
"refuses an add-on that did not declare actions",
"is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user",
"reports an unknown outcome, not a refusal, when the runtime fails after the call began",
- "runs a resource-less read tool for the calling user only",
- "denies a tool that declares a resource when none is given, and never reaches the add-on",
- "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared",
- "keeps its address stable across starts",
- "does not share state between different roots",
- "fails clearly when the port is in use",
- "rejects an invalid port file",
- "rejects a port file with a loose mode",
- "rejects a port file that links outside the root",
- "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend",
- "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend",
- "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend",
- "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner",
- "keeps any other failure to resolve a resource a plain denial, so nothing more is told",
- "carries the chat binding signed and rejects a changed one",
- "signs a request without a binding as before",
- "gives the owner the chat the frontend bound the call to",
- "projects private policy safely",
- "forwards authenticated owner decisions",
- "validates strict request payloads",
- "checks CAS and administrator authority",
- "accepts before a slow backup, detaches the request and polls through either frontend"
+ "validates finite relay budgets and prevents disabling polling bounds",
+ "serves declared assets of the current enabled generation by package digest only"
]
},
{
@@ -2362,10 +3749,17 @@
],
"cases": [
"lists installed enabled addons for users and adds installed disabled ones only for administrators",
+ "carries the install time to the list and the management view, and none for an old record",
"derives display fields, the main page and the icon URL from the verified manifest only",
+ "describes the immutable UI assets of an enabled addon by the package digest and never otherwise",
"bounds the tool summary and coerces unknown process states to failed",
"shows controls and the management link only to administrators and never leaks owner details",
- "describes the immutable UI assets of an enabled addon by the package digest and never otherwise",
+ "lists the host controls an add-on is entitled to, whatever its ID",
+ "projects two non-Studio addon pages without identity dispatch",
+ "projects home commands with resolved declared pages display metadata immutable icons and generation",
+ "keeps disabled home apps installed without UI and never invents a destination",
+ "projects typed model actions without sharing manifest or view slices",
+ "keeps unavailable installed records and omits unavailable or absent model metadata",
"denies enable and disable for a stale generation without recording anything",
"lists the identifiers of installed addons, disabled ones included",
"refuses to list installed identifiers while recovery is required",
@@ -2409,6 +3803,39 @@
"shows the pending update in the apps list from the cache only",
"does not accept pins without an update checker or on another action",
"keeps the unpinned typed upgrade of an earlier release working",
+ "carries the SDK refusal text to the caller of a failed install and keeps the generic error",
+ "installs an add-on whose SDK range contains the host SDK",
+ "copies them at install, upgrade and owner start, and removes them at uninstall",
+ "never fails the operation when the copy fails",
+ "fails the install and says the package could not be verified, without installing anything",
+ "names a release that the library does not list",
+ "names two libraries that list the same add-on",
+ "names a library that cannot be read",
+ "reads the library on demand when the startup refresh has not filled the cache",
+ "reads the library again when the startup refresh failed",
+ "writes the owner and storage only, with no address",
+ "loads a file written in the old format with an address",
+ "loads when only the address changed",
+ "refuses a changed owner ID",
+ "refuses a changed storage ID",
+ "copies every capability field of the manifest, so a new field cannot be forgotten",
+ "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build",
+ "refuses the upgrade when the tag moved again after the check, and installs nothing",
+ "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall",
+ "needs a state root for the owner only",
+ "upgrades an installed add-on through the same path as a manual upgrade",
+ "does nothing when no newer version is listed",
+ "refuses a candidate whose package no longer matches its listed digest and replaces nothing",
+ "refuses a candidate that fails the signature policy of its library and replaces nothing",
+ "does not upgrade to a release the host cannot run",
+ "does nothing without an update checker",
+ "keeps the manual upgrade closed to a caller who is not an administrator",
+ "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum",
+ "distinguishes healthy empty connector discovery from loss of runtime ownership",
+ "persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback",
+ "does not retain the caller's map or prefix slices",
+ "keeps an explicitly empty startup policy immutable",
+ "publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms",
"registers only ready generation bound service headless and UI capabilities",
"rejects forged registration nonloopback and stale incarnation handshakes",
"bounds readiness and handles exit during start and stop",
@@ -2441,6 +3868,38 @@
"an HTTP 422 answer",
"keeps a bounded, printable reason and nothing else of the body",
"leaves 503, 500 and 404 as outages",
+ "types a withdrawn client, a full slot table, a bad envelope and a missing schema as not sent",
+ "keeps a not-sent error an ErrRuntime for callers that do not tell them apart",
+ "types a refused connection as not sent",
+ "never types a possibly-sent request as not sent",
+ "types the add-on's own 'forbidden' answer as a denial of the person, not as an outage",
+ "keeps a 403 about the envelope itself an outage: it is a host fault, not a permission",
+ "reaches an add-on that declares actions, as the actor of the envelope, under the act scope",
+ "never sends a call that is not an act call or has another scope",
+ "never sends an act call to an add-on that did not declare actions",
+ "stops an act call when the caller gives up",
+ "posts the request with the incarnation credential and decodes the answer",
+ "is not sent to an add-on that declared no connector",
+ "gives it time to release what it owns before it kills the process",
+ "kills an add-on that ignores the closing of its startup channel, within the grace period",
+ "goes to the log line by line with the add-on ID and a cap per line",
+ "records the chat with the envelope of a read and of an action while it runs",
+ "sends the add-on an envelope without a new field",
+ "requires exact typed capability equality on authenticated registration health",
+ "keeps all typed declarations on automatic crash restart with a fresh incarnation",
+ "replays typed and legacy receipts across process restarts",
+ "refuses missing wire tool discriminators before any read",
+ "serializes exact identity admission with withdrawal and never dispatches the replacement",
+ "cannot observe a restarted same-generation process through the captured client",
+ "rejects output handles before they can reach storage or output-open",
+ "settles callback denial as failure even if the addon returns a valid positive payload",
+ "cleans up after cancellation and rejects repeat correlation",
+ "keeps an ordinary concurrent read authorized while an observation is pending",
+ "error",
+ "malformed/artifact payload",
+ "success",
+ "timeout",
+ "cancels an admitted observation when the runtime withdraws",
"rejects anonymous and forged forwarding authority through real authentication",
"lets callers through only when the deployment selected local-owner mode",
"keeps authentication, the feature gate and administration when accounts exist",
@@ -2510,52 +3969,7 @@
"reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable",
"keeps the update actions out of the tools and names every field of the status in the Swagger spec",
"answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend",
- "types a withdrawn client, a full slot table, a bad envelope and a missing schema as not sent",
- "keeps a not-sent error an ErrRuntime for callers that do not tell them apart",
- "types a refused connection as not sent",
- "never types a possibly-sent request as not sent",
- "carries the SDK refusal text to the caller of a failed install and keeps the generic error",
- "installs an add-on whose SDK range contains the host SDK",
"returns the same text through a forwarding frontend",
- "carries the install time to the list and the management view, and none for an old record",
- "lists the host controls an add-on is entitled to, whatever its ID",
- "copies them at install, upgrade and owner start, and removes them at uninstall",
- "never fails the operation when the copy fails",
- "fails the install and says the package could not be verified, without installing anything",
- "names a release that the library does not list",
- "names two libraries that list the same add-on",
- "names a library that cannot be read",
- "reads the library on demand when the startup refresh has not filled the cache",
- "reads the library again when the startup refresh failed",
- "writes the owner and storage only, with no address",
- "loads a file written in the old format with an address",
- "loads when only the address changed",
- "refuses a changed owner ID",
- "refuses a changed storage ID",
- "copies every capability field of the manifest, so a new field cannot be forgotten",
- "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build",
- "refuses the upgrade when the tag moved again after the check, and installs nothing",
- "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall",
- "needs a state root for the owner only",
- "upgrades an installed add-on through the same path as a manual upgrade",
- "does nothing when no newer version is listed",
- "refuses a candidate whose package no longer matches its listed digest and replaces nothing",
- "refuses a candidate that fails the signature policy of its library and replaces nothing",
- "does not upgrade to a release the host cannot run",
- "does nothing without an update checker",
- "keeps the manual upgrade closed to a caller who is not an administrator",
- "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum",
- "types the add-on's own 'forbidden' answer as a denial of the person, not as an outage",
- "keeps a 403 about the envelope itself an outage: it is a host fault, not a permission",
- "reaches an add-on that declares actions, as the actor of the envelope, under the act scope",
- "never sends a call that is not an act call or has another scope",
- "never sends an act call to an add-on that did not declare actions",
- "stops an act call when the caller gives up",
- "posts the request with the incarnation credential and decodes the answer",
- "is not sent to an add-on that declared no connector",
- "gives it time to release what it owns before it kills the process",
- "kills an add-on that ignores the closing of its startup channel, within the grace period",
- "goes to the log line by line with the add-on ID and a cap per line",
"Sec-Fetch-Site cross-site is refused even with a Referer on the same host",
"Sec-Fetch-Site same-origin passes",
"Sec-Fetch-Site same-site is refused",
@@ -2598,19 +4012,7 @@
"returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator",
"logs the cause of a failed install and types it for the client",
"logs the reason behind a generic denial and leaves the response unchanged",
- "names every typed cause of a failed install in the 403 of the dispatch route",
- "records the chat with the envelope of a read and of an action while it runs",
- "sends the add-on an envelope without a new field",
- "distinguishes healthy empty connector discovery from loss of runtime ownership",
- "installs upgrades and uninstalls the actual Sandbox document skills through verified receipts",
- "finishes a real backup and commit after the submitting request is cancelled",
- "polls and retries during a blocked backup, rejects conflicting IDs, and retains the receipt on restart",
- "reconciles durable accepted work to interrupted without replay",
- "Close cancels and joins an accepted owner worker before releasing storage",
- "traces completion, retries, detach, interruption and late delivery without replay",
- "keeps terminals immutable for every event and gives each active state a terminal exit",
- "Close cancels a backup in flight and joins its cleanup before releasing ownership",
- "serializes synchronous disable against an accepted upgrade without overwriting its journal"
+ "names every typed cause of a failed install in the 403 of the dispatch route"
],
"report": ".cache/addons/logs/apps.json",
"reports": [
@@ -2618,12 +4020,17 @@
"path": ".cache/addons/logs/apps.json",
"cases": [
"lists installed enabled addons for users and adds installed disabled ones only for administrators",
+ "carries the install time to the list and the management view, and none for an old record",
"derives display fields, the main page and the icon URL from the verified manifest only",
+ "describes the immutable UI assets of an enabled addon by the package digest and never otherwise",
"bounds the tool summary and coerces unknown process states to failed",
"shows controls and the management link only to administrators and never leaks owner details",
- "describes the immutable UI assets of an enabled addon by the package digest and never otherwise",
- "carries the install time to the list and the management view, and none for an old record",
- "lists the host controls an add-on is entitled to, whatever its ID"
+ "lists the host controls an add-on is entitled to, whatever its ID",
+ "projects two non-Studio addon pages without identity dispatch",
+ "projects home commands with resolved declared pages display metadata immutable icons and generation",
+ "keeps disabled home apps installed without UI and never invents a destination",
+ "projects typed model actions without sharing manifest or view slices",
+ "keeps unavailable installed records and omits unavailable or absent model metadata"
]
},
{
@@ -2709,7 +4116,11 @@
"traces completion, retries, detach, interruption and late delivery without replay",
"keeps terminals immutable for every event and gives each active state a terminal exit",
"Close cancels a backup in flight and joins its cleanup before releasing ownership",
- "serializes synchronous disable against an accepted upgrade without overwriting its journal"
+ "serializes synchronous disable against an accepted upgrade without overwriting its journal",
+ "persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback",
+ "does not retain the caller's map or prefix slices",
+ "keeps an explicitly empty startup policy immutable",
+ "publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms"
]
},
{
@@ -2763,7 +4174,22 @@
"kills an add-on that ignores the closing of its startup channel, within the grace period",
"goes to the log line by line with the add-on ID and a cap per line",
"records the chat with the envelope of a read and of an action while it runs",
- "sends the add-on an envelope without a new field"
+ "sends the add-on an envelope without a new field",
+ "requires exact typed capability equality on authenticated registration health",
+ "keeps all typed declarations on automatic crash restart with a fresh incarnation",
+ "replays typed and legacy receipts across process restarts",
+ "refuses missing wire tool discriminators before any read",
+ "serializes exact identity admission with withdrawal and never dispatches the replacement",
+ "cannot observe a restarted same-generation process through the captured client",
+ "rejects output handles before they can reach storage or output-open",
+ "settles callback denial as failure even if the addon returns a valid positive payload",
+ "cleans up after cancellation and rejects repeat correlation",
+ "keeps an ordinary concurrent read authorized while an observation is pending",
+ "error",
+ "malformed/artifact payload",
+ "success",
+ "timeout",
+ "cancels an admitted observation when the runtime withdraws"
]
},
{
@@ -3356,6 +4782,27 @@
"leaves a database whose step fails exactly as it was and resumes on the next run",
"refuses data in a newer format, a downgrade and a version it has no path to, and writes nothing",
"leaves no pending line for a change the add-on refused, so the same ID applies the corrected request",
+ "records an inverse for an insert and removes exactly those rows on undo",
+ "restores the previous values of an update, for the columns it set only",
+ "brings deleted rows back with their row IDs and every value, nulls included",
+ "is idempotent by operation ID: the same ID returns the same receipt and undoes once",
+ "refuses the same operation ID for another change",
+ "refuses a stale undo with what changed and overwrites nothing",
+ "refuses an undo whose expected revision moved between the read and the apply",
+ "unwinds in order: undoing the latest change makes the earlier one undoable again",
+ "keeps no change set that is too large and says so at apply time",
+ "expires a change set after the retention and drops what it held",
+ "keeps the total of change sets of one database bounded by dropping the oldest",
+ "answers not found for a change that never existed or belongs to a read",
+ "removes a created table again and refuses once it was touched",
+ "renames a table or a column back",
+ "removes an added column again, with its unique index",
+ "restores a dropped column with its data, its type, required and unique flags",
+ "restores a dropped table with its columns, rows and row IDs",
+ "says a dropped column or table was too large to keep, at apply time",
+ "renames the database back only while it still has the new name",
+ "undoes a creation only while the database is untouched, and marks that the resource is deleted",
+ "keeps the change sets in the database file, so deleting the database removes them",
"starts the real executable through the authenticated handshake and refuses unauthenticated callers",
"creates a database under the host-assigned ID and lists it only for people who may read it",
"drops the reservation when the addon refuses the name, and leaves no database",
@@ -3379,27 +4826,6 @@
"stores every change as a pending proposal and applies it only on the proposer's own session, exactly once",
"keeps the roles of the host for tool calls and rejects forged fields before the owner sees them",
"lists at most the bound of pending proposals and refuses more than the user's quota",
- "records an inverse for an insert and removes exactly those rows on undo",
- "restores the previous values of an update, for the columns it set only",
- "brings deleted rows back with their row IDs and every value, nulls included",
- "is idempotent by operation ID: the same ID returns the same receipt and undoes once",
- "refuses the same operation ID for another change",
- "refuses a stale undo with what changed and overwrites nothing",
- "refuses an undo whose expected revision moved between the read and the apply",
- "unwinds in order: undoing the latest change makes the earlier one undoable again",
- "keeps no change set that is too large and says so at apply time",
- "expires a change set after the retention and drops what it held",
- "keeps the total of change sets of one database bounded by dropping the oldest",
- "answers not found for a change that never existed or belongs to a read",
- "removes a created table again and refuses once it was touched",
- "renames a table or a column back",
- "removes an added column again, with its unique index",
- "restores a dropped column with its data, its type, required and unique flags",
- "restores a dropped table with its columns, rows and row IDs",
- "says a dropped column or table was too large to keep, at apply time",
- "renames the database back only while it still has the new name",
- "undoes a creation only while the database is untouched, and marks that the resource is deleted",
- "keeps the change sets in the database file, so deleting the database removes them",
"tells the model and the chat that a data change can be undone, and undoes it from the person's own tab",
"never lets a key, a tool call, another session or a stranger undo, so a model cannot undo its own work",
"refuses a stale undo with what changed, and unwinds from the newest change to the oldest",
@@ -3636,6 +5062,27 @@
"leaves a database whose step fails exactly as it was and resumes on the next run",
"refuses data in a newer format, a downgrade and a version it has no path to, and writes nothing",
"leaves no pending line for a change the add-on refused, so the same ID applies the corrected request",
+ "records an inverse for an insert and removes exactly those rows on undo",
+ "restores the previous values of an update, for the columns it set only",
+ "brings deleted rows back with their row IDs and every value, nulls included",
+ "is idempotent by operation ID: the same ID returns the same receipt and undoes once",
+ "refuses the same operation ID for another change",
+ "refuses a stale undo with what changed and overwrites nothing",
+ "refuses an undo whose expected revision moved between the read and the apply",
+ "unwinds in order: undoing the latest change makes the earlier one undoable again",
+ "keeps no change set that is too large and says so at apply time",
+ "expires a change set after the retention and drops what it held",
+ "keeps the total of change sets of one database bounded by dropping the oldest",
+ "answers not found for a change that never existed or belongs to a read",
+ "removes a created table again and refuses once it was touched",
+ "renames a table or a column back",
+ "removes an added column again, with its unique index",
+ "restores a dropped column with its data, its type, required and unique flags",
+ "restores a dropped table with its columns, rows and row IDs",
+ "says a dropped column or table was too large to keep, at apply time",
+ "renames the database back only while it still has the new name",
+ "undoes a creation only while the database is untouched, and marks that the resource is deleted",
+ "keeps the change sets in the database file, so deleting the database removes them",
"starts the real executable through the authenticated handshake and refuses unauthenticated callers",
"creates a database under the host-assigned ID and lists it only for people who may read it",
"drops the reservation when the addon refuses the name, and leaves no database",
@@ -3659,27 +5106,6 @@
"stores every change as a pending proposal and applies it only on the proposer's own session, exactly once",
"keeps the roles of the host for tool calls and rejects forged fields before the owner sees them",
"lists at most the bound of pending proposals and refuses more than the user's quota",
- "records an inverse for an insert and removes exactly those rows on undo",
- "restores the previous values of an update, for the columns it set only",
- "brings deleted rows back with their row IDs and every value, nulls included",
- "is idempotent by operation ID: the same ID returns the same receipt and undoes once",
- "refuses the same operation ID for another change",
- "refuses a stale undo with what changed and overwrites nothing",
- "refuses an undo whose expected revision moved between the read and the apply",
- "unwinds in order: undoing the latest change makes the earlier one undoable again",
- "keeps no change set that is too large and says so at apply time",
- "expires a change set after the retention and drops what it held",
- "keeps the total of change sets of one database bounded by dropping the oldest",
- "answers not found for a change that never existed or belongs to a read",
- "removes a created table again and refuses once it was touched",
- "renames a table or a column back",
- "removes an added column again, with its unique index",
- "restores a dropped column with its data, its type, required and unique flags",
- "restores a dropped table with its columns, rows and row IDs",
- "says a dropped column or table was too large to keep, at apply time",
- "renames the database back only while it still has the new name",
- "undoes a creation only while the database is untouched, and marks that the resource is deleted",
- "keeps the change sets in the database file, so deleting the database removes them",
"tells the model and the chat that a data change can be undone, and undoes it from the person's own tab",
"never lets a key, a tool call, another session or a stranger undo, so a model cannot undo its own work",
"refuses a stale undo with what changed, and unwinds from the newest change to the oldest",
@@ -3916,6 +5342,27 @@
"leaves a database whose step fails exactly as it was and resumes on the next run",
"refuses data in a newer format, a downgrade and a version it has no path to, and writes nothing",
"leaves no pending line for a change the add-on refused, so the same ID applies the corrected request",
+ "records an inverse for an insert and removes exactly those rows on undo",
+ "restores the previous values of an update, for the columns it set only",
+ "brings deleted rows back with their row IDs and every value, nulls included",
+ "is idempotent by operation ID: the same ID returns the same receipt and undoes once",
+ "refuses the same operation ID for another change",
+ "refuses a stale undo with what changed and overwrites nothing",
+ "refuses an undo whose expected revision moved between the read and the apply",
+ "unwinds in order: undoing the latest change makes the earlier one undoable again",
+ "keeps no change set that is too large and says so at apply time",
+ "expires a change set after the retention and drops what it held",
+ "keeps the total of change sets of one database bounded by dropping the oldest",
+ "answers not found for a change that never existed or belongs to a read",
+ "removes a created table again and refuses once it was touched",
+ "renames a table or a column back",
+ "removes an added column again, with its unique index",
+ "restores a dropped column with its data, its type, required and unique flags",
+ "restores a dropped table with its columns, rows and row IDs",
+ "says a dropped column or table was too large to keep, at apply time",
+ "renames the database back only while it still has the new name",
+ "undoes a creation only while the database is untouched, and marks that the resource is deleted",
+ "keeps the change sets in the database file, so deleting the database removes them",
"starts the real executable through the authenticated handshake and refuses unauthenticated callers",
"creates a database under the host-assigned ID and lists it only for people who may read it",
"drops the reservation when the addon refuses the name, and leaves no database",
@@ -3939,27 +5386,6 @@
"stores every change as a pending proposal and applies it only on the proposer's own session, exactly once",
"keeps the roles of the host for tool calls and rejects forged fields before the owner sees them",
"lists at most the bound of pending proposals and refuses more than the user's quota",
- "records an inverse for an insert and removes exactly those rows on undo",
- "restores the previous values of an update, for the columns it set only",
- "brings deleted rows back with their row IDs and every value, nulls included",
- "is idempotent by operation ID: the same ID returns the same receipt and undoes once",
- "refuses the same operation ID for another change",
- "refuses a stale undo with what changed and overwrites nothing",
- "refuses an undo whose expected revision moved between the read and the apply",
- "unwinds in order: undoing the latest change makes the earlier one undoable again",
- "keeps no change set that is too large and says so at apply time",
- "expires a change set after the retention and drops what it held",
- "keeps the total of change sets of one database bounded by dropping the oldest",
- "answers not found for a change that never existed or belongs to a read",
- "removes a created table again and refuses once it was touched",
- "renames a table or a column back",
- "removes an added column again, with its unique index",
- "restores a dropped column with its data, its type, required and unique flags",
- "restores a dropped table with its columns, rows and row IDs",
- "says a dropped column or table was too large to keep, at apply time",
- "renames the database back only while it still has the new name",
- "undoes a creation only while the database is untouched, and marks that the resource is deleted",
- "keeps the change sets in the database file, so deleting the database removes them",
"tells the model and the chat that a data change can be undone, and undoes it from the person's own tab",
"never lets a key, a tool call, another session or a stranger undo, so a model cannot undo its own work",
"refuses a stale undo with what changed, and unwinds from the newest change to the oldest",
@@ -4080,7 +5506,11 @@
"go: does not let a listing-capable provider invent a named-user principal",
"go: revalidates listing failures through an already-bound consumer without namespace fallback",
"go: preserves the model read byte bound",
- "go: offers the host read even when Tables has no executable definitions"
+ "go: offers the host read even when Tables has no executable definitions",
+ "go: keeps declared uiOnly execution status tools out of generic adapters",
+ "go: round trips valid components and enforces the callable length boundary",
+ "go: rejects malformed qualified identities while reserving their namespace",
+ "go: forwards bindings only through an explicit binding backend"
]
},
"tables-admin": {
@@ -4745,5 +6175,154 @@
"shows the conflict message the store gives next to the source that clashes",
"turning it off hides the default galleries and shows the defaults as off, and on brings them back"
]
+ },
+ "host-admission": {
+ "kind": "ginkgo",
+ "commands": [
+ [
+ "go",
+ "test",
+ "-count=1",
+ "-p",
+ "2",
+ "-tags",
+ "auth",
+ "./core/services/addons/hostops",
+ "-ginkgo.json-report=../../../../.cache/addons/logs/host-admission.json"
+ ]
+ ],
+ "cases": [
+ "materializes / as the folder and adds the chat folder",
+ "refuses a mount outside the folder",
+ "refuses export and import outside the folder",
+ "works inside the folder with the agent's mode",
+ "imports into the folder of a writable agent, for the chat",
+ "lists no change outside the folder",
+ "reports a commit outside the view as a conflict",
+ "binds a read call that has no resource ID",
+ "stays user-wide for a call with no binding",
+ "keeps an unscoped chat on the whole home",
+ "fails closed when the chat of the call cannot be resolved",
+ "resolves an action without a binding by its chat",
+ "refuses what is malformed",
+ "refuses an unregistered operation under an allowed prefix",
+ "refuses everything for a broker without a feature check",
+ "needs no feature check for the local owner",
+ "does not let another add-on use the operation of a live request",
+ "drops the envelope when the request ends",
+ "stores the content and returns a ref, not a path",
+ "is idempotent and fine-tuning only",
+ "refuses an empty or oversized content",
+ "refuses a name with a directory",
+ "refuses an extension that is not a text dataset",
+ "refuses content that is not UTF-8",
+ "resolves only a reference the host issued to that user",
+ "bounds the issued references to 256",
+ "refuses a wrong credential",
+ "refuses a call without a live envelope",
+ "refuses an expired authority",
+ "does not let a read scope run an apply operation",
+ "checks the feature of the actor",
+ "runs the operation as the actor of the envelope",
+ "refuses an operation outside the allowed prefix",
+ "refuses an oversized body",
+ "runs an idempotent operation once per operation ID",
+ "materializes the folders of the user of the live request",
+ "refuses every operation when the host maps the actor to no user",
+ "never reaches the files of another user",
+ "refuses a directory outside the data directory of the add-on",
+ "builds the tree in the folder it opened when a folder above it is swapped for a link",
+ "commits the upper layer as the user of the request",
+ "commits for the chat of the request and names the paths it did not save in the add-on's terms",
+ "does not commit an upper layer that is a link or not a regular file",
+ "lets a read envelope materialize but not commit",
+ "takes the user from the request, never from the arguments",
+ "tells an administrator apart from anybody else",
+ "lists the changes after a revision",
+ "exports a file of the user and never one of another user",
+ "imports a file as the user of the request and refuses a link",
+ "names the change of an imported file after the write, not as an upload by the person",
+ "keeps both files when the file changed after the add-on read it",
+ "commits once for a repeated operation",
+ "gives an add-on outside the allowlist no workspace operation",
+ "export registers a model and returns only its name",
+ "export uses the highest step checkpoint",
+ "export of a job with no checkpoint is a conflict naming it",
+ "export refuses a bad name",
+ "export refuses an export format that is not listed",
+ "export of another user's job is not found",
+ "export runs once per operation ID",
+ "export refuses a returned path that is not a model name",
+ "import registers the quantized model and returns only its name",
+ "import of a job that is not completed is a conflict",
+ "model operations are apply, idempotent and admin only",
+ "export is refused for a feature-holding non-admin",
+ "export is allowed for an admin",
+ "import is refused for a feature-holding non-admin",
+ "import is allowed for an admin",
+ "lets the admin check decide in every mode",
+ "matches the supported vocabulary exactly to every real builtin registration",
+ "detects missing extra duplicate and case-changed operations with the parity oracle",
+ "uses registered exact names and literal nonempty grant prefixes without self grants",
+ "permits missing optional requests and bounds diagnostics to declaration names",
+ "keeps the binding with the envelope and gives it to the operation",
+ "refuses an operation ID that two requests with different bindings share",
+ "carries a binding in a context and ignores an incomplete one",
+ "runs with the incarnation credential alone, with no live request and no actor",
+ "refuses an absent or foreign credential",
+ "takes the add-on ID from the broker, not from the request",
+ "refuses an argument that names a user, an actor or an owner",
+ "refuses an op outside the prefix the host setting gives",
+ "does not turn a normal operation into an inbound one",
+ "refuses to register an inbound operation that names a feature",
+ "maps a refusal of the handler to 403",
+ "takes the chat from the envelope's resource and the user from its actor",
+ "refuses arguments that name a chat, a user or the policy, and unknown ones",
+ "refuses a call with no live operation, a lower scope, no chat or no chats feature",
+ "takes at most 500 audit lines and only done or block verdicts",
+ "start passes the same user key as the route",
+ "start refuses a local path as model or dataset",
+ "start refuses a backend that is not a fine-tuning backend",
+ "start names the missing fine-tuning engine, the default one included",
+ "start refuses a field that is not in the tool schema",
+ "get of another user's job is not found",
+ "list returns only the caller's jobs",
+ "a job result never carries a filesystem path",
+ "a second user's list does not show the first user's jobs",
+ "start refuses a local path as model",
+ "start refuses a backend that is not a quantization backend",
+ "start names the missing quantization engine, the default one included",
+ "list returns only the caller's jobs without a path",
+ "registers no operation for nil services",
+ "finetune.checkpoints needs its feature",
+ "finetune.get needs its feature",
+ "finetune.list needs its feature",
+ "finetune.start needs its feature",
+ "quantize.get needs its feature",
+ "quantize.list needs its feature",
+ "quantize.start needs its feature",
+ "must be a gallery or Hugging Face ID",
+ "refusal is a 400 naming the field",
+ "accepts Hugging Face and gallery IDs",
+ "accepts a dataset ref as dataset source",
+ "returns the next event within the wait",
+ "returns the snapshot when the wait ends with no event",
+ "caps the wait",
+ "reports done for a final status",
+ "does not find the progress of another user's job",
+ "stops once per operation",
+ "keeps the checkpoint only when asked",
+ "does not find the stop of another user's job",
+ "ends the wait when the context is cancelled",
+ "denies every correlated class before handlers and retains late denial",
+ "cannot overwrite an observation with a forged identity or duplicate operation",
+ "rejects mismatched authenticated incarnation and forged IDs without touching handlers",
+ "removes retired state on endpoint close",
+ "fails closed at the retained correlation bound without restricting ordinary operations",
+ "keeps authenticated pending inbound callbacks denied while another endpoint works",
+ "refuses an authorized decoded callback paused across endpoint Stop",
+ "stops without waiting for a handler that ignores cancellation"
+ ],
+ "report": ".cache/addons/logs/host-admission.json"
}
}
diff --git a/scripts/addons/verify.sh b/scripts/addons/verify.sh
index 5400055dd..696dfec29 100644
--- a/scripts/addons/verify.sh
+++ b/scripts/addons/verify.sh
@@ -60,22 +60,8 @@ def failure_summary(command, output, status):
if tail: lines.append('last output: ' + ' | '.join(l[:200] for l in tail))
lines.append('child status %d; failed: %s; log: %s' % (status, ', '.join(names[:10]) or 'unknown', log))
return '\n'.join(lines)
-def ginkgo_report(path, expected):
- try:
- reports = json.loads(path.read_text())
- assert isinstance(reports, list) and reports
- cases = []
- for report in reports:
- assert report['SuiteSucceeded'] is True
- specs = report['SpecReports']
- assert specs
- for spec in specs:
- assert spec['State'] == 'passed'
- if spec['LeafNodeType'] == 'It': cases.append(spec['LeafNodeText'])
- assert cases and sorted(cases) == sorted(expected)
- except (OSError, ValueError, KeyError, TypeError, AssertionError):
- sys.exit('Ginkgo report missing, empty, skipped, failed or inventory mismatch: '+str(path))
- return cases
+sys.path.insert(0, str(root / 'scripts/addons'))
+from ginkgo_report import ginkgo_report
seen = []
if entry['kind'] == 'ginkgo':
@@ -93,7 +79,7 @@ for command in entry['commands']:
seen.append(item['case'])
if entry['kind'] == 'ginkgo':
seen = []
- for report in reports: seen.extend(ginkgo_report(root/report['path'], report['cases']))
+ for report in reports: seen.extend(ginkgo_report(root/report['path'], report['cases'], report.get('focus')))
if sorted(seen) != sorted(entry['cases']):
sys.exit('required case inventory mismatch: '+repr(seen))
# Run real readiness only after the shell test inventory has passed.
diff --git a/tests/addons/fixture/cmd/main.go b/tests/addons/fixture/cmd/main.go
index e9af15c33..ba9386de1 100644
--- a/tests/addons/fixture/cmd/main.go
+++ b/tests/addons/fixture/cmd/main.go
@@ -35,7 +35,8 @@ func main() {
// per committed operation, and ".applies" with one line per
// apply request that reached the process, duplicates included. Empty
// keeps the receipts in memory only.
- Effects string `json:"effects"`
+ Effects string `json:"effects"`
+ ObservationLog string `json:"observationLog"`
}
b, _ := os.ReadFile("fixture.json")
_ = json.Unmarshal(b, &config)
@@ -155,6 +156,21 @@ func main() {
if !read(&v) || !valid(v, r.URL.Path[1:]) {
return
}
+ if config.Mode == "execution-status" && r.URL.Path == "/read" {
+ // Record the authenticated envelope in a private test artifact, not
+ // in the status payload (whose schema is deliberately closed).
+ b, _ := json.Marshal(struct {
+ Incarnation string `json:"incarnation"`
+ Envelope contract.Envelope `json:"envelope"`
+ }{startup.Registration.Incarnation, v})
+ noteLine(config.ObservationLog, string(b))
+ if string(v.Request.Arguments) != `{"tool":"health"}` && string(v.Request.Arguments) != `{"tool":"inspect"}` {
+ http.Error(w, "wrong status tool", 400)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(contract.Result{Payload: json.RawMessage(`{"readiness":"available","isolation":"isolated"}`)})
+ return
+ }
if v.Request.Action == "delay" {
select {
case <-r.Context().Done():