import { DEFAULT_HUMAN_SURFACE, SYSTEM_INTERNAL_SURFACE } from './languageOperatingModel' export const HOLOLAKE_UI_PLUGIN_SCHEMA = 'hololake.ui-plugin/v1' as const export type UiPluginCapability = | 'goal.submit' | 'confirmation.answer' | 'evidence.expand' export type UiPluginNodeKind = | 'shell' | 'stack' | 'row' | 'text' | 'button' | typeof DEFAULT_HUMAN_SURFACE[number] | 'evidence_toggle' export type UiPluginNode = { kind: UiPluginNodeKind id?: string text?: string approveText?: string declineText?: string action?: UiPluginCapability children?: UiPluginNode[] } export type HoloLakeUiPlugin = { manifest: { schema: typeof HOLOLAKE_UI_PLUGIN_SCHEMA id: string version: string target: 'language_shell' requestedCapabilities: UiPluginCapability[] } tokens: Record layout: UiPluginNode } export type UiPluginValidation = { ok: boolean; errors: string[] } const ALLOWED_ACTIONS = new Set([ 'goal.submit', 'confirmation.answer', 'evidence.expand', ]) const SAFE_TOKEN_NAME = /^--hl-world-[a-z0-9-]+$/ const UNSAFE_TOKEN_VALUE = /(?:javascript:|expression\s*\(|@import|<\/?script)/i function walk(node: UiPluginNode, visit: (node: UiPluginNode) => void): void { visit(node) node.children?.forEach((child) => walk(child, visit)) } export function validateUiPlugin(plugin: HoloLakeUiPlugin): UiPluginValidation { const errors: string[] = [] const seen = new Set() if (plugin.manifest.schema !== HOLOLAKE_UI_PLUGIN_SCHEMA) errors.push('SCHEMA_NOT_SUPPORTED') if (!/^[a-z0-9][a-z0-9.-]+$/.test(plugin.manifest.id)) errors.push('PLUGIN_ID_INVALID') if (!/^\d+\.\d+\.\d+$/.test(plugin.manifest.version)) errors.push('PLUGIN_VERSION_INVALID') walk(plugin.layout, (node) => { seen.add(node.kind) if ((SYSTEM_INTERNAL_SURFACE as readonly string[]).includes(node.kind)) { errors.push(`SYSTEM_INTERNAL_SURFACE_FORBIDDEN:${node.kind}`) } const actionAllowed = !node.action || ALLOWED_ACTIONS.has(node.action) if (node.action && !actionAllowed) { errors.push(`ACTION_NOT_ALLOWED:${node.action}`) } if (node.action && actionAllowed && !plugin.manifest.requestedCapabilities.includes(node.action)) { errors.push(`ACTION_NOT_DECLARED:${node.action}`) } if (node.kind === 'language_input' && !node.text?.trim()) errors.push('COPY_REQUIRED:language_input') if (node.kind === 'reality_boundary_confirmation' && (!node.approveText?.trim() || !node.declineText?.trim())) { errors.push('COPY_REQUIRED:reality_boundary_confirmation') } if (node.kind === 'evidence_toggle' && !node.text?.trim()) errors.push('COPY_REQUIRED:evidence_toggle') }) for (const surface of DEFAULT_HUMAN_SURFACE) { if (!seen.has(surface)) errors.push(`REQUIRED_SURFACE_MISSING:${surface}`) } for (const capability of plugin.manifest.requestedCapabilities) { if (!ALLOWED_ACTIONS.has(capability)) errors.push(`CAPABILITY_NOT_ALLOWED:${capability}`) } for (const [name, value] of Object.entries(plugin.tokens)) { if (!SAFE_TOKEN_NAME.test(name)) errors.push(`TOKEN_NAME_NOT_ALLOWED:${name}`) if (UNSAFE_TOKEN_VALUE.test(value)) errors.push(`TOKEN_VALUE_UNSAFE:${name}`) } return { ok: errors.length === 0, errors } } export type UiPluginRuntime = { current(): HoloLakeUiPlugin activate(candidate: HoloLakeUiPlugin): UiPluginValidation rollback(): boolean subscribe(listener: (plugin: HoloLakeUiPlugin) => void): () => void } export function createUiPluginRuntime(initial: HoloLakeUiPlugin): UiPluginRuntime { const initialValidation = validateUiPlugin(initial) if (!initialValidation.ok) throw new Error(initialValidation.errors.join(',')) let active = initial let previous: HoloLakeUiPlugin | null = null const listeners = new Set<(plugin: HoloLakeUiPlugin) => void>() const publish = () => listeners.forEach((listener) => listener(active)) return { current: () => active, activate(candidate) { const result = validateUiPlugin(candidate) if (!result.ok) return result previous = active active = candidate publish() return result }, rollback() { if (!previous) return false const replacement = previous previous = active active = replacement publish() return true }, subscribe(listener) { listeners.add(listener) return () => listeners.delete(listener) }, } } export function applyUiPluginTokens( plugin: HoloLakeUiPlugin, target: Pick, previousTokenNames: readonly string[] = [], ): string[] { previousTokenNames.forEach((name) => target.removeProperty(name)) Object.entries(plugin.tokens).forEach(([name, value]) => target.setProperty(name, value)) return Object.keys(plugin.tokens) }